先问一个问题:你在刷 LeetCode Hot 100 的时候,是不是经常遇到双指针的题,看答案觉得“就这?”,但自己拿到新题还是一脸懵?如果你正好刷到第 11 题“盛最多水的容器”,并且对“为什么移动矮的那一边”这个操作背后的逻辑有点似懂非懂,那这篇文章就是给你写的。
这道题在 Hot 100 里属于经典中的经典,难度标成 medium,但说实话代码量连十行都不到。它难的不是写代码,而是理解那个双指针的移动策略为什么是对的。很多人靠背题解记住了“谁矮谁移动”,但面试官一追问原理就露馅。这篇文章我会从最朴素的暴力解法开始,一步步推出双指针解法,再用严格的思路证明它为什么不会漏掉最优解,最后结合我自己刷题踩过的坑,帮你把这道题的每个细节都吃透。不管你是刚入门的新手,还是准备面试想系统过一遍 Hot 100 的选手,这篇都能帮你在双指针这个专题上少走弯路。
1. 题目理解与暴力解法推演
1.1 题目到底在问什么
题目描述本身不难懂:给定一个长度为 n 的整数数组 height,数组里的每个数代表一根垂直线的高度,你要在其中选两根线,和 x 轴一起构成一个容器,问这个容器最多能装多少水。
举个例子,height = [1, 8, 6, 2, 5, 4, 8, 3, 7],你选下标 2(高度 6)和下标 8(高度 7)这两根线,容器宽度是 8 - 2 = 6,容器高度取决于较矮的那根线(因为水会从矮的那边溢出),也就是 min(6, 7) = 6,所以面积是 6 × 6 = 36。当然这个不是最优解,最优解是选下标 1(高度 8)和下标 8(高度 7),宽度 7,高度 min(8, 7) = 7,面积 49。
这里有几个关键点要提醒一下:第一,容器的容量取决于短板,不是长板,这就是经典的“木桶效应”;第二,题目说的是垂直线,所以容器壁是垂直于 x 轴的,不需要考虑倾斜的情况;第三,数组元素的数量上限是 10 的 5 次方,这个数量级直接决定了暴力解法不可行,后面会仔细算这笔账。
1.2 最直观的暴力枚举法
拿到这道题,正常人第一反应都是两层循环枚举所有两根线的组合。用代码写出来也很简单:
java复制public int maxArea(int[] height) {
int n = height.length;
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
int area = (j - i) * Math.min(height[i], height[j]);
ans = Math.max(ans, area);
}
}
return ans;
}
这段代码的逻辑没有任何问题,穷举了所有可能的两根线组合,计算每种情况的面积,然后取最大值。但问题在于时间复杂度是 O(n²)。当 n = 10^5 时,需要比较的组合数是 n(n-1)/2,大约是 5 × 10^9 次,也就是五十亿次计算。在 LeetCode 的评测环境下,这个数量级的操作通常需要几十秒甚至更久,必然超时。
所以,这道题真正的挑战不是“怎么算面积”,而是“怎么不用全部枚举就能找到最大面积”。这就要用到双指针了,但双指针不是凭空蹦出来的,它背后有一套非常漂亮的排除逻辑。我们先把暴力解法和双指针解法的差异放在一起看:
| 解法 | 时间复杂度 | 空间复杂度 | 能通过 LeetCode |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 不能,超时 |
| 双指针 | O(n) | O(1) | 能,稳过 |
从 O(n²) 到 O(n),这中间不是靠什么玄学优化,而是靠“排除不可能的解”来减少计算量。下面我们重点拆解这个优化是怎么一步步想出来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力到双指针:思考过程的完整还原
2.1 面积公式里藏着什么样的优化空间
我们先回到面积公式:S = (j - i) × min(height[i], height[j])。这个公式由两部分组成:宽度 (j - i),以及高度 min(height[i], height[j])。
暴力解法是把所有 i 和 j 的组合都算一遍。但如果你仔细观察,会发现一个反直觉的现象:当两根线离得很远(宽度很大)的时候,如果其中一根线非常矮,那么面积依然会被压得很小;反过来,当两根线离得很近的时候,即使两根线都很高,面积也可能不是最大的。也就是说,我们不能单纯靠“宽度优先”或者“高度优先”来排序找答案。
那有没有一种办法,可以让我们在每一步都安全地排除掉一些组合,像“剪枝”一样缩小搜索空间呢?双指针就是干这个的。
2.2 双指针的起点:为什么从两端开始
双指针对这道题的策略很朴素:一开始左指针指向数组开头(l = 0),右指针指向数组末尾(r = n - 1),此时容器的宽度是最大的。然后每次比较两个指针对应的高度,移动较矮的那一侧指针,直到两个指针相遇。
为什么一开始要选择最宽的情况?因为宽度是面积公式里唯一一个“不变量”——当两个指针往中间移动时,宽度一定在减小。既然宽度一定会越来越小,我们唯一能让面积变大的希望,就是找到更高的线,让“短板”变高。所以我们必须从宽度最大的情况开始,才有机会在宽度减小的过程中找到高度提升带来的补偿。
我在这里多说一句:这种“从两端向中间收缩”的思路,和很多双指针题(比如三数之和、接雨水)是一样的,但每一道题判断“移动哪边”的依据完全不同。第 11 题的依据很简单——谁矮移动谁,但为什么这是对的,需要严格的证明,这就是下一节的重点。
2.3 用“排除法”理解双指针的本质
很多讲双指针的文章上来就甩证明,但我觉得先用排除法的思路理解一下会更顺。假设当前 l 指向高度 3 的柱子,r 指向高度 8 的柱子,那么当前容器的面积是 (r - l) × 3,也就是说,高度由左边那个矮柱子决定。
这时候如果考虑以 l 为左边界的所有容器,请问有没有可能得到比当前更大的面积?注意,如果保持 l 不动,把 r 往左移(也就是 r 变成 r',且 r' < r),那么新的宽度是 r' - l,一定小于 r - l;而新的高度是 min(3, height[r']),这个值无论如何都不会超过 3,因为左边那根柱子高度只有 3。一个更小的宽度乘上一个不超过 3 的高度,怎么可能超过当前面积?
所以结论是:以当前这个矮柱子作为左边界,配合右边任何位置的柱子,都不可能产生比当前更大的面积。既然这个矮柱子已经被“榨干”了,那它就没有继续保留的价值,直接把它丢掉,左指针向右移动一位,去下一个位置寻找新的可能性。
这个排除法就是双指针正确性的直觉来源。它不是“猜”谁该移动,而是逻辑上证明了当前柱子作为边界已经没有潜力了,必须换掉。
3. 为什么移动矮的那一边:严格证明与手推过程
3.1 形式化的证明表述
这一节我放一个严格的证明,你理解了它,就算面试官怎么追问都不怕。
设当前左指针为 i,右指针为 j,且 height[i] <= height[j]。当前面积为 S(i, j) = (j - i) × height[i]。
现在任取一个 j',满足 i < j' < j,也就是保持 i 不动,把右指针左移到任意位置。此时对应的面积是 S(i, j') = (j' - i) × min(height[i], height[j'])。
因为 j' < j,所以 j' - i < j - i。又因为 min(height[i], height[j']) <= height[i](这是显然的,最小化之后不可能大于其中任何一个),所以:
S(i, j') = (j' - i) × min(height[i], height[j']) < (j - i) × height[i] = S(i, j)。
也就是说,任何保持矮柱子 i 不动、移动高柱子 j 得到的面积,都不可能超过当前面积。既然这些状态都严格劣于当前状态,我们就完全不用考虑它们,可以放心地让 i 向右移动,也就是把矮的那一侧指针移动掉。
反过来,如果 height[i] > height[j],对称地,我们应该移动右指针 j。总之,规律就一句话:移动高度较小的那一侧指针。
3.2 为什么不能用“移动高的那一边”的思路
很多初学会有一个疑惑:既然容器高度取的是 min,那如果把矮的留着、把高的往中间移动,容器高度不还是由矮的决定吗?不也一样不会变大?
这里要区分清楚:如果移动高指针 j,当前的面积会不会变大?我们证明了不会。重点是,我们“排除”的是哪些状态?移动高指针,排除的状态是“保持矮柱子 i 不动,j 在 (i, j) 之间的所有组合”。这些组合已经被证明全部不可能超过当前面积,所以排除它们没有任何损失。
但是反过来,如果刚才移动的是矮指针 i,我们排除了什么?是“保持高柱子 j 不动,i 在 (i, j) 之间的所有组合”——这部分我们还证明不了它们不可能更大。因为高度取 min,万一 i 向右移动后遇到一个比 j 还高的柱子,新高度可能超过原来的矮柱子高度,面积可能变大。这就是为什么我们不能先动高的那一边:动了高的,就相当于把可能有潜力的组合全部放弃了,而动了矮的,我们是在确认潜力耗尽后才放弃的。
用生活化一点的类比:你面前有一高一矮两堵墙,水桶装水量取决于矮墙。如果拆掉高墙往中间挪,矮墙还是原来的矮墙,水桶只会更小;但如果拆掉矮墙,往中间走有可能遇到更高的新墙,这时候水桶可能会变大。所以每次我们都是去动那个没有潜力的矮墙。
3.3 用示例数组完整跑一遍指针移动过程
为了把过程彻底讲透,我用题目自带的示例 height = [1, 8, 6, 2, 5, 4, 8, 3, 7] 来手推一遍。
初始状态:l = 0(高度 1),r = 8(高度 7)。min(1, 7) = 1,宽度 8,面积 8。这时矮的是左边高度 1,移动 l,l 变成 1。
状态 2:l = 1(高度 8),r = 8(高度 7)。min(8, 7) = 7,宽度 7,面积 49。当前答案是 49。注意,这里 49 就是整个数组的最优解。但先别急着停,程序还要继续跑。此时矮的是右边高度 7,移动 r,r 变成 7。
状态 3:l = 1(高度 8),r = 7(高度 3)。min(8, 3) = 3,宽度 6,面积 18,小于 49,不更新。矮的是右边高度 3,移动 r,r 变成 6。
状态 4:l = 1(高度 8),r = 6(高度 8)。min(8, 8) = 8,宽度 5,面积 40,小于 49。两边一样高,按规则移动哪边都行。我们假设移动 r,r 变成 5。
状态 5:l = 1(高度 8),r = 5(高度 4)。min(8, 4) = 4,宽度 4,面积 16,小于 49。矮的是右边,移动 r,r 变成 4。
状态 6:l = 1(高度 8),r = 4(高度 5)。min(8, 5) = 5,宽度 3,面积 15。移动 r,r 变成 3。
状态 7:l = 1(高度 8),r = 3(高度 2)。min(8, 2) = 2,宽度 2,面积 4。移动 r,r 变成 2。
状态 8:l = 1(高度 8),r = 2(高度 6)。min(8, 6) = 6,宽度 1,面积 6。移动 r,r 变成 1。
此时 l == r,循环终止。最终答案是 49,正确。
整个过程我们只算了 8 次面积,而暴力枚举要算 C(9, 2) = 36 次。数组越大,这个优势越明显。
4. 代码实现与边界处理细节
4.1 Java 与 Python 的标准实现
代码本身非常简单,我直接给出我平时喜欢用的版本,注释写得比较详细:
java复制public int maxArea(int[] height) {
int l = 0, r = height.length - 1;
int ans = 0;
while (l < r) {
int area = (r - l) * Math.min(height[l], height[r]);
ans = Math.max(ans, area);
// 先算面积,再移动指针,顺序不要搞反
if (height[l] < height[r]) {
l++;
} else {
r--;
}
}
return ans;
}
Python 版本逻辑完全一致:
python复制def maxArea(self, height: List[int]) -> int:
l, r = 0, len(height) - 1
ans = 0
while l < r:
area = (r - l) * min(height[l], height[r])
ans = max(ans, area)
if height[l] < height[r]:
l += 1
else:
r -= 1
return ans
这两个版本就是 LeetCode 上最常见的双指针解法。时间复杂度 O(n)(每个元素最多被访问一次),空间复杂度 O(1)。两段代码在功能上是完全等价的,区别只在语言语法上。
4.2 等号情况的处理:高度相等时到底移动哪边
当 height[l] == height[r] 时,代码里走的是 else 分支,也就是移动右指针。但严格来说,移动哪边都不会影响最终结果,因为:
如果两边高度相等,当前面积是 (r - l) × h(h 是那个相同高度)。移动左边,排除的状态是保持右指针 r 不动、l 向右的所有组合;移动右边,排除的状态是保持左指针 l 不动、r 向左的所有组合。由于两个指针当前高度一样,无论移动哪边,排除的“不可能超过当前面积”的状态数量是对称的,不会漏掉最优解。
不过我在实际写代码的时候,习惯统一写成 if (height[l] < height[r]) { l++; } else { r--; },也就是相等时归入“移动右指针”的分支。这样做的好处是代码简洁、不需要额外判断,而且不会因为等号情况引入 bug。如果你写成 if (height[l] <= height[r]) { r--; } else { l++; } 也是一样的,完全没问题。
4.3 几个容易被忽略的代码细节
第一个细节:计算面积和更新答案的顺序。必须先计算当前两指针对应的面积,再移动指针。如果你先移动了指针再算面积,那面积的定义就变了,结果大概率是错的。这个错误在初学阶段特别容易犯。
第二个细节:更新答案时的比较逻辑。ans = Math.max(ans, area) 中间用 Math.max 是最稳妥的写法,不要自己写 if 判断,很容易漏掉等于的情况。虽然等于的情况不影响最终结果,但用 Math.max 更省心。
第三个细节:int 溢出问题。这道题的约束里,height 数组元素最大是 10^4,数组长度最大是 10^5,所以最大面积是 (10^5) × (10^4) = 10^9,int 类型的上限大概是 2.1 × 10^9,所以理论上不会溢出。但如果你在变体题里遇到更大的数据范围,比如长度和高度都扩大到 10^6 甚至更多,那就需要用 long 类型来存面积了。我一般会在做题时先瞄一眼数据范围,养成习惯。
第四个细节:循环终止条件。while (l < r) 而不是 while (l <= r)。当两个指针相遇时,容器宽度为 0,面积没有任何意义,必须终止。这个条件写对了,整个循环的运动轨迹才是正确的。
5. 常见问题与实战踩坑记录
5.1 为什么我不会在移动指针前先“跳过相同高度”
这是一个很有意思的优化点。假设当前 height[l] 是 5,而 height[l + 1] 也是 5,那我在移动 l 后,容器宽度变小了,但最小高度还是 5,面积一定不会变大,那是不是可以直接跳过 l + 1,让 l 直接跳到 l + 2 呢?
理论上可以,我也见过有人写这样的优化:先算面积,然后 while (l < r && height[l] == height[l+1]) l++; 但我实测下来的结论是:没必要。因为双指针本来就是 O(n) 的复杂度,跳过相同高度不会改变复杂度量级,顶多减少几次乘法运算。而一旦加了这个 while 循环,代码的可读性会下降,而且在边界处理上更容易出错(比如跳过之后 l 和 r 错位)。在 LeetCode 这种数据规模下,完全不需要这个优化。保持简单,才是双指针解题的正确心态。
5.2 为什么这道题不能用“从中间往两边扩”的思路
有些小伙伴可能会想:既然从两边往中间收缩可以,那我能不能先找到中间最高的柱子,然后往两边扩展?
这个思路听起来很合理,但实际是不可行的。原因在于,我们不一定要选最高的那根柱子作为容器边界,有时候两根中等高度的柱子,因为距离很远,反而能形成更大的面积。比如 height = [5, 1, 1, 1, 5],最优解是两端的两根 5,中间全是矮柱子,从中间扩展一定会漏掉这个最优解。
从两端收缩为什么是对的?因为收缩过程保证了两根指针的“跨度”是从最大到最小的完整序列,每一步都只排除已经被证明不可能的状态。而从中间扩展,没有任何信息能帮助我们确定“哪两根柱子作为起点”,所以本质上还是得回到枚举上去。这就是双指针方向性的重要性:方向反了,算法就失效了。
5.3 常见问题速查表
我把做这道题以及在类似题里经常遇到的问题整理成了一张表,方便你快速对照:
| 问题 | 原因 | 正确做法 |
|---|---|---|
| 先移动指针再算面积,结果错误 | 面积公式依赖指针位置 | 必须先算面积再移动指针 |
| 循环条件写成 l <= r | 两指针重合时宽度为 0,无意义 | 使用 while (l < r) |
| 高度相等时不知道移动哪边 | 两边等价,移动哪边都不会漏解 | 代码里用 else 统一处理即可 |
| 试图跳过相同高度来优化 | 对复杂度没有实质改善 | 保持最简单写法,避免边界 bug |
| 认为该选最高柱子作为边界 | 面积同时取决于宽度和短板 | 距离产生的宽度收益可能更大 |
| 与“接雨水”思路混淆 | 两题都是双指针但逻辑完全不同 | 区分“当前位置能装多少”和“两根线能装多少” |
5.4 面试中常见的追问与应对
刷题归刷题,面试的时候这道题还有一些追问,我建议你提前准备。最常见的是面试官问:“你能证明这个算法的正确性吗?”这时候你把上面第三节的证明讲清楚就够了。注意要用“排除法”的语言,说清楚为什么移动矮指针后,被排除的那些状态不可能包含最优解。
另一个追问是:“如果数组里有负数高度怎么办?”这个其实是个陷阱,因为题目本身约束了 height 是非负整数,而且物理上容器高度不可能是负数。你直接回答“题目约束保证了非负,所以不需要考虑”就行。
还有一个比较有价值的追问是:“你能把这道题改造成三维版本吗?”也就是让你在一个二维矩阵上找最大的水的容器,这个问题会复杂很多,一般面试不会真的让你写代码,但你要能说出“这是一个立体几何问题,需要枚举底面的两个维度,复杂度至少是平方级别”这样有深度的思考。
6. 双指针体系串联:从这道题到其他 LeetCode 经典题
6.1 和“接雨水”的区别到底在哪
我在评论区经常看到有人把第 11 题和第 42 题“接雨水”搞混,这两题虽然长得像,但核心问题完全不同。“盛最多水的容器”是让你选两根线,容器长度是两根线的距离,水装在“两根线之间”的一个矩形区域里。而“接雨水”是一个地形剖面问题,每个位置可以存储的水量取决于它左右两侧最高柱子的较小值减去当前高度,水是装在“凹槽”里的。
双指针在两题中的应用方式也不一样。第 11 题的双指针,每次比较的是两个端点的高度,移动较低的一端,答案更新的是两端的面积。而“接雨水”的双指针,需要同时维护左右两边的最大高度,逻辑更复杂一些。如果你把这两题放在一起刷,对双指针的理解会更深。
6.2 Hot 100 里值得一起刷的双指针题
既然你已经在刷 Hot 100,我顺便帮你把双指针这个专题的经典题串一下,这样你可以按体系来刷,效果比单打独斗好得多。
| 题目 | 难度 | 双指针特点 |
|---|---|---|
| 11. 盛最多水的容器 | 中等 | 左右端点向中间收缩,谁矮移谁 |
| 15. 三数之和 | 中等 | 先排序,固定一个数,剩余用双指针 |
| 42. 接雨水 | 困难 | 左右指针维护最大值,按列累计存水 |
| 5. 最长回文子串 | 中等 | 中心扩展法,本质是双指针向外扩 |
| 88. 合并两个有序数组 | 简单 | 双指针从后往前,避免覆盖 |
这里的“合并两个有序数组”是双指针从后往前的经典应用,很多人在初学时会忽略这种方向上的变化,但实际面试里很常见。第 15 题三数之和则展示了排序 + 双指针的组合打法。如果把这一串题都刷明白,面试遇到双指针基本不会卡壳。
6.3 从一道题到一类题的思考方法
最后说一个我自己的心得。刷到第 11 题这种“证明比代码更重要”的题目时,不要急着背代码。我建议你按这个顺序来:先想清楚暴力解法为什么慢,再想清楚双指针为什么能减少状态,最后严格证明排除的状态一定不是最优解。当你把这三步都走通之后,代码自然就写出来了,而且不管面试官怎么问,你都能接住。
从我个人经验来看,双指针之所以让很多人觉得难,不是因为代码复杂,而是因为“为什么这么移动”讲不清楚。你在刷题时,每做一道双指针题,都养成“先证明再写码”的习惯,慢慢地你会发现在面对新题时,判断能不能用双指针的直觉也会越来越准。比如看到“有序数组”和“找两数之和”这种关键词,你会下意识想到左右指针;看到“最大容器”这种题,你会想到两端收缩,这就是刷题积累出来的题感。
