本文准备把常见的算法题中,不同的题型进行分类整理,相信将相同类型的思路放在一起能够起到事半功倍的效果。本分类很大程度参考了灵神 的题单和题解,感谢灵神!
遍历+哈希表
两数之和 梦开始的地方,利用哈希表将2次遍历简化为1次。我们采用枚举右,寻找左 的方法,将当前遍历到的j作为两数中的一个,去哈希表中寻找左边的 另一个数target - j,如果能找到就是一个解;然后将自身添加到哈希表中,等待后续右边的 数来找自己。
1 2 3 4 5 6 7 8 9 10 11 12 13 class Solution { public int [] twoSum(int [] nums, int target) { Map<Integer, Integer> idx = new HashMap <>(); for (int j = 0 ; ; j++) { int x = nums[j]; if (idx.containsKey(target - x)) { return new int []{idx.get(target - x), j}; } idx.put(x, j); } } }
前缀和
303. 区域和检索 - 数组不可变
核心是“任意子数组的和,都可以表示为两个前缀和的差”,前缀和数组得开辟n+1长度,并且定义pres[i]为[0,i-1]范围内的元素和,并令pres[0]=0,想象如下两个数组:
1 2 [_, 0, 1, 2, 3, ...] : 原数组nums [0, 1, 2, 3, 4, ...] : 前缀和pres
从而任意闭区间子数组的和:nums[i] + ... + nums[j] = pres[j + 1] - pres[i]
560. 和为 K 的子数组 见前缀和
437. 路径总和 III 见前缀和
滑动窗口 定长滑动窗口
1456. 定长子串中元音的最大数目
可以背一下模板,如果窗口大小为k,总长度为n,则:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 n = nums.length; // 数组长度 j = 0; // 窗口的右边界 for 0 <= j < n { 1. 右端点进入窗口,例如累加值,算最值等等 i = j - k + 1; if (i < 0) { continue; // 窗口大小还没有到k,不进行统计 } 2. 操作ans,此时是一个合法的窗口 3. 左端点离开窗口 }
变长滑动窗口 模板还是类似定长滑动窗口的,但是左端点就不是直接从右端点算得了,而是要不断右移,直到满足/不满足条件。
3. 无重复字符的最长子串
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Solution { public int lengthOfLongestSubstring (String s) { Map<Character, Integer> mp = new HashMap <>(); int i = 0 , ans = 0 ; char [] sc = s.toCharArray(); for (int j = 0 ; j < sc.length; j++) { mp.put(sc[j], mp.getOrDefault(sc[j], 0 ) + 1 ); while (mp.get(sc[j]) > 1 ) { mp.put(sc[i], mp.get(sc[i]) - 1 ); i++; } ans = Math.max(ans, j - i + 1 ); } return ans; } }
713. 乘积小于 K 的子数组
越短越合法,因此找到个最长的合法子数组,并计算比它短的子数组的个数。注意此处枚举右端点,所以每次计算时要固定右端点,否则会重复计算。
链表 链表的操作就那么几个,很简单但熟练度要高。
反转链表
这个题要注意,链表长度为n的话,反转的循环体就执行n次,但直接看链表的话好像反转n-1次就够了,这个链表只有两个“->”:0 -> 1 -> 2;但我们得把null算进去,相当于本来在尾部的null变到头部去了,即:0 -> 1 -> 2变成null <- 0 <- 1 <- 2,所以就是要反转n=3次。
还要注意循环不变量,即pre和cur,最开始是pre为null和cur为head,最后cur为null和pre为head,所以最后直接返回pre即可。这在后面问题的下标处理 上比较重要。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public ListNode reverseList (ListNode head) { ListNode pre = null , cur = head; int cnt = 0 ; while (cur != null ) { cnt++; ListNode nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } System.out.printf("%s\n" , cnt); return pre; }
92. 反转链表 II
要先找到目标链表的头结点的前一个node,那么如果目标链表就是第一个,无法统一编码,因此引入冗余头结点; 从冗余头结点开始,操作n次.next,就到达第n个节点,因此操作left - 1次到达第left个节点的前一个; 链表的长度是闭区间[left, right],长度为right - left + 1,所以循环体要循环这么多次;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public ListNode reverseBetween (ListNode head, int left, int right) { ListNode dummyHead = new ListNode (-1 , head); ListNode p0 = dummyHead; for (int i = 0 ; i < left - 1 ; i++) { p0 = p0.next; } ListNode pre = null , cur = p0.next; for (int i = 0 ; i < right - left + 1 ; i++) { ListNode nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } p0.next.next = cur; p0.next = pre; return dummyHead.next; }
25. K 个一组翻转链表
如果能在短时间内熟练手敲出这个题,链表的所有题基本问题不大了,最怕面试时调试半天。
同样是需要设置冗余头节点dummyHead;注意p0的含义,是要进行反转操作的闭区间[left,right]的前一个节点,反转是和上面的题类似的,但是反转结束后要将p0设置为下一个要反转的起点,即:
p0 -> 2 -> 3 -> 4反转2和3后是p0 -> 2 <- 3 -> 4,此时的pre=3, cur=4,要将p0.next.next指向cur=4,p0.next指向pre=3,而下一个p0是旧的p0.next=2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public ListNode reverseKGroup (ListNode head, int k) { ListNode dummyHead = new ListNode (-1 , head); int n = 0 ; while (head != null ) { n++; head = head.next; } ListNode p0 = dummyHead; for (int i = 0 ; i < n / k; i++) { ListNode pre = null , cur = p0.next; for (int j = 0 ; j < k; j++) { ListNode nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } p0.next.next = cur; ListNode p0Next = p0.next; p0.next = pre; p0 = p0Next; } return dummyHead.next; }
142. 环形链表 II
这个题直接背下来还是挺方便的,无非是快慢指针一起走,如果会相遇则存在环。此时将头结点和慢指针同步走,他们相遇的节点就是环的入口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class Solution { public ListNode detectCycle (ListNode head) { if (head == null ) { return null ; } ListNode fast = head, slow = head; while (fast.next != null && fast.next.next != null ) { fast = fast.next.next; slow = slow.next; if (fast == slow) { while (head != slow) { head = head.next; slow = slow.next; } return head; } } return null ; } }
147. 对链表进行插入排序
由于每次curNode可能会被插入到链表之中,更新时不能使用curNode = curNode.next,而是应该维护一个lastSorted作为已排序链表的末尾,使用curNode = lastSorted.next进行更新; 每次遇到curNode时,根据它是否比lastSorted大,来决定是否要进行插入操作;这样的话,每次执行插入操作时,必然是在lastSorted之前的位置,就不会乱掉了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public ListNode insertionSortList (ListNode head) { ListNode dummyHead = new ListNode (-1 , head); ListNode curNode = head.next, lastSorted = head; while (curNode != null ) { if (lastSorted.val <= curNode.val) { lastSorted = lastSorted.next; } else { ListNode tarNode = dummyHead; while (tarNode.next.val <= curNode.val) { tarNode = tarNode.next; } lastSorted.next = curNode.next; curNode.next = tarNode.next; tarNode.next = curNode; } curNode = lastSorted.next; } return dummyHead.next; }
148. 排序链表
经典归并算法,这里用的是递归实现的分治法,需要额外的空间;也可以用自底向上的迭代法,这个比较复杂就先不考虑了。 注意事项是,从中间断开时,如果是偶数,要取左侧的中间结点,否则必然左边偏长;边界情况是只有1个节点或链表为空;有序链表的合并也是比较高频的题,要保证快速写出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 class Solution { public ListNode sortList (ListNode head) { if (head == null || head.next == null ) { return head; } ListNode midNode = this .middleNode(head); ListNode head2 = midNode.next; midNode.next = null ; head = this .sortList(head); head2 = this .sortList(head2); return this .mergeTwoLists(head, head2); } public ListNode middleNode (ListNode head) { ListNode slow = head, fast = head.next; while (fast != null && fast.next != null ) { slow = slow.next; fast = fast.next.next; } return slow; } public ListNode mergeTwoLists (ListNode list1, ListNode list2) { ListNode dummyHead = new ListNode (-1 ); ListNode curNode = dummyHead; while (list1 != null && list2 != null ) { if (list1.val < list2.val) { curNode.next = list1; list1 = list1.next; } else { curNode.next = list2; list2 = list2.next; } curNode = curNode.next; } curNode.next = list1 == null ? list2 : list1; return dummyHead.next; } }
23. 合并 K 个升序链表
这个题可以用最小堆来完成,写法比较简单,注意所用语言是如何定义最小堆的;代码就不贴了
1206. 设计跳表
跳表其实挺简单,就是在只有一个链表的基础上,添加多层的索引。
二分查找 你可能觉得二分查找非常简单,它也确实很简单,但我之前理解的二分查找是要判断mid是否等于target的,这种方式用来查找第一个>=target的值 就不好使了,所以有必要对下标和边界 进行理解。为了巩固基础,可刷灵神的题单 。
34. 在排序数组中查找元素的第一个和最后一个位置
二分查找最好使用开区间 ,左右端点都是不可取的,即针对长度为n的数组进行二分的话,初始化就是i = -1, j = n;当然闭区间 或前闭后开区间 也可以,分别是i = 0, j = n - 1和i = 0, j = n; 循环的条件应该是这个区间存在元素,例如开区间应当是j - i > 1,闭区间是i <= j,前闭后开区间是i < j。
如果是要找第一个>=target的元素,我们就可以考虑nums[]中有一排target,当nums[mid]是这一排target中的一个时,我们应该将右端点j左移到mid,而不是将左端点i右移到mid,因为我们找的是第一个>=target的元素,右移左端点i可能会丢失第一个>=target的元素; 同样,我们更不能判断nums[mid]==target时就跳出循环,而是将=target的情况并入到>target或<target中,那么并入到哪里呢?这个和循环终止时最终答案的位置有关系。由于要找第一个>=target的元素,必须让j右边是>=target的,i左边是<target的(此处都包含端点i和j,因为是开区间),这样第一个>=target的就是nums[j]了。同样的,=target的情况就必须并入>target了。
下面的代码就是用于搜索nums中第一个>=target的元素下标,如果没有这个元素,则返回数组长度。
1 2 3 4 5 6 7 8 9 10 11 12 private int searchFirstLargerEqual2 (int [] nums, int target) { int i = -1 , j = nums.length; while (j - i > 1 ) { int mid = i + (j - i) / 2 ; if (nums[mid] < target) { i = mid; } else { j = mid; } } return j; }
153. 寻找旋转排序数组中的最小值
始终和最后一个元素比较大小,搜索第一个>=nums[n - 1]的元素,搜索范围是闭区间[0, n - 2];无需特判最小元素是nums[n - 1]的情况,因为这种情况下闭区间[0, n - 2]找不到它,自然会返回n-1。
单调栈 单调栈就是用来找左/右边第一个更大/小的元素的,所以可能有4种题意;并且每种题意都可以从左往右/从右往左遍历数组,所以甚至有8种做法。而且我个人暂时无法对单调栈理解得非常到位,时常不知道如何判断该出栈还是入栈。
739. 每日温度
如果要找遍历方向上的下一个更大元素,则让大元素nums[i]把栈顶的小元素弹出去,直到栈顶比nums[i]更大,此时: 那些被弹出去的小元素,它们在遍历方向 上的下一个更大元素都是nums[i];nums[i]在反遍历方向 上的下一个更大元素是栈顶。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public int [] dailyTemperatures(int [] temperatures) { int [] ans = new int [temperatures.length]; Deque<Integer> st = new ArrayDeque <>(); for (int i = temperatures.length - 1 ; i >= 0 ; i--) { while (!st.isEmpty() && temperatures[st.peek()] <= temperatures[i]) { st.pop(); } if (st.isEmpty()) { ans[i] = 0 ; } else { ans[i] = st.peek() - i; } st.push(i); } return ans; }
注意等号的取法,假设是寻找更大/更小的元素,则如下:
当正向遍历时,使用大元素弹出小元素,如果相等则不要弹出,因为此时会去计算弹出元素的正向更大元素(为当前元素),必须是更大的将它弹出;
当反向遍历时,使用大元素弹出小元素,如果相等也要弹出,因为此时会计算当前元素的反向更大元素(为栈顶),因此栈顶不能和当前元素相等;
当然也可能是大于等于 或小于等于 的元素,此时要将上面是否带等号的情况 反转,见题1475. 商品折扣后的最终价格
496. 下一个更大元素 I
和每日温度类似的,这里是求元素而非下标相关的东西。
503. 下一个更大元素 II
遍历两次,即for (i = 0; i < 2 * n; i++) {},并且当i作为下标时都使用i % n。
单调队列
239. 滑动窗口最大值
树形DP
543. 二叉树的直径 此题算的是路径的长度,和节点的数字和无关。
124. 二叉树中的最大路径和 此题算的是节点上的数字和
动态规划 动态规划的东西有点太多了,我会新开一个博客进行整理。