☰
盛水最多的容器:为什么双指针有效?从暴力到最优解
2026/10/9 14:44:56 网站建设 项目流程

这些年面试算法题里,有一道题几乎每个刷过 LeetCode 的人都绕不开,它就是“盛水最多的容器”。题目看着简单,一堆竖线里挑两根,让它和 x 轴围成的容器装水最多,但真正做过这题的人往往卡在同一个问题上:为什么双指针对这道题一定有效?答案其实就一句话——每次移动指针,都能安全地排除一组不会再变好的候选答案。这篇内容我想围绕双指针这个核心方法,把这题从暴力解到最优解完整讲透,适合正在准备笔试面试的开发者,也适合刚接触双指针、想搞懂“两个指针为什么要这样移动”的算法新人。读完你不仅会写这题的代码,还能把背后的单调收缩逻辑迁移到其他题目上。

1. 从题目到直觉:这题到底在问什么

1.1 题面拆解

原题描述不复杂:给定 n 个非负整数 a1, a2, ..., an,每个数代表在坐标 (i, ai) 处画一条垂直于 x 轴的线段,x 轴当作容器底部,现在要挑两条线段当作左右侧壁,问这两条线段和 x 轴围成的容器最多能装多少水。

这里有一个特别容易被忽略的细节:装水多少不取决于最高的那块板,而是取决于最低的那块。用木桶效应来类比就非常直观——木桶能装多少水,永远由最短的那块木板决定。两根柱子一高一矮,水装到矮柱子的顶端就会溢出去,所以容器高度永远是 min(height[i], height[j])。

容器宽度就更好理解了,两根柱子在 x 轴上隔着多远,容器的底就有多宽。两点的坐标差是 j - i,注意不是 j - i + 1。很多新手在这里栽过跟头,后面我会专门说到这个坑。

所以这个问题本质上是求:

max( min(height[i], height[j]) * (j - i) )

其中 i 小于 j,i 和 j 都是合法的下标。这个式子看起来简单,但真正麻烦的地方在于:一共有 C(n, 2) 种配对方式,也就是大约 n²/2 个候选答案。如果数组长度是 10 万,这个数字就是 50 亿。如果不做任何优化,直接枚举,性能会非常难看。

1.2 先把暴力解摆出来

拿到一道题,我习惯先把最直接的暴力解写出来,不是为了提交,而是为了确认自己对题目的理解没有偏差。暴力解就是两层循环,枚举所有 i 和 j 的组合,每算出一个面积就和当前最大值比较一次。

def max_area_brute(height): n = len(height) res = 0 for i in range(n): for j in range(i + 1, n): area = min(height[i], height[j]) * (j - i) res = max(res, area) return res

这个解法的时间复杂度是 O(n²),空间复杂度 O(1)。在 n 很小的时候没有任何问题,但一旦数据规模上升到 10 万以上,它就会超时。原因也很简单,50 亿次核心计算,就算把 min 和乘法都优化得非常快,也至少需要十几秒,更别说实际工程里还要处理更复杂的数据。

写暴力解还有一个额外收获:你可以拿它当“标准答案”来验证后面的优化算法是否正确。我调试双指针版本的时候,就经常随机生成几万个数组,把暴力解和双指针解的结果对比,只要有一组不一致,说明优化版本有 bug。这种做法在刷题和实际工程里都非常值得推崇。

暴力解之所以慢,本质上是算了太多“不可能成为答案”的组合。那么问题来了,我们能不能像“剪枝”一样,有根据地排除一部分组合,而不是一个不落地全部算一遍?

2. 双指针的切入逻辑:凭什么移动矮的那一侧

2.1 从两端开始,宽度最大

双指针的第一步是把两个指针放在数组的两端。为什么要从两端开始?因为这时候容器的宽度最大。宽度是 j - i,两端位置让这个差值达到最大值。在这个基础上,如果还能找到一个更优的方案,那一定是把低矮的那一侧向内移动,去寻找一根更高的柱子。

我拿官方示例 [1, 8, 6, 2, 5, 4, 8, 3, 7] 来走一遍。初始时 left = 0,height[left] = 1;right = 8,height[right] = 7。此时容器面积是:

min(1, 7) * (8 - 0) = 1 * 8 = 8

这个面积不大,因为左边的柱子只有 1 这么高,是典型的“短板”。

那么这时候应该移动左指针还是右指针?很多人的直觉是:把高的那边往中间挪一挪,也许能找到更高的组合。但仔细想想就发现问题了:如果移动右指针,容器的矮边仍然是 height[left] = 1,无论右边换成哪根柱子,容器高度都不可能超过 1,而宽度还从 8 降到了 7,面积只会更小。右边这一侧向内移动的所有组合,都不可能超过当前面积 8。

这就解释了为什么必须移动矮的那一侧。反过来说,矮侧柱子 1 和区间内任何柱子配对,最高也只能是 1 的高度,宽度又不会超过 8,所以它的面积注定不可能超过当前值。既然以这根矮柱子为一边的所有组合都不会更优,那就干脆把它丢掉,left 右移一位。

2.2 把正确性证明写下来

刚才说的其实是一种直觉,但面试时光有直觉不够,最好能把这个逻辑形式化地讲清楚,否则面试官很容易觉得你是背了答案,而不是真正理解了。

假设当前左指针在 i,右指针在 j,并且 height[i] 小于 height[j]。此时面积是:

area = height[i] * (j - i)

现在考虑以 i 为左边界,右边界取区间 (i, j) 内任意一个位置 k。无论 k 具体是哪一根柱子,面积最多是:

min(height[i], height[k]) * (k - i)

由于 height[i] 是当前更矮的一边,而 min 的结果不可能超过 height[i],所以:

min(height[i], height[k]) <= height[i]

又因为 k 小于 j,所以 k - i 一定小于 j - i。一个不大于 height[i] 的数,乘以一个更小的宽度,结果必然小于 height[i] * (j - i),也就是小于当前面积。

结论很干净:只要左边比右边矮,那以左边柱子为左边界的全部剩余组合,都不可能产生更优答案,可以整体舍弃。反过来,如果右边比左边矮,就以右边柱子为右边界做同样的推理。

这个证明是整个双指针解法的基石。它告诉我们:每次移动矮的一侧,不是在“碰运气”,而是在有理有据地淘汰一批永远不会翻盘的候选答案。整个搜索空间的规模虽然是 O(n²),但双指针每一步都安全地砍掉一条边,所以整体只需要 O(n) 步就能收敛到最优解。

2.3 两根柱子一样高时怎么办

还有一个不少人在意的问题:如果 height[left] 和 height[right] 相等,该移动哪一边?

答案是哪边都行,不会影响最终结果。我可以简单解释一下原因:假设 height[i] = height[j] = h,那么当前面积是 h * (j - i)。如果最优解仍在这个区间内,它要么是以 i 为左侧边界,要么是以 j 为右侧边界。由于两边界高度相同,移动任意一侧都不会同时丢失所有可能性,剩下的搜索空间中仍然保留着最优解。

不过在实际编码时,我一般习惯在 height[left] < height[right] 时移动 left,否则统一移动 right。这样代码最简洁,也不需要额外的分支。如果你愿意,相等时两边同时向中间移动也是安全的,但没必要,反而多一次判断。在面试场景里,讲清楚“相等时移动任意一侧都不会漏解”这个点,会显得你有更完整的推导能力。

3. 完整实现与代码细节

3.1 Python 实现与逐行注释

有了前面的证明,代码反而成了最简单的一步。下面是我平时推荐的标准写法,每个关键点都加了注释:

from typing import List def maxArea(self, height: List[int]) -> int: left = 0 right = len(height) - 1 ans = 0 while left < right: # 当前容器的宽度 width = right - left # 面积由较矮的一侧决定 if height[left] < height[right]: area = height[left] * width # 矮侧柱子已经不可能再成为最优左边界,右移 left += 1 else: area = height[right] * width # 相等时移动 right,同样安全 right -= 1 # 更新全局最大面积 if area > ans: ans = area return ans

这里我稍稍做了一个小优化:在计算 area 时直接用较矮一侧的高度去乘宽度,省去了一次 min 函数调用。省掉这次调用的收益极其微小,但好处是让代码更贴近上面的推导逻辑,也方便你在面试时一边写一边讲解。如果你觉得 min 写法更易读,完全没有问题。

def maxArea_simple(self, height: List[int]) -> int: left, right = 0, len(height) - 1 ans = 0 while left < right: ans = max(ans, min(height[left], height[right]) * (right - left)) if height[left] < height[right]: left += 1 else: right -= 1 return ans

两种写法核心逻辑完全一致,选哪种全看个人习惯。

3.2 手动模拟一遍完整过程

为了让你对指针移动有更扎实的体感,我把示例数组完整走一遍。这个数组是 [1, 8, 6, 2, 5, 4, 8, 3, 7],下标从 0 到 8。

第一步:left=0,height=1;right=8,height=7。面积是 1*8=8,ans 更新为 8。1 更矮,移动 left,left 变成 1。

第二步:left=1,height=8;right=8,height=7。面积是 7*7=49,比 8 更大,ans 变成 49。7 更矮,移动 right,right 变成 7。

第三步:left=1,height=8;right=7,height=3。面积是 3*6=18,ans 保持不变。3 更矮,移动 right,right 变成 6。

第四步:left=1,height=8;right=6,height=8。面积是 8*5=40,ans 仍然是 49。因为高度相等,按代码逻辑移动 right,right 变成 5。

第五步:left=1,height=8;right=5,height=4。面积是 4*4=16,ans 不变。4 更矮,移动 right,right 变成 4。

第六步:left=1,height=8;right=4,height=5。面积是 5*3=15,ans 不变。5 更矮,移动 right,right 变成 3。

第七步:left=1,height=8;right=3,height=2。面积是 2*2=4,ans 不变。2 更矮,移动 right,right 变成 2。

第八步:left=1,height=8;right=2,height=6。面积是 6*1=6,ans 不变。此时 left 和 right 相邻,移动任意一边后循环结束,最终答案是 49。

可以看到,整个移动路线非常短,只做了 8 次面积计算,而暴力解需要数 36 次。差距在数据规模变大之后会指数级拉大。

3.3 复杂度与语言注意事项

时间复杂度是 O(n),因为 left 和 right 各自最多移动 n 步,合起来最多 n 次迭代。空间复杂度 O(1),只用到了几个变量,没有额外数组。

如果你用 C++ 或者 Java 写这道题,有一个非常现实的坑需要注意:height[i] 和 width 的乘积可能超出 int 范围。假设数组长度是 10 万,高度最大值也是 10 万,那么理论最大面积接近 10^10,早就超过 32 位整数的上限 2147483647。所以 C++ 里建议把面积变量声明成 long long,Java 里用 long。Python 由于整数是任意精度的,不需要考虑溢出,但如果你在工作中写其他语言,这个细节能让程序避免非常隐蔽的 bug。

4. 常见错误与调试经验

4.1 循环条件、初始化与返回值

这道题的边界条件不算复杂,但我在带新人时见过不少经典错误。

第一个是循环条件写错。有人写 while left <= right,结果 left 和 right 相等的时候,宽度是 0,面积也是 0。这不会改变最终答案,但会让循环多跑一轮,属于逻辑不严谨。标准写法应该是 while left < right,因为左右指针重合时已经没有容器可谈。

第二个是 right 初始化写错。数组长度为 n 时,最后一个下标是 n-1,如果初始化成 n,第一次访问 height[right] 就数组越界了。这个错误在面试压力下很容易犯,最好写完代码后自己拿空数组和单元素数组测一下。空数组需要额外判断,题目一般会保证 n 至少为 2,但养成防御性编程的习惯总没坏处。

第三个是返回值搞错。题目要求返回最大面积,不是返回两个柱子的下标。我见过有人辛辛苦苦维护 left_index 和 right_index,最后返回了下标。面试官看完代码会沉默几秒,然后问你这个返回值是不是题目要的。额外变量会让代码变得臃肿,还不利于讲清楚思路。

4.2 宽度到底是不是 j - i

这个坑真的很离谱,但确实很常见。很多人会写成 right - left + 1,觉得“两根柱子中间隔了几根柱子,占了几个格子,所以宽度要加一”。这是对题目的误解。

题目说的是坐标 (i, ai) 和 (j, aj) 之间的区域,容器的底就是 x 轴上 i 到 j 的距离,也就是 j - i。比如 i=1,j=3,那么底的长度是 2,中间其实只隔了一个单位。如果你记成 j - i + 1,所有面积都会偏大,但答案又是错的,而且很难一眼看出来。我的记忆方式是:宽度等于两根柱子在 x 轴上的坐标差,不是“相隔几根柱子”的计数。

4.3 不要随便再加“剪枝优化”

网上有一些代码会试图在双指针基础上加额外判断,比如如果当前矮柱高度乘以最大可能宽度都比 ans 小,就跳过这根柱子。听起来很合理,但实际操作时要非常小心,因为这些剪枝条件往往没有经过严格的数学证明。

举个例子,假设 left 当前位置高度很低,但右边很远的地方有一根极高的柱子,两者相乘可能是一个很大的面积。如果你因为“当前矮柱看起来没希望”就跳过它,就会漏掉正确结果。双指针已经是线性复杂度,完全不需要再额外剪枝。我自己的经验是,算法题里除非能严格证明某个优化不丢解,否则不要画蛇添足。

4.4 别和“接雨水”搞混

盛水最多的容器和“接雨水”是两道非常经典的双指针题,很多人刷题时会把它们混在一起。这里我简单对比一下,帮你理清思路。

盛水容器求的是两根柱子之间能围出的最大矩形面积,核心是“找两个最合适的边界”,指针根据矮侧移动,目标是最大化宽度和短板高度的乘积。接雨水求的是某个区域里能积攒的总水量,需要维护左右两边的最大高度,然后逐个位置计算能存多少水,更依赖对局部凹槽的判断。

两道题虽然都用了双指针,但指针移动的依据完全不同。如果面试时背错策略,把接雨水的维护左右最大值的逻辑搬到容器题里,代码会变得异常复杂,还大概率出错。我建议刷完这道题之后,紧接着刷一遍接雨水,在对比中记住两者的差异。

5. 双指针思想的可迁移性

5.1 双指针的本质是安全收缩

很多人把双指针当成一种“套路”,做题的时候套模板,但真正值得学习的,是它背后的思维方式:面对一个庞大的组合搜索空间,想办法找到某种单调性,然后证明每一步都能安全地划掉一批候选答案。

这套题目中,双指针从两端开始,利用“矮侧边界不可能会更优”这个单调性质,实现了 O(n²) 到 O(n) 的跨越。你不需要记住代码,只需要记住“移动矮的一侧”这个决策,以及背后那条证明链。

这种思路可以迁移到很多其他的程序设计中。凡是遇到“枚举所有配对、找最优”的题目,第一步不是硬写两层循环,而是先问自己:能不能确定某种规则,把明显不可能成为答案的候选直接排除?如果能,双指针大概率是可行方案。

5.2 三个值得顺手刷掉的相关题目

我建议你学完本题后,把下面这几个经典问题连着刷一遍,都是双指针思想的直接应用。

第一是“三数之和”。数组无序时先排序,然后固定一个数,用双指针在剩余区间里寻找两个数,让三数之和等于目标值。这里的双指针依据是数组有序后,左侧指针右移会让和变大,右侧指针左移会让和变小,通过这种单调性快速逼近目标。

第二是“有序数组的两数之和”。这个更简单,一个指针在左,一个指针在右,两数之和偏大就右指针左移,偏小就左指针右移。每次移动都排除一个方向的所有组合,本质上和盛水容器同源。

第三是“最长回文子串”的中心扩展法。以每个字符为中心向外扩展,用两个指针一左一右比较字符是否相等。虽然它更多是中心扩展而不是端到端收缩,但从“维护两个游标并利用单调性剪枝”的角度看,仍然是一脉相承。

刷完这些题,你会发现双指针不是孤立技巧,而是一种通用的“搜索空间剪枝”策略。

5.3 怎么训练这种直觉

我给自己的建议是,每道新题先尝试画一画搜索空间。把两两配对的候选集想象成一个二维矩阵,行是左边界,列是右边界,可行区域在对角线右侧。双指针的每一步,实际上都是从左下角或者右上角的方向,一次性划掉一行或一列。

如果你能在草稿纸上画出这个过程,你对题目的理解就不再停留在代码层面,而是真正的空间想象。面试时也可以借助这个思路,向面试官解释为什么这个解法是可靠的。这种训练方法不只在双指针上有效,在处理区间类问题时都很有帮助。

我个人在实际操作中的体会是,刷题初期最容易犯的错误不是写不出双指针代码,而是写完代码后自己也讲不清“为什么移动这一侧是安全的”。后来我调整了学习方式,不再追求快速刷题数量,而是每道题都把证明过程用文字写一遍,把“排除依据”直接注释在代码里。这个方法虽然慢,但效果非常明显。如果你现在正被双指针题困住,不妨也试试在代码注释里多写一行理由,等你想清楚那一行怎么写的下一秒,很多双指针题目都会突然变得通透。

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

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

立即咨询