上周有读者问我一个问题:“单调栈、单调队列(滑动窗口)和 KMP,这三样东西到底有什么关联?为什么很多人把 ta 们放在一起复习?”
我想了一下,发现自己当初也是分三次才学会这三种技巧的——先是在刷“下一个更大元素”时认识了单调栈,后来又被滑动窗口最大值虐了一遍才搞懂单调队列,最后在字符串匹配上被 KMP 的 next 数组绕晕过好几天。但真正把它们放在同一个抽屉里对比之后,我才意识到:这三个名字看着像三个独立算法,底层思路其实是一家人。
这篇文章不打算写成教科书式的定义罗列。我会直接从“为什么需要这个结构”“代码怎么写”“哪里容易踩坑”三个角度,把单调栈、单调队列/滑动窗口、KMP 一次讲透。文章末尾还会分享我实际做题时总结的几个通用套路,以及一个很多人问过我的问题:KMP 的 next 数组到底算不算动态规划。
无论你是刚接触算法的新手,还是正在备战笔试面试的候选人,希望这篇能把这三块“硬骨头”里的筋络给你拆开,后面再见到同类题,脑子里能自动弹出一句:“哦,这题可以用那套单调性思维。”
1. 为什么要把这三种“老古董”放在一起学
先说个现象。
你去看 LeetCode 热题、笔试高频题、竞赛入门题,单调栈、单调队列(滑动窗口)和 KMP 出现的频率不算低,但也很少有人把它们当成一个整体去讲。大多数教程的安排是:先讲栈,再讲队列,最后单独开一章字符串匹配。于是很多人的学习路径就变成了——今天会了单调栈,下周忘了;下周学单调队列,又跟单调栈搞混;到了 KMP,只记住“有个 next 数组”,但为什么要那样跳,完全说不清。
我自己的转折点是某天做题时突然意识到一件事:这三种算法本质上都在做同一个动作——“消除冗余比较”。
- 暴力办法为什么会慢?因为大量时间花在了注定不可能产生答案的比较上。
- 单调栈解决的,是把每个元素“左边第一个比我大/小”“右边第一个比我大/小”这类问题的冗余扫描去掉。
- 单调队列解决的,是滑动窗口内重复扫描窗口里每个元素求最大/最小值的低效问题。
- KMP 解决的,是主串与模式串失配时,又把模式串头重新拉回来,导致主串指针反复回退的重复比较。
当你把“消除冗余比较”这句话记在心里,再看这三种数据结构的名字就顺眼多了。它们不是三个孤岛,而是同一个思维在不同场景下的三副面孔。
从面试角度说,这三个点也正好覆盖了三种典型题型:
| 题型场景 | 经典代表 | 核心数据结构 |
|---|---|---|
| “下一个更大/更小元素”类 | 每日温度、下一个更大元素、柱状图最大矩形 | 单调栈 |
| 固定窗口内的最大/最小值 | 滑动窗口最大值、定长子数组问题 | 单调队列 |
| 字符串精确匹配 | 实现 strStr、重复子字符串判断 | KMP |
所以我建议的学习顺序也很固定:先单调栈,再单调队列,最后 KMP。前两个是亲兄弟,学会了单调栈,单调队列只是多了一个“过期淘汰”的逻辑;KMP 则是把“单调性”从元素大小换成了“前缀相等性”,思想依然相通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈:一次 pop,把前因后果全问清楚
2.1 长在“下一个更大元素”上的直觉
先看一道最入门的题:给定数组 [2, 1, 5, 6, 2, 3],求每个元素右边第一个比它大的元素。
暴力法很简单:对每个元素,往右扫描到第一个更大的值并记录。这么做的时间复杂度是 O(n²),当数组长度到 10^5 量级时基本就超时了。
单调栈的思路巧妙在:它不急着回答“某个元素右边第一个更大是谁”,而是边走边维护一个候选区。
具体规则是这样的:
- 我们维护一个栈,栈内元素保持从栈底到栈顶单调递减(或非递增)。
- 遍历数组每个元素
nums[i],当栈不为空且nums[i]大于栈顶元素时,说明对于栈顶那个元素来说,nums[i]就是它右边第一个更大的数。此时弹出栈顶并记录答案。 - 把当前元素下标入栈。
我刚开始学的时候怎么都绕不过一个弯:为什么“当前元素大于栈顶”时,栈顶的答案就一定是当前这个元素?万一中间隔了别的没用到的元素呢?
其实这正是单调栈的精髓:栈里存的是一群“还没找到右边更大元素”的候选人,而且从栈底到栈顶,它们的位置从左到右排列,值从大到小排列。 当前新元素 nums[i] 出现后,它比栈顶大,说明栈顶这个元素的“等待期”结束了——右侧第一个比它大的,就是当前元素。至于中间有很多不在栈里的元素,它们要么已经找到了答案,要么大小上不可能成为栈顶的“第一个更大值”,所以可以直接忽略。
2.2 单调栈代码模板
以“下一个更大元素的下标差”为例:
cpp复制vector<int> nextGreater(vector<int>& nums) {
int n = nums.size();
vector<int> res(n, -1); // 默认 -1,表示右边没有更大的
stack<int> st; // 存下标,方便算距离
for (int i = 0; i < n; i++) {
while (!st.empty() && nums[i] > nums[st.top()]) {
res[st.top()] = i; // 记录答案,这里可以存下标也可以存值
st.pop();
}
st.push(i);
}
return res;
}
注意这里栈里存的是下标,不是元素值。原因很简单:很多题目不仅要你回答“谁更大”,还要计算“距离相差多少”,存下标可以同时拿到值和位置。
如果要找“左边第一个比当前元素大的”,可以换个遍历方向:从右往左扫,或者从左往右扫但改变比较逻辑。本质上都是同一个套路。
2.3 为什么“等于”的判断那么关键
这是单调栈最容易翻车的地方,也是真正考查理解深度的地方。
比如题目要“右边第一个大于等于当前元素的值”,那 while 条件应该是 nums[i] > st.top(),即只有当严格更大时才弹出。如果条件是 >=,遇到相同元素也会弹栈,会导致重复元素的边界答案被错误更新。
反过来,如果要找“右边第一个大于当前元素的值”,重复元素不应该相互覆盖答案时,就必须用 >,且相等元素直接入栈。
我自己以前刷题时为了快,经常随手写一个 >= 或 >,结果样例能过,提交就 WA,后来才明白原因:“大于”和“大于等于”虽然只差一个等号,但应对的是完全不同的定义。 做题前先问自己一句:两元素相等时,谁才是“右边第一个符合条件的”?这决定了你 while 里是 > 还是 >=。
