老读者都知道,代码随想录算法训练营到第11天,已经过了数组、链表、哈希表这些基础关,开始往“栈和队列”这个更抽象的方向走了。这几道题——150. 逆波兰表达式求值、239. 滑动窗口最大值、347. 前 K 个高频元素——表面上看是三道题,实际上对应的是三种基础数据结构在算法题里的典型用法:栈的应用、队列的变形、堆的应用。说实话,我在刷这三题之前,栈和队列就停留在“会用API”的程度,但刷完这一天的内容,我开始真正理解“为什么需要这三种结构”,也理解了为什么很多面试官喜欢围绕这几题往下挖。
今天的题量不大,但信息密度很高。150 逆波兰表达式是栈最经典的场景;239 滑动窗口最大值是“单调队列”从概念到实战的敲门砖;347 前 K 个高频元素则是“小顶堆”这种数据结构的第一次正式登场。如果你刚过完基础内容、正准备进入中等难度的题目,这一天值得多花点时间吃透。
1. 150. 逆波兰表达式求值:栈的“消除”能力到底强在哪
1.1 为什么会有逆波兰表达式这玩意儿
先问一个问题:你平时写 (1 + 2) * 3 这种算式,计算机是怎么算的?人眼扫一眼就知道先算括号里的 1 + 2,再乘以 3。但计算机从左往右读字符串时,得先拆出数字和运算符,还要处理运算符优先级和括号的嵌套关系,这就很麻烦。逆波兰表达式(Reverse Polish Notation,RPN)也被称为后缀表达式,它把运算符写在操作数后面,比如 (1 + 2) * 3 写成 1 2 + 3 *,括号直接不需要了,优先级也不需要了。
这就是计算机友好的表达方式:从左到右扫描,遇数字就压栈,遇运算符就弹出最近的两个数做运算,再把结果压回去。它把“表达式求值”这种需要处理优先级的人为规则,变成了“无脑按顺序执行”的机械规则。编译器在做表达式翻译、计算器程序做表达式解析时,用的就是这套思路。
所以 150 题表面上是在考“你能不能用一个栈模拟计算过程”,实际上是在训练你把“中缀表达式怎么求值”这个复杂问题拆成“后缀表达式 + 栈”的简单模型。训练营把这题放在这里,就是为了让你把栈当成一个“有记忆的临时存储区”来用,而不是只会用 push 和 pop。
1.2 核心思路与完整推演
逆波兰表达式求值的规则就四条:
- 遇到数字,压入栈。
- 遇到运算符,弹出栈顶两个数字。
- 先弹出的数字放在运算符右侧,后弹出的放在左侧,做对应运算。
- 把运算结果压回栈。
最后栈里剩下的唯一数字,就是整个表达式的值。
我拿题目里的例子 ["2", "1", "+", "3", "*"] 走一遍完整过程:
- 读入
2,栈变为[2] - 读入
1,栈变为[2, 1] - 读入
+,弹出1和2,计算2 + 1 = 3,栈变为[3] - 读入
3,栈变为[3, 3] - 读入
*,弹出3和3,计算3 * 3 = 9,栈变为[9]
结果就是 9,和 (2 + 1) * 3 完全一致,但没用到任何括号优先级逻辑。
再看一个更长的例子:["4", "13", "5", "/", "+"],对应中缀是 4 + 13 / 5,但因为都是整数除法,结果是 4 + 2 = 6。
这里有一个细节必须注意:弹出两个数时,谁是被减数、谁是被除数。假设栈从栈底到栈顶依次是 a, b,弹出时先拿到的是 b,后拿到的是 a。做减法必须用 a - b,而不是 b - a;做除法必须用 a / b,而不是 b / a。很多人第一次写的时候会在这个地方写反,结果整个表达式结果全错了,而且错误非常隐蔽,不容易一眼看出来。
1.3 代码实现(Python)
python复制from typing import List
def evalRPN(tokens: List[str]) -> int:
stack = []
operators = {"+", "-", "*", "/"}
for token in tokens:
if token not in operators: # 数字直接入栈
stack.append(int(token))
else:
right = stack.pop() # 先弹出的是右操作数
left = stack.pop() # 后弹出的是左操作数
if token == "+":
stack.append(left + right)
elif token == "-":
stack.append(left - right)
elif token == "*":
stack.append(left * right)
else: # "/" 整数除法,需要向0取整
# Python 的 // 是向下取整,负数时会出错
stack.append(int(left / right))
return stack[0]
重点说一下除法。13 / 5 在 Python 里用 / 得到 2.6,然后 int() 截断小数部分得到 2,这是正确的。但如果写成 13 // 5,结果是 2 也正确。问题出在负数上:-13 // 5 在 Python 里结果是 -3,因为 // 是向下取整;但题目要求向零取整,所以正确结果应该是 -2。这就是为什么注释里强调需要 int(left / right) 而不是 left // right。这个坑在 LeetCode 上专门有测试用例卡它,C++ 和 Java 的整数除法都是直接向零取整,只有 Python 新手容易翻车。
复杂度方面:遍历一次数组,每个元素最多入栈出栈一次,时间 O(n),空间 O(n)(栈的深度最多是数字数量级别)。
1.3 这题的扩展思路和刷题心得
150 题本身不难,但它可以延伸到很多地方:
- 把“后缀表达式”换成“前缀表达式”,也就是运算符在操作数之前,求值思路就变成从右往左扫描,或者把表达式反转后复用后缀表达式逻辑。
- 如果本题目让你处理“中缀表达式”,那就变成 LeetCode 227 基本计算器 II,思路是先把中缀转后缀或直接双栈模拟。
- 在实际工作中,实现一个自定义公式引擎、解析 CSV 里的公式、写简单的 DSL 解释器时,逆波兰表达式这套“栈消除嵌套结构”的思路会反复出现。
我在刷这一题时最大的体会是:不要在 left 和 right 上省那两行代码。我第一次偷懒,弹出后直接取第一个为 num1、第二个为 num2,结果代码跑起来一半用例是错的。后来老老实实写成 right = stack.pop()、left = stack.pop(),一眼就能看出谁先谁后,再也没出过错。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 239. 滑动窗口最大值:暴力解法不是不能过,而是怎么过得更漂亮
2.1 题目场景与暴力解的真实瓶颈
题目给一个整数数组 nums 和一个大小为 k 的滑动窗口,窗口从数组最左边开始往右移动,每次移动一格,要求每移动一次,都返回当前窗口里的最大值。
最直接的思路,就是每次窗口移动后,扫描窗口内的 k 个元素找最大值,时间复杂度 O(nk)。如果数组长度 n 是 10^5,k 是 10^5,那就是 10^10 次运算,基本不可能在规定时间内跑完。
那有没有 O(n) 的做法?有,就是维护一个“单调队列”。我第一次听到“单调队列”时觉得很高大上,但拆开来看,它的思路很简单:没有必要在窗口里维护所有元素,只需要维护“可能成为最大值的元素”,而且队列头部永远是当前窗口的最大值。
2.2 单调队列的核心机制:为什么它是对的
单调队列的本质是一个双端队列(deque),队头到队尾的元素保持单调递减(或者严格递减)。之所以能保证队头就是最大值,靠的是两个规则:
规则一:入队时,从队尾弹出所有小于等于当前元素的元素。
为什么?因为当前窗口从左往右移动,新的元素比旧元素更靠右。如果新元素比队尾旧元素大,那么只要新元素还在窗口内,它就会一直压着旧元素,旧元素永远没有机会成为最大值。既然旧元素已经“不可能再翻身”,不如直接丢掉,省得后面白做比较。
规则二:队头元素如果滑出窗口,就把它弹出。
窗口右移时,最左边的元素会移出窗口。如果这个最左边的元素恰好就是当前队列的队头(当前窗口最大值),它在后面的窗口里已经不存在了,必须把它弹出。
这里要理解一个细节:队列里存的是数组下标,而不是元素值。因为只存值的话,我们没法判断这个值对应的位置是否还在窗口里;存下标之后,判断队头是否过期只需比较 queue[0] <= i - k 即可(i 是当前窗口右边界)。
我们拿 nums = [1, 3, -1, -3, 5, 3, 6, 7],k = 3 来手动推演一遍。
初始化,i=0,窗口还没满。队列为空,把 0 入队,队列为 [0],对应值 [1]。
i=1,nums[1]=3。队尾下标 0 对应值 1,1 < 3,所以弹出下标 0。队列变为空,把 1 入队,队列为 [1],对应值 [3]。此时窗口 [1, 3] 还没满,不记录结果。
i=2,nums[2]=-1。队尾下标 1 对应值 3,3 > -1,不弹出,把 2 入队,队列为 [1, 2],对应值 [3, -1]。窗口 [1, 3, -1] 已满,队头下标 1 对应值 3,是当前窗口最大值,结果数组记为 [3]。
i=3,nums[3]=-3。队尾下标 2 对应值 -1,-1 > -3,不弹出,把 3 入队,队列为 [1, 2, 3],对应值 [3, -1, -3]。判断队头下标 1 是否在窗口 [1..3] 内:1 >= 3 - 3 + 1 = 1,在,保留。最大值还是 3,结果数组 [3, 3]。
i=4,nums[4]=5。先看队尾:下标 3 对应值 -3 < 5,弹出;下标 2 对应值 -1 < 5,弹出;下标 1 对应值 3 < 5,弹出。队列变空,把 4 入队,队列为 [4],对应值 [5]。队头在窗口内(4 >= 4 - 3 + 1 = 2),最大值 5,结果数组 [3, 3, 5]。
i=5,nums[5]=3。队尾下标 4 对应值 5 > 3,不弹出,把 5 入队,队列为 [4, 5],对应值 [5, 3]。队头 4 在窗口内,最大值 5,结果数组 [3, 3, 5, 5]。
i=6,nums[6]=6。队尾下标 5 对应值 3 < 6,弹出;队尾下标 4 对应值 5 < 6,弹出。把 6 入队,队列为 [6],最大值 6,结果 [3, 3, 5, 5, 6]。
i=7,nums[7]=7。队尾下标 6 对应值 6 < 7,弹出。把 7 入队,队列为 [7],最大值 7,结果 [3, 3, 5, 5, 6, 7]。
整个过程,每个元素最多入队一次、出队一次,时间 O(n),空间 O(k)。
2.3 代码实现(Python)
python复制from typing import List
from collections import deque
def maxSlidingWindow(nums: List[int], k: int) -> List[int]:
res = []
q = deque() # 存储下标,保持队头到队尾递减
for i in range(len(nums)):
# 移除队头已经滑出窗口的元素
if q and q[0] < i - k + 1:
q.popleft()
# 新元素从队尾入队前,把所有小于等于它的元素弹出
# 因为只要新元素还在,这些旧元素就没机会成为最大值
while q and nums[q[-1]] <= nums[i]:
q.pop()
q.append(i)
# 窗口达到 k 大小后,开始记录结果
if i >= k - 1:
res.append(nums[q[0]])
return res
代码里有两个细节值得说。
第一个是弹出条件用 <= 而不是 <。如果新元素和队尾元素值相等,弹出旧元素就对了。因为旧元素下标更靠左,会先滑出窗口;留下新元素,窗口范围覆盖时间更长。用 < 保留相等旧元素也不会错,但队列里会多存一些“不可能再成为最大值的元素”,白白浪费时间。所以写 <= 有理论依据。
第二个是判断队头是否过期用 q[0] < i - k + 1。窗口范围是 [i - k + 1, i],如果队头下标小于这个范围左边界,说明已经滑出窗口了,需要从队头弹出。这里的计算建议在草稿里演算一次,我第一次写成了 < i - k,边界差一位,直接寄。
2.4 单调队列为什么不是“大顶堆的简单替代”
有人会问:这题是不是可以用大顶堆?每次窗口移动时把新元素加入堆,同时把过期元素标记删除,堆顶就是最大值。思路确实可行,但复杂度是 O(n log k),比单调队列的 O(n) 慢一个 log。LeetCode 上这一题用堆也能过,但是面试时如果只回答出堆,面试官大概率会追问“能不能 O(n)”。这时能讲清楚单调队列就很有优势。
单调队列的本质是一个“优化版的滑动窗口最大值维护器”,它的妙处在于:元素一旦入队,就注定它只会被更大的新元素淘汰,所有“内部元素”之间的比较在入队时就做完了。而普通队列做不到这一点,因为普通队列只支持队尾进、队头出,无法从尾部弹出元素。这就是为什么必须用双端队列 deque,而不是普通队列。
2.5 这题常见错误总结
我在训练营里见到最多的错误有三个:
- 忘记处理队头过期。只做了入队时的弹出,没做窗口滑动后的队头弹出,导致结果出现“旧窗口的最大值”,不是当前窗口的真实最大值。
- 队列里存了值没存下标。存值的队列无法判断元素是否还在窗口内,只能靠“剩下的队列长度”来猜,逻辑会越写越乱。
- 结果数组的记录时机不对。
i >= k - 1才开始记录,否则窗口没满就输出最大值,结果长度不对。
这题的变体也很多,比如“滑动窗口最小值”,思路完全相同,只要把递减改成递增即可;还有“滑动窗口中位数”,那就要上两个堆或者有序容器了。
3. 347. 前 K 个高频元素:小顶堆为什么比大顶堆更省
3.1 题意拆解与朴素解法的局限
给定一个整数数组 nums 和一个整数 k,要求返回出现频率前 k 高的元素。比如 nums = [1, 1, 1, 2, 2, 3],k = 2,结果就是 [1, 2],因为 1 出现 3 次,2 出现 2 次,3 只出现 1 次。
最朴素的做法分两步:
- 用一个哈希表统计每个数字的出现次数。
- 对哈希表按出现次数从大到小排序,取前 k 个。
哈希表统计的时间是 O(n),但排序是 O(n log n)。如果 n 是 10^5,完全能过;如果 n 是 10^6,或者数据是流式输入的(无法一次性拿到全量数据),排序就不合适了。这时需要“维护一个大小为 k 的数据结构”,让它在扫描过程中一直保存“当前出现频率最高的 k 个元素”,最终直接输出。
3.2 为什么选择小顶堆而不是大顶堆
TopK 问题里,最经典的数据结构是堆。但这里有人会纠结:既然要“前 K 个高频”,直觉是“把频率最高的放堆顶”,那不就是大顶堆吗?
如果要用大顶堆,做法是:把所有元素都放进堆里,堆的大小是 n,最后弹出 k 次堆顶。这个做法时间复杂度是建堆 O(n) + 弹出 k 次 O(k log n)。如果 k 很大,可以勉强接受;但你可以做得更好。
更好的做法是维护一个大小为 k 的小顶堆:堆顶是“当前 k 个候选者中频率最小的”。遍历每个元素时,如果堆还没满,直接入堆;如果堆满了,且新元素频率比堆顶大,就弹出堆顶,把新元素放进去。这样堆里始终保持最新 k 个最高频元素。最终堆里所有元素就是答案。
为什么小顶堆比大顶堆好?因为小顶堆的堆大小始终是 k,而不是 n。插入一个元素的时间是 O(log k),总时间是 O(n log k)。当 k 远小于 n 时,O(n log k) 明显优于 O(n log n)。而且如果数据是流式的,比如实时日志、在线用户行为流,你不可能把全部历史数据存下来再排序,只能靠这种“有限大小的堆”来做增量统计。
这是面试里很常见的思路迁移:从“排序”到“局部有序”,用空间换时间。你可以和面试官聊到“如果这题是 TopK 问题,堆是最常用解法;如果再加一个约束比如数据不能全部读入内存,那就要考虑外部排序或者分治”。
3.3 手写代码与细节坑
python复制from typing import List
import heapq
from collections import Counter
def topKFrequent(nums: List[int], k: int) -> List[int]:
# 1. 统计频率
freq = Counter(nums) # {元素: 出现次数}
# 2. 用小顶堆维护频率最高的前 k 个元素
heap = [] # 存 (频率, 元素),默认按频率排序
for num, count in freq.items():
if len(heap) < k:
heapq.heappush(heap, (count, num))
else:
# 堆顶是前 k 个候选者里频率最小的
if count > heap[0][0]:
heapq.heappop(heap)
heapq.heappush(heap, (count, num))
# 3. 堆里剩下的元素就是答案
return [item[1] for item in heap]
这里的核心是 heap 存的是元组 (freq, num)。Python 的 heapq 默认按元组第一个元素排序,所以直接比较频率即可。如果两个元素的频率相同,才会比较第二个元素(元素值),顺序无所谓,不影响正确性。
有一个隐藏坑:如果 k 等于哈希表的总长度,循环里堆永远不会满到需要弹出,直接返回所有元素。这个边界情况要处理好。
如果你用 Java,可以手写 PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> a[1] - b[1]);C++ 就是 priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>>。思路完全一致,区别只在语法。
3.4 基于小顶堆的扩展:TopN 问题的统一套路
347 题只是 TopK 的一个变体,刷完这题后,你可以顺手把下面这些题全串起来:
-
- 数组中的第 K 个最大元素:可以直接用小顶堆维护最大的 k 个数,堆顶就是第 k 大的数。
-
- 前K个高频单词:在 347 的基础上,要求频率相同时按字典序排序。维护堆时要用
(频率, 单词)的元组,并且 Python 的 heap 元组默认按字典序排,处理起来非常丝滑。
- 前K个高频单词:在 347 的基础上,要求频率相同时按字典序排序。维护堆时要用
-
- 最接近原点的 K 个点:把“频率”改成“距离”,堆的键从
freq改成distance,套路一模一样。
- 最接近原点的 K 个点:把“频率”改成“距离”,堆的键从
训练营里反复强调“模板化刷题”是有道理的。你不需要背每道题的代码,而是要把“统计 + 堆 + 边界条件处理”这个套路吃透,遇到任何 TopK 变体就能套用。
3.5 从这题看“哈希表 + 堆”的协作模式
347 题看起来简单,但它真正教你的是两个数据结构怎么配合:哈希表负责数据的聚合统计(把每个元素的出现次数算出来),堆负责 TopK 选择(保留频率最高的 k 个)。这种“统计阶段 + 选择阶段”的协作模式,在很多业务场景里非常有用。
比如推荐系统要算“近 7 天热度最高的 10 个商品”,第一步可以按天滑动窗口统计每个商品的浏览次数,第二步用一个大小为 10 的小顶堆维护当前热度 Top10。数据是流式的,堆的大小固定,内存占用可控。再比如服务器日志分析时,想找“出现次数最多的 5 个报错码”,也得靠 HashMap + 堆这两板斧。所以别只把这题当成算法题,它其实是一个现实工程问题的高度抽象。
4. 三道题串起来看:栈、队列、堆的“选型逻辑”才是这天的核心
4.1 为什么这三题会放在同一天
训练营的题单不是随便排的。150 题考栈,239 题考单调队列,347 题考堆,三者都属于“线性数据结构”的进阶用法,但它们的“职责”完全不同:
- 栈:进去的路和出来的路是同一条路的后进先出。适合处理需要“撤销最近一步”或“配对消除”的场景。
- 队列(单调队列):按顺序处理一段连续区间,但只关心区间内最值。适合处理滑动窗口场景。
- 堆:不关心元素的绝对顺序,只关心全局最大/最小的若干个。适合处理 TopK 或频繁取最值的场景。
这一天的题感就是:你开始从“这个数据结构有哪些 API”切换到“这个数据结构适合解决什么形状的问题”。栈解决嵌套结构,队列解决顺序窗口,堆解决择优问题。当你拿到一个新题,能第一时间想到用哪种结构,才说明这些基础真的转化成了你的算法直觉。
4.2 面试官常在这三道题上追着往下问的延伸
我在训练营里总结过,面试时一旦考到这三题,面试官大概率会继续追问:
- 对 150 题:会问“如果表达式里包含括号和普通运算符,你怎么扩展?”这是要看你能不能把中缀转后缀、或者用两个栈模拟。建议自己手写一遍中缀转后缀流程,比单纯背逆波兰求值更有用。
- 对 239 题:会问“如果窗口大小不固定,而是动态变化的呢?”这就不能用固定 k 的单调队列简单套了,可能需要结合其他数据结构。但基本原理还是“过期元素要从队头清理,新元素从队尾维护单调性”。
- 对 347 题:会问“如果数据量非常大,比如超过内存,怎么处理?”这就是刚才说的外部排序、哈希分片、分治等思路。哪怕你只是答出“把小顶堆的 k 控制在一定范围内,分批处理”,也比卡壳强。
这些追问的本质,并不是考你背题型,而是考察你是否理解数据结构的“为什么”。所以你在刷这三题时,别只追求 AC 一次就完,要把“为什么用这个数据结构”“为什么这个复杂度更优”这些底层逻辑想通。
4.3 我实际刷完这一天的体会
说实话,第一次刷 239 题的时候,我盯着单调队列的代码看了很久才想通:“队列里弹出的元素,为什么不会影响最终结果?”后来我用纸笔把整个数组的滑动过程画了一遍,才真正理解里面每个元素“出生、进入队列、被更大的元素淘汰、被窗口挤出”的生命周期。这一步是值得的,因为理解生命周期之后,再遇到任何变体你都不会慌。
还有一点,训练营里的题往往都有很多题解从不同的语言角度来写,我会建议你至少用两种语言把这三题各写一遍。我就是先用 Python 快速 AC,再用 Java 写一遍,在这个过程中才发现“Python 的 // 和 Java 的整数除法在负数场景下行为不同”,这种细节是刷一遍很难体会到的。
5. 刷题方法之外:算法题和真实代码之间的那道坎
5.1 这三题最容易“背模板背出问题”的地方
训练营学员经常有这种情况:把三题的模板记熟了,但稍微变一下就不会做了。有一个原因是他们只背了代码,没记适用条件。这里我把自己踩过的坎整理一下:
- 逆波兰表达式要区分“当前 token 是不是运算符”,不要用
isalpha()这类方法去判断。因为负数数字里也可能有-符号,比如["-2", "3", "*"],如果你遇到token[0] == "-"就当运算符,绝对会出错。最稳妥的是维护一个运算符集合,用in判断。 - 滑动窗口最大值里,判断队列是否非空再访问
q[0]。我在写的时候有时会漏掉if q的空值判断,直接访问队头,结果在窗口刚初始化时崩溃。 - 前 K 个高频元素里,堆里保存的是元组,不要临时把元组改成“先压元素后压频率”,否则排序规则完全错乱。
这些坑看着小,但每一道都能让你在评论区和 debug 过程里浪费半小时。如果能提前知道,至少能省下一些无谓的挣扎。
5.2 从这三题提炼出的实用刷题方法
最后,根据这三题,我总结了几条实用的刷题经验,特别适合训练营这种“一天三道题”的节奏:
第一,用“复杂度目标”反推数据结构。如果题目要求在 O(n) 时间内完成、需要维护窗口最值,首先考虑单调队列;如果是 TopK,要求 O(n log k) 甚至 O(n),考虑堆 / 快速选择。如果没要求,可以直接用排序先过一遍,然后再优化。
第二,一定要手写例子推演。不要一上来就敲代码。拿纸笔写一个 5-7 个元素的数组,模拟算法的每一步,尤其注意“边界时刻”。比如滑动窗口第一轮窗口刚满、窗口即将滑出最后一个元素,这些都是最容易写错的地方。
第三,把代码按“步骤”切成块,每个块尽量保证只做一件事。像 347 题的“统计频率”“维护堆”“输出结果”三个块,面试中如果让你讲思路,你可以按块讲;如果某个块写错了,也能快速定位而不会整个重来。
我经常说,算法训练营的价值不是让你“过完这些题”,而是给你一套稳定的、可复用的解题框架。第 11 天这三题,就是“栈 / 队列 / 堆”这三种结构从入门到实战的转折点。如果你能把单调队列和 TopK 的思路真正吃透,后面做很多中等难度题都会顺利得多。
