☰
盛最多水的容器:双指针拔河类比与Java实现
2026/10/10 4:15:15 网站建设 项目流程

如果你最近在准备算法面试,或者刷题进度正好走到数组、双指针这一块,那力扣第11题“盛最多水的容器”一定绕不过去。很多人的状态是:看到题面第一反应暴力枚举两根柱子,写完之后提交超时;看完题解倒是会背双指针模板,可面试官追问一句“为什么每次移动矮的那一侧”,当场就卡住。这道题我前后给不少朋友讲过,最后发现最有效的讲法不是堆公式,而是把它想象成一场拔河选队友——谁弱换谁。大脑一旦接受了这个画面,双指针那套思路就基本刻进肌肉记忆了。这篇文章会一步一步把题目建模、拔河类比、严谨证明和完整 Java 代码串一遍,既适合零基础入门,也适合面试前快速回顾关键考点。

1. 先把题目啃明白:盛水的本质是一个公式

1.1 题目到底在说什么:从竖线到容器的数学建模

题目描述很简短:给你一个整数数组height,数组里每个值代表一根垂直于水平线的柱子高度,你可以任意选择两根柱子作为容器的左右壁,问最多能盛多少水。

这里有一个容易误解的点:题目里说的“容器”并不是普通的矩形桶,而是两根柱子之间、以水平线和两根柱子围成的区域。水的容量只受两个因素影响,一个是左右两根柱子的高度中较短的那一个,另一个是两根柱子之间的水平距离。用公式写就是:

容量 = min(height[i], height[j]) × (j - i)

为什么取的是“较矮的柱子”而不是“较高的柱子”?这个其实用生活经验就能理解。两个高度不同的桶拼在一起,水面永远不可能越过矮桶的桶沿,矮桶决定了整个容器的最高水位。数组里的柱子也一样,右边那根再高也没有用,水到了矮柱子的高度就会溢出去,所以真正参与计算的有效高度永远是min。

拿一个经典用例来感受一下:height = [1,8,6,2,5,4,8,3,7]。这个数组的最大盛水量是 49,来自下标 1(高度 8)和下标 8(高度 7)这两根柱子之间,宽度是8 - 1 = 7,有效高度是min(8,7)=7,所以容量是7 × 7 = 49。你可能会好奇,下标 1 和下标 6 都是高度 8,宽度只有 5,算出来只有 40;最远的两根柱子下标 0 和 8 虽然宽度是 8,但高度最小值只有 1,也只有 8。这组对比说明一个关键事实:面积是一个“宽和高做乘法”的博弈,不是单纯越远越大,也不是单纯越高越大。

1.2 暴力解法为什么不行:从 O(n²) 说起

第一次看到这个问题,正常人都会想到暴力。把所有二元组(i, j)都枚举一遍,每个都按公式算一次面积,最后取最大值,逻辑上完全没毛病。

代码也很短,两层循环就搞定:

public int maxArea(int[] height) { int n = height.length; int max = 0; for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { int area = Math.min(height[i], height[j]) * (j - i); max = Math.max(max, area); } } return max; }

但问题在于它太慢了。数组长度为n时,需要比较的组合数量是n × (n - 1) / 2,时间复杂度是 O(n²)。题目给的数组规模一般能到 10⁵ 这个量级,算一下就是约 50 亿次组合,在 OJ 上必然会超时。

不过暴力解也不是完全没用。它的价值在于“正确性对照”。我之前调试双指针解法的时候,会专门写一个暴力方法,再用随机数组把两个方法的结果对拍,确认两边答案一致后才放心提交。这也是一个值得养成的工程习惯:先用笨办法写一个可参考的 baseline,再去优化。

1.3 双指针的大方向是怎么想出来的

既然暴力不行,那就要找更聪明的搜索方式。先回头看看面积公式,它由两个变量控制:一个是min(左高度, 右高度),一个是宽度(j - i)。

宽度有一个很明显的特征:如果我们从数组的两端开始,此时的宽度是全局最大的。之后不管哪个指针向内移动,宽度都只会变小,不会变大。这说明“最大化宽度”这个方向已经到顶了,唯一能让面积继续增长的途径,就是让“有效高度”有机会变大。

所以一个非常自然的策略就出现了:一开始把左右指针放在数组首尾,宽度最大;此后每次向内移动一步,都尽量去“替换掉当前限制高度的那个短板”,让新的短板有可能更高。这个想法本身并不需要看题解,而是从“宽度只能变小,高度必须变大才有希望”这个约束条件里自然推出来的。

这个策略本质上是一种剪枝式的搜索:每一步移动都会排除掉一大片“不可能成为最优解”的组合,从而把 O(n²) 的枚举压缩成 O(n) 的线性扫描。至于为什么排除是安全的,后面第 2 章用拔河类比和数学证明一起讲透。

2. 拔河选队友:一套能解释所有细节的双指针直觉

2.1 拔河和盛水容器,共享同一个“短板规则”

很多人看双指针代码看得很顺,但说不出每一步移动的理由,核心原因是脑子里缺少一个“决策模型”。我这里强烈推荐用“拔河选队友”来建模。

想象一场拔河比赛,每队不是站成一排,而是左右两队各派一名队员上场,站在绳子的两端往外拉。绳子会不会被拉过去,不取决于两队里最强的那个人,而取决于两队整体实力中较弱的一方——更准确地说,取决于当前上场的两个人里,谁是那个“短板”。

容器盛水也一样。左右两根柱子同时站在场上,水的容量不看谁更高,只看谁更矮。左边是 100 米高,右边是 2 米高,那水面最高只能到 2 米。矮的那根柱子,就是当前这个容器的瓶颈,也就是拔河里的“弱队友”。

现在场上站着两个队友,一个有 8 分实力,一个有 3 分实力,组合出来的有效实力是 3 分。如果你想提高整体实力,你该换掉哪一个?只要不是傻子,都知道应该换掉那个 3 分的,而不是把 8 分的换下去。因为 3 分的人还在场上,整体实力永远不可能超过 3 分。这个直觉,就是双指针移动规则的灵魂。

2.2 谁弱换谁:每一步的移动决策

把拔河规则翻译回数组下标。假设当前左指针指向left,右指针指向right,高度分别是height[left]和height[right]。

  • 如果height[left] < height[right],说明左边这根柱子更矮,是短板。
  • 如果height[left] > height[right],说明右边这根柱子更矮,是短板。
  • 如果两者相等,额外讨论,但结论很宽松。

移动规则就是一句话:把当前比较矮的那根柱子往中间挪一步,换一个新的“队友”上来。

为什么不能移动高的一侧?这个也很直观。用一个具体例子算一算:左边高度 8,右边高度 3,当前宽度是 10,面积是min(8,3) × 10 = 30。如果移动右边的高个?这里注意,在这个例子里右边高度 3 才是矮的,所以高的是左边 8。假如我们执意移动左边的高个,新的左指针可能变成 0.5、2、3 这样的数,而右边还是 3,短板还是 3 或者更小,宽度却从 10 变成了 9,面积最多不会超过 3×9=27,甚至可能更低。换句话说,移动高个既没有改变短板,还压缩了宽度,属于纯亏操作。

移动矮个则不同。右边高个保持不变,左边从 3 换成一个更高的值,比如 9,那新面积就是min(9,10) × 9 = 81,直接起飞。所以“谁弱换谁”是唯一有可能让面积突破当前纪录的选择。

2.3 严谨性证明:为什么移动矮侧不会漏掉最优解

拔河直觉虽然好懂,但面试官通常还会补一句:“你每次都移动矮的,凭啥保证最后一定找到最大值?”这一问其实是这道题真正的技术含量所在,值得用数学语言写清楚。

假设当前height[left] = hL,height[right] = hR,且hL < hR。此时计算出的面积:

cur = hL × (right - left)

现在我想移动left,但我得先证明:所有“保持left不动,只改变右指针位置”的组合,都不可能超过当前已经得到的最大值。

任取一个位置k,满足left < k < right,那么以left和k为左右柱子的面积是:

area_k = min(height[left], height[k]) × (k - left)

因为k - left < right - left,并且min(height[left], height[k]) ≤ height[left] = hL,所以:

area_k ≤ hL × (k - left) < hL × (right - left) = cur

也就是说,把left固定住,右边无论换成数组中间哪根柱子,算出来的面积都不会超过当前这个cur。既然cur已经被我们计算并记录过,那这一整批组合就直接被“判死刑”了,可以放心地把left向右移动,抛弃这根已经榨不出更多价值的矮柱子。

反过来,如果hL > hR,逻辑完全对称,我们把right向左移动,排除掉所有固定右指针、左指针在中间的组合。

这个证明的本质是一个反证法:假设最优解存在某个“最优组合(i, j)”,如果这个组合在一开始被我们排除了,那唯一可能是因为当时以某根矮柱为固定边,算出的当前面积已经大于等于这个组合的面积,矛盾。所以最优组合永远不会被排除,它一定会在指针相遇之前被扫描到。这就是双指针正确性的核心根基,把这个梳理清楚,面试基本就稳了。

3. Java 代码落地:提交前你需要掌握的全部细节

3.1 最小可运行实现与逐行解读

思路已经理清了,Java 代码可以写得非常短。完整实现如下:

public int maxArea(int[] height) { int left = 0; int right = height.length - 1; int maxArea = 0; while (left < right) { int hL = height[left]; int hR = height[right]; int width = right - left; int area = Math.min(hL, hR) * width; maxArea = Math.max(maxArea, area); if (hL < hR) { left++; } else { right--; } } return maxArea; }

这段代码里只有三层意思,但每一层都有讲究。

第一层是初始化。left = 0、right = len - 1,让两个指针放在数组两端,保证宽度一开始最大。

第二层是计算面积并更新最大值。先把两个高度取出来放进局部变量,既是为了避免反复读取数组,也是为了后面移动指针时判断方便。Math.min(hL, hR)得到当前容器的有效高度,right - left得到宽度,相乘就是当前面积,再去和maxArea比较。

第三层是移动策略。判断哪个高度更小,更小的那一边向内移动。注意这里我用的是if (hL < hR) left++; else right--;也就是说当左右高度相等时,走的是else分支,移动右指针。其实相等时移动哪边都无所谓,程序上只要统一就行,这个点在后面边界小节里细说。

3.2 用一个标准例子验证指针走向

光看代码可能还是有点悬,拿height = [1,8,6,2,5,4,8,3,7]手工跑一遍,把每一步都列出来,你就能直观感受到“面积不一定每一步都在变大,但最大值被稳稳保住”。

leftright高度较小宽度面积本轮动作
081 vs 7,取188left 移到 1
188 vs 7,取7749right 移到 7
178 vs 3,取3618right 移到 6
168 vs 8,取8540right 移到 5
158 vs 4,取4416right 移到 4
148 vs 5,取5315right 移到 3
138 vs 2,取224right 移到 2
128 vs 6,取616right 移到 1,循环结束

从表格里能看得很明白:第二轮算出 49 之后,后面的面积一路下降,但maxArea始终是 49,最后返回的就是它。这就是双指针算法和贪心算法的区别——它不是在每一步都追求局部最优,而是在每一步都排除一批不可能超过当前纪录的组合,全局扫描完一定得到全局最优。

3.3 边界条件与容易忽略的细节

先说循环条件。while (left < right)是标准写法,不能写成<=,因为左右指针指向同一根柱子时,容器是不存在的,必须保证至少有两根不同的柱子。题目如果保证数组长度至少为 2,可以不用单独判空;但为了函数健壮,可以在开头加一句:

if (height == null || height.length < 2) { return 0; }

然后是左右高度相等的情况。前面代码里相等时走的是right--,这没问题,但你写成left++也一样是正确。原因在第 2.3 节的证明里:当两边相等时,无论固定哪一边,当前这个“矮高度”乘以更小的宽度都不可能超过刚算出的面积,所以移动任意一边都可以安全排除一批组合。很多教科书会在相等时单独处理成“同时移动两边”,这也是一种正确优化,但并不是必须的,反而容易让代码多写一个分支。我的建议是保持最简判断,用<=或<统一处理就行。

再一个是乘法溢出。题目常规范围内,height[i]不超过 10⁴,n不超过 10⁵,最大乘积大约是10⁴ × 10⁵ = 10⁹,Java 的int完全装得下。但如果扩展成更大的数据范围,或者面试官让你手写更通用的版本,直接改用long会更安全。这种细节不影响主思路,但提一嘴能让面试官觉得你考虑得很全面。

3.4 复杂度分析和优化边界

时间复杂度非常好看:left和right一共只向中间移动,每一轮固定移动一个指针,总共最多移动n - 1次,每轮内部只做常数次计算和比较,所以整体是 O(n)。空间上只用了几个临时变量,没有额外数组,所以是 O(1)。

这里有一个容易误解的点:既然我们每轮都在移动“较矮的那一侧”,那能不能提前终止,比如出现连续好几轮面积都在变小就退出?不能。因为当前面积下降不代表后面的面积不会超过历史纪录。前面表格里第二轮之后面积一路下降,但假设我们把柱子改成[1,8,6,2,100,4,8,3,7],中间某个高柱子和当前被换上的高柱子就可能组合出更大的面积。双指针的正确性依赖全量扫描,任何提前终止的优化都可能剪掉最优解。在实际工程里,能接受 O(n) 就尽量别为了微弱的常数优化去冒险。

4. 刷透这一题:常见问题、面试追问与同类题迁移

4.1 新手最容易踩的坑速查表

刷过这题的人多多少少都踩过几个坑,我整理成一张表,对照着自查比看十遍代码更有用。

常见错误具体表现后果正确做法
把面积公式记错直接用height[i] * height[j]算面积答案偏大先用min取有效高度
移动高的一侧看到左边高就动左边,看到右边高就动右边面积不可能突破,可能漏最优解永远移动较矮的一侧
循环条件写错写成left <= right指针重叠时多算一次无效容器用left < right
宽度计算错误写成i + j或忘记right - left计算结果完全偏宽度就是两个下标的差值
忘记更新maxArea只算面积不比较最后返回 0每轮用Math.max更新
误以为相等时不能移动在==分支纠结代码复杂且没有必要等号时移动哪边都行,统一即可

其中“移动高的一侧”是最要命的错误,因为它表面上看不出问题,只是答案偶尔会偏小。我之前帮人复查代码时,看到一个版本在else里写的是left++,目标也是想“移动矮的一侧”,但这等于反过来了,导致结果时对时错,随机数据一测就暴露。写代码的时候,最好先把判断条件想成“谁矮动谁”的口诀,再对应到下标上。

4.2 面试官常追问的几个点怎么答

第一问通常是:“为什么双指针移动矮的一侧能保证正确?”这时候不要只背结论,而是要把固定矮柱后、“这一边和中间所有柱子的组合面积都不超过当前面积”这个剪枝逻辑讲清楚。能用一句话概括最好:“因为当前面积已经覆盖了所有以这根矮柱为边的组合,它们不可能超过已记录的答案,所以放心把它们排除。”

第二个高频追问是:“左右高度相等的时候怎么办?”你可以回答:移动哪边都正确,也可以选择两边同时移动;关键是用同一套代码逻辑保持一致。然后补一句证明理由,说明当前面积已经把固定任意一边的组合全部“封顶”了,面试官通常就会满意。

第三个追问是:“如果题目还要你返回最大面积对应的两根柱子的下标,怎么写?”这只需要在更新maxArea的时候顺便记录bestLeft和bestRight,代码增加三个变量的事,属于很常见的扩展。

第四个追问是:“能不能用单调栈或者二分做?”我的建议是承认双指针是这题最自然的解法,单调栈需要维护额外信息,复杂度也不占优;在面试场景里,抛出双指针的剪枝证明就够了,不必主动把思路绕进复杂数据结构里。

4.3 从这一题延伸出去的双指针题型地图

“盛最多水的容器”是相向双指针里最好的入门题之一,因为它把“为什么移动某一边”的理由讲得最干净。顺着这个思路,可以串联出一张双指针题型地图。

最直接的相关题是“接雨水”。它同样是从两端向内扫描,但需要分别维护左侧当前最大高度和右侧当前最大高度,核心判断仍然是“矮的那一边决定当前格能接多少水”。两道题对比着刷,你会发现在“短板决定容量”这个点上惊人地一致。

再往外扩展,“三数之和”用的是先排序再固定一个数、其余两个数相向扫描的双指针;“最长回文子串”用的是从中间向两边扩散的另一种双指针。它们都属于“双指针”家族,但移动逻辑完全不同。刷题时最好的方法不是硬记代码,而是问自己三个问题:指针怎么初始化?每一轮移动哪一边?为什么移动这一边是安全的?能把这三个问题答清楚,题目才算真正刷透了。

我个人在实际操作中的体会是:这一题最大的价值不在于代码,而在于那个“谁弱换谁”的决策依据。面试前你不需要把整个数组的移动过程背下来,只需要记住一句话——“只有换掉短板,整体才有机会变强”。把它想通了,以后再遇到相向双指针,你会发现自己能很快推导出移动规则,而不是翻题解。

最后再分享一个小习惯。写完代码别急着提交,先在纸上手算几个小用例,比如[1,1]应该返回 1,[2,0,2]应该返回 4,[1,2,1]应该返回 2。这些带边界和重复值的测试用例能在一分钟之内帮你确认思路没有跑偏,省下不少无谓的提交失败。这道题对你来说,应该从“背模板”变成“讲得清”,一旦做到了,双指针这个大类的题就真的通了一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询