☰
栈与队列:数据结构基础与经典算法题解析
2026/10/27 23:16:44 网站建设 项目流程

1. 项目概述

作为一名参加过多次算法训练营的老学员,我清楚地记得第一次面对栈和队列相关题目时的困惑。这次参加代码随想录算法训练营的二周目,让我有机会重新梳理这些基础但至关重要的数据结构知识。本篇将详细记录第十天的学习内容,包括理论基础和四道经典题目(232.用栈实现队列、225.用队列实现栈、20.有效的括号、1047.删除字符串中的所有相邻重复项)的解题思路和实现细节。

栈和队列是算法面试中的常客,看似简单却暗藏玄机。很多同学在面试中遇到"用栈实现队列"这类题目时,虽然知道要用两个栈,但总是写不出bug-free的代码。通过这次系统性的训练,我总结出了一套可复用的解题模板和调试技巧。

2. 理论基础精讲

2.1 栈与队列的核心特性

栈(Stack)是一种后进先出(LIFO)的数据结构,只允许在一端(称为栈顶)进行插入和删除操作。它的核心操作包括:

  • push:将元素压入栈顶
  • pop:弹出栈顶元素
  • peek:查看栈顶元素但不弹出
  • isEmpty:判断栈是否为空

队列(Queue)则是先进先出(FIFO)的数据结构,允许在一端(队尾)插入,在另一端(队头)删除。核心操作包括:

  • offer/enqueue:元素入队
  • poll/dequeue:元素出队
  • peek:查看队头元素
  • isEmpty:判断队列是否为空

关键理解:栈和队列的根本区别在于元素的出入顺序。栈像是叠盘子,最后放上去的盘子最先被拿走;队列像是排队,先来的人先接受服务。

2.2 实现方式对比

在Java中,Stack是一个类,而Queue是一个接口。常见的实现方式:

数据结构Java实现类时间复杂度
栈Stackpush/pop/peek: O(1)
队列LinkedList/ArrayDequeoffer/poll/peek: O(1)

C++中则使用stack和queue模板类:

#include <stack> #include <queue> std::stack<int> s; std::queue<int> q;

Python中使用list实现栈,collections.deque实现队列:

stack = [] queue = collections.deque()

3. 题目精解与实现

3.1 232. 用栈实现队列

3.1.1 解题思路

这道题要求使用栈的FILO特性来实现队列的FIFO特性。核心思路是使用两个栈:

  • 输入栈(inStack):负责接收push操作的新元素
  • 输出栈(outStack):负责pop和peek操作

当需要pop或peek时:

  1. 如果outStack为空,将inStack的所有元素依次弹出并压入outStack
  2. 此时outStack的栈顶元素就是队列的队头元素

这样操作后,元素的顺序就被反转了两次(inStack反转一次,outStack再反转一次),相当于保持了原始顺序。

3.1.2 代码实现
class MyQueue { private Stack<Integer> inStack; private Stack<Integer> outStack; public MyQueue() { inStack = new Stack<>(); outStack = new Stack<>(); } public void push(int x) { inStack.push(x); } public int pop() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.pop(); } public int peek() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.peek(); } public boolean empty() { return inStack.isEmpty() && outStack.isEmpty(); } }
3.1.3 复杂度分析
  • 时间复杂度:
    • push:O(1)
    • pop/peek:均摊O(1)(每个元素最多被压入和弹出各两次)
  • 空间复杂度:O(n),需要两个栈存储所有元素

注意事项:在面试中写这道题时,一定要先解释清楚为什么均摊时间复杂度是O(1)。可以类比银行排队场景:虽然偶尔需要把整个队伍转移到另一个窗口(O(n)操作),但每个客户平均只被转移一次。

3.2 225. 用队列实现栈

3.2.1 解题思路

这道题与232题相反,需要用队列的FIFO特性实现栈的FILO特性。有两种主要方法:

  1. 双队列法(push为O(1),pop为O(n)):

    • 主队列存储元素,辅助队列用于倒腾
    • push时直接入队主队列
    • pop时将主队列的前n-1个元素转移到辅助队列,剩下最后一个元素就是栈顶
  2. 单队列法(push为O(n),pop为O(1)):

    • 每次push新元素后,将队列中已有的元素依次出队再入队,使新元素位于队头
    • pop时直接出队即可
3.2.2 代码实现(双队列法)
from collections import deque class MyStack: def __init__(self): self.main_queue = deque() self.helper_queue = deque() def push(self, x: int) -> None: self.main_queue.append(x) def pop(self) -> int: while len(self.main_queue) > 1: self.helper_queue.append(self.main_queue.popleft()) res = self.main_queue.popleft() self.main_queue, self.helper_queue = self.helper_queue, self.main_queue return res def top(self) -> int: return self.main_queue[-1] if self.main_queue else None def empty(self) -> bool: return not self.main_queue
3.2.3 复杂度分析
  • 时间复杂度:
    • push:O(1)
    • pop/top:O(n)
  • 空间复杂度:O(n),需要两个队列存储元素

实战技巧:在Python中,使用deque的[-1]可以直接访问队尾元素,这使得top操作可以在O(1)时间内完成。这是Python特有的优化,在其他语言中可能需要完整遍历队列。

3.3 20. 有效的括号

3.3.1 解题思路

这是栈的经典应用场景。算法步骤:

  1. 初始化一个空栈
  2. 遍历字符串中的每个字符:
    • 如果是左括号('('、'['、'{'),将其压入栈
    • 如果是右括号,检查栈顶元素是否匹配:
      • 匹配则弹出栈顶
      • 不匹配则直接返回false
  3. 最后检查栈是否为空:
    • 空栈说明所有括号都匹配
    • 非空栈说明有未匹配的左括号
3.3.2 代码实现
class Solution { public: bool isValid(string s) { stack<char> st; unordered_map<char, char> pairs = { {')', '('}, {']', '['}, {'}', '{'} }; for (char c : s) { if (pairs.count(c)) { // 是右括号 if (st.empty() || st.top() != pairs[c]) { return false; } st.pop(); } else { // 是左括号 st.push(c); } } return st.empty(); } };
3.3.3 复杂度分析
  • 时间复杂度:O(n),只需遍历一次字符串
  • 空间复杂度:O(n),最坏情况下需要存储所有左括号

常见错误:只考虑括号数量匹配而忽略顺序。例如"([)]"虽然每种括号数量匹配,但顺序不正确,应该返回false。使用栈结构可以完美解决顺序问题。

3.4 1047. 删除字符串中的所有相邻重复项

3.4.1 解题思路

这道题可以看作是"有效的括号"的变种,同样使用栈来解决:

  1. 初始化空栈
  2. 遍历字符串:
    • 如果栈不为空且当前字符等于栈顶字符,弹出栈顶(表示消除这对重复项)
    • 否则将当前字符压入栈
  3. 最后将栈中字符拼接成字符串返回
3.4.2 代码实现
class Solution { public String removeDuplicates(String s) { StringBuilder stack = new StringBuilder(); for (char c : s.toCharArray()) { int len = stack.length(); if (len > 0 && stack.charAt(len - 1) == c) { stack.deleteCharAt(len - 1); } else { stack.append(c); } } return stack.toString(); } }
3.4.3 复杂度分析
  • 时间复杂度:O(n),每个字符最多被处理两次(压入和弹出)
  • 空间复杂度:O(n),最坏情况下需要存储整个字符串

优化技巧:使用StringBuilder模拟栈操作,避免了频繁的栈对象创建和销毁,在Java中性能更好。类似的,在Python中可以直接使用list,C++可以使用string作为栈。

4. 常见问题与调试技巧

4.1 边界条件处理

在实现这些数据结构题目时,最容易出错的就是边界条件的处理。以下是一些常见边界情况:

  1. 空输入:所有操作都应该能正确处理空栈/空队列的情况
  2. 连续操作:如连续pop或peek时,确保内部状态正确
  3. 大小边界:特别是用数组实现时,要考虑扩容和缩容

4.2 调试技巧

  1. 可视化跟踪:在纸上画出栈和队列的变化过程
  2. 状态打印:在关键操作后打印内部数据结构的状态
  3. 单元测试:针对每个方法编写边界测试用例

4.3 面试常见问题

面试官可能会追问:

  • 为什么这种方法的时间复杂度是均摊O(1)?
  • 如何进一步优化空间使用?
  • 如果要求线程安全,该如何修改实现?

5. 扩展思考

5.1 实际应用场景

  1. 栈的应用:

    • 函数调用栈
    • 浏览器前进后退
    • 撤销操作(Undo)
    • 表达式求值
  2. 队列的应用:

    • 消息队列
    • 打印任务队列
    • 广度优先搜索(BFS)
    • 缓存实现

5.2 变种数据结构

  1. 双端队列(Deque):两端都可以进行插入和删除
  2. 优先队列(PriorityQueue):元素按优先级出队
  3. 单调栈/队列:维护栈/队列中元素的单调性

5.3 性能优化方向

  1. 延迟转移:在用栈实现队列时,可以等到必须转移时才进行
  2. 批量操作:适当合并操作可以减少数据移动次数
  3. 空间复用:合理设计可以节省一个栈/队列的空间

通过这次系统的训练,我对栈和队列的理解更加深入了。最大的收获是认识到:看似简单的数据结构,通过巧妙的组合和变形,可以解决各种复杂问题。建议大家在掌握这些基础题目后,尝试解决一些更复杂的变种问题,如"155.最小栈"、"239.滑动窗口最大值"等,进一步巩固这些技术。

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

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

立即咨询