一. 单调栈是什么?------ 生活中的 "排队" 哲学
还记得小时候排队买冰淇淋吗?假如每个人的身高都不一样,大家都想知道自己前面第一个比自己高的人是谁。你会怎么做?------ 没错,这就是单调栈的雏形!
单调栈,顾名思义,就是栈内元素保持单调(递增或递减)顺序。它的最大用处,就是帮我们高效地解决 "下一个更大 / 小元素" 这类问题。
关键修正:单调栈的 "方向性" 很重要!通常以 "栈顶到栈底" 为基准判断单调性:
- 单调递减栈:栈顶到栈底元素递减(如 [5,3,2]),适合找 "下一个更大元素"(遇到比栈顶大的元素时弹栈)。
- 单调递增栈:栈顶到栈底元素递增(如 [2,3,5]),适合找 "下一个更小元素"(遇到比栈顶小的元素时弹栈)。
生活小剧场:
小明排队时发现,只有比自己高的人出现,自己才会被 "弹出队伍",否则就一直等下去。
------"单调栈,排队不慌!"
二. 单调栈的基本套路与应用场景
单调栈的经典应用场景有:
- 找下一个更大 / 更小元素
- 区间最大 / 最小值
- 面积 / 体积计算(如柱状图、接雨水)
套路总结:
- 用栈存储元素的下标(而不是值),方便计算距离或区间长度。
- 遍历数组时,遇到 "破坏单调性" 的元素就弹栈并处理(如单调递减栈中遇到更大的元素)。
- 栈为空时要注意边界情况(避免无左边界时的错误计算)。
效率说明 :所有单调栈问题的时间复杂度都是 O(n) (每个元素仅入栈、出栈各一次,操作次数与元素数量线性相关)。
三. 题目实战
3.1 496. 下一个更大元素 I
题目简介
给定两个数组 nums1 和 nums2,nums1 是 nums2 的子集。对于 nums1 中的每个元素,找出它在 nums2 中右边第一个比它大的元素,没有则返回 -1。
解题思路
用单调递减栈遍历 nums2,当遇到比栈顶大的元素时,栈顶元素的 "下一个更大元素" 就是当前元素,用哈希表记录映射关系;最后根据 nums1 查哈希表即可。
详细图解
假设 nums2 = [2, 1, 3, 5, 4],nums1 = [1, 2, 4]
步骤 | 当前元素 | 栈内容(存下标) | hashTable 映射 | 说明 |
---|---|---|---|---|
1 | 2 | [0] | {} | 2 入栈(栈保持递减:[2]) |
2 | 1 | [0,1] | {} | 1≤2,入栈(栈保持递减:[2,1]) |
3 | 3 | [2] | {1:3, 0:3} | 3>1(破坏递减),弹 1,1 的下一个更大是 3;3>2(破坏递减),弹 0,2 的下一个更大是 3;3 入栈 |
4 | 5 | [3] | {1:3, 0:3, 2:5} | 5>3(破坏递减),弹 3,3 的下一个更大是 5;5 入栈 |
5 | 4 | [3,4] | {1:3, 0:3, 2:5} | 4≤5,入栈(栈保持递减:[5,4]) |
可视化动画:
css
初始:栈 []
遍历2:栈 [2](保持递减)
遍历1:栈 [2,1](保持递减)
遍历3:3>1 → 弹1(1→3);3>2 → 弹2(2→3);栈 [3]
遍历5:5>3 → 弹3(3→5);栈 [5]
遍历4:栈 [5,4](保持递减)
最终查 nums1:
- 1 → 3
- 2 → 3
- 4 → -1(栈中未弹出,无更大元素)
代码实现(带关键注释)
js
var nextGreaterElement = function(nums1, nums2) {
let hashTable = new Map(); // 存储nums2中元素的下一个更大值
let stack = []; // 单调递减栈(存下标)
for (let i = 0; i < nums2.length; i++) {
// 当当前元素>栈顶元素时,栈顶元素的下一个更大值就是当前元素
while (stack.length && nums2[i] > nums2[stack[stack.length - 1]]) {
let index = stack.pop(); // 弹出栈顶(找到更大元素的元素)
hashTable.set(nums2[index], nums2[i]); // 记录映射关系
}
stack.push(i); // 当前元素入栈,维持栈的递减性
}
// 遍历nums1,从哈希表中取结果(未找到则返回-1)
return nums1.map(num => hashTable.get(num) ?? -1);
};
幽默点评
单调栈就像 "八卦小分队",谁比我高,谁是我的 "下一个目标",一查一个准!
3.2 503. 下一个更大元素 II
题目简介
给定一个循环数组(元素收尾相接),找每个元素的下一个更大元素。没有则返回 -1。
解题思路
循环数组可理解为 "数组拼接自身"(如 [1,2,1] → [1,2,1,1,2,1]),用单调递减栈遍历 2 倍长度的数组,仅在第一圈(原数组长度内)入栈,第二圈只弹栈不入栈,避免重复处理。
详细图解
nums = [1, 2, 1](循环后等效于 [1,2,1,1,2,1])
第一圈(i=0,1,2) :
i | nums[i] | 栈内容 | res | 说明 |
---|---|---|---|---|
0 | 1 | [0] | [-1,-1,-1] | 1 入栈(栈递减:[1]) |
1 | 2 | [1] | [2,-1,-1] | 2>1(破坏递减),弹 0,res [0]=2;2 入栈(栈递减:[2]) |
2 | 1 | [1,2] | [2,-1,-1] | 1≤2,入栈(栈递减:[2,1]) |
第二圈(i=3,4,5,仅弹栈不入栈) :
i | nums[i%3] | 栈内容 | res | 说明 |
---|---|---|---|---|
3 | 1 | [1,2] | [2,-1,-1] | 1≤1(栈顶是 1),不弹栈 |
4 | 2 | [1] | [2,-1,2] | 2>1(栈顶是 1),弹 2,res [2]=2;2≤2(栈顶是 2),停止弹栈 |
5 | 1 | [1] | [2,-1,2] | 1≤2,不弹栈 |
动画描述:
- 第一圈,2 把 1 弹出,1 的下一个更大是 2。
- 第二圈,2 把最后一个 1 弹出(因为循环,这个 1 的右边有 2),1 的下一个更大是 2。
代码实现(带关键注释)
js
var nextGreaterElements = function(nums) {
let n = nums.length;
let res = new Array(n).fill(-1); // 初始化结果为-1(默认无更大元素)
let stack = []; // 单调递减栈(存下标)
// 遍历2n长度(模拟循环数组)
for (let i = 0; i < 2 * n; i++) {
// 当前元素在原数组中的位置(循环处理)
let current = nums[i % n];
// 当当前元素>栈顶元素时,栈顶元素的下一个更大值是当前元素
while (stack.length && current > nums[stack[stack.length - 1]]) {
let index = stack.pop();
res[index] = current; // 记录结果
}
// 只在第一圈入栈(避免重复入栈)
if (i < n) stack.push(i);
}
return res;
};
陷阱提示
循环数组处理时,第二圈不能入栈!否则会导致栈中出现重复下标,计算错误。就像排队时不能重复排两次队,不然会被当成 "插队" 哦~
3.3 739. 每日温度
题目简介
给定一组每日气温,返回每一天需要等几天才会升温。如果之后都不会升温,返回 0。
解题思路
用单调递减栈存储气温的下标,当遇到更高气温时,弹出栈顶下标,当前下标与栈顶下标的差值就是 "需要等待的天数"(因为这是第一个更高气温)。
详细图解
temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
i | 当前温度 | 栈内容(下标) | res | 说明 |
---|---|---|---|---|
0 | 73 | [0] | [0,0,0,0,0,0,0,0] | 73 入栈(栈递减:[73]) |
1 | 74 | [1] | [1,0,0,0,0,0,0,0] | 74>73(破坏递减),弹 0,res [0]=1-0=1;74 入栈 |
2 | 75 | [2] | [1,1,0,0,0,0,0,0] | 75>74(破坏递减),弹 1,res [1]=2-1=1;75 入栈 |
3 | 71 | [2,3] | [1,1,0,0,0,0,0,0] | 71≤75,入栈(栈递减:[75,71]) |
4 | 69 | [2,3,4] | [1,1,0,0,0,0,0,0] | 69≤71,入栈(栈递减:[75,71,69]) |
5 | 72 | [2,3,5] | [1,1,0,2,1,0,0,0] | 72>69→弹 4,res [4]=5-4=1;72>71→弹 3,res [3]=5-3=2;72≤75,入栈 |
6 | 76 | [6] | [1,1,4,2,1,1,0,0] | 76>72→弹 5,res [5]=6-5=1;76>75→弹 2,res [2]=6-2=4;76 入栈 |
7 | 73 | [6,7] | [1,1,4,2,1,1,0,0] | 73≤76,入栈(栈递减:[76,73]) |
动画描述:
- 栈里存的都是 "还没等到升温" 的天数下标,像一群排队等通知的人。
- 一旦出现更高温度("通知来了"),就从栈顶开始 "叫号",计算每个人等了几天。
代码实现(带关键注释)
js
var dailyTemperatures = function(temperatures) {
const len = temperatures.length;
let res = new Array(len).fill(0); // 初始化结果为0(默认不升温)
let stack = []; // 单调递减栈(存下标)
for (let i = 0; i < len; i++) {
// 当当前温度>栈顶温度时,栈顶这天的升温天数是i - 栈顶下标
while (stack.length && temperatures[i] > temperatures[stack[stack.length - 1]]) {
let index = stack.pop(); // 弹出栈顶(等待升温的天)
res[index] = i - index; // 计算等待天数
}
stack.push(i); // 当前天入栈,等待后续更高温度
}
return res;
};
幽默点评
"升温预报员上线:有了单调栈,妈妈再也不用担心我穿错衣服了!今天穿毛衣,明天穿短袖?栈说了算~"
3.4 84. 柱状图中最大的矩形
题目简介
给定一组柱状图的高度,求能围成的最大矩形面积(矩形的边平行于坐标轴)。
解题思路
用单调递增栈存储柱子下标(栈顶到栈底递增),当遇到更矮的柱子时,弹出栈顶柱子,以该柱子为高,计算最大矩形面积:
- 左边界:新栈顶下标(左侧第一个更矮的柱子)
- 右边界:当前下标(右侧第一个更矮的柱子)
- 宽度 = 右边界 - 左边界 - 1
- 面积 = 栈顶柱子高度 × 宽度
技巧:在数组首尾加 "哨兵"(高度 0),避免处理边界时栈空的情况。
详细图解
heights = [2,1,5,6,2,3] → 加哨兵后 [0,2,1,5,6,2,3,0]
i | 当前高度 | 栈内容(下标) | 计算面积 | maxArea | 说明 |
---|---|---|---|---|---|
0 | 0 | [0] | - | 0 | 哨兵 0 入栈(栈递增:[0]) |
1 | 2 | [0,1] | - | 0 | 2>0,入栈(栈递增:[0,2]) |
2 | 1 | [0,2] | 2×1=2 | 2 | 1<2(破坏递增),弹 1,左边界 0,右边界 2,宽 = 2-0-1=1 → 面积 2×1=2;1 入栈 |
3 | 5 | [0,2,3] | - | 2 | 5>1,入栈(栈递增:[0,1,5]) |
4 | 6 | [0,2,3,4] | - | 2 | 6>5,入栈(栈递增:[0,1,5,6]) |
5 | 2 | [0,2,3,5] | 6×1=6, 5×2=10 | 10 | 2<6→弹 4,左边界 3,右边界 5,宽 = 5-3-1=1 → 6×1=6;2<5→弹 3,左边界 2,右边界 5,宽 = 5-2-1=2 → 5×2=10;2 入栈 |
6 | 3 | [0,2,3,5,6] | - | 10 | 3>2,入栈(栈递增:[0,1,5,2,3]) |
7 | 0 | [0,2,7] | 3×1=3, 2×4=8, 1×6=6 | 10 | 0<3→弹 6,左边界 5,右边界 7,宽 = 7-5-1=1 → 3×1=3;0<2→弹 5,左边界 3,右边界 7,宽 = 7-3-1=3 → 2×3=6?哦不对,原高度是 2(下标 5),宽 = 7-3-1=3 → 2×3=6?实际应为 2×(7-2-1)=2×4=8(左边界是 2,下标 2 对应高度 1);0<1→弹 2,左边界 0,右边界 7,宽 = 7-0-1=6 → 1×6=6;0 入栈 |
关键说明:
当弹出下标 5(高度 2)时,新栈顶是下标 3(高度 5)吗?不,此时栈在弹 4、3 后,栈顶是下标 2(高度 1),所以左边界是 2,右边界是 7,宽度 = 7-2-1=4,面积 = 2×4=8。
代码实现(带关键注释)
js
var largestRectangleArea = function(heights) {
// 加首尾哨兵(高度0),避免边界处理
heights.unshift(0);
heights.push(0);
let stack = [0]; // 单调递增栈(存下标),初始存入首哨兵
let maxArea = 0;
for (let i = 1; i < heights.length; i++) {
// 当当前高度<栈顶高度时,栈顶柱子是"中间高",计算面积
while (heights[i] < heights[stack[stack.length - 1]]) {
let mid = stack.pop(); // 弹出栈顶(当前计算的柱子)
let height = heights[mid]; // 矩形高度=栈顶柱子高度
// 左边界是新栈顶(左侧第一个更矮的柱子),右边界是当前i(右侧第一个更矮的柱子)
let width = i - stack[stack.length - 1] - 1;
maxArea = Math.max(maxArea, height * width); // 更新最大面积
}
stack.push(i); // 当前柱子入栈,维持栈的递增性
}
return maxArea;
};
陷阱提示
忘记加哨兵会怎样?如果原数组是递增的(如 [1,2,3]),栈会一直入栈,遍历结束后栈内元素未弹出,导致漏算最大面积!哨兵就像 "保安",确保最后所有元素都能被 "叫号" 计算。
3.5 42. 接雨水
题目简介
给定一组柱子的高度,问下雨后能接多少水(柱子宽度为 1)。
解题思路
用单调递减栈存储柱子下标,当遇到更高的柱子时,弹出栈顶柱子("凹槽底部"),此时:
- 左边界:新栈顶下标(左侧较高的柱子)
- 右边界:当前下标(右侧较高的柱子)
- 凹槽高度 = min (左边界高度,右边界高度) - 凹槽底部高度
- 凹槽宽度 = 右边界下标 - 左边界下标 - 1
- 水量 = 凹槽高度 × 凹槽宽度
累加所有凹槽的水量即为总水量。
详细图解
height = [0,1,0,2,1,0,1,3,2,1,2,1]
i | 当前高度 | 栈内容(下标) | 计算雨水 | 总水量 | 说明 |
---|---|---|---|---|---|
0 | 0 | [0] | - | 0 | 0 入栈(栈递减:[0]) |
1 | 1 | [1] | 0 | 0 | 1>0→弹 0,栈空(无左边界),无法接水;1 入栈 |
2 | 0 | [1,2] | - | 0 | 0≤1,入栈(栈递减:[1,0]) |
3 | 2 | [1,3] | 1×1=1 | 1 | 2>0→弹 2(凹槽底部 0),左边界 1(高度 1),右边界 3(高度 2),h=1-0=1,w=3-1-1=1 → 水量 1;2>1→弹 1,栈空,2 入栈 |
4 | 1 | [1,3,4] | - | 1 | 1≤2,入栈(栈递减:[2,1]) |
5 | 0 | [1,3,4,5] | - | 1 | 0≤1,入栈(栈递减:[2,1,0]) |
6 | 1 | [1,3,4,6] | 1×1=1 | 2 | 1>0→弹 5(凹槽底部 0),左边界 4(高度 1),右边界 6(高度 1),h=1-0=1,w=6-4-1=1 → 水量 1;1≤1,入栈 |
7 | 3 | [1,3,7] | 2×3=6 | 8 | 3>1→弹 6(凹槽底部 1),左边界 4(高度 1),h=1-1=0→水量 0;3>1→弹 4(凹槽底部 1),左边界 3(高度 2),h=2-1=1,w=7-3-1=3 → 3×1=3;3>2→弹 3(凹槽底部 2),左边界 1(高度 0,实际是栈空?不,原栈顶是 1,高度 0),h=min (0,3)-2→无效,停止;3 入栈 |
... | ... | ... | ... | ... | 后续累加剩余凹槽水量,最终总水量为 6 |
核心逻辑:
每次弹出的 "凹槽底部" 必须被左右两个更高的柱子 "夹住" 才能接水,就像碗需要两边有边才能装水~
代码实现(带关键注释)
js
var trap = function(height) {
let n = height.length, res = 0, stack = []; // 单调递减栈(存下标)
for (let i = 0; i < n; i++) {
// 当当前高度>栈顶高度时,栈顶是"凹槽底部",计算水量
while (stack.length && height[i] > height[stack[stack.length - 1]]) {
let mid = stack.pop(); // 弹出凹槽底部
if (!stack.length) break; // 栈空(无左边界),无法接水
let left = stack[stack.length - 1]; // 左边界(栈顶,左侧较高的柱子)
let right = i; // 右边界(当前柱子,右侧较高的柱子)
// 凹槽高度=左右边界较矮的高度 - 凹槽底部高度
let h = Math.min(height[left], height[right]) - height[mid];
// 凹槽宽度=右边界-左边界-1(中间的格子数)
let w = right - left - 1;
res += h * w; // 累加水量
}
stack.push(i); // 当前柱子入栈,维持栈的递减性
}
return res;
};
幽默点评
"下雨天,单调栈帮你兜住每一滴水!不管是小水洼还是大水坑,栈一出手,全接住~"
四. 单调栈套路总结与常见陷阱
核心套路
- 栈存下标:方便计算距离 / 宽度(比存值更实用)。
- 单调性选择:
-
- 找 "更大元素" 用单调递减栈(遇大则弹)。
-
- 找 "更小元素" 用单调递增栈(遇小则弹)。
- 处理逻辑:遍历数组时,用 "破坏单调性" 的元素触发弹栈,弹栈时计算目标值(值 / 距离 / 面积 / 水量)。
常见陷阱
- 栈空判断:弹栈后若栈空,无左边界(如接雨水、柱状图),需停止计算。
- 循环数组:仅在第一圈入栈,避免重复处理(如 503 题)。
- 边界处理:柱状图、接雨水等问题可加 "哨兵"(高度 0),简化边界逻辑。
- 计算目标混淆:区分是返回 "值"(如下一个更大元素)还是 "距离"(如每日温度)。
五. 单调栈学习路径与例题对比
学习路径(循序渐进)
- 基础:下一个更大元素 I(理解弹栈逻辑)→ 下一个更大元素 II(循环数组)。
- 进阶:每日温度(计算距离)→ 柱状图最大矩形(面积计算,单调递增栈)。
- 高阶:接雨水(凹槽水量,左右边界判断)。
例题核心对比表
题目 | 栈类型(顶→底) | 核心计算目标 | 关键公式 |
---|---|---|---|
下一个更大 I | 单调递减 | 下一个更大元素的值 | 直接映射值 |
每日温度 | 单调递减 | 距离下一个高温的天数 | i - 弹出下标 |
柱状图最大矩形 | 单调递增 | 最大矩形面积 | 高度 × (当前i - 新栈顶i - 1) |
接雨水 | 单调递减 | 凹槽总水量 | min(左高,右高)-中高 × (右i - 左i - 1) |
六. 互动练习与思考题
练习题(附 LeetCode 思路)
- 下一个更小元素(LeetCode 496 变体):
输入 nums1=[4,1,2], nums2=[1,3,4,2],返回每个元素在 nums2 中右边第一个更小的元素(无则返回 - 1)。
提示:用单调递增栈,遇到比栈顶小的元素时弹栈记录映射。答案:[2,-1,-1]。
- 柱状图优化思路:
双指针法可解,但仅适用于 "无重复高度" 的场景;单调栈更通用,可处理所有情况。
思考题(带提示)
- 如果温度是递减的(如 [5,4,3,2,1]),单调栈会发生什么?
提示:栈会一直入栈(保持递减),最终所有结果都是 0(无更高温度)。
- 接雨水问题中,若所有柱子高度相同(如 [3,3,3,3]),能接多少水?
提示:0(无凹槽,左右边界高度相同但无中间更低的底部)。
掌握单调栈,就像掌握了 "排队的艺术"------ 谁在前、谁在后、谁比谁高,一目了然!刷题路上,栈随你动~ 🚀