C++栈与队列:从STL容器适配器到模拟实现与性能优化
1. 项目概述:从容器到数据结构,理解C++中的栈与队列
最近在折腾一些项目时,发现无论是部署服务还是写算法,
stack
和
queue
这两个概念总是绕不开。比如,用
docker-compose
编排服务时,启动一个
redis stack
,背后是一系列有序的容器启动队列;而在解决一个编译错误,像
intermediate exitcode after execution queue : 1603
时,本质上也是安装程序内部的任务队列执行出了问题。这让我意识到,
“栈”与“队列”不仅仅是教科书上的抽象数据结构,更是贯穿于我们日常开发、系统设计乃至问题排查中的核心思想
。
在C++的世界里,
std::stack
和
std::queue
是标准模板库(STL)中两个极为重要且常用的容器适配器。对于初学者,它们可能是学习“数据结构”这门课的敲门砖;对于有经验的开发者,它们是实现特定算法逻辑(如广度优先搜索BFS用队列,深度优先搜索DFS用栈)、管理任务、缓冲数据的利器。甚至在你配置VSCode的C++环境,或者处理那些恼人的
Microsoft Visual C++ Redistributable
安装问题时,底层系统也在默默地使用着栈和队列来管理函数调用、消息传递。
所以,这篇文章我想和你深入聊聊C++中
stack
和
queue
的
使用
与
模拟实现
。我们不止步于会调用几个API,更要亲手从零搭建它们,理解其底层容器如何选择、接口为何这样设计,以及在什么场景下该用谁。这不仅能帮你彻底掌握这两个工具,更能提升你对程序底层运行机制的理解,无论是应对面试中的“C++八股文”,还是解决实际开发中的复杂问题,都大有裨益。
2. 核心概念与设计思路拆解
2.1 栈与队列的本质:LIFO vs FIFO
让我们先抛开代码,用最生活的例子来理解它们的核心区别。
栈
就像一个羽毛球筒,或者一摞盘子。你只能从最顶端放入新的羽毛球或盘子,也只能从最顶端取出。最后放进去的,总是最先被拿出来。这种规则在计算机科学中称为
LIFO
。想象一下函数调用:
main()
函数调用
funcA()
,
funcA()
又调用
funcB()
。系统会用一个调用栈来记录,
funcB()
最后被调用,所以它需要最先执行完毕并返回,然后才是
funcA()
,最后是
main()
。这就是栈的典型应用。
队列
则完全相反,它像现实生活中的排队队伍,或者打印机任务列表。新来的人排在队伍末尾,而服务总是从队伍的最前端开始。先来的人先被服务,这种规则叫做
FIFO
。当你用
std::cin
等待用户输入时,操作系统会将键盘输入放入一个消息队列;当你启动多个
docker
容器时,
docker-compose
也会按照依赖关系形成一个启动队列。这些都是队列思想的体现。
理解了这个根本区别,我们就能明白为什么C++标准库将
stack
和
queue
设计为“容器适配器”而不是独立的容器。
2.2 容器适配器:站在巨人的肩膀上
std::stack
和
std::queue
在STL中被称为
容器适配器
。这意味着它们本身并不直接管理内存和存储元素,而是“适配”一个已有的底层容器,为其赋予栈或队列的访问语义。
你可以把底层容器想象成一个“仓库”,这个仓库本身可能支持随机访问(像
vector
),或者只支持双向访问(像
deque
、
list
)。而
stack
和
queue
则像是给这个仓库安装了特殊的“大门”和“规则”:
-
给仓库只留一个“顶门”,规定只能从这个门进出,就变成了
stack。 -
给仓库开一个“入口”和一个“出口”,规定入口只进、出口只出,就变成了
queue。
C++标准默认选择的底层容器是
deque
。为什么是
deque
?这背后有深思熟虑的权衡:
-
内存效率与性能平衡
:
deque(双端队列)由多个固定大小的块组成,增长时不需要像vector那样大规模复制原有元素,在头部和尾部插入删除的效率都是O(1)。这完美契合了栈(只操作尾部)和队列(一头进一头出)的核心操作。 -
避免
vector的陷阱 :如果用vector作stack的底层容器,push操作在容量不足时触发扩容,可能导致所有元素被复制移动,虽然均摊复杂度仍是O(1),但在某些对实时性要求极高的场景可能不够理想。而deque的块状结构避免了这个问题。 -
相比
list的空间优势 :list(双向链表)每个元素都需要额外的指针开销,对于存储小对象(如int)的栈或队列来说,内存利用率较低。deque在这方面通常更有优势。
当然,你也可以指定其他容器。例如,如果你确信栈的大小非常固定,且需要极致的尾部操作性能,可以指定
vector
为底层容器:
std::stack<int, std::vector<int>> myStack;
。但你需要自己承担
vector
扩容可能带来的风险。
实操心得 :在99%的场景下,使用默认的
deque作为底层容器是最佳选择。除非你有非常明确的性能剖析数据证明vector或list在你的特定场景下更优,否则不要轻易更改。过早优化是万恶之源。
3. 标准库接口详解与实战应用
3.1 std::stack 的完全指南
std::stack
的接口非常简洁,只暴露了栈操作必需的方法。这符合“最小接口原则”,避免了误用。
核心操作:
-
push(const T& value)/push(T&& value):将元素压入栈顶。这是栈的核心“入栈”操作。 -
pop():移除栈顶元素。注意!这个方法 不返回 被移除的元素。这是一个容易踩坑的设计。 -
top():返回栈顶元素的引用(可修改)。这是你查看或修改栈顶元素的唯一方式。 -
empty():检查栈是否为空。 -
size():返回栈中元素的数量。
为什么
pop()
不返回值?
这是一个经典的C++设计决策,主要基于
异常安全
的考虑。假设
pop()
需要返回被移除的元素,那么函数签名可能是
T pop();
。这涉及到两个步骤:1) 返回栈顶元素的副本;2) 从栈中移除该元素。如果在复制元素时(调用拷贝构造函数)抛出异常,那么元素既被复制(可能失败),又从栈中移除了,这个元素就永远丢失了。为了避免这种尴尬局面,STL将职责分离:用
top()
获取元素,用
pop()
移除元素。虽然这需要两步操作,但保证了操作的强异常安全性。
典型使用模式:
#include <iostream>
#include <stack>
#include <string>
int main() {
std::stack<std::string> history; // 浏览历史记录栈
// 模拟用户浏览网页
history.push("www.homepage.com");
history.push("www.news.com/article/123");
history.push("www.shop.com/product/abc");
std::cout << "当前页面: " << history.top() << std::endl; // 输出: www.shop.com/product/abc
// 用户点击“后退”按钮
if (!history.empty()) {
history.pop(); // 移除当前页面
}
std::cout << "后退后页面: " << history.top() << std::endl; // 输出: www.news.com/article/123
// 查看历史记录深度
std::cout << "历史记录条数: " << history.size() << std::endl;
return 0;
}
另一个经典应用是 括号匹配检查 ,这是栈的“杀手级”应用:
bool isValidParentheses(const std::string& s) {
std::stack<char> stk;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
stk.push(c);
} else {
if (stk.empty()) return false;
char top = stk.top();
if ((c == ')' && top != '(') ||
(c == ']' && top != '[') ||
(c == '}' && top != '{')) {
return false;
}
stk.pop();
}
}
return stk.empty(); // 最后栈必须为空才算完全匹配
}
3.2 std::queue 的完全指南
std::queue
的接口同样保持精简,专注于队列操作。
核心操作:
-
push(const T& value)/push(T&& value):将元素添加到队列末尾。 -
pop():移除队列前端的元素。和stack::pop()一样,它也不返回被移除的元素。 -
front():返回队列前端元素的引用(可修改)。这是获取“下一个要处理元素”的方式。 -
back():返回队列末尾元素的引用。这在某些场景下有用,比如查看最新加入的任务。 -
empty():检查队列是否为空。 -
size():返回队列中元素的数量。
典型使用模式:任务处理队列
#include <iostream>
#include <queue>
#include <string>
struct Task {
int id;
std::string description;
};
int main() {
std::queue<Task> taskQueue;
// 模拟任务产生
taskQueue.push({1, "处理用户登录请求"});
taskQueue.push({2, "生成每日报表"});
taskQueue.push({3, "发送邮件通知"});
// 模拟任务处理(简单的轮询)
while (!taskQueue.empty()) {
Task& currentTask = taskQueue.front(); // 获取队首任务,但不移除
std::cout << "正在处理任务[" << currentTask.id << "]: "
<< currentTask.description << std::endl;
// ... 执行任务处理逻辑 ...
taskQueue.pop(); // 任务处理完毕,从队列中移除
std::cout << "队列中剩余任务数: " << taskQueue.size() << std::endl;
}
std::cout << "所有任务处理完毕!" << std::endl;
return 0;
}
队列在算法中最著名的应用是 广度优先搜索 。BFS的核心就是利用队列来保证“先发现的节点先被探索”:
void BFS(std::vector<std::vector<int>>& graph, int startNode) {
std::vector<bool> visited(graph.size(), false);
std::queue<int> q;
visited[startNode] = true;
q.push(startNode);
while (!q.empty()) {
int currentNode = q.front();
q.pop();
std::cout << "访问节点: " << currentNode << std::endl;
for (int neighbor : graph[currentNode]) {
if (!visited[neighbor]) {
visited[neighbor] = true;
q.push(neighbor);
}
}
}
}
注意事项 :无论是
stack::top()还是queue::front()/back(),在调用前都必须确保容器 非空 。对空容器调用这些方法会导致 未定义行为 ,通常就是程序崩溃。这是一个非常常见的错误。安全的做法是养成习惯,先检查empty()。
3.3 栈与队列的底层容器选择与性能考量
虽然我们通常使用默认的
deque
,但了解不同底层容器的特性对于编写高性能代码至关重要。
| 操作 |
std::stack
with
std::deque
(默认)
|
std::stack
with
std::vector
|
std::stack
with
std::list
|
std::queue
with
std::deque
(默认)
|
std::queue
with
std::list
|
|---|---|---|---|---|---|
push
(入栈/队)
| 平摊O(1) | 平摊O(1) ,可能触发扩容复制 | O(1) | 平摊O(1) | O(1) |
pop
(出栈/队)
| O(1) | O(1) | O(1) | O(1) | O(1) |
top
/
front
/
back
| O(1) | O(1) | O(1) | O(1) | O(1) |
| 内存布局 | 分段连续 | 单块连续 | 非连续(链表) | 分段连续 | 非连续(链表) |
| 内存开销 | 较低(有控制块开销) | 最低(仅容量可能浪费) | 最高(每个元素两个指针) | 较低(有控制块开销) | 最高(每个元素两个指针) |
| 关键特性 | 头尾插入删除都快,无扩容复制 | 尾部插入快,随机访问快,扩容代价大 | 任何位置插入删除都快,无扩容问题 | 头尾插入删除都快,无扩容复制 | 任何位置插入删除都快,无扩容问题 |
如何选择?
-
默认情况
:无脑用
deque。它是STL设计者为栈和队列精心挑选的“全能型”底层容器,在绝大多数场景下提供了最佳的综合性能。 -
选择
vector的情况 :当你需要栈,并且满足以下 所有 条件时:-
元素类型是
平凡可复制
的(如
int,double, 简单结构体)。 -
栈的
最大尺寸可以预估
,并且你能通过
reserve()预先分配足够内存,避免扩容。 - 你非常需要 内存连续性 来利用CPU缓存,或者后续可能需要将整个栈内容复制到C风格API。
-
元素类型是
平凡可复制
的(如
-
选择
list的情况 :相对少见。除非你的元素非常大(拷贝代价高),且栈/队列的大小变化非常频繁且不可预测,使得vector的扩容或deque的内存块管理开销成为瓶颈。但通常,deque仍然是更好的选择。
4. 从零开始模拟实现
理解了接口和原理,最好的巩固方式就是自己动手实现一遍。我们将分别实现一个简易版的
MyStack
和
MyQueue
。为了聚焦于栈和队列的逻辑本身,我们选择
std::vector
作为底层容器来实现栈,选择
std::deque
来实现队列,这样我们可以更专注于适配器模式的封装。
4.1 实现一个简易栈
我们的目标是封装一个
std::vector
,只暴露栈的接口。
#include <vector>
#include <stdexcept> // 用于抛出异常
template <typename T, typename Container = std::vector<T>>
class MyStack {
private:
Container c; // 底层容器
public:
// 类型别名,增加可读性
using value_type = typename Container::value_type;
using size_type = typename Container::size_type;
using reference = typename Container::reference;
using const_reference = typename Container::const_reference;
// 构造函数:默认、拷贝、移动
MyStack() = default;
MyStack(const MyStack& other) : c(other.c) {}
MyStack(MyStack&& other) noexcept : c(std::move(other.c)) {}
// 赋值运算符
MyStack& operator=(const MyStack& other) {
if (this != &other) {
c = other.c;
}
return *this;
}
MyStack& operator=(MyStack&& other) noexcept {
c = std::move(other.c);
return *this;
}
// 核心接口
reference top() {
if (empty()) {
throw std::out_of_range("MyStack::top(): stack is empty");
}
return c.back(); // vector的back()返回尾部元素
}
const_reference top() const {
if (empty()) {
throw std::out_of_range("MyStack::top(): stack is empty");
}
return c.back();
}
bool empty() const {
return c.empty();
}
size_type size() const {
return c.size();
}
void push(const value_type& value) {
c.push_back(value);
}
void push(value_type&& value) {
c.push_back(std::move(value)); // 完美转发
}
template <typename... Args>
void emplace(Args&&... args) {
c.emplace_back(std::forward<Args>(args)...); // 原位构造
}
void pop() {
if (empty()) {
throw std::out_of_range("MyStack::pop(): stack is empty");
}
c.pop_back();
}
void swap(MyStack& other) noexcept {
using std::swap;
swap(c, other.c);
}
// 比较运算符(非必需,但STL容器通常提供)
bool operator==(const MyStack& other) const { return c == other.c; }
bool operator!=(const MyStack& other) const { return c != other.c; }
bool operator<(const MyStack& other) const { return c < other.c; }
bool operator<=(const MyStack& other) const { return c <= other.c; }
bool operator>(const MyStack& other) const { return c > other.c; }
bool operator>=(const MyStack& other) const { return c >= other.c; }
};
// 特化swap算法,用于ADL查找
template <typename T, typename Container>
void swap(MyStack<T, Container>& lhs, MyStack<T, Container>& rhs) noexcept {
lhs.swap(rhs);
}
关键实现细节解析:
-
模板设计
:类模板接受两个参数:元素类型
T和底层容器类型Container。默认使用std::vector<T>。这模仿了STL的设计,提供了灵活性。 -
类型别名
:
using语句定义了内部类型,这使得我们的类模板更像一个标准的STL组件,也方便其他模板代码使用。 -
异常安全
:在
top()和pop()中,我们检查了容器是否为空。如果为空,我们抛出std::out_of_range异常。这是比未定义行为更好的做法。STL的标准stack在调用top()时如果栈为空,行为是未定义的,但我们的实现选择了更安全的路径。 -
完美转发与原位构造
:我们实现了
push的左值/右值引用版本,以及emplace方法。emplace利用可变参数模板和完美转发,直接在容器尾部构造对象,避免了不必要的拷贝或移动,这是现代C++的重要优化手段。 -
swap操作 :我们提供了成员函数swap和非成员函数swap特化。这遵循了STL容器的惯例,并且通过noexcept声明告知编译器此操作不会抛出异常,有助于编译器进行优化。
4.2 实现一个简易队列
队列需要在一端插入,另一端删除。用
vector
实现队列的头部删除是低效的(O(n)),因此我们选择
std::deque
作为默认底层容器。
#include <deque>
#include <stdexcept>
template <typename T, typename Container = std::deque<T>>
class MyQueue {
private:
Container c;
public:
using value_type = typename Container::value_type;
using size_type = typename Container::size_type;
using reference = typename Container::reference;
using const_reference = typename Container::const_reference;
MyQueue() = default;
MyQueue(const MyQueue& other) : c(other.c) {}
MyQueue(MyQueue&& other) noexcept : c(std::move(other.c)) {}
MyQueue& operator=(const MyQueue& other) {
if (this != &other) {
c = other.c;
}
return *this;
}
MyQueue& operator=(MyQueue&& other) noexcept {
c = std::move(other.c);
return *this;
}
// 核心接口
reference front() {
if (empty()) {
throw std::out_of_range("MyQueue::front(): queue is empty");
}
return c.front();
}
const_reference front() const {
if (empty()) {
throw std::out_of_range("MyQueue::front(): queue is empty");
}
return c.front();
}
reference back() {
if (empty()) {
throw std::out_of_range("MyQueue::back(): queue is empty");
}
return c.back();
}
const_reference back() const {
if (empty()) {
throw std::out_of_range("MyQueue::back(): queue is empty");
}
return c.back();
}
bool empty() const {
return c.empty();
}
size_type size() const {
return c.size();
}
void push(const value_type& value) {
c.push_back(value); // 从尾部插入
}
void push(value_type&& value) {
c.push_back(std::move(value));
}
template <typename... Args>
void emplace(Args&&... args) {
c.emplace_back(std::forward<Args>(args)...);
}
void pop() {
if (empty()) {
throw std::out_of_range("MyQueue::pop(): queue is empty");
}
c.pop_front(); // 从头部删除!这是deque才有的高效操作
}
void swap(MyQueue& other) noexcept {
using std::swap;
swap(c, other.c);
}
// 比较运算符
bool operator==(const MyQueue& other) const { return c == other.c; }
bool operator!=(const MyQueue& other) const { return c != other.c; }
// 注意:queue的比较语义可能不直观,这里直接委托给底层容器。
// 实际STL的queue比较是基于元素的字典序。
};
template <typename T, typename Container>
void swap(MyQueue<T, Container>& lhs, MyQueue<T, Container>& rhs) noexcept {
lhs.swap(rhs);
}
关键实现细节解析:
-
底层容器的要求
:我们的
MyQueue要求底层容器Container必须提供push_back,pop_front,front,back等操作。std::deque和std::list都满足,但std::vector不提供pop_front(或者提供但效率是O(n)),因此不能用作默认实现。STL的std::queue默认底层容器就是std::deque。 -
pop_front的使用 :这是队列实现的关键。pop()操作对应底层容器的pop_front(),确保了FIFO语义。 -
front()和back():队列需要访问两端,因此我们提供了这两个方法。注意它们都需要进行空队列检查。
踩坑提醒 :如果你尝试用
std::vector作为MyQueue的底层容器,编译不会立即报错(因为vector有push_back和front/back),但当你调用pop()时,就会找不到pop_front成员函数而编译失败。这就是模板元编程中“隐式接口”的体现:模板代码对类型的要求是通过其使用的表达式来定义的,而不是显式的继承或虚函数。
5. 进阶话题、性能陷阱与最佳实践
5.1 栈与队列的迭代器问题
一个重要的区别是:
STL的
stack
和
queue
不提供迭代器
。这是有意为之的设计。迭代器意味着可以遍历容器中的所有元素,甚至可以修改中间的元素,这会破坏栈和队列所保证的LIFO和FIFO访问约束。如果你发现自己需要遍历一个栈或队列,那很可能意味着你选错了数据结构,应该考虑使用
deque
、
list
或
vector
。
5.2 线程安全考量
标准库的
stack
和
queue
不是线程安全的
。如果多个线程同时读写同一个栈或队列对象,会导致数据竞争和未定义行为。在多线程环境下,你需要自行加锁(如使用
std::mutex
)来保护这些容器,或者使用支持并发的数据结构库(如Intel TBB中的
concurrent_queue
)。
一个简单的线程安全栈包装器示例:
#include <stack>
#include <mutex>
template <typename T>
class ThreadSafeStack {
private:
std::stack<T> data;
mutable std::mutex mtx; // mutable允许在const成员函数中加锁
public:
ThreadSafeStack() = default;
void push(const T& value) {
std::lock_guard<std::mutex> lock(mtx);
data.push(value);
}
bool try_pop(T& value) { // 非阻塞式弹出
std::lock_guard<std::mutex> lock(mtx);
if (data.empty()) {
return false;
}
value = std::move(data.top()); // 假设T支持移动
data.pop();
return true;
}
std::shared_ptr<T> try_pop() { // 返回智能指针的版本
std::lock_guard<std::mutex> lock(mtx);
if (data.empty()) {
return std::shared_ptr<T>();
}
std::shared_ptr<T> res(std::make_shared<T>(std::move(data.top())));
data.pop();
return res;
}
bool empty() const {
std::lock_guard<std::mutex> lock(mtx);
return data.empty();
}
};
5.3 避免常见的性能陷阱
-
不必要的拷贝 :向栈或队列中存入大对象时,优先使用
emplace进行原位构造,或者使用push配合std::move。std::stack<std::vector<int>> stk; std::vector<int> largeVec(1000000, 42); // 不好:发生一次拷贝 // stk.push(largeVec); // 好:移动语义,零拷贝 stk.push(std::move(largeVec)); // 此时largeVec变为有效但未指定状态(通常为空) // 更好:直接原位构造 // stk.emplace(1000000, 42); -
pop()与top()的误用 :永远记住pop()不返回值。一个常见的错误模式是:// 错误!top()返回引用,pop()后该引用可能失效(取决于底层容器) process(stk.top()); stk.pop(); // 正确做法:先保存值,再pop auto value = stk.top(); // 如果是复杂类型,考虑std::move stk.pop(); process(value); -
算法选择 :栈和队列是工具,选择正确的算法才能发挥其威力。例如,需要“回溯”的场景(如路径搜索、撤销操作)用栈;需要“按序处理”的场景(如消息缓冲、BFS)用队列。用错数据结构会导致代码复杂且低效。
5.4 栈与队列在面试中的经典问题
- 用栈实现队列 :这是考察对两者特性理解的经典题。思路是使用两个栈,一个作为输入栈,一个作为输出栈。入队时压入输入栈;出队时,如果输出栈为空,则将输入栈的所有元素依次弹出并压入输出栈,然后从输出栈弹出。
- 用队列实现栈 :同样使用两个队列。入栈时,将元素加入非空队列(或任一队列);出栈时,将非空队列的前n-1个元素依次转移到另一个空队列,然后弹出最后一个元素。
-
最小栈
:设计一个栈,支持
push、pop、top,并能在 常数时间 内检索到栈内最小元素。思路是使用一个辅助栈,同步记录主栈每个状态下的最小值。 - 中缀表达式转后缀表达式 :栈的经典算法应用。运算符入栈,根据优先级决定入栈或出栈,操作数直接输出。
6. 总结与个人体会
走完这一趟从使用到模拟实现,再到深入剖析的旅程,你应该对C++中的
stack
和
queue
有了全新的认识。它们不再是简单的“后进先出”和“先进先出”的抽象概念,而是有着精巧设计、严格约束和广泛应用的实用工具。
我个人在多年的C++开发中,一个很深的体会是:
理解一个工具,最高效的方式就是去思考“如果让我来设计,我会怎么做”
。模拟实现
stack
和
queue
的过程,强迫你去思考为什么
pop()
不返回值,为什么默认底层容器是
deque
,为什么它们没有迭代器。这些问题想通了,你不仅记住了用法,更理解了其背后的设计哲学和权衡。
最后,再分享一个小技巧:当你遇到一个复杂问题,感觉无从下手时,试着在白板上画一画,想想这个问题里的数据流动,是否符合“后进先出”或者“先进先出”的模型。很多时候,一个合适的数据结构选择,能让复杂的算法问题迎刃而解。栈和队列,就是帮你化繁为简的利器。
更多推荐
所有评论(0)