多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从盛最多水的容器到接雨水:双指针与单调栈精讲

从盛最多水的容器到接雨水:双指针与单调栈精讲 1. 为什么这两道题必须放到一起学先说个刷题感受很多人在 LeetCode 上刷到“盛最多水的容器”时觉得双指针思路很好懂不就是左右两边往中间夹逼嘛。可一旦做到“接雨水”就卡住了感觉同样是数组题一个简单一个复杂完全没有意识到它们背后其实是一套东西。灵茶山艾府的基础算法精讲系列我一直在跟第 02 讲把这两道题编排在一起恰好解决的就是这个认知断层——接雨水是盛最多水容器的“逐列版本”盛最多水容器是接雨水的“单坑版本”。前者的双指针移动规则是“矮边往内走”后者的双指针移动规则其实也是“矮边往内走”只不过它把每个柱子当成一个独立的坑位来结算。把这个共同点看穿之后两道题就不需要背代码了完全可以从第一性原理推出来。这一篇笔记更适合处于两种阶段的人一种是刚开始刷基础算法题、双指针还停留在“背模板”水平的初学者另一种是刷过这两题但总觉得自己是“碰巧过了”换个条件就写不出来的人。我会把暴力解、双指针推导过程、单调栈写法、边界条件和容易踩的坑全部过一遍用的语言是 Python3。先说题目本身。“盛最多水的容器”是给定一组高度数组每个位置是一根竖着的柱子让你选两根柱子围成一个容器配合 x 轴问最多能装多少水。“接雨水”则是给定高度数组假设下了一场大雨问这些柱子之间能存住多少雨水。一个是找两根边界一个是算整片地形上的积水总量但核心都在“短板效应”一个盛水区域能装多少水永远由较短的那一侧决定而不是较长的那一侧。正是这个结论衍生出了双指针算法里最经典的一对移动规则每次移动较矮的那一侧。理解了这句口诀的严格证明这两道题基本就拿下一半了。2. 盛最多水的容器双指针的“淘汰制”逻辑2.1 先写暴力解看清问题的复杂度瓶颈如果你第一次接触这题最朴素的想法肯定是枚举所有两根柱子的组合from typing import List class Solution: def maxArea(self, height: List[int]) - int: n len(height) ans 0 for i in range(n): for j in range(i 1, n): area (j - i) * min(height[i], height[j]) ans max(ans, area) return ans假设数组长度是 n这个双重循环需要比较 n(n-1)/2 对组合时间复杂度 O(n^2)。LeetCode 上这题的 n 可以到 10^5 级别O(n^2) 意味着 10^10 次操作Python3 根本跑不完。暴力解的价值在于它让我们意识到这题真正要解决的是“如何省掉那些没必要算的组合”。很多初学者觉得双指针是玄学其实双指针的本质就是把暴力枚举中大量注定不可能成为答案的组合一次性裁掉。2.2 “矮边内移”为什么是对的一次严格推导设左右指针为i、j当前盛水面积是area (j - i) * min(height[i], height[j])假设此时height[i] height[j]也就是左柱子矮、右柱子高。请看下面这个判断当前面积的瓶颈是矮柱子height[i]因为min(height[i], height[j])取到的一定是它。如果我把j往左移一格也就是移动长边会发生什么宽度j - i一定变小高度min这一项呢它最多也就是height[i]不可能超过当前高度。宽度变小高度不会变大 → 面积不可能超过当前面积。这意味着以i作为左边界、和当前j右边的任何柱子组合都不需要再算了。j再往左移宽度只会越来越小高度上限不变结果只可能更差。既然i这个矮柱子已经把上限锁死了那唯一有希望突破的就是换掉它也就是i 1。反过来如果height[i] height[j]就是矮边在右边那就要j - 1。如果两边相等移动哪边都行因为无论如何都淘汰了一个不可能更优的边界位置。这就是双指针的“淘汰制”每次移动矮边都是在说“这个柱子作为边界已经没有希望了我把它从候选集中删掉下次不用再看到它”。每根柱子最多被当作矮边淘汰一次所以双指针只需要 O(n) 的时间。2.3 Python3 实现与两个容易被忽略的边界代码非常短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 (right - left) * min(height[left], height[right]) ans max(ans, area) if height[left] height[right]: left 1 else: right - 1 return ans这里有两个细节要注意。第一为什么while left right而不是因为左右指针指向同一根柱子时容器宽度为 0面积必然为 0没有计算意义还容易在取min时出现自减导致指针错乱。第二当height[left] height[right]时走else分支把right - 1这是安全的因为前面推导过两条边一样高时无论移动哪边都会把这个“等高的短边可能性”淘汰掉剩余候选解不会丢失。实测最经典的用例是height [1,8,6,2,5,4,8,3,7]暴力解答案是 49左边界下标 1右边界下标 8高度取 7宽度 7面积 49。用上面的双指针跑一遍结果一致。把代码提交到 LeetCode耗时基本在 60ms 左右和 O(n^2) 的版本是天壤之别。3. 接雨水从“按列累加”到单调栈3.1 每个位置上方能存多少水把问题拆小“盛最多水的容器”是一次只能选两个边界而“接雨水”是整片地形都要算。一个很自然的拆解思路是把整个积水面积拆成每一根柱子正上方能存多少水然后累加。这个拆法的基础是一个简单事实某个位置i上方能不能积水取决于它左边所有柱子的最高值left_max[i]和右边所有柱子的最高值right_max[i]。木桶效应告诉我们这个位置的水位最高只能到两者中的较小值water_i max(0, min(left_max[i], right_max[i]) - height[i])只要左右两边都存在一根比当前位置高的柱子上面就会形成一个凹槽能存住的水就是“两边较矮的高柱子”减去“当前柱子自己的高度”。如果这个值是负数说明当前位置自己就是边上最高的存不住水记 0。3.2 动态规划预处理版本最容易写对的一版先从左往右扫一遍记录每个位置左侧的最高值再从右往左扫一遍记录每个位置右侧的最高值。最后再扫一遍计算每个位置的积水量。这是 O(n) 时间、O(n) 空间的写法也是新手最容易理解、最不容易写错的一版from typing import List class Solution: def trap(self, height: List[int]) - int: n len(height) if n 2: return 0 left_max [0] * n right_max [0] * n cur 0 for i in range(n): cur max(cur, height[i]) left_max[i] cur cur 0 for i in range(n - 1, -1, -1): cur max(cur, height[i]) right_max[i] cur ans 0 for i in range(n): water min(left_max[i], right_max[i]) - height[i] if water 0: ans water return ans把经典测试用例[0,1,0,2,1,0,1,3,2,1,2,1]代入从左到右的left_max是[0,1,1,2,2,2,2,3,3,3,3,3]从右到左的right_max是[3,3,3,3,3,3,3,3,2,2,2,1]。逐位置求差正的贡献分别是位置 2 得 1位置 4 得 1位置 5 得 2位置 6 得 1位置 9 得 1位置 10 得 0累计 6和标准答案一致。这个版本的缺点也很明显用了三个长度为 n 的循环、两个辅助数组空间复杂度 O(n)。在面试追问“能不能优化空间”的情况下你需要继续往下走。3.3 单调栈写法模拟“坑的形成过程”接雨水还有一种非常漂亮的单调栈写法。栈里存的是下标维护的是一个从栈底到栈顶单调不增的序列遇到比栈顶高度低的柱子说明它在“下坡”先入栈一旦遇到比栈顶高的柱子说明开始“上坡”了这时候可以结算刚才那个坑里的水。from typing import List class Solution: def trap(self, height: List[int]) - int: n len(height) ans 0 stack [] for i in range(n): while stack and height[i] height[stack[-1]]: bottom stack.pop() if not stack: break left stack[-1] width i - left - 1 h min(height[left], height[i]) - height[bottom] ans width * h stack.append(i) return ans核心是在while循环里bottom是当前被弹出的凹槽底部left是它左边最近的、比它高的柱子i是右边某根比它高的柱子。水的宽度是i - left - 1高度是两侧较矮的柱子减去bottom自身高度。为什么左边直接取stack[-1]因为单调栈里的元素从左到右高度是递减的stack[-1]恰好就是bottom左边最近的高柱子。单调栈的优点是思路通用后面刷“柱状图中最大的矩形”时完全是一套打法。时间上每个柱子最多入栈一次、出栈一次均摊 O(n)空间最坏 O(n)。3.4 双指针版本把空间优化到 O(1)接雨水的双指针写法和“盛最多水的容器”有异曲同工之妙但需要绕一个弯。它维护left_max和right_max两个变量而不是两个数组from typing import List class Solution: def trap(self, height: List[int]) - int: n len(height) if n 2: return 0 left, right 0, n - 1 left_max, right_max 0, 0 ans 0 while left right: if height[left] height[right]: if height[left] left_max: left_max height[left] else: ans left_max - height[left] left 1 else: if height[right] right_max: right_max height[right] else: ans right_max - height[right] right - 1 return ans为什么可以只用一个变量因为当height[left] height[right]时右边那根柱子已经给left位置兜底了——右边界至少是height[right]而height[right]比height[left]高说明left这一格的水位只受左侧影响。此时left_max足够用来结算left位置的积水量。等left往右走到一个比right更高的位置时就反过来走else分支。这里建议你亲手画一遍left、right、left_max的走势图印象会非常深。我最早学这版的时候总觉得这是“碰巧对了”直到在草稿纸上把每个位置的积水量标记出来才意识到它本质上是在做“按列累加但无需预知右侧最大值因为某一侧的边界已经由另一头的指针保证了”。4. 刷题现场最容易踩的坑与排查方法4.1 数组长度不足时的“隐形边界”盛最多水的容器如果数组只有 0 根或 1 根柱子双指针left right条件天然不成立答案是 0不会报错。但接雨水不一样n 2时根本没有形成任何凹槽的条件所以必须在开头直接返回 0。有些写法里如果不提前判这个后面left_max或right_max的初始化会出问题动态规划版本尤其明显。LeetCode 上很多题都有这种“长度小于某个阈值直接返回”的套路。见到数组类问题第一步先问自己n 0、n 1会发生什么把边界条件写成显式判断比在循环里靠条件侥幸跳过要稳妥得多。4.2 双指针相等时到底移动哪边接雨水的双指针里height[left] height[right]走左边等于的情况归到左边分支盛最多水的容器里相等时走else移动右边。这个区别没必要死记——相等时移动哪边都是安全的因为移动哪边都等价于淘汰掉一根“不可能成为唯一瓶颈的高柱子”。真正要理解的是只要保证移动的是不高于另一侧的那一边剩余候选就不会丢。如果你在本地调试时发现等值情况输出不稳定多半不是算法问题而是left或right更新和height下标访问写错了位置。建议把双指针模板固定成“先算结果再移动指针”避免更新完指针后拿一个新的下标去参与本次计算。4.3 单调栈的空栈检查千万不能省接雨水的单调栈写法里if not stack: break这行非常关键。弹出bottom之后如果栈空了说明bottom左侧没有更高的柱子它根本不在一个凹槽里这个位置存不了水直接break去处理下一个柱子。漏掉这一行代码会在空栈上执行stack[-1]直接抛出IndexError。出现这个报错时不要急着在 LeetCode 上反复提交先自己在本地跑一下这三个用例[1, 2, 3, 4, 5]递增序列答案 0[5, 4, 3, 2, 1]递减序列答案 0[2, 0, 2]单坑答案 2单调栈对递增和递减序列的处理要尤其小心。递增序列里只有入栈没有出栈结算最终答案 0 是对的递减序列里每次出栈都发现栈空break后也合计 0。只有第三个用例能真正触发一次完整的坑结算宽度 1、高度 2答案 2。4.4 用“最朴素的测试用例”验证代码我刷完这两题后养成一个习惯任何双指针题写完先用三条固定测试数据自查。第一是“全是同一个高度”的数组例如[4,4,4,4,4]第二是“单边最高”的数组例如[0,1,0,2,1,0,1,3,2,1,2,1]第三是“随机长度、覆盖空数组”的边界。这三条过了再上 LeetCode 提交正确率会高很多。这个习惯对“盛最多水的容器”同样有效[1,2,1]应该输出 2[1,8,6,2,5,4,8,3,7]应该输出 49[1,1]应该输出 1都做一遍再跑平台。5. 从这两题发散出去的同类题5.1 柱状图中最大的矩形单调栈的反向应用接雨水的单调栈是在凹坑处收集水柱状图中最大的矩形则是在“凸起”处找最大矩形代码几乎一样但栈的方向和结算逻辑反过来遇到比栈顶矮的柱子时出栈算出以一个柱子为高的最大宽度。这两题经常被面试官连起来考因为都是“单调栈处理相邻更大/更小元素”的典型代表。5.2 二维接雨水把一维思路扩成矩阵LeetCode 407二维接雨水把一维地形换成二维网格。这时候双指针解法就不能直接套用需要借助优先队列最小堆从边界往里走因为边界上最矮的那一圈才决定内部积水水位。这道题我建议你等上面两题完全吃透后再碰否则很容易在“哪个柱子先被访问”的问题上绕晕。5.3 面试时怎么讲清楚解题思路面试官问接雨水时优先级最高的考察点不是你会不会背代码而是你能不能从暴力解推演出优化解。我推荐按这个顺序讲先讲每个位置的水量只取决于左右最大值的较小值。再用动态规划预处理出左右最大值数组给出 O(n) 时间 O(n) 空间版本。最后说“如果空间也有限制可以双指针因为某一侧的最大值可以用变量维护”。这个思路链展示的是你对问题结构的理解深度比直接甩出双指针代码要打动面试官。6. 写在最后的个人体会跟着这节精讲把两道题完整刷完后我最大的收获不是记住了双指针的写法而是想通了一个道理双指针的每一步移动都是在做一次有依据的淘汰。移动矮边不是因为“反正矮的没戏”而是因为我们已经证明了它和当前另一边之间不可能产生更优解。这个“有依据的淘汰”思维比任何模板都值钱。如果让我给初学者排一个刷题顺序我会建议按这样的路径走先独立写出盛最多水的容器的暴力解再尝试证明双指针的淘汰规则然后写接雨水的动态规划版再尝试优化成单调栈最后回到双指针版把两个题放在一起对比。整个过程走下来你收获的不只是一两道题的代码而是一整套“从暴力到最优”的推导习惯。最后再分享一个小技巧准备一个本子每刷一道和双指针、单调栈相关的题就把该题“为什么不能移动另一边”用一两句话写出来。积累多了你会发现很多看似不同的题底层的淘汰逻辑惊人地一致。算法基础说白了就是把这类“为什么”从靠感觉变成靠推导。
返回列表