LeetCode Hot 100 · 双指针篇
双指针是 Hot 100 里性价比最高的一类:代码短、套路清晰、面试官爱问正确性证明。这四道题恰好覆盖了双指针的两个子流派:
- 快慢指针(283 移动零):两个指针同向出发,一个探路、一个落子,用于原地覆写。
- 相向指针(11 盛水容器、15 三数之和、42 接雨水):两个指针从两端夹逼,通过「淘汰不可能的一侧」把 O(n²) 降到 O(n)。
相向双指针的核心心法只有一句话:当前状态下,如果某一侧的指针继续参与不可能得到更优解,就把它往中间赶。三题都是同一个思路的不同包装,下面逐个拆。
#283 移动零
题意
给定整数数组 nums,把所有 0 移到末尾,保持非零元素的相对顺序不变,并且必须原地操作(不能复制数组)。
输入:nums = [0,1,0,3,12]
输出:[1,3,12,0,0]
难点与易错点
- 原地 + 保持相对顺序,两个约束叠加:如果允许用额外数组,一趟扫描把非零依次放入新数组再补 0 即可,毫无难度;难就难在「原地」和「相对顺序」要同时满足。用错误的原地覆盖(比如左右指针交换,见 905 按奇偶排序)会打乱非零元素的相对顺序——这里必须用同向的快慢指针,而不是相向。
- 覆盖 vs 交换的差别:一种写法是「把非零值覆盖到 slow 位置,最后统一把 slow 之后补 0」,另一种是「遇到非零且 slow 处是 0 才交换」。交换法要小心
slow == fast时把自己和自己交换,所以源材料的实现加了if (nums[slow] == 0)的守卫——这个守卫正是「slow 落后于 fast 时 slow 位置必为 0」这一不变式的体现,面试时能说出来会加分。 - 边界:全零数组(
[0,0,0]应原样返回,slow 始终不动);开头就是 0 的数组;已经排好的数组(此时 slow 和 fast 始终同步,不发生交换)。
解法一:暴力 / 直观
新建一个等长数组,第一遍把非零元素按顺序塞到前面,记录个数 cnt,第二遍把剩余位置补 0,最后拷回原数组。
class Solution {
public void moveZeroes(int[] nums) {
int n = nums.length;
int[] temp = new int[n]; // 额外数组
int cnt = 0;
// 第一遍:按顺序收集所有非零元素
for (int x : nums) {
if (x != 0) {
temp[cnt++] = x;
}
}
// 第二遍:把非零部分拷回原数组(temp 剩余位置默认就是 0)
for (int i = 0; i < n; i++) {
nums[i] = temp[i];
}
}
}
- 时间复杂度 O(n):两趟遍历,每趟 n 个元素,O(n + n) = O(n)。
- 空间复杂度 O(n):额外的
temp数组。
瓶颈:空间 O(n),违反题目「原地」的要求,面试直接写这个会被追问优化。它的价值只是把「收集非零 + 补零」的思路讲清楚,为下面的原地覆盖铺路。
解法二:优化 / 最优(快慢指针)
本质是快慢指针原地覆写:慢指针 slow 指向「下一个可放非零元素的位置」,快指针 fast 扫描全数组。
现实类比——捡石头:一条传送带上有普通石头和空石头(0)。你用左手(慢指针)标记下一个放实心石头的位置,右手(快指针)负责扫描,遇到实心石头就搬到左手位置,遇到空石头就跳过。左手只会在右手确认实心后往前挪,所以搬完一遍,实心石头天然排在前半段,空石头自然堆在后半段。
class Solution {
public void moveZeroes(int[] nums) {
int slow = 0; // 慢指针:指向下一个可放非零元素的位置
int fast = 0; // 快指针:扫描整个数组
while (fast < nums.length) {
if (nums[fast] != 0) {
// 只有 slow 位置是 0 时才需要交换;否则 slow 与 fast 同位置,交换无意义
if (nums[slow] == 0) {
nums[slow] = nums[fast];
nums[fast] = 0;
}
slow++; // 非零元素已就位,慢指针右移
}
fast++; // 快指针始终右移,直到遍历完数组
}
}
}
复杂度逐步推导:
fast从下标 0 走到n-1,恰好走 n 步,每一轮fast++必执行。slow只在遇到非零元素时右移,最多也走 n 步(全非零时);但slow每步都伴随一次fast的前进,两指针加起来是「fast 独占 n 步 + slow 额外至多 n 步」。- 因此总步数 ≤ n + n = 2n,去掉常数,时间复杂度 O(n)。
- 全程只用了
slow、fast两个整型变量,没有分配任何与 n 相关的新空间,空间复杂度 O(1)。
另一种更简洁的等价写法是「覆盖 + 末尾补零」:
slow处直接写nums[fast],最后把[slow, n)补 0。交换法交换次数更少(只在有 0 时才动手),两者都是 O(n)/O(1),选哪种都行,关键是要能说清「slow 之前全是非零」这条不变式。
💭 思考:为什么「原地 + 保持相对顺序」这两个约束叠加,就决定了必须用「同向」快慢指针?——先想暴力解:开新数组收集非零再补 0,空间 O(n) 违约。要在原地做,又要不改变非零元素的先后,就不能用「相向左右指针交换」(那会把后面的非零换到前面、打乱顺序,那是「无序分区」)。于是只剩一条路:两个同向指针,
fast探路、slow落子,维护「slow之前全是非零」这条不变式。看到「原地 + 保持顺序」这个信号,就直接锁定快慢指针,而不是相向指针——两者的取舍全看「顺序是否必要」。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 额外数组 | O(n) | O(n) | 只求写对、不要求原地;作为讲解铺垫 |
| 快慢指针(交换/覆盖) | O(n) | O(1) | 题目要求原地;面试标准答案 |
本题约束是原地 + 保持相对顺序,暴力法的 O(n) 额外空间直接违约,所以最优解锁定快慢指针——它用两个指针把空间压到 O(1),且不破坏相对顺序。
CodeTop 变体
- 字节跳动高频追问:把题目改成「非零元素不要求保持相对顺序」,此时可以用左右指针分区(类似快速排序的 partition),左指针找 0、右指针找非零交换,一趟完成、交换次数更少。这题考察的是「相对顺序是否必要」直接决定了用同向还是相向双指针。
- 同套路延伸(必刷):
- 26 删除有序数组中的重复项、27 移除元素——都是「快慢指针原地覆写」的裸题。
- 88 合并两个有序数组——「原地 + 从后往前」的指针覆写,与本题共用「慢指针指向落子位置」的心法。
- 905 按奇偶排序数组——「偶数在前奇数在后」,是「相向指针 + 无序分区」的变体。
#11 盛最多水的容器
题意
给定 n 条垂线,第 i 条线端点为 (i, 0) 和 (i, height[i])。任选两条线与 x 轴围成一个容器,求能容纳的最大水量(即最大矩形面积)。
输入:[1,8,6,2,5,4,8,3,7]
输出:49 // 选 height[1]=8 与 height[8]=7,宽度 7,装水 7×7=49
难点与易错点
- 贪心正确性是这个题的灵魂:为什么「每次移动较矮的一侧」一定不漏解?这是字节/腾讯最爱追问的证明,代码三行写完,但证不出来照样挂。核心是:面积 = 宽度 × 两侧较矮的高度,宽度随指针靠拢单调递减,所以只有「换掉更矮的板」才可能换来更高的下限、抵消宽度的损失;移动更高的一侧,矮板不变、宽度还变小,面积必然不增。
- 容易被「看似单调」带偏:面积既不随宽度单调(高度会变),也不能直接二分或单调栈,不少人想到这层就卡住。正确姿势是抓住「面积由短板决定」这个木桶原理,把问题转化为「淘汰短板」。
- 边界:
height[left] == height[right]时移动哪边都行(此时两边一样矮,随便淘汰一个都不影响正确性);只有两条线时直接返回min×1;不要忘记用Math.min取短板而不是取平均。
解法一:暴力 / 直观
枚举所有两条线的组合 (i, j),逐个算面积取最大。
class Solution {
public int maxArea(int[] height) {
int n = height.length;
int maxWater = 0;
for (int i = 0; i < n; i++) { // 左端点
for (int j = i + 1; j < n; j++) { // 右端点
int water = (j - i) * Math.min(height[i], height[j]);
maxWater = Math.max(maxWater, water);
}
}
return maxWater;
}
}
- 时间复杂度 O(n²):组合数 C(n, 2) = n(n−1)/2 种,逐步推:外层 i 走 n 次,内层 j 平均走约 n/2 次,乘积 ≈ n²/2,去掉常数即 O(n²)。
- 空间复杂度 O(1):只用了两个循环变量和一个结果变量。
瓶颈:时间 O(n²),n 到 10⁵ 量级时必超时。它穷举了所有对,但没有利用「短板决定面积」这个结构——很多组合从面积角度一眼就不可能成为答案,这正是可剪枝的地方。
解法二:优化 / 最优(相向双指针 + 贪心)
题目本质:双指针贪心。容积 = 宽度 × 较小高度,宽度随指针靠拢必然减小,因此每次移动较小的那一侧,才可能换来更大的容积。
现实类比——水桶装水:两块木板夹住的水量取决于较短的那块(木桶原理)。我们从最宽的状态(两端)开始,每次把较短的板换掉,看能不能找到更高的板来弥补宽度的损失;如果两块一样高,换哪块都行。
class Solution {
public int maxArea(int[] height) {
int left = 0; // 左指针从最左出发
int right = height.length - 1; // 右指针从最右出发(宽度最大)
int maxWater = 0;
while (left < right) {
// 当前容积 = 宽度 × 两侧最小高度(木桶原理)
int currentWater = (right - left) * Math.min(height[left], height[right]);
maxWater = Math.max(maxWater, currentWater);
// 移动高度较小的指针:保留较高的一侧,才可能找到更大容积
// 移动较高侧只会让宽度减小而高度不增,必然不优
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
}
复杂度逐步推导:
- 初始
left = 0、right = n-1,两个指针相向移动,每轮 while 恰好有一个指针移动一步。 - 两个指针从相距
n-1到相遇,中间一共移动了 n-1 步(每个指针各自走了一段,但两指针位移之和恒等于 n-1)。 - 每步只做一次常数时间的乘法/比较,所以总耗时与 n-1 成正比,时间复杂度 O(n)。
- 只维护
left、right、maxWater三个变量,空间复杂度 O(1)。
为什么移动短板是对的(正确性证明):设当前 left < right 且 height[left] < height[right],宽度为 w = right - left。固定 left,让 right 任意左移到 right',此时面积 = min(height[left], height[right']) × (right' - left) ≤ height[left] × w(宽度变小、短板不高于原短板)。也就是说,只要 left 还是那块短板,right 怎么动都不可能超过当前面积,所以 left 这一侧可以放心淘汰、直接 left++。每步都淘汰一个「不可能更优」的指针,最终不会漏掉最优解。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | n 很小;用于验证正确性 |
| 相向双指针 | O(n) | O(1) | 本题约束(n 可到 10⁵);标准答案 |
本题 n 上限通常到 10⁵,O(n²) 会跑到 10¹⁰ 量级直接超时,而双指针 O(n) 一趟扫完,且空间 O(1),因此最优解必选相向双指针。
CodeTop 变体
- 字节跳动 / 腾讯高频:写完代码几乎必问「为什么移动矮的那侧不会漏掉最大面积」——就是上面那段的贪心正确性证明,务必背熟。另一个高频追问是「两边一样高时移动哪边」,答案是任意,因为短板相等时淘汰任一侧都不影响。
- 同套路延伸(必刷):
- 42 接雨水——同属「木桶/短板效应」,但接雨水是「求总和」而非「求最大单点」,二者常被一起考来区分。
- 84 柱状图中最大的矩形——也是面积类问题,但「短板 × 连续区间」让它更适合单调栈,面试官常把 11 / 42 / 84 三题放一起对比,问「为什么 84 不能用双指针」。
#15 三数之和
题意
给定整数数组 nums,找出所有满足 i != j != k 且 nums[i] + nums[j] + nums[k] == 0 的不重复三元组,返回所有这样的三元组(元素顺序任意,但结果集合不能有重复三元组)。
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]] // 注意 [-1,0,1] 只出现一次,尽管 -1 出现两次
难点与易错点
- 去重是最大的坑,而且是三层去重,缺一不可:
- 固定元素去重:
i > 0 && nums[i] == nums[i-1]时跳过,否则[-1,0,1]会被两个不同的-1各找出一遍。 - 左指针去重:找到一组解后,
while (left < right && nums[left] == nums[left+1]) left++。 - 右指针去重:
while (left < right && nums[right] == nums[right-1]) right--。 - 初学者常漏掉后两层,或者把「固定元素去重」写成
nums[i] == nums[i+1](与「后一个」比较是错的,会漏掉[0,0,0]这类解)。
- 固定元素去重:
[0,0,0]是经典特例:全 0 数组应返回[[0,0,0]]。如果去重写错(比如先跳固定元素再用 if 去重),很容易把唯一这组解也跳过。- 边界与剪枝:排序后若
nums[i] > 0,由于数组升序、后面的数只会更大,三数之和不可能为 0,可直接return;去重用while而非if(连续重复元素不止一个);访问nums[left+1]时必须先判left < right,否则越界。 - 为什么不用哈希表:哈希能 O(n²) 求「两数之和」,但三数之和里处理「三元组去重」极其繁琐(要排序键去重),还容易踩「同一元素复用」的坑,所以排序 + 双指针才是标准答案。
解法一:暴力 / 直观
三重循环枚举所有 (i, j, k),用 Set 去重(先对三元组排序再入 Set 作为键)。
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
int n = nums.length;
Set<List<Integer>> set = new HashSet<>(); // 用 Set 去重
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
for (int k = j + 1; k < n; k++) {
if (nums[i] + nums[j] + nums[k] == 0) {
List<Integer> triple = Arrays.asList(nums[i], nums[j], nums[k]);
Collections.sort(triple); // 排序后才能作为去重键
set.add(triple);
}
}
}
}
return new ArrayList<>(set);
}
}
- 时间复杂度 O(n³):三重循环 C(n, 3) ≈ n³/6 种组合,逐步推:三层循环每层走 n、n、n,乘积 O(n³);再叠加 Set 去重的开销。
- 空间复杂度 O(n):
Set最多存 O(n³) 个三元组(此处为示意,实际去重存储可能很大)。
瓶颈:时间 O(n³) 在 n=3000 时已到 10¹⁰ 量级必然超时;而且用「先算出全部、再 Set 去重」的思路很笨,等于把重复的活全干完再回头清理。优化的关键是排序后让重复元素天然相邻,用指针跳过,从源头避免重复。
解法二:优化 / 最优(排序 + 固定 + 双指针)
题目本质:排序后,对每个固定元素 nums[i],用双指针在其右侧查找两数之和等于 -nums[i] 的组合,同时跳过重复元素避免重复结果。
现实类比——三人拼车凑钱:先把参与者按出资额排好队,固定出资最少的一位,然后让「出资最多的一位」和「出资次少的一位」两个指针从两端往中间凑,凑出剩余金额的组合;凑到了就收下并跳过相同出资额的重复人选,凑多了就减少出资多的那端,凑少了就增加出资少的那端。
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
Arrays.sort(nums); // 排序是双指针的前提:让重复元素相邻、让大小关系可用
for (int i = 0; i < nums.length; i++) {
// 剪枝:排序后最小值已 > 0,三数之和不可能为 0,直接退出
if (nums[i] > 0) {
return result;
}
// 第一层去重:跳过重复的固定元素,避免结果中出现重复三元组
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1; // 双指针在 i 的右侧查找
int right = nums.length - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum > 0) {
right--; // 三数和偏大,右指针左移减小和
} else if (sum < 0) {
left++; // 三数和偏小,左指针右移增大和
} else {
// 找到一个合法三元组
result.add(Arrays.asList(nums[i], nums[left], nums[right]));
// 第二层去重:跳过 left 侧的重复元素
while (left < right && nums[left] == nums[left + 1]) {
left++;
}
// 第三层去重:跳过 right 侧的重复元素
while (left < right && nums[right] == nums[right - 1]) {
right--;
}
// 内缩,继续寻找下一组
left++;
right--;
}
}
}
return result;
}
}
复杂度逐步推导:
- 排序:
Arrays.sort为快速排序/归并,耗时 O(n log n)。 - 外层循环:
i从 0 到 n-1 共 n 次迭代。 - 内层双指针:对每个固定的
i,left与right相向移动,两个指针在[i+1, n-1]区间内合计最多移动 O(n) 次(每轮 while 至少有一个指针走一步,两指针从相距 n-1 到相遇共移动 n 步量级)。 - 合并:外层 O(n) × 内层 O(n) = O(n²),再加排序 O(n log n),总复杂度取高次项为 O(n²)。
- 空间:除结果集
result(题目要求返回,不计入)外,只用常数个指针变量;Arrays.sort对 int 数组是原地排序(递归栈 O(log n)),所以额外空间为 O(1)(或 O(log n),取决于排序实现)。
💭 思考:为什么「排序 + 双指针」能把三数之和从 O(n³) 降到 O(n²)?——先看暴力为什么慢:三重循环枚举
(i,j,k),还要靠 Set 去重,等于把重复解全算出来再清理。排序带来两个红利:一是大小关系可用,固定一个数后「两数之和等于-nums[i]」就退化成双指针对撞(和偏大右指针左移、偏小左指针右移);二是重复元素天然相邻,用 while 跳过即可从源头避免重复。看到「三数之和 / 四数之和 / 找所有组合」这类「多个数凑定值 + 要去重」的信号,就锁定「排序 + 固定一端 + 相向双指针」这个框架。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 三重循环 + Set 去重 | O(n³) | O(n) | n 很小;只求正确 |
| 排序 + 固定 + 双指针 | O(n²) | O(1) | 本题约束(n 可到 3000);标准答案 |
本题 n 上限约 3000:暴力 O(n³) ≈ 2.7×10¹⁰ 超时,而排序 + 双指针 O(n²) ≈ 9×10⁶ 轻松通过,且去重在 O(1) 空间内完成,故最优解锁定后者。
CodeTop 变体
- 字节跳动高频:几乎必问「为什么排序后能用双指针把 O(n³) 降到 O(n²)」以及「三层去重分别防的是什么」。进阶追问:如果要求返回「是否存在」而不是「全部三元组」,只需去掉结果收集、遇到一组就提前返回。
- 同套路延伸(必刷):
- 16 最接近的三数之和——去掉「去重」包袱,改成维护「与 target 最接近的和」,是本题的降级/换皮版。
- 18 四数之和——固定两层 + 双指针,把「三数之和」框架直接套一层循环,阿里/腾讯常考。
- 1 两数之和——本题内层的原型(哈希或双指针),是理解「和为定值」的基础。
#42 接雨水
题意
给定 n 个非负整数表示柱子高度,计算这些柱子按此排列后,下雨能接住多少雨水(每个位置能积的水等于其左右两侧最高柱子中的较矮者减去自身高度)。
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
难点与易错点
- 每个位置的接水量公式是核心:
水量[i] = min(左侧最大高度, 右侧最大高度) - height[i],若为负则取 0。这是木桶效应的直接体现——左右最高的墙里较矮的那面决定水位上限。想清楚这一点,所有解法都是它的不同实现。 - 双指针的正确性要能讲透:为什么「从
preMax < sufMax的较小侧结算」是对的?关键是不变式——当preMax < sufMax时,left右侧必然存在一根高度 ≥sufMax的墙(就是right或者更右边的柱子),因此left位置的右侧最大高度一定 ≥sufMax > preMax,于是left处的积水只由preMax决定,可以立刻结算、无需知道更右侧的细节。 - 边界:首尾两根柱子本身接不到水(其一侧没有墙,min 减自身为 0);空数组或单根柱子直接返回 0(
left < right循环天然不进入);preMax/sufMax要在结算前先更新再计算,否则用旧值会算错。
解法一:暴力 / 直观
对每个位置 i,分别向左、向右扫描找到左最大和右最大,套公式累加。
class Solution {
public int trap(int[] height) {
int n = height.length;
int totalWater = 0;
for (int i = 0; i < n; i++) {
int leftMax = 0, rightMax = 0;
// 向左找最高墙
for (int j = i; j >= 0; j--) {
leftMax = Math.max(leftMax, height[j]);
}
// 向右找最高墙
for (int j = i; j < n; j++) {
rightMax = Math.max(rightMax, height[j]);
}
totalWater += Math.min(leftMax, rightMax) - height[i];
}
return totalWater;
}
}
- 时间复杂度 O(n²):逐步推——外层 i 走 n 次,每次内层左右两趟各扫 O(n),单次 O(n) + O(n) = O(n),共 n × O(n) = O(n²)。
- 空间复杂度 O(1):只用常数变量。
瓶颈:时间 O(n²),n 到 2×10⁴ 就会超时。重复劳动非常明显——每个位置都在重新扫描整个数组求最值,而「左侧最大」「右侧最大」完全可以用一次预处理或双指针一次求出来。
解法二:优化 / 最优(相向双指针 + 前后缀最大值)
题目本质:每个位置能接的水量 = min(左侧最大高度, 右侧最大高度) - 当前高度。用双指针维护前后缀最大值,从较小侧入手可以保证计算正确。
现实类比——盛水的槽:每根柱子能接多少水,取决于左边最高墙和右边最高墙里较矮的那面(木桶效应)。从两端夹逼,较短那侧的最大值已经确定,于是可以直接算出当前柱子的积水,往中间收一格;就像站在两边矮墙上往里看,水只会积到矮墙的高度。
class Solution {
public int trap(int[] height) {
int left = 0; // 左指针
int right = height.length - 1; // 右指针
int preMax = 0; // left 左侧(含 left)已见的最大高度
int sufMax = 0; // right 右侧(含 right)已见的最大高度
int totalWater = 0;
while (left < right) {
// 先更新两侧历史最大值,再据此结算
preMax = Math.max(preMax, height[left]);
sufMax = Math.max(sufMax, height[right]);
if (preMax < sufMax) {
// left 侧较矮:left 位置的积水由 preMax 决定
// 因为右侧必存在高度 >= sufMax > preMax 的墙,水位被 preMax 卡住
totalWater += preMax - height[left];
left++;
} else {
// right 侧较矮:right 位置的积水由 sufMax 决定
totalWater += sufMax - height[right];
right--;
}
}
return totalWater;
}
}
复杂度逐步推导:
left从 0 向右、right从 n-1 向左,每轮 while 恰好一个指针移动一步,两指针从相距 n-1 走到相遇,总共移动 n-1 步。- 每步只做常数次的
Math.max、比较和加法,所以总耗时与 n-1 成正比,时间复杂度 O(n)。 - 全程只维护
left、right、preMax、sufMax、totalWater五个变量,不分配与 n 相关的空间,空间复杂度 O(1)。
折中方案(面试也常被要求写):用两个数组
leftMax[i]、rightMax[i]预处理「i 左侧/右侧的最大值」,再一趟套公式。它时间 O(n)、空间 O(n),比双指针更好理解,是「从暴力到双指针」的桥梁——双指针的精髓就是用两个滚动变量替代两个前缀数组,把空间从 O(n) 压到 O(1)。
💭 思考:接雨水怎么一步步从 O(n²) 优化到 O(n)/O(1)?——暴力版对每个位置都左右扫一遍求最值,重复劳动明显;于是想到「左右最大值各预处理一次」的前后缀数组,时间降到 O(n) 但空间 O(n)。再进一步:算
left位置的水量其实只依赖preMax,而「右侧必有更高的墙」由preMax < sufMax这条不变式保证,根本不需要存整个右后缀数组。于是两个滚动变量就替代了两个数组。看到「每个位置的值由『左右最值』决定」这种结构,就沿着「暴力 → 前缀数组 → 双指针滚动」这条链往上爬,最后的落点是双指针。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力(逐列左右扫描) | O(n²) | O(1) | n 很小;理解公式 |
| 前缀/后缀数组 | O(n) | O(n) | 容易想到、好解释的过渡方案 |
| 双指针 + 前后缀最大值 | O(n) | O(1) | 本题约束;最优空间,标准答案 |
| 单调栈 | O(n) | O(n) | 求「每段凹槽」细节、按层而非按列 |
本题 n 上限可达 2×10⁴ 甚至更大,暴力 O(n²) 超时;前缀数组 O(n) 已可过,但双指针 O(n)/O(1) 空间更优,且是「用两个变量滚动替代两个数组」的经典技巧,面试官最想看到的是双指针。
CodeTop 变体
- 字节 / 阿里 / 腾讯高频:必问「为什么较小侧可以直接结算」的正确性证明(见难点 2),还会要求现场比较暴力、前缀数组、双指针、单调栈四种解法并说清取舍。另一个常见追问:若要求返回「每个位置的具体积水量」而非总和,双指针依然适用(逐个记录即可)。
- 同套路延伸(必刷):
- 407 接雨水 II——三维版(给定 m×n 高度矩阵),需用最小堆 + BFS(从四周最低处向内扩展),阿里面试真实考过,是本题的硬核升级。
- 11 盛最多水的容器——同属木桶效应,但一个求「最大单点面积」、一个求「按列积水和」,务必说清差异。
- 84 柱状图中最大的矩形——「短板 × 连续宽度」的区间最值,用单调栈;面试官常把 42 与 84 并列,问「为什么一个用双指针、一个用单调栈」。
小结
| 题号 | 题目 | 双指针类型 | 核心心法 |
|---|---|---|---|
| 283 | 移动零 | 快慢指针(同向) | slow 落子、fast 探路,原地覆写保持顺序 |
| 11 | 盛最多水的容器 | 相向双指针 | 淘汰短板,木桶效应 |
| 15 | 三数之和 | 排序 + 相向双指针 | 固定一端,两端夹逼 + 三层去重 |
| 42 | 接雨水 | 相向双指针 + 前后缀最值 | 较小侧最大值已定,即可结算 |
四道题里,283 是快慢指针的「原地覆写」范式,11 / 15 / 42 是相向双指针的三种变形:11 淘汰短板、15 排序后夹逼去重、42 用滚动最值按列结算。它们共同的心法始终是那句——找到「当前状态下不可能更优的那一侧」,把它淘汰掉,O(n²) 就塌缩成了 O(n)。手撕时代码都不长,真正拉开差距的是你能不能把「为什么能淘汰」这条不变式讲清楚。
章末提问
- 移动零为什么必须用「同向」快慢指针,而不能用「相向」左右指针?——结论:因为要「保持非零元素相对顺序」,相向交换会把后面的非零元素换到前面、打乱顺序。因为相向指针本质是「无序分区」,只保证 0 在右,不保证非零元素的先后。
- 接雨水的双指针凭什么能把前缀/后缀数组省成两个滚动变量?——结论:因为计算
left位置只需要preMax,而「右侧必有更高墙」由preMax < sufMax这条不变式保证,无需真正存下整个右后缀最大值数组,所以两个变量就替代了两个数组。 - 三数之和的复杂度为什么是 O(n²) 而不是 O(n³)?——结论:因为排序后外层固定一个数,内层双指针在剩余区间「相向合拢」,每轮 while 至少移动一个指针,两个指针总共只走 O(n) 步,外层 O(n) × 内层 O(n) = O(n²);排序的 O(n log n) 被高次项吸收。