ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

双指针解“盛最多水的容器”:从暴力到O(n)的最优路径

双指针解“盛最多水的容器”:从暴力到O(n)的最优路径 1. 从题面到思路先别急着写代码力扣第11题“盛最多水的容器”是我在刷热题100时反复做的一道题。说它经典是因为它看起来简单但背后的双指针思想能串起一大类数组问题。说它常考是因为面试官喜欢在这种题上考察你能否从暴力解法中跳出来找到单调性。题面其实很直白给一个非负整数数组height每个元素代表一条垂直于 x 轴的线的高度下标代表线的位置。要你找出两条线和 x 轴一起构成一个容器使得容器能装的水最多。容器的盛水量怎么算初中物理大家都懂水的高度取决于较短的那块板也就是木桶效应。所以容器的容量公式是容量 min(height[i], height[j]) * (j - i)注意这里的 j - i 不是 j - i 1。两条线各占一个位置容器底边的长度是两条线之间的距离也就是下标的差值不需要额外加一。这个细节有同学第一次写的时候会搞错。暴力解谁都会双重循环枚举所有(i, j)组合取最大值。时间复杂度 O(n²)在n接近 10 的 5 次方时直接超时。力扣给的数据范围一般是height.length在 2 到 10 的 5 次方之间暴力一定过不了。那怎么优化我第一次刷这道题时第一反应是能不能排序但仔细一想排序会改变线的原始位置而下标直接参与底边计算排序这条路根本走不通。能不能用单调栈似乎也意义不大因为我们关注的是任意两条线的组合不是找某个方向上的“下一个更大/更小元素”。后来我仔细琢磨了一下发现一个关键点容器的宽度越大底边越长容量本身就有天然优势。既然如此我们为什么不从最宽的容器开始试也就是先选最左边和最右边的线然后往中间收缩。这就是双指针的雏形。2. 双指针思路为什么移动较矮的一边才是正解先固定两个指针左指针left 0右指针right height.length - 1此时容器的宽度最大。接下来要考虑的是指针往中间挪宽度变小但高度有可能变大。我们要在“宽度变小”和“高度变大”之间找平衡。关键决策点来了这次移动到底是left还是right--先说结论移动较矮的那条线对应的指针。为什么我们从容量公式看容量 min(height[left], height[right]) * (right - left)假设height[left] height[right]也就是左边矮当前容量由左指针的高度决定。此时如果移动较高的右指针会发生什么我们分两种情况分析新的height[right]比原来的height[right]更高但由于 min 取的是两个高度中的较小值而左指针依然较矮所以 min 值不会变宽度却变小了容量必定下降。新的height[right]比左指针还矮min 值反而变小了宽度也变小容量下降更多。也就是说只要移动较高的指针无论新高度如何容量都不可能大于当前值。这是这道题最核心的单调性。反过来如果移动较矮的左指针情况就不同了。虽然宽度变小了但新的左指针高度有可能比原来高从而让 min 值变大。一进一出容量有可能变大也有可能变小但这至少保留了一个“变好”的可能性。所以整个算法的策略就是每次计算当前容量更新答案 然后比较 height[left] 和 height[right] 移动较矮的那一个 直到 left right 时停止。这个过程只需要遍历数组一遍时间复杂度 O(n)空间复杂度 O(1)。打个比方就像打牌时你手里有一对牌你每次都想换掉较小那张看看能不能摸到更大的。一旦你决定换大的那张那手牌只会越换越差不可能更好。这个类比虽然不是特别精确因为我们同时还要受宽度影响但核心逻辑是通的。3. 代码实现C 主解法与多语言对照思路理清楚之后代码写起来就非常快了。我用 C 写的版本如下class Solution { public: int maxArea(vectorint height) { int left 0; int 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; } };有几个细节值得注意第一height[left] height[right]时怎么处理我在代码里是else直接right--也就是默认移动右指针。但其实移动哪边都行因为两边一样高的时候移动任一边都不会错过最优解。严谨一点说不管移动哪一边容量都不可能超过当前值依然符合单调性。有的大佬在相等时会做left; right--;同时移动也是正确的。这不会导致漏解因为如果 left 自增之后遇到更高的高度那此时右指针已经让位给了左边这组新的组合仍然会在后续遍历中被覆盖到。第二ans 初始化为 0 就够了。因为所有高度都是非负数容量至少为 0不需要设置为极小值。第三right - left不要写成right - left 1。前面已经强调过容器底边是两条线之间的距离是“间隔数”而不是“占位数”。Python 版顺手也贴一下求职面试用 Python 的同学可以直接抄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) ans max(ans, area) if height[left] height[right]: left 1 else: right - 1 return ansJava 版也不复杂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; } }三种语言的逻辑完全一致刷题时选自己最熟的语言写一遍其余语言看懂思路即可。4. 这个题背后的“为什么”比代码更重要很多刷题笔记写到代码就停了但我一直觉得这题如果只背下双指针模板那和没刷差不多。真正值得琢磨的是为什么双指针对这道题有效这个思想能不能迁移到其他题我们再来把推导过程抽象一遍。一开始我们有两个边界容器宽度最大。此时容量由较矮的边限制。如果我们不做任何移动那答案就是当前值。如果我们想找到更大的容量唯一的希望是让“较矮的那条边变高”。而较矮的那条边的位置要么在左边要么在右边但一定不是较高那条。所以移动较高那条边是毫无意义的——因为无论怎么移它都不会成为“瓶颈”不会对容量公式中的 min 部分做出任何贡献。而移动较矮那条边才有可能换上一个更高的边进而提高整个容器的盛水量。这种“只移动限制项”的思路在很多题目里都能看到影子。比如力扣第 42 题“接雨水”也是典型的双指针题目。那道题里我们需要分别维护左右两侧的最大高度通过比较left_max和right_max决定从哪边开始结算水量。思路同样是“哪边限制当前水位就先处理哪边”。再比如力扣第 11 题的变体有些面试官会改成“三条线中选两条使得容器最大”或者“在二维平面上选四个点组成矩形使面积最大”本质上都是先找边界再根据单调性收缩。记住一个判断标准当问题的答案由两个“可变的端点”共同决定且移动任意一端后可以通过数学推导排除一部分不可能的情况时双指针往往是高效解法。这比死记硬背模板有用得多。5. 实操心得刷题过程中最常见的三个坑我刷这道题刷了不止一遍也帮很多同学 review 过代码发现大家的错误集中在几个点。这里直接整理成避坑清单。坑一在 while 循环里忘了更新答案。有些同学先把指针移动写在了答案更新前面结果最后返回的是“移动后的容器容量”漏掉了最宽容器本身。我建议代码顺序固定住先算当前面积再更新 ans最后移动指针。逻辑清晰也不容易漏。坑二把 height 当成排好序的数组来推理。数组的原始下标对应 x 轴位置这个位置是参与体积计算的。一旦排序坐标信息全丢。我最初也想过先按高度排序再套单调栈但很快意识到不可行。记住凡是答案和“位置”强相关的题排序需要格外谨慎。坑三认为双指针要“两边同时收窄”。有些博客写的是“从两边向中间靠拢”容易让人误解。实际上双指针每次只移动一边不是两边一起移动。而且移动的是“较矮的那一边”不是“随便一边”。这两点理解错代码方向就全错了。我自己的习惯是每道题刷完会在笔记里补一段“如果是我在面试现场我会怎么和面试官讲思路”的话。对这道题我会说先用暴力法确认公式然后从最宽区间开始。计算当前面积后由于面积受限于较矮的边移动较矮的边才有可能让面积增大移动较高的边面积只减不增因此每次移动较矮的边并用一个变量维护最大值。这样的表达面试官一听就知道你是真的理解而不是背了答案。6. 延伸思考如果数据变了这个解法还能用吗这题还有几个常见的扩展变形这里一并说一下。变形一如果 height 数组非常长双指针 O(n) 仍然是最优吗从比较模型的角度看这个问题的下界就是 O(n)因为你至少需要知道所有线的高度才能确定答案。所以双指针就是渐进最优解没有更快的可能。面试时如果被追问“能不能 O(log n)”可以直接回答不可能。变形二如果数组里允许出现负数“盛水容器”在日常生活中不可能出现负高度但有些题目会改成“面积最大矩形”之类的说法此时负数就意味着“低于水面”。不过力扣这道题明确限定是非负整数所以不必过度发散。真要考虑一般情况公式本身依然成立只是 min 可能取负数没有实际物理意义。变形三如果要求输出这两条线的下标而不是最大容量只要在更新 ans 的时候同步记录left和right即可代码改动不超过三行。很多面试官会顺手加这么一步追问考察你是否理解了每一步在做什么。变形四如果不止选两条线而是选三条线中的两条其实本质上就是原题因为第三条线对盛水不起任何作用。但有些题目会把三条线的存在加上“中间隔板”的设定此时就复杂了涉及前缀最大值。这类题已经脱离第 11 题本身的范围属于中等偏难的组合题了。7. 小结式经验这道题教会我的不只是“双指针”我在实际刷题过程中最受用的不是背下解法而是养成了一种习惯任何一道题先花 5 分钟推导“为什么这个方案是对的”。很多同学刷题是“看题 → 看答案 → 背下来”这在短时间内看起来刷得快但两周后基本全忘。而我个人的方法是先自己写暴力解法哪怕是 O(n²) 也行然后想一想哪里浪费了计算再想想能否贪心地跳过某些不可能的情况最后把跳过的逻辑写成注释附在代码旁边。比如这道题我的笔记里就写了一句移动较矮的指针不会错过最优解因为较高指针不是当前容量的瓶颈移动它只会让容量减少没有任何好处。这句话以后遇到任何双指针题都能复用。如果你正在刷力扣热题 100我强烈建议你把第 11 题和第 42 题放在一起刷。第 11 题是“容器”第 42 题是“接雨水”。两道题长相不同但骨子里都在用双指针处理“高度瓶颈”问题。刷完这两道你对双指针的掌握会直接上一个台阶。另外我自己在代码里还习惯先判断一下height.size()是否小于 2不过力扣的约束保证height.length 2所以这道题不需要特判。但在实际工程里这种防御性判断最好还是加上万一输入是空数组height.size()减 1 就是负数循环条件会直接出问题。最后再分享一个小技巧如果你在本地跑代码想验证双指针算法的正确性可以随机生成一个长度 200 左右的数组用暴力法跑一遍再用双指针跑一遍对比结果。我每次怀疑某个贪心策略时都用这种随机对拍的方式验证。这个方法比看十篇题解都靠谱。这道题我当年就是这么验证过来的从此对双指针的信任度直线上升。
返回列表