
第一次刷到 LeetCode 第 11 题“盛最多水的容器”时我的第一反应是这不就是两层循环枚举所有组合小学数学就能算面积直接暴力提交。真正动手之后才意识到算法题的意义从来不是“能算出答案”而是“在输入规模变大之后依然能快速算出答案”。这道题在双指针专题里非常经典表面上是数组操作实际上考的是你懂不懂容量公式背后的短板效应以及能不能证明一个贪心式的移动策略是安全且完备的。这篇文章会把题目拆解、暴力思路、双指针推导、代码实现、边界情况、复杂度分析和面试实战一次讲清楚。你不用事先掌握什么高深算法只要能理解“容器能装多少水由短边决定”这句话剩下的一切都可以顺着推出来。无论是刚开始刷题的新手还是想系统整理双指针套路的老手跟着完整过一遍这题收获都不会差。1. 问题拆解与核心公式1.1 输入输出长什么样题目给了一个非负整数数组height下标从 0 开始height[i]表示第 i 条竖线的高度。你需要从中选出两条竖线这两条线和 x 轴围成一个容器最后返回这个容器能装下的最大水量。举个例子height [1,8,6,2,5,4,8,3,7]这个用例答案是 49。对应的是下标 1 那条高度为 8 的线和下标 8 那条高度为 7 的线两个下标之间的距离是 7高度取较短一边 7所以水量就是 7 × 7 49。先把这个底层公式记牢后面所有优化都围绕它展开。需要注意题目只要求选两根线不要求这两根线相邻也不要求数组有序。你甚至可以选第一根和最后一根只要它们的下标满足 i j 就行。输入长度至少是 2因为一根线不可能围成容器。1.2 容量公式为什么是“短板 × 宽度”容量公式长这样area min(height[i], height[j]) × (j - i)为什么是min而不是max用生活经验想一遍就懂了。你往两块高度不同的挡板中间倒水水一定会从较低的那块板边缘溢出去水面高度永远被矮板限制。这个现象就是我们常说的“木桶效应”决定容器容量的不是最高的木头而是最短的那块。宽度部分也直观两线之间隔得越远容器底部就越宽。下标差j - i就是实际宽度。这道题容量就由两个因素决定短板高度和下标距离。题目希望你在“距离”和“高度”之间做权衡这也是整个算法推导的出发点。1.3 短板效应决定了优化方向很多初学者记住了公式里的min却没有真正理解它在优化时有多大的分量。当你选定两条线时想让容量变大只有两个方向把宽度拉大或者把高度下限抬上去。宽度最大时两条线一定在数组两端。之后每向内移动一次宽度只会变小不可能变大。所以唯一能够补偿损失的变量就是高度。换句话说优化目标不是单纯寻找最高的那根线而是寻找“两根线都尽量高并且距离不要太近”的组合。双指针解法之所以高效就是因为它把这两个因素的权衡过程压缩到了一次线性扫描里。2. 暴力解法先把正确答案握在手里2.1 两层循环怎么写先从最直观的暴力解法开始。既然要求选两条线那就把所有 i j 的组合全部枚举一遍计算面积并持续更新最大值。代码非常短def max_area_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) if area ans: ans area return ans这段代码的正确性没有任何悬念它遍历了所有配对组合不可能漏掉任何一个。数组长度小的时候比如十个元素总共 45 种组合一眨眼就算完了。暴力解法在面试里做第一版回答完全没问题它还承担了一个很重要的作用作为正确性基准用来验证后续优化算法。2.2 为什么暴力解撑不住大数组暴力解法的时间复杂度是 O(n²)。这里的风险不是“不够优雅”而是“跑不完”。假设数组长度 n 100000组合总数约为 n×(n-1)/2大约是 5×10⁹ 次计算。就算每次算面积只需要一个纳秒量级整体也要好几秒甚至更长时间在在线评测环境里基本都会超时。这里需要建立一种规模感当题目数据范围来到 10⁵ 甚至更大时O(n²) 基本就是不可接受的。面试官期待的是 O(nlogn) 甚至 O(n) 的方案。所以看到“求最大”并且数据规模很大时第一反应不应该是枚举全部组合而应该去想是否存在某种单调性可以跳过大量无效配对。2.3 暴力解在调试里的意义不要觉得暴力解说出来很没面子。我在实际刷题和本地写小工具时经常先写一个暴力版本再用它和优化版本做对拍。对拍就是生成一堆随机小数组分别跑两个函数比较结果是否完全一致。这个方法极其好用。因为小数组规模小暴力解也很快一旦发现结果不一致就能迅速定位到优化算法的问题。刷算法题和工作写代码一样先有“正确但慢”的版本再去改进“快且正确”的版本风险会低很多。后面第 5 节我会再展开对拍的完整脚本。3. 双指针解法推导与正确性证明3.1 先把宽度拉满双指针解法的切入点很简单既然容量和宽度正相关不如一开始就选最远的两个位置也就是左指针指向 0右指针指向 n-1。这时的宽度是数组能提供的最大值。接下来要面临的问题是宽度不能继续增大只能向内收缩。每次收窄一步宽度必然减少 1。那么聪明的做法就只剩一种每次移动时尽量让新窗口有更高的高度下限以便弥补宽度损失。换句话说哪边矮就想办法把哪边换掉。这不是什么高深道理你在生活里也这么处理。手里拿了一块短板和一块长板你想装更多水肯定会优先去换掉矮板而不是把高板扔掉再换一根。高板代表着潜力矮板才是当前真正卡脖子的地方。3.2 移动较矮指针的逻辑假设当前左指针高度是h_left右指针高度是h_right。如果h_left h_right那么当前面积由左指针的矮板限制。这时候如果把右指针往内移动会发生什么宽度变小而最小高度还是由h_left决定所以新面积不可能超过当前面积。更重要的是当前这个左指针无论和右边哪一根线配对宽度都不会超过现在的right - left高度也不会超过h_left所以容量都不可能比当前更好。既然当前左指针已经“参与不出更大结果”把它丢掉就是安全的。反过来如果左比右高就用同样的逻辑丢掉右指针。每一步移动较矮的那一侧看似只是贪心操作实际上每次都在排除一批确定不可能成为最优解的配对。3.3 为什么不会错过全局最优解这一小段是双指针题目的灵魂面试也经常追问。我们用反证法来想。假设全局最优解是下标对(i*, j*)。算法最终会有一个时刻左右指针分别抵达或越过某个最优端点的附近。真正需要担心的是某一步会不会把这个最优配对丢掉关键点在于我们丢弃策略只会丢掉“在当前配对中较矮”的那一侧。丢之前这一侧无论和窗口内任何其他位置配对容量都不可能超过当前面积所以这一侧已经被“榨干”了。既然它参与的所有未来组合都产生不了更大的值那么最优解如果还要用到这一侧最优值就必然已经被当前面积覆盖或者会小于等于当前值。换句话说如果一个端点注定要被排除那一定是因为它已经无法创造新纪录了。全局最优组合真正有价值的那一侧一定会被继续保留。随着指针不断收拢算法最终会碰到最优组合或者早就用至少一样大的面积覆盖了它。这个推理逻辑可以套用在很多双指针题目上面试时把它说清楚比单纯念代码加分得多。3.4 经典用例手动推演用官方示例[1,8,6,2,5,4,8,3,7]来走一遍双指针过程。初始 left0right8宽度是 8面积是 min(1,7)×88。由于左边更矮左指针向右移动。轮次leftrightheight[left]height[right]面积当前最大值移动方向1081788left → 1218874949right → 7317831849right → 6416884049right → 5515841649right → 4614851549right → 371382449right → 281286649right → 1走到 left 和 right 相等时循环结束最终答案是 49。仔细观察会发现49 在第二轮就被找到了后面的轮次全部在验证它确实是最大值。这个现象在双指针题目里很常见最优解往往很早出现后面更多是在排除剩余可能性。还有一个隐藏细节第 4 轮两个高度都是 8此时移动哪边都可以。代码里写成不满足“左矮”就移动右指针其实完全不影响正确性因为两边高度相等时任何一侧都无法靠当前配对产生更优解。4. 代码实现与关键细节4.1 Python 实现from typing import List class Solution: def maxArea(self, height: List[int]) - int: left, right 0, len(height) - 1 ans 0 while left right: area min(height[left], height[right]) * (right - left) if area ans: ans area if height[left] height[right]: left 1 else: right - 1 return ans这段代码没有任何多余操作完全对应推导逻辑。Python 写起来最简洁min函数和while循环都很直白。在 LeetCode 上提交时这类写法的时间和内存表现通常都非常好。4.2 C 和 Java 实现竞赛选手或者主要用 Java 面试的同学看下面两个版本class Solution { public: int maxArea(vectorint height) { int left 0, right height.size() - 1; int ans 0; while (left right) { int area min(height[left], height[right]) * (right - left); ans max(ans, area); if (height[left] height[right]) { left; } else { --right; } } return ans; } };class Solution { public int maxArea(int[] height) { int left 0, right height.length - 1; int ans 0; while (left right) { int area Math.min(height[left], height[right]) * (right - left); ans Math.max(ans, area); if (height[left] height[right]) { left; } else { right--; } } return ans; } }两种语言写法几乎一模一样。C 版本要注意vector的长度是size_t这里用int接收没问题因为题目约束长度不会超出int范围。Java 版本里Math.min和Math.max都是静态方法直接用即可。4.3 最容易写错的三个点第一个坑是循环条件写成left right。当两个指针相遇时宽度为 0虽然算出来的面积是 0不影响答案但会让循环多做一次无意义计算。更严重的是如果移动逻辑写错可能出现 left 超过 right 再继续访问数组的越界错误。稳妥的写法就是while (left right)。第二个坑是把移动指针写到了计算面积之前。先缩小窗口再算面积会导致第一轮漏掉最宽的那个组合答案直接算错。顺序必须是“先算当前面积再决定移动谁”。第三个坑是相等时不知道该动哪边。其实两边都行但面试里最好统一规则。我习惯写成if (height[left] height[right]) left; else right--;也就是相等时移动右指针。这个规则固定下来后面试追问时回答也会更有底气。5. 边界情况与容易踩的坑5.1 测试用例速查表写算法题最忌讳只盯着官方示例边界用例才是题感的分水岭。下面几个场景我每次复习都会过一遍。输入输出说明[1,1]1最小区间两根等高等距的线[0,0,0]0全零没有任何可用的容器高度[1,2]1只有一根有效宽度[1,2,3,4]4递增序列最优在两端附近[4,3,2,1]4递减序列跟递增对称[1,8,6,2,5,4,8,3,7]49官方示例用来验主流程[3,9,3,6,7]21验证移动策略是否正确的敏感用例这些用例都不长手动推演一遍也很快。尤其是[3,9,3,6,7]它专门用来检测“如果移动较高指针会怎样”下面单独展开。5.2 一个有力的反例有人会在面试中脱口而出“移动较高的指针不是更合理吗毕竟高板决定潜力”。这个直觉是错的看[3,9,3,6,7]就知道。正确双指针从 left0right4 开始面积是 min(3,7)×412。因为左侧更矮所以移动 left下一步 left1right4面积是 min(9,7)×321这就是全局最优。如果反过来第一轮看到右侧更高就把 right 往左移right 变成 3下标 4 那根高度为 7 的线从此被丢弃。后续不管怎么移动都不可能再凑出(1,4)这个最优组合最后返回的错误值很可能是 12 或者更小。这个反例直观地说明了“移动较矮指针”不是一种感觉而是严密推理后的必然结果。5.3 本地随机对拍脚本如果你想在本地验证自己的双指针实现是否正确可以写一个简单的对拍脚本。暴力解和双指针解放在一起用随机小数组跑几千轮结果不一致就会立刻暴露。import random def brute_force(height): n len(height) ans 0 for i in range(n): for j in range(i 1, n): ans max(ans, min(height[i], height[j]) * (j - i)) return ans def two_pointer(height): 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 for _ in range(10000): n random.randint(2, 30) arr [random.randint(0, 100) for _ in range(n)] assert brute_force(arr) two_pointer(arr), arr print(all ok)把assert改成直接打印出错数组也行我习惯在本地看到具体输入方便追问题。对拍 1 万组随机数据两个函数全部一致基本可以放心提交。6. 复杂度分析与性能对照6.1 时间和空间复杂度双指针版本的时间复杂度是 O(n)。每次循环只移动一个指针两个指针从两端向中间靠拢总共最多移动 n-1 次所以是严格线性扫描。空间复杂度是 O(1)只用了 left、right、ans 几个变量没有额外数组也没有递归栈。暴力解法是 O(n²) 时间和 O(1) 空间。两者空间相同时间天差地别。这张复杂度对照表是面试最基本的表述解法时间复杂度空间复杂度暴力枚举O(n²)O(1)双指针贪心O(n)O(1)6.2 规模放大后的直观对比n 100000 时暴力需要约 5×10⁹ 次组合计算双指针只需要约 100000 次循环。差距大概是五万倍。我把这个数字记在心里后就明白了为什么面试题几乎不会接受纯暴力解的答案。当然实际运行时间和语言、机器环境都有关系但量级差异足以说明一切。你在面试时可以直接说“暴力是 O(n²)当数据到十万级别时会超时双指针是 O(n)即使百万数据也能轻松跑完”。6.3 为什么 O(n) 就是这道题的最优查询下界有同学可能会问能不能用二分、排序或者某种跳步方式做到比 O(n) 更快答案是不能。原因是这道题对输入没有任何结构性限制数组不是有序的高度分布完全未知。任何一个位置的元素都有可能成为最优配对的一员如果你跳过某个元素不看那就存在一组输入数据让这个被忽略的元素成为决定答案的关键。换句话说正确算法必须检查数组里的每个元素所以至少需要 O(n) 的读取量。双指针做到了时间和读取量都达到下界已经是理论最优。如果面试官继续追问这个问题你可以顺势说“所以后面任何类似‘用堆’‘用排序’‘用二分’的方案要么复杂度更高要么不够通用。”这种回答会让对方觉得你不只背会了代码还真的理解了题目的边界。7. 面试实战与同类题型扩展7.1 推荐答题话术与节奏实际面试时我建议按下面这个顺序来说第一步先给出暴力解法和复杂度。直接说“最直接的做法是枚举所有配对时间复杂度 O(n²)”不要急着直接跳最优解。表示你能分析问题。第二步提出宽度最大化的思路。说“因为容量等于短板高度乘以宽度我先让宽度最大也就是两个指针放在首尾”。第三步说明移动策略。说“每次比较两边高度移动较矮的那一侧。因为矮板决定了当前窗口的高度上限保留矮板继续收缩宽度只会让面积越来越小所以矮板在后续搭配里已经不可能创造新纪录”。第四步手动走一遍小例子。可以直接用[1,8,6,2,5,4,8,3,7]边移动边报面积把 49 这个答案脱口而出。第五步总结复杂度。一句“时间 O(n)空间 O(1)并且由于必须读全所有元素这已经是最优”就能收尾。这套话术逻辑完整不需要背稿子只要你真正理解了前面的推导面试时自然说得出来。7.2 同类题型横向对比双指针是一个非常通用的套路经常和其他题一起打包出现。比较下面几道经典题会很有收获。题目核心技巧时间复杂度11. 盛最多水的容器双指针从两端收敛移动较矮侧O(n)42. 接雨水双指针维护左右最高挡板O(n)167. 两数之和 II有序数组双指针夹逼O(n)15. 三数之和排序后固定一点双指针扫描剩余区间O(n²)这些题目都有一个共同特征某个量具有单调性比如数组有序、两端移动有某种必然的排除条件。找到这个单调性双指针就能把穷举压缩成线性扫描。刷题时如果遇到数组和两指针、求最优、有单调性的组合优先往双指针方向想。7.3 一句话复盘我自己复习这道题时会把它浓缩成一句话“先把宽度拉满然后每次丢掉当前较矮的那一侧因为它在任何更窄的窗口里都不再具备竞争力。”这句话既描述了算法行为也概括了正确性依据。如果你能把这句话用自己的语言解释给面试官听这道题就算真正吃透了。最后再分享一个实际刷题的小习惯做完这道题后我会顺手把[3,9,3,6,7]这个反例记录到本地题库里。它体积小、特性鲜明以后复习双指针题时每次看到都能提醒我“贪心策略必须建立在排除逻辑上而不是直觉上”。这个习惯让我在好几道同类型题上都少踩了坑你也可以试试。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。