从零开始学习单调栈:图文并茂的算法教程

从零开始学习单调栈:图文并茂的算法教程

关键词:单调栈、算法设计、数据结构、栈结构、区间最值问题、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)。

其核心操作规则可总结为:

  1. 初始化空栈
  2. 遍历数组元素,对当前元素current
    a. 当栈非空且栈顶元素不满足单调性(如递增栈要求栈顶<current),则弹出栈顶;
    b. 将current入栈;
  3. 遍历结束后,栈中剩余元素的边界为数组末尾(或哨兵节点)。

Mermaid 流程图

初始化空栈
遍历数组元素current
栈非空且栈顶不满足单调性?
弹出栈顶
将current入栈
遍历结束

核心算法原理 & 具体操作步骤

单调栈的“三板斧”操作

要掌握单调栈,只需记住三个关键步骤(以单调递增栈为例):

步骤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更新
0i=0(值2)空→[0]入栈-
1i=1(值1)[0]→检查nums[0]=2 >1→不弹出→入栈→[0,1]入栈-
2i=2(值5)[0,1]→nums[1]=1 <5→弹出1→result[1]=2;nums[0]=2 <5→弹出0→result[0]=2→入栈[2]弹出1、0result[1]=2,result[0]=2
3i=3(值6)[2]→nums[2]=5 <6→弹出2→result[2]=3→入栈[3]弹出2result[2]=3
4i=4(值2)[3]→nums[3]=6 >2→不弹出→入栈[3,4]入栈-
5i=5(值3)[3,4]→nums[4]=2 <3→弹出4→result[4]=5;nums[3]=6 >3→不弹出→入栈[3,5]弹出4result[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)。
  • 步骤
    1. 用单调递增栈找每个柱子的left数组(左侧第一个更小元素的索引);
    2. 用同样的方法找right数组(右侧第一个更小元素的索引);
    3. 计算每个柱子的面积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,此时itop_index的右侧第一个更高温度,ans[top_index] = i - top_index
  • 遍历结束后,栈中剩余元素无更高温度,ans保持0(初始值)。

实际应用场景

单调栈适用于所有需要**快速找左右边界(第一个更大/更小元素)**的问题,常见场景包括:

  1. 几何问题:柱状图最大矩形(LeetCode 84)、接雨水(LeetCode 42);
  2. 温度/价格问题:每日温度(LeetCode 739)、股票价格跨度(LeetCode 901);
  3. 数组区间问题:子数组的最小值之和(LeetCode 907)、最大矩形(LeetCode 85);
  4. 单调序列问题:下一个更大元素(LeetCode 496、503)。

工具和资源推荐

  • 学习工具
  • 参考书籍
    • 《算法导论》(第3章数据结构基础);
    • 《剑指Offer》(专项突破版中的“栈与队列”章节)。

未来发展趋势与挑战

  • 复杂场景扩展:当前单调栈多处理一维数组问题,未来可能与二维数组(如矩阵中的最大矩形)、树结构(如二叉搜索树的边界问题)结合;
  • 时间复杂度优化:虽然单调栈已做到O(n),但在处理超大规模数据(如1e6+元素)时,需注意常数优化(如用数组模拟栈代替Python的list);
  • 跨领域应用:单调栈的思想可迁移到金融(实时股价波动分析)、物流(货车排队调度)等领域,解决动态区间最值问题。

总结:学到了什么?

核心概念回顾

  • 普通栈:后进先出的线性结构;
  • 单调栈:维护元素单调递增/递减的特殊栈,核心作用是找左右边界;
  • 边界定义:左侧/右侧第一个比当前元素大(或小)的元素的索引。

概念关系回顾

  • 单调栈是普通栈的“功能增强版”,通过维护单调性将O(n²)问题优化到O(n);
  • 单调栈的“递增”或“递减”选择取决于问题需要找“更大”还是“更小”的边界;
  • 几乎所有需要找“第一个XX元素”的区间问题,都可以用单调栈解决。

思考题:动动小脑筋

  1. 基础题:如何用单调栈找每个元素“左侧第一个更大的元素”?提示:调整遍历方向和栈的单调性。
  2. 进阶题:LeetCode 42. 接雨水问题中,如何用单调栈计算能接的雨水量?(提示:维护递减栈,计算左右边界的高度差)
  3. 开放题:生活中还有哪些场景可以用单调栈的思想解决?(例如超市排队结账,如何让每个顾客快速知道前面第一个结账更快的人?)

附录:常见问题与解答

Q1:单调栈一定是严格递增/递减吗?
A:不一定,根据问题需求可以是“非严格”(允许相等)。例如,在计算柱状图最大矩形时,若存在相等高度的柱子,允许栈中保留相等元素(因为相等高度的柱子不会互相遮挡边界)。

Q2:如何选择递增栈还是递减栈?
A:关键看目标是找“更大”还是“更小”的元素:

  • 找右侧第一个更大的元素→递增栈(栈内元素递增,遇到更大的会弹出栈顶);
  • 找右侧第一个更小的元素→递减栈(栈内元素递减,遇到更小的会弹出栈顶)。

Q3:栈中存储索引还是值?
A:推荐存储索引,因为需要同时知道元素的值和位置(计算宽度或距离时需要索引)。


扩展阅读 & 参考资料

  1. LeetCode官方题解:84. 柱状图中最大的矩形
  2. 知乎专栏:单调栈的入门与应用
  3. 算法导论(第3版):第10章“基本数据结构”中关于栈的描述。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值