2025-11-10 19:30:26

告别死记硬背!一文彻底搞懂单调栈的本质与应用

什么是单调栈?

单调栈是一种特殊的栈结构,栈内元素保持单调性(递增或递减)。

但这只是表象!单调栈的本质是

用栈动态维护"全局待定"元素集合,通过弹出操作完成"全局问题→局部确定"的转化过程。

核心思想

┌─────────────┐
│  全局问题    │
└──────┬──────┘
       │ 从左到右扫描
       ↓
┌─────────────┐      触发条件满足
│  单调栈中    │ ────────────→ ┌─────────────┐
│ (全局待定)   │                │  弹出栈      │
└─────────────┘                │ (局部确定)   │
                                └─────────────┘
  • 栈中元素 = 全局待定的元素(还没被"确定")
  • 弹出元素 = 局部确定的元素(作用范围确定了)
  • 栈的单调性 = 优先级顺序的体现

识别单调栈问题的三个信号

信号1:全局 vs 局部

问题涉及"全局最优"到"局部确定"的转化

例如

  • 最少操作次数:全局操作 → 遇到小值后局部确定
  • 接雨水:全局高度 → 遇到更高柱子后局部确定
  • 下一个更大元素:全局查找 → 遇到更大值后局部确定

信号2:前后元素关系

当前元素会影响之前元素的有效性

例如

  • 最少操作次数:遇到小值,大值失效(被隔离成孤岛)
  • 接雨水:遇到高柱,低柱可以接水了
  • 最大矩形:遇到低柱,高柱的范围确定了

信号3:优先级顺序

有明确的优先级(最小/最大优先)

关键特征

  • 小的先处理 → 单调递增栈
  • 大的先处理 → 单调递减栈

识别检查清单

遇到问题时,问自己:

  • 元素之间有优先级吗?(大小、高低、远近)
  • 当前元素会让之前的元素失效吗?
  • **问题涉及"范围确定"或"局部最优"**吗?
  • 需要知道"下一个更大/更小"元素吗?
  • 有明确的分隔符或边界吗?

如果3个以上✓,很可能是单调栈问题!


单调栈通用模板

func monotoneStack(nums []int) int { result := 0 stack := []int{} // 或 []ElementType{} for i, num := range nums { // 1. 处理分隔符(如果有) if isSeparator(num) { stack = stack[:0] continue } // 2. 弹出不满足单调性的元素 // 这些元素的"局部解"确定了 for len(stack) > 0 && shouldPop(stack[len(stack)-1], num) { // 可选:在弹出时处理该元素 top := stack[len(stack)-1] stack = stack[:len(stack)-1] // 计算top的贡献 result += calculate(top, stack, num) } // 3. 判断是否需要新操作 // - 栈空 → 需要 // - 栈顶满足条件 → 可能需要 if needNewOperation(stack, num) { result++ stack = append(stack, num) } // 如果栈顶 == num,可能不需要操作 } return result } // 核心函数: // - isSeparator(num):是否是分隔符 // - shouldPop(top, cur):是否弹出栈顶 // - 单调递增栈:top > cur // - 单调递减栈:top < cur // - needNewOperation(stack, num):是否需要新操作 // - calculate(top, stack, num):计算贡献(可选)

三层核心洞察

洞察1:基线数量

有多少个需要处理的单位,至少需要多少次操作

  • 最少操作次数:不同值的个数 = 基线操作数
  • 接雨水:能接水的位置 = 基线容量

洞察2:分隔增量

单位之间被分隔,会增加处理次数/成本

  • 最少操作次数:相同值被小值分隔 → 操作数+1
  • 接雨水:连续低洼被高柱分隔 → 分段计算

洞察3:局部确定条件

遇到什么条件,元素的局部解就确定了?

  • 最少操作次数:当前值 < 栈顶当前值 == 0
  • 接雨水:当前高度 > 栈顶高度
  • 下一个更大元素:当前值 > 栈顶

示例详解:将所有元素变为0的最少操作次数

LeetCode 3542: 将所有元素变为0的最少操作次数

问题描述

给你一个大小为 n非负整数数组 nums。你的任务是对该数组执行若干次(可能为 0 次)操作,使得所有元素都变为 0。

在一次操作中,你可以:

  • 选择一个子数组 [i, j]0 <= i <= j < n
  • 将该子数组中所有最小的非负整数设为 0

返回使整个数组变为 0 所需的最少操作次数。

注意:一个子数组是数组中的一段连续元素。

示例1

输入: nums = [0,2]
输出: 1
解释:
- 选择子数组 [1,1](即 [2]),最小非负整数是 2
  将所有 2 设为 0 → [0,0]

示例2

输入: nums = [3,1,2,1]
输出: 3
解释:
- 选择子数组 [1,3](即 [1,2,1]),最小非负整数是 1
  将所有 1 设为 0 → [3,0,2,0]
- 选择子数组 [2,2](即 [2]),将 2 设为 0 → [3,0,0,0]
- 选择子数组 [0,0](即 [3]),将 3 设为 0 → [0,0,0,0]

示例3

输入: nums = [1,2,1,2,1,2]
输出: 4
解释:
- 选择子数组 [0,5](即 [1,2,1,2,1,2]),最小非负整数是 1
  将所有 1 设为 0 → [0,2,0,2,0,2]
- 选择子数组 [1,1](即 [2]),将 2 设为 0 → [0,0,0,2,0,2]
- 选择子数组 [3,3](即 [2]),将 2 设为 0 → [0,0,0,0,0,2]
- 选择子数组 [5,5](即 [2]),将 2 设为 0 → [0,0,0,0,0,0]

问题分析

核心洞察

洞察1:0的分隔作用

0就像一道不可逾越的墙,0将数组分隔成独立的区域

举例:[0,3,2,0]

  • 被0分隔成两个区域:[3][2]
  • 每个区域需要独立处理
  • 所以总共需要2次操作

洞察2:最小值优先策略

在非0的连续子数组中,每次操作都应该处理当前的最小值

为什么?

  • 处理最小值时,可以把该值的所有出现位置一起清零
  • 处理最小值后,比它大的值会被分隔成更小的子区域
  • 这确保了操作次数最少

洞察3:小值"隔离"大值

更深层的洞察:当处理一个小值时,大值会被0分隔成孤岛

举例:[3,1,2,1]

  • 处理1后:[3,0,2,0]
  • 3被隔离成孤岛 [3],需要单独处理
  • 2被隔离成孤岛 [2],需要单独处理

这就是"全局→局部"的转化!

三层洞察应用

洞察1:基线数量

  • 不同值的个数 = 基线操作数
  • [1,2,3,4,5] → 5个不同值 → 至少5次操作

洞察2:分隔增量

  • 相同值被小值分隔 → 增加操作数
  • [2,1,2] → 值2被1分隔成2个孤岛 → 需要3次操作(处理1 + 2个孤岛的2)

洞察3:局部确定

  • 遇到更小值或0 → 大值的局部解确定
  • [3,1,...] → 遇到1时,3的作用范围确定为[0,1)

方法演进

方法1:递归模拟(直观但慢)

思路:按0分割 → 找最小值 → 递归处理子区域

func minOperations(nums []int) int { // 按0分割成多个区域 // 对每个区域:找最小值,标记为0,递归处理子区域 // 时间复杂度:O(n²) 最坏情况 }

问题:递归+数组修改开销大,会超时

方法2:按值分组(离线算法)

思维跳跃:既然最小值优先,那就按值从小到大排序处理

关键洞察:对于每个值,检查它的相邻两个位置之间是否有比它小的值

  • 如果有,说明处理小值后会产生分隔
  • 段数 = 该值被分成多少段
func minOperations(nums []int) int { // 1. 按值分组,记录每个值的位置 // 2. 按值从小到大排序 // 3. 对每个值,检查相邻位置间是否有更小值 // 时间复杂度:O(n×k),k是不同值的个数 }

示例 [1,2,1,2,1,2]

  • 值1(位置0,2,4):相邻位置间没有更小值 → 1段
  • 值2(位置1,3,5):
    • 位置1-3间有1(1<2)→ 分隔!
    • 位置3-5间有1(1<2)→ 分隔!
    • 总共3段
  • 总计:1 + 3 = 4次操作

问题:虽然正确,但在极端情况下仍会超时

方法3:单调栈(最优解)

思维的飞跃

从"最小值优先"和"小值隔离大值"出发:

  • 从左到右扫描时,如果遇到小值,之前的大值就不重要了(会被隔离)
  • 所以只需维护"还有用的值" → 这些值必然是递增的
  • 用栈维护 → 单调递增栈

算法描述

维护一个单调递增栈,栈中保存当前"活跃"的不同值:

func minOperations(nums []int) int { operations := 0 stack := []int{} // 单调递增栈 for _, num := range nums { if num == 0 { // 0是分隔符,清空栈 stack = stack[:0] continue } // 弹出所有 > num 的元素 // 为什么?因为这些大值会被隔离成孤岛 for len(stack) > 0 && stack[len(stack)-1] > num { stack = stack[:len(stack)-1] } // 如果栈为空或栈顶 < num,需要新操作 if len(stack) == 0 || stack[len(stack)-1] < num { operations++ stack = append(stack, num) } // 如果栈顶 == num,不需要操作(已在计划中) } return operations }

时间复杂度:O(n) - 每个元素最多入栈出栈各一次
空间复杂度:O(k) - k是不同值的个数

详细示例演示

示例1:[1,2,0,5,1,2,1]

步骤1: num=1
  栈: [] → 栈空,需要操作
  操作数: 1
  栈: [1]

步骤2: num=2
  栈: [1] → 栈顶1 < 2,需要操作
  操作数: 2
  栈: [1, 2]

步骤3: num=0
  栈: [1, 2] → 遇到0,清空栈(硬分隔)
  操作数: 2
  栈: []

步骤4: num=5
  栈: [] → 栈空,需要操作
  操作数: 3
  栈: [5]

步骤5: num=1
  栈: [5] → 弹出5(5 > 1,5被隔离)
  栈: [] → 栈空,需要操作
  操作数: 4
  栈: [1]

步骤6: num=2
  栈: [1] → 栈顶1 < 2,需要操作
  操作数: 5
  栈: [1, 2]

步骤7: num=1
  栈: [1, 2] → 弹出2(2 > 1,2被隔离)
  栈: [1] → 栈顶1 == 1,不需要操作
  操作数: 5
  栈: [1]

最终答案:5次操作 ✓

示例2:[3,1,2,1]

步骤1: num=3 → ops=1, 栈=[3]
步骤2: num=1 → 弹出3, ops=2, 栈=[1]  (3被隔离)
步骤3: num=2 → 栈顶1<2, ops=3, 栈=[1,2]
步骤4: num=1 → 弹出2, 栈顶1==1, ops=3, 栈=[1]  (2被隔离)

最终答案:3次操作 ✓

示例3:[1,2,1,2,1,2]

步骤1: num=1 → ops=1, 栈=[1]
步骤2: num=2 → ops=2, 栈=[1,2]
步骤3: num=1 → 弹出2, 栈顶==1, ops=2, 栈=[1]
步骤4: num=2 → ops=3, 栈=[1,2]
步骤5: num=1 → 弹出2, 栈顶==1, ops=3, 栈=[1]
步骤6: num=2 → ops=4, 栈=[1,2]

最终答案:4次操作 ✓

核心理解

栈的含义

栈中保存的是**“当前需要独立操作处理的不同值”**(单调递增)

为什么弹出大值?

当遇到小值 num 时:

  • 所有比 num 大的值都会被"隔离成孤岛"
  • 孤岛内的大值会在局部处理
  • 所以这些大值不再需要全局操作,弹出!

弹出 = 从全局计划移除(因为会变成局部问题)

为什么重复值不操作?

如果 栈顶 == num,说明之前已经安排了处理这个值的操作,不需要重复。

为什么单调递增?

因为我们总是弹出比当前值大的元素,所以栈自然保持递增。


常见单调栈问题分类

问题类型 栈类型 弹出条件 典型题目
下一个更大元素 递减栈 cur > top LC 496, 503
下一个更小元素 递增栈 cur < top LC 1475
矩形面积 递增栈 cur < top LC 84, 85
接雨水 递增栈 cur > top LC 42
移除K个数字 递增栈 cur < top LC 402
最少操作次数 递增栈 cur < top LC 3542

记忆口诀

单调栈四问

  1. 为什么栈? → 动态维护全局待定元素
  2. 为什么单调? → 体现优先级顺序
  3. 为什么弹出? → 局部解确定,转化完成
  4. 为什么入栈? → 新的全局待定元素

一句话精髓

单调栈 = 用栈维护"全局待定"集合,通过弹出完成"全局→局部"的动态转化。


调试技巧

如果单调栈不工作,检查:

  1. 单调性方向反了?递增 vs 递减
  2. 弹出条件错了> vs >=
  3. 忘记处理相等情况
  4. 结果统计位置错了?入栈时 vs 弹出时
  5. 边界条件遗漏?栈空、数组开头/结尾

总结

单调栈的精髓:

  • 不是机械的"维护单调性"
  • 而是动态的"全局→局部转化"
  • 栈只是载体,转化才是本质

从洞察到最优解的思维路径

  1. 观察到分隔规律 → 按分隔符分割独立处理
  2. 观察到优先级顺序 → 按优先级排序处理
  3. 观察到相互影响关系 → 小值会隔离大值
  4. 从隔离想到范围确定 → 大值的作用范围确定
  5. 从范围确定想到维护有效集 → 单调栈
  6. 单调栈 = 动态维护活跃值集合

关键跳跃:从"全局排序"到"局部维护",从"离线"到"在线"。

当你理解了"转化",单调栈就不再神秘了!

本文链接:http://blog.go2live.cn/post/stack.html

-- EOF --