1. 为什么这道题值得反复咀嚼
1.1 题目本身与第一印象
先花三十秒把题目过一遍:给定一个长度为n的整数数组height,数组里的每个数代表一根垂直线的高度,需要从中挑出两条线,连同 x 轴组成一个容器,求这个容器最多能盛多少水。
我第一次刷到这道题时,第一反应是"这不就是找两根最高的柱子吗"——这个直觉错得离谱。容器的盛水量由两个因素决定:短板高度和两线之间宽度。数学表达式非常简洁:
水量 = min(height[i], height[j]) * (j - i)i和j是两根线的下标,min(height[i], height[j])是木桶效应的最直接体现——水永远从矮的那边溢出,高的那部分高度根本派不上用场。这个公式从题目看完的那一刻就该刻在脑子里,因为后面所有优化的思路都是从它推导出来的。
这道题是 LeetCode 热题 100 里的第 11 题,也是各大厂笔试面试的高频题。但说实话,把它刷过一遍容易,真正把双指针的贪心逻辑想透,需要多花一些时间。我见过不少候选人能背出解法,但被追问一句"为什么移动矮的指针,而不是移动高的指针"就卡住了。这篇复盘就是想把这一层窗户纸捅破。
1.2 为什么它能进入"热题 100"
热题 100 是对海量面试反馈筛选出来的题目集合,能进这个榜单的题通常具备两个特征:高频出现和背后思想可迁移。盛最多水的容器恰好两点都占。
从考点分布看,它既不涉及复杂的动态规划状态定义,也不需要什么数据结构基础,一块白板、一支笔就能在面试中开展讨论。更重要的是,它把双指针和贪心策略结合得非常自然,而且可以通过逐步追问把面试者从暴力解引导到最优解,整个过程非常考察思维链条的完整性。
从面试官视角看,这道题是一个绝佳的考察工具:第一层看候选人能否写出暴力解并分析复杂度;第二层看能否通过数学直觉发现状态可以剪枝;第三层看能否严谨地证明贪心选择的正确性。大多数人都倒在第三层,而第三层恰恰是这道题真正的价值所在。
2. 从暴力枚举到双指针的思维跃迁
2.1 暴力解法的天花板在哪里
拿到题目最朴素的想法当然是枚举所有下标对(i, j),计算每种组合的盛水量并取最大值。
def maxArea_bruteforce(height): n = len(height) ans = 0 for i in range(n): for j in range(i + 1, n): area = min(height[i], height[j]) * (j - i) ans = max(ans, area) return ans逻辑一丁点问题都没有,但它有一个致命短板:时间复杂度是O(n^2)。当n = 10^5时,需要执行约5 * 10^9次计算,在现代机器上也要跑几十秒,提交上去必然超时。LeetCode 对这道题的约束是n最大为10^5,所以暴力解在工程上是不可接受的。
我早期刷题时有个习惯,遇到O(n^2)先不急着想优化,而是问自己一个问题:这些枚举的组合里,有多少是"注定不可能成为答案"的?这个问题是通向双指针的钥匙。以盛水问题为例,任意两根线的组合都要计算一次,但事实上有些组合根本不需要算——它们的上界已经被现有结果限制住了。一旦意识到这一点,"逐个枚举"这个框架就开始松动了。
2.2 双指针解题的直觉从哪里来
双指针解法很优雅:两个指针left = 0、right = n - 1分别指向数组两端,计算当前容器容量,然后移动高度较小那一侧的指针,重复这个过程直到两个指针相遇,全程维护最大值。
def maxArea_double_pointer(height): left, right = 0, len(height) - 1 ans = 0 while left < right: area = min(height[left], height[right]) * (right - left) ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1 return ans这段代码短到让人怀疑它是否漏掉了什么,但它就是正确答案。时间复杂度O(n),空间复杂度O(1)。
问题来了:凭什么"移动矮的指针"不会错过最优解?很多人记这个策略只是机械记忆,没有真正理解其内在逻辑。我在实际面试复盘和带人刷题的过程中发现,卡住大家的不在于代码本身,而在于右指针起始位置的选取、高度相等分支的处理这类细节,更在于对"为什么必须移动矮的一侧"的数学直觉。如果这个直觉没建立起来,换个类似的题(比如接雨水)就又会懵。所以我想花一整节来拆解这个证明过程,它是这道题真正的"题眼"。
3. 核心证明:为什么移动矮指针不会错过最优解
3.1 一段简明但严谨的推理
记当前左指针指向height[left],右指针指向height[right],不妨假设height[left] <= height[right]。此时容器容量为:
area = height[left] * (right - left)现在关键的一步来了:如果固定左指针left不动,让右指针right向左移动,任何移动后的新容量都不可能超过当前容量。为什么?
因为移动右指针后,宽度right - left一定变小了;而新容器的高度是min(height[left], height[new_right]),它最多是height[left]——左指针没变,矮板仍然是那根矮的。用一个不等式表达:
new_area <= height[left] * (new_width) < height[left] * (right - left) = area结论非常直接:以当前的left为左边界,和任何一个位于它右侧的线搭配,产生的容量都不可能超过当前值。也就是说,当前这轮比较结束之后,left这跟柱子已经"再也无法产生更优解了",它可以毫无遗憾地被排除出搜索空间。
既然如此,我们自然应该把left向右移动,去寻找新的可能性。而当height[left] > height[right]时,对称地,right这跟柱子也失去了继续留在搜索空间的理由,应该向左移动。
这个论证的本质是一种状态剪枝:每次移动前,我们其实判断了当前指针所代表的那一侧已经不可能成为最优解中"较矮的那条边"。有人可能会觉得"万一它和更远的柱子搭配能更大呢"——但刚才的推导锁死了这个可能:一旦它是较矮的一侧,高度上限被它自己锁死,而宽度又在减小,所以同侧的所有更远组合都不可能超过当前面积。正因为每次都能安全地排除掉一侧,双指针才敢大步流星地向中间收缩,省掉了海量无效计算。
3.2 用一个完整例子走一遍
理论推完了,我们用 LeetCode 官方示例[1,8,6,2,5,4,8,3,7]完整模拟一遍指针移动,加深肌肉记忆。
初始状态:left = 0, right = 8,对应高度1和7。
- 容量 =
min(1, 7) * 8 = 8。1 < 7,移动左指针。
left = 1, right = 8,高度8和7。
- 容量 =
min(8, 7) * 7 = 49,目前最大值 49。8 > 7,移动右指针。
left = 1, right = 7,高度8和3。
- 容量 =
min(8, 3) * 6 = 18,没能超过 49。8 > 3,移动右指针。
left = 1, right = 6,高度8和8。
- 容量 =
min(8, 8) * 5 = 40,小于 49。8 == 8,这里移动左边或右边都可以,我们约定移动左指针。
left = 2, right = 6,高度6和8。
- 容量 =
min(6, 8) * 4 = 24,不能刷新最大值。6 < 8,移动左指针。
left = 3, right = 6,高度2和8。
- 容量 =
min(2, 8) * 3 = 6。2 < 8,继续移动左指针。
left = 4, right = 6,高度5和8。
- 容量 =
min(5, 8) * 2 = 10。继续移动左指针。
left = 5, right = 6,高度4和8。
- 容量 =
min(4, 8) * 1 = 4。此时left == right,循环结束。
最终答案是 49,与预期一致。
注意一个有意思的细节:最优解的两个下标是1和8,而我们在第一轮就把高度为1的左边界排除掉了,因为以它作为矮边的最优容量也就8而已。这种"明知它不行就直接放弃"的魄力,正是贪心思想的精髓——每一步都做出当前看起来最优的选择,并且每一步丢弃的选择都有严谨依据,不会对最终答案产生威胁。
3.3 高度相等时怎么处理
高度相等的情况是评论区经常争论的点。当height[left] == height[right]时,移动左指针还是右指针?
答案是:都可以,不影响最终正确性。但需要明确一点,如果只移动一侧,另一侧仍然可能在下一次比较中产生更大的容量吗?比如[8, 1, 8],初始left = 0, right = 2,容量 =min(8, 8) * 2 = 16。移动左边,left = 1, right = 2,容量 = 8,答案仍是 16。移动右边,left = 0, right = 1,容量 = 8,答案同样还是 16。所以平局时哪边都行。
不过在代码里,如果写成if height[left] < height[right]: left += 1 else: right -= 1,那么相等时移动的是右指针。这只是一个实现细节,对算法正确性零影响。真正需要在意的是另一件事:跳过较短边时会不会连着跳过潜在最优解?我们的数学证明已经给出了否定答案,只要移动的那侧是"较矮或等高"中的矮方,排除它就是无损的。
4. 代码实现与复杂度分析
4.1 谈谈代码里的细节
双指针的代码虽然短,但真正手写时还是有几个容易出错的地方。
第一个是循环边界条件。应该是while left < right还是while left <= right?我们知道容器至少需要两根线,所以当left == right时再算容量已经没有任何意义,循环条件用<即可。
第二个是最大值初始化。ans初始化为 0 是安全的,因为盛水量不可能为负。如果题目改成了别的约束,初始化值需要谨慎选择,但本题的最小合法值是 0,直接用 0 即可。
第三个是避免重复计算。有些人在循环体内会先判断两个高度的大小关系,再决定移动哪边,但每次循环其实只需要做一次min和一次乘法,没必要把代码写复杂。下面这个版本是我认为最干净的:
def maxArea(height: List[int]) -> int: left, right = 0, len(height) - 1 ans = 0 while left < right: h_left, h_right = height[left], height[right] ans = max(ans, min(h_left, h_right) * (right - left)) if h_left <= h_right: left += 1 else: right -= 1 return ans这里的<=使得相等时移动左指针,纯粹是我个人的编码习惯,你也可以写成<然后移动右指针,效果完全一样。
如果要顺手练习 C++ 版本,逻辑几乎一致:
int maxArea(vector<int>& height) { int left = 0, right = height.size() - 1; int ans = 0; while (left < right) { ans = max(ans, min(height[left], height[right]) * (right - left)); if (height[left] < height[right]) left++; else right--; } return ans; }这些代码编译运行的时间都在个位数毫秒级别。n = 10^5的数组,双指针只需要最多n - 1次计算,和暴力的10^10量级拉开了十个数位的差距。
4.2 复杂度分析不能只背结论
时间复杂度和空间复杂度的结论很好记:O(n)时间和O(1)空间。但作为博主,我想多提醒一句:面试时被问到复杂度,不能只说结论,还要快速组织出推导过程。
- 时间:
left和right从数组两端相向而行,每次循环移动且仅移动一个指针,两个指针相遇时循环结束。循环最多执行n - 1次,每次只做常数时间操作,所以是O(n)。 - 空间:只用两个指针变量和一个答案变量,不需要任何辅助数组,所以是
O(1)。
这个复杂度推导之流畅,本身就说明了双指针算法在设计上的优越性——它几乎没有额外开销,却能把搜索空间从平方级压缩到线性级。
4.3 一个被很多人忽略的要点
刷题多了之后我意识到,双指针问题的本质是利用数据本身的单调性或对称性来剪枝。盛水问题里,高度的随机性并没有给我们某种全局单调性,但指针移动时,宽度单调递减,这构成了一个天然的约束:每走一步,可选范围都在缩小,而我们的贪心策略保证了缩小的同时不会把最优解丢掉。
理解这一点比背题重要得多,因为很多双指针题目都共享这个"剪枝哲学"。一旦掌握,你看到"求某个量最大"且暴力解是O(n^2)的题目时,第一反应应该是思考能不能用双指针让搜索空间线性化,而不是急着写暴力解。
5. 这道题背后的方法论:双指针与贪心的组合拳
5.1 双指针题型的识别信号
从盛水问题出发,我总结了一下什么题目适合用双指针。首先,题目涉及序列或数组,尤其是需要在其中找两个元素满足某种"最大化/最小化"条件的。其次,暴力解往往是枚举所有二元组,复杂度O(n^2),让人望而却步。再者,如果数组在某种维度上(排序后的大小、位置距离等)存在隐藏的单调性或对称性,就很可能是双指针的菜。
以盛水问题为例,"宽度"天然具有单调性:你从两端走,距离只减不增。至于高度,它不单调,但因为有min这个取小运算在,矮的那侧直接封顶了当前可能的最大值,从而让贪心策略有的放矢。
5.2 和同类型题目的横向对比
我把"双指针 + 贪心"这个组合常考的几个热门题放在一起做了个对比表,方便你复习时快速定位重点:
| 题目 | 核心思路 | 指针移动规则 | 复杂度 | 与盛水题的异同 |
|---|---|---|---|---|
| 盛最多水的容器 | 矮边乘宽度 | 移动较矮一侧 | O(n) | 基准题,最纯粹的双指针贪心剪枝 |
| 三数之和 | 排序后固定中间值 | 左右指针按和与目标值比较移动 | O(n^2) | 多了排序,依赖有序数组的单调性 |
| 接雨水 | 按列求水,用双指针维护左右最大值 | 左右哪边最大值小就走哪边 | O(n) | 同为双指针,但需要比较左右最大值 |
| 买卖股票的最佳时机 | 遍历中维护历史最低价 | 单指针即可,不算双指针 | O(n) | 贪心但结构更简单,只有一根指针 |
这个表格里,接雨水是很容易和盛水题搞混的进阶版。接雨水也需要双指针,而且也是"哪边低走哪边",但它的状态维护比盛水题多一层:不仅要看当前列的左右柱子高度,还要实时更新左右两侧的最大高度。如果你把盛水题吃透了,接雨水其实是一个很好的自然延伸练习。我甚至建议刷完盛水题的当天,就顺手把接雨水做了,趁热打铁把双指针的"左右最大值维护"手感练出来。
5.3 从这道题看贪心的适用边界
很多人会把贪心和动态规划搞混。就这道题而言,它贪在"每一步都移动短边",而这个贪心选择是安全的,因为我们证明了短边已无潜力。但如果问题变成"从数组里挑任意多个数,使某些收益最大",这种单步贪心往往就失效了,可能需要动态规划甚至更复杂的算法。
我踩过的坑是:有一段时间刷题,看到"最大"两个字就条件反射想贪心,结果在背包类题目上栽了好几次跟头。所以我想强调,贪心一定需要"每一步决策的局部最优能推出全局最优"的证明,不是所有最大化问题都能贪。盛水题是一个完美的正面教材:它的证明简洁、直观,又不失严谨。
6. 复盘与延伸:从一道题到一类题
6.1 复盘这道题给我的三点启发
第一次刷完盛水题时,我只记住了"移动矮指针"这个口诀。真正让我进步的是第二次、第三次反复推导,每一次都有新的收获。总结下来有三点:
一是不要轻视暴力解。暴力解不是无用功,它帮我们确认了问题的最朴素模型,而且很多优化思路是在暴力解的基础上观察规律得来的。我至今习惯先写一版暴力解(至少在草稿纸上),再去想怎么剪枝。
二是证明比答案更值钱。如果能给出"为什么移动矮指针一定不会错过最优解"的严谨论证,那么这道题才算真正掌握。这不仅是面试加分项,更是让你在未来遇到陌生题目时具备类比能力的根本。
三是警惕口诀式记忆。"移动矮指针"这句话我可以一秒说出来,但如果没有背后的数学直觉,遇到变种题就会露馅。同理,很多高频题都有类似的"惯用解法",但理解其原理才是长期记忆的开始。
6.2 一道变种题帮你检验掌握程度
最后留一个变种练习:如果题目改成"求能接住的总雨水量",即 LeetCode 第 42 题,你会怎么做?
提示几个关键点:单列能接的水量取决于它左右两侧最大高度中较低的一个减去自身高度;直接用暴力对每列找左右最大值是O(n^2);用两个额外数组记录每个位置的左侧最大值和右侧最大值,可以优化到O(n)时间和O(n)空间;继续用双指针维护左右最大值,可以把空间降到O(1)。
这道变种题和盛水题的正确代码结构有一些神似之处,但思维重心从"找一对线"转移到了"处理每一列的积水贡献"。如果你能不看题解独立写出O(n)时间和O(1)空间的解法,那么"双指针 + 贪心"这个组合拳就已经在你的肌肉记忆里了。我个人在带人刷题时最推荐这对组合题作为"双指针入门到进阶"的完美训练闭环。