1. 为什么C++程序员必须掌握栈与队列OJ题
在技术面试和算法竞赛中,栈(Stack)和队列(Queue)是最基础也最常考的数据结构。我见过太多候选人因为对这两种结构的理解不够深入,在面试中错失良机。实际上,栈和队列不仅是数据结构的基础,更是理解计算机底层运作机制的关键。
栈遵循LIFO(后进先出)原则,就像我们叠放盘子,最后放上去的盘子会被最先拿走。这种特性使得栈在函数调用、表达式求值、括号匹配等场景中表现优异。而队列遵循FIFO(先进先出)原则,就像排队买票,先来的人先得到服务。队列在广度优先搜索(BFS)、缓存实现、任务调度等方面有着广泛应用。
提示:理解栈和队列的核心在于掌握它们的操作特性——栈只能从顶部插入和删除(push/pop),队列则是一端插入(enqueue)另一端删除(dequeue)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈的经典OJ题解析与实战
2.1 有效的括号(LeetCode 20)
这是栈最典型的应用场景。题目要求判断一个只包含'(',')','{','}','[',']'的字符串是否有效。有效需满足:开括号必须用相同类型的闭括号闭合,且顺序正确。
cpp复制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();
}
关键点:遇到开括号就压栈,遇到闭括号就检查栈顶是否匹配。最后栈必须为空才算完全匹配。
2.2 最小栈(LeetCode 155)
设计一个支持push、pop、top操作,并能在常数时间内检索到最小元素的栈。
cpp复制class MinStack {
private:
stack<int> data_stack;
stack<int> min_stack;
public:
void push(int val) {
data_stack.push(val);
if (min_stack.empty() || val <= min_stack.top()) {
min_stack.push(val);
}
}
void pop() {
if (data_stack.top() == min_stack.top()) {
min_stack.pop();
}
data_stack.pop();
}
int top() {
return data_stack.top();
}
int getMin() {
return min_stack.top();
}
};
这个解法使用了辅助栈来存储最小值。每次push时,如果新值小于等于当前最小值,就同时压入min_stack。pop时,如果弹出的值是当前最小值,则min_stack也弹出。
注意:这里判断val <= min_stack.top()而不是val < min_stack.top(),是为了处理重复最小值的情况。
3. 队列的经典OJ题解析与实战
3.1 用栈实现队列(LeetCode 232)
这道题要求使用栈来实现队列的所有操作(push、pop、peek、empty)。
cpp复制class MyQueue {
private:
stack<int> input, output;
public:
void push(int x) {
input.push(x);
}
int pop() {
if (output.empty()) {
while (!input.empty()) {
output.push(input.top());
input.pop();
}
}
int val = output.top();
output.pop();
return val;
}
int peek() {
if (output.empty()) {
while (!input.empty()) {
output.push(input.top());
input.pop();
}
}
return output.top();
}
bool empty() {
return input.empty() && output.empty();
}
};
关键思路:使用两个栈,一个(input)专门处理push操作,另一个(output)处理pop和peek。当output为空时,将input中的所有元素倒到output中,这样顺序就反转了,实现了FIFO。
3.2 滑动窗口最大值(LeetCode 239)
给定一个数组和滑动窗口的大小,找出所有滑动窗口中的最大值。
cpp复制vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> dq;
vector<int> res;
for (int i = 0; i < nums.size(); ++i) {
// 移除超出窗口范围的元素
if (!dq.empty() && dq.front() == i - k) {
dq.pop_front();
}
// 移除比当前元素小的元素,保持队列单调递减
while (!dq.empty() && nums[dq.back()] < nums[i]) {
dq.pop_back();
}
dq.push_back(i);
// 当窗口形成后,记录最大值
if (i >= k - 1) {
res.push_back(nums[dq.front()]);
}
}
return res;
}
这里使用了双端队列(deque)来维护一个单调递减的队列。队列头部始终是当前窗口的最大值。这种解法的时间复杂度是O(n),比暴力解法的O(nk)高效得多。
4. 栈与队列的高级应用与技巧
4.1 单调栈的应用
单调栈是指栈中的元素保持单调递增或递减的顺序。它在解决"下一个更大元素"、"柱状图中最大矩形"等问题时非常高效。
以"每日温度"(LeetCode 739)为例,要求对于每一天,找到其后第一个更高温度出现在几天后。
cpp复制vector<int> dailyTemperatures(vector<int>& temperatures) {
stack<int> st;
vector<int> res(temperatures.size(), 0);
for (int i = 0; i < temperatures.size(); ++i) {
while (!st.empty() && temperatures[i] > temperatures[st.top()]) {
int prev = st.top();
res[prev] = i - prev;
st.pop();
}
st.push(i);
}
return res;
}
这个解法维护了一个单调递减栈。当遇到更高温度时,就计算之前那些天的等待天数并弹出栈。
4.2 优先队列(堆)的应用
虽然标准库中的priority_queue是基于堆实现的,但它在很多场景下可以看作是队列的高级变种。例如在"合并K个排序链表"(LeetCode 23)问题中:
cpp复制struct Compare {
bool operator()(ListNode* a, ListNode* b) {
return a->val > b->val;
}
};
ListNode* mergeKLists(vector<ListNode*>& lists) {
priority_queue<ListNode*, vector<ListNode*>, Compare> pq;
for (auto list : lists) {
if (list) pq.push(list);
}
ListNode dummy(0);
ListNode* tail = &dummy;
while (!pq.empty()) {
ListNode* node = pq.top();
pq.pop();
tail->next = node;
tail = tail->next;
if (node->next) pq.push(node->next);
}
return dummy.next;
}
这里使用最小堆来高效获取当前最小的节点,时间复杂度是O(NlogK),其中N是总节点数,K是链表数量。
5. 常见错误与调试技巧
5.1 栈溢出问题
在递归实现栈操作时,很容易出现栈溢出。例如用递归实现栈反转:
cpp复制void insertAtBottom(stack<int>& st, int x) {
if (st.empty()) {
st.push(x);
return;
}
int temp = st.top();
st.pop();
insertAtBottom(st, x);
st.push(temp);
}
void reverseStack(stack<int>& st) {
if (st.empty()) return;
int x = st.top();
st.pop();
reverseStack(st);
insertAtBottom(st, x);
}
对于大尺寸的栈,这种递归实现会导致栈溢出。应该使用迭代方法或显式地管理调用栈。
5.2 队列的边界条件处理
在实现循环队列时,特别要注意队满和队空的判断条件:
cpp复制class MyCircularQueue {
private:
vector<int> data;
int head, tail, size;
public:
MyCircularQueue(int k) : data(k), head(-1), tail(-1), size(0) {}
bool enQueue(int value) {
if (isFull()) return false;
if (isEmpty()) head = 0;
tail = (tail + 1) % data.size();
data[tail] = value;
size++;
return true;
}
bool deQueue() {
if (isEmpty()) return false;
if (head == tail) {
head = tail = -1;
} else {
head = (head + 1) % data.size();
}
size--;
return true;
}
int Front() {
return isEmpty() ? -1 : data[head];
}
int Rear() {
return isEmpty() ? -1 : data[tail];
}
bool isEmpty() {
return size == 0;
}
bool isFull() {
return size == data.size();
}
};
这里使用size变量来简化队满/队空的判断,避免了head和tail位置关系的复杂判断。
6. 性能优化与工程实践
6.1 内存分配优化
在需要频繁操作栈/队列的场景中,预先分配足够空间可以避免频繁的内存重新分配:
cpp复制stack<int> st;
st.reserve(1000); // 预先分配空间(注意:标准库stack没有reserve方法)
// 替代方案:使用vector作为底层容器
vector<int> vec;
vec.reserve(1000);
stack<int, vector<int>> st(vec);
对于队列,可以考虑使用deque而不是queue,因为deque在两端操作都是O(1)时间复杂度,且内存分配更高效。
6.2 多线程环境下的栈和队列
在并发环境中,标准库的stack和queue不是线程安全的。可以使用互斥锁保护:
cpp复制#include <mutex>
#include <queue>
template<typename T>
class ThreadSafeQueue {
private:
queue<T> q;
mutex mtx;
public:
void push(T val) {
lock_guard<mutex> lock(mtx);
q.push(val);
}
bool try_pop(T& val) {
lock_guard<mutex> lock(mtx);
if (q.empty()) return false;
val = q.front();
q.pop();
return true;
}
bool empty() {
lock_guard<mutex> lock(mtx);
return q.empty();
}
};
对于高性能场景,可以考虑无锁队列实现,但实现复杂度会大大增加。
6.3 自定义分配器
在嵌入式系统或对性能要求极高的场景中,可以为栈和队列实现自定义内存分配器:
cpp复制template<typename T>
class PoolAllocator {
// 实现内存池分配逻辑
};
stack<int, vector<int, PoolAllocator<int>>> custom_stack;
这样可以避免频繁的系统内存分配,提高性能并减少内存碎片。
