1.两数之和
-
哈希 ,每次循环将 元素值 和对应 下标 放入 map 中,每次更新 map 之前先判断一下,如果 map 中已经包含 target - nums[i] 的 key ,则找到答案,返回 当前下标 和之前的 key 对应的 下标 。
167. 两数之和 II - 输入有序数组
-
1. 对撞指针 , L 从 0 开始, R 从 N - 1 开始,搜索 nums[L] + nums[R] 的和,如果 等于 target ,就返回 L + 1 和 R + 1 , 否则如果 小于 target 就让 L++ ,如果 大于 target 就让 R-- 。
-
2. 二分查找 ,既然数组是有序的,那么在一个有序数组中查找固定目标值,自然想到的就是二分查找,对于 [0, N - 1] 区间的每个元素 nums[i] , 到剩余区间 [i + 1, N - 1] 中 二分查找 元素为 target - nums[i] 的下标 index ,如果找到就返回 i + 1 和 index + 1 。
170. 两数之和 III - 数据结构设计
-
1. 数组 + 排序 + 对撞指针 ,参考167. 创建一个 足够大的数组 来存储数字,设置一个标志位 isSorted 表示是否排序,每次调用 find() 先判断有没有排序,如果未排序就先排序,将 isSorted 标记为 true , 然后进行 对撞指针 查找两数之和等于 value 的,每次调用 add() 时将 isSorted 标记为 false .
注意这里的对撞指针的L指针使用 nums.length - i 即可,因为 i 表示数组当前实际使用的大小,排序后已使用的部分一定是从 nums.length - i 开始到数组的末尾,形如 [-100001,-100001,-100001,.........,3, 7, 8, 12, 23, 94]
当然,上面代码你也可以选择在add方法中进行排序,效果是一样的,就看add和find方法哪个调用的频率高了。
解题思路:
-
2. 哈希 ,在 add() 方法中使用 Map 记录每个元素 出现的次数 ,在 find() 方法中,每次遍历 map 的所有 key , 判断 value - key 这个数是否存在于 map 中,如果存在,那么:
-
1)如果该数和 key 的值 不同 ,则一定存在解,
-
2)如果该数和 key 的值 相同 ,则该数在 map 中必须至少出现 2 次才有解。
解释一下上面代码中关键的部分,即当 map 中包含 num = value - key 时:
1)如果 num 和 key 不是同一个数,肯定有解,这很好理解,比如 value = 10, key = 3,那么 num = 10 - 3 = 7,只要 7 存在于 map 中,且 7 != 3 则一定有解
2)如果 num 和 key 相同,例如 value = 10, key = 5,那么 num = 10 - 5 = 5,也就是 value 此时是由两个相同的数字之和构成,因此,map 中必须至少有 2 个 5 才行
653. 两数之和 IV - 输入 BST
-
1. 中序遍历 + 对撞指针 ,先对二叉搜索树进行 中序遍历 ,可以得到一个 有序数组 ,再用 对撞指针 在有序数组中查找。
-
2. DFS + 哈希 ,对二叉搜索树进行 DFS遍历 ,每次访问一个节点就将该节点值加入到HashSet 中,但是在这之前先判断一下 target - 当前节点值 是否已经包含在 HashSet 集合中,如果已经存在,则存在解,返回 true 。否则将 递归调用左右子树 的返回结果作为当前递归函数的返回值,左右子树有一个返回 true 即可。
-
递归终止:空树返回 false 表示不存在解。
15. 三数之和
-
排序 + 对撞指针 ,第一步先 排序 ,然后外层循环中 i 枚举 [0, N - 3] ,固定数字 nums[i] ,在内层循环中使用 对撞指针 求解, L 从 i + 1 开始, R 从 N - 1 开始,找 nums[i] + nums[L] + nums[R] == 0 的。
-
如果三数之和等于 0,就将三个数收集到答案结果集中,然后 L++,R-- ,此时 L 和 R 两头都要通过 while循环去重 ;
-
如果三数之和 小于 0 ,让 L++ ;
-
如果三数之和 大于 0 ,让 R-- ;
-
优化:外层循环中首先判断一下如果 当前元素大于 0 , 直接跳出 ,不用进行内层循环了,因为是排序后的,后面的肯定都 大于 0 ,不可能得到三数之和 等于 0 的。
-
注意:进入内层循环的对撞指针之前,外层循环的 nums[i] 和 nums[i - 1] 需要去重判断,去重判断的方法:比较当前的跟前一个的值,如果相等则跳过。
解释一下上面代码中的3处去重的作用:
- “跳过重复元素 ①” :例如 [-3, -3, -3, -3, -3, 1, 2],如果选择了第一个 -3 和 1, 2 组成和为 0 的三元组 [-3, 1, 2],那么后面的连续相同的 -3 就需要跳过,否则就会得到若干个重复的三元组[-3, 1, 2],这跟题目要求不符
- “去重②” 和 “去重③” 处的代码,其实等价于下面代码的写法:
while (L < R && nums[L] == nums[L + 1]) L++;
L++;
while (L < R && nums[R] == nums[R - 1]) R--;
R--;
- 比如 [-2, -1, -1, -1, 3, 3, 3],当 i = 0, L = 1, R = 6,即将[-2, -1, 3]收集答案后,L 和 R 就需要排除相邻重复的 -1 和 3,否则就会得到若干个重复的三元组[-2, -1, 3],这跟题目要求不符
注意:本题在LeetCode上最新版本的提示条件改了 3 <= nums.length <= 3000,否则还需要添加一个特判 N < 3 时,return res。
18. 四数之和
-
同15. 三数之和,也是 先排序 ,只不过 外面多套一层for循环 ,第一层 i 从 [0, N - 4] 遍历,第二层 j 从 [i + 1, N - 3] 遍历,最内层用 对撞指针 L 从 j + 1 开始, R 从 N - 1 开始,搜索 nums[i] + nums[j] + nums[L] + nums[R] == target 的。
-
注意点:第一层的 i 和第二层的 j 都需要 去重 操作, 等于target 时收集完答案之后,移动 L 和 R 时也需要 去重 判断。
16. 最接近的三数之和
-
类似15.三数之和,这题求的不是三个元素的集合,而是三个数的和,当 sum == target 时,直接返回 sum 即可,否则就更新最接近 target 的答案,并移动 L 和 R 指针。
-
初始时设一个变量 closest 表示最接近 target 的和,开始时设置为一个比数组中任意三数之和还小的值,需要更新 closest 时,看如果 abs(sum - target) < abs(closest - target) ,就将 closest 更新为 sum 。
31. 下一个排列
-
先从 右往左 找 第一个 出现的【 降序点 】(当前的小于其右边的),找到后记住这个位置 i ,然后再从 右往左 找 第一个 比之前找到的 【 降序点 】大 的位置,记为 j , 然后 交换 i 和 j 位置的值,最后 反转 i 之后的所有元素 ,即得到下一个全排列。
-
如果第一步中没有找到 【 降序点 】 ,也就是 i == -1 ,那么说明从右往左都是升序的,也就是 从左往右 是一个 降序序列 ,如54321,则 下一个排列 应该返回 字典序最小的排列 。这通过反转整个数组即可得到。
-
反转 操作通过 对撞指针 可以实现,从两头往中间逼近,不断的交换两头指针的元素。
下图是上面算法的执行动画过程:
第一步:1 5 8 4 7 6 5 3 1
第二步:1 5 8 4 7 6 5 3 1
第三步:1 5 8 5 7 6 4 3 1
第四步:1 5 8 5 1 3 4 6 7
189. 轮转数组
-
方法一: 数组反转 ,首先 反转全部 数组 nums[0, N-1] ,然后只反转数组的 前 k 个 nums[0, k - 1] ,最后 反转剩余部分nums[k, N - 1] 。
-
反转操作通过 对撞指针 实现,从两头往中间不停的交换两个指针的值。
-
注意, k 需要先取余 k = k % n ,以防止下标越界
上图中的两种反转顺序都可以实现题目所求结果。
-
方法二: 环状替换 ,
-
1) start 记录 外层 循环中的下标, 初始时 start = 0 ,
-
2) cur 记录内层循环中 当前坑位下标 ,初始时 cur = start ,
-
3) prev 记录 上一个坑位 被怼出来的值,初始时 prev = nums[start] 。
-
4) 内层 循环中,每次用 cur 计算出 下一个坑位 坐标 next = ( cur + k )%N ,先用 temp 记住 next 坑位值 ,再将 上一个坑位 被怼出来的 prev 值填入 next 坑位 ,然后更新 prev = temp , 然后 cur 移动到 next 坑位坐标。
-
5) 内层循环的判断条件是 cur != start ,因为环状替换最终一定会回到起始点,每当回到起始点,内层循环结束,外层循环 start + 1 。
-
6) 外层循环的轮数有两种选择:一种是在内层循环中计数 count ,在外层循环判断 count < N ,即有 N 个坑位最多循环 N 次,另一种是直接计算出外层循环的轮数为 N 和 k 的最大公约数 , 最大公约数公式 gcd(a, b) = b == 0 ? a : gcd(b, a%b) 。
使用最大公约数计算外层循环的轮数的版本:
下图是上面算法的执行步骤示例:
-
方法三:使用 额外数组 ,空间复杂度O(n),代码略。
总结本题三种方法中还是方法一最简单方便。
941.有效的山脉数组
-
线性扫描 :从 左往右 扫描,按照 严格递增 条件( arr[L] < arr[L+1] )寻找 山峰 位置,如果找到 且位置不是 0 或 N-1 , 再从该位置 按照 严格递减 的条件 ( arr[L] > arr[L+1] ) , 继续往右扫描。扫描结束后,如果是有效山脉数组,则下标只可能会走到 N-1 位置 。
-
对撞指针 : L 从 0 开始从 左往右 按照 严格递增 条件 寻找左侧山峰 , R 从 N - 1 开始从右往左 按照 严格 递增 的条件 寻找 右侧山峰。最终判断如果 L==R,即 左侧找到的山峰和右侧找到的山峰是同一个,且 L 和 R 不 是两端 0 和 N-1 ,则数组是有效山脉 数组 。
163.缺失的区间
-
1. 直接遍历,记住前一个数 ,定义 prev 表示遍历的 前一个数 ,初始时 prev = lower - 1 ,遍历数组的每一个数,如果 当前数 num != prev + 1 ,说明 num 和 prev 中间缺失了数字,此时判断 num 和 prev + 2 的关系来收集结果:1) 如果 num == prev + 2 , 则说明 prev 和 num 中间缺失了一个数 , 即 prev + 1 ;2) 如果 num > prev + 2 , 则说明 prev 和 num 中间缺失了不止一个数,缺失的是一个区间即 [prev + 1, num - 1] 。判断完后将 prev 更新为当前的 num ,继续下一次循环 。循环结束 跳出循环时, prev 是数组中最后一个数,如果 prev != upper, 说明 prev 和 upper 之间还缺失了数字,此时判断 prev 和 upper - 1 之间的关系来收集结果:1) 如果 prev == upper - 1 , 则缺失的就是 upper ;2) 如果 prev < upper - 1 , 则 缺失的就是从 prev + 1 到 upper 的闭区间 [prev + 1, upper] 。
-
2. 哨兵思想,预处理数组 ,构造长度 N + 2 的新数组,在数组的 前面 添加一个 lower - 1 ,在数组的 后面 添加一个 upper + 1 ,然后再遍历数组:1) 如果 nums[i] + 1 == nums[i+1] ,则不做处理;2) 如果 nums[i] + 2 == nums[i+1] ,则中间缺失的数是 nums[i] + 1;3) 如果 nums[i] + 2 < nums[i+1] ,则缺失的数是区间 【 nums[i] + 1,nums[i+1] - 1 】 ;
228. 汇总区间
解题思路:
- 循环模拟,遍历数组,先将当前位置 i 的元素拼接到结果StringBuilder中,然后让 j 从当前 i 位置开始寻找连续区间的结尾处,
- 例如 [0,1,2,4,5,7],我们从 0 开始一直找到 2 为止,它跟后面的 4 就不能够成连续区间了,找到的连续区间是 [0,1, 2],由于我们已经先拼接了 i 了,此时只需将 j 位置的元素拼接到StringBuilder中即可,
- 但是,假如 i 之后就是不连续的的区间,例如 [0,2,3,4],0 下一个就不连续,因此还需要判断 j != i 时,才将 j 位置的元素拼接到StringBuilder中。
- 然后将StringBuilder中的结果收集到答案集合res中,并让 i 从 j + 1开始继续循环处理下一个元素。
674.最长连续递增序列
这题其实是求连续递增子数组,子序列有歧义。
- 1.快慢指针,慢指针slow从0开始,快指针fast从1开始,不断向前移动快指针fast++,如果nums[fast - 1] < nums[fast] 即只要快指针比前面的大就满足递增,此时计算并更新最大长度 = max(res, fast - slow + 1),否则如果出现了非递增,就将slow移动到fast位置,继续下一次循环。
- slow的含义是指向递增区间的起始位置,fast的含义是指向递增区间的结尾位置,即 [slow...fast] 是连续递增区间,
- 不断移动fast的过程就是判断连续递增区间的结尾位置能不能再继续扩大一点,能扩大就更新区间长度作为答案,
- 如不能继续扩大了,说明当前递增区间到头了,slow转向下一个作为递增区间的起始位置即fast处,继续判断。
-
2. 贪心 , 本质 跟上面方法是一样的,只是思考的方式不一样,首先默认为整个数组都是递增的,起始点 start 从 0 开始,遍历数组,不断更新递增区间的最大长度= max(res, i - start + 1) ,但是如果从某个位置开始出现了逆序点(或相等,非递增),则递增区间的起始点 start 应该重新设置为降序点的位置,再计算区间的长度。
42. 接雨水
-
1. 按列求 + 辅助数组 ,根据木桶效应,每根柱子上面装水的多少,取决于这根柱子左右两边的最矮的柱子,我们只需要看左边最高的墙和右边最高的墙中较矮的一个就够了。
-
先 分别求出 每根柱子 的 左边最高的柱子 和 右边最高的柱子 ,并用 两个数组leftMax 和 rightMax 来分别存储,那么第 i 根柱子能接的水 = min(leftMax[i], rightMax[i]) - height[i] ,所以总的接水量res就是遍历数组 累加 每一根柱子能接的水即可。
-
注意:在求每根柱子左边/右边最高的柱子时,跟求 前缀和 数组的思想类似,比如 leftMax[i] = max(前一根柱子的左边的最高的柱子,前一根柱子的高度) = max(leftMax[i - 1], height[i - 1])
根据较矮的那个柱子和当前柱子的高度可以分为三种情况:
-
1)较矮的柱子的高度大于当前柱子的高度
为了方便理解,把正在求的列左边最高的柱子和右边最高的柱子确定后,我们把无关的柱子都去掉。
这样就很清楚了,现在想象一下,往两边最高的柱子之间注水。正在求的列会有多少水?
很明显,较矮的一边,也就是左边柱子的高度,减去当前柱子的高度就可以了,也就是 2-1=1,可以存一个单位的水。
- 2)较矮的柱子的高度小于当前柱子的墙的高度
同样的,我们把其他无关的列去掉。
想象下,往两边最高的柱子之间注水。正在求的柱子会有多少水?
很显然,正在求的柱子不会有水,因为它大于了两边较矮的柱子。
- 3)较矮的柱子的高度等于当前柱子的墙的高度。和上一种情况是一样的,不会有水。
明白了这三种情况,程序就很好写了,遍历每一根柱子,然后分别求出这一根柱子两边最高的柱子。找出较矮的一端,和当前柱子的高度比较,结果就是上边的三种情况。
上面代码还可以省一个数组,使用一个变量left来代替leftMax数组,先求出rightMax数组,然后在循环遍历计算更新答案的同时计算left的值。
-
2. 按列求 + 对撞指针 ,用两个变量代替两个数组, L 从左边开始搜索, R 从右边开始搜索,只需要知道 L 左边最高的柱子 和 R 右边最高的柱子 的关系就能确定当前柱子的积水。
-
用 leftMax 表示 L 左边最高的柱子, rightMax 表示 R 右边最高的柱子,循环不断更新这两个值,同时判断:
-
1)如果 leftMax < rightMax ,则对 L 柱子来说,积水就是 leftMax - height[L] ,累加到结果 res 中并移动 L++ ,
-
2) 如果 leftMax > rightMax ,则对 R 柱子来说,积水就是 rightMax - height[R], 累加到结果 res 中 并 移动 R--。
注意:一般的对撞指针while退出条件是 L < R,但这里是 L <= R,因为本题中每一根柱子都要计算积水,所以相等的时候也不能放过。
下面解释一下上面的代码:
其实对于 L 和 R 指针而言,按照方法1的逻辑,我们应该计算出来它们各自左右两边最高的柱子,对于 L 而言是 leftMaxL 和 rightMaxL,对于 R 而言是 leftMaxR 和 rightMaxR。
由于我们是从左往右求解的leftMax,因此可以确定 leftMaxL 是准确的,但是 rightMaxL不确定的,也就是说,leftMax 一定可以代表 L 指针左边最高的柱子,但是它不一定代表 L 指针右边最高的柱子。
同理,可以确定 rightMaxR 是准确的,但是 leftMaxR 是不确定的。
看一个例子来感受一下代码计算的 leftMax 和 rightMax 是什么样的值:
我们在代码中计算的 leftMax 其实就是计算的 L 左边的最高的柱子 leftMaxL ,rightMax 其实就是计算的 R 右边的最高的柱子 rightMaxR 。
虽然 rightMaxL 不确定,但是 rightMaxL 至少不会比 rightMaxR 小,即 rightMaxL ≥ rightMaxR ,因为 rightMaxL 的含义就是 L 指针右边最高的柱子,如果它比某一个数 rightMaxR 还小的话,那它就不是 L 右边最高的了。
如果 leftMaxL < rightMaxR ,那么就有 leftMaxL < rightMaxR ≤ rightMaxL,因此 min(leftMaxL, rightMaxL)=leftMaxL,参考下图理解:
同样的,如果 leftMaxL > rightMaxR,而 leftMaxR 一定满足 leftMaxR ≥ leftMaxL,于是就有 leftMaxR ≥ leftMaxL > rightMaxR,因此 min(leftMaxR, rightMaxR)=rightMaxR,参考下图理解:
因此,最终的结果就只跟左指针的 leftMaxL 和右指针的 rightMaxR 有关,也就是代码中计算的 leftMax 和 rightMax,根据这二者的大小关系就能推断出 L 和 R 各自左右两边最高的柱子中较矮的那一个柱子。
虽然方法2的代码形式上比方法1的更简洁,但是理解上不是很容易一下就能够理解的,需要绕一下子。
-
3. 单调递减栈 , 遇到 比栈顶高 的柱子,就 弹栈 处理求解该柱子,遇到 比栈顶矮的柱子下标入栈 。
-
求以下三根柱子包围形成的矩形的面积:
-
左边的柱子 :弹栈后新的栈顶记作 L ,
-
所求的柱子 :当前已弹出的栈顶记作 cur ,
-
右边的柱子 :当前遇到的第 i 根柱子记作 R ,
-
所求柱子上积水矩形的宽度: R - L - 1 ( 即 索引差值 - 1 ) ,
-
矩形的高度: min(height[L], height[R]) - height[cur] ,
-
累加每次弹栈计算的矩形面积即可。
-
注意:所求的其实是每次弹出的柱子的积水,当柱子与栈顶比较的时候需要循环比较,如果一直比栈顶高,就要一直循环弹栈并计算。
-
4. 韦恩图解法 ,从 左往右 遍历,不管是雨水还是柱子,都计算在有效面积内,面积记为 S1 ,具体 每次更新一个 maxLeft 逐步增大, S1 累加 maxLeft, 同样的方法从右往左遍历可得 S2 , 于是我们有如下发现:
-
S1 + S2 会覆盖整个矩形 ,并且 S 1 + S2 = 矩形面积 + 柱子面积 + 积水面积 ,因此 积水面积 = S1 + S2 - 矩形面积 - 柱子面积
从左往右遍历得 S1,每步 S1 += max1 且 max1 逐步增大
同样地,从右往左遍历可得 S2。
从右往左遍历得 S2,每步 S2+= max2 且 max2 逐步增大
于是我们有如下发现,S1+S2 会覆盖整个矩形,并且:重复面积=柱子面积+积水面积
最终,积水面积=S1+S2 - 矩形面积 - 柱子面积。
11. 盛最多水的容器
-
对撞指针 + 贪心 ,设 L 从 0 开始, R 从 N-1 开始,只要 L<R ,就不断计算当前面积 s = min(h[L], h[R]) * (R - L) ,然后判断如果 左边的柱子更短 ,就移动左指针 L++ ,否则就移动右指针 R-- ,在这个过程中记录最大的面积即为答案。
-
之所以 优先移动较短的柱子 ,这是基于以下几点的考虑:
-
1) 无论移动哪一个, 横轴 的距离会 减少
-
2) 横轴 距离 不变 的情况下, 面积 取决于 短的 那根
-
3) 当遇到一短一长时,当然是 优先移动 短的 柱子 可能会获得 增加面积 的机会,但是如果移动 长的 柱子,则获得新面积只会比现在的 更小
注意,这个题计算面积的宽度是下标索引值之差 R - L,因为本题每个柱子是宽度为0的一条线,因此两个柱子之间的宽度值就是下标之差
例如这里的 8 - 1 其实是表示 [1...8] 中间隔了 7 个间隔。
相比42题,42题的单调栈解法中宽度是 R - L - 1,这是因为它的柱子是宽度为 1,例如 [0, 4] 这个区间有 5 个柱子,但是计算 0 和 4之间的积水要扣除这两根柱子,R - L - 1 = 4 - 0 - 1 = 3,这表示的是 0 和 4 之间的柱子的个数。
这个题的难点在于计算面积之后,如何确定移动哪一个指针?
很显然,如果选择移动较长的柱子,由于矩形面积由 w * h 组成,这时横轴 w 缩小了,此时无论新的柱子的高度如何(小于 R、等于 R、大于 R),都不会得到更大的 h,因此这种情况下不会得到比原来更大的面积。
而如果选择移动较短的柱子,虽然同样横轴 w 缩小了,但是新的柱子与原来的 L 柱子之间的组合可能产生比原来更大的 h,因此这种情况下有可能会得到比原来更大的面积。这就是【贪心】的体现。
也许有人会想为什么不能同时移动 L 和 R 指针呢?
因为上面已经证明了只移动较短的柱子可能得到更优的解,如果两个同时移动,就包含了可能使结果变得更坏的解。如果不放心,我们只需要举出一个反例即可,例如下图所示的情况就证明了移动两个指针不如只移动一个较短的指针更好:
1480. 一维数组的动态和
-
前缀和,初始 prefixSum[0] = nums[0] ,然后从 1 开始遍历数组计算 prefixSum[i] = prefixSum[i - 1] + nums[i]
303. 区域和检索 - 数组不可变
解题思路:
- 前缀和,在构造函数中创建长度 N + 1 的前缀和数组,第 0 个空着不用,从第 1 个开始计算,prefixSum[i] = prefixSum[i - 1] + nums[i - 1],然后在sumRange方法中利用前缀和数组之差求解 prefixSum[right + 1] - prefixSum[left]。
注意: prefixSum[i]的含义是nums数组中前 i 个元素之和,题目sumRange方法所求的是包含left和right的区间和,所以是用 prefixSum[right + 1] - prefixSum[left],这里prefixSum[right + 1] = sum(nums[0]..nums[right]),而prefixSum[left] = sum(nums[0]...nums[left - 1]),因此二者之差就是 sum(nums[left]..nums[right])
307. 区域和检索 - 数组可修改
- 前缀和,同303,只不过在调用update()方法更新时,需要同时更新前缀和数组,具体的可以先计算出更新值和当前位置元素的差值 diff = val - nums[index],然后只需要更新从 index 位置往后的前缀和,将每个前缀和加上差值 diff 即可。
238. 除自身以外数组的乘积
-
前缀/后缀乘积 ,利用前缀乘积数组和后缀乘积数组, 先从 左往右 遍历一遍计算出 每个元素左侧的乘积 ,存放到 数组 left 中,再 从 右往左 遍历一遍计算出 每个元素右侧的乘 积 ,存放到 数组 right 中,最后遍历输出 answer[i] = left[i] * right[i] 就是题目所求答案。
-
每个元素的左侧的乘积的计算公式: left[i] = left[i - 1] * nums[i - 1]
-
每个元素的右侧的乘积的计算公式:right[i] = right[i + 1] * nums[i + 1]
-
注意点:在计算 left数组 时, 将 left[0] 初始化为 1 ,在计算 right 数组 时, 将 right[N - 1] 初始化为 1 。
剑指 Offer 66. 构建乘积数组
-
前缀/后缀乘积 , 同238. 除自身以外数组的乘积,利用 前缀乘积数组 和 后缀乘积数组
使用一个left变量节省left数组的版本:
560. 和为 K 的子数组
-
1. 前缀和 + 线性查找 ,先求出前缀和数组,然后 遍历前缀和数组 ,对于 [0, N] 的每一个 i ,遍历 [0, i) 区间查找,如果存在一个 j 它满足 prefixSum[i] - prefixSum[j] = k 就进行计数统计答案。
-
2. 前缀和 + 哈希查找 ,先求出前缀和数组,然后 遍历前缀和数组 ,用 map 存储 前缀和出现的次数 ,对于每个 prefixSum[i] ,如果 map 中存在一个 prefixSum[j] 满足: prefixSum[i] - prefixSum[j] = k 则累加 prefixSum[j] 出现的次数。(这种思路其实是将问题转化为 前缀和数组版本的“两数之和”问题 )
- 优化:可以使用一个变量代替前缀和数组, 在计算前缀和的过程中更新答案。
- 先将第一个前缀和0出现1次放入map中,再遍历原数组,用变量prefixSum累加每个数,并用prefixSum更新答案,最后在map中更新prefixSum出现的次数。
上面代码中如果 map 中初始化时不放入前缀和为 0 的出现 1 次,在计算答案时会遗漏,比如 nums = [1,2,3],k = 1,i = 0 时,prefixSum = 0 + 1 = 1,计算出的 prefixSumj = 1 - 1 = 0,如果此时 Map 中不存在 0 的次数,那么这时就不会统计答案。
384. 打乱数组
-
洗牌算法,在shuffle()函数中,遍历[0, N],在每次迭代中,生成一个范围在 [i, N]之间的随机整数下标 ,然后将 当前元素 和 随机选出的下标 所指的元素互相 交换 。
-
在构造函数中,保存一份原数组的克隆,在reset()函数中,就将数组重置为原数组的克隆,并返回原数组。
-
生成随机数的算法: min + random.nextInt(max - min)
169. 多数元素 & 摩尔投票算法
-
1. 对撞指针 + 快排分区优化 ,查找第 k 小元素 , k = N / 2 + 1 , L 从 0 开始, R 从 N - 1 开始,每次在 [L, R] 区间上执行 快排分区逻辑 得到当前分区点的下标 index ,然后判断:
-
1)如果 index == k - 1 ,则停止分区,找到答案为 nums[index] ,
-
2)如果 index < k - 1 ,就让 L = index + 1 , 到右边区间 [index + 1, R] 上找,
-
3)如果 index > k - 1 , 就让 R = index - 1 ,到左边区间 [L, index - 1] 上找。
注意:这种解法是建立在题设【给定的数组总是存在多数元素】的基础之上的,我们设置 k = N/2 + 1,这是因为多数元素的定义是重复次数大于 N/2,比如我们有 10 个元素的话,多数元素肯定至少为 6 个,所以我们只需要通过分区算法定位到第 6 小的元素,那么它一定是多数元素。
另外上面代码中有两个细节需要注意:
- 1)分区算法查找的是下标,所以与分区点index进行比较的是 k - 1 的值,而不是 k。
- 2)对撞指针的while循环退出条件是while(true),这样写也是建立在题设【给定的数组总是存在多数元素】的基础之上的,如果按照常规对撞指针的写法,这里也可以写成while(L <= R)
-
2. 摩尔投票法 ,初始化候选人为 -1 , 票数为 0 ,遍历数组:
-
① 如果当前票数为 0,重置候选人,让当前元素成为新的候选人 ,
-
② 如果遇到相同的候选人,则票数 + 1,
-
③ 如果遇到不同的候选人,则票数 - 1 ,
-
最后剩下的候选人一定是 大于 N / 2 的多数元素。
-
注意:题设一定存在多数元素,如果没有这个题设,拿到最后的候选人还需要 再遍历一遍 数组验证次数是否真的大于 N / 2 。
-
摩尔投票法相当于两拨人相互抵消,数量多的那一拨人肯定最终会剩下。
注意:这个代码中 count == 0 的 if 判断一定要放在统计票数的前面,否则结果不正确。
这个算法过程可以认为是预留了一个坑位/虚位,只有一个虚位,当虚位为空时,先坐上一个人,然后开始对其进行投票,票数减为 0 时,虚位又重新空出来。
更通俗的理解:
229. 多数元素 II
-
摩尔投票算法 同169
-
如果所求是超过数组长度的 N / 2 ,至多有 1 个候选人
-
如果所求是超过数组长度的 N / 3 ,至多有 2 个候选人
-
如果所求是超过数组长度的 N / 4 ,至多有 3 个候选人
-
如果所求是超过数组长度的 N / k ,至多有 k - 1 个候选人
-
遍历数组,判断每一个元素与两个候选人的关系:
-
1)如果当前元素与candidate1或者candidate2相同,让对应票数 + 1,
-
2)如果当前元素与两个候选人都不同,按照以下顺序设置:
-
① 如果 count1==0 说明候选人1空了,填充候选人1,让candidate1=num,count1=1
-
② 如果 count2==0 说明候选人2空了,填充候选人2,让candidate2=num,count2=1
-
③ 否则,说明两个候选人位置都不为空且当前都有余票,将票数各 - 1
-
注意:与 169 不同的是,本题遍历完数组找到 2 个候选人之后,最后还需要再遍历一遍数组,统计两个候选人的真实数量如果满足大于 N / 3 才将对应的候选人收集到答案中。
注意:本题代码最好是按照上面这样的顺序,先判断与候选人相同的情况进行投票,再判断与候选人不同的情况分别处理票数。如果是将count1==0和count2==0的判断放在最上面,虽然逻辑上通顺,但是结果会出现错误答案。
更严谨的写法(官方题解):
这个算法过程可以认为是预留了两个坑位/虚位,当坑位不为空时,对其进行投票,当坑位为空时,按顺序先坐上人,再对其进行投票。当有坑位票数减为0时,坑位重新变成空。
扩展:
- 对于超过2个以上候选人的问题,可以使用一个map来存每个候选人的票数:
- 1)如果遇到跟map中候选人相同的候选人,给map中相同的候选人加1票,
- 2)如果遇到的候选人跟map中的现有候选人都不同,
- ① 如果map未满 k - 1 个,就将当前候选人加入map中,
- ② 如果map已满 k - 1 个,就将map中所有候选人票数都减 1 票,如果减到 0 了,就从map中移除该候选人。
- 循环完毕,对map中的每个候选人到原始数组中再遍历一遍,统计每个候选人出现的真实次数。最后将出现次数 > N / k 的收集答案。
参考代码如下:
349. 两个数组的交集
-
1. HashSet , 先将第一个数组全部放入 set 中,然后遍历第二个数组的每个元素,如果存在于set中,就将其保存到结果集中, 结果集也使用HashSet来去重 ,最终再将结果集 set 转成数组 返回。
-
2. 排序 + 双指针遍历 + HashSet 去重 ,先对两个数组分别排序,然后用 双指针 i 和 j 同时遍历两个数组,比较两个数组的当前元素 ,
-
1)如果nums1[i] == nums2[j] 即当前元素 相同 ,就保存到结果 set 集合中,然后让 两个指针同时前进 ,
-
2)否则,就只让其中 较小元素的指针 前进,直到有一个数组处理完毕,结束循环。
-
最终再将结果set集合转成数组返回。
为什么两个指针对应元素不同时,选择让较小的那个指针前进呢?请看下图:
如果两个数组排序后拥有一段相同的数据,则 nums1[i] != nums2[j] 时,必然是较小的指针距离那段相同的数据更近,所以移动较小的指针能更快的逼近相同的数据段进行比较。
-
3. 排序 + 双指针 遍历 + 数组去重 , 同方法2,用一个 数组 res 来保存结果, res 长度为 两个数组的最小长度 ,在保存结果时, 判断 res 的最后一个元素与当前要存入的元素不同才放入 (因为数组是 升序 存的,所以只看最后一个),最终再拷贝一下数组返回。
如上图所示,在往结果数组中存入第一个 8 之后,由于后面相同的两个 8 与 res 中最后一次存入的 8 相同,因此这两个 8 被跳过,继续存入后面的 9。
350. 两个数组的交集 II
-
1. 哈希计数 , 首先用 map 对 nums1 数组计数, 然后遍历 nums2 数组 的每个元素,如果存在于 map 中,就添加到结果数组中,并将其 在map中的计数 - 1 ,如果 计数值减到 0 了,就将其 从map中移除 。
-
注意点:保存结果时可以直接 利用nums1来保存 ,最后再拷贝一下。
-
对于进阶问题二:只需选择长度 较小 的数组放入 哈希表 ,然后到 较大 的数组中去 查找 。
下图是上面算法的执行动画过程:
-
2. 计数数组 ,题目数组值最大为 1000 ,因此可以使 用长度 1001 的 计数数组 代替 方法1中的 HashMap 。
-
3. 排序 + 双指针 , 同349题方法2, 无需去重 ,保存结果时,复用 nums1 来存,最后再拷贝一下结果。
由于题目数值的范围不大,所以这里排序采用了时间复杂度更优的计数排序(O(n)) ,当然也可以直接使用内置的Arrays.sort(),其复杂度为O(nlogn)。
对于本题的进阶问题三:nums2全部在磁盘上,而内存十分有限,因此这时不能将nums2全部加载到内存,也就不能使用排序算法,这时可以有以下两种选择
- 1)可以使用方法1,由于nums1在内存中,仍然对nums1进行哈希计数,在方法1中,nums2 只关系到查询操作,因此可以每次读取 nums2 中的一小部分数据,并到哈希表中查找重复数进行处理即可。
- 2)可以先通过归并外排将两个数组排序后,再使用排序 + 双指针查找。一般来说排序算法都是指的内部排序,也就是方法3属于是内部排序,一旦涉及到跟磁盘打交道(外部排序),则需要特殊的考虑。归并排序是天然的适合外部排序的算法,我们可以将分割后的子数组写到单个文件中,归并时将小文件合并为更大的文件。当两个数组均排序完成并生成两个大文件后,即可使用双指针遍历两个文件来求解,如此可以使空间复杂度最低。
剑指 Offer 61. 扑克牌中的顺子
-
1. 排序 + 计数 0 , 缺失的数量用 0 来替补 。
-
先对原数组进行 计数排序(或直接Arrays.sort()) ,并拿到 0 的个数 zeroCount 。
-
然后遍历数组,从 第一个非 0 的数字 (即 zeroCount...N-1 )开始判断后一个元素跟当前元素的差值 nums[i + 1] - nums[i] :
-
1) 如果出现重复值 (nums[i] == nums[i + 1]),肯定不能构成顺子,
-
2) 如果前后差值大于 1 ,说明需要 0 来替补,需要消耗的 0 的数量为前后元素之间的缺的数字的个数 (前后差值 - 1), 如果替补的 0 数量不够,则不能构成顺子,否则就从 0 的总个数中减去消耗的 0 的数量。
-
2. 统计 非 0 的 最大值和最小值之差 < 5 且 无重复 ,除大小王外,最大值和最小值之差 小于 5 才有可能构成顺子,
-
例如 1,2,3,4,5 或者 6,7,8,9,10,连续的5个数最大值与最小值之差是4,如果包含 0,例如 0, 0, 2, 3, 4,非0的最大值最小值之差也小于 5,
-
如果最大值最小值之差大于等于 5,例如 1, 2, 3, 5, 6,显然不能构成顺子。
-
另外, 非 0 的数不能有重复值 。这是显然的,因为顺子是连续递增的数字。
面试题一
解题思路:
-
方法一:从左往右求一个left最大值数组,从右往左求一个right最大值数组,遍历一遍 i 位置从上面两个数组里取值,取绝对值
解题思路:
-
方法二:先找到数组的最大值max,然后用 max - min(arr[0], arr[N-1])
面试题二
-
对于 [0, N-1] 区间上的每个数 L,判断区间 [L, R] 的数是否无重复(R < N),且区间上的 最大值 - 最小值 等于区间 [L, R] 的长度 - 1 ,此时区间 [L, R] 的 长度就是一个可整合数组的长度,记录最大值即可。
-
注意:题目是假设排序后会有 arr[i]=arr[i - 1]+1,但是并不是真的要排序
对于判断一个区间内无重复元素,使用一个HashSet就可以做到,对于最大值最小值,使用两个变量min和max,在扫描区间的过程中不断比较更新这两个值即可。实现代码如下: