1. 数据结构基础:栈与队列的本质区别
在开始解决这组LeetCode题目之前,我们需要先明确栈(Stack)和队列(Queue)这两种基础数据结构的核心特性。很多初学者在实际编码时容易混淆二者的操作规则,导致解题思路出现偏差。
1.1 栈:后进先出的数据容器
栈是一种LIFO(Last In First Out)结构,就像我们日常生活中叠放的盘子:
- 你只能从最顶部放入新盘子(push操作)
- 也只能从最顶部取出盘子(pop操作)
- 无法直接访问中间或底部的元素
C++ STL中栈的典型用法:
cpp复制#include <stack>
std::stack<int> s;
s.push(1); // 栈:[1]
s.push(2); // 栈:[1,2]
int top = s.top(); // 获取2但不移除
s.pop(); // 移除2
1.2 队列:先进先出的数据管道
队列则是FIFO(First In First Out)结构,类似于排队买票的队伍:
- 新来的人排在队尾(enqueue/push操作)
- 总是队首的人先获得服务(dequeue/pop操作)
- 无法直接插队或访问中间的人
C++ STL中队列的基本操作:
cpp复制#include <queue>
std::queue<int> q;
q.push(1); // 队列:[1]
q.push(2); // 队列:[1,2]
int front = q.front(); // 获取1但不移除
q.pop(); // 移除1
1.3 关键差异对比表
| 特性 | 栈(Stack) | 队列(Queue) |
|---|---|---|
| 操作端 | 仅顶部 | 头部出/尾部进 |
| 移除顺序 | 后进先出(LIFO) | 先进先出(FIFO) |
| 典型操作 | push/pop/top | push/pop/front/back |
| 时间复杂度 | 所有操作O(1) | 所有操作O(1) |
| 应用场景 | 函数调用、括号匹配 | 消息队列、BFS遍历 |
提示:在C++中,stack和queue默认基于deque实现,但也可以指定其他底层容器如list。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用栈实现队列(LeetCode 232)
这是数据结构相互实现的经典问题,要求仅使用栈的操作来模拟队列的所有功能。
2.1 双栈法的设计思路
核心观察:栈的反转特性。当把元素依次压入栈后再全部弹出,得到的顺序就是原始顺序的逆序。利用两个栈可以完成两次反转,从而恢复原始顺序。
具体实现方案:
- 使用两个栈:inputStack和outputStack
- 入队操作:直接push到inputStack
- 出队操作:
- 如果outputStack为空,将inputStack的所有元素弹出并压入outputStack
- 然后从outputStack弹出栈顶元素
- 查看队首:与出队类似,但不移除元素
2.2 完整C++实现代码
cpp复制class MyQueue {
private:
stack<int> inStack, outStack;
void in2out() {
while (!inStack.empty()) {
outStack.push(inStack.top());
inStack.pop();
}
}
public:
MyQueue() {}
void push(int x) {
inStack.push(x);
}
int pop() {
if (outStack.empty()) {
in2out();
}
int x = outStack.top();
outStack.pop();
return x;
}
int peek() {
if (outStack.empty()) {
in2out();
}
return outStack.top();
}
bool empty() {
return inStack.empty() && outStack.empty();
}
};
2.3 复杂度分析与优化点
时间复杂度:
- 摊还分析下,每个操作的时间复杂度为O(1)
- 最坏情况下(当outputStack为空时),pop和peek需要O(n)时间转移元素
空间复杂度:O(n),需要两个栈存储所有元素
实际工程中的优化:可以设置一个阈值,当inputStack元素超过一定数量时才进行转移,减少频繁的小规模数据移动。
3. 用队列实现栈(LeetCode 225)
与前一题相反,这次需要用队列的操作来模拟栈的行为。
3.1 单队列的旋转法
虽然可以使用两个队列实现,但单队列方案更为简洁高效。核心思想是通过队列的旋转操作,使得最新入队的元素始终保持在队首。
操作步骤:
- 入栈(push):
- 将新元素入队
- 将队列中除新元素外的所有元素依次出队再入队
- 出栈(pop):直接出队队首元素
- 查看栈顶(top):返回队首元素
3.2 C++实现代码
cpp复制class MyStack {
private:
queue<int> q;
public:
MyStack() {}
void push(int x) {
int n = q.size();
q.push(x);
for (int i = 0; i < n; ++i) {
q.push(q.front());
q.pop();
}
}
int pop() {
int x = q.front();
q.pop();
return x;
}
int top() {
return q.front();
}
bool empty() {
return q.empty();
}
};
3.3 复杂度与实现对比
时间复杂度:
- push操作:O(n),因为需要旋转n个元素
- pop/top操作:O(1)
空间复杂度:O(n),只需要一个队列存储元素
与双队列方案对比:
- 双队列方案中,push操作可以是O(1),但pop操作需要O(n)时间转移元素
- 单队列方案代码更简洁,适合元素数量不大的场景
4. 有效的括号(LeetCode 20)
这是栈的经典应用场景,用于检查字符串中的括号是否匹配。
4.1 栈匹配算法详解
算法步骤:
- 初始化一个空栈
- 遍历字符串中的每个字符:
- 如果是左括号('(', '{', '['),压入栈中
- 如果是右括号,检查栈顶元素是否匹配:
- 栈为空或不匹配 → 返回false
- 匹配 → 弹出栈顶元素
- 最后检查栈是否为空
4.2 边界情况处理
需要考虑的特殊情况:
- 空字符串(应返回true)
- 只有左括号或只有右括号
- 括号嵌套顺序错误
- 字符串中包含非括号字符(题目通常保证输入有效性)
4.3 优化后的C++实现
cpp复制class Solution {
public:
bool isValid(string s) {
stack<char> st;
for (char c : s) {
if (c == '(' || c == '{' || c == '[') {
st.push(c);
} else {
if (st.empty()) return false;
char top = st.top();
if ((c == ')' && top != '(') ||
(c == '}' && top != '{') ||
(c == ']' && top != '[')) {
return false;
}
st.pop();
}
}
return st.empty();
}
};
4.4 扩展:支持更多括号类型
如果需要支持更多类型的括号匹配,可以使用哈希表来维护配对关系:
cpp复制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();
}
5. 删除字符串中所有相邻重复项(LeetCode 1047)
这个问题展示了栈在字符串处理中的巧妙应用。
5.1 栈解法与双指针对比
栈解法直观且易于实现:
- 初始化空栈
- 遍历字符串:
- 如果栈不为空且当前字符等于栈顶字符 → 弹出栈顶
- 否则 → 压入当前字符
- 最后将栈中字符组成结果字符串
与双指针法的比较:
- 栈解法空间复杂度O(n),但代码更简洁
- 双指针法可以实现O(1)空间(原地修改),但实现稍复杂
5.2 C++栈实现
cpp复制class Solution {
public:
string removeDuplicates(string s) {
string stack;
for (char c : s) {
if (!stack.empty() && stack.back() == c) {
stack.pop_back();
} else {
stack.push_back(c);
}
}
return stack;
}
};
5.3 性能优化技巧
- 预分配内存:根据输入字符串长度预先分配足够空间,避免多次扩容
cpp复制stack.reserve(s.size()); - 使用字符串模拟栈:直接使用string的push_back和pop_back操作,比stack
更高效 - 对于特别长的字符串,可以考虑分块处理+合并检查的优化策略
6. 逆波兰表达式求值(LeetCode 150)
逆波兰表达式(后缀表达式)是栈应用的又一典型案例,特别适合计算机求值。
6.1 逆波兰表达式基础
特点:
- 操作符位于两个操作数之后
- 不需要括号来指定运算优先级
- 计算顺序明确,适合栈处理
示例:
- 中缀:2 + 3 * 5
- 后缀:2 3 5 * +
6.2 栈求值算法步骤
- 初始化空栈
- 遍历表达式:
- 遇到数字 → 压栈
- 遇到运算符 → 弹出栈顶两个元素,计算后将结果压栈
- 最后栈中剩余的一个元素即为结果
6.3 完整实现与边界处理
cpp复制class Solution {
public:
int evalRPN(vector<string>& [token](https://taotoken.net?utm_source=general)s) {
stack<int> st;
for (const string& token : tokens) {
if (token == "+" || token == "-" || token == "*" || token == "/") {
int b = st.top(); st.pop();
int a = st.top(); st.pop();
if (token == "+") st.push(a + b);
else if (token == "-") st.push(a - b);
else if (token == "*") st.push(a * b);
else st.push(a / b);
} else {
st.push(stoi(token));
}
}
return st.top();
}
};
6.4 常见错误与防御性编程
- 除零检查:
cpp复制else if (token == "/") { if (b == 0) throw runtime_error("Division by zero"); st.push(a / b); } - 操作数不足检查:
cpp复制if (st.size() < 2) { throw runtime_error("Insufficient operands"); } - 无效token处理:
cpp复制try { st.push(stoi(token)); } catch (...) { throw runtime_error("Invalid token"); }
7. 综合应用与进阶思考
7.1 数据结构选择的艺术
在实际工程中,选择栈还是队列取决于问题特性:
- 需要"撤销"功能 → 栈
- 需要保持顺序处理 → 队列
- 混合场景 → 可能需要两者结合
例如,浏览器历史记录通常使用栈实现后退/前进,而打印任务则使用队列管理。
7.2 内存管理中的栈与队列
在系统层面:
- 函数调用栈管理函数调用和返回地址
- 消息队列用于进程间通信
- 内存分配中栈区(自动管理)和堆区(手动管理)的区别
7.3 并发环境下的特殊变体
在多线程编程中:
- 需要线程安全的阻塞队列
- 无锁队列的实现挑战
- 栈在递归算法中的应用与限制
7.4 从LeetCode到工程实践
虽然这些题目看起来简单,但它们体现了计算机科学的核心思想:
- 抽象数据类型的重要性
- 通过基本构建块组合解决复杂问题
- 算法与数据结构的正交性
在实际项目中,我们经常需要根据需求灵活组合这些基础结构,甚至设计新的变体。例如,一个网络爬虫可能同时使用:
- 队列管理待抓取URL
- 栈处理深度优先的抓取路径
- 哈希集合记录已访问站点
掌握这些基础结构的本质和实现原理,是成为优秀程序员的关键一步。
