从零开始学习单调栈:图文并茂的算法教程
关键词:单调栈、算法设计、数据结构、栈结构、区间最值问题、LeetCode经典题、时间复杂度优化
摘要:本文是面向算法新手的单调栈入门教程。我们将从生活场景出发,用“排队看烟花”的故事类比单调栈的核心逻辑,逐步拆解单调栈的定义、操作规则和应用场景。通过Python代码示例、经典LeetCode题实战(如柱状图最大矩形、每日温度),结合图文分析,帮助你彻底掌握这个能将O(n²)问题优化到O(n)的“神器”。无论你是准备面试的学生,还是想提升算法能力的开发者,读完本文都能快速上手单调栈!
背景介绍
目的和范围
在算法问题中,我们常遇到需要快速找到“某个元素左侧/右侧第一个更大/更小元素”的场景(例如计算柱状图最大矩形面积、接雨水问题)。传统暴力解法需要O(n²)时间复杂度,而单调栈能将这类问题优化到O(n)。本文将系统讲解单调栈的核心原理,覆盖从概念理解到实战应用的全流程,帮助读者掌握这一高效算法工具。
预期读者
- 算法初学者(掌握基础栈操作)
- 准备面试的求职者(需解决LeetCode中等难度题)
- 想优化代码效率的开发者(需处理区间最值问题)
文档结构概述
本文从生活故事引入单调栈概念→拆解核心操作规则→用Python实现基础模板→通过2个经典问题实战→总结应用场景与未来扩展。全文穿插图示、代码注释和复杂度分析,确保“零基础可懂,实战能上手”。
术语表
核心术语定义
- 栈(Stack):一种后进先出(LIFO)的线性数据结构,仅允许在栈顶(Top)插入(Push)和删除(Pop)元素。
- 单调栈(Monotonic Stack):栈内元素保持严格单调递增或递减的特殊栈结构(如[1,3,5]是递增,[5,3,2]是递减)。
- 单调栈的“单调性”:栈中元素满足“从栈底到栈顶,元素值严格递增/递减”(无相等元素)或“非严格递增/递减”(允许相等)。
相关概念解释
- 区间最值问题:需要找到数组中每个元素左右两侧第一个比它大/小元素的问题(如“每日温度”需找每个温度右侧第一个更高温度)。
- 哨兵节点(Sentinel):在数组首尾添加辅助元素(如-1或无穷大),简化边界条件处理(常见于单调栈问题)。
缩略词列表
- LIFO(Last In First Out):后进先出(栈的核心特性)
- LeetCode:全球知名算法练习平台
核心概念与联系
故事引入:排队看烟花的“身高规则”
周末社区放烟花,小朋友们围成一排仰头看天空(图1)。但有个问题:如果前面的小朋友比后面的高,后面的小朋友就会被挡住看不到烟花。这时,保安叔叔想了个办法:让队伍保持“从前往后身高严格递增”——每当新来一个小朋友,就让他和队伍末尾的小朋友比身高,如果自己更高,就把前面挡住他的小朋友“请出队伍”,直到队伍里剩下的都是比他矮的小朋友,然后自己站到队尾。这样,每个小朋友都能快速知道“前面第一个没挡住自己的人”是谁。
这个“动态维护递增队伍”的过程,就是单调栈的核心逻辑!

图1:排队看烟花与单调栈的类比
核心概念解释(像给小学生讲故事一样)
核心概念一:普通栈(Stack)
普通栈就像一个“装羽毛球的管子”:
- 只能从管子顶部(栈顶)放球(Push操作);
- 取球时也只能从顶部拿(Pop操作);
- 最后放进去的球(栈顶元素)最先被取出(后进先出LIFO)。
例如:往管子里依次放1、3、5号球,此时栈顶是5;要取球时,只能先取5,再取3,最后取1。
核心概念二:单调栈(Monotonic Stack)
单调栈是“有脾气的栈”:它对栈内元素的顺序有严格要求——要么“从栈底到栈顶越来越大”(单调递增栈),要么“越来越小”(单调递减栈)。就像前面排队看烟花的小朋友,队伍必须保持递增,否则就要把不符合条件的人请出去。
举个例子:
现在要维护一个单调递增栈,依次处理数组[3,1,4,2]:
- 处理3:栈空,直接入栈→栈=[3]
- 处理1:1 < 栈顶3(破坏递增),所以3必须出栈→栈空,1入栈→栈=[1]
- 处理4:4 > 栈顶1(符合递增),直接入栈→栈=[1,4]
- 处理2:2 < 栈顶4(破坏递增),4出栈;现在栈顶是1,2 > 1(符合递增),2入栈→栈=[1,2]
最终栈是[1,2],保持严格递增。
核心概念三:单调栈的“使命”——找左右边界
单调栈的核心作用是:为每个元素快速找到左侧/右侧第一个比它大(或小)的元素。就像排队看烟花时,每个小朋友能立刻知道“前面第一个比自己矮的人”(左侧边界)和“后面第一个比自己高的人”(右侧边界),这两个边界决定了他能看到的烟花范围。
核心概念之间的关系(用小学生能理解的比喻)
- 普通栈 vs 单调栈:普通栈是“无规则的管子”,单调栈是“有规则的管子”(必须保持递增/递减)。就像普通停车场随便停,而“VIP停车场”必须按车牌号码从小到大排列。
- 单调栈 vs 区间最值问题:单调栈是解决区间最值问题的“钥匙”。就像用钥匙开特定的锁,当问题需要找“第一个更大/更小元素”时,单调栈就是最适合的工具。
- 递增栈 vs 递减栈:两者是“兄弟”,根据问题需求选择。比如要找“右侧第一个更大元素”用递增栈,找“右侧第一个更小元素”用递减栈(后面会详细解释)。
核心概念原理和架构的文本示意图
单调栈的本质是通过维护元素的单调性,将“暴力遍历找边界”的O(n²)操作优化为O(n)。每个元素最多入栈和出栈一次,总操作次数是2n,因此时间复杂度是O(n)。
其核心操作规则可总结为:
- 初始化空栈;
- 遍历数组元素,对当前元素
current
:
a. 当栈非空且栈顶元素不满足单调性(如递增栈要求栈顶<current),则弹出栈顶;
b. 将current
入栈; - 遍历结束后,栈中剩余元素的边界为数组末尾(或哨兵节点)。
Mermaid 流程图
核心算法原理 & 具体操作步骤
单调栈的“三板斧”操作
要掌握单调栈,只需记住三个关键步骤(以单调递增栈为例):
步骤1:确定“维护方向”(递增/递减)
根据问题需求选择:
- 找“右侧第一个更大元素”→用递增栈(因为栈内元素递增,遇到更大的元素时,栈顶元素会被弹出,此时更大的元素就是它的右侧边界);
- 找“右侧第一个更小元素”→用递减栈(栈内元素递减,遇到更小的元素时,栈顶元素会被弹出)。
步骤2:遍历数组,维护栈的单调性
对每个元素current
,循环检查栈顶元素:
- 如果栈顶元素≥current(破坏递增性),则弹出栈顶(因为current是栈顶元素右侧第一个更大的元素);
- 直到栈为空或栈顶元素<current,将current入栈。
步骤3:处理边界情况(哨兵节点)
为了避免处理栈空的情况,可在数组首尾添加“哨兵”:
- 左侧哨兵:通常设为极小值(如-∞),确保第一个元素能顺利入栈;
- 右侧哨兵:通常设为极小值(如-∞),确保所有元素都能找到右侧边界。
Python代码实现:单调栈基础模板
def monotonic_stack(nums):
stack = [] # 初始化空栈
n = len(nums)
result = [0] * n # 存储每个元素的右侧第一个更大元素的索引
for i in range(n):
# 步骤2:维护单调递增栈(栈顶到栈底递增)
while stack and nums[stack[-1]] < nums[i]:
# 当前元素nums[i]是栈顶元素的右侧第一个更大元素
top_index = stack.pop()
result[top_index] = i # 记录右侧边界索引
stack.append(i) # 将当前元素索引入栈
# 步骤3:处理栈中剩余元素(右侧无更大元素,设为n)
while stack:
top_index = stack.pop()
result[top_index] = n
return result
代码解读:
- 栈中存储的是元素的索引(方便记录位置);
while stack and nums[stack[-1]] < nums[i]
:循环判断栈顶元素是否小于当前元素,若小于则弹出(因为当前元素是它的右侧第一个更大元素);result[top_index] = i
:记录弹出元素的右侧边界索引;- 遍历结束后,栈中剩余元素的右侧无更大元素,边界设为数组长度
n
(即超出数组范围)。
数学模型和公式 & 详细讲解 & 举例说明
时间复杂度为什么是O(n)?
每个元素最多入栈一次(stack.append(i)
),最多出栈一次(stack.pop()
)。总操作次数是2n(入栈n次,出栈n次),因此时间复杂度是O(n)。
空间复杂度为什么是O(n)?
栈中最多存储n个元素(当数组严格递减时,所有元素都入栈),因此空间复杂度是O(n)。
举例说明:找右侧第一个更大元素
输入数组:[2, 1, 5, 6, 2, 3]
目标:为每个元素找到右侧第一个更大的元素的索引(没有则为n=6)。
过程图解(图2):
步骤 | 当前元素i | 栈状态(索引) | 操作 | result更新 |
---|---|---|---|---|
0 | i=0(值2) | 空→[0] | 入栈 | - |
1 | i=1(值1) | [0]→检查nums[0]=2 >1→不弹出→入栈→[0,1] | 入栈 | - |
2 | i=2(值5) | [0,1]→nums[1]=1 <5→弹出1→result[1]=2;nums[0]=2 <5→弹出0→result[0]=2→入栈[2] | 弹出1、0 | result[1]=2,result[0]=2 |
3 | i=3(值6) | [2]→nums[2]=5 <6→弹出2→result[2]=3→入栈[3] | 弹出2 | result[2]=3 |
4 | i=4(值2) | [3]→nums[3]=6 >2→不弹出→入栈[3,4] | 入栈 | - |
5 | i=5(值3) | [3,4]→nums[4]=2 <3→弹出4→result[4]=5;nums[3]=6 >3→不弹出→入栈[3,5] | 弹出4 | result[4]=5 |
结束 | - | [3,5]→弹出5→result[5]=6;弹出3→result[3]=6 | - | result[5]=6,result[3]=6 |
最终result数组:[2,2,3,6,5,6]
验证:
- 索引0(值2)的右侧第一个更大元素在索引2(值5);
- 索引1(值1)的右侧第一个更大元素在索引2(值5);
- 索引2(值5)的右侧第一个更大元素在索引3(值6);
- 索引3(值6)右侧无更大元素→6;
- 索引4(值2)的右侧第一个更大元素在索引5(值3);
- 索引5(值3)右侧无更大元素→6。

图2:单调栈找右侧第一个更大元素过程图解
项目实战:代码实际案例和详细解释说明
实战1:LeetCode 84. 柱状图中最大的矩形
题目描述
给定n个非负整数表示柱状图中各柱子的高度,求柱状图中能勾勒出的最大矩形面积(图3)。

图3:示例输入[2,1,5,6,2,3]的最大矩形面积是10(由5和6组成,宽度2,高度5)
暴力解法的缺陷
暴力解法需要遍历每个柱子,计算以该柱子为高度的最大宽度(向左找第一个比它矮的柱子,向右找第一个比它矮的柱子),时间复杂度O(n²)。当n=1e5时,会超时。
单调栈解法思路
- 核心观察:以某个柱子
h[i]
为高度的矩形的最大宽度,等于right[i] - left[i] - 1
,其中left[i]
是i左侧第一个比h[i]小的柱子的索引,right[i]
是i右侧第一个比h[i]小的柱子的索引(图4)。 - 步骤:
- 用单调递增栈找每个柱子的
left
数组(左侧第一个更小元素的索引); - 用同样的方法找
right
数组(右侧第一个更小元素的索引); - 计算每个柱子的面积
h[i] * (right[i] - left[i] - 1)
,取最大值。
- 用单调递增栈找每个柱子的

图4:柱子i的左右边界决定其最大宽度
开发环境搭建
- 语言:Python 3.8+
- 工具:LeetCode在线判题系统(或本地IDE如PyCharm)
源代码详细实现和代码解读
def largestRectangleArea(heights):
n = len(heights)
if n == 0:
return 0
# 步骤1:找左侧第一个更小元素的索引(left数组)
left = [-1] * n # 初始化为-1(左边界哨兵)
stack = []
for i in range(n):
# 维护单调递增栈(栈顶到栈底递增)
while stack and heights[stack[-1]] >= heights[i]:
stack.pop()
if stack:
left[i] = stack[-1]
else:
left[i] = -1 # 左侧无更小元素,边界为-1
stack.append(i)
# 步骤2:找右侧第一个更小元素的索引(right数组)
right = [n] * n # 初始化为n(右边界哨兵)
stack = []
for i in range(n-1, -1, -1):
# 维护单调递增栈(从右往左遍历)
while stack and heights[stack[-1]] >= heights[i]:
stack.pop()
if stack:
right[i] = stack[-1]
else:
right[i] = n # 右侧无更小元素,边界为n
stack.append(i)
# 步骤3:计算最大面积
max_area = 0
for i in range(n):
width = right[i] - left[i] - 1
area = heights[i] * width
if area > max_area:
max_area = area
return max_area
代码解读:
- left数组计算:从左往右遍历,维护单调递增栈(栈中元素对应高度递增)。当当前高度
heights[i]
小于等于栈顶高度时,弹出栈顶(因为栈顶的右侧第一个更小元素是i)。最终left[i]
是栈顶元素(左侧第一个更小的索引)。 - right数组计算:从右往左遍历,逻辑类似。
right[i]
是右侧第一个更小的索引。 - 面积计算:每个柱子的宽度是
right[i]-left[i]-1
,高度是heights[i]
,相乘得到面积,取最大值。
代码测试与验证
输入[2,1,5,6,2,3]
,计算过程:
- left数组:
[-1, -1, 1, 2, 1, 4]
(例如i=2(高度5)的左侧第一个更小是i=1(高度1)); - right数组:
[1, 6, 4, 4, 6, 6]
(例如i=2(高度5)的右侧第一个更小是i=4(高度2)); - 各柱子面积:
- i=0:2*(1-(-1)-1)=2*1=2;
- i=1:1*(6-(-1)-1)=1*6=6;
- i=2:5*(4-1-1)=5*2=10;
- i=3:6*(4-2-1)=6*1=6;
- i=4:2*(6-1-1)=2*4=8;
- i=5:3*(6-4-1)=3*1=3;
- 最大面积是10(正确)。
实战2:LeetCode 739. 每日温度
题目描述
给定一个整数数组T
,表示每天的温度,返回一个数组ans
,其中ans[i]
是第i天之后需要等待多少天才能遇到更高的温度。如果之后没有更高的温度,设为0。
示例:输入T = [73,74,75,71,69,72,76,73]
,输出[1,1,4,2,1,1,0,0]
。
单调栈解法思路
- 目标:对每个元素i,找右侧第一个更大元素的索引j,
ans[i] = j - i
。 - 方法:用单调递减栈(因为要找更大的元素,栈内保持递减,遇到更大的元素时,栈顶元素的右侧边界就是当前索引)。
源代码实现
def dailyTemperatures(T):
n = len(T)
ans = [0] * n
stack = [] # 存储索引,对应温度递减
for i in range(n):
# 维护单调递减栈:当前温度大于栈顶温度时,弹出栈顶
while stack and T[i] > T[stack[-1]]:
top_index = stack.pop()
ans[top_index] = i - top_index
stack.append(i)
return ans
代码解读:
- 栈中存储索引,对应温度递减(如栈顶是最近的较低温度);
- 当当前温度
T[i]
大于栈顶温度时,弹出栈顶top_index
,此时i
是top_index
的右侧第一个更高温度,ans[top_index] = i - top_index
; - 遍历结束后,栈中剩余元素无更高温度,
ans
保持0(初始值)。
实际应用场景
单调栈适用于所有需要**快速找左右边界(第一个更大/更小元素)**的问题,常见场景包括:
- 几何问题:柱状图最大矩形(LeetCode 84)、接雨水(LeetCode 42);
- 温度/价格问题:每日温度(LeetCode 739)、股票价格跨度(LeetCode 901);
- 数组区间问题:子数组的最小值之和(LeetCode 907)、最大矩形(LeetCode 85);
- 单调序列问题:下一个更大元素(LeetCode 496、503)。
工具和资源推荐
- 学习工具:
- VisuAlgo:可视化数据结构操作(包括栈和单调栈);
- LeetCode单调栈专题:包含50+道经典题。
- 参考书籍:
- 《算法导论》(第3章数据结构基础);
- 《剑指Offer》(专项突破版中的“栈与队列”章节)。
未来发展趋势与挑战
- 复杂场景扩展:当前单调栈多处理一维数组问题,未来可能与二维数组(如矩阵中的最大矩形)、树结构(如二叉搜索树的边界问题)结合;
- 时间复杂度优化:虽然单调栈已做到O(n),但在处理超大规模数据(如1e6+元素)时,需注意常数优化(如用数组模拟栈代替Python的list);
- 跨领域应用:单调栈的思想可迁移到金融(实时股价波动分析)、物流(货车排队调度)等领域,解决动态区间最值问题。
总结:学到了什么?
核心概念回顾
- 普通栈:后进先出的线性结构;
- 单调栈:维护元素单调递增/递减的特殊栈,核心作用是找左右边界;
- 边界定义:左侧/右侧第一个比当前元素大(或小)的元素的索引。
概念关系回顾
- 单调栈是普通栈的“功能增强版”,通过维护单调性将O(n²)问题优化到O(n);
- 单调栈的“递增”或“递减”选择取决于问题需要找“更大”还是“更小”的边界;
- 几乎所有需要找“第一个XX元素”的区间问题,都可以用单调栈解决。
思考题:动动小脑筋
- 基础题:如何用单调栈找每个元素“左侧第一个更大的元素”?提示:调整遍历方向和栈的单调性。
- 进阶题:LeetCode 42. 接雨水问题中,如何用单调栈计算能接的雨水量?(提示:维护递减栈,计算左右边界的高度差)
- 开放题:生活中还有哪些场景可以用单调栈的思想解决?(例如超市排队结账,如何让每个顾客快速知道前面第一个结账更快的人?)
附录:常见问题与解答
Q1:单调栈一定是严格递增/递减吗?
A:不一定,根据问题需求可以是“非严格”(允许相等)。例如,在计算柱状图最大矩形时,若存在相等高度的柱子,允许栈中保留相等元素(因为相等高度的柱子不会互相遮挡边界)。
Q2:如何选择递增栈还是递减栈?
A:关键看目标是找“更大”还是“更小”的元素:
- 找右侧第一个更大的元素→递增栈(栈内元素递增,遇到更大的会弹出栈顶);
- 找右侧第一个更小的元素→递减栈(栈内元素递减,遇到更小的会弹出栈顶)。
Q3:栈中存储索引还是值?
A:推荐存储索引,因为需要同时知道元素的值和位置(计算宽度或距离时需要索引)。
扩展阅读 & 参考资料
- LeetCode官方题解:84. 柱状图中最大的矩形
- 知乎专栏:单调栈的入门与应用
- 算法导论(第3版):第10章“基本数据结构”中关于栈的描述。