力扣热题100里的“盛最多水的容器”,大概是我见过的双指针题目里最优雅的一道。它没有复杂的递归,没有晦涩的动规,最终通过的代码撑死也不超过15行,但就这不到15行的逻辑,当年让我对着屏幕愣了好几分钟——为什么要移动那根矮的柱子?这题适合两类人看:一类是刚开始刷题、想搞明白双指针到底是怎么被“想出来”的人;另一类是面试前想梳理高频题、并且准备好应对面试官连环追问的人。无论你是哪一类,这篇文章都会把暴力解法、双指针原理、正确性证明、代码细节、常见坑和同类题一次性讲透。
1. 题目还原:先想清楚我们在算什么
1.1 题面与示例
原题描述非常短:给你一个非负整数数组height,每个元素代表一个垂直于 x 轴的柱子的高度,下标代表柱子的位置。请你找出两个柱子,使得它们与 x 轴共同构成的容器能容纳最多的水。
换句话说,任选两个下标i和j(i < j),容器的底边长度是j - i,容器的高度由两个柱子中较短的那根决定,所以容积公式是:
min(height[i], height[j]) * (j - i)题目要你求这个式子的最大值。
官方示例是height = [1,8,6,2,5,4,8,3,7],答案是 49。这个 49 来自下标 1 的高度 8 和下标 8 的高度 7:min(8,7) * (8-1) = 7 * 7 = 49。
很多新手上来会以为答案是下标 1 和下标 6 这两个 8 之间的组合,min(8,8) * 5 = 40,其实不是最大的。这个范例选得挺巧妙,它能直接打掉“哪两根最高就选哪两根”的直觉。
1.2 容易踩的直觉误区
第一反应多半是暴力枚举,这个没错,但如果你以为这题只是“把所有组合算一遍”,那你还没有摸到这道题真正想考的东西。它的数据范围是n <= 10^5,暴力是 O(n^2),等数组长到几万,在线评测系统直接给你超时。
这题还有一个更隐蔽的误区:有人会想,能不能固定一个最高点,然后左右扩展?但容器高度受制于较矮的边,最高点不一定能带来最大面积;甚至面积最大的时候,两根柱子可能都不是全局最高的。所以任何“只看高度”的策略在这里都会翻车。
注意:这道题本质上是在最大化一个由二元组决定的乘积,而且乘积的两个因子——高度和宽度——存在拉扯关系:宽度越大,高度往往越小。你要找的是两者乘积最大的平衡点,而不是单纯的高或者单纯的宽。
2. 暴力解法:先写出人人都会的版本
2.1 暴力代码与思路
暴力解法没什么技术含量,就是两层循环枚举所有i < j的组合,逐个计算面积并更新最大值。
def max_area_brute(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这段代码在数组长度只有几十的时候完全没问题,逻辑也直白:把所有可能的情况都看一遍,答案必然在其中。
2.2 复杂度与教训
暴力做法的时间复杂度是 O(n^2),空间复杂度是 O(1)。当n = 10^5时,组合数接近 50 亿,任何在线评测环境都跑不动。我最早刷这道题时,第一版就交了暴力解法,结果预期的超时提示如期而至。
不过暴力解法的意义在于它提供了一个“基准答案生成器”。当你后面写出双指针解法时,完全可以用小规模的随机数组,把两种结果互相印证,确认双指针版本没有算错。这个思路在刷题里很实用:先用暴力解法当测试基准,再优化,能少走很多弯路。
实操心得:如果你在面试中一时想不出最优解,先给暴力解法,再把复杂度分析清楚,然后自然过渡到更优方案,面试官通常不会觉得你菜。最怕的是你暴力解法都不写,干坐着憋大招。
3. 双指针方案:为什么移动矮的一边
3.1 移动策略与直觉
双指针解法的代码极短,但它的核心只有一句话:用两个指针分别指向数组的最左和最右,每次计算当前面积并更新答案,然后把高度较小的那根柱子对应的指针向中间移动一步,重复直到两个指针相遇。
用生活里最亲密的例子来理解:想象你手里有一块木板,左右各有一个支架,木板能放多高的水取决于两个支架中较矮的那个。现在两个支架之间距离很大,但有一只支架很矮,水的高度被它压死了。你想让容积变大,唯一可能的方向是换掉这只矮支架——因为如果你换掉高的那只,新的支架只会更矮或持平,而两个支架之间的距离还在缩小,容积怎么可能变大呢?
所以每走一步,我们都“放弃”当前较矮的柱子,把赌注押在“另一侧可能存在更高的柱子”上。
3.2 正确性证明:这不是蒙对的,是剪枝剪出来的
有人会问:你每次只移动矮指针,凭什么保证丢掉的那一堆组合里没有最优解?这就需要用反证法做一次严格的证明,也是面试官最喜欢追问的点。
假设最优解对应的两根柱子下标是(i, j),其中i < j。算法从两端向中间逼近,那么在某个时刻,必然出现下面两种情况之一:
情况一:左指针已经移动到i,右指针还在j的右边,设它是r,即left = i, right = r > j。
如果此时height[i] <= height[r],那么当前左指针和右指针形成的容器面积:
min(height[i], height[r]) * (r - i) = height[i] * (r - i)因为r > j,所以(r - i) > (j - i),又因为min(height[i], height[j]) <= height[i],我们可以推出:
height[i] * (r - i) > height[i] * (j - i) >= min(height[i], height[j]) * (j - i)右边正好是最优解的面积。这就说明在left = i, right = r的那一刻,我们已经找到了一个比所谓“最优解”更大的面积,产生了矛盾。所以不可能出现height[i] <= height[r]。于是必然有height[i] > height[r],也就是说当前右指针是较矮的一边,算法会移动右指针,让它一步步向左靠近j,从而最终走到最优解上。
情况二:右指针先到达j,而左指针还在i的左边,证明过程完全对称,结论也一致。
这个证明想说明的核心是:在每一轮移动中,我们丢掉的那些候选解,要么已经被当前解覆盖,要么严格劣于当前解。双指针之所以能用 O(n) 搞定,是因为它用一次反证法把 O(n^2) 的组合搜索空间安全地剪掉了。
注意:这个证明有一个关键边界条件——如果两个指针高度相等,移动哪边都可以。因为此时两边的“矮边”高度相同,无论移动哪边,都不会把更优的组合漏掉。但要小心:代码里不能用
if (h[left] < h[right]) left++; else right--;吗?可以,相等时默认移动右指针,这也是安全的。
3.3 高度相等的特殊处理
当height[left]等于height[right]时,很多实现都习惯把右指针往左移,有些实现则喜欢左边右移、右边左移同时进行。其实移动单边就足够了,因为容器的高度被这个相同的高度限制住,宽度在缩小,所以无论保留哪一边继续向内探索,都不可能在这一步产生更大的面积,接下来需要看的是中间区域有没有更高的柱子。移动单边的差异只影响遍历路径,不影响最终答案。
在实际代码里,我推荐写成:
if height[left] < height[right]: left += 1 else: right -= 1高度相等时走 else 分支右移一位,逻辑简单,也不容易写错。
4. 代码实现与复杂度分析
4.1 Python 版本
def max_area(height): left, right = 0, len(height) - 1 ans = 0 while left < right: h = min(height[left], height[right]) ans = max(ans, h * (right - left)) if height[left] < height[right]: left += 1 else: right -= 1 return ans这段代码的核心就是 while 循环里的三步:计算面积、更新答案、移动矮边。每次循环只做常数次操作,循环最多执行n轮。
4.2 Java 版本
public int maxArea(int[] height) { int left = 0, right = height.length - 1; int ans = 0; while (left < right) { int h = Math.min(height[left], height[right]); ans = Math.max(ans, h * (right - left)); if (height[left] < height[right]) { left++; } else { right--; } } return ans; }Java 版和 Python 版在逻辑上完全等价。唯一的细节差异是:数组长度为 0 或 1 时,while 条件不成立,直接返回 0,这个行为恰好符合题意——只有一根柱子根本装不了水。
4.3 复杂度与对比
双指针版本的时间复杂度是 O(n),空间复杂度是 O(1)。
| 解法 | 时间复杂度 | 空间复杂度 | 适用范围 |
|---|---|---|---|
| 暴力枚举 | O(n^2) | O(1) | n <= 1000,仅用于验证 |
| 双指针 | O(n) | O(1) | n <= 10^5,面试标准解 |
我实际跑过一组对比数据:数组长度 10 万,元素随机生成。暴力解法在本地环境跑到一半就被我手动终止了,因为按每秒 10^8 次简单运算估算,它需要 5000 秒以上。双指针版本在同一台机器上运行时间不到 1 毫秒。差距就是这么悬殊。
实操心得:写代码时先把 area 算出来再移动指针,顺序反了会导致漏算初始状态的最大值。比如两个指针一开始就形成了最大面积,结果你先移动了指针,就把这个答案漏掉了。最稳妥的写法是先算、再比、最后移动。
5. 常见错误与调试技巧
5.1 四类高频错误
这道题代码量小,但错误率并不低。我见过最多的是下面这几种:
| 错误类型 | 典型写法 | 后果 |
|---|---|---|
| 先移动再算面积 | 循环开头先改指针 | 漏掉初始状态或某些组合 |
| 移动了高指针 | if (left < right) left++ | 直接错过最优解 |
| while 条件多等号 | while (left <= right) | 两指针重叠时多算一次零面积,影响不大但没必要 |
| 宽度计算错误 | right - left + 1 | 容器宽度多算 1,答案错误 |
特别说一下宽度的问题。很多从“区间长度”习惯过来的人会写成right - left + 1,但这里的两根柱子只是立在数轴上,中间的水域宽度等于下标之差,而不是闭区间元素个数。比如下标 0 和下标 3,容器底边长度是 3 而不是 4。这个细节很容易被忽略,但一错就是整体答案全错。
5.2 调试方法
如果你发现答案不对,最快的排查方式是在循环里打印每一步的状态:
while left < right: h = min(height[left], height[right]) area = h * (right - left) print(f"left={left}, right={right}, h={h}, area={area}, ans={ans}") ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1打印出来的每一行,你都可以手动核验一次:面积计算对不对?指针移动的方向是不是指向矮边?对比一下最终答案和暴力解法跑出来的结果。两个解法对拍,是刷题验证正确性的最硬核手段。我建议所有人在初学阶段养成“用暴力结果验证最优解”的习惯,遇到再复杂的题,这个方法都能帮你快速定位逻辑错误。
注意:如果输入里有大数,
height[left] * (right - left)的结果在 C++ 里可能超过 int 范围,建议声明为 long。虽然这题数据范围通常不会触发溢出,但健壮性在面试中永远是加分项。
6. 举一反三:双指针的模式识别与同类题
6.1 对撞双指针的适用条件
这道题解出来不是终点,“盛最多水的容器”只是双指针家族的一个代表。你需要从它身上抽象出一种可复用的思维模式:当问题的答案由两个下标决定,并且两个下标之间有某种单调的物理约束时,就可以考虑从两端用对撞指针向内收缩。
适合对撞双指针的题目通常有三个特征:
- 数据是数组或字符串,答案与两个位置有关;
- 暴力遍历所有二元组是 O(n^2),需要降到 O(n);
- 移动某一个指针时,可以证明某些候选组合不会再成为最优解。
如果你能清晰地说出“为什么排除这些候选”,那你就真正掌握了对撞双指针,而不是背了一套模板。
6.2 同类题与差异点
我知道很多人刷题喜欢按专题来,那这里直接列几个和它高度相关的题目,帮你把双指针的知识网络串起来。
第一道是“接雨水”。同样是数组、同样是柱子、同样用双指针,但接雨水需要对每个位置单独计算能存多少水,而不是只找两个边界,所以它的指针移动策略会更复杂,维护的变量也更多。
第二道是“三数之和”。它用到的思路是排序加双指针,把三个数的问题降维成“固定一个数之后的两个数对撞”,这个套路非常经典,和盛水容器共享同一个“排序 + 对撞”的底层思维。
第三道是“验证回文串”。它也是左右指针夹逼,但它属于更简单的对撞场景——每次只比较两个字符,不需要计算面积,也不需要维护最大值。
从这三道题回看盛最多水的容器,你会发现它真正有价值的训练点有两个:一是证明贪心策略的正确性,二是把 O(n^2) 的枚举空间剪成 O(n) 的线性扫描。面试里能把这两点讲清楚的人,远远比直接默写代码的人更让面试官印象深刻。
我个人在实际刷题中还有一个体会:第一次看这道题时,卡住我的不是双指针这个名词,而是“为什么可以放心地移动矮指针”。我后来把证明过程手写了三遍,再遇到类似的对撞指针题,思路就顺了。建议你也别急着背代码,先花十分钟把第三节那个反证法推一遍,这十分钟花得非常值。