☰
单调栈通解:线性时间解决最近更大/更小值问题
2026/10/21 9:02:29 网站建设 项目流程

1. 单调栈的本质——先搞清楚它在解决什么问题

1.1 暴力解法为什么让人头疼

先想一个最直观的问题:给你一个数组 [2, 1, 4, 3],让你找出每个元素右边第一个比它大的数,你会怎么做?

绝大多数人第一反应是双重循环:外层遍历每个元素,内层从当前位置往后找,遇到第一个更大的就停下。这个思路没有任何问题,但内层循环的代价是 O(n),整体复杂度是 O(n²)。数据量小的时候问题不大,一旦 n 到了 10⁵ 甚至 10⁶ 的量级,双重循环跑起来就是灾难。

我在实际刷题中体感最深的是,很多题目表面上看是“找某个位置的最近更大值/最近更小值”,但只要你开始写双循环,基本就掉进了超时的陷阱。这道题真正考察的不是你会不会写循环,而是你能不能把“每个元素与它相邻的更大/更小值之间的关系”用一种线性时间的方式统一处理。

1.2 单调栈的核心思想

单调栈这个名字听着有点唬人,说白了就是一种栈,栈内元素按照从栈底到栈顶的顺序保持单调递增或单调递减。

它解决的问题非常集中:给定一个序列,快速找到每个元素左边或右边第一个比它大或比它小的元素位置。注意关键词是“第一个”,不是“最大”,也不是“最小”,而是某种比较关系下距离最近的那个边界元素。

为了帮助理解,我经常用一个生活化的类比:想象一个班级里按身高排队,每个人想知道离自己最近的前面那个人里,第一个比自己高的人是谁。每个人进入队伍的时候,如果发现队尾的人比自己矮,那队尾的人马上就知道答案了——答案是当前这个比自己高的人,然后队尾的人就可以离开队伍。反复执行这个“矮的出队、高的留下”的操作,队伍里的人始终是从低到高排的。

这就是单调栈的运作模式:新元素进来的时候,把栈顶那些“不如自己”的元素赶出去,因为这些元素在比较关系中已经确定了它们要找的边界。每个元素最多入栈一次、出栈一次,所以总时间复杂度是 O(n)。

1.3 单调递增栈和单调递减栈怎么选

这里有个高频混淆点。很多初学者分不清什么时候用单调递增栈、什么时候用单调递减栈,我给出一个非常实用的判断方法:

  • 要找左边或右边第一个比自己大的元素,维护的是从栈底到栈顶单调递减的栈。因为当新元素比栈顶元素大时,栈顶立刻找到了“第一个比自己大”的答案。
  • 要找左边或右边第一个比自己小的元素,维护的是从栈底到栈顶单调递增的栈。因为当新元素比栈顶元素小时,栈顶立刻找到了“第一个比自己小”的答案。

这个很容易记反。我的记忆方式很简单:你要找“大的”,那就让“大的”能产生威胁,也就是新元素大的时候会触发弹栈;你要找“小的”,那就让“小的”产生威胁,也就是新元素小的时候会触发弹栈。触发弹栈的瞬间,就是被弹栈元素得到答案的瞬间。

2. 通解的推导——四种情况如何统一成一版代码

2.1 问题的完整形式

标题里说的“通解”,我理解成这样一个完整的问题族:给定一个数组 nums,要求:

  1. 每个元素左边第一个比它小的元素(下标或值)
  2. 每个元素左边第一个比它大的元素
  3. 每个元素右边第一个比它小的元素
  4. 每个元素右边第一个比它大的元素

这四种情况看起来是四个不同的问题,代码也可能四份,但从单调栈的角度来看,通解只需要一套模板。核心区别只有三点:遍历方向(从左往右还是从右往左)、比较条件(什么时候弹栈)、记录位置(记录左边还是右边)。

2.2 什么时候更新答案

这是整个通解里最关键的判断逻辑。每次新元素 nums[i] 尝试入栈时,如果栈顶元素已经“找到了答案”,就把栈顶弹出,并在弹出的瞬间给栈顶元素的位置设置结果。

举个例子,我们要找右边第一个比自己大的元素。从左往右遍历,当新元素 nums[i] 大于栈顶元素时,对于栈顶元素来说,nums[i] 就是它右边第一个比它大的值,因为我们是顺着数组顺序扫描的,第一个遇到的更大的值天然就是“右边第一个”。

同理,要找右边第一个比自己小的元素,从左往右遍历,当新元素 nums[i] 小于栈顶元素时,栈顶元素就得到了它的答案。

那找左边第一个比自己大或者比自己小呢?两种思路都行:

  • 思路一:从右往左遍历,这样“左边”相对于遍历方向反而变成了“右边”,镜像处理。
  • 思路二:还是从左往右遍历,但把答案记录逻辑反过来,也就是在扫描过程中维护一个已经见过的元素的单调栈,对于每一个新元素,栈里存的都是它左边所有还没有找到答案的元素,弹出的时机依然受比较条件控制。

我实际用下来更推荐“统一从左往右扫描 + 反向记录”的方案,因为这样左右两种情况代码结构完全一致,只需要把比较符号换一下、记录方式换一下。

2.3 统一模板思路

先定义几个变量:

  • res:结果数组,初始化为 -1 或 null,表示没找到
  • stack:存的是元素下标,不是元素值,这一点非常重要

从左往右遍历 nums,对于每个 i:

while 栈不为空 且 当前元素与栈顶元素满足弹栈条件: 栈顶下标 top = stack.pop() res[top] = i // 记录右侧最近值的位置 stack.push(i)

这里“弹栈条件”就是问题的核心开关:

  • 右侧最近更大值:nums[i] > nums[stack[-1]]
  • 右侧最近更小值:nums[i] < nums[stack[-1]]

如果你要的是左侧最近值,有两种处理方式:

  • 方式一:上面的模板得到的是“右侧最近值”,然后对这个模板遍历方向反转(从右往左),得到的就是“左侧最近值”。
  • 方式二:从左往右遍历,当弹出栈顶时,栈顶的下一个栈内元素就是栈顶元素左侧最近的候选值。

我建议先把右侧的两种情况吃透,左侧的用反转遍历方向来镜像解决。这样需要记忆的内容最少,不容易出错。

3. 从模板到代码——四种变体的具体实现

3.1 右侧最近更大值的完整实现

先看最经典的“右侧第一个比自己大的元素”。

function nextGreaterElement(nums) { const n = nums.length; const res = new Array(n).fill(-1); const stack = []; for (let i = 0; i < n; i++) { while (stack.length > 0 && nums[i] > nums[stack[stack.length - 1]]) { const top = stack.pop(); res[top] = nums[i]; } stack.push(i); } return res; }

这里我特意在res[top]里存的是值,有些题目要求存下标,那就是res[top] = i。建议先统一存下标,因为有了下标可以随时取值,反过来不行。这个细节看起来不起眼,但在追问环节很容易救你一命。

有些人会问:为什么初始化是 -1?因为如果一个元素后面没有更大的数,它的结果应该是个特殊标识。用 -1 在竞赛和面试里都是默认做法,不用纠结。

3.2 右侧最近更小值的实现

把比较符号改成小于号就完成了。

function nextSmallerElement(nums) { const n = nums.length; const res = new Array(n).fill(-1); const stack = []; for (let i = 0; i < n; i++) { while (stack.length > 0 && nums[i] < nums[stack[stack.length - 1]]) { const top = stack.pop(); res[top] = nums[i]; } stack.push(i); } return res; }

区别只有>变成了<。这就是通解的力量——你不必把四种情况当作四个独立题目去背,只要理解“弹栈瞬间即答案”,剩下的都是条件切换。

3.3 左侧最近更大值与左侧最近更小值

左侧版本我推荐直接反转遍历方向,从右往左,复用右侧的模板逻辑。

function previousGreaterElement(nums) { const n = nums.length; const res = new Array(n).fill(-1); const stack = []; for (let i = n - 1; i >= 0; i--) { while (stack.length > 0 && nums[i] > nums[stack[stack.length - 1]]) { const top = stack.pop(); res[top] = nums[i]; } stack.push(i); } return res; }

注意看,代码结构和nextGreaterElement几乎一致,只是循环从i = 0; i < n变成了i = n - 1; i >= 0,比较符号不变。因为在“从右往左看”的世界里,右侧变成了我们的主扫描方向,而被弹出的元素得到的答案,就是它在这个反向扫描中遇到的第一个更大值,从原数组角度看就是它左侧的最近更大值。

同理可得:

function previousSmallerElement(nums) { const n = nums.length; const res = new Array(n).fill(-1); const stack = []; for (let i = n - 1; i >= 0; i--) { while (stack.length > 0 && nums[i] < nums[stack[stack.length - 1]]) { const top = stack.pop(); res[top] = nums[i]; } stack.push(i); } return res; }

四个函数放在一起看,就是两处变量:一个循环方向,一个比较符号。能用一个模板理解的东西,就不要拆成四个知识点来记。

3.4 写代码时的三个关键细节

第一个细节:栈里存的是下标而不是值。为什么?因为很多后续题目需要知道位置差、计算宽度、计算面积,只有下标才能推导出来。如果只存值,位置信息就丢了。

第二个细节:弹栈之后,栈里剩下的元素之间的关系。栈始终保持单调性,也就是说,当你弹掉若干元素之后,新的栈顶一定是当前元素之前最近且满足特定比较关系的位置。这个性质在“柱状图中最大的矩形”这类题目里是核心。

第三个细节:边界条件。数组为空直接返回空数组;数组只有一个元素时结果应该是 [-1] 或 [null];所有元素都相等时,不会有任何弹栈发生,结果全部是 -1。这些边界不用额外特判,因为初始化和循环逻辑天然覆盖了,但测试的时候要想到。

4. 经典场景与题目映射——通解怎么用

4.1 下一个更大元素系列

这类题有两种常见形态:一种是无重复数组,一种是循环数组。

无重复数组的版本可以直接套模板。循环数组的处理方式有一个经典技巧:把数组复制一倍拼接起来,遍历长度为原来的两倍,下标对原长度取模,这样就能模拟环形结构。另一种更省内存的方式是遍历两遍,用i % n来访问元素,效果完全一样。

我在实战中更推荐遍历两遍的方式,因为拼接数组在 n 很大的时候浪费空间,而两遍遍历只增加了常数时间的开销,代码也很干净。

4.2 柱状图中最大的矩形

这道题是单调栈的经典应用题。给定一个柱状图的高度数组,找到能勾勒出的最大矩形面积。暴力解法是枚举每个柱子作为矩形的高,然后向左右扩展边界,复杂度 O(n²)。

单调栈解法就变成了:找到每个柱子左边第一个比它矮的位置、右边第一个比它矮的位置,这两个位置之间就是当前柱子能作为高度的最大宽度。这正好是“左右两侧最近的更小值”的典型应用。

套用左侧更小和右侧更小的模板,得到左右边界,然后:面积 = 高度 × (right[i] - left[i] - 1),取全局最大值。

我当初在这道题上踩过一个坑:左右边界初始化。如果某个柱子左边没有更矮的,它的 left 边界应该是 -1;右边没有更矮的,它的 right 边界应该是 n。否则计算宽度时会漏掉边界柱子,导致结果变小。细节上必须有这个“哨兵”意识。

4.3 接雨水问题

接雨水也是单调栈的高频出题点,本质上是在找一个元素左右两侧最近的高度关系。

用单调栈处理时,核心思路是:当出现“中间低、两边高”的结构时,中间低处就能接到雨水。栈内维护的是递减的高度下标,当新元素比栈顶高的时候,把栈顶弹出,此时弹出的位置就是一个凹陷,它的积水深度取决于它左侧的新栈顶和右侧的新元素中较矮的那个。

这个题目比单纯的“找更大/更小”多了一步——需要计算面积,但底层的查找逻辑依然是最近的更大值/更小值关系。很多读者会觉得这题难,其实难的是把“积水区间”和“左右边界”的关系看透。一旦你能熟练写出上一节的四个模板函数,这题的骨架已经在了。

4.4 每日温度问题

这个题是单调栈最入门、最友好的应用:给定每日气温列表,返回一个列表,每个位置表示要等多少天才会出现更高温度。

翻译一下就是:对每个元素,找右边第一个比它大的元素下标,然后算下标的差值。这个和nextGreaterElement的唯一区别是返回的不是下标本身,而是“下标差值”。

这个题我建议作为单调栈的入门第一题。因为它没有面积计算、没有宽度合并、没有循环数组,纯粹就是查最近更大值位置,然后把模板吃透。很多教学材料把它和柱状图混在一起讲,导致初学者同时面对两个陌生概念,非常劝退。

5. 常见问题与排查技巧实录

5.1 弹栈条件写反导致结果全错

这是我见过最多的问题,也是自己刚开始学的时候栽过的地方。

举个例子,找右侧第一个比当前元素大的值,如果你错误地写成了while (stack.length > 0 && nums[i] < nums[stack[stack.length - 1]]),那么你会把“新元素比栈顶元素小”作为弹栈条件。这样栈里元素会不断累积,完全无法触发任何“找到答案”的逻辑,最后结果全部是 -1。

排查方式也很简单:随便拿一个小数组,比如 [1, 3, 2],手动走一遍你的 while 循环。第一个元素进栈;第二个元素 3 比栈顶 1 大,应该弹栈,但你写的是小于号,没弹出;第三个元素 2 比栈顶 3 小,也没弹出。整个栈越堆越满,没有任何答案产生。走到这一步,99% 是比较符号写反了。

5.2 栈里存值不存下标

很多初学者图省事,直接把值放进栈里,结果算出来的答案是对的,但一旦题目要求返回下标或计算距离,又要回头改代码。

我的习惯是:只要使用单调栈,一律存下标。即使题目只要求返回最大值,我也用下标去访问,因为这样代码的可扩展性最好。面试中你很难预判追问方向,存下标等于给自己留了后路。

5.3 相等元素的处理

题目没有说明数组是否包含相等元素时,这里就藏着坑。找“第一个比自己大”时,相等元素不能触发弹栈;找“第一个比自己小”时,相等元素也不能触发弹栈。所以用>和<,不要用>=和<=,这是默认选择。

但如果题目要求是“第一个不小于”或“第一个不大于”,那就要改成>=或<=。这种细微差别在具体题目里经常成为区分点。

5.4 单调栈的时间复杂度为什么是 O(n)

这是一个面试常问的问题,要能讲清楚。每个元素最多入栈一次、出栈一次,while 循环中所有迭代的总次数不超过 2n,所以整体是线性复杂度。不要被“循环里还有循环”的表象迷惑,内层 while 是均摊 O(1) 的,因为每个元素只会被弹出一次。

我用一个生活比喻来解释给朋友听:就像是给每个进教室的人安排座位,每个人坐下后起身离开教室最多一次。虽然看起来一直在忙活,但总的“坐下—离开”次数不会超过人数的两倍。

5.5 如何肉眼验证结果

我要求自己写完模板后,一定用一个小例子在纸上跑一遍。拿 [2, 1, 4, 3] 测右侧更大值:

  • i=0,栈空,push 0
  • i=1,nums[1]=1,不比栈顶 2 大,push 1
  • i=2,nums[2]=4,比栈顶下标 1 的元素 1 大,弹出 1,res[1]=4;继续比栈顶下标 0 的元素 2 大,弹出 0,res[0]=4;栈空,push 2
  • i=3,nums[3]=3,比栈顶下标 2 的元素 4 小,push 3
  • 最后栈里剩下标 2 和 3,res[2]=-1,res[3]=-1

结果应该是 [4, 4, -1, -1]。检查一下:数组 [2, 1, 4, 3] 里,2 的右边第一个更大是 4,1 的右边第一个更大是 4,4 的右边没有更大,3 的右边没有更大。完全正确。

这种手跑的方式,在初学阶段非常有效,也是我向所有读者建议的调试手段。

5.6 从二维扩展中得到的经验

单调栈的模板掌握之后,很多看似高级的问题会迎刃而解。比如“矩阵中最大矩形”、“统计子矩阵数量”等题目,往往需要先对每一行或每一列进行预处理,把问题转化为一维的柱状图问题,然后再套单调栈。

这类二维问题的关键一步在于如何构造出每个位置的高度数组。我的建议是,先在一维层面把四个模板写到肌肉记忆的程度,再去看二维扩展,否则容易混淆。

实际项目中,我还会把四个模板封装成一个小工具模块,传入一个数组和配置项,直接返回四个结果。这种做法在竞赛和面试准备阶段帮助极大,因为它把大量重复代码收敛成了一个函数,调试起来异常方便。

6. 进阶优化与模式识别——如何把模板用到炉火纯青

6.1 统一封装成一个多功能函数

如果你和我一样,前前后后写过不下二十道单调栈题目,会发现每次都在复制同样的结构。与其反复改符号,不如直接封装成一个函数。

用 JavaScript 写的话,可以是这样的思路:

function monotonicStack(nums, mode) { const n = nums.length; const res = new Array(n).fill(-1); const stack = []; const range = mode.includes('left') ? Array.from({ length: n }, (_, i) => n - 1 - i) : Array.from({ length: n }, (_, i) => i); const shouldPop = mode.includes('greater') ? (cur, top) => cur > top : (cur, top) => cur < top; for (const i of range) { while (stack.length > 0 && shouldPop(nums[i], nums[stack[stack.length - 1]])) { const top = stack.pop(); res[top] = nums[i]; } stack.push(i); } return res; }

这个封装不算最优,主要是给读者展示一种“参数化比较条件 + 参数化遍历方向”的设计思路。真正用的时候,如果你不想写这么抽象,那就手写四个函数,20 行不到,反而更直观。

6.2 用单调栈解决区间问题的高阶思路

单调栈不仅能定位最近更大/更小值,还能定位“以某个元素为最小值的最大区间”,这是它更高级的用法。

想象一个数组,你想知道每个元素作为最小值时,能覆盖的最远区间是什么。你只需要找这个元素左边第一个比它小的位置和右边第一个比它小的位置,两个位置之间就是它能作为最小值的最远范围。这个区间信息在很多统计类问题里非常有用。

比如有一类题目让你计算“所有子数组的最小值之和”,如果你枚举所有子数组,复杂度是 O(n²)。但利用单调栈找出每个元素作为最小值的区间范围后,每个元素作为最小值的子数组数量可以直接算出来,再用乘法分配律累积结果,整体复杂度降到 O(n)。这类题目强烈推荐在掌握基础模板后专门练一练,它是单调栈从“会写”到“会用”的质变点。

6.3 与双指针、前缀和的组合

单调栈经常不是单独出现的,它会和前缀和、差分数组组合在一起。比如在某些需要计算“给定数组中所有区间最小值之和”的场景中,先用单调栈找到每个元素的左右边界,再用前缀和快速计算区间贡献,整个过程行云流水。

组合应用的窍门是:先问自己每个元素对最终答案的贡献是什么,如果贡献和“它能成为最大/最小的范围”有关,单调栈几乎总是第一选择。

6.4 我常做的三类练习设计

如果你在准备面试或者竞赛,我建议把单调栈的练习分成三个阶段:

第一阶段:只写四个模板函数。不追求速度,但要保证每次都能不假思索地写对弹栈条件、遍历方向、记录方式。这个阶段的目标是把代码结构刻进肌肉记忆。

第二阶段:做三道经典题。每日温度、柱状图中最大的矩形、接雨水。这三题分别对应“找更大值”“双侧找更小值并计算宽度”“双侧找更大值并计算面积”,覆盖了单调栈的主要考点。

第三阶段:做一道二维变体题。比如把矩阵按行累加后转换成一维柱状图问题。这一步能显著提升你对底层模式的识别能力。

我在带过的几个初学小伙伴身上验证过,按照这个节奏练,基本一周内就能从零到能够独立解决中等难度的单调栈题目。这个阶段的练习价值非常大,远超过单纯刷题的数量意义。

7. 个人经验与避坑心得——写给正在刷题的你

最后分享一些我在反复使用单调栈过程中的真实体验。

我最开始接触这个知识点时,也觉得它不过是一个“找最近更大值”的小技巧。但后来在一次处理实际数据的需求中,遇到一个百万级日志序列,需要为每一条日志找到它前面最近的一条异常记录,朴素做法跑了几分钟也没出结果。当时我忽然想到单调栈,改完之后耗时降到了毫秒级。从那时起,我对这个小技巧有了完全不同的认识——它不是一个应试工具,而是一个真正能在数据量面前改变效率的算法思想。

如果你正在学习这块内容,我的建议是不要硬背代码。先理解那个“弹栈瞬间即答案”的核心逻辑,然后用一组固定的小数据反复手推,直到你看到任何“最近更大/更小”的问题,第一反应不是写双重循环,而是想“这里能不能用单调栈”。

还要提醒一点:写模板的时候不要图省事把判断条件直接内联到循环里,先用变量名把“ isGreater ”这样的语义表达出来,代码会清晰很多,调试的时候也更容易发现问题。这个习惯在写到第 10 个题目时会给你带来极大的优势。

单调栈是一门“会者不难”的技术。把这个通解吃透,你以后再遇到各种花式包装的题目,比如循环数组、二维矩阵、区间贡献统计,会发现它们不过是在这个核心模板上增加了一些外围逻辑。掌握了核心,一切都会显得理所当然。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询