这三个算法放在一起讲,不是随便拼盘的。网上讲单调栈、单调队列、KMP的教程不少,但大多数人都是挨个背模板、刷题,很少有人把它们串起来想。我在实际刷题和带新人的过程中发现,这三个东西本质上是一条线:它们都在做同一件事——用空间换时间,用历史信息避免重复比较。只不过一个用栈,一个用队列,一个用前缀匹配表。
这篇文章我会从这三块的核心思想讲起,然后逐个拆解原理、手写模板、分析复杂度,最后附上我自己踩过的坑和常见的面试变体。内容不追求面面俱到,但保证每一段都能直接拿去用。无论你是刚接触算法的初学者,还是被“接雨水”“字符串匹配”折磨过的刷题老手,这篇文章应该都能给你一些新的视角。
1. 为什么这三个算法值得放在一起学
先抛出我的核心观点:单调栈、单调队列、KMP的共同敌人是“重复比较”。
拿暴力做法举例。求每个元素右边第一个比它大的数,正常人第一反应就是二重循环,每个元素向右扫描,O(n²)就出来了。滑动窗口最大值也是,每个窗口扫一遍,O(nk)。字符串匹配更是,模式串失配了就从头再来,O(nm)。这些暴力解法的通病是:曾经比较过的信息被直接丢弃,下次换个位置又重新比较一遍。而这三个算法都在做同一件事——把已经扫过的数据用一种“有组织”的方式缓存起来,让每个元素至多被处理常数次。
这就是为什么它们都是O(n)或O(n+m)。不是发明了什么魔法,只是不再把比较结果扔掉而已。
换个角度说,你可以把这些算法理解为“淘汰制”。单调栈里,每次新元素入栈,会把栈里“不如自己”的元素统统淘汰;单调队列里,每次滑动,会把队尾“不如自己”的元素淘汰;KMP里,每次匹配失败,会把已经匹配过的前缀信息拿来复用,而不是老老实实退回去重来。只要能理解“淘汰什么、保留什么、什么时候淘汰”,这三个算法你就通了。
所以这篇文章的目录我打算这么排:先分别讲清楚单调栈和单调队列,因为这两个是“结构性”的,比较直观;然后讲KMP,因为它的next数组是这三者里最抽象的一个;最后做一个横向对比,总结它们的适用场景和刷题判断方法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈:下一个更大元素的“淘汰制”玩法
2.1 从一道题说起:每日温度
LeetCode 739题“每日温度”是单调栈的经典入门题。题目大概是:给你一个数组temperatures,返回一个数组answer,answer[i]表示对于第i天,至少需要等几天才能等到更暖和的一天;如果之后都没有更高的温度,就填0。
暴力解法就是每个位置往右找,最坏情况O(n²)。在数据量1万以内还好,超过10万直接超时。单调栈解法如下:
python复制def dailyTemperatures(temperatures):
n = len(temperatures)
ans = [0] * n
stack = [] # 栈里存下标,保证对应的温度从栈底到栈顶递减
for i in range(n):
while stack and temperatures[stack[-1]] < temperatures[i]:
prev = stack.pop()
ans[prev] = i - prev
stack.append(i)
return ans
核心就三行代码:当前元素比栈顶大,就把栈顶弹出来,并记录答案;然后把当前元素下标压入栈中。每个元素最多入栈一次、出栈一次,总时间复杂度O(n)。
为什么能这么做? 因为对于栈里的元素,它们已经形成了一个“从栈底到栈顶递减”的序列。比如栈底是70度,栈顶是50度。当新来一个60度时,50度可以直接弹出,因为60度就是50度右边第一个比它大的元素;而70度保留着,因为60度还没有超过它,将来也许有更大的能把它弹出。整个过程就像是一个“等在候场区”的选手,谁比我高,我就能退场,而且退场时记录的是“等了多久”。
2.2 右边第一个更小、左边第一个更大,怎么变通
很多新手只会写“右边第一个更大”,遇到变体就懵。其实单调栈的核心操作都一样,变化只在两个点:
- 遍历方向:从右往左遍历,可以求“左边第一个更大/更小”
- 比较符号:
<弹出栈顶求“右边第一个更大”,>弹出栈顶求“右边第一个更小”
我自己给新人推荐一个万能模板,直接背下来然后改符号:
python复制def nextGreater(nums):
n = len(nums)
res = [-1] * n
stack = [] # 存下标
for i in range(n):
while stack and nums[stack[-1]] < nums[i]:
res[stack.pop()] = nums[i] # 或者存 i,看题目要什么
stack.append(i)
return res
求“右边第一个更小”就把 < 换成 >。求“左边”就把循环反着写:for i in range(n-1, -1, -1)。
有一个细节容易踩坑:单调栈里存的是下标还是值? 大部分时候存下标,因为你要answer时往往需要下标差。如果题目只问值,存值也能写,但存下标通用性更强。我建议一律存下标,用的时候再通过原数组取值。
2.3 实战进阶:接雨水和最大矩形
单调栈真正展现威力的是两个“Hard”题。
接雨水(LeetCode 42):每个柱子能接多少水,取决于它左边最高和右边最高的较小值。单调栈解法是维护一个单调递减栈,当发现当前柱子比栈顶高,说明存在一个“洼地”,可以结算这部分水量。这里需要特别注意,结算水量时不能只看栈顶,要看弹出栈顶之后的新的栈顶和当前柱子的高度差,因为水是“跨柱子”存的。
柱状图中最大的矩形(LeetCode 84):每个柱子能形成的最大矩形,取决于它左右两侧第一个比它矮的柱子。这就是典型的“左右两边第一个更小元素”问题。用单调栈分别求出每个柱子左边第一个更矮的位置left[i]和右边第一个更矮的位置right[i],然后面积就是 heights[i] * (right[i] - left[i] - 1)。
这两道题我建议你至少手写三遍。第一遍按题解抄,第二遍关掉代码自己写,第三遍找变体题做。我当时带训练营的时候发现,能把84题完全吃透的人,基本见到单调栈题就能秒。
3. 单调队列与滑动窗口:窗口内的“在线最值”维护
3.1 滑动窗口最大值
LeetCode 239题:给定数组nums和窗口大小k,每个窗口内取最大值,输出所有窗口最大值组成的数组。
暴力做法不用说了,每个窗口遍历k个元素。单调队列解法是维护一个双端队列,队列里存的是窗口内“可能成为最大值的候选元素”的下标,从队头到队尾对应值递减。每次滑动窗口时做三件事:
- 检查队头是否已经滑出窗口,滑出则弹出(过期处理)
- 从队尾把小于当前元素的值全部弹出(淘汰制)
- 将当前元素下标入队,队头元素即为当前窗口最大值
python复制from collections import deque
def maxSlidingWindow(nums, k):
dq = deque()
res = []
for i, v in enumerate(nums):
# 1. 队头过期
if dq and dq[0] <= i - k:
dq.popleft()
# 2. 淘汰队尾所有小于当前值的元素
while dq and nums[dq[-1]] <= v:
dq.pop()
# 3. 入队
dq.append(i)
# 窗口形成后才能取最大值
if i >= k - 1:
res.append(nums[dq[0]])
return res
我拿 [1, 3, -1, -3, 5, 3, 6, 7],k=3 来手动推一遍,这是网传很热的一个图解示例,用文字推给你看:
- i=0,v=1:dq为空,入队 -> dq=[0],窗口未满
- i=1,v=3:队尾是1,比3小,弹出;入队dq=[1],窗口未满
- i=2,v=-1:队尾是3,比-1大,直接入队;dq=[1,2];窗口已满,最大值为nums[1]=3
- i=3,v=-3:dq队头1没出窗口(1 > 3-3),入队dq=[1,2,3];最大值3
- i=4,v=5:队头1已出窗口(1 <= 4-3),弹出;队尾-3比5小弹出,-1比5小弹出,3比5小弹出;dq=[4];最大值5
- i=5,v=3:队头4在窗口内,队尾5比3大,入队dq=[4,5];最大值5
- i=6,v=6:队头4没出窗口(4 > 3),但队尾3比6小弹出、5比6小弹出;dq=[6];最大值6
- i=7,v=7:队头6在窗口内,队尾6比7小弹出,dq=[7];最大值7
最终结果是 [3,3,5,5,6,7]。整个过程每个元素只出入队一次,O(n)。
单调队列最大的优势是“在线”:数据是一个一个流进来的,我们不用等整个窗口凑齐再重新找最大值,而是每来一个新数据,用常数次操作更新候选队列,当前窗口最大值随时可查。这种特性在很多实时场景很有用。
3.2 单调栈和单调队列的区别,很多人分不清
这两个东西名字像,实现也有共同点,但职责完全不同:
| 对比项 | 单调栈 | 单调队列 |
|---|---|---|
| 数据结构 | 栈(单端) | 双端队列 |
| 核心场景 | 找每个元素左边/右边第一个更大/更小 | 滑动的区间内找最大值/最小值 |
| 出队的条件 | 栈空或栈顶不满足单调性 | 队头元素过期 + 队尾元素被淘汰 |
| 典型时间复杂度 | O(n) | O(n) |
一句话总结:如果题目中“区间”是固定起点固定终点的,用单调栈;如果区间是一个滑动的窗口,用单调队列。
当然也有例外,有些题可以互相转化,但那是进阶玩法,新手先按这个判断走。
3.3 热点延展:滑动窗口滤波与延迟
热词里反复出现“滑动窗口滤波模型”“滑动窗口滤波器的延迟”“滑动窗口滤波verilog”,这其实是滑动窗口思想在信号处理领域的应用。
滑动窗口滤波(通常指移动平均滤波)做法是:维护一个固定窗口,窗口内数值取平均值作为当前滤波输出。它和单调队列解决的是不同的问题——滤波关心的不只是最大值,而是整个窗口的统计量(均值、中位数、方差等)。
这里有个关键概念叫“群延迟”(group delay),因为滤波器必须等窗口内的数据到齐才能输出,所以输出信号会天然滞后输入信号约 (k-1)/2 个采样周期。很多做信号处理的新手会疑惑“为什么滤波后的波形比原始波形偏了”,其实就是窗口延迟导致的。
说到verilog实现,滑动窗口滤波在FPGA里很常见。核心是移位寄存器(Shift Register)缓存窗口数据 + 加法器树求累加和。网络热词里提到“滑动窗口滤波verilog”,说明这个话题确实有人搜。如果你要做硬件实现,我的建议是:窗口宽度用2的幂(8、16、32),这样除法可以直接用右移代替,省大量逻辑资源;累加器要考虑位宽扩展,不然溢出是必然的。
不过回到算法题,滑动窗口最大值和滑动窗口滤波是两码事,你别混了。后者属于DSP(数字信号处理),前者属于数据结构题,解题套路完全不同。
3.4 滑动窗口最值的其他应用
单调队列的应用不只在“滑动窗口最大值”“滑动窗口最小值”这类直球题。有些题包装得很深,但核心还是滑动窗口最值:
- 求数组每个长度为k的子数组的最大值与最小值之差(直接维护两个单调队列:一个递减存最大值,一个递增存最小值)
- 求滑动窗口内的中位数(要堆或双堆,不是单调队列的活,但如果你只需要最大值,单调队列是首选)
- 形如“求使max(arr) - min(arr) <= limit的最长子数组长度”(LeetCode 1438)——双指针 + 两个单调队列,左边是窗口左边界,右边扩展,队列维护窗口内极值
我个人经验是:但凡题里有“窗口”两个字,第一优先考虑双端队列和单调队列的组合。单调队列的模板并不复杂,真正的难度在于判断什么时候用“过期出队”,什么时候用“维护单调性”。
4. KMP:失配时不回头,靠的是前缀后缀的自我认知
4.1 朴素匹配的问题在哪里
字符串匹配大家都写过:在文本串text里找模式串pattern的位置。暴力做法是两层循环,外层遍历text的每个起点,内层一个个字符比对,一旦发现不匹配就text起点+1,pattern从0重新开始。这个过程最坏O(nm)。
问题出在哪里?举个例子:
文本串:abcabcabd
模式串:abcabd
前5个字符abcab都匹配上了,第6个字符text是c,pattern是d,不匹配。暴力做法直接跳到text第2个字符继续匹配,但实际上前5个字符里已经包含了大量“对齐”线索。pattern的前缀ab正好是后缀ab,所以模式串可以直接右移,让前缀ab跟文本已经匹配过的后缀ab对齐,而不是退回text起点后面一位。
KMP的核心思想就是这个:已匹配的部分,不要丢掉,利用这些信息决定模式串指针回退到哪里。
4.2 next数组是什么
next数组是KMP的灵魂。它记录的是模式串每个位置之前的子串中,最长的相等前缀和后缀的长度。
画个图的话是这样的:
code复制模式串 a b c a b d
下标 0 1 2 3 4 5
对于下标4的前缀子串abcab,它最长的相等前后缀是ab,长度2;对于下标5的前缀子串abcabd,没有相等的前后缀,长度0。所以:
code复制next[0] = 0 (或者约定为-1,取决于版本)
next[1] = 0
next[2] = 0
next[3] = 1 (前缀a = 后缀a)
next[4] = 2 (前缀ab = 后缀ab)
next[5] = 0 (没有相等前后缀)
这个数组代表什么含义?它告诉你:如果模式串在第i个位置失配,下一次匹配应该从模式串的next[i]这个位置继续,不需要从头再来。
这里有个全网刷到的热点:“kmp算法next计算方法”。我用大白话讲一遍手算方法:
- 先看下标i之前(不含i)的子串,即pattern[0:i]
- 找出这个子串的所有前缀集合和后缀集合
- 取交集,挑长度最大的那个
比如求next[4],看pattern[0:4] = abca。前缀有:a、ab、abc、abca(不含整个串自己,严格来说是真前缀);后缀有:a、ca、bca、abca(真后缀)。交集是a,长度为1,所以next[4] = 1。
4.3 next数组的两种写法,千万别混
网上关于next数组的写法五花八门,核心区别在于next[i]存的是什么:
- 版本A:next[i] = 模式串下标i之前的子串的最长相等前后缀长度。KMP失配后,j = next[j]。
- 版本B:next[i] = 当模式串下标i失配时,j应该跳到哪个位置。这个版本往往next[0] = -1,next数组整体是在版本A的基础上右移一位并减1。
我自己带过的学习小组里,90%的混乱都出在这里。两个版本都是对的,但配套的KMP匹配代码不同,不能混着用。
我习惯用版本A,理由很简单:它和前后缀的定义一一对应,手算不容易错。匹配时代码如下:
python复制def kmp_search(text, pattern):
m = len(pattern)
nxt = get_next(pattern)
j = 0 # 模式串指针
for i in range(len(text)):
while j > 0 and text[i] != pattern[j]:
j = nxt[j - 1] # 注意这里是j-1,因为next存的是位置
if text[i] == pattern[j]:
j += 1
if j == m:
# 匹配成功,返回起始下标
return i - m + 1
return -1
如果版本B,匹配代码会变成:
python复制def kmp_search_b(text, pattern):
m = len(pattern)
nxt = get_next_b(pattern) # next[0] = -1
j = 0
for i in range(len(text)):
while j >= 0 and text[i] != pattern[j]:
j = nxt[j]
j += 1
if j == m:
return i - m + 1
return -1
两种都行,但你在对比代码时一定要先确认是哪个版本,别拿着版本A的next数组去跑版本B的匹配,否则一定挂。
4.4 求next数组的递推过程(核心细节)
next数组可以O(m)时间求出来,方法就是用模式串自己匹配自己。
python复制def get_next(pattern):
m = len(pattern)
nxt = [0] * m
j = 0 # 前缀长度 / 当前匹配长度
for i in range(1, m):
while j > 0 and pattern[i] != pattern[j]:
j = nxt[j - 1]
if pattern[i] == pattern[j]:
j += 1
nxt[i] = j
return nxt
这段代码的逻辑和KMP匹配本质一样。i是后缀末尾,j是前缀末尾。如果pattern[i] == pattern[j],说明前缀继续延长,j自增;如果不相等,就沿着已有的next往回跳,直到相等或j归零。
我以模式串 ababcabab 为例,手推一遍:
- i=0,nxt[0]=0
- i=1,pattern[1]='b',j=0,不相等,nxt[1]=0
- i=2,pattern[2]='a',j=0,相等,j=1,nxt[2]=1
- i=3,pattern[3]='b',j=1,pattern[3]==pattern[1](都'b'),j=2,nxt[3]=2
- i=4,pattern[4]='c',j=2,pattern[4]!=pattern[2]('a'),j=nxt[1]=0;pattern[4]!='a',nxt[4]=0
- i=5,pattern[5]='a',j=0,相等,j=1,nxt[5]=1
- i=6,pattern[6]='b',j=1,相等,j=2,nxt[6]=2
- i=7,pattern[7]='a',j=2,相等,j=3,nxt[7]=3
- i=8,pattern[8]='b',j=3,相等,j=4,nxt[8]=4
所以next = [0, 0, 1, 2, 0, 1, 2, 3, 4]。手推一遍以后你对递推的理解会清晰很多。
4.5 KMP的进阶优化
版本A存在一个常见优化问题。比如模式串是aaaab,文本串是aaaacaaaab。当模式串在b处失配时,前面的aaaa都匹配了,按照next一步步回退,j依次变成3、2、1、0,每一次回退后还都是比较同一个失配字符c,效率浪费。
优化思路是:如果回退后的位置pattern[j]和原来失配的位置pattern[old_j]是同一个字符,那匹配大概率还会失败,直接跳过。具体做法是求next时判断pattern[i]和pattern[j]是否相等,如果相等,就继承回退后的next值。这个优化不改变整体复杂度,但在极端重复模式下能显著减少比较次数。
不过说实话,正常笔试面试不会考到优化版,你先掌握基本版,后续遇到极端性能要求再优化不迟。
4.6 KMP的典型应用与工程场景
字符串匹配的应用太广泛了。比如文本编辑器里的查找功能、IDE里的代码搜索、日志系统中多行文本的关键词匹配、基因序列比对、敏感词过滤等。KMP的优势在于预处理一次模式串,后面每次匹配都是O(n)线性扫描,适合模式串固定、文本串多次变化的场景。
搜索引擎用的一般不是KMP,因为搜索引擎的倒排索引策略完全不同,但KMP在单模式匹配场景里足够经典。小到一个几十行的文本编辑器插件,大到CDN的URL规则匹配,只要需要高效的字符串查找,KMP都是基础选项。
5. 三件套的横向总结与刷题建议
5.1 复杂度对比
| 算法 | 数据结构 | 预处理 | 单次查询 | 核心思想 |
|---|---|---|---|---|
| 单调栈 | 栈 | 无 | O(n) | 一次遍历,淘汰劣势元素 |
| 单调队列 | 双端队列 | 无 | O(n) | 窗口滑动,淘汰+过期 |
| KMP | 数组(next) | O(m) | O(n+m) | 失配复用已匹配信息 |
三个都做到了“线性”,这是它们能在大量实际场景中规模化应用的根本原因。如果只是追求“能跑”,暴力法都能跑,但数据量一上来,线性与平方的差距是灾难性的。
5.2 怎么判断一道题用哪个
我用我自己的经验给你一套判断流程:
- 题目出现“每个元素 + 左边/右边 + 第一个更大/更小/相等” → 单调栈
- 题目出现“窗口 + 最大值/最小值 + 滑动” → 单调队列
- 题目出现“匹配 + 字符串 + 找子串/重复模式” → KMP
- 题目出现“字符串 + 判断循环节/重复子串” → 也要考虑KMP的next数组,比如LeetCode 459“重复的子字符串”就是经典应用:判断len % (len - next[len-1]) == 0
- 题目出现“区间极值 + 双指针 + 固定条件” → 大概率是单调队列配合双指针
当然这不是100%准的,但有90%的题可以靠这个判断命中。
5.3 我踩过的坑与建议
第一个坑:单调栈忘记处理栈里剩余元素。很多题在遍历结束后,栈里可能还有元素,需要单独处理。比如接雨水问题你如果不处理栈里剩余的递减柱子,答案就少了。解决办法是:写循环时先想清楚“当栈空/栈不空时分别会发生什么”。
第二个坑:KMP匹配时索引边界搞混。版本A中,j == m意味着匹配成功,返回值i - m + 1;但如果text长度小于pattern长度,会越界,记得先判断。
第三个坑:单调队列里队列存下标,比较值时忘了加nums[]。这是个很低级但很常见的错误。因为存的是下标,所以在比较时写nums[dq[-1]] <= v,不是dq[-1] <= v。我见过太多人在这上面Debug半小时。
第四个坑:窗口最大值和最小值的单调性方向写反。求最大值时队头到队尾递减,求最小值时递增。很多人图省事只背一个,遇到求最小值就废。我的经验是:真的去理解“为什么最大值需要递减队列”,而不是死背。
5.4 最后说点个人体会
这三个算法是我认为算法思维进阶的第一道坎。它们不涉及复杂的数学,不需要高深的数据结构功底,但需要你跳出“模拟题”的思维——不是跟着题目做动作,而是想清楚哪些信息可以丢弃、哪些信息必须保留、用什么结构保留最合适。想通了这一层,后面学BFS、DFS、动态规划、树、图的时候,很多技能都是平移过去。
我个人建议,新手不要急着做难题,先把三个模板写到不需要思考就能默写出来的程度,然后用每个算法配套的5道基础题反复训练。训练时不要看题解,试着自己推导,哪怕推得慢。只要能独立做出来两遍,你对这些算法的理解就超过大多数背模板的人了。
