1. 这个"trap"到底在装什么水
1.1 题目速览:接雨水到底在算什么
接雨水(Trapping Rain Water)是 LeetCode 第 42 题,也是单调栈这个数据结构最经典的出场场景之一。题目本身很短:给你一个非负整数数组height,每个数字代表一根宽度为 1 的柱子的高度,问下过雨后这些柱子之间总共能存多少水。示例[0,1,0,2,1,0,1,3,2,1,2,1]的答案是 6,这个结果我一开始怎么都看不出来,后来才知道要先学会"把空间切成水层"。
第一次刷这题的人通常分两派:一派觉得简单,不就是找些凹坑然后逐个加吗;另一派完全懵,不知道从哪里下手、从哪里结束。我是第二派。直到我换了一个视角:积水不是"一个坑一个坑"孤立存在的,而是被左右两根更高的柱子"夹"出来的。任何一格能不能存水,取决于它左边最高柱和右边最高柱里较矮的那根,再减去自身高度。这个看上去很朴素的观察,最后演化出了四种主流解法,其中结构最优雅、最能和面试官聊出深度的,就是单调栈。
这篇文章我打算从一个很偏的角度切入——先聊聊英文题名里的 trap 到底"困住"了什么,然后完整拆解单调栈的推导、代码、手推样例和边界坑。单调栈解法本身只有十行左右,但十行背后的那层窗户纸,值得认真捅破。
1.2 trap 的双关:从 charge trap 和 floating gate 说起
我特别喜欢这题的英文名里 trap 这个词。在半导体存储领域,有两个跟"困住"密切相关的概念:floating gate(浮栅)和 charge trap(电荷陷阱)。传统闪存用浮栅存电子——一个完全被绝缘层包裹的导体层,电荷一旦注入进去就被困在势垒里出不来;后来工艺继续缩小,出现了 charge trap flash,改用氮化硅里的分立陷阱态来困住电子,比连续导体层更容易微缩,也让 3D NAND 成为现实。
讲这个不是为了掉书袋,而是"困住"这事儿的本质是相通的:想困住任何东西,必须先有边界。电荷困得住,是因为四周绝缘层构成了势垒;雨水困得住,是因为左右两根更高的柱子构成了墙。你在做接雨水这道题时,从头到尾只干一件事——反复确认某个凹槽的左边墙、右边墙和坑底分别在哪里。墙找对了,水自然算得出来;墙找错了,答案就离谱。想通这一点,算法题就不再是背代码,而是一种"找边界"的思维训练。
1.3 四种主流解法,先画张全景图
在敲单调栈之前,必须知道这题还有其他做法。因为面试时大概率会被追问"还有没有更优解",你至少得能说出四种方案的差异。
| 解法 | 核心思想 | 时间复杂度 | 空间复杂度 | 难点 |
|---|---|---|---|---|
| 暴力 | 每个位置分别向左、向右找最高柱子 | O(n²) | O(1) | 慢,但最容易理解 |
| 前缀/后缀最大值(DP) | 预处理 leftMax[i] 和 rightMax[i] 数组 | O(n) | O(n) | 思路直给,代码好写 |
| 双指针 | 左右夹逼,动态维护两侧最大值 | O(n) | O(1) | 数学技巧性强,不太好懂 |
| 单调栈 | 用单调递减栈维护"可能的左墙"候选 | O(n) | O(n) | 理解门槛最高,代码最短 |
我的个人看法:双指针在复杂度上确实最优,但初学阶段最推荐先写 DP 版,因为它最贴近"每根柱子看左右最高"的直觉;单调栈的价值在于它把扫描过程中"发现凹槽"这件事完完整整地模拟了出来。双指针考验的是数学观察力,而单调栈考验的是结构感——所以你如果想把数据结构和算法"内化",单调栈这题绕不过去。
2. 单调栈为什么能解这道题
2.1 把视角从"柱子"切换到"凹槽"
我最早犯的错误,是站在每根柱子的角度问自己:这一根柱子能存多少水?结果中间矮的柱子看似能存不少,等换了边界答案又对不上,越算越乱。后来我才意识到:人的直觉是"数柱子",但自然的物理过程是"积水填坑"——水是一层一层漫进凹槽里的,不是一格一格挂在柱子上的。
看一个最朴素的例子:高度[2, 1, 1, 2]。从左往右扫,扫到前三个柱子时什么都不能确定,因为还不知道后面会不会出现右墙;等扫到最后一个 2 时,我们发现它比中间的 1 高,于是形成了凹槽。这个过程用栈描述非常自然:柱子们先进栈,等遇到能当右墙的柱子时,再回头把之前存着的信息取出来结算。
这里有一个关键点:右墙并不一定要比左墙高,只要比坑底高,水就能存。示例里最后那个 2 比第一个 2 等高,照样把中间的洼地填满了。真实数据里,同一个大水坑往往会被多个右墙分阶段"填满",这正是单调栈解法最精妙之处——它不奢求一次性识别出完整的大坑,而是每遇到一面可能的右墙,就当场结算掉一层能确定的水。
2.2 单调栈到底维护了什么
单调栈在这题里维护的是:到目前为止还没找到右墙的柱子下标,并且它们的高度从栈底到栈顶严格递减。你可以把栈想象成一条从高到低的下坡路——栈底是最远处的高墙,栈顶是当前最低点,也是下一个凹槽最可能的坑底。
为什么要严格递减?因为"低处"才有资格当未来的坑底。只要新来的柱子比栈顶矮,它就不可能成为任何人的右墙,先入栈候着;一旦新来的柱子比栈顶高,它就有了当右墙的潜质,于是触发结算。这里的"严格"两个字别忽视——如果出现相同高度,用>判断就不会触发结算,干净利落;如果写成>=,虽然一样能算出正确答案,但会平白多几次弹出和加 0 水量的无效计算,面试时容易被追问细节。
另一个容易踩的误区是"栈里存什么"。答案是下标,不是高度。因为计算水量需要宽度,而宽度等于右墙下标减左墙下标再减 1。如果只存高度,算完高度发现没有宽度信息,代码立刻僵住。这也是很多初学者第一版代码写一半写不下去的原因。下标是个"索引",通过它既能拿到高度,也能参与宽度计算,一举两得。
2.3 结算公式到底怎么来的
假设当前扫描到下标i,高度为height[i],且它大于栈顶对应的高度。此时触发一段结算逻辑:
- 弹出栈顶,记为
bottom——这是坑底。 - 如果栈空了,说明
bottom左侧没有比它更高的墙,无法形成凹槽,直接跳过。 - 如果栈没空,新的栈顶记为
left——这是左墙,当前下标i是右墙。 - 这一层水的宽度是
width = i - left - 1。 - 这一层水的高度是
depth = min(height[left], height[i]) - height[bottom]。
为什么高度要取 min?因为水面是平的,水一定会从矮的那面墙溢出,所以实际存水深度由矮墙决定。同时,由于栈内高度严格递减,height[bottom]必然小于height[left],所以depth一定非负,不会算出负水量。
用[2, 1, 1, 2]推一遍:扫到 i=3 时,先弹出下标 2 的高度 1,栈顶变成下标 1 的高度 1,width = 3 - 1 - 1 = 1,depth = min(2,2) - 1 = 1,累加 1;再弹出下标 1 的高度 1,栈顶变成下标 0 的高度 2,width = 3 - 0 - 1 = 2,depth = min(2,2) - 1 = 1,累加 2。总水量 3。你看,同一个大水坑被拆成了两层来算:先算最深的一层,再算宽一些的一层。这就是"水层视角"——它把积水分层而不是分柱,是理解单调栈解法的钥匙。
2.4 为什么单调栈值得单独掌握
和 DP 解法对比一下会更清楚。DP 是"每个位置都提前算好左右最大值,再按格累加",思路是静态的;单调栈是"扫描过程中动态发现并结算凹槽",思路是动态的。前者把每个格子孤立地看,后者把柱子之间的联系串起来了。
从训练价值来说,我强烈建议你把单调栈写在纸上演算一遍。因为这种"入栈、发现右墙、循环结算"的模式,不止出现在接雨水里——柱状图中最大的矩形、每日温度、滑动窗口最大值,全都能用单调栈解。花一小时吃透这一题,相当于同时预习了四五道经典题,这买卖很划算。
3. 完整实现与手推过程
3.1 Python 实现,逐行注释
def trap(height): stack = [] # 栈里存下标,高度从栈底到栈顶严格递减 ans = 0 for i, h in enumerate(height): # 当前柱子比栈顶高,说明它可能成为右墙 while stack and h > height[stack[-1]]: bottom = stack.pop() # 坑底 if not stack: # 栈空 = 没有左墙,结算不了水 break left = stack[-1] # 左墙 width = i - left - 1 # 水层宽度 depth = min(height[left], h) - height[bottom] # 水层高度 ans += width * depth stack.append(i) # 当前柱子入栈,作为未来候选 return ans这个版本我实际提交过,能直接通过所有测试用例。重点看 while 循环里为什么是 "结算完后继续看新的栈顶"——因为一面右墙可以同时给好几层凹槽当墙。比如[5,1,1,5]最右边的 5,一口气结算了两层水,这个行为全靠 while 的持续循环来完成。你如果只写 if,答案立刻变小,这是新手最容易忽略的地方。
3.2 C++ 版本:换语言不换逻辑
int trap(vector<int>& height) { stack<int> st; int ans = 0; for (int i = 0; i < (int)height.size(); ++i) { while (!st.empty() && height[i] > height[st.top()]) { int bottom = st.top(); st.pop(); if (st.empty()) break; int left = st.top(); int width = i - left - 1; int depth = min(height[left], height[i]) - height[bottom]; ans += width * depth; } st.push(i); } return ans; }C++ 版和 Python 版逐行对应。唯一提醒是(int)height.size()的强转,避免 size_t 无符号类型在空数组时参与比较引发问题。还有min在 C++ 里默认比较的是值,这里传入的是两个高度值,没问题。
3.3 手推标准样例:一步步看着水被"算"出来
拿题目示例[0,1,0,2,1,0,1,3,2,1,2,1]完整走一遍。下表里的"栈内容"指操作结束后的栈,栈中元素是下标。
| i | height[i] | 操作 | 本次累加 | ans | 栈内容 |
|---|---|---|---|---|---|
| 0 | 0 | 入栈 0 | 0 | 0 | [0] |
| 1 | 1 | 1>0,弹 0,栈空 break;入栈 1 | 0 | 0 | [1] |
| 2 | 0 | 0>1 否,入栈 2 | 0 | 0 | [1,2] |
| 3 | 2 | 弹 2:left=1,width=1,depth=1;再弹 1:栈空 break;入栈 3 | 1 | 1 | [3] |
| 4 | 1 | 1>2 否,入栈 4 | 0 | 1 | [3,4] |
| 5 | 0 | 0>1 否,入栈 5 | 0 | 1 | [3,4,5] |
| 6 | 1 | 弹 5:left=4,width=1,depth=1;1>1 否;入栈 6 | 1 | 2 | [3,4,6] |
| 7 | 3 | 弹 6:left=4,width=2,depth=0;弹 4:left=3,width=3,depth=1;弹 3:栈空 break;入栈 7 | 3 | 5 | [7] |
| 8 | 2 | 2>3 否,入栈 8 | 0 | 5 | [7,8] |
| 9 | 1 | 1>2 否,入栈 9 | 0 | 5 | [7,8,9] |
| 10 | 2 | 弹 9:left=8,width=1,depth=1;2>2 否;入栈 10 | 1 | 6 | [7,8,10] |
| 11 | 1 | 1>2 否,入栈 11 | 0 | 6 | [7,8,10,11] |
最终 ans = 6,和预期一致。这张表我建议你自己在纸上重画一遍,尤其是 i=6 和 i=7 这两步,它们分别演示了"结算后立刻停"和"一个右墙连续结算多个水层"两种典型场景。画过一遍之后,你就再也不会忘记 while 循环的存在了。
3.4 最容易写错的两个点
第一,弹出bottom之后必须检查栈是否为空。因为左墙是从"新的栈顶"拿的,如果栈空了,说明这个坑底左边没有更高的边界,水根本存不住。很多初版代码在这里要么直接越界,要么算出一个巨大的错误宽度。记住:if not stack: break这句是安全阀,不是可有可无。
第二,宽度公式里的left是"新的栈顶",不是刚弹出的bottom。我见过很多版本写成了i - bottom - 1,把宽度凭空拉大好几格,答案自然偏大。调试这类问题最快的方法,是打印出每一次结算时的bottom、left、width、depth四个值,对照手推表,一眼就能定位是哪个环节出了问题。
4. 边界条件与踩坑记录
4.1 边界用例清单,直接拿去测
刷算法题最怕"提交前自我感觉良好,提交后一片红"。以下是我长期保留的一组自测用例:
height = [],期望 0height = [1],期望 0height = [1,2],期望 0(只有两面墙,没有坑)height = [3,3,3,3],期望 0(等高,没有凹槽)height = [0,1,0,2,1,0,1,3,2,1,2,1],期望 6height = [4,2,0,3,2,5],期望 9height = [0,1,2,3,4],期望 0(严格递增,右墙永远比左墙晚到或不到)height = [4,3,2,1,0],期望 0(严格递减,永远没有右墙)height = [5,1,1,5],期望 8(连续结算两层)height = [2,1,1,2],期望 3(等高的左右墙也能存水)
把这组用例封装成一个测试函数,每次改完代码先跑一遍再提交,能帮你省下大量试错时间。特别是严格递增和严格递减这两个用例,专门用来验证"栈空时正确跳过结算"的逻辑,非常有效。
4.2 我踩过的几个真实坑
第一个坑:栈里存高度而不是下标。第一版代码写完,算到宽度时发现手里只有高度值,完全没有坐标信息,只能干瞪眼。后来彻底想明白:下标索引是算法题的"万能钥匙",拿到下标的一瞬间,高度、宽度、位置关系全部解锁。以后凡是涉及"区间宽度"的栈类题目,我默认先考虑存下标。
第二个坑:把 while 比较方向写反。曾经把height[i] > height[stack[-1]]写成height[i] < height[stack[-1]],结果单调递减栈变成了单调递增栈,整个逻辑反了。后来我养成了一个习惯:写单调栈前先在草稿纸上画一根横轴、几根柱子的简笔画,然后问自己"新来的柱子比栈顶高,意味着什么"——高,才有资格当右墙;矮,只能当候选坑底。
第三个坑:while 只写了一次,没写循环。还是[5,1,1,5]这个用例,右墙 5 明明能连续结算两层水,如果代码里只if一次,答案直接少一半。单调栈解法的灵魂就在这个"持续向左回溯"的 while 上,一旦漏掉,正确性全毁。
4.3 复杂度分析:为什么这个解法是 O(n)
时间上,每个下标最多入栈一次、出栈一次,while 循环总执行次数不会超过元素个数,所以整体时间复杂度 O(n)。空间上,最坏情况是严格递减数组,比如[5,4,3,2,1],所有元素都会留在栈里等待永远不出现的右墙,空间 O(n)。
这里有个面试加分点:你可以主动提一句,接雨水的本质是"向右找第一个能当右墙的更高柱子,同时向左借助栈结构找到左墙",这和"下一个更大元素"系列题是同一套思维。如果你能进一步说出"这题和 LeetCode 84 柱状图中最大的矩形用的是同一种单调栈,只是那个是维护递增栈、找左右两边更矮的边界",面试官基本就能确定你是真懂,而不是背题。
4.4 变体和扩展方向
如果输入数组特别大,比如千万级别,单调栈 O(n) 的空间可能有点吃紧,可以改用双指针,把空间压到 O(1)。如果柱子宽度不等,变成不同宽度的平台,单调栈照样能用,只要把宽度公式从i - left - 1改成实际坐标差,逻辑不用大改。
更进阶的玩法是二维接雨水(LeetCode 407),从一维凹槽升级成"从外向内灌水",解法变成了优先队列加 BFS,但核心思想一脉相承——水能被困住的前提,依然是四周存在更高的边界。如果你能把一维单调栈理解通透,再去啃二维,会有一种"原来如此"的顺畅感。
5. 常见问题速查手册
下面这个表我整理了很久,把刷题社区里关于单调栈解接雨水的典型问题都列了出来,按"现象—原因—对策"三列组织。
| 现象 | 可能原因 | 对策 |
|---|---|---|
| 答案偏大 | 宽度错误地用了bottom到i的距离 | 改成i - left - 1,left 是弹栈后的新栈顶 |
| 程序崩溃或越界 | 弹出 bottom 后没有判栈空 | 在取 left 之前加if not stack: break |
| 递增数组返回非 0 | 结算逻辑没有正确跳过"无左墙"场景 | 检查栈空分支是否在所有路径上都生效 |
| 严格递减数组返回非 0 | 右墙始终不出现,但代码误判了某种边缘情况 | 确认 while 条件是>,而不是>= |
| 等高柱子场景出错 | 比较符号用错导致边界语义混乱 | 统一使用>,让等高柱子保持栈内秩序 |
| 连续多层积水漏算 | while 写成了 if | 确认结算过程放在 while 循环内部 |
| 栈里存了值而不是下标 | 设计时没考虑宽度计算 | 改成存下标,通过height[stack[-1]]取高度 |
另外有一个小疑问我见过不少人问:"为什么min(height[left], height[i]) - height[bottom]可能等于 0?"答案很简单:当凹槽底部和某一边界高度相等时,这层水高度为 0。比如栈里存在一段等高序列时就会出现这种"白算"的情况,但不影响最终正确性。这里千万别为了跳过低效计算去改比较符号,保持代码语义清晰比省几微秒重要得多。
6. 最后分享一点实战体会
这道题我前前后后重写了不下十遍,每次重写都有新收获。第一次写是背代码,换一个用例就不会了;第二次写才真正理解 while 循环存在的原因;第三次开始尝试自己推导width和depth公式,才彻底和"水层视角"和解。
如果让我给一个可复现的学习路径,它是这样的:先用暴力法把答案算对,建立信任感;再写 DP 版,理解"左右最高值"这个核心概念;然后写单调栈版,重点体会从"静态计算"到"动态结算"的转变;最后看双指针版,理解如何用数学观察把空间省掉。四步走完,这一题就真正变成你的了。
最后再分享一个小技巧:我本地一直保留着一个"接雨水测试台",里面放着这章列出的十个边界用例。每次学习新的栈相关算法,我都会拿它来当基准测试,顺带验证自己有没有把逻辑写"死"。这个习惯帮我省了无数次提交失败带来的返工时间——毕竟,一遍过的快乐,只有刷题人才懂。