☰
栈、队列、双向队列:算法刷题必会的三大基础数据结构
2026/10/13 3:48:39 网站建设 项目流程

1. 为什么算法入门都得先啃栈、队列、双向队列

很多人刚开始刷 LeetCode 的时候,上来就纠结:我是不是该先学动态规划?还是先啃图论?其实我见过太多人绕了一圈又回来补基础结构的课。栈、队列、双向队列这三样,几乎可以说是算法题里的"基础设施",它们本身不难,但几乎所有高频题——括号匹配、层次遍历、滑动窗口、单调栈、缓存淘汰——底层都是这三样在支撑。你要是连它们的特性和时间复杂度都没形成肌肉记忆,后面看任何中等难度的题都会觉得"看不懂题解在干嘛"。

这篇内容就是我实际刷题过程中的一份整理,核心围绕三个结构展开:栈(Stack)、队列(Queue)、双向队列(Deque)。每个结构我都会先讲它到底解决什么问题,再直接上 LeetCode 真题拆解,包括代码、时间复杂度和我自己踩过的坑。适合的人群很明确:刚入门算法、准备面试、或者已经刷了一些题但觉得"背了题解也不会举一反三"的人。不夸张地说,把这三样吃透,LeetCode 上简单到中等难度的题你至少能独立拿下三四成。

先说一个我反复强调的观点:数据结构不是孤立的知识点,它背后是一种"约束"——栈约束你只能从一端进出,队列约束你先进先出,双向队列约束你两头都能操作。正是这些约束,决定了它们各自能优雅地解决哪一类问题。理解了这个,你就不需要背题,而是遇到问题自然知道该选哪个容器。

2. 栈:后进先出的"撤销栈"

2.1 栈到底在模拟什么场景

栈最形象的理解就是"一摞盘子":你最后放上去的盘子,永远是最先被拿走的。计算机里函数调用就是典型的栈结构——函数A调用函数B,B调用C,返回的时候永远是先返回C,再返回B,最后回到A。所以栈天然适合处理"嵌套""逆序""最近相关性"这类问题。

LeetCode 里栈的出场率极高,我粗略统计过,前 300 道热门题里直接用栈的至少有 30 道以上。它的核心操作只有四个:push(压栈)、pop(出栈)、peek/top(看栈顶)、isEmpty(判空)。在 Python 里直接用 list 就能模拟:append 和 pop 都是在尾部操作,时间复杂度 O(1),这就是一个标准的栈。

很多入门者会有一个疑问:栈这么简单,为什么题还能出得很难?答案是,栈的难点不在于操作,而在于你什么时候决定用栈、栈里存什么。后面我会用三道题来拆解这个"什么时候用"的问题。

2.2 真题拆解:有效的括号(LeetCode 20)

这道题是栈的"入门第一题",几乎所有刷题的人都会遇到。题目大意:给一个只包含( ) { } [ ]的字符串,判断括号是否有效。有效定义是:左括号必须用相同类型的右括号闭合,且必须按正确的顺序闭合。

我第一次做这道题的时候,想的是用计数器:遇到(加一,遇到)减一。结果发现一遇到[()这种混搭就崩了。原因很简单,计数器只能跟踪"数量",不能跟踪"类型"和"顺序"。而括号嵌套本质上是"最近的左括号必须最先被匹配"——这不就是栈的特性吗?

正确的思路是:

def isValid(s: str) -> bool: stack = [] mapping = {')': '(', ']': '[', '}': '{'} for char in s: if char in mapping: # 遇到右括号,必须和栈顶匹配 if not stack or stack.pop() != mapping[char]: return False else: # 左括号直接入栈 stack.append(char) return not stack # 栈为空表示所有括号都匹配完了

核心逻辑就三句话:遇到左括号压栈;遇到右括号时检查栈顶是否是对应的左括号,是就弹出,不是就直接判定无效;最后栈不为空说明有左括号没被闭合,也是无效。

这里有两个容易忽略的细节。第一个,if not stack or stack.pop() != mapping[char]这个写法里,not stack的判断必须在前面,不然遇到]这种开头的情况会直接对空栈 pop,抛异常。第二个,最后返回not stack而不是直接return True,因为输入可能是"((("这种只有左括号的情况。

我实际刷题时还遇到一个很实用的变体:如果是判断 XML 标签、HTML 标签的闭合,或者带优先级的符号匹配,核心思路完全一致,只是 mapping 要扩展。这题的复杂度是 O(n),因为每个字符最多入栈出栈一次,空间复杂度也是 O(n)。

2.3 进阶用法:单调栈与每日温度(LeetCode 739)

括号匹配算栈的入门用法,单调栈才是栈真正体现"高效"的地方。什么叫单调栈?就是栈里的元素从栈底到栈顶保持单调递增或递减。它最经典的应用是:在一组数据里,快速找到每个元素右边(或左边)第一个比它大(或小)的元素,时间复杂度能压到 O(n)。

LeetCode 739 每日温度就是标准的单调栈题。题目:给一个每日温度的数组,要求返回一个数组,每个位置表示"要等几天才能等到更高的温度",如果没有更高的温度就存 0。

暴力解法很简单,两层循环:对每个 i,往后找第一个比temperatures[i]大的位置 j,存入j - i。但最坏情况是 O(n²),在数组长度 10 万时直接超时。单调栈的思路是:维护一个递减栈,栈里存的是下标而不是温度本身,当遇到一个比栈顶温度更高的日子时,就说明栈顶那天的"下一个更高温度"就是今天。

def dailyTemperatures(temperatures: List[int]) -> List[int]: n = len(temperatures) ans = [0] * n stack = [] # 存下标,栈底到栈顶温度递减 for i, temp in enumerate(temperatures): while stack and temperatures[stack[-1]] < temp: prev = stack.pop() ans[prev] = i - prev stack.append(i) return ans

为什么栈里存下标而不是温度?因为我们要计算天数差,存下标才能知道距离。为什么当temperatures[stack[-1]] < temp时要一直弹出?因为当前这个温度对于栈里所有比它低的温度来说,都是"右边第一个更高的温度",它们一个个弹出、结算,正好符合 "后进先出" 的顺序。

这个"延迟结算"的思路是单调栈的精髓:你不是在遇到某个元素时立刻去找它的答案,而是把待处理的元素放进栈里,等到"答案出现的那一刻"再统一结算。用生活类比就是:你晾着几件衣服,哪件先干不知道,突然下雨了(来了一个更大的温度),所有还没干的衣服都得收回来(弹出结算),新衣服(当前温度)暂时挂上去。

单调栈的变体非常多:接雨水(LeetCode 42)、柱状图中最大的矩形(LeetCode 84)、每日温度这些都是同一个套路。我的建议是,做完 739 之后不要急着往下做,自己手动跑一遍[73, 74, 75, 71, 69, 72, 76, 73]的例子,把每一轮循环里栈的变化画出来,跑两遍就通了。

2.4 栈的常见坑点

栈的题看着简单,但有几个坑我在实际刷题和帮别人 review 代码时反复见到。

第一个坑是用 pop 之前忘了判空。特别是涉及多个栈的场景,一个栈弹空了你还在 pop,直接抛异常。健壮写法是stack and stack.pop(),或者先if not stack: 处理。

第二个坑是栈里存值还是存下标分不清。像每日温度这种需要计算"距离"的题,必须存下标;像有效的括号这种只需要匹配值的题,存值就够了。我见过有人硬把下标存进去,最后取值还要套一层temperatures[stack[-1]],逻辑复杂度翻倍。

第三个坑是对"单调"的方向判断错误。找右边第一个更大的元素用递减栈,找右边第一个更小的元素用递增栈,方向反了就全错。我的记忆方法是:栈顶元素是"最需要被结算的",如果新来的元素可以"干掉"栈顶,那这个栈的单调方向就是被弹出那一侧的方向。多做两题自然就记住了。

3. 队列:先进先出的"排队窗口"

3.1 队列存在的意义

队列就是现实里的排队:先来的人先办业务。操作核心就是两个:enqueue(入队,从尾部进)和 dequeue(出队,从头部出)。Python 里用collections.deque是最合适的,因为用 list 在头部 pop(0) 是 O(n) 时间,数据量一大就卡。

队列在算法里最核心的应用场景有两个。第一个是广度优先遍历(BFS)——树的层序遍历、图的求最短路径,全靠队列一层一层往外扩。第二个是生产者-消费者模型的解耦——数据从一个端进来,从另一个端被处理,天然形成缓冲区。

很多入门教程会直接把队列和 BFS 绑在一起讲,但我想先强调一个更基础的能力:队列可以用来"反转顺序"。怎么理解?栈是后进先出,所以把一个序列依次入栈再依次出栈,顺序就反过来了。队列是先进先出,所以需要另一个结构配合才能反转顺序。这个"两个结构互相配合"的思路,就是 LeetCode 232 的核心考点。

3.2 真题拆解:用栈实现队列(LeetCode 232)

题目要求:只用两个栈,实现一个队列的push、pop、peek、empty操作。

刚看到这题的人可能会懵:栈是后进先出,队列是先进先出,这俩天生矛盾,怎么可能模拟?答案是:用两个栈,一个只负责"入队",一个只负责"出队"。入队时直接压入输入栈in_stack;出队时,如果输出栈out_stack为空,就把输入栈的所有元素倒进输出栈——这一倒,顺序就反过来了,刚好变成队列的顺序。

class MyQueue: def __init__(self): self.in_stack = [] self.out_stack = [] def push(self, x: int) -> None: self.in_stack.append(x) def pop(self) -> int: self.peek() return self.out_stack.pop() def peek(self) -> int: # 输出栈为空时,才从输入栈倒数据 if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack[-1] def empty(self) -> bool: return not self.in_stack and not self.out_stack

这里最关键的设计是:倒数据的时机。我在第一次实现时犯过一个错,在 push 的时候就把数据全部倒到 out_stack,结果连续 push 两次再 pop 一次,顺序就乱了。正确做法是懒加载:只有当 out_stack 空了、需要 pop 或 peek 时才触发倒数据。这样每个元素最多被 push 两次、pop 两次,均摊下来每个操作还是 O(1)。

这个"两个栈倒来倒去"的思路,我在面试时被追问过一个变体:用两个栈实现一个能支持get_min的最小队列,或者用队列实现栈(LeetCode 225)。LeetCode 225 的思路是完全对称的:用两个队列,出栈时把前面的元素全部搬到辅助队列,只剩最后一个,弹出它,然后再把辅助队列的元素搬回来。虽然时间复杂度是 O(n),但对于理解"结构间的互补关系"非常有帮助。

3.3 真题拆解:设计循环队列(LeetCode 622)

如果说 232 考的是"逻辑设计",那 622 考的就是"底层实现"。循环队列是面试里非常喜欢考的设计题,它也是操作系统里环形缓冲区、消息队列中间件里常见的数据结构。

题目要求设计一个循环队列,支持enQueue、deQueue、Front、Rear、isEmpty、isFull,容量固定。

循环队列的核心是复用已弹出元素的空间。普通队列用数组实现时,每 pop 一个元素,队头指针往后移,队头之前的空间就浪费了。循环队列通过取模运算(tail + 1) % capacity让指针绕回数组开头,实现"环形"复用。

class MyCircularQueue: def __init__(self, k: int): self.capacity = k self.data = [0] * k self.head = 0 self.tail = -1 # tail 指向最后一个元素 self.size = 0 # 当前元素个数 def enQueue(self, value: int) -> bool: if self.isFull(): return False self.tail = (self.tail + 1) % self.capacity self.data[self.tail] = value self.size += 1 return True def deQueue(self) -> bool: if self.isEmpty(): return False self.head = (self.head + 1) % self.capacity self.size -= 1 return True def Front(self) -> int: return -1 if self.isEmpty() else self.data[self.head] def Rear(self) -> int: return -1 if self.isEmpty() else self.data[self.tail] def isEmpty(self) -> bool: return self.size == 0 def isFull(self) -> bool: return self.size == self.capacity

这个实现里我额外维护了size变量,这是有讲究的。如果你只用head和tail判断队列是否为空或满,会遇到一个经典问题:空队列和满队列时 head 和 tail 的相对位置可能是一样的,区分不开。有的教科书方案是"牺牲一个存储单元",让tail + 1 == head表示满,这样就不用 size。但实际工程里我更喜欢维护 size,逻辑更直观,也不浪费空间。

还有一个细节很多人会搞混:初始时tail = -1而不是 0,因为队列为空时 tail 应该指向"最后一个元素的前一个位置"。第一次 enQueue 时(-1 + 1) % capacity = 0,正确落在下标 0。如果你初始设置tail = 0,第一个元素就会放在下标 1,头尾对不上。

4. 双向队列:两头都能操作的"多面手"

4.1 双向队列的独特位置

双向队列(Deque,Double-Ended Queue)是栈和队列的"合体升级版":头部尾部都能在 O(1) 时间内插入和删除。Python 里的collections.deque就是它的标准实现,底层是用双向链表或者环形数组实现的,所以appendleft、popleft、append、pop四个操作全是 O(1)。

有人会问:既然栈和队列够用了,为什么还需要双向队列?我的回答是:它最核心的价值在于能同时维护一个窗口的"两端信息"。最典型的场景就是滑动窗口问题:窗口左端要不断淘汰过期元素,右端要不断加入新元素,同时你还想快速拿到窗口内的最大值或最小值——如果你只用栈或者队列,做不到"两头同时高效操作加内部有序"。

举个生活化的例子:你是一个饭店里同时管"取号"和"叫号"的服务员,普通队列只能让人从队尾加入、队头离开;但有时候门口大爷想插队到队头加个号,或者队头的人等不及走了,你就需要能从两头操作的能力。双向队列就是这个"两边都能动的队"。

4.2 真题拆解:滑动窗口最大值(LeetCode 239)

这是双向队列最经典的高频题,也是我觉得"做一遍就通"的题目。题目:给一个数组nums和一个窗口大小k,窗口从左往右滑动,每次移动一格,返回每个窗口内 k 个元素的最大值。

暴力解法是每滑动一次就在窗口内扫一遍找最大值,时间复杂度 O(n·k),在数据量大时直接超时。最优解就是用双向队列维护一个"递减队列"——队列里存的是下标,且对应的值从队头到队尾严格递减。这样队头永远指向当前窗口的最大值。

def maxSlidingWindow(nums: List[int], k: int) -> List[int]: from collections import deque dq = deque() # 存下标,对应值单调递减 result = [] for i, num in enumerate(nums): # 1. 移除窗口外的元素 if dq and dq[0] <= i - k: dq.popleft() # 2. 维护单调递减:把队尾所有小于当前值的下标弹出 while dq and nums[dq[-1]] < num: dq.pop() # 3. 当前下标入队 dq.append(i) # 4. 窗口形成后才开始记录结果 if i >= k - 1: result.append(nums[dq[0]]) return result

这段代码每一步都值得细看。第一步移除过期元素,用的是dq[0] <= i - k,意思是窗口最右边的位置是 i,窗口最左边是 i - k + 1,如果队头下标已经小于等于 i - k,说明它已经滑出窗口了,直接 popleft。第二步维护单调性是核心:新来的元素如果比队尾大,那队尾那个元素"永远不可能成为任何后续窗口的最大值",因为新元素更大且更晚过期,留着它毫无用处,所以直接弹出。这就是"淘汰无用数据"的思路。

我在做这题时最大的收获是认识到:单调双端队列的本质是维护一个候选集。窗口内不是所有元素都有资格成为最大值,只有那些"值大且位置靠后"的元素才值得保留。每次滑动,pop 掉没资格的元素、popleft 掉过期的元素、append 新元素,三步走完,整个队列始终保持整洁,队头就是答案。

这题还有一个面试官常问的拓展:如果窗口大小是动态变化的,或者要同时维护最大值和最小值,怎么办?答案是维护两个双端队列,一个单调递减存最大值候选,一个单调递增存最小值候选,思路完全一样。时间复杂度 O(n),每个下标最多入队出队一次,这是这类题的最优解。

4.3 Python 的 deque 使用建议

collections.deque虽然好用,但在 LeetCode 上有几个值得注意的细节。第一个,deque 的随机访问dq[0]是 O(1),越往中间越慢,所以"只看队头队尾"是它的最佳姿势,千万不要在循环里对 deque 做切片或list(dq)这种操作。第二个,如果题目要求同时记录"值"和"位置",优先存下标,通过nums[dq[i]]取值,这样代码更简洁。第三个,偶尔会遇到 LeetCode 在特殊用例下 deque 性能略慢的情况,如果题目对时间要求极严格,可以试试用两个 list 手写头尾指针,或者直接用list模拟栈配合while循环代替 deque,实测在某些环境能快一些。

5. 常见问题与排查技巧

5.1 栈溢出、超时、答案错误:先看这三件事

我在带新人刷题时发现,栈和队列的题如果报错,十有八九是以下三类问题。

第一类是空容器操作。报错信息是IndexError: pop from empty list或者AttributeError。排查方法很简单:在所有 pop 之前打一个日志,确认当前容器里有什么。养成习惯后你会发现,80% 的 bug 都在于"你假设了容器里有元素,但实际已经空了"。特别是在循环里,边界条件i == 0或i == n-1最容易触发。

第二类是死循环导致超时。常见于 while 循环条件写错,比如单调栈里while stack and nums[stack[-1]] < num写成了while stack and nums[stack[-1]] <= num。这个影响很隐蔽:遇到相等元素时,<=会导致相等的元素被弹出,某些题里会改变答案,某些题里会导致 candidate 数量变化进而引发死循环。排查方法是给循环加一个最大迭代次数,或者用小的测试用例手动推演。

第三类是答案错误,且只在特定长度的用例错。这种通常是下标计算问题。比如滑动窗口最大值里,判断过期用的是<= i - k还是< i - k,就差一个等号。我的技巧是:不要只在脑子里想,拿一个长度恰好为 k+1 的数组手动走一遍,把每次循环里队列的内容写出来,边界自然就清晰了。

5.2 如何高效练习这三类题

很多人刷题有一个误区:上来就按题号顺序刷,今天做一道栈的明天做一道队列的,结构记忆完全碎片化。我推荐的做法是"按结构集训":花一周时间,每天只做栈的题,从简单的括号匹配一路做到接雨水;下一周只做队列和 BFS 的题,从层序遍历做到拓扑排序;最后再集中做双向队列的题。这样训练的好处是,你能清晰感知到"同一个结构在不同难度下的演化路径",而不是每道题都从零开始猜。

我个人用过的训练清单大致是这个顺序:

  • 栈:LeetCode 20 → 155 → 232(反过来实现)→ 739 → 42 → 84
  • 队列:LeetCode 225 → 622 → 102(层序遍历)→ 207(拓扑排序)
  • 双向队列:LeetCode 239 → 862(和为 K 的最短子数组)→ 剑指 Offer 59

一个提醒:862 那题其实是"前缀和 + 单调双端队列"的组合题,新手做不出来很正常,但它能帮你理解"单调队列不只是滑动窗口专用工具,还能解决区间最值相关的问题"。

5.3 面试中的代码风格建议

如果你刷题是为了面试,栈和队列的题在面试里考得比算法本身更多的往往是代码风格。我总结了几条实际反馈最好的习惯:写peak操作时不要直接return stack.pop(),因为这会改变栈状态,面试官一看就知道你没分清"查看"和"取出";用collections.deque时记得from collections import deque写在最顶部;变量名不要用s、q这种单字母含糊命名,in_stack、out_stack、mono_dq这种能自我解释的名字在面试里很加分;最后,写完代码主动把时间复杂度说一遍,最好再说一下均摊复杂度,比如 232 题的最坏情况 O(n) 均摊 O(1),这一个细节就能体现你比"背题者"高一个层次。

6. 最后再分享一个实用技巧

刷到后面你会发现,栈、队列、双向队列之间经常可以互相替代。比如某些题目里双向队列能干的活,用两个栈也能干,只是代码更丑、常数更大。所以我个人的判断标准其实很简单:只要需要"维护一个序列,且只关注它的两端",优先想 deque;只要需要"回退到最近的状态",优先想栈;只要需要"按顺序处理且先到先得",优先想队列。把这个判断流程刻在脑子里,遇到新题你不用犹豫就能选对结构。

还有一个小技巧很实用:在本地调试单调栈或单调队列的题时,别直接打印最终答案,而是在每一轮循环里打印当前的栈内容。比如每日温度题,每次循环后打印stack的内容,你会直观看到"矮的总是被高的弹出去",这个画面感一旦建立,后面再做接雨水、最大矩形,你甚至不需要看题解就能写出框架。算法入门阶段,最重要的不是刷多少题,而是把每个结构的"动态过程"内化成直觉。栈、队列、双向队列就是最好的三块磨刀石。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询