1. STL容器适配器:stack与queue深度解析
作为C++标准模板库(STL)的重要组成部分,stack和queue是两种常用的数据结构。但很多初学者容易产生误解,认为它们和vector、list一样是独立的容器。实际上,它们属于容器适配器(container adapter),这是STL六大组件之一。
1.1 容器与容器适配器的本质区别
容器(如vector、list)是独立管理自己内存和数据的完整结构:
- 拥有自己的内存管理机制(通过第二个模板参数Allocator配置)
- 直接实现数据的增删改查等基本操作
- 提供完整的迭代器支持
而容器适配器(如stack、queue):
- 不直接管理内存和数据
- 基于现有容器进行接口适配
- 通常不提供迭代器功能(为了保持特定数据结构的特性)
cpp复制// 典型容器定义
template <class T, class Allocator = allocator<T>>
class vector {...};
// 容器适配器定义
template <class T, class Container = deque<T>>
class stack {...};
这种设计体现了适配器模式的思想:不改变原有容器,只是通过包装提供新的接口。这使得我们可以用同一个底层容器实现多种数据结构。
1.2 stack:后进先出(LIFO)结构详解
stack遵循后进先出原则,只允许在栈顶进行插入和删除操作。它的接口设计简洁明了:
cpp复制template <class T, class Container = deque<T>>
class stack {
public:
void push(const T& x); // 入栈
void pop(); // 出栈
T& top(); // 访问栈顶元素
const T& top() const; // const版本访问
bool empty() const; // 判空
size_t size() const; // 元素数量
};
1.2.1 关键实现细节
-
emplace与push的区别:
- push接收已构造的对象
- emplace直接在容器内构造对象(C++11引入)
cpp复制stack<pair<int, string>> s; s.push(make_pair(1, "hello")); // push方式 s.emplace(1, "hello"); // emplace方式 -
swap优化:
- 使用成员函数swap而非std::swap
- 避免不必要的元素拷贝
1.2.2 典型使用场景
cpp复制#include <stack>
#include <iostream>
int main() {
std::stack<int> s;
// 压栈操作
for(int i = 1; i <= 5; ++i) {
s.push(i * 10);
}
// 遍历栈(通过出栈操作)
while(!s.empty()) {
std::cout << s.top() << " ";
s.pop();
}
// 输出:50 40 30 20 10
return 0;
}
1.3 queue:先进先出(FIFO)结构剖析
queue遵循先进先出原则,元素从队尾入列,从队头出列。其接口设计与stack类似但有所区别:
cpp复制template <class T, class Container = deque<T>>
class queue {
public:
void push(const T& x); // 入队
void pop(); // 出队
T& front(); // 访问队头
const T& front() const;
T& back(); // 访问队尾
const T& back() const;
bool empty() const;
size_t size() const;
};
1.3.1 实现注意事项
-
底层容器选择:
- 默认使用deque
- 不能用vector适配(缺少pop_front操作)
-
线程安全:
- STL实现非线程安全
- 多线程环境需要额外同步机制
1.3.2 基本使用示例
cpp复制#include <queue>
#include <iostream>
int main() {
std::queue<std::string> q;
// 入队操作
q.push("first");
q.push("second");
q.push("third");
// 遍历队列
while(!q.empty()) {
std::cout << q.front() << " ";
q.pop();
}
// 输出:first second third
return 0;
}
2. 经典算法问题实战解析
掌握基本用法后,我们通过几个经典算法题来深入理解stack和queue的应用场景和解题技巧。
2.1 最小栈问题(LeetCode 155)
2.1.1 问题描述
设计一个支持push、pop、top操作,并能在常数时间内检索到最小元素的栈。
2.1.2 解题思路分析
常规思路的缺陷:
- 单独维护min变量:pop时无法确定新的最小值
- 每次查询时遍历:不满足O(1)时间要求
最优解决方案:双栈法
- 主栈_normal存储所有元素
- 辅助栈_min存储历史最小值序列
cpp复制class MinStack {
stack<int> _normal;
stack<int> _min;
public:
void push(int val) {
_normal.push(val);
if(_min.empty() || val <= _min.top()) {
_min.push(val);
}
}
void pop() {
if(_normal.top() == _min.top()) {
_min.pop();
}
_normal.pop();
}
int top() { return _normal.top(); }
int getMin() { return _min.top(); }
};
2.1.3 复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| push | O(1) | O(n) |
| pop | O(1) | O(n) |
| top | O(1) | O(n) |
| getMin | O(1) | O(n) |
2.2 栈的压入、弹出序列验证
2.2.1 问题描述
给定两个整数序列,第一个表示栈的压入顺序,判断第二个序列是否为可能的弹出顺序。
2.2.2 算法思路
模拟整个压入弹出过程:
- 初始化空栈和指针
- 遍历压入序列,依次入栈
- 每次入栈后,循环检查栈顶是否等于当前弹出元素
- 最终检查栈是否为空
cpp复制bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
stack<int> st;
int popIdx = 0;
for(int num : pushed) {
st.push(num);
while(!st.empty() && st.top() == popped[popIdx]) {
st.pop();
popIdx++;
}
}
return st.empty();
}
2.2.3 示例分析
输入:pushed = [1,2,3,4,5], popped = [4,5,3,2,1]
过程:
- 压入1,2,3,4
- 发现4匹配,弹出4
- 压入5
- 弹出5,3,2,1
结果:true
2.3 逆波兰表达式求值
2.3.1 问题背景
逆波兰表达式(后缀表达式)不需要括号即可明确运算顺序,计算时:
- 遇到数字入栈
- 遇到运算符弹出栈顶两个元素运算
- 将结果压回栈中
2.3.2 实现代码
cpp复制int evalRPN(vector<string>& tokens) {
stack<int> st;
for(const auto& token : tokens) {
if(token == "+" || token == "-" || token == "*" || token == "/") {
int right = st.top(); st.pop();
int left = st.top(); st.pop();
if(token == "+") st.push(left + right);
else if(token == "-") st.push(left - right);
else if(token == "*") st.push(left * right);
else st.push(left / right);
} else {
st.push(stoi(token));
}
}
return st.top();
}
2.3.3 注意事项
- 操作数顺序:先弹出的是右操作数
- 除零问题:题目通常保证输入有效
- 数字转换:使用stoi处理字符串
2.4 二叉树的层序遍历
2.4.1 算法思想
利用队列先进先出的特性:
- 根节点入队
- 记录当前层节点数
- 处理完一层后,其子节点正好形成下一层
cpp复制vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> result;
queue<TreeNode*> q;
if(root) q.push(root);
while(!q.empty()) {
int levelSize = q.size();
vector<int> currentLevel;
for(int i = 0; i < levelSize; ++i) {
TreeNode* node = q.front();
q.pop();
currentLevel.push_back(node->val);
if(node->left) q.push(node->left);
if(node->right) q.push(node->right);
}
result.push_back(currentLevel);
}
return result;
}
2.4.2 复杂度分析
时间复杂度:O(n) 每个节点访问一次
空间复杂度:O(n) 队列最大存储量
3. 底层实现与STL设计哲学
3.1 STL六大组件概述
- 容器(Containers):管理数据的集合
- 算法(Algorithms):处理数据的操作
- 迭代器(Iterators):访问容器的统一接口
- 仿函数(Functors):使类像函数一样使用
- 适配器(Adapters):转换接口的包装器
- 分配器(Allocators):内存管理的组件
3.2 stack的底层实现
stack默认使用deque作为底层容器,但也可以指定其他容器:
cpp复制template <class T, class Container = deque<T>>
class stack {
protected:
Container c; // 底层容器
public:
// 接口实现
void push(const T& x) { c.push_back(x); }
void pop() { c.pop_back(); }
// ...
};
3.2.1 自定义stack实现
cpp复制namespace my {
template <typename T, typename Container = std::deque<T>>
class stack {
Container _container;
public:
void push(const T& val) { _container.push_back(val); }
void pop() { _container.pop_back(); }
T& top() { return _container.back(); }
bool empty() const { return _container.empty(); }
size_t size() const { return _container.size(); }
};
} // namespace my
3.3 queue的底层实现
queue同样默认使用deque,但要求容器支持front操作:
cpp复制template <class T, class Container = deque<T>>
class queue {
protected:
Container c;
public:
void push(const T& x) { c.push_back(x); }
void pop() { c.pop_front(); }
// ...
};
3.3.1 注意事项
- 不能用vector适配(缺少pop_front)
- list和deque是常用选择
3.4 为什么选择deque作为默认适配器
deque(双端队列)结合了vector和list的优点:
| 特性 | vector | list | deque |
|---|---|---|---|
| 随机访问 | O(1) | O(n) | O(1) |
| 头尾插入删除 | O(n)/O(1) | O(1) | O(1) |
| 中间插入删除 | O(n) | O(1) | O(n) |
| 内存连续性 | 连续 | 不连续 | 部分连续 |
3.4.1 deque的底层结构
deque采用分块存储策略:
- 多个固定大小的buffer(通常512字节)
- 中控数组(指针数组)管理buffer
- 迭代器需要维护多个状态
cpp复制// 简化的deque迭代器结构
struct _Deque_iterator {
T* cur; // 当前元素指针
T* first; // 当前buffer起始
T* last; // 当前buffer末尾
T** node; // 中控数组节点
};
3.4.2 deque的优缺点
优点:
- 头尾操作高效
- 支持随机访问
- 内存使用较均衡
缺点:
- 中间操作效率低
- 迭代器比vector复杂
- 不适合排序等大量随机访问操作
4. 性能对比与工程实践建议
4.1 vector vs deque性能测试
cpp复制void benchmark() {
const int N = 1000000;
// 插入性能
{
std::vector<int> v;
auto start = std::clock();
for(int i = 0; i < N; ++i) {
v.insert(v.begin(), i); // 头部插入
}
std::cout << "vector front insert: "
<< (std::clock()-start)/CLOCKS_PER_SEC << "s\n";
}
{
std::deque<int> dq;
auto start = std::clock();
for(int i = 0; i < N; ++i) {
dq.push_front(i); // 头部插入
}
std::cout << "deque front insert: "
<< (std::clock()-start)/CLOCKS_PER_SEC << "s\n";
}
// 排序性能
{
std::vector<int> v(N);
std::iota(v.begin(), v.end(), 0);
auto start = std::clock();
std::sort(v.begin(), v.end());
std::cout << "vector sort: "
<< (std::clock()-start)/CLOCKS_PER_SEC << "s\n";
}
{
std::deque<int> dq(N);
std::iota(dq.begin(), dq.end(), 0);
auto start = std::clock();
std::sort(dq.begin(), dq.end());
std::cout << "deque sort: "
<< (std::clock()-start)/CLOCKS_PER_SEC << "s\n";
}
}
4.2 工程实践建议
-
stack/queue选择:
- 需要LIFO特性 → stack
- 需要FIFO特性 → queue
- 需要遍历或中间访问 → 考虑其他容器
-
底层容器选择:
- 默认deque通常足够好
- 极端性能要求时可测试list/vector
- 内存敏感场景可考虑自定义分配器
-
线程安全:
- STL容器非线程安全
- 多线程环境需要加锁或使用并发容器
-
异常安全:
- 基本操作提供强异常保证
- 自定义类型需保证移动/拷贝不抛异常
4.3 常见陷阱与最佳实践
-
未检查空容器:
cpp复制stack<int> s; // 错误:未检查empty()直接访问top() int val = s.top(); -
迭代器失效:
cpp复制deque<int> dq = {1,2,3}; auto it = dq.begin(); dq.push_front(0); // 可能导致迭代器失效 -
性能陷阱:
cpp复制// 低效:频繁扩容 vector<int> v; for(int i = 0; i < 1e6; ++i) { v.push_back(i); } // 更好:预分配空间 vector<int> v; v.reserve(1e6); for(int i = 0; i < 1e6; ++i) { v.push_back(i); } -
移动语义应用:
cpp复制stack<vector<string>> s; vector<string> largeVec; // 低效:拷贝 s.push(largeVec); // 高效:移动 s.push(std::move(largeVec));
在实际项目中,理解这些数据结构的底层实现和特性,能够帮助我们做出更合理的设计选择,编写出更高效、更健壮的代码。