C++ stack、queue 与 priority_queue:容器适配器原理与实战
栈、队列和优先级队列都限制了元素的访问方式。普通容器允许迭代和定位元素,它们只开放少量入口:栈只操作栈顶,队列从一端进入、另一端离开,优先级队列每次只取当前优先级最高的元素。
C++ 标准库没有为这三种结构重新设计一套存储系统,而是把已有容器包装起来,只暴露符合规则的接口。这种设计叫作容器适配器 。理解这一点之后,默认使用 deque 还是 vector、为什么没有迭代器、怎样替换底层容器,都会变得顺理成章。
一、三种受限线性结构
1.1 stack:后进先出
栈遵循 LIFO(Last In First Out,后进先出)。元素只从栈顶进入,也只从栈顶离开。
text
push / pop
|
v
+-------+ <- top
| 30 |
+-------+
| 20 |
+-------+
| 10 |
+-------+
后压入的 30 会先于 20 和 10 弹出。函数调用栈、表达式求值、撤销操作和深度优先搜索中都能看到这种顺序。
1.2 queue:先进先出
队列遵循 FIFO(First In First Out,先进先出)。元素从队尾进入,从队头离开。
text
pop push
<--- [10] [20] [30] [40] <---
front back
10 最早入队,因此最早出队。任务排队、消息缓冲和广度优先搜索都依赖这种顺序。
1.3 priority_queue:按优先级取元素
优先级队列不按进入时间决定离开顺序,而是让优先级最高的元素位于顶部。标准库默认使用大堆,最大元素优先弹出。
text
插入顺序:3 7 2 9 5
弹出顺序:9 7 5 3 2
三者的区别可以压缩成一张表:
| 结构 | 顺序规则 | 可直接访问的位置 | 默认底层容器 |
|---|---|---|---|
std::stack |
后进先出 | top() |
std::deque |
std::queue |
先进先出 | front()、back() |
std::deque |
std::priority_queue |
优先级最高者先出 | top() |
std::vector |
二、什么是容器适配器
适配器的思路是:内部保存一个已有容器,对外换一组更受限制的接口。
以栈为例,只要底层容器支持 back()、push_back() 和 pop_back(),就能这样映射:
text
stack::top() -> container::back()
stack::push() -> container::push_back()
stack::pop() -> container::pop_back()
队列则需要底层容器同时支持头部删除和尾部插入:
text
queue::front() -> container::front()
queue::back() -> container::back()
queue::push() -> container::push_back()
queue::pop() -> container::pop_front()
这也解释了两个常见问题。
stack 和 queue 没有 begin()、end(),因为任意遍历会绕开它们规定的访问顺序。如果业务必须查看中间元素,说明需要的可能是普通容器,而不是受限接口。
pop() 不返回被删除的值。要取出元素,需要先读取 top() 或 front(),再调用 pop():
cpp
int value = numbers.top();
numbers.pop();
三、stack 的接口与使用
使用栈需要包含 <stack>:
cpp
#include <stack>
| 接口 | 作用 |
|---|---|
empty() |
判断栈是否为空 |
size() |
返回元素个数 |
top() |
返回栈顶元素的引用 |
push(value) |
压入元素 |
pop() |
删除栈顶元素 |
3.1 基本操作
cpp
#include <iostream>
#include <stack>
int main()
{
std::stack<int> numbers;
numbers.push(10);
numbers.push(20);
numbers.push(30);
while (!numbers.empty())
{
std::cout << numbers.top() << ' ';
numbers.pop();
}
std::cout << '\n';
}
输出:
text
30 20 10
3.2 维护常数时间的最小值
普通栈只能在 O(1) 时间内得到栈顶。若还想随时得到当前最小值,可以额外维护一个最小值栈:
cpp
#include <stack>
#include <stdexcept>
class MinStack
{
public:
void push(int value)
{
elements_.push(value);
if (minimums_.empty() || value <= minimums_.top())
minimums_.push(value);
}
void pop()
{
ensureNotEmpty();
if (elements_.top() == minimums_.top())
minimums_.pop();
elements_.pop();
}
int top() const
{
ensureNotEmpty();
return elements_.top();
}
int getMin() const
{
ensureNotEmpty();
return minimums_.top();
}
bool empty() const
{
return elements_.empty();
}
private:
void ensureNotEmpty() const
{
if (elements_.empty())
throw std::underflow_error("MinStack is empty");
}
std::stack<int> elements_;
std::stack<int> minimums_;
};
minimums_ 只记录"成为过当前最小值"的元素。压入值与当前最小值相等时也要保存,否则两个相同最小值连续出现时,弹出一个就会错误地丢失最小值。
所有接口的时间复杂度都是 O(1),额外空间最坏为 O(n)。
3.3 判断出栈序列是否合法
给定入栈序列和候选出栈序列,可以用一个辅助栈模拟整个过程:不断压入元素;只要栈顶等于下一个待出栈元素,就立即弹出。
cpp
#include <cstddef>
#include <stack>
#include <vector>
bool isValidPopOrder(const std::vector<int>& pushOrder,
const std::vector<int>& popOrder)
{
if (pushOrder.size() != popOrder.size())
return false;
std::stack<int> pending;
std::size_t popIndex = 0;
for (int value : pushOrder)
{
pending.push(value);
while (!pending.empty() &&
pending.top() == popOrder[popIndex])
{
pending.pop();
++popIndex;
}
}
return pending.empty();
}
每个元素最多入栈、出栈各一次,所以时间复杂度为 O(n),额外空间复杂度为 O(n)。
3.4 逆波兰表达式求值
逆波兰表达式把运算符写在操作数之后。例如 (2 + 1) * 3 写成:
text
2 1 + 3 *
遇到数字就压栈;遇到运算符就弹出右操作数和左操作数,计算后把结果压回栈中。
cpp
#include <stack>
#include <string>
#include <vector>
int evaluateRpn(const std::vector<std::string>& tokens)
{
std::stack<int> values;
for (const std::string& token : tokens)
{
if (token != "+" && token != "-" &&
token != "*" && token != "/")
{
values.push(std::stoi(token));
continue;
}
const int right = values.top();
values.pop();
const int left = values.top();
values.pop();
switch (token[0])
{
case '+': values.push(left + right); break;
case '-': values.push(left - right); break;
case '*': values.push(left * right); break;
case '/': values.push(left / right); break;
}
}
return values.top();
}
减法和除法不能交换操作数。第一次弹出的是右操作数,第二次弹出的才是左操作数。
四、queue 的接口与使用
std::queue 与 std::priority_queue 都定义在 <queue> 中。
| 接口 | 作用 |
|---|---|
empty() |
判断队列是否为空 |
size() |
返回元素个数 |
front() |
返回队头元素的引用 |
back() |
返回队尾元素的引用 |
push(value) |
从队尾入队 |
pop() |
删除队头元素 |
4.1 基本操作
cpp
#include <iostream>
#include <queue>
#include <string>
int main()
{
std::queue<std::string> tasks;
tasks.push("compile");
tasks.push("test");
tasks.push("deploy");
std::cout << "front=" << tasks.front()
<< ", back=" << tasks.back() << '\n';
while (!tasks.empty())
{
std::cout << tasks.front() << ' ';
tasks.pop();
}
std::cout << '\n';
}
预期输出:
text
front=compile, back=deploy
compile test deploy
4.2 用两个栈实现队列
一个栈负责接收入队元素,另一个栈负责出队。当输出栈为空时,把输入栈的元素全部倒过去,顺序就会反转两次,最早进入的元素来到顶部。
cpp
#include <stack>
#include <stdexcept>
template<class T>
class QueueByStacks
{
public:
void push(const T& value)
{
input_.push(value);
}
void pop()
{
moveIfNeeded();
if (output_.empty())
throw std::underflow_error("queue is empty");
output_.pop();
}
T& front()
{
moveIfNeeded();
if (output_.empty())
throw std::underflow_error("queue is empty");
return output_.top();
}
bool empty() const
{
return input_.empty() && output_.empty();
}
std::size_t size() const
{
return input_.size() + output_.size();
}
private:
void moveIfNeeded()
{
if (!output_.empty())
return;
while (!input_.empty())
{
output_.push(input_.top());
input_.pop();
}
}
std::stack<T> input_;
std::stack<T> output_;
};
某次 front() 可能搬运很多元素,但每个元素只会从输入栈转移到输出栈一次,因此单次操作的均摊复杂度是 O(1)。
五、priority_queue 的接口与堆结构
优先级队列通常用堆实现。堆可以保存在数组中,对下标 i 的节点:
text
左孩子下标 = 2 * i + 1
右孩子下标 = 2 * i + 2
父节点下标 = (i - 1) / 2
大堆要求父节点不小于孩子,根节点就是最大值。插入时把新元素放到数组末尾并向上调整;删除顶部时把末尾元素换到根位置,再向下调整。
| 接口 | 作用 | 常见复杂度 |
|---|---|---|
top() |
访问堆顶 | O(1) |
push(value) |
插入元素 | O(log n) |
pop() |
删除堆顶 | O(log n) |
empty() |
判断是否为空 | O(1) |
size() |
返回元素个数 | O(1) |
5.1 大堆与小堆
cpp
#include <functional>
#include <iostream>
#include <queue>
#include <vector>
int main()
{
std::vector<int> values{3, 2, 7, 6, 0, 4, 1, 9, 8, 5};
std::priority_queue<int> maxHeap(values.begin(), values.end());
std::priority_queue<int,
std::vector<int>,
std::greater<int>> minHeap(values.begin(), values.end());
std::cout << maxHeap.top() << '\n';
std::cout << minHeap.top() << '\n';
}
输出:
text
9
0
模板参数的含义是:
cpp
std::priority_queue<元素类型, 底层容器, 比较器>
默认比较器是 std::less<T>,得到大堆;使用 std::greater<T> 得到小堆。比较器表达的是元素之间的优先级关系,而不是简单地回答"谁应该放在顶部"。这也是初学时容易把方向写反的地方。
5.2 自定义类型的比较器
不必为了放入优先级队列而修改类型本身。可以单独定义比较器:
cpp
#include <iostream>
#include <queue>
#include <string>
#include <vector>
struct Task
{
std::string name;
int priority;
};
struct LowerPriority
{
bool operator()(const Task& left, const Task& right) const
{
return left.priority < right.priority;
}
};
int main()
{
std::priority_queue<Task,
std::vector<Task>,
LowerPriority> tasks;
tasks.push({"backup", 2});
tasks.push({"restart", 5});
tasks.push({"cleanup", 1});
std::cout << tasks.top().name << '\n';
}
输出是 restart,因为它的 priority 最大。
5.3 查找第 k 大元素
把所有元素放入大堆,弹出前 k - 1 个,顶部就是第 k 大元素:
cpp
#include <queue>
#include <stdexcept>
#include <vector>
int findKthLargest(const std::vector<int>& numbers, int k)
{
if (k <= 0 || static_cast<std::size_t>(k) > numbers.size())
throw std::out_of_range("invalid k");
std::priority_queue<int> heap(numbers.begin(), numbers.end());
for (int i = 1; i < k; ++i)
heap.pop();
return heap.top();
}
建堆后再弹出 k - 1 次,复杂度可写为 O(n + k log n)。如果 n 很大而 k 很小,也可以维护一个最多保存 k 个元素的小堆,把空间降到 O(k)。
六、deque 为什么适合作为默认底层容器
std::deque 是双端队列,支持在头尾高效插入和删除,也支持常数时间随机访问。它看起来像连续数组,内部通常采用分段存储:一个映射表保存若干缓冲区地址,每个缓冲区内部连续。
text
map
+----+----+----+----+
| * | * | * | * |
+--|-+--|-+--|-+--|-+
| | | |
v v v v
[block][block][block][block]
[0..7] [8..15][16..23][24..31]
它不是一整段连续内存,因此不能像 vector 那样假定所有元素地址组成单一连续数组。随机访问需要先找到所在缓冲区,再计算块内偏移。
6.1 与 vector 和 list 的差异
| 特征 | vector |
deque |
list |
|---|---|---|---|
| 存储方式 | 整体连续 | 分段连续 | 节点分散 |
| 随机访问 | O(1) |
O(1) |
不支持 |
| 头部插入删除 | O(n) |
均摊 O(1) |
O(1) |
| 尾部插入删除 | 均摊 O(1) |
均摊 O(1) |
O(1) |
| 扩展时搬运全部元素 | 可能需要 | 通常不需要 | 不需要 |
| 缓存局部性 | 通常最好 | 介于两者之间 | 通常较弱 |
stack 只操作一端,queue 操作两端,而且两者都不提供遍历接口。deque 的双端操作正好满足需求,分段存储带来的迭代器复杂度在这里基本不会暴露给调用者,所以它成为两者的默认底层容器。
deque 仍然可以正常遍历,时间复杂度也是 O(n)。只是迭代器跨越缓冲区边界时需要额外处理,元素也不具备 vector 那样完整的连续性。把它描述为"不能遍历"或"遍历一定很慢"都不准确。
七、自己实现容器适配器
容器适配器的代码并不神秘。下面两个类只负责转发底层容器接口。
7.1 通用栈适配器
cpp
#include <cstddef>
#include <deque>
#include <list>
#include <vector>
template<class T, class Container = std::deque<T>>
class StackAdapter
{
public:
void push(const T& value) { container_.push_back(value); }
void pop() { container_.pop_back(); }
T& top() { return container_.back(); }
const T& top() const { return container_.back(); }
bool empty() const { return container_.empty(); }
std::size_t size() const { return container_.size(); }
private:
Container container_;
};
底层容器可以换成 std::vector<T>、std::deque<T> 或 std::list<T>,只要提供尾部访问、尾插和尾删接口。
cpp
StackAdapter<int, std::vector<int>> stackOnVector;
StackAdapter<int, std::list<int>> stackOnList;
7.2 通用队列适配器
cpp
#include <cstddef>
#include <deque>
template<class T, class Container = std::deque<T>>
class QueueAdapter
{
public:
void push(const T& value) { container_.push_back(value); }
void pop() { container_.pop_front(); }
T& front() { return container_.front(); }
const T& front() const { return container_.front(); }
T& back() { return container_.back(); }
const T& back() const { return container_.back(); }
bool empty() const { return container_.empty(); }
std::size_t size() const { return container_.size(); }
private:
Container container_;
};
std::list 和 std::deque 都能满足要求。std::vector 没有 pop_front(),也不适合直接作为这个队列适配器的底层容器。
7.3 用堆算法包装优先级队列
std::make_heap、std::push_heap 和 std::pop_heap 可以在随机访问区间上维护堆结构:
cpp
#include <algorithm>
#include <cstddef>
#include <functional>
#include <vector>
template<class T,
class Container = std::vector<T>,
class Compare = std::less<T>>
class PriorityQueueAdapter
{
public:
bool empty() const { return container_.empty(); }
std::size_t size() const { return container_.size(); }
const T& top() const { return container_.front(); }
void push(const T& value)
{
container_.push_back(value);
std::push_heap(container_.begin(), container_.end(), compare_);
}
void pop()
{
std::pop_heap(container_.begin(), container_.end(), compare_);
container_.pop_back();
}
private:
Container container_;
Compare compare_;
};
push_heap 假定新元素已经放在区间末尾,并把它向上调整。pop_heap 会把原堆顶交换到区间末尾,同时让前面的区间继续满足堆结构;随后 pop_back() 才真正删除该元素。
底层容器必须提供随机访问迭代器,因此 vector 和 deque 可以使用,list 不行。
八、完整验证示例
下面的程序串联几个主要接口:
cpp
#include <functional>
#include <iostream>
#include <queue>
#include <stack>
#include <string>
#include <vector>
int main()
{
std::stack<int> numbers;
numbers.push(10);
numbers.push(20);
std::cout << "stack top: " << numbers.top() << '\n';
std::queue<std::string> tasks;
tasks.push("compile");
tasks.push("test");
std::cout << "queue front: " << tasks.front() << '\n';
std::priority_queue<int> maxHeap;
std::priority_queue<int,
std::vector<int>,
std::greater<int>> minHeap;
for (int value : {4, 1, 7, 3})
{
maxHeap.push(value);
minHeap.push(value);
}
std::cout << "max heap top: " << maxHeap.top() << '\n';
std::cout << "min heap top: " << minHeap.top() << '\n';
}
编译运行:
bash
g++ stack_queue.cpp -std=c++11 -Wall -Wextra -pedantic -o stack_queue
./stack_queue
预期输出:
text
stack top: 20
queue front: compile
max heap top: 7
min heap top: 1
九、常见问题与易错点
1. 为什么 pop 不返回元素
适配器把访问和删除拆成两个动作。先用 top() 或 front() 获取引用,再调用 pop() 删除。这样既保持接口简单,也避免为了返回值额外复制元素。
2. 空结构能否调用 top、front、back 或 pop
不能。标准适配器不会替调用者检查空状态,这些操作要求容器中已有元素。稳妥写法是在操作前判断 empty()。
3. stack 和 queue 为什么没有 clear
它们只提供符合栈或队列语义的操作。清空可以循环 pop(),也可以与一个空对象交换:
cpp
std::stack<int> empty;
values.swap(empty);
4. queue 能不能使用 vector 作为底层容器
标准 queue 要求底层容器提供 pop_front(),vector 没有该接口。即使手动擦除首元素,也要移动后续元素,成本是 O(n)。
5. priority_queue 为什么不能遍历
它只承诺堆顶元素优先,底层数组并不是完整有序序列。开放迭代器既容易破坏堆结构,也会让调用者误以为遍历结果按优先级排序。需要完整有序结果时,可以复制一份优先级队列并反复读取 top()、pop()。
6. greater 为什么得到小堆
比较器用于判断一个元素的优先级是否低于另一个元素。使用 std::greater<T> 后,较小元素会被保留在堆顶。记不清时,写一个包含两个数的小测试比背模板参数方向更可靠。
7. deque 是连续内存吗
不是整体连续。它通常由多块连续缓冲区组成,适合双端扩展,也支持随机访问,但不能把全部元素当成一个普通 C 数组处理。
十、总结
stack、queue 和 priority_queue 的价值在于限制访问方式。限制不是缺功能,而是让数据流向变得明确:栈只认最后压入的元素,队列只认最早进入的元素,优先级队列只认当前优先级最高的元素。
从实现上看,它们都是薄薄的一层接口包装。stack 和 queue 默认借助适合双端操作的 deque,priority_queue 默认在 vector 上维护堆。掌握底层容器必须提供哪些接口,再结合空容器检查、比较器方向和 pop() 的行为,这三类适配器就能用得很稳。