上周有读者问我一个问题:“单调栈、单调队列(滑动窗口)和 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 里是 > 还是 >=。
2.4 实战推演:柱状图最大矩形
LeetCode 84“柱状图中最大的矩形”,是单调栈最经典的进阶题,我想用它把机制彻底讲清楚。
题目给一个高度数组 heights = [2, 1, 5, 6, 2, 3],每一根柱子宽度是 1,问这些柱子能勾勒出的最大矩形面积是多少。
暴力做法是以每根柱子为矩形的高,向左右扩展,找到第一个比它矮的柱子作为边界,然后计算宽度。对 n 根柱子,最坏 O(n²)。
用单调栈怎么做?
- 维护一个从栈底到栈顶单调递增的栈(存下标)。
- 当遇到
heights[i] < heights[st.top()]时,说明栈顶柱子的右边界已经出现(就是当前这根矮柱子),它的左边界则在栈内它下面的那根柱子处。 - 此时弹出的栈顶柱子能形成的最大矩形面积 = 它的高度 ×(右边界下标 - 左边界下标 - 1)。
这里最妙的地方在于:栈内相邻的两根柱子之间,那些被弹出的柱子一定都比这两根高,所以它们不会影响当前柱子向左右扩展的宽度计算。
我手动推一遍前几根:
- i=0,高度 2,栈空,入栈,栈为 [0]。
- i=1,高度 1,小于栈顶高度 2,弹出下标 0。此时栈空,说明柱子 0 的左边界是 -1(虚拟位置),右边界是 1,宽度 = 1 - (-1) - 1 = 1,面积 = 2×1=2。
- 下标 1 入栈,栈为 [1]。
- i=2,高度 5,大于 1,入栈,栈为 [1,2]。
- i=3,高度 6,大于 5,入栈,栈为 [1,2,3]。
- i=4,高度 2,小于栈顶 6,弹出 3,此时栈顶是 2,左边界 = 2,右边界 = 4,宽度 = 4-2-1=1,面积 = 6×1=6;继续,弹出 2,栈顶是 1,左边界 = 1,宽度 = 4-1-1=2,面积 = 5×2=10。
最后还要在数组末尾补一个高度 0 的虚拟柱,把栈里剩余元素全部弹出结算。
这道题几乎把单调栈的原理用到了极致:每个元素最多入栈一次、出栈一次,总体 O(n);而它在处理时,相当于瞬间知道了“左右两边第一个比自己小的位置”。
2.5 循环数组与“下一个更大元素 II”
很多朋友做到 LeetCode 503“下一个更大元素 II”时,会觉得循环数组很难处理。其实处理方法非常土但非常好用:拉直成两倍长数组,或者遍历时对下标取模模拟两次遍历。
比如要查循环数组中每个元素的下一个更大元素,你只需要从 i = 2n-1 往 i = 0 反向扫一遍,但入栈时依然用 i % n 对应的值去比较。这样做的原因是:循环数组里“右边”的元素可能绕回左边来,只有扫两遍长度才能保证每个元素有机会“看到”绕回来的部分。
单调栈在这一类问题里的角色几乎从未变过:它始终帮你维护着“还没等到答案”的候选项。
3. 单调队列:滑动窗口里维护一个“活的最值”
3.1 从暴力到单调队列:到底优化掉了什么
如果说单调栈是“一维数组中找左右边界”,那么单调队列最常见的场景就是:给定数组和一个固定大小的滑动窗口,窗口每次往右移动一格,求窗口内的最大值或最小值。
以 LeetCode 239“滑动窗口最大值”为例,数组 [1,3,-1,-3,5,3,6,7],窗口大小 k=3。
暴力做法是每次窗口移动后,重新遍历窗口内 k 个元素找最大值,总复杂度 O(nk)。当 n 和 k 都很大时,这就不太现实了。
那能不能用堆?可以,窗口内的元素都能查到最大值,但堆的问题是删除“过期元素”很麻烦,你没法快速判断堆顶是不是已经滑出窗口。所以需要一种结构:既能快速获取最值,又能方便地删除队首过期元素、插入新元素、并按需淘汰不可能成为答案的旧元素。
这就是单调队列登场的时候了。
3.2 队列里为什么存下标,而不是直接存值
单调队列的核心规则是:
- 队列从队首到队尾,对应的元素值严格递减(求最大值时)。
- 队首始终是当前窗口的最大值。
- 每次窗口滑动时,先检查队首元素是否已经滑出窗口(下标 < 左边界),如果是就从队首弹出。
- 新元素入队前,从队尾开始把“所有比新元素小或等于”的旧元素全部弹出,再让新元素入队。
这里有一个非常关键的设计决策:队列里存的是下标,不是值。 为什么?
因为你需要判断元素是否过期。窗口滑动时,左边界是 i - k + 1,如果队首存的是下标,直接判断 q.front() < i - k + 1 即可弹出;如果存的是值,就无法知道它对应的位置是否已经越过左边界。
我见过不少新手写这道题时直接用 deque 存值,结果发现“过期判断”没法做,只能额外开一个数组记录值对应的下标,绕了一圈又回到原点。
单调队列的代码模板如下(C++ 风格,方便看出每一步):
cpp复制vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> q; // 存下标
vector<int> ans;
for (int i = 0; i < nums.size(); i++) {
// 1. 把滑出窗口的队首去掉
while (!q.empty() && q.front() <= i - k) {
q.pop_front();
}
// 2. 队尾维护单调递减,新元素从队尾入队
while (!q.empty() && nums[q.back()] <= nums[i]) {
q.pop_back();
}
q.push_back(i);
// 3. 窗口成形后,队首就是当前窗口最大值
if (i >= k - 1) {
ans.push_back(nums[q.front()]);
}
}
return ans;
}
很多人会把第 1 步放在第 2 步后面写,甚至只写第 2 步不写第 1 步,结果样例能过,但遇到窗口快速移动时就会出现“队首元素已经滑出窗口却仍被当作最大值”的错误。
这里我要特别强调一个顺序问题:先处理队首过期,再维护队尾单调性。 因为如果先做第 2 步,可能会把队首那个本该过期的最大值,在和新区间元素比较时误删;而先做第 1 步可以保证留在队列里的元素都还活着,队尾维护时不会受“死人”干扰。
3.3 手推一遍:为什么队尾要弹出“比新元素小的旧元素”
用手上那个官方例子来跑一遍窗口大小为 3 的情况。
- i=0,数组值 1。队列空,直接入队下标 0。队列:[0]。
- i=1,值 3。队首 0 没过期。从队尾看,nums[0]=1 <= 3,弹出下标 0,入队下标 1。队列:[1]。
- i=2,值 -1。队首 1 没过期。nums[1]=3 > -1,不弹出,直接入队 2。队列:[1,2]。此时窗口 [0..2] 形成,最大值是 nums[1]=3,答案是 3。
- i=3,值 -3。先看队首 1 是否过期?左边界 = 1,下标 1 还活着。尾比较 nums[2]=-1 > -3,所以入队 3。队列:[1,2,3]。窗口 [1..3] 最大值仍为 3。
- i=4,值 5。队首 1 已过期(下标 < 2),弹出。然后从队尾开始,nums[3]=-3 <= 5 弹出,nums[2]=-1 <= 5 弹出,队列空了,入队 4。队列:[4]。窗口 [2..4] 最大值是 5。
- i=5,值 3。队首 4 没过期,nums[4]=5 > 3,入队 5。队列:[4,5]。窗口 [3..5] 最大值是 5。
- i=6,值 6。队首 4 已过期(左边界是 4?不对,左边界是 4,下标 4 还活着),先不弹出。队尾比较 nums[5]=3 <= 6 弹出,nums[4]=5 <= 6 弹出,入队 6。队列:[6]。窗口 [4..6] 最大值是 6。
- i=7,值 7。队首 6 活着。nums[6]=6 <= 7 弹出,入队 7。队列:[7]。窗口 [5..7] 最大值是 7。
最终答案是 [3,3,5,5,6,7],和 LeetCode 官方输出一致。
在这个过程中,队列保存的其实是一个“候选最大值序列”——既是单调递减的,又都在当前窗口内。每个元素最多入队一次、出队一次,整体 O(n),这就是单调队列的核心效率来源。
3.4 单调栈与单调队列的异同对比
这两个兄弟结构容易搞混。我整理了一张对照表,建议收藏:
| 维度 | 单调栈 | 单调队列 |
|---|---|---|
| 底层结构 | 栈(只能在栈顶操作) | 双端队列(队首队尾都可操作) |
| 单调方向 | 栈底到栈顶单调 | 队首到队尾单调 |
| 典型场景 | 找左右两边第一个更大/更小元素 | 固定窗口内持续取最值 |
| 过期元素处理 | 不需要,元素一经弹出就结束 | 必须从队首弹出过期元素 |
| 存下标原因 | 方便计算距离/左右边界 | 判断窗口是否过期 + 取最值 |
| 复杂度 | O(n) | O(n) |
一句话总结:单调栈适合“静止地看每个元素的左右边界”,单调队列适合“动态地随着窗口移动持续输出最值”。如果你觉得单调队列难,不妨先把它想成“支持队首过期的单调栈”,心理负担会小很多。
3.5 滑动窗口的更多变形
窗口最大值只是最基础的形态。实际应用中,还会遇到:
- 窗口最小值:把队尾维护改成递增即可。
- 求窗口内最大值与最小值之差小于某个阈值:同时维护最大和最小两个单调队列。
- 定长子数组的平均值、中位数等问题:一部分需要换数据结构(如对顶堆),但滑动窗口的整体框架不变。
- 优化 DP:很多“每 k 步取最优”的动态规划题,可以用单调队列把内层循环从 O(n) 降到 O(1)。
我在训练时还发现一个规律:只要题目里有“连续子数组”“固定长度窗口”“最值”这三个关键词,单调队列大概率是第一直觉。 这个规律很粗,但实战中命中率很高。
4. KMP:用 next 数组跳过注定失败的比较
4.1 朴素匹配输在哪里
字符串匹配问题的描述很简单:给定主串 haystack 和模式串 needle,返回 needle 在 haystack 中第一次出现的下标,不存在则返回 -1。
朴素匹配的做法是:从主串的每个位置开始,依次和模式串比较,如果中间某个字符不匹配,主串指针回退到起始位置的下一个字符,模式串指针也重置为 0。最坏情况下,比如主串是 "aaaaaaaaab",模式串是 "aaab",每次都要比较到模式串最后一个字符才发现不匹配,然后主串指针才挪一位,复杂度 O(nm)。
KMP 的核心突破是:当发生失配时,主串指针不回头,而是把模式串向右滑动尽量多的距离,这个距离由模式串自身的结构预先计算好。
为什么能做到这点?因为失配时,我们已经知道主串从当前位置往前的一段字符和模式串的前缀是匹配的。这段“已经匹配的前缀”里,它的后缀也许能跟模式串的某个前缀重合,那我们就可以直接把模式串的指针跳到那个重合前缀的末尾继续比较,而不是从头开始。
4.2 next 数组:最长相等前后缀的长度
KMP 最难啃的骨头就是 next 数组的计算。
先来一个基础概念:对一个字符串来说,它的“前缀”指从开头开始的子串,但不包含整个字符串本身;“后缀”指从末尾往前的子串,同样不包含整个字符串本身。
next 数组的定义通常有两种写法,这也是很多初学者被搞晕的原因。
按主流竞赛写法,next[i] 表示:模式串 P[0...i] 这个子串中,最长的相等前缀和后缀的长度(但不包括整个子串本身)。比如 P = "ababc":
next[0]:子串"a",没有真前后缀,为 0。next[1]:子串"ab",前缀有"a",后缀有"b",不相等,为 0。next[2]:子串"aba",前缀"a"等于后缀"a",长度 1;前缀"ab"不等于后缀"ba",所以为 1。next[3]:子串"abab",前缀"ab"等于后缀"ab",长度 2。next[4]:子串"ababc",最长的相等前后缀为 0("a"与"c"不相等,"ab"与"bc"不相等)。
这个数组的物理意义是什么?就是:当我们匹配到 P[i] 时发现失配,模式串的指针应该跳到哪里继续匹配。更准确地说,失配时看的是 next[i-1] 或 next[i] 的具体定义。
4.3 手动推一遍 next 数组:用“递归的相似性”来算
如果只是背诵代码,很容易忘记。我建议你手动推几遍 next 数组,建立手感。
以 P = "ababc" 为例,完整的 next 数组应该是 [0, 0, 1, 2, 0]。
很多人想不通的一个点:为什么算 next[3] 的时候,可以先看 next[2]?
比如求 next[3] 时,已知 P[0...2] = "aba" 的 next[2] = 1,说明 P[0] 等于 P[2]。现在我们想扩展 P[3],那就要看新加进来的字符 'b' 能不能匹配到“当前最长相等前后缀的下一个位置”。
当前最长相等前后缀是长度为 1 的前缀 "a"。它的下一个位置是 P[1] = 'b'。好消息是 P[3] = 'b' 正好等于 P[1],所以最长相等前后缀的长度从 1 变成了 2,也就是 next[3] = next[2] + 1 = 2。
如果新字符不匹配怎么办?假设字符串是 "ababx",算到 x 这一位时,当前最长前缀是 "ab",下一个字符是 P[2] = 'a',但 x != 'a',所以不能直接 +1。这时就要沿着 next 链回退:看 next[2](等于 1),说明可以考虑更短的前缀 "a",然后比较 P[1] 是否等于 'x',如果还不等,继续回退。这个过程有人叫“看 next 的 next”,本质上就是在尝试更短的相等前后缀。
这个“回退尝试”的过程,是 KMP 初学者最容易卡住的地方。你可以把它理解成:已经匹配过的前缀里,如果长前缀后面接不上,就去试短前缀后面能不能接上。 跟你在草稿纸上手算的思路一模一样,只不过代码把它迭代化而已。
4.4 KMP 匹配主循环:主串指针不回退,模式串“跳着走”
有了 next 数组,匹配过程就非常简单了。伪代码如下:
cpp复制int strStr(string haystack, string needle) {
int n = haystack.size(), m = needle.size();
if (m == 0) return 0;
vector<int> next = buildNext(needle);
int j = 0; // 模式串指针
for (int i = 0; i < n; i++) {
while (j > 0 && haystack[i] != needle[j]) {
j = next[j - 1]; // 失配,模式串往右跳
}
if (haystack[i] == needle[j]) {
j++;
}
if (j == m) {
return i - m + 1; // 完全匹配
}
}
return -1;
}
这段代码里最精华的就是 j = next[j - 1]。它表达的是:主串的第 i 个字符和模式串的第 j 个字符失配时,前面已经有 j 个字符匹配成功了。这 j 个字符组成的模式串前缀里,它的最长相等前后缀长度为 next[j-1],于是我们可以让模式串的指针跳回到 next[j-1] 的位置,继续用当前主串字符去匹配。
这个过程重复下去,如果一直失配,模式串的指针最终会回到 0,说明当前主串位置已经不可能再有匹配,主串指针 i 继续前进。
这里值得停下来感受一下:主串指针 i 从头到尾只加了 n 次,即使 while 循环内部看起来有回退,也只会让模式串指针往左跳,主串指针从不回头。这就是 KMP 能达到 O(n+m) 的根本原因。
4.5 “KMP 到底算不算动态规划?”
最近看到不少人在问这个问题,我把我的理解说清楚。
从某个角度看,next 数组的构建过程确实有动态规划的味道。next[i] 的计算依赖于 next[i-1](以及更早的 next 值),并且我们通过“状态回退”重复利用之前已经算好的结果,避免了重新从头匹配。这种“用已知子问题的答案推导新问题答案”的思路,和动态规划是相通的。
但严格来说,KMP 通常不被归类为“动态规划题”,因为它的目标不是求“最优解”,而是构建一个确定性的状态转移表。你甚至可以把它理解成在构建一个有限状态自动机:每个模式串前缀长度是一个状态,遇到某个字符后状态如何转移,是预先定义好的。
如果面试里有人问“KMP 是动态规划吗”,你可以这样回答:它的 next 构建有 DP 的自底向上思想,但是问题模型更贴近自动机,刷题时通常归到字符串匹配这一类。这样既承认了内在联系,也不至于把概念搞混。
4.6 手动计算 next 数组时常见的三种歧义
最后必须提醒:不同教材、不同代码里,next 数组的下标定义并不完全一致。有的从 0 开始,有的从 1 开始;有的把 next 数组整体右移一位;有的把“最长相等前后缀长度”定义为不包括自身,也有的在求匹配跳转时用的是 next[i] 而不是 next[i-1]。你只需要认准一种写法,做熟它即可。
我建议你手算 next 时固定用“前缀函数”的官方定义:pi[i] 表示子串 P[0...i] 的最长相等前后缀长度。代码里真正常用的是 next[j-1],也就是匹配到第 j 位失配时,参考前 j 个字符的最长相等前后缀。这个命名方式虽然绕,但是最不容易出错。
5. 三兄弟背后的暗线:从“单调性”到“状态跳跃”
如果说前面四章是拆开讲细节,这一章我想把镜头拉远,讲一讲三者的暗线联系。理解这条暗线之后,你再做题时的感觉会完全不一样。
5.1 暗线一:都靠“用一个数据结构记住历史信息”
暴力算法为什么慢?因为它每次都把历史清空重来。单调栈记住的是“还没找到答案的候选者”,单调队列记住的是“窗口内仍然可能成为最值的候选者”,KMP 记住的是“模式串前缀的内部匹配关系”。三者都有一个共同逻辑:把历史信息压缩成一个有序/有序等价的结构,供未来决策直接使用。
所以当你遇到一个新题,先别急着套模板,而是问自己一句:我能不能设计一个结构,把已经扫描过的部分压缩成一条单调序列或一张跳转表?如果能,这道题大概率就能从暴力优化到线性。
5.2 暗线二:都是“吃后悔药不用从头吃”
单调栈里弹出元素,相当于承认“这个元素的左右边界我都确定了”,不需要再回头看它。单调队列从队尾弹出旧元素,相当于承认“只要新元素在位,旧元素永远不可能成为窗口内最大值的候选”。KMP 里的 next 回退,则是承认“这一段前缀和后缀重合的部分已经验证过了,不需要重新比较”。
这三种说法听起来不一样,但本质上的动作都是:删除或者跳跃掉已经确定无用的比较。
做题训练时,如果你能把每一个“为什么可以删除/跳过”的原因都用自己的话说清楚,基本就掌握了这一类算法的灵魂。反之,如果你只是背代码,看到新题还是无从下手。
5.3 暗线三:都是“状态转移”,只是转移依据不同
- 单调栈的状态转移依据是元素值大小比较。
- 单调队列的状态转移依据是窗口边界与元素值大小。
- KMP 的状态转移依据是失配位置和已匹配前缀的相等性。
往深了说,这三者都可以纳入“自动机/状态机”的思考框架:你有一个当前状态,来一个新输入,你根据规则决定状态怎么变。对于单调栈,新输入是数组元素,状态是栈内候选集合;对于单调队列,新输入是窗口中滑入的新元素,状态是队内的单调序列;对于 KMP,新输入是主串当前字符,状态是模式串当前匹配到的位置。
这个视角还有一个实际好处:当你在 LeetCode 上看到“设计一个支持滑动窗口最大值的数据结构”这类题时,你不会只想着套模板,而是会自然想到“我要维护一个状态,它的更新规则要能支持 add 和 remove 操作”,于是单调队列就成了理所当然的方案。
5.4 从单调栈到单调队列的“一步之遥”
如果你已经理解了单调栈,再看单调队列其实只需要补一个概念:窗口的过期淘汰。
单调栈处理的是整个数组,所有元素只进不出(弹出了就不管了)。单调队列处理的是滑动窗口,元素不仅要进,而且随着窗口左移,队首的旧元素要被迫退出。所以单调队列 = 单调栈 + 过期弹出逻辑 + 双端操作。
很多人不知道这个递进关系,于是学完单调栈再学单调队列时,会从头再痛苦一遍。如果你也正处于这个阶段,我建议你把单调栈的代码和单调队列的代码并排放在一起,一行行对比。你会惊讶地发现,核心 while 循环几乎一模一样,单调队列只是多了一个 pop_front() 的步骤。
5.5 关于 KMP 与滑动窗口的“表面亲戚”
还有一个很多人会问的点:字符串匹配能不能直接套滑动窗口?
其实你可以在主串上维护一个长度等于模式串大小的窗口,每次比较窗口内字符串和模式串是否相等。这个做法对某些随机字符串也许还行,但最坏情况下每次都要比较 m 个字符,复杂度退化到 O(nm)。KMP 本质上是一种“带记忆的滑动窗口”,当窗口内已经匹配了一部分,失配时它能根据 next 数组知道“窗口右滑多少格最合适”,而不是老老实实只滑一格。
所以说,KMP 是滑动窗口思想在字符串匹配领域的进阶版本。理解这一层,你可以把前面学的滑动窗口概念无缝迁移到字符串题里,很多所谓的“新题”其实都是旧思路的包装。
6. 一些踩坑记录和训练建议
6.1 别再犯的错:单调栈里“存下标”还是“存值”
刚开始学单调栈时,我看到有些题解直接存值,有些存下标,以为无所谓,结果做完几道题后发现,几乎每道“求距离”的题都要求存下标。到后来我养成一个习惯:默认存下标,除非你能证明这题只关心值不关心位置。 单调队列也一样,默认存下标,因为过期判断必须依赖下标。
比如“每日温度”这道题,要返回的是“需要等多少天”,如果栈里存值,你根本不知道这个值对应第几天,也就没法算天数差。
6.2 不要再犯的错:window 最大值里的 <= 到底该不该带等号
在维护单调递减队列时,弹出条件写 nums[q.back()] <= nums[i] 还是 < nums[i]?
对于“求最大值”来说,如果新元素等于队尾旧元素,旧元素在未来不可能比新元素更优(因为新元素位置更靠右,活得更久),可以放心弹出。所以用 <=。
但如果是求“最大值对应的下标尽量靠后”这类变体需求,等号的处理可能会影响答案。所以写之前先明确一个原则:当两个值相等时,留哪个在队列里对未来更有利? 每次遇到边界条件,先问自己这个问题,就可以避免很多无谓的 WA。
6.3 KMP 的 next 数组最容易写错的地方
next 数组的构建代码非常短,但错误率极高,尤其是这两处:
- 忘记了变量
j代表的是“当前已匹配的前缀长度”,因此在P[i] == P[j]时应该是j++,然后把j赋给pi[i],而不是直接pi[i] = j+1。 - 回退的循环写成
while (j > 0 && P[i] != P[j]) j = pi[j-1];,这里的pi[j-1]很多人会误写成pi[j],一写错整个数组就乱了。
我建议你理解时把自己当成一个“正在匹配主串和模式串”的人,把 P[i] 当作主串字符,把 P[j] 当作模式串当前比较位置,这样构建 next 的代码和 KMP 主循环代码就会长得一模一样,记忆负担大大减小。
这里给一个验证用的小窍门:构建完 next 后,手动用它去匹配 haystack = "abababc", needle = "ababc",在纸上把 j 的每一步变化都写下来,如果某个时刻 j 的变化规律跟你想的不一样,多半就是 next 写错了。
6.4 刷题顺序和练习量建议
如果你正准备系统性攻克这部分内容,我建议按这个顺序来,每个阶段配合一定量的题目巩固。
- 第一阶段:弄懂单调栈的“下一个更大元素”,刷 LeetCode 496、503、739。这三题分别是基础版、循环数组版、距离版。
- 第二阶段:用“柱状图最大矩形”检验是否真正理解单调栈的左右边界原理,刷 LeetCode 84、85。
- 第三阶段:进入单调队列,主攻 LeetCode 239,再找一两道变形题,比如“绝对差不超过限制的最长连续子数组”(用两个单调队列)。
- 第四阶段:KMP 先不做难题,把 next 数组手算到熟,再刷 LeetCode 28。如果有余力,可以做 LeetCode 686“重复叠加字符串匹配”来感受模式串循环场景的用法。
我自己的体感是:这几个知识点不是看一遍就会的,至少要经历“看懂→复现→错几回→再看→彻底懂”这个过程。特别是 KMP 的 next 数组,我见过太多人“看懂了”但一写就错,原因就是手动推演的次数不够。
6.5 面试中如果能主动说出“为什么”,很加分
面试官出这类题,通常不是想听你背模板,而是想确认你有没有真正理解背后的动机。比如问滑动窗口最大值时,当你说完单调队列的解法后,如果能补一句“因为队尾维护保证了每个元素最多入队出队一次,所以总复杂度是 O(n)”,面试官会知道你不仅会写代码,还理解效率来源。
再比如讲到 KMP 时,如果你可以主动点出“主串指针 i 永远不会左移,这是它能做到 O(n+m) 的关键”,通常会给面试官留下很好的印象。因为这些“为什么”才是算法真正的灵魂,代码只是骨架。
这几个月我回过头复盘自己学习这三种算法的经历,最大的感受是:真正难的从来不是记住某种数据结构的操作,而是理解“为什么可以这样跳”“为什么可以把这个元素丢掉”“为什么历史信息可以压缩成这样一条链”。一旦你把这些为什么想明白,单调栈、单调队列和 KMP 就不再是三个需要死记硬背的模板,而会变成你分析新问题时顺手的思维工具。希望这篇长文能帮你把这三块骨头啃得更顺一些。
