C++ stack、queue 与 priority_queue:容器适配器原理与实战

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()

这也解释了两个常见问题。

stackqueue 没有 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::queuestd::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::liststd::deque 都能满足要求。std::vector 没有 pop_front(),也不适合直接作为这个队列适配器的底层容器。

7.3 用堆算法包装优先级队列

std::make_heapstd::push_heapstd::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() 才真正删除该元素。

底层容器必须提供随机访问迭代器,因此 vectordeque 可以使用,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 数组处理。

十、总结

stackqueuepriority_queue 的价值在于限制访问方式。限制不是缺功能,而是让数据流向变得明确:栈只认最后压入的元素,队列只认最早进入的元素,优先级队列只认当前优先级最高的元素。

从实现上看,它们都是薄薄的一层接口包装。stackqueue 默认借助适合双端操作的 dequepriority_queue 默认在 vector 上维护堆。掌握底层容器必须提供哪些接口,再结合空容器检查、比较器方向和 pop() 的行为,这三类适配器就能用得很稳。

相关推荐
晚笙coding1 小时前
LeetCode 226. 翻转二叉树(Invert Binary Tree)
算法·leetcode·职场和发展
爱勇宝1 小时前
《道德经》第6章:别把团队的创造力用到枯竭
前端·后端
铅笔侠_小龙虾1 小时前
Rust 学习(6)-所有权规则、移动语义、Clone 与 Copy
python·学习·rust
无双_Joney1 小时前
[四期 - 4] NestJS专栏 - 征召一下大家的意见,非常想知道大家都想看看Nest的哪些方面
前端·后端·nestjs
彭亚川Allen1 小时前
停更的这一年,All In AI、陪媳妇生娃、还考了个研
前端·后端·产品经理
kiss strong1 小时前
AI学习-langchain
学习·langchain
techdashen1 小时前
Go 1.25 新增 `reflect.TypeAssert`:更直接、更高效地从 `reflect.Value` 取出类型值
开发语言·后端·golang
2zcode1 小时前
项目文档:基于MATLAB低采样率ISAR成像的快速稀疏重建算法研究
开发语言·算法·matlab
xixi09242 小时前
JMeter5.6.3基础使用教程1
java·开发语言