单调栈
概述
单调栈是一种特殊的栈结构,栈内元素始终保持单调递增或单调递减的顺序。通过维护单调性,可以高效解决"寻找下一个更大/更小元素"等问题,将原本O(n²)的问题优化到O(n)。
单调栈的核心思想
单调栈通过在入栈前弹出破坏单调性的元素,利用弹出的元素与当前元素的关系,在O(1)时间内找到"下一个更大/更小元素",整体时间复杂度为O(n)。
单调栈类型
类型
栈内顺序
适用场景
图示
单调递增栈
栈底→栈顶递增
求下一个更小元素
[1, 3, 5, 7]
单调递减栈
栈底→栈顶递减
求下一个更大元素
[7, 5, 3, 1]
graph TB
subgraph "单调递减栈示例"
A["入栈顺序: 3, 1, 5, 2, 4"]
B["栈的变化过程:"]
C["入栈3: [3]"]
D["入栈1: 弹出3, [1]<br/>3的下一个更大元素是1? 不对"]
E["入栈5: 弹出1, [5]"]
F["入栈2: [5, 2]"]
G["入栈4: 弹出2, [5, 4]"]
end
A --> B --> C --> D --> E --> F --> G
核心原理详解
为什么单调栈有效?
Text Only 问题: 找到每个元素右边第一个比它大的元素位置
朴素做法: O(n²)
for i in range(n):
for j in range(i+1, n):
if arr[j] > arr[i]:
result[i] = j
break
单调栈做法: O(n)
- 维护一个单调递减栈(存索引)
- 当遇到比栈顶大的元素时,栈顶元素的答案就是当前元素
- 弹出栈顶,继续检查
工作流程示意
graph TB
subgraph "处理数组 [3, 1, 5, 2, 4] 找下一个更大元素"
A["i=0, val=3<br/>栈: [] → [0]"]
B["i=1, val=1<br/>1 < 3, 不弹出<br/>栈: [0, 1]"]
C["i=2, val=5<br/>5 > 1, 弹出1, ans[1]=2<br/>5 > 3, 弹出0, ans[0]=2<br/>栈: [2]"]
D["i=3, val=2<br/>2 < 5, 不弹出<br/>栈: [2, 3]"]
E["i=4, val=4<br/>4 > 2, 弹出3, ans[3]=4<br/>4 < 5, 不弹出<br/>栈: [2, 4]"]
end
A --> B --> C --> D --> E
style C fill:#E8F5E9
style E fill:#E8F5E9
结果解释:
ans[0] = 2 → 元素3的下一个更大元素是位置2的元素5
ans[1] = 2 → 元素1的下一个更大元素是位置2的元素5
ans[2] = -1 → 元素5右边没有更大的元素
ans[3] = 4 → 元素2的下一个更大元素是位置4的元素4
ans[4] = -1 → 元素4右边没有更大的元素
基本实现
1. 下一个更大元素
从右向左遍历,维护单调递减栈:
2. 下一个更大元素的位置
栈中存储索引而非值:
3. 每日温度问题
给定每日温度数组,计算每天需要等几天才会有更高温度:
graph LR
subgraph "每日温度示例"
A["温度: [73, 74, 75, 71, 69, 72, 76, 73]"]
B["结果: [ 1, 1, 4, 2, 1, 1, 0, 0]"]
end
A --> B
style B fill:#E8F5E9
Text Only 解释:
第0天温度73,等1天后(第1天)有更高温度74
第2天温度75,等4天后(第6天)有更高温度76
第6天温度76,之后没有更高温度,返回0
经典应用:柱状图中最大矩形
问题分析
给定一个柱状图,求能勾勒出的最大矩形面积。
graph TB
subgraph "柱状图最大矩形"
A["高度: [2, 1, 5, 6, 2, 3]"]
B["最大矩形面积 = 10<br/>宽度=2, 高度=5"]
end
A --> B
style B fill:#E8F5E9
Text Only 柱状图示意:
□
□ □
□ □
□ □ □ □
□ □ □ □ □
2 1 5 6 2 3
最大矩形:
■ ■
■ ■
■ ■ ← 高度5, 宽度2, 面积=10
解题思路
对于每个柱子,找到左右两边比它矮的第一个柱子,计算以该柱子高度为高的最大矩形。
graph TB
subgraph "计算以heights i 为高的矩形"
A["找到左边第一个<heights i 的位置 left"]
B["找到右边第一个<heights i 的位置 right"]
C["宽度 = right - left - 1"]
D["面积 = heights i × 宽度"]
end
A --> B --> C --> D
style D fill:#E8F5E9
实现
Text Only 执行过程: heights = [2, 1, 5, 6, 2, 3]
i=0, h=2: 栈[] → [0]
i=1, h=1: 弹出0, height=2, width=1, area=2
栈[] → [1]
i=2, h=5: 栈[1] → [1, 2]
i=3, h=6: 栈[1, 2] → [1, 2, 3]
i=4, h=2: 弹出3, height=6, width=1, area=6
弹出2, height=5, width=2, area=10 ← 最大
栈[1] → [1, 4]
i=5, h=3: 栈[1, 4] → [1, 4, 5]
i=6, h=0: 弹出5, height=3, width=1, area=3
弹出4, height=2, width=4, area=8
弹出1, height=1, width=6, area=6
最大面积 = 10
经典应用:接雨水
问题分析
给定高度数组,计算能接多少雨水。
graph TB
subgraph "接雨水示意"
A["高度: [0,1,0,2,1,0,1,3,2,1,2,1]"]
B["雨水总量 = 6"]
end
A --> B
style B fill:#E3F2FD
Text Only 接雨水示意:
■
■ □ ■
■ □ ■ □ ■ □ ■
□ ■ □ ■ □ ■ □ ■ □ ■
0 1 0 2 1 0 1 3 2 1 2 1
□ = 可以接雨水的位置
雨水总量 = 6
解题思路
使用单调栈维护可能形成凹槽的位置。
graph TB
subgraph "接雨水计算过程"
A["栈维护递减高度序列"] --> B["遇到更高柱子时计算雨水"]
B --> C["凹槽底部 = 栈顶元素"]
C --> D["左边界 = 新栈顶元素"]
D --> E["右边界 = 当前元素"]
E --> F["水量 = min 左高,右高 - 底高 × 宽度"]
end
style F fill:#E8F5E9
实现
Text Only 执行过程: height = [0,1,0,2,1,0,1,3,2,1,2,1]
i=0: 栈[0]
i=1: 弹出0, 栈空, 无法形成凹槽
栈[1]
i=2: 栈[1, 2]
i=3: 弹出2, bottom=0, left=1, h=min(1,3)-0=1, w=3-1-1=1, water+=1
弹出1, 栈空
栈[3]
i=4: 栈[3, 4]
i=5: 栈[3, 4, 5]
i=6: 弹出5, bottom=0, left=4, h=min(1,1)-0=1, w=6-4-1=1, water+=1
栈[3, 4, 6]
i=7: 弹出6, bottom=1, left=4, h=min(1,3)-1=0, water+=0
弹出4, bottom=1, left=3, h=min(2,3)-1=1, w=7-3-1=3, water+=3
弹出3, 栈空
栈[7]
i=8: 栈[7, 8]
i=9: 栈[7, 8, 9]
i=10: 弹出9, bottom=1, left=8, h=min(2,2)-1=1, w=10-8-1=1, water+=1
栈[7, 8, 10]
i=11: 栈[7, 8, 10, 11]
总水量 = 1 + 1 + 3 + 1 = 6
左右两侧更小元素
一次遍历同时找到每个元素左右两边更小的元素:
C C++ Python Java Go Rust
C void findLeftRightSmaller ( int nums [], int n , int left [], int right []) {
int * stack = ( int * ) malloc ( sizeof ( int ) * n );
int top = -1 ;
// 初始化
for ( int i = 0 ; i < n ; i ++ ) left [ i ] = -1 ;
for ( int i = 0 ; i < n ; i ++ ) right [ i ] = n ;
for ( int i = 0 ; i < n ; i ++ ) {
// 当前元素比栈顶小,栈顶的右边界就是当前元素
while ( top >= 0 && nums [ stack [ top ]] > nums [ i ]) {
right [ stack [ top -- ]] = i ;
}
// 当前元素的左边界是当前栈顶
if ( top >= 0 ) {
left [ i ] = stack [ top ];
}
stack [ ++ top ] = i ;
}
free ( stack );
}
C++ void findLeftRightSmaller ( std :: vector < int >& nums , std :: vector < int >& left , std :: vector < int >& right ) {
int n = nums . size ();
std :: stack < int > st ;
left . assign ( n , -1 );
right . assign ( n , n );
for ( int i = 0 ; i < n ; i ++ ) {
while ( ! st . empty () && nums [ st . top ()] > nums [ i ]) {
right [ st . top ()] = i ;
st . pop ();
}
if ( ! st . empty ()) {
left [ i ] = st . top ();
}
st . push ( i );
}
}
Python def find_left_right_smaller ( nums ):
n = len ( nums )
left = [ - 1 ] * n
right = [ n ] * n
stack = []
for i in range ( n ):
while stack and nums [ stack [ - 1 ]] > nums [ i ]:
right [ stack . pop ()] = i
if stack :
left [ i ] = stack [ - 1 ]
stack . append ( i )
return left , right
Java public int [][] findLeftRightSmaller ( int [] nums ) {
int n = nums . length ;
int [] left = new int [ n ] ;
int [] right = new int [ n ] ;
Arrays . fill ( left , - 1 );
Arrays . fill ( right , n );
Deque < Integer > stack = new ArrayDeque <> ();
for ( int i = 0 ; i < n ; i ++ ) {
while ( ! stack . isEmpty () && nums [ stack . peek () ] > nums [ i ] ) {
right [ stack . pop () ] = i ;
}
if ( ! stack . isEmpty ()) {
left [ i ] = stack . peek ();
}
stack . push ( i );
}
return new int [][] { left , right };
}
Go func findLeftRightSmaller ( nums [] int ) ( left [] int , right [] int ) {
n := len ( nums )
left = make ([] int , n )
right = make ([] int , n )
stack := [] int {}
for i := 0 ; i < n ; i ++ {
left [ i ] = - 1
right [ i ] = n
}
for i := 0 ; i < n ; i ++ {
for len ( stack ) > 0 && nums [ stack [ len ( stack ) - 1 ]] > nums [ i ] {
right [ stack [ len ( stack ) - 1 ]] = i
stack = stack [: len ( stack ) - 1 ]
}
if len ( stack ) > 0 {
left [ i ] = stack [ len ( stack ) - 1 ]
}
stack = append ( stack , i )
}
return left , right
}
Rust fn find_left_right_smaller ( nums : & [ i32 ]) -> ( Vec < i32 > , Vec < i32 > ) {
let n = nums . len ();
let mut left = vec! [ - 1 ; n ];
let mut right = vec! [ n as i32 ; n ];
let mut stack : Vec < usize > = Vec :: new ();
for i in 0 .. n {
while ! stack . is_empty () && nums [ * stack . last (). unwrap ()] > nums [ i ] {
right [ stack . pop (). unwrap ()] = i as i32 ;
}
if ! stack . is_empty () {
left [ i ] = * stack . last (). unwrap () as i32 ;
}
stack . push ( i );
}
( left , right )
}
时间复杂度分析
问题
时间复杂度
空间复杂度
说明
下一个更大元素
O(n)
O(n)
每个元素最多入栈出栈一次
柱状图最大矩形
O(n)
O(n)
每个元素最多入栈出栈一次
接雨水
O(n)
O(n)
每个元素最多入栈出栈一次
为什么是O(n)?
虽然有while循环,但每个元素最多入栈一次、出栈一次,所以总的操作次数不超过2n,时间复杂度为O(n)。
单调栈的应用总结
应用场景
栈类型
存储内容
关键点
下一个更大元素
单调递减
值/索引
从右向左遍历
下一个更小元素
单调递增
值/索引
从右向左遍历
上一个更大元素
单调递减
值/索引
从左向右遍历
每日温度
单调递减
索引
从左向右遍历
柱状图最大矩形
单调递增
索引
处理边界
接雨水
单调递减
索引
计算凹槽
解题模板
模板1:下一个更大元素
C void findNextGreater ( int nums [], int n ) {
int stack [ n ], top = -1 ;
for ( int i = n - 1 ; i >= 0 ; i -- ) {
while ( top >= 0 && stack [ top ] <= nums [ i ]) {
top -- ;
}
// 处理答案: stack[top] 或 -1
stack [ ++ top ] = nums [ i ];
}
}
模板2:从左向右,当前元素作为答案
C void processAsAnswer ( int nums [], int n ) {
int stack [ n ], top = -1 ;
for ( int i = 0 ; i < n ; i ++ ) {
while ( top >= 0 && nums [ stack [ top ]] < nums [ i ]) {
int idx = stack [ top -- ];
// nums[idx] 的答案 = nums[i]
}
stack [ ++ top ] = i ;
}
}
参考资料