刷算法题或者搞竞赛的朋友,一定绕不开三个高频模板:单调栈、单调队列(滑动窗口)、KMP。这三个东西属于那种“看起来技巧性很强,一旦理解了就非常固定”的内容,网上讲原理的不少,但很多教程只给结论不给推导,或者只给代码不解释边界条件,导致很多人背了模板换一道题就不会用了。
这篇东西我就用实际做题和带新人的经验,把这三个算法的来龙去脉、代码模板、常见坑一次性说清楚。不是从零讲数据结构基础,而是面向“已经会基础栈、队列、字符串匹配,但遇到这三类题还是会卡壳”的读者,目标是你学完以后能自己推导、能改模板、能应付面试手撕。文中的所有代码都是可运行的,注释也按“能直接抄作业”的标准写。
1. 三种算法的定位与适用场景拆解
1.1 它们到底在解决什么问题
先把这三个东西的“本质”说透,不然你永远觉得它们是孤立的技巧。
单调栈解决的核心问题是:在一维数组中,快速找到每个元素左边或右边第一个比它大(或小)的元素位置。典型场景有柱状图最大矩形、接雨水、每日温度、下一个更大元素。它把暴力解法的O(n²)优化到O(n),代价是空间O(n)的栈。
单调队列解决的核心问题是:在长度为n的数组上,求所有长度为k的滑动窗口内的最大值或最小值。经典题目是LeetCode 239滑动窗口最大值。它同样把暴力O(nk)优化到O(n),借助的是一个内部元素保持单调性的双端队列。
KMP解决的核心问题是:在文本串中查找模式串的所有出现位置。相比暴力匹配每次失配只移动一位,KMP利用已经匹配的前缀信息,让模式串失配后跳到合适的位置继续比对,整体时间复杂度O(n+m),其中n是文本串长度,m是模式串长度。核心难点是next数组的构建与含义理解。
你可以把这三个算法归成一类:它们都是利用“历史信息”来减少重复计算。单调栈用栈保留“还没找到答案的元素”,单调队列用双端队列保留“窗口内可能成为答案的元素”,KMP用next数组保留“已经匹配过的前缀信息”。理解了这一层,你会发现它们的设计哲学是相通的。
1.2 选型思路:什么时候用哪个
我见过太多人一看到“左边第一个比它大”就用单调栈,一看到“连续子数组最值”就用单调队列,然后套模板发现不对。这里给你一个我自己总结的判断逻辑,按顺序问三个问题:
第一,题目是否要求“某个元素附近的最值”?如果是,考虑单调栈。注意“附近”的典型描述是“左边/右边第一个比它大/小”。这个问题本质是找边界,单调栈天然擅长。
第二,题目是否涉及“固定长度窗口”的最值统计?如果是,考虑单调队列。滑动窗口的最值、均值、滤波、滑窗统计基本都走这条路。
第三,题目是否涉及“字符串匹配”或“字符串前缀重复结构”?如果是,考虑KMP。KMP还有一个容易被忽略的用法:求一个字符串的最小循环节,这在竞赛里很常见。
现在把这三个算法掰开揉碎,每个都讲原理、模板、变体、坑,顺序按难度从易到难。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈:一个模板吃透“下一个更大元素”
2.1 核心思想与“为什么能O(n)”
单调栈的思路一句话就能说完:维护一个栈,让栈内元素从栈底到栈顶保持单调(递增或递减),遍历数组时,利用“入栈、出栈”的过程记录答案。
以“找每个元素右边第一个比它大的元素”为例,我们维护一个栈底到栈顶递减的栈(也就是栈顶最小),从左到右遍历数组。每来一个新元素nums[i],就循环弹出所有比nums[i]小的栈顶元素——因为它们右边第一个更大的元素就是nums[i],弹出的时候记录答案。然后nums[i]入栈,继续下一轮。
关键点在于:每个元素最多被压入栈一次、弹出栈一次,所以总操作次数是O(n)。这个证明很重要,面试常问,你要能说清楚“为什么每个元素只进出一次,因此均摊复杂度是O(1)”。
很多人一开始不理解为什么是“弹栈时记录答案”而不是“入栈时记录”,这涉及到答案归属的问题。在“找右边第一个更大值”的语境下,答案属于被弹出的那个旧元素,而不是新来的元素。新元素入栈只是“等待”它右边出现更大的值。反过来,如果你要“找左边第一个比它大的元素”,就改成从右往左遍历。记住这个对称性,模板就不容易写错。
2.2 万能模板代码与细节说明
以经典题“每日温度”(LeetCode 739,找每个元素右边第一个更大的元素距离)为例,给一份可以直接跑的C++模板:
cpp复制vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int> ans(n, 0); // 默认0,表示右边没有更大
stack<int> st; // 栈里存索引,不存值,因为可以通过索引取值
for (int i = 0; i < n; i++) {
// 当前温度高于栈顶索引对应的温度时,栈顶的“右边第一个更高温度”就是i
while (!st.empty() && temperatures[i] > temperatures[st.top()]) {
int idx = st.top();
st.pop();
ans[idx] = i - idx; // 距离
}
st.push(i); // 当前索引入栈,等待答案
}
return ans;
}
注意几个细节:
栈里存索引而不是存值,这是几乎所有的工程实践中推荐的写法。因为存值虽然比较方便,但记录答案时你还需要知道位置下标,到时候再查一遍数组或者用哈希表映射都是多余操作。存索引的唯一问题是比较时多写一次下标取值的操作,但这几乎不影响效率,换来的是逻辑统一。
等号的处理是第一个大坑。题目说“比它大”,那严格大于才算。如果写成>=,那么相等元素也会被弹出,答案就是“右边第一个大于等于”,这会导致相同温度的天数记录错误。反过来,如果题目要求“右边第一个大于等于”,你的循环条件就要反着写。做题前先看清楚是严格大于还是非严格大于。
这个模板能变形出很多题,比如“下一个更大元素 I”(LeetCode 496)、“下一个更大元素 II”(LeetCode 503,环形数组,处理方式是把数组遍历两遍)、“柱状图中最大的矩形”(LeetCode 84,两边都要找边界)、“接雨水”(LeetCode 42,找左右最大值的较小者)。
2.3 变体:单调栈找边界
说一下柱状图最大矩形,因为这道题最能体现单调栈“找边界”的本质。给定一个柱状图,每个柱子的宽度为1,求能勾勒出的最大矩形面积。
暴力做法是枚举每个柱子作为矩形的高度,然后往左右扩展直到遇到比它矮的柱子。这个“往左右扩展”就是找左边第一个比它矮的位置和右边第一个比它矮的位置,两个边界之间的距离乘以当前高度就是面积。
用单调栈一次遍历可以同时得到左右边界:维护一个栈底到栈顶递增的栈,当新柱子高度小于栈顶时,说明栈顶柱子的右边界已经找到(就是当前柱子),而栈顶柱子弹出后,新的栈顶柱子就是它的左边界(因为它是左边第一个比它矮的)。这样每个柱子对应的最大矩形面积都能算出来。
cpp复制int largestRectangleArea(vector<int>& heights) {
// 首尾各加一个高度0的哨兵,避免处理边界特殊情况
heights.insert(heights.begin(), 0);
heights.push_back(0);
int n = heights.size();
stack<int> st;
int ans = 0;
for (int i = 0; i < n; i++) {
while (!st.empty() && heights[i] < heights[st.top()]) {
int h = heights[st.top()];
st.pop();
int left = st.top(); // 弹出后新的栈顶就是左边界
int right = i; // 当前索引就是右边界
ans = max(ans, h * (right - left - 1));
}
st.push(i);
}
return ans;
}
这里加哨兵技巧非常关键,它让代码更简洁,不用在while里特判栈空的情况。我自己一开始写这道题老是漏边界条件,加了哨兵之后一次性通过。这个技巧在很多单调栈题目里都能用。
3. 单调队列:滑动窗口最值的正确打开方式
3.1 为什么双端队列是“天选之子”
先明确一个基本概念:单调队列通常用双端队列(deque)实现。为什么必须用双端?因为滑动窗口滑动时,我们要从头部移除滑出窗口的元素,同时从尾部加入新元素;为了维护单调性,还要从尾部弹出不可能再成为答案的元素。头部需要出队,尾部需要进出,这恰好就是双端队列的能力边界。用数组加两个指针也可以模拟,但代码可读性不如deque。
“移除头部过期元素”和“从尾部弹出无用元素”这两件事必须分开理解,这是很多人写不对滑动窗口最大值的原因。
- 尾部弹出:窗口内新来一个元素,如果它比队列尾部元素大,那么尾部元素在未来的窗口里永远不可能是最大值(因为新元素更靠右、更大),于是弹出。这保证了队列从头到尾是递减的,所以队头永远是当前窗口的最大值。
- 头部移除:当窗口向右滑动,队头元素的下标如果已经小于窗口左边界,就把它弹出。
两条规则缺一不可。只做尾部弹出不做头部移除,队头会残留窗口外的元素;只做头部移除不做尾部弹出,队列单调性被破坏,队头不一定最大。
3.2 滑动窗口最大值模板(LeetCode 239)
来一份完整的C++实现,以“nums=[1,3,-1,-3,5,3,6,7],k=3”为例,跑一遍你就明白了。
cpp复制vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> dq; // 存储下标
vector<int> ans;
for (int i = 0; i < nums.size(); i++) {
// 1. 清理队尾:把小于等于当前元素的全部弹出
// 为什么是“小于等于”而不是“小于”?如果相等也弹出,
// 因为前面的相同值更靠左,会先滑出窗口,留下靠右的更好
while (!dq.empty() && nums[i] >= nums[dq.back()]) {
dq.pop_back();
}
dq.push_back(i);
// 2. 清理队头:队头下标超出窗口左边界就弹出
// i - k 是当前窗口左边界的前一个位置
if (dq.front() <= i - k) {
dq.pop_front();
}
// 3. 当窗口完全形成(i >= k - 1)时,记录答案
if (i >= k - 1) {
ans.push_back(nums[dq.front()]);
}
}
return ans;
}
这段代码在LeetCode 239上可以直接AC。注意我特意把头部清理放在尾部清理之后、记录答案之前,这个顺序是为了避免极端情况下队头刚被弹出又没来得及加入新元素导致的错乱。实际跑下来,先清尾部、再清头部、再记录答案这个顺序最稳。
有一种常见的错误写法是把头部清理放在最前面,这在大多数情况下也能过,但当窗口大小大于数组长度或k=1时容易出问题。我建议统一按“尾部清理 → 头部清理 → 取答案”的顺序来,少心力交瘁。
3.3 关于“小于等于”而不是“小于”的深究
上面注释里提到了一个很多人忽略的细节:为什么弹出条件用>=而不是>?假设窗口右端加入新元素5,队尾也有一个5,如果你用>,那么旧5不会弹出,队列里会出现两个5,且旧5更靠左。当窗口滑动,旧5会先于新5滑出窗口。虽然计算最大值时结果相同,但旧5的存在白白占用了队列空间,并且在某些变种题(比如同时求最大值和最小值)中会导致计数错误。所以一次性把等于的旧元素弹出,用靠右的新元素代替,是最干净的做法。
如果你刷题遇到“滑动窗口最小值”,把比较方向反过来即可:队清尾部时弹出所有大于等于当前元素的值,这样队头就是最小值。一句话总结:求最大值用递减队列(队头最大),求最小值用递增队列(队头最小)。
还有一个高频变体:滑动窗口的中位数。这个就不能用单调队列了,因为中位数需要随窗口滑动快速删除任意元素,更合适的做法是“双堆对顶堆”或者“有序集合+双指针”。如果面试遇到,不要硬套单调队列,要能说出区别。
3.4 滑动窗口思想的工程延伸
虽然本文主讲算法,但“滑动窗口”这个概念在工程信号处理里也极其常见,很多人搜“滑动窗口滤波模型”“滑动窗口滤波器延迟”“滑动窗口滤波verilog”时会联想到这。这里的“滑动窗口滤波”指的是对实时数据流取最近N个点的均值或中位数,抑制噪声,本质就是维护一个固定窗口的统计量。
工程上有个重要指标叫滤波延迟:窗口长度为k的滑动平均滤波,输出相对于真实信号的延迟约为(k-1)/2个采样周期。这个延迟与算法里的“窗口完全形成后才能输出”是同一个概念——窗口未满时,统计结果不可靠。理解了这个,你就把算法和工程串起来了。
如果你要在FPGA或嵌入式环境里实现滑动窗口最大值,基本思路也是双端队列或环形缓冲加比较器网络,但硬件和软件的资源约束不一样,不能直接照搬软件模板。这里不展开硬件实现细节,只提醒一句:软件里的“均摊O(1)”在硬件里可能变成“最坏O(k)”,实时系统要关注最坏情况而不是均摊情况。
4. KMP:next数组的计算与匹配全解析
4.1 KMP到底“快”在哪里
暴力字符串匹配慢的原因在于:每次失配后,文本串指针i回退到本次匹配起点+1的位置,模式串指针j也回到0,之前比较过的很多字符被重复比较。
KMP的核心优化是:失配时,i不回退,只回退模式串指针j。回退到哪里?回退到“当前已匹配的前缀中,最长的相等前后缀的长度”的位置。这个“最长相等前后缀长度”就是next数组。更准确地说,next[j]表示“模式串前j个字符组成的子串中,最长的相同前后缀长度”。
用生活化的类比来说,你在读一句话时发现某个词拼错了,你不会从头读起,而是会利用已经读过的部分,找到这个单词的前缀在哪里重复出现过,然后从那个地方继续。KMP就是把这个“人脑的直觉”形式化了。
4.2 next数组的手算方法与标准模板
next数组的构建是整个KMP的难点,很多人在这里卡住。我讲一个算例,你跟着手算一遍就通了。
设模式串为“ABABCABAB”,求它的next数组。这里的next[i]定义是“模式串前i个字符中,最长相同前后缀的长度”,按这个定义,next[0] = 0,next[1] = 0(“A”没有真前后缀),我们约定next[0]=0、next[1]=0,失配时j跳到next[j]处。
手算过程:
- 前缀“AB”:最长相同前后缀为0,所以next[2]=0。
- 前缀“ABA”:前缀有A、AB,后缀有A、BA,最长相同前后缀是“A”长度为1,所以next[3]=1。
- 前缀“ABAB”:前缀有A、AB、ABA,后缀有B、AB、BAB,最长相同前后缀是“AB”长度为2,所以next[4]=2。
- 前缀“ABABC”:最长相同前后缀为0(结尾是C,前面没有以C结尾的前缀),所以next[5]=0。
- 前缀“ABABCA”:最长相同前后缀为1(前缀“A”=后缀“A”),所以next[6]=1。
- 前缀“ABABCAB”:最长相同前后缀是2(“AB”),所以next[7]=2。
- 前缀“ABABCABA”:最长相同前后缀是3(“ABA”),所以next[8]=3。
- 模式串完整串“ABABCABAB”:最长相同前后缀是2(“AB”),所以next[9]=2(如果题目要算的话)。
手算可以用一个递推过程代替:next[i]的求解可以借助next[i-1]的结果逐步扩展,这也是代码实现的方式。下面给C++版KMP完整模板,支持从文本串中找到所有模式串出现位置:
cpp复制vector<int> buildNext(const string& p) {
int m = p.size();
vector<int> next(m, 0);
// j表示当前已匹配的相同前后缀长度,i从1开始遍历
for (int i = 1, j = 0; i < m; i++) {
while (j > 0 && p[i] != p[j]) {
j = next[j - 1]; // 失配时回退到之前的前缀长度
}
if (p[i] == p[j]) {
j++;
}
next[i] = j;
}
return next;
}
vector<int> kmpSearch(const string& s, const string& p) {
int n = s.size(), m = p.size();
if (m == 0) return {};
vector<int> next = buildNext(p);
vector<int> matches;
for (int i = 0, j = 0; i < n; i++) {
while (j > 0 && s[i] != p[j]) {
j = next[j - 1]; // 失配时模式串回退
}
if (s[i] == p[j]) {
j++;
}
if (j == m) {
matches.push_back(i - m + 1); // 匹配起点
j = next[j - 1]; // 继续找下一个匹配
}
}
return matches;
}
这段代码是“next数组存储在模式串下标对应的前缀长度”的版本,注意j = next[j - 1]而不是j = next[j],这是初学者最容易搞混的地方,因为不同资料对next数组的定义不一样(有的定义next[j]是“失配时j应该跳到的位置”,这种写法可以直接j = next[j])。两种定义等价,但代码不同,你需要固定一种理解。我用的是“next[i]表示前i个字符的最长相等前后缀长度”,所以回退时要写next[j-1]。
4.3 一个由next数组引出的高阶用法:最小循环节
KMP除了匹配,还有一个被竞赛圈广泛使用的推论:若字符串长度为L,且L % (L - next[L-1]) == 0,则它的最小循环节长度为L - next[L-1]。这里next[L-1]是整个字符串的最长相等前后缀长度。
举个例子,字符串“abcabcabcabc”,长度12,next[11] = 9(因为最长相等前后缀是“abcabcabc”),所以循环节长度 = 12 - 9 = 3,也就是“abc”。这个结论在很多题里能直接秒杀。
为什么成立?因为如果字符串由循环节重复而成,那么整个字符串的最长相等前后缀必然是由最前面的若干循环节和最后面的若干循环节组成的,剩余的部分就是单个循环节。反之,如果L不能整除L - next[L-1],说明该字符串不是由某个更短的子串循环重复生成的,循环节长度就是L本身。
这个推论很多教材不写,但面试和竞赛里很爱考。你要能现场推导,不要只背结论。
5. 常见问题与排查技巧实录
5.1 单调栈/单调队列的疑难杂症
问题1:单调栈里到底存下标还是存值?
强烈建议存下标。原因有三个:其一,答案通常要返回位置信息;其二,存下标可以通过nums[st.top()]取值,一举两得;其三,遇到元素值相同需要区分位置时,下标是唯一标识。存值的话一旦元素重复,你无法区分“这个值”是哪个位置的,答案会错。
问题2:什么时候用严格单调,什么时候用非严格单调?
判断标准只有一个:题目里“大于/小于”是否包含等于。找“下一个更大元素”,就应该严格递减栈(弹出条件用>,即严格大于才弹),相等不弹。找“下一个大于等于”,用非严格递减栈(弹出条件用>=)。最简单的记忆方式:弹栈条件就是题目条件的“逆条件”。题目找更大,那弹栈的条件就是“当前元素大于栈顶”,栈内保持递减。套用的时候把题目条件反向写进while即可。
问题3:滑动窗口最大值为什么一定要用双端队列?用优先队列不行吗?
优先队列(堆)也能求窗口最大值,但堆无法直接删除窗口外的任意元素。要实现滑动删除,要么用“懒删除”——堆顶元素如果下标超出窗口左边界就弹出,这种做法的时间复杂度是O(n log n),比单调队列的O(n)差。LeetCode 239官方数据下,优先队列能过但性能明显不如单调队列。另外,堆无法直接处理“窗口内最小值”与“最大值”同时要求的情况,而单调队列则比较直观。
问题4:为什么单调队列的模板一定要在i >= k - 1才开始记录答案?
因为窗口至少要包含k个元素才能“成形”。在前k-1个元素遍历时,我们的队列内部虽然已经维护了单调性,队头也是当前已遍历范围内的最大值,但这不是一个完整的窗口结果。有些初学者会在每个i处都记录,导致答案数组多了k-1个错误值。
5.2 KMP的常见翻车现场
问题1:next数组下标从0开始还是从1开始?
这是KMP教程最混乱的地方。有的书用next[1]=0起手,有的用next[0]=0,两种都有人写。我的建议是你统一用“next[i]表示前i个字符(包含第i个)的最长相等前后缀长度,下标从0开始”这个版本,代码见上文。这个版本和C++的string下标天然吻合,写起来少出错。你可以在笔记里标注清楚自己的定义,面试时向面试官说明你的定义即可。
问题2:失配回退写j = next[j-1],为什么不是j = next[j]?
因为数组下标从0开始,当前模式串已经匹配了j个字符(0到j-1),这j个字符的“最长相等前后缀长度”是next[j-1]。如果j=0,表示模式串一个字符都没匹配上,此时不能回退,只能i往后走。很多人的bug是没判断j>0就访问next[j-1],导致越界。
问题3:字符串匹配有多个模式串怎么办?
KMP是单模式串匹配算法。多个模式串的情况,要么用AC自动机(本质是KMP在Trie上的扩展),要么用其他多模匹配算法。面试的时候如果你能主动说出“多个模式串需要AC自动机”,是一个不错的加分项。
5.3 三个算法综合踩坑速查表
| 算法 | 最常犯的错 | 正确的做法 |
|---|---|---|
| 单调栈 | 栈里存值不存下标 | 存下标,取值用nums[st.top()] |
| 单调栈 | 等号处理错误 | 先明确“大于”还是“大于等于”,再写弹栈条件 |
| 单调栈 | 栈空时访问栈顶 | 循环条件写!st.empty(),或用哨兵消边界 |
| 单调队列 | 忘记移除窗口外元素 | 每次循环都检查dq.front() <= i - k |
| 单调队列 | 窗口未成形就开始记录 | 判断i >= k - 1再记录答案 |
| KMP | next数组定义混淆 | 固定一种定义并写清注释 |
| KMP | 失配回退写成j = next[j]导致死循环 |
明确自己的定义后统一写j = next[j-1] |
| KMP | 构造next时忘记处理j>0 | while条件里先判断j > 0 |
这个表是我带人时总结的,几乎覆盖了90%的初学者错误。如果你遇到“样例过但提交错”的情况,按这个表逐项排查,很多时候问题就出在这些细节上。
5.4 说一下我自己的调试三板斧
第一板斧:小规模数据手跑。比如单调队列,手动模拟一个长度为5、窗口大小为3的数组,每一步都写出队列内容和队头答案,和代码输出对照。这个过程很笨但极其有效,很多“我以为我懂了”的错觉都是这样被击碎的。
第二板斧:打印调试。在while循环里打印每次栈/队列的变化过程,比如进入循环前、弹出后、入栈/入队后、记录答案后各打印一次。不要觉得打印低级,它能让你直观看到“哪一步的状态和预期不符”。
第三板斧:边界用例自测。单调栈用全递增、全递减、全部相等的数组自测;滑动窗口用k=1、k=n、数组长度等于k的极端情况;KMP用模式串全相同、模式串与文本串完全重合、模式串比文本串还长的情况。这些边界用例能帮你找出隐藏的越界和逻辑错误。
最后分享一个实际经验
这三个算法我教过很多遍,也陪跑过很多面试模拟。我个人最大的体会是,背模板不是不行,但一定要理解每个模板里那几行关键代码的“为什么”。比如单调栈里的弹出条件为什么和题目条件相反,单调队列为什么要先清尾部再清头部,KMP的next为什么有时要减1。这些细节一旦想通了,你面对变体题就不会慌,因为你知道这个模板是怎么来的,也知道哪些地方可以改、哪些地方不能动。
如果你正在准备面试或者竞赛,建议把这三个模板的代码手写三遍以上,第一遍照抄,第二遍默写,第三遍在一张空白纸上从题目要求开始自己推导出模板。到第三遍的时候,这些细节才能真的变成你自己的东西。后续遇到“接雨水”“滑动窗口最小值”“重复子字符串”这类题,你也会自然地联想到对应的算法,不用再翻题解了。
