盛最多水的容器:双指针高效解法与算法面试必学思路 1. 说真的这道题我第一次也卡了很久如果你在刷力扣或者刷别的算法平台这道盛最多水的容器基本是绕不过去的。它排在第97位但热度一直很高属于那种——看完题目觉得很简单动手一写却不小心写了个三层循环的人一抓一大把。我第一次做它的时候脑子里首先蹦出来的想法就是暴力枚举两两配对算面积取最大值。听起来很合理对吧结果一提交数据规模一大直接超时。然后我就开始琢磨这里面一定藏着某种更好的规律。先简单说一下题目本身给定一个长度为 n 的整数数组 height数组中的每个数字代表一根垂直于 x 轴的柱子的高度下标 i 和 j 之间的距离就是容器的宽度盛水的容量由较矮的那根柱子高度决定。也就是说面积 两根柱子的下标差 × 两根柱子高度的较小值。问的是任意选两根柱子最多能装多少水。这个较矮的那根决定水量其实才是最核心的约束也是双指针解法能够成立的根源。这篇文章我会把这道题从思路、证明、代码到扩展全部拆开来讲。不管你现在是刚开始刷题的新手还是已经刷了一段但遇到瓶颈想突破的人应该都能找到有用的东西。我也会把很多刷题攻略里不会明确写出来的思考过程和易错点摊开讲尽量让你不仅会写这段代码还知道它为什么必须这么写。2. 为什么暴力解法看着合理实际却走不通2.1 暴力解法的思路与真实成本暴力解法的思路确实很直白固定一个左指针 i让右指针 j 从 i1 一直扫到数组末尾每扫一个位置就计算一次 area (j - i) * min(height[i], height[j])用一个全局最大值不断更新。两层循环嵌套所有组合都覆盖到最后返回最大值。这个写法一定是对的而且代码非常短。但它的问题也很明显时间复杂度是 O(n²)。当 n 是 100、1000 的时候还好但题目给出的数组长度可以达到 10 的 5 次方级别也就是十万元素。十万元的平方就是一亿这个数量级在大多数评测环境里已经处于超时边缘如果再严谨点说两两配对的总数是约 n²/2 次计算对于 10^5 的规模这就接近 50 亿次操作了哪怕每个操作只是简单的数学运算一样会被卡死。一个很形象的类比暴力法就像是把班里所有人都两两握手一遍看谁和谁最默契。人少没问题一旦上百人上千人这个握手次数就爆掉了。2.2 暴力解法留下的关键线索暴力解法虽然效率低但它给了我们很重要的线索面积由宽度和高度两个因素共同决定。当你固定一个端点另一个端点慢慢移动时宽度是一直在缩小的。想要让面积变大唯一的可能就是新的较矮柱子比之前更高而且高出来的部分足以弥补宽度缩小带来的损失。这个观察在双指针解法里起到了决定性的作用。双指针法的每一步其实都蕴含了一个选择是保留较矮的那根还是保留较高的那根要回答这个问题我们得先弄明白一个数学上的基本事实。如果当前左指针指向的柱子高度 h[left] 小于右指针指向的柱子高度 h[right]那么无论右指针再怎么向左移动以左指针为左边界的容器的高度上限永远是 h[left]不可能更高了。因为容器高度是由较矮的柱子决定的左柱子既然矮它就一直起着瓶颈作用。而右指针向左移动意味着宽度在不断变小一个高度永远被 h[left] 卡住、宽度还在不断缩小的容器面积只会越来越小根本不可能超过当前以 h[left] 和 h[right] 形成的面积。换句话说当左柱子比右柱子矮的时候你继续往右移右指针或者说右柱子向左收其实是在用一个不可能更高的瓶颈去换一个不断变小的底边这笔买卖怎么算都不划算。既然左柱子作为边界已经尽力了它在当前情况下可形成的最大容器面积就是这一时刻的面积那最优解一定不在保留左柱子的方案里。所以干脆把左指针往右移动换一个更高的左柱子来试试。这个逻辑就是双指针法每次移动较矮的那一边的理论依据。很多人第一次看题解的时候会困惑为什么不是移动较高的那边或者为什么不能两边都试着动原因就是上面这句话——较矮的那根柱子已经不可能作为更高的瓶颈了它的潜力在当前组合下已经用尽留着它只会白白浪费宽度。2.3 双指针法的整体框架有了上面的逻辑双指针法的步骤就非常清晰了初始化 left 0right n - 1记录当前面积并维护最大值。在 left right 的循环里每次比较 height[left] 和 height[right]谁矮就先移动谁的指针同时计算新的宽度和面积更新最大值。直到两个指针相遇循环结束。时间复杂度 O(n)空间复杂度 O(1)。这段代码写出来大概十行左右但它背后的推理过程才是重点。面试官往往不会只满足于你把代码背出来他们更想听你解释为什么移动矮的都是安全的为什么这个解法不会漏掉真正的答案能够把这个问题讲清楚比手速快不快重要得多。3. 核心代码实现与普通解法没告诉你的细节3.1 一份可以直接抄的 Python 实现我们先看一个最常规的 Python 实现写法简洁但信息量足。def maxArea(height): left, right 0, len(height) - 1 max_water 0 while left right: w right - left h min(height[left], height[right]) max_water max(max_water, w * h) if height[left] height[right]: left 1 else: right - 1 return max_water这个版本我见过很多地方都贴过但它有两点值得特别注意。第一else 分支里用的是 right - 1而不是把移动较高的那条分支写成 else if。为什么可以这样因为当 height[left] 和 height[right] 相等的时候随便移动哪一边都是安全的。想一想两根柱子一样高时无论移动哪一边当前容器面积已经是这个宽度下以这个高度能达到的最大值了。宽度继续缩小即使新的柱子更高面积也不一定更大但至少不会漏掉最优解。因此直接让右指针移动或者左指针移动效果都一样。把它写在 else 里既简洁又不会出错。第二计算面积时用的是 w * h也就是宽度乘以当前组合中的较矮高度。这个较矮高度是关键的中间变量最好不要为了省一行代码直接写成 min(height[left], height[right]) * (right - left)那样虽然结果一样但阅读代码的人会多一步换算。题解代码不仅是给机器看的更是给人看的把 h 单独命名出来思路更清晰。3.2 各语言实现差异与性能观察用 C 写的时候我通常会写成这样int maxArea(vectorint height) { int left 0, right height.size() - 1; int ans 0; while (left right) { int w right - left; int h min(height[left], height[right]); ans max(ans, w * h); if (height[left] height[right]) { left; } else { --right; } } return ans; }C 版本几乎和 Python 完全镜像但要注意 height.size() 返回的是无符号整数如果不先转成 int 直接赋值给 right当容器为空或只有一个元素时会有无符号回绕的隐患。牛客或者力扣的测试用例虽然很少给空数组但自己写测试时还是要养成先判空或者用显式转换的习惯。Java 版本的写法和 C 类似需要注意的也只是 int 的溢出问题——height[i] 和下标差相乘时理论上最大能达到 10^4 × 10^5 这个量级还在 int 范围内所以不用担心溢出。但如果哪天题目改成 height 范围更大那就要考虑用 long 了。我在实际测试中发现对于中等规模十万个元素左右的数据Python 版本运行时间大概在几十毫秒C 则通常是亚毫秒级。两者的思路完全一样性能差异主要是语言运行环境带来的所以并不代表某个版本更适合面试。只要你能在白板上正确写出逻辑用什么语言都没问题。3.3 容易被忽略的边界测试用例很多人在刷题时只关注主流程跑通边界情况常常懒得测。但一道经典题能不能拿高分往往就看你有没有考虑这些容易被忽略的角落。第一个值得注意的是 n 2 的情况。此时左右指针一开始就指向唯二的两根柱子循环只执行一次答案就是这两根柱子的容量。代码自然能正确处理但如果你没有意识到这个场景的存在写代码时可能就会在循环条件上犯错比如无意中写成 left right指针最后会交错虽然大部分情况不会死循环但已经不是一个严谨的实现了。第二个值得注意的是数组中存在高度为 0 的柱子。0 高度的柱子意味着当前组合的容量是 0无论宽度多大都没有意义。双指针法在这里依然有效因为逻辑会自动跳过劣势组合比如较矮那边是 0移动的必然是它。第三个值得注意的是所有柱子高度都相等的情况。比如数组 [3, 3, 3, 3, 3]任意两根柱子的容积都等于下标差乘以 3。双指针法从左到右收缩每一步的面积都记一下最后停在中间答案一定能取到最大值。这种情况看起来很平淡但它能帮你验证移动相等高度时随便动哪边都不影响最终结果的结论。我建议在实际提交之前自己构造这几个测试用例输入期望输出说明[1, 1]1最小可计算的场景[1, 8, 6, 2, 5, 4, 8, 3, 7]49经典例子左柱高 8 右柱高 7[0, 0, 0]0全零数组[1, 2, 3, 4, 3, 2, 1]9先升后降的山形[2, 3, 4, 5, 6, 7]15单调递增右边界收益递减把这些用例跑一遍如果你的代码输出都正确那基本可以放心提交了。4. 这道题在庞大题库坐标里的位置与常见变体4.1 从容器到接雨水千万别混淆力扣上有一道同样高频的题叫做接雨水两者名字里都带水主题也都涉及柱子高度和面积但解法逻辑完全不同。很多初学者把它们混在一起记忆结果做这题时用了接雨水的单调栈思路做接雨水时又用双指针扫描最大面积思路两边都写不对。它们的本质区别在于盛最多水的容器只关注两根柱子围成的矩形区域水面的高度取决于这两根柱子的较矮者内部其他柱子完全不影响而接雨水关注的是整个柱子之间凹陷部分的累计水量每一格能存多少水取决于左右两侧更高的墙以及当前柱子的高度差。一句话记忆法容器题看的是边界的两根接雨水看的是缝隙间的每一格。容器题的解法核心是双指针从外往内收缩接雨水的常见解法有三种暴力逐格计算、单调栈、双指针前后缀最大。它们的思维起点天差地别如果把两道题理解成一个套路那就会浪费很多时间去踩本来不用踩的坑。4.2 与三数之和的联系双指针家族的共同基因如果你刷过三数之和这道题会发现它也用到了双指针思想先排序固定一个数然后用左右指针在剩余部分双向移动。和容器题一样它们都利用了某种单调性来避免无效枚举。容器题的单调性是较矮的边是瓶颈移动它才有机会提高高度三数之和的单调性是排序后左指针右移让和变大右指针左移让和变小根据当前和与目标值的大小关系决定移动方向。理解了这一点你就明白双指针不是一个孤立的算法技巧而是一类问题共享的分析框架。以后再遇到类似题第一反应不应该是套模板而是先问自己移动这个指针是会让某个量单调上升还是单调下降如果能回答上来你离写出正确解法就不远了。我见过不少朋友刷题时步履维艰原因不是记不住模板而是不知道模板背后的理由。所以我在写题解时一直强调为什么因为只有当你真正想通一个算法为什么正确遇到变体才不会被表面迷惑。4.3 这道题在面试中的考察形态在真正的面试场景里盛最多水的容器很少会被原封不动地抛出来。它更常见的出现方式是两个方向的变形。第一个方向是数据规模的放大或缩小。比如数组中可能出现负数高度在某些题目的变体中柱子高度允许为负但容器高度依然是较矮者与 0 的较大值。这种情况会让题目复杂不少因为负值会直接让你的容器高度计算逻辑改变。如果遇到这种改编题你要做的不只是套双指针而是先分析新的高度定义是否还保持单调性。第二个方向是输出结果的改变。有些改版不只是求最大面积而是要求输出对应的两个下标或者当存在多个相同最大面积时输出所有可能的组合。这种情况下双指针主框架仍然适用但你需要额外记录满足条件时的指针位置并且在指针移动时注意处理相等高度可能产生的多个解。面试官还可能追问一个很有意思的问题如果数组极大但内存有限怎么办这其实是在考察你是否理解双指针法只需要 O(1) 额外空间而暴力法的 O(n²) 枚举不仅时间慢有些场景下还会因为内存问题崩溃。双指针法的空间优势在这里同样非常明显。5. 证明过程为什么双指针不会漏掉最优解5.1 形式化但好懂的推理我们来把这个关键证明讲透。假设当前左指针 L 指向高度 a右指针 R 指向高度 b且 a b。当前容器的面积为 (R - L) × a。现在如果我把右指针向左移动一格指向 R R - 1。新的容器的宽度是 (R - L)比原来小 1。新容器的高度是 min(a, height[R])由于 a 比原来的 b 小而任何新右柱子的高度与 a 比较时min(a, height[R]) 一定小于等于 a。这样的话新面积 (R - L) × min(a, height[R]) ≤ (R - L) × a (R - L) × a。也就是说任何把右指针向左移动得到的组合面积都不可能比当前组合更大。换句话说当前组合在以 a 作为左边界的所有组合里已经是最大面积了。左边界 a 的最优解已经拿到手现在往右移动 L抛弃这根矮柱子完全不会丢掉全局最优解。同理如果 b a那就向左移动 R抛弃右边界这根矮柱子。如果 a b移动哪边都一样因为两边的潜力都已经在当前组合中兑现了。把这段证明反复推几遍你自己也可以试着走出来。一旦想通这道题对你来说就不再是记忆型题目而是真正变成了一道推导型题目。5.2 一个具体的模拟过程我拿一个简单的数组来走一遍height [1, 8, 6, 2, 5, 4, 8, 3, 7]这是题目自带的一个经典例子。初始 left 0right 8高度分别是 1 和 7面积 8 × 1 8。因为左边更矮left 右移。left 指向 1 位置的高度 8right 指向 8 位置的高度 7。面积 7 × 7 49。接下来 left 位置的高度 8 高于 right 位置的高度 7right 左移。right 指向 7 位置的高度 3面积 (7-1) × 3 18。没超过 49。继续比较left 位置高度 8 大于 right 位置高度 3right 左移。right 指向 6 位置的高度 8此时 left 和 right 的高度都是 8面积 (6-1) × 8 40还是没超过 49。接下来 height[left] 等于 height[right]随便移动一边。移动 left 后指向 3 位置的高度 2面积 (6-3) × 2 6。没超过。继续移动较矮的一边left 移到 4 位置高度 5面积 (6-4) × 5 10。继续left 移到 5 位置高度 4面积 (6-5) × 4 4。最后 left 和 right 相遇返回 49。这个例子里最优组合恰好是最左边的高度 8 和最后边的高度 7最终答案 49 与标准答案一致。这个模拟过程说明了一个事实双指针并不是每一步都看一眼所有可能的组合而是通过丢弃不可能成为最优解的边快速缩小范围最终在有限步数内收敛到结果。每一步虽然只移动一个指针但其实我们是在剪枝剪掉了一个很大的搜索区域。5.3 关于贪心与双指针的边界讨论有些资料会把双指针法描述为一种贪心策略因为每一步都做出一个局部最优决策移动较矮的一边试图获得更高的高度。但和经典的贪心算法如区间调度、背包问题的贪心近似不同双指针法在这里是精确算法不是近似算法。它不会漏掉最优解原因就在于我们前面的证明。这也是算法的名字里没有贪心二字的原因——它的正确性不是依赖某种启发式直觉而是基于严格的数学推导。具体到代码实现层面这个精确性就体现为无论数据分布如何双指针法最后得到的答案永远和暴力枚举一致。我自己在做随机数组的大规模测试时用双指针法的结果和暴力结果对比过很多次从未出现过偏差。如果你也想验证可以写一段随机数据测试脚本把暴力法和双指针法跑上几百组然后比对输出这比做任何证明都更能让你安心。6. 我踩过的几个坑与最终经验总结6.1 从以为懂了到真懂的落差我第一次接触这道题时看题解感觉自己全都看懂了逻辑也顺畅代码抄下来提交也通过了。但过了两天再让我独立写一遍竟然卡住了。怎么想都想不明白为什么移动矮的那边只能把代码默写下来。后来我才意识到光看别人证明的经验是不可靠的我必须自己动手推演一遍或者至少手动模拟几组不同的输入才能真正理解每一步的理由。所以这里我想给所有刷题的朋友一个建议刷题不是背例题而是练思维方式。建议每做完一道值得反复品味的题隔三到五天再不看题解独立做一遍。如果第二次还能又快又对地写出来才说明它在你脑子里真正留住了。盛最多水的容器这道题非常适合作为这种自我检验的题。6.2 容易按摩出错逆逻辑的细节在实际编码时我见过不少朋友在这几个位置出错。第一个是循环条件。left right 和 left right 虽然只差一个等号但后者会让左右指针在相等时再进入一次循环此时宽度为 0面积计算也为 0虽然不影响最终最大值但会让人误以为有问题还白多一次运算。严谨写 left right 就好。第二个是 else 分支问题。当 height[left] height[right] 时有人会写两个独立的 ifif left_height right_height 移动左指针if right_height left_height 移动右指针。这样看起来清晰但必须确保没有漏掉相等的情况否则相等时会陷入死循环。最稳妥的写法是 if ... else ...把相等的情况归入 else 分支移动任意一边即可。第三个是在编写测试脚本时的数据生成问题。有些人会直接生成包含负数高度的随机数组来测双指针法导致输出和预期不一致以为算法写错了。实际上容器题的高度一般是非负整数数组随机测试时也要保证数据范围正确不然只会给自己添堵。6.3 这道题带给我最大的收获如果把所有关于这道题的记忆浓缩成一句话我会说瓶颈决定了上限而移动瓶颈是破局的方向。这个结论不仅适用于算法题也适用于很多工程问题的排查当系统吞吐量上不去时先找到那个最慢的环节而不是优化已经很快的模块。这和双指针法移动较矮的一边本质上是同一种思维方式。回到代码本身这道题还有一个好处它足够短短到你可以在任何地方手写出来很适合作为面试开场题或者热身题。如果你能把它的原理讲清楚甚至能在白板上即兴推导一遍面试官通常会对你的基本功留下不错的印象。最后再分享一个小技巧我习惯随身带一个小笔记本上面记下自己觉得想明白了但过几天又可能忘的题目。盛最多水的容器就排在比较靠前的位置。每过一两周翻看一次每次都重新在脑子里推一遍移动矮边的理由直到形成肌肉记忆。这种方法对长期刷题的朋友很管用至少对我来说真正让我在刷题路上稳定进步的不是刷了多少题而是隔段时间就回头反问自己这道题到底为什么这样写我真的还能完整回答吗如果你也想突破刷题瓶颈不妨从这道题开始试试。