LeetCode 热题 100 里有一道很特别的问题:11. 盛最多水的容器。说它特别是因为,代码量短到可以瞬间背下来,但真正能把双指针方案为什么正确讲清楚的人,反而不多。我第一次做这题时老老实实写了双层循环,一提交直接超时;后来照题解把双指针背了下来,面试被追问“为什么移动矮的那根柱子”时当场卡住。这篇笔记想把这道题彻底掰开揉碎讲明白:从暴力解出发,一步步推演到双指针,再把正确性证明、边界情况、提交时踩过的坑全部过一遍。适合刚开始刷题的小白,也适合想在面试中把思路讲得有层次的人。
1. 题面拆解与暴力解法:先搞清楚面积公式
1.1 一句话说清面积公式
题目给你一个非负整数数组height,数组里的每个数字代表一根垂直于 x 轴的柱子高度,下标就是这根柱子的 x 坐标。要你找出两根柱子,让它们和 x 轴围成的区域能盛下的水最多。
这里有个生活常识要先点破:一个容器能装多少水,取决于短板。所以两根柱子 i 和 j 之间的盛水面积不是height[i] * (j - i),而是
S(i, j) = min(height[i], height[j]) * (j - i)高度取两根柱子的较小值,宽度是下标的差。题目要的就是这个S(i, j)的最大值。
原题示例[1,8,6,2,5,4,8,3,7]的答案是 49,对应下标 1 的柱子和下标 8 的柱子,高度取min(8, 7) = 7,宽度是8 - 1 = 7,面积正好是7 * 7 = 49。到这里题目本身没有任何玄机,纯粹是求一堆点对里面积的最大值。
1.2 暴力枚举:最直接的思路和它的上限
最容易想到的方案就是枚举所有两根柱子的组合。两层循环,外层固定左柱子 i,内层枚举右侧所有柱子 j,计算面积并更新最大值。
def max_area_bruteforce(height): n = len(height) ans = 0 for i in range(n): for j in range(i + 1, n): area = min(height[i], height[j]) * (j - i) ans = max(ans, area) return ans这段代码逻辑完全正确,小规模用例也能得到正确答案。但问题是它的时间复杂度是 O(n^2)。当 n 达到 10^5 级别时,总比较次数在 10^10 左右,现代计算机跑这个数量级的纯循环也有明显压力。LeetCode 的判定数据不会让你用暴力解法轻松通过,它存在的意义,恰恰是让你意识到“必须优化”。
遇到这类求最值的问题,第一步先把暴力解写出来,不是为了提交,而是为了确认自己对题面的理解没有偏差。暴力解是很好的“对照答案”,后面写出高效解法后,可以用小数据量跑一遍,两个结果对得上才放心。
1.3 为什么要优化:数据规模带来的真实压力
原题约束里 n 最大到 10^5,这意味着任何 O(n^2) 的做法都会在大数据上超时。面试里聊这题,通常也默认你要给出 O(n) 或 O(n log n) 级别的方案。
O(n log n) 当然也能接受,但这道题更漂亮的解法是 O(n),而且只需要 O(1) 额外空间,也就是常说的“双指针”。
为什么能用双指针?核心在于面积公式存在单调性特征:向内移动指针会让底边变短,除非高度变大到足以补偿,否则面积不会增加。这个特征给了我们用“排除法”不断缩小搜索范围的可能。
2. 双指针优化思路:为什么每次移动矮的那根
2.1 从“底边变短”想到“短板翻盘”
假设数组长度是 n,初始情况我们让一个指针 left 指向最左边下标 0,另一个指针 right 指向最右边下标 n - 1。此时底边是最长的,也就是right - left最大。
如果从这个状态向内移动指针,无论移动哪边,底边right - left都会变小。所以想让面积比当前值大,唯一的希望是“高度”涨上来。但高度是取两个端点的较小值,真正决定高度的是那个矮的端点。
这里就有个直觉判断:既然短板决定了高度,那我把长板往内挪,高度不会变高;把短板往内挪,新端点可能比原来高,面积才有机会变大。
这个直觉是对的,但面试官不会只听直觉,所以要把数学推导补上。
2.2 核心推导:为什么敢于丢弃矮的一侧
我们分析一种情况:当前 left = i,right = j,且height[i] < height[j],也就是左端点矮。
先计算当前面积:
S(i, j) = height[i] * (j - i)对于任意一个同样以 i 作为左端点、右端点 k 落在 i 和 j 之间的组合,面积是:
S(i, k) = min(height[i], height[k]) * (k - i)因为min(height[i], height[k]) <= height[i],同时k - i < j - i,所以一定有:
S(i, k) < S(i, j)这说明什么?说明只要左端点还是 i,它和内侧任意右端点组合出来的面积,都不可能超过当前(i, j)这个组合。既然当前位置已经算出了“以 i 为左端点的最大可能面积”,而且这个面积已经被记录到答案里,那么之后左端点 i 就再也没有保留价值了。可以放心地把 left 向右移动一位,丢弃 i。
反过来,如果height[i] > height[j],也就是右端点矮,那么固定右端点 j 时,任意内侧左端点 k 与 j 的组合面积都小于当前面积,于是可以把 right 向左移动一位。
这个推导就是双指针方案的核心。它不是在猜,而是通过严格的数学关系说明:每一次移动,都是在删除一批不可能成为最优解的候选区间。
2.3 完整正确性证明:排除法确定不漏解
有人会担心:你这样一次次排除,万一最优解区间在中间某个位置,还没被检查到就被跳过了怎么办?答案是不会。
我们用排除法的思路来理解。算法维护的搜索范围是所有可能成为答案的区间集合。每一步,我们基于当前两个端点的高低关系,证明了某个端点和所有内侧端点组成的区间都不可能是最优解。于是这个端点被删除,搜索范围缩小。
比如height[i] < height[j]时,我们证明的是“以 i 为左端点的所有区间”都无法超过当前区间,因此删除左端点 i 不会伤及还没考虑的最优解。因为任何包含 i 的区间都已经在上一步被证明不如当前区间了,而当前区间的面积已经保存在 ans 里。全局最优解要么是当前区间,要么在剩余区间里,所以删除 i 后仍然覆盖所有可能性。
整个过程不断收缩左右边界,直到两个指针相遇。每一步删除的都是“已排除的候选”,剩下的搜索空间始终包含全局最优解。等到循环结束时,ans 自然就是最大值。
如果两个端点高度相等,即height[i] == height[j],移动哪边都一样,因为无论固定左端点还是固定右端点,都能用同样的不等式证明内侧组合不会超过当前面积。代码里通常写成移动右指针,只是习惯,不是必须。
3. 最终代码实现与编码细节:Python、C++与边界处理
3.1 Python实现:最短路径到正确答案
def maxArea(height): left, right = 0, len(height) - 1 ans = 0 while left < right: h = min(height[left], height[right]) area = h * (right - left) ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1 return ans核心逻辑就一个 while 循环。每次计算当前两根柱子的面积,更新答案,然后比较两个端点的高度,移动较矮一侧的指针。很多初学者最容易写错的地方是把更新 ans 的语句放错位置,或者忙了半天忘记在移动前计算面积,导致结果偏小。
还有一个小细节:if height[left] < height[right]: left += 1 else: right -= 1这段代码在两端高度相等时会移动右指针。前面已经说过,相等时移动哪边都正确,所以这样写没有任何问题。
3.2 边界条件与健壮性处理
LeetCode 原题保证数组长度至少为 2,所以代码里不需要显式判空。但在工程化写法里,函数应该具备自我保护能力。比如传入空数组或者长度为 1 的数组时,直接返回 0 会更合理,因为至少需要两根柱子才能围成容器。
def maxArea(height): if not height or len(height) < 2: return 0 # 后面逻辑不变还有就是全 0 数组的情况,比如[0, 0, 0, 0],任何两根柱子组成的面积都是 0,代码会正常返回 0。这些边界情况在面试的时候主动提一下,是很加分的细节。
循环条件的写法也要注意。while left < right是正确的,因为当 left 和 right 指向同一根柱子时,宽度为 0,没有计算意义。如果写成while left <= right,等于多做一次无效计算,虽然不影响结果,但显得不够严谨。
3.3 C++/Java版本与溢出注意事项
C++ 写法和 Python 基本一致,但有一个值得展开的点:面积计算会不会溢出。
原题约束 n 最大 10^5,height 里单个元素最大 10^4,所以面积理论上限大约是 10^4 * 10^5 = 10^9。这个数值在 C++ 的 int 范围内,因为 int 最大值约 2.1 * 10^9。用 int 提交也不会出事。
但我在实际刷题时更倾向于用 long long 保存中间结果,原因是很多变体题会放宽数据范围。比如 n 放大到 10^6,面积就可能轻松超过 int 上限。用 64 位变量写出来的代码,即使题目数据变化,也不会留下隐患。
class Solution { public: int maxArea(vector<int>& height) { int left = 0, right = height.size() - 1; long long ans = 0; while (left < right) { long long h = min(height[left], height[right]); long long area = h * (right - left); if (area > ans) ans = area; if (height[left] < height[right]) { ++left; } else { --right; } } return (int)ans; } };Java 版本同理,用 long 保存 ans,最后强转 int 返回。LeetCode 的判题系统不会因为你用 long 而扣分,反而显得更有经验。
4. 实战排坑:这几个错误提交我全都踩过
4.1 高频错误对照表
我当年做这道题时,前几次提交无一例外都挂在细节上。这里整理一份高频错误对照表,很多坑没有实际跑过根本想不到。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 答案偏小 | 计算面积放在移动指针之后 | 先算面积,再移动指针 |
| 运行超时 | 用了 O(n^2) 暴力双层循环 | 改用双指针 O(n) 解法 |
| 返回 0 | 循环写成 while left <= right,最后 left 和 right 相等时宽度为 0 | 改用 while left < right |
| 面积结果溢出 | 高度乘以距离超过 int 上限 | 中间变量用 long long / long |
| 数组为空时崩溃 | 没有判空,直接访问 height[0] | 函数开头加空数组判断 |
| 结果不稳定 | 两端高度相等时移动指针逻辑混乱 | 记住相等时移动任意一边均可 |
看起来都是小问题,但面试现场时间紧张,很容易在这种地方翻车。我的习惯是写完代码后,先用一个示例手推一遍流程,确认每一步指针和 ans 的变化,再提交。
4.2 手推几个边界用例
拿示例数组[1,8,6,2,5,4,8,3,7]手推:
初始 left=0,right=8,height[0]=1,height[8]=7,面积1 * 8 = 8。左边矮,left 移到 1。
此时 left=1,right=8,height[1]=8,height[8]=7,面积7 * 7 = 49,更新 ans=49。右边矮,right 移到 7。
之后无论指针怎么移动,宽度都在变小,面积很难再超过 49。最终返回 49,与题目一致。
再看几个特殊用例:
[1,1]:面积是1 * 1 = 1,代码正常返回 1。[1,2,1]:left=0,right=2,面积1 * 2 = 2,左矮,left 移到 1,left==right 退出,返回 2。实际最大也是 2,正确。[2,3,4,5,18,17,6]:最大面积应该出现在下标 1 的 3 和下标 5 的 17 之间,面积3 * 4 = 12?不对,让我重新算:height = [2,3,4,5,18,17,6],最大面积是下标 3 的 5 和下标 5 的 17,宽度 2,高度 5,面积 10;下标 4 的 18 和下标 2 的 4,宽度 2,高度 4,面积 8;两个端点 2 和 6,宽度 6,高度 2,面积 12。所以最大是 12。双指针从两边收拢时会记录到 12,没问题。
手推用例的目的不是一个个背下来,而是验证自己的代码逻辑没有系统性偏差。
4.3 面试现场讲这题的正确姿势
面试遇到这题,不建议上来就甩双指针代码。比较稳的讲解节奏是这样的:
先说暴力做法:枚举所有点对,O(n^2),确认题面理解正确。然后说“我发现了面积公式里的单调性”,解释为什么移动矮的一端是安全的。如果面试官追问,就把前面第 2 节的不等式推导讲清楚。最后给出代码,分析时间复杂度 O(n)、空间复杂度 O(1)。
我自己在面试里吃过亏,当时直接写了最优解,面试官反而问“你怎么知道这样不会漏掉最优解”,我一时答不上来。后来才明白,这题考察的正是你能否证明自己的优化是安全的。能讲明白证明,比光写出代码有价值得多。
5. 从这一题看一类“双指针收缩”问题
5.1 和接雨水的区别到底在哪
很多人刷题时会拿“盛最多水的容器”和“接雨水”对比,因为它们都涉及柱子、下标差和水,看起来很像。但两道题的核心思想有本质区别。
本题求的是“两根柱子围成的容器最大面积”,只有两条边,水量取决于短板乘以底边。接雨水求的是“下雨后能接住多少水”,每根柱子上的水量由它左右两侧最高柱子的较小值决定,需要累加所有凹陷处的积水量。
代码细节上差异也很大。本题双指针移动的是“较矮的那侧”;接雨水的双指针则要维护左侧最大值和右侧最大值,哪边的最大值更矮,就从哪边结算当前位置的水量。如果只背模板,很容易把两题的指针移动逻辑搞混。
5.2 三数之和也是同一套路
双指针收缩的经典套路还有“三数之和”。那道题先对数组排序,然后固定一个数,剩下的区间用双指针寻找两数之和。指针移动的依据是当前和与目标值的大小关系,和大于目标值就右指针左移,和小于目标值就左指针右移。
和盛水容器这题放在一起看,会发现共同套路:利用数据本身的顺序或单调性,每一步都能排除一批不可能的解,从而把暴力枚举的 O(n^2) 甚至 O(n^3) 降到 O(n) 或 O(n^2)。
所以刷题时不要孤立地背某一道题,而是把“双指针收缩”当成一类工具。遇到能用排除法缩小搜索范围的问题时,优先思考这个工具是否适用。
5.3 什么信号提醒你“该用双指针了”
根据我刷题的经验,出现以下特征时通常可以考虑双指针:
- 问题涉及数组或字符串,并且要求连续区间内的某种属性
- 暴力解法是 O(n^2) 的区间枚举
- 区间端点向内收缩时,计算结果存在单调性
- 要求空间复杂度 O(1),不能用哈希表或额外数组
盛水容器完美命中这些特征:区间是两根柱子围成的范围,暴力枚举所有点对,移动矮侧指针有严格的数学保证,额外空间只需要两个变量。
从这道题开始理解双指针,再迁移到接雨水、三数之和、最长回文子串等问题,会比刷十道题但每道只看题解有效得多。
我自己反复做这道题至少三遍。第一遍背代码,第二遍推证明,第三遍才开始真正理解“移动矮的”背后的排除逻辑。现在再遇到类似的双指针题目,我会先问自己:这一步排除的候选集到底是什么?它为什么不可能成为最优解?想明白这个问题,代码反而只是顺手写出来的东西。
如果你也在这题上卡过,或者面试时被“为什么不会漏”问住,希望这篇笔记能帮你把这层窗户纸捅破。算法学习有时候真的不是看多少遍题解的事,而是需要在某个瞬间,把每个不等式都自己推一遍。