1. 为什么一道medium的容器题,在Hot100里值得反复刷
1.1 先看清题目在问什么:不是接雨水
LeetCode Hot100第11题“盛最多水的容器”,题目描述非常短:给你一个非负整数数组 height,每个元素代表坐标轴上的一个点 (i, height[i]),画一条垂直于 x 轴的线段,然后要你从中挑两条线段当作“容器壁”,和 x 轴一起围成一个容器,问最多能装多少水。
很多第一次刷到这题的人容易犯一个认知错误——把它和另一道经典题“接雨水”混在一起。实际上这里有个关键区别:盛最多水的容器是只选两根柱子,不管中间那些柱子长什么样;中间哪怕有根一万米高的柱子,也完全不参与计算。容器能装多少,取决于两根被选中的柱子中较短的那根的高度,再乘以它们之间的水平距离。用公式写就是 max((j - i) * min(height[i], height[j])),其中 i < j。
这题在 Hot100 里的定位很有意思:难度只是 medium,但它几乎是双指针思想的最佳入门载体。它的代码极致简单,标准解法大概十几行就能写完;但它的思维拐点极大,从“两层循环遍历所有柱子对”到“两个指针往中间走”,这一步跳跃如果没有想透,后面做接雨水、三数之和、最长回文子串之类的问题,都会觉得地基不太稳。
1.2 这道题真正卡人的地方:双指针收缩的“为什么”没人讲
我相信很多人看过题解后的第一反应是:就这?左边一个指针,右边一个指针,哪边矮就动哪边,每次算一下面积,最后取最大值。代码背下来很容易,但心里始终有一个挥之不去的疑问——凭什么我直接把矮的那根柱子丢掉了,不会丢掉全局最优解?
说实话,我第一次做这题时也是这个状态。代码能在五分钟内默写出来,但面试官只要追问一句“你证明一下为什么移动矮指针是正确的”,我就开始支支吾吾。这个问题如果答不好,面试官就会觉得你对双指针只是“记住了套路”,没有真正理解背后的单调性逻辑。
这篇文章不会只给你代码,而是把整道题从暴力解法到双指针推导的完整链路拆开,再把代码落地的各种边界细节讲清楚。适合正在刷 Hot100 准备面试的人,也适合已经刷过一遍但想把双指针题“串成体系”的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法先推到极限,才能体会双指针省掉了什么
2.1 两层循环完整写法与复杂度账
先别急着上双指针。遇到这种“选两个点求最优”的问题,第一反应应该是暴力枚举所有可能,把复杂度的天花板看清楚。这是一道组合优化题,候选解就是所有满足 i < j 的下标对,总数是 C(n, 2),也就是 O(n^2) 个。
暴力解法很好写:
java复制public int maxArea(int[] height) {
int n = height.length;
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
int area = (j - i) * Math.min(height[i], height[j]);
ans = Math.max(ans, area);
}
}
return ans;
}
这个版本的优点是绝对正确,不会漏掉任何一种组合。缺点是当 n 到 10^5 量级时,两层循环要跑约 5 * 10^9 次,在 LeetCode 的评测环境下基本会超时。
很多教程到这里就直接说“暴力不行,我们用双指针”,然后甩出双指针代码。这种讲法对新手很不友好,因为你不知道双指针到底优化掉了什么、为什么能优化,只知道“这道题可以用双指针”。
2.2 从暴力到双指针:三个关键观察
我们不妨盯着面积公式看:area = (j - i) * min(height[i], height[j])。它由两个因子组成,一个是宽度 j - i,一个是较短边的高度。想优化暴力,本质上要回答一个问题:每次能不能排除掉一大批“不可能成为最优解”的候选组合?
这里有几个观察非常关键:
第一个观察:面积的上限受制于短边。无论另一根柱子多高,容器高度都取两者中的较小值。所以如果你想提高面积,要么增加宽度,要么提高短边的高度。
第二个观察:当两个指针分别指向左右两端时,宽度处于全局最大状态。接下来无论怎么移动,宽度都只会减小。这就意味着,在指针向中间靠拢的过程中,面积的“补偿”只能来自高度变高,如果高度没有变高,这步移动就是白费的直觉——但它能帮我们排除无效区域。
第三个观察,也是整道题最核心的:假设当前 height[left] < height[right],也就是说左边是矮墙,右边是高墙。那对于以 left 作为左边界的所有可能容器来说,right 已经是最远的右边界了。为什么?因为 left 再往右移动,所有容器的宽度一定小于当前宽度。而以 left 为左边界时,容器高度最多不超过 height[left](因为它自己就是矮墙)。即使把 left 和更右侧的任意柱子配对,面积也不会超过 height[left] * (当前最大宽度)。既然当前这个 left 已经在“最远宽度 + 自身高度”的条件下都没能超过已知最优解,那它跟更近的柱子组合就更没有希望了。所以左指针 left 可以安全地丢弃,向右移动。
反之,如果 height[right] < height[left],那 right 作为右边界时,最远左边界就是当前的 left,它也不可能靠更近的左边界产生更大面积,所以右指针向左移动。
2.3 用数组 [1,8,6,2,5,4,8,3,7] 跑一遍轨迹
光说不练容易飘,我们用 LeetCode 官方示例来手推一遍:height = [1, 8, 6, 2, 5, 4, 8, 3, 7]。标准答案应该是 49。
初始状态 left=0,right=8,height[0]=1,height[8]=7。面积 = (8 - 0) * min(1, 7) = 8,当前最大值是 8。左边太矮了,如果我们保留左指针 index=0 这根高度为 1 的柱子,它和任何其他柱子配对,容器高度都只能 <= 1,而且右边的距离只会比现在的宽度 8 更小,所以面积不可能超过 8。因此把 left 往右移到 1。
left=1(高度 8),right=8(高度 7)。面积 = 7 * 7 = 49,刷新最大值 49。此时右边是 7,比左边矮,按照规则应该移动 right,把右指针向左移到 7。为什么不移动左边?因为如果保留右边这根高度为 7 的墙,它和 index 7 处高度为 3 的墙之间的宽度是 1,即便高度取 3,面积也只有 3;而它和左边任何墙的配对已经被“当前宽度最远”的情况覆盖过了,右边柱子作为矮墙时,它离左边第 1 根柱子最远,不可能有更大的面积。
后面继续:left=1(8),right=7(3),面积 = 6 * 3 = 18,小于 49,右边矮,右指针左移。left=1,right=6(8),面积 = 5 * 8 = 40,小于 49,此时左边 8 和右边 8 一样高,移动哪边都可以,按代码习惯移动左边。left=2(6),right=6(8),面积 = 4 * 6 = 24。移动左边。left=3(2),right=6(8),面积 = 3 * 2 = 6。移动左边。left=4(5),right=6(8),面积 = 2 * 5 = 10。移动左边。left=5(4),right=6(8),面积 = 1 * 4 = 4。此时 left == right,循环结束。
把上面这些面积列个表:
| 步骤 | left | right | height[left] | height[right] | 宽度 | 面积 | 当前最大值 |
|---|---|---|---|---|---|---|---|
| 1 | 0 | 8 | 1 | 7 | 8 | 8 | 8 |
| 2 | 1 | 8 | 8 | 7 | 7 | 49 | 49 |
| 3 | 1 | 7 | 8 | 3 | 6 | 18 | 49 |
| 4 | 1 | 6 | 8 | 8 | 5 | 40 | 49 |
| 5 | 2 | 6 | 6 | 8 | 4 | 24 | 49 |
| 6 | 3 | 6 | 2 | 8 | 3 | 6 | 49 |
| 7 | 4 | 6 | 5 | 8 | 2 | 10 | 49 |
| 8 | 5 | 6 | 4 | 8 | 1 | 4 | 49 |
从这张表可以看得很清楚:双指针不是“碰运气”式地搜答案,而是每一步都在用“短边决定了容器上限”这个逻辑,成片地淘汰不可能产生最优解的组合。整个搜索空间从 O(n^2) 压缩到 O(n),因为每步只移动一个指针,两个指针合计最多移动 n 次。
3. 双指针为什么收敛就一定不会漏答案:三步推理
3.1 直觉层:矮墙决定了容器天花板
很多人觉得双指针正确性是“显然”的,但真让他证明就卡壳。我建议分三步来理解。
先来一个生活化的类比。想象你面前有一排长短不一的木板,你要选两块板子围一个矩形水池,水只能漫到矮板的高度。如果你左手拿着的是一块特别短的板子,右手拿着的是一块特别长的板子,那这块短板和右边任何板子搭配,水池高度都不可能超过它自己的高度。而且更关键的是:你现在和它搭配的已经是离它最远的长板了,宽度最大。既然“最宽 + 短板”都没能刷新纪录,那这根短板和右边任何更近的板子组合就更不可能刷新纪录。所以手里这根短板可以直接扔到一边,换下一根板子试试。
这就是为什么“哪边矮动哪边”在实际操作中的含义:我们不是随机丢弃候选解,而是把“以当前矮边为边界的所有可能组合”一次性判了死刑,因为它在最优情况下都已经暴露过了,没有再继续尝试的价值。
3.2 严谨层:为什么丢掉矮边不影响全局最优解
直觉有了,再用数学语言把话说死。假设全局最优解对应的左边界是 L0,右边界是 R0。考虑双指针运行过程中的某一个中间状态,假设 left < L0 且 right > R0,也就是说当前指针区间还完整地包含全局最优解那一对下标。
如果此时 height[left] < height[right],我们的策略是 left 右移。这相当于断言:所有满足“左边界等于当前 left”的容器,都不可能成为全局最优解。
我们来证明这个断言。因为 height[left] 是当前两根指针里的矮边,所以对任意位置 k,只要 left < k <= right(指针未越过之前的所有右端点),以 left 和 k 构成的容器面积一定是:
(k - left) * min(height[left], height[k])
注意 min(height[left], height[k]) <= height[left],而且 k - left < right - left(因为 k 最远只能取到 right,取到 right 时等号成立)。所以这些面积都不可能超过 (right - left) * height[left]。
而 (right - left) * height[left] 恰好就是“当前 left 与当前 right 计算出来的面积”,也就是我们在移动前刚刚算过并参与过最大值比较的那个值。它既然没有超过当前已知最优解,那以 left 为左边界的所有其他候选自然也不可能超过当前已知最优解。全局最优解一定大于等于当前已知最优解,所以这些候选全都可以舍弃。
height[right] < height[left] 时完全对称。如果两指针中某一边就是全局最优解的左边界或右边界,上述推理同样成立——因为舍弃的是“以短板为边界的一组不可能更优的候选”,而全局最优解只要还留在指针区间内,就不会在这一步被误删。每一步都安全,最终收敛时得到的最大值就是全局最优。
3.3 两墙一样高时的处理逻辑
总有读者较真:如果 height[left] == height[right],该移动哪边?
其实移动哪边都可以。因为当两边一样高时,无论保留哪边,以这个高度为上限的容器在当前宽度下已经达到了局部极限。即使移动左边,右边这根高墙在未来与更靠近它的左柱子配对时,容器高度最多也就是当前这个高度,但宽度一定变小,不可能超过现在这个面积。如果题目只要求返回最大面积,不需要返回具体下标,那么相等时随便移动一边都不影响正确性。
不过在代码实现时,常见习惯是使用 <= 或 < 的某个固定写法,保证逻辑一致。如果你用 height[left] <= height[right] 作为移动 left 的条件,会发现相等时移动的是左指针。这仅仅是一种约定,不会让答案出错。后续面试时如果被问到这一点,可以明确告诉面试官:相等时移动任意一边都不会丢解,因为当前组合的面积已经被完整评估过了。
3.4 这一步推导在算法题里的通用意义
把这一节的证明总结成一句话:双指针能保证正确,是因为每一步都利用问题的单调性,成批排除了一组“在结构上不可能成为最优解”的候选。
这句话几乎适用于所有双指针题目。比如有序数组的两数之和,排序后左指针指向最小值、右指针指向最大值,如果加和太大,说明右指针和任何更左的值配对都会更大,因此右指针左移;这本质上也是在利用“单调性排除候选区间”。理解了这层通用逻辑,你刷题时就不会再觉得每道双指针题都是独立的套路,而能看出它们共享同一套“排除候选”的骨架。
4. 代码落地:Java实现里那些不起眼的细节
4.1 标准实现与逐行拆解
先给出 Java 的完整实现:
java复制public int maxArea(int[] height) {
int left = 0;
int right = height.length - 1;
int ans = 0;
while (left < right) {
int h = Math.min(height[left], height[right]);
int area = h * (right - left);
ans = Math.max(ans, area);
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return ans;
}
边界条件需要注意:如果数组长度小于 2,根本形成不了容器。LeetCode 的题目约束里 n >= 2,所以代码里不需要额外判空;但如果你在本地测试或者抽成工具方法,最好在入口处加一行防御性判断。
主循环条件是 left < right,而不是 left <= right。因为当 left == right 时,两个指针指向同一根柱子,容器宽度为 0,面积必然为 0,再参与计算没有意义。
循环体内部逻辑分三步:先取两条线段中较短的高度,这是容器能装水的天花板;再乘以宽度,得到当前面积;最后更新全局最大值。更新完之后,根据两根柱子高度关系决定丢弃哪一边。
4.2 刷题时最容易踩的三个坑
第一个坑:把“移动矮的一边”错误理解成“哪边矮就移动另外一边”。很多人会记反。实际上要移动的是矮的那一边,因为矮边是当前组合的瓶颈,保留高边、丢弃矮边才有机会让下一轮容器高度提升。怎么记比较稳?想一下极端情况:如果数组是 [1, 100, 100, 100, 1],最左边和最右边都是 1,你移动哪边都行;但如果左边是 1、右边是 100,你不把左边这根 1 丢掉,它和任何柱子的组合高度都不可能超过 1,永远不会有起色。
第二个坑:在移动指针之前就更新完面积,这本身没错,但要注意别把面积公式写错。有人会写成 (right - left) * height[right],忘了取最小值,这在两边不等高时会算出一个根本不存在的“超级容器”。我见过不少刚刷题的同学在这上面翻车,代码跑起来答案偏大,还半天查不出原因。
第三个坑:忽略整型溢出的可能性。LeetCode 这题的数据范围里 height[i] 最大是 10^4,数组长度最大是 10^5,理论最大面积是 10^9,int 能装得下。但假如面试官把题目改成 height[i] 最大 10^9,那 area 用 int 就会溢出。养成好习惯的话,面积用 long 存,最后返回值类型再按题目要求处理。刷题时多写一个 long 不丢人,能帮你规避很多数据范围的坑。
4.3 用边界用例验证正确性
拿到一个算法实现,别急着交,先跑几个边界用例:
- 两个元素:[1, 2],答案是 1。左右指针各指向一个元素,宽度 1,高度取 1,面积为 1。
- 完全递增数组:[1, 2, 3, 4, 5]。答案是 6,也就是选择下标 0 的 1 和下标 4 的 5,宽度 4,高度 1,面积 4?不对,再算一下:宽度是 4,min(1,5)=1,面积是 4;但选下标 1 的 2 和下标 4 的 5,宽度 3,高度 2,面积 6;选下标 0 和下标 3 是 31=3;选下标 1 和下标 3 是 22=4;正确的最大值应该是下标 1 和下标 4 得到的 6。双指针跑一遍也能得到 6。
- 所有高度相等:[4, 4, 4, 4],答案是 12,即最左和最右组合,宽度 3,高度 4。双指针第一次算出 12,后面无论移动哪边,面积都只会变小,最终答案保持 12。
- 高度极高值出现在中间:[1, 10, 10, 10, 1]。两头都是 1,先算面积 = 4*1 = 4;左边矮,左指针右移,到 index 1;然后算 10 和 1,宽度 3,面积 3;右边矮,右指针左移,到 index 3;算 10 和 10,宽度 2,面积 20,刷新最大值。最后答案 20。这个用例提醒我们:就算两端的墙很矮,最大值也可能藏在中间区域,不要一开始就用“两头最高”来猜答案。
这些用例跑完,代码逻辑基本就稳了。建议刷题时准备一个本地测试目录,或者至少在 LeetCode 编辑器里把示例和几个自造用例都跑一遍,能有效避免“自以为对了,提交却 WA”的尴尬。
5. 面试官不会只问这一题:双指针题型的延伸
5.1 容器题和接雨水:别把两种双指针搞混
刷题刷多了你会发现,“盛最多水的容器”经常和“接雨水(Trapping Rain Water)”被放在一起讨论,因为两者都可以用双指针做。但它们的双指针含义完全不同,理解错了做题必翻车。
“盛最多水的容器”选两根柱子,决定面积的是两堵墙的较短边和它们之间的距离。而“接雨水”是在一整段柱状图里计算所有凹陷区域能存多少水,每一格能存的水取决于它左右两侧最高柱子的较小值减去当前高度,然后对所有位置求和。
对比一下就很清楚:
| 对比维度 | 盛最多水的容器 | 接雨水 |
|---|---|---|
| 求解目标 | 选一对柱子,面积最大 | 所有位置存水量之和 |
| 数据结构 | 只要两个指针 | 需要左右两侧最大高度信息 |
| 面积/水量公式 | (j-i) * min(height[i], height[j]) | sum(min(leftMax, rightMax) - height[i]) |
| 双指针作用 | 收缩搜索区间,排除不可能 | 维护左右最大值,逐格累计水量 |
| 时间复杂度 | O(n),O(1) 额外空间 | O(n),可以做到 O(1) 额外空间 |
如果你在面试中先被问容器题,面试官很可能会顺势问一句“那接雨水你会不会”。这两个放在一起考察,本质上是看你能不能分清楚“单调性排除候选”和“前后缀最大值逐项计算”这两种思维模式。我的建议是刷容器题的时候,顺手把接雨水也刷了,两者对照着记,比孤立刷题效果好得多。
5.2 同是双指针,三数之和又是另一种玩法
Hot100 里另一道经典双指针题是“三数之和”。它的思路是:先排序,然后固定第一个数,剩下的区间用左右指针去找两数之和。这里双指针能够工作的前提是数组已经排序,指针移动时能确定性地让和变大或变小。
对比“盛最多水的容器”和“三数之和”,你会发现两者对数据的要求不一样。容器题不需要排序,因为它的“单调性”来自物理约束:宽度只会越缩越小,短板决定了高度,要提升面积只能寄希望于提高短板。三数之和则需要预排序才能让双指针有了“根据当前和与目标值的大小关系决定左右移动”的依据。
这是双指针题的两种典型骨架:一种是“区间收缩型”,靠问题的内在约束排除候选;一种是“单调查找型”,靠数据的有序性进行二分式搜索。看到一道新题,先判断它属于哪种骨架,再套对应策略,通常要比死记硬背具体代码可靠。
5.3 被追问时的一句话证明思路
如果面试官问“你证明一下容器题的双指针是正确的”,标准的简洁回答可以这样组织:
每一轮循环中,当前两条线段里较短的那条如果是 left,那么以 left 作为容器左边界的任何方案,其宽度最大就是当前 right - left,高度最大不会超过 height[left]。因此 left 和 right 这一对已经是“以 left 为左边界”时面积上界的代表,如果它都没能超过当前最优解,那 left 与其他右边界组合也不可能超过全局最优解,所以可以安全把 left 丢弃并右移。
这个回答同时包含了三个要素:局部上界、全局最优安全、丢弃逻辑。能做到这点,基本上就可以让面试官相信你不是背题的。如果再被追问“相等高度怎么办”,就按 3.3 节的内容答:移动哪边都不影响正确性,因为当前组合已经被完整计算过了。
6. 复盘:这题带给真实刷题人的三个建议
6.1 第一遍别背代码,先写证明
很多 Hot100 题解都强调代码短、模板化,这导致很多人刷容器题时直接背代码。但我的真实体会是,这道题如果不理解证明过程,改天遇到“最多能盛多少水”或者“最大矩形面积”这类变体,还是会一脸懵。第一遍刷的时候,我建议花半小时把 3.2 节那个推导自己写一遍,哪怕写得比较啰嗦也没关系。这个过程能帮你建立一种能力:面对“看起来显然”的算法,你有办法从数学上确认它是对的。
一个实用的检查方法是:用笔在纸上画几组随机数组,模拟双指针每一步的移动和面积更新,然后标注“这一步丢弃的候选集合是哪些”。如果每一轮你都能清楚说出丢弃的理由,这题就算是真会了。我当初做到这一步之后,再去看接雨水里的双指针解法,明显觉得顺畅很多。
6.2 二刷和三刷时的变形练习
第一遍理解了基本解法后,不要急着做下一题。可以给自己出几个变体问题来加深理解:
第一个变体:如果题目要求返回能组成最大容器的两个下标,而不是返回面积,怎么写?其实很简单,在更新面积的时候同步记录 left 和 right 即可。唯一的注意事项是:当面积相等时,题目如果没有特别说明,保留第一次出现的下标就好。
第二个变体:如果数组里允许出现负数怎么办?负数高度在物理上没有意义(墙不可能有负高度),但如果硬要扩展,按照同样公式,负数会让 min 值变得更小,影响结果。一个可取的扩展是把所有负数先过滤掉。这种扩展练习能帮你发现,所谓“非负整数数组”这个约束对题目成立非常重要,去掉它问题性质就变了。
第三个变体:如果最多可以删掉一根柱子再求最大容器呢?这个就不是简单双指针能搞定的了,可能需要枚举删除位置,会显著增加复杂度。不建议初学者深究,但思考一下能让你理解原题约束有多优雅。
6.3 我自己的实践体会
说点个人经验。这道题是我觉得 Hot100 里“性价比”非常高的一道medium,因为它正好卡在一个巧妙的位置:实现难度约等于 easy,但思想深度够得上 hard 的敲门砖。我刷了这么多题之后回头看,容器题最大的收获不是记住了双指针怎么写,而是养成了一个习惯——任何指针移动策略,我都会追问一句“被丢弃的那一侧为什么不可能成为最优解”。这个问题在绝大多数双指针题里都成立,而且往往是解题的关键。遇到新题卡住时,把这句话问一遍,经常能问出思路来。
具体到刷题节奏,我的建议是放在数组专题的中段去刷。前面先做几道简单的遍历题热手,再做这道容器题完成从暴力到双指针的思维跨越,然后再去挑战接雨水、柱状图中的最大矩形这类更复杂的场景。按照这个顺序走下来,整个双指针专题的坡度会缓和很多,不会一上来就被 hard 题劝退。如果你正在规划 Hot100 刷题顺序,可以参考这个安排试试。
