1. 先读懂题:它考的其实是一道“木桶效应”应用题
1.1 题目描述与最容易忽略的隐含条件
先把这个题的题面原样摆出来:给你一个长度为 n 的整数数组 height,height[i] 表示在坐标 (i, height[i]) 处竖着一根垂直线。现在要你在这个坐标系里任意选两根线,让它们和 x 轴一起围成一个容器,问这个容器最多能装多少水。
很多人第一次看到这个题,第一反应是“这不就是求两根柱子之间围成的矩形面积吗?”对,但不完全对。容器能装多少水,不是由两根柱子里高的那根决定的,而是由矮的那根决定的。换句话说,两根柱子高度分别是 3 和 8,中间距离是 5,那么能装的水量就是 3 × 5 = 15。哪怕那根 8 的柱子再高也没用,水会从 3 那一侧漫出去。这个逻辑放到生活里就是典型的木桶效应:一个木桶能装多少水,取决于最短的那块木板,而不是最长的那块。
所以这道题真正在考的数学模型是:在数组里找两个下标 i 和 j,让min(height[i], height[j]) * (j - i)这个值最大。其中j - i是两根柱子的水平距离,也就是容器的底边长度。
我拿一道面试的真实场景说说。LeetCode 官方给出的示例是height = [1,8,6,2,5,4,8,3,7],答案是 49。这个示例选的是下标 1 和下标 8,也就是高度 8 和高度 7 两根柱子,水平距离是 7,所以面积是min(8,7) * 7 = 49。很多人一开始看答案会以为选的是最高的那根 8 和另一根 8,也就是下标 1 和下标 6,距离只有 5,面积是8 * 5 = 40,反而小了。这就是这个题最反直觉的地方:并不是柱子越高越好,还要考虑它们之间的水平距离够不够远。
1.2 为什么这道题能成为面试高频题
我在面试里出这道题,其实不只是因为它被 LeetCode 收录了。它是一道非常典型的“中等难度”题目,它有很清晰的暴力解法,也有很漂亮的最优解法,而且两种解法之间的思维跨度恰好能反映出一个候选人真实的算法功底。
如果候选人只能写出暴力解,那至少说明他具备最基础的枚举能力,能看懂题、能写出可运行的代码。如果候选人能在提示一句“能不能不用两层循环”之后,自己推导出双指针解法,那说明他脑子里是有数据结构与算法的整体图景的,知道什么时候该用什么样的套路去优化。这种差距,比背一百道题要真实得多。
另外这道题的知识点范围很干净,不会牵扯到堆、树、图这些复杂数据结构。它只考数组、双指针、还有一点点贪心思想。范围小不代表简单,正因为涉及的概念少,反而能更精确地看出一个人对“为什么这个解法是对的”这件事有没有想清楚。很多人能默写出双指针代码,但一追问“为什么移动矮的那一根不会漏掉最优解”,就卡住了。这恰恰是面试官最想挖的地方。
1.3 暴力解:先让思路跑通
不管面试还是刷题,我的习惯永远是先把暴力解写出来。一方面是给自己一个保底方案,另一方面是用它来验证后续优化解法的正确性。
暴力解的逻辑很简单:把数组里任意两根柱子的组合都算一遍面积,取最大值。这个没有任何技巧,两层循环直接枚举:
def max_area_bruteforce(height): n = len(height) res = 0 for i in range(n): for j in range(i + 1, n): cur = min(height[i], height[j]) * (j - i) res = max(res, cur) return res这段代码的时间复杂度是 O(n²),空间复杂度是 O(1)。在数组长度小的时候完全能跑,但一旦 n 超过 10 万,这个代码基本就废了。面试官让你做这道题,等的是你把 O(n²) 优化到 O(n),而不是看你能不能在同一个复杂度里抠那一点点常数。
暴力的意义还在于,它可以当作测试基准。你后面写双指针解法的时候,可以随机生成一堆小数组,把两个函数的输出对拍一下,这是最可靠的正确性验证手段。我写算法题一直保留这个习惯,后面也会再强调。
2. 双指针解法:为什么移动“矮柱子”一定不会错过最优解
2.1 从最宽的容器开始:双指针的基本框架
双指针是一种直觉上很自然的优化:既然我们要找两根柱子让面积最大,而面积等于min(height[i], height[j]) * (j - i),那我们可以先让底边最长,也就是一开始左指针指在下标 0,右指针指在下标 n-1。这时候水平距离是最大的,但容器高度可能不高。
接下来怎么办?我们想让面积更大,只有两种可能:要么变得更高,要么水平距离不要缩得太小。但水平距离在指针相向移动的过程中只会越来越短,所以唯一的希望就是柱子高度能变大。也就是说,每次移动指针时,我们要保留较高的一根柱子,放弃较矮的一根柱子,去尝试能不能在更靠里的位置找到更高的柱子来弥补距离的损失。
于是代码骨架就出来了:
- 左指针 left 初始化为 0,右指针 right 初始化为 n-1。
- 计算当前两根柱子围成的面积,更新最大面积。
- 比较 height[left] 和 height[right]。
- 左指针的柱子更矮,就 left 加 1;右指针的柱子更矮,就 right 减 1。
- 直到 left 和 right 相遇,返回最大面积。
这个流程非常简单,但核心问题在于:为什么移动较矮的那根就一定是安全的?万一正确答案藏在当前这根矮柱子和另一根柱子之间呢?
2.2 严谨版证明:为什么移动较矮的一侧是安全的
我用数学的方式说清楚这一点,这也是面试追问时最好用的回答思路。
假设当前左指针指向下标 L,右指针指向下标 R,且 height[L] < height[R]。那么当前容器的面积是height[L] * (R - L),因为容器高度受制于较矮的左柱子。
现在我问一个问题:如果把右指针往左移动,也就是尝试所有右指针在(L, R)之间的下标 k,那么由 L 和 k 组成的容器面积会是多少?它的面积是min(height[L], height[k]) * (k - L),因为 k 肯定小于 R,所以k - L < R - L。同时,min(height[L], height[k])一定小于等于 height[L]。因此,这个面积一定小于等于height[L] * (R - L),也就是一定小于等于当前面积。
换句话说,只要左指针保持在 L 这个位置,无论右指针怎么收,面积都不可能超过当前这组 L 和 R 形成的面积。既然当前左指针已经把它能贡献的最大面积算完了,那再继续留着 L 就没有意义了,只有把 L 往右移,让它离开这个位置,才有可能通过换一根更高的柱子来创造惊喜。
反过来,如果 height[L] >= height[R],那就对称地说明,只要右指针保持在 R,无论左指针怎么向右收,面积都不可能超过当前面积。所以此时应该把右指针向左移动。
这个证明最核心的一点,是“当前较矮的那根柱子已经达到了它所能达到的最大宽度的极限”。它不是被随便放弃的,是带着“当前面积已经是它作为瓶颈时理论上的最大值”这种结论被放弃的。这个结论比背口诀重要得多,因为面试官追问的时候,考的就是你能不能当场说出这一步。
2.3 正确性验证:收窄区间但不漏答案
光有上面的证明还不够,我建议你在理解之后再自己跑一遍一个简单例子,感受一下“为什么过程中会错过一些组合,但不影响最终答案”。
比如数组[2, 5, 4, 8, 3]。初始 L=0,R=4,height[0]=2,height[4]=3,面积是2 * 4 = 8。因为左边更矮,所以 L 右移到 1。此时 L=1,R=4,height[1]=5,height[4]=3,面积是3 * 3 = 9。因为右边更矮,R 左移到 3。此时 L=1,R=3,height[1]=5,height[3]=8,面积是5 * 2 = 10。继续移动较矮的左边,L=2,R=3,面积是4 * 1 = 4,最终最大值是 10。
你回头检查一下,左边那根高度为 2 的柱子有没有可能是最优解的一部分?它和右边最远的柱子组合面积是 8,而它和任何中间柱子组合距离更短,高度也被它自己限制在 2,所以最多也就是2 * 4 = 8,不可能是 10。这就是那个“安全”的含义:我们确实没有枚举到所有组合,但所有被跳过的组合,都被证明不可能成为最优解。
这个证明思路还可以这样理解:每一次移动指针,本质上是把“当前指针所指的那根柱子”从候选集里永久淘汰。淘汰它不是拍脑袋,而是基于它作为较矮一侧时,目前这个最大宽度已经锁死了它的面积上限。这个淘汰逻辑在每一步都成立,所以最终留下的最值一定是全局最值。
2.4 复杂度分析:为什么一定是 O(n)
空间复杂度不说了,只用两个变量和几个常数,是 O(1)。时间复杂度要注意看循环条件。
双指针解法里,left 只增不减,right 只减不增,两者相遇时循环结束。每次循环只移动一个指针,所以总的迭代次数最多就是 n 次。也就是说时间复杂度是 O(n),比暴力的 O(n²) 整整降了一个数量级。
这里有个面试爱问的点:有人会误以为每次移动一根指针,两个指针都可能移动,所以复杂度是 O(2n),其实常数 2 在渐进分析里不算数,O(2n) 还是 O(n)。但如果你把这个话说出去,反而显得你不懂什么叫渐进复杂度。面试时直接说“线性时间内遍历完成,总体 O(n)”就够了,多余的话一句都不用加。
3. 手写代码与关键细节
3.1 代码实现
代码本身很短,但短不代表好写。我先把最终版放出来:
def maxArea(height): left = 0 right = len(height) - 1 max_area = 0 while left < right: current_height = min(height[left], height[right]) current_width = right - left max_area = max(max_area, current_height * current_width) if height[left] < height[right]: left += 1 else: right -= 1 return max_area别看才十几行,里面有几个细节值得单独拎出来说。
第一,循环条件是left < right,不是left <= right。如果允许 left 和 right 相等,那两根柱子变成同一根,容器宽度为 0,面积也是 0,没有任何意义,而且还会造成多余的比较。实际代码里写<=也不会报错,但逻辑上不严谨。
第二,计算面积用的是min(height[left], height[right]),不是height[left] if height[left] < height[right] else height[right]这种写法。上面这种写法更简洁,而且自动处理了 height 为 0 的情况。
第三,移动指针的判断标准和面积计算用的“矮的那个值”是同一个逻辑。但要注意,你判断的是原始高度,不是计算面积后的任何中间量。有些初学者会在计算面积后顺手把较矮的一根“跳过”,这在某些变体里可能加速,但会引入边界风险,我先不建议这样写。
第四,当两根柱子高度相等时,我们的代码走的是else分支,也就是右指针左移。这其实是任意的。为什么?因为高度相等时,无论移动哪一边,当前面积都已经用到了两根柱子的上限。移动左边后,新的左柱子如果更高,那右柱子作为瓶颈仍然存在,但距离变短,面积不会超过当前值,除非新左柱子低到改变瓶颈,但那样面积更小。所以相等时移左移右都不影响最终结果。面试时如果你能把这个结论也顺手说出来,是很加分的。
3.2 从运行过程看代码为什么这么写
我用示例[1,8,6,2,5,4,8,3,7]手推一遍,你对照代码看会非常清晰:
| 轮次 | left | right | height[left] | height[right] | 面积 | max_area |
|---|---|---|---|---|---|---|
| 1 | 0 | 8 | 1 | 7 | 1×8=8 | 8 |
| 2 | 1 | 8 | 8 | 7 | 7×7=49 | 49 |
| 3 | 1 | 7 | 8 | 3 | 3×6=18 | 49 |
| 4 | 1 | 6 | 8 | 8 | 8×5=40 | 49 |
| 5 | 1 | 5 | 8 | 4 | 4×4=16 | 49 |
| 6 | 1 | 4 | 8 | 5 | 5×3=15 | 49 |
| 7 | 1 | 3 | 8 | 2 | 2×2=4 | 49 |
| 8 | 1 | 2 | 8 | 6 | 6×1=6 | 49 |
注意第 2 轮里,左指针已经指向了高度 8 的那根柱子,右指针指向高度 7 的柱子,得到 49。后面右指针一直往左收,面积始终没有超过 49,最终返回 49。
这里有个很反直觉的现象:第 4 轮两根柱子都是 8,距离是 5,面积是 40,明明两个柱子都很高,却不如第 2 轮的一根 8 和一根 7 来得大。原因就是距离少了 2,而容器高度没有提升。这再次验证了这个题的本质:高度和距离是互相制约的两个变量,不是单纯地追求某一个极端。
3.3 快速改成其他语言时要注意什么
Java 版本基本是照搬,但要注意数组长度别每次循环都调height.length,虽然现代 JIT 会优化,但写成变量更干净:
class Solution { public int maxArea(int[] height) { int left = 0; int right = height.length - 1; int maxArea = 0; while (left < right) { int h = Math.min(height[left], height[right]); maxArea = Math.max(maxArea, h * (right - left)); if (height[left] < height[right]) { left++; } else { right--; } } return maxArea; } }JavaScript 和 C++ 也基本一致,主要区别在类型声明和语法。这里有一个我要强调的共性坑:在 C++ 里,height[left] < height[right]如果写成<=会改变指针移动方向吗?不会,但对代码可读性没有提升,反而有人会困惑。我的建议是严格按if (左边 < 右边) 左移; else 右移;来写,这个分支结构简单清晰,不需要为了“相等时哪边都行”这种话去故意加一些花活。
另外在 JS 里要注意height可能是很长的数组,但双指针解法本来就不会爆栈,也没有递归,所以不用担心调用栈问题。真正需要担心的是面试现场手写时,把while写成for导致索引越界。
4. 边界情况、易错点与面试官追问清单
4.1 边界情况速查表
面试里写完代码,面试官一定会让你过一遍测试用例。数组题目的边界情况其实很固定:空数组、单元素、双元素、全零、全相同、单调递增、单调递减、极大极小值。
| 输入 | 预期输出 | 原因 |
|---|---|---|
[]或[3] | 0 | 少于两根柱子,无法构成容器 |
[4, 9] | 4 | 只有一种组合,宽度是 1,高度是 min(4,9)=4 |
[0,0,0] | 0 | 高度全为 0,面积永远是 0 |
[5,5,5,5] | 15 | 任意两根组合,最大宽度是 3,高度是 5 |
[1,2,3,4,5] | 6 | 单调递增时,最大面积往往出现在第一根和最后一根之间,1*4=4,2*3=3,3*2=6?实际上需要手算:[1,5]=4、[2,5]=6、[3,4]=6、[4,5]=4,最大值 6。这里说明单调并不代表只有两端有用 |
[5,4,3,2,1] | 6 | 对称情况,最大值也是 6 |
你可能会发现,单调递增或递减时,最大面积不一定出现在两端。因为虽然距离最大,但高度是线性变化的。这类测试用例最容易暴露出“以为双指针只要移矮的就稳了”这种理解误差——移矮的是正确的,但你必须理解“为什么移”,而不是机械地执行。
4.2 常见易错点
我见过好几个人把代码写成这样,结果测试用例只有部分能过:
# 错误示范 while left < right: area = min(height[left], height[right]) * (right - left) max_area = max(max_area, area) if height[left] <= height[right]: left += 1 if height[left] >= height[right]: right -= 1这个写法有两个问题。第一,第二个if不是elif,可能导致一轮循环里同时移动两个指针,跳过了某些本应检查的组合。第二,当两个if都成立时,left 已经变了,再拿新的height[left]去和旧的height[right]比,逻辑已经混乱了。
其他常见错误包括:面积公式写成max(height[left], height[right]) * (right-left),这直接违背了“木桶装水看短板”的定义;初始化left=0, right=0导致循环根本不进去;把height数组当成有序数组处理;还有的人在移动指针时用了while内层循环连续跳过多个相同高度的柱子,导致指针直接越过边界。
这里我想多说一句:连续跳过相同高度的柱子的优化并非完全不可取,某些特殊情况下确实能减少比较次数。比如数组是[5,5,5,5,5,5,5,5,5,8],从左往右看全是重复的 5,每次移动一次指针确实浪费。但如果要写这种优化,你必须非常小心地处理“跳过后的位置可能已经越界”和“跳过过程中是否错过了某个更优组合”这两个问题。在面试现场,除非你足够熟练,否则我不会推荐这种花活,老老实实每次移动一步最安全。
4.3 面试官可能会怎么追问
这个题最容易被追问的点,我觉得有四个。
第一个:为什么移动较矮的柱子不会漏掉可能的最优解?这个问题我在前面已经给出了完整证明,不再重复。关键是你能不能当场用“瓶颈已经达到理论极限”这句话把逻辑讲清楚。
第二个:如果数组长度特别大,内存装不下怎么办?这个问题有点偏系统设计,双指针解法本身只用 O(1) 空间,所以不会因为数组过大而增加内存压力。但如果数组是流式输入,你不能同时拿到全部数据,那就没法直接用双指针了。碰到这种追问,你起码要知道:流式场景需要换一种思路,比如维护一个左侧最大高度和右侧最大高度的辅助结构,或者用分块处理,具体要看能否接受精度或近似。
第三个:如果要返回能盛最多水的两个柱子的下标,而不是只返回水量,怎么改?这个非常简单:在更新max_area时,顺手把left和right记下来即可。Python 里可以额外维护ans_left和ans_right两个变量,最后返回它们。
第四个:如果把题目改成“盛最多水的容器,但是容器底部不是水平的”,也就是把数组想象成地形剖面,盛水的量会怎样变化?这个其实就是接雨水那道题的一部分。你如果把这个题和接雨水一起准备,面试时就形成一个完整的知识块了。
5. 相关题型串联与备考建议
5.1 双指针题型的共性套路
做题多了以后,你会发现双指针并不是一种具体的算法,而是一类场景下的解题框架。凡是遇到“在一个数组/字符串里,要找两个下标,使得某个由这两个下标构成的表达式最大/最小/满足某个条件”这种题目,都可以优先考虑双指针。
常见的可以归入这个框架的题目有:
- 三数之和:排序后固定一个数,另外两个数用双指针在区间内寻找。
- 最接近的三数之和:同样是排序后双指针。
- 盛最多水的容器:就是本题。
- 接雨水:这是双指针的进阶用法,要用 left_max 和 right_max 配合。
- 长度最小的子数组:滑动窗口本质上也属于双指针的变体。
- 最小覆盖子串:同样是滑动窗口。
这些题如果一个个孤立地刷,很容易觉得每道题都是新知识,但只要整理成“双指针/滑动窗口”这个专题,你会发现它们共享同一套思维模型:两个指针维护一个区间,通过调整左右端点的位置来逼近答案。区别只在于指针移动的条件不同、区间需要满足的性质不同。
5.2 从盛水到接雨水:一道题延伸出的完整知识块
接雨水和盛最多水的容器经常被搞混,我来帮有需要的朋友理一下。
盛最多水的容器是让你“选两根柱子围一个容器”,容器是虚拟的,中间没有东西挡着,水也不会漏到别的地方去。接雨水则是把整个数组看成一个地形剖面,当雨水降下来之后,有多少水会积在数组本身形成的坑洼里。
接雨水的经典解法之一是把每个位置能接的雨水量计算出来:这个位置左侧最高的柱子和右侧最高的柱子中较低的那个,减去当前位置高度,如果大于 0,就是当前位置可以接到的雨水量。这个逻辑用双指针写起来也很顺:维护 left_max 和 right_max,哪边矮优先处理哪边。
两道题的共同点在于都要用“短板”来决定最终结果,都要用双指针实现 O(n) 复杂度。区别在于盛水题只需要一个全局最大面积,而接雨水题需要逐位累加。如果你能把这两道题对比着刷,对双指针和“贪心思想”的理解都会更深一层。
5.3 刷题之外的一点心得
这道题我用不同语言写过不止十遍,每次给学生讲课、给候选人面试,我都会下意识地把这道题拿出来当典型。它教会我的不是“这个答案要背下来”,而是“当一个问题的短板可以被明确识别时,它的优化方向往往就藏在短板里”。
最后的最后,如果你正在准备面试,我建议你做这么一件事:把这题的代码从 Python、Java、C++ 三种语言各写一遍,然后用随机生成的大数组去验证,确保逻辑没有因为语言差异而写错。写作顺序上,先在白纸上画一遍指针移动的过程,再动键盘。等你能够用两分钟把双指针思路和“为什么移矮的”这个证明流畅讲出来,这道题才算真正吃透了,而不是背会了。