深入理解 C++ STL:stack 和 queue 的底层原理与实现

系列文章:继 stringvectorlist 之后,这篇聊 stackqueue。 这两个容器在 STL 里有点特殊------它们不是"真正的容器",而是容器适配器(Container Adapter)。 理解这个设计思想,比死记接口重要得多。


一、stack 是什么

1.1 概念与结构

栈(stack)是一种**后进先出(LIFO, Last In First Out)**的数据结构。

你可以把它想象成一摞盘子:放盘子只能放在最上面,拿盘子也只能从最上面拿。任何时候你都只能操作**栈顶(top)**这一个位置。

这个限制看起来很强,但正因为有这个限制,stack 在括号匹配、递归模拟、表达式求值、DFS 等场景里极其好用。

1.2 接口一览

函数 说明
stack() 构造空栈
empty() 检测是否为空,返回 bool
size() 返回元素个数
top() 返回栈顶元素的引用(可读可写)
push(val) 将 val 压入栈顶
pop() 弹出栈顶元素(无返回值)

注意 pop() 不返回被弹出的值,这是 STL 的一贯设计:让操作和访问分开 ,避免在异常场景下同时承担两个职责。如果你想拿到栈顶的值再弹出,要先 top()pop()

cpp 复制代码
#include <stack>
#include <iostream>
using namespace std;

int main()
{
    stack<int> st;

    // 压栈
    st.push(1);
    st.push(2);
    st.push(3);

    // 遍历:只能通过 top() + pop() 逐个取出
    while (!st.empty())
    {
        cout << st.top() << " ";  // 先访问
        st.pop();                 // 再弹出
    }
    // 输出:3 2 1(后进先出)

    return 0;
}

二、stack 的底层:容器适配器

2.1 stack 不是"亲自实现"的容器

打开 STL 源码(libstdc++),你会发现 stack 的定义非常短:

cpp 复制代码
template <class T, class Container = deque<T>>
class stack
{
public:
    typedef typename Container::value_type      value_type;
    typedef typename Container::size_type       size_type;
    typedef          Container                  container_type;

protected:
    Container c;  // 底层容器

public:
    bool      empty()        { return c.empty(); }
    size_type size()         { return c.size(); }
    value_type& top()        { return c.back(); }
    void push(const T& val)  { c.push_back(val); }
    void pop()               { c.pop_back(); }
};

stack 自己不存储数据 ,它把所有操作转发给底层的容器 c。这就是"适配器"的含义:用一个已有的容器,套上受限制的接口,暴露特定的行为。

2.2 默认底层是 deque,也可以换成 vector 或 list

cpp 复制代码
// 默认:底层用 deque
stack<int> st1;

// 显式指定底层用 vector
stack<int, vector<int>> st2;

// 底层用 list 也可以(但性能差一些)
stack<int, list<int>> st3;

为什么默认是 deque 而不是 vector

deque 两端的 push/pop 都是 O(1),而且没有 vector 那种整体扩容的开销。虽然 vector 在 push_back/pop_back 上也是均摊 O(1),但 deque 的内存分配策略更稳定,不会出现大块搬移。实际使用中两者性能差异不大,大多数情况下用 vector 底层的 stack 会更缓存友好。

底层容器的要求: 必须支持 back()push_back()pop_back()empty()size()。满足这些的容器都能作为 stack 的底层。

2.3 手写一个 stack(基于 vector)

理解了上面的原理,手写一个就很简单:

cpp 复制代码
namespace Jianyi
{
    template <class T, class Container = std::vector<T>>
    class stack
    {
    public:
        void push(const T& val)
        {
            _con.push_back(val);
        }

        void pop()
        {
            // 不检查 empty,和 STL 保持一致:由调用者保证
            _con.pop_back();
        }

        T& top()
        {
            return _con.back();
        }

        const T& top() const
        {
            return _con.back();
        }

        bool empty() const
        {
            return _con.empty();
        }

        size_t size() const
        {
            return _con.size();
        }

    private:
        Container _con;
    };
}

几乎所有逻辑都是对 _con 的转发调用。这个设计的好处:stack 自己不需要管内存,底层容器全权负责。换底层容器只需要改模板参数,stack 的代码一行都不用改。


三、经典问题:最小栈

这是 LeetCode 155 题,也是 stack 最经典的面试题之一。

要求: 设计一个栈,在 O(1) 时间内支持 pushpoptop、以及 getMin(返回栈中最小值)。

3.1 为什么这道题有难度

难点在于 pop 操作。如果只用一个变量 _min 记录当前最小值,那么当最小元素被弹出时,你不知道下一个最小值是什么------除非重新扫描整个栈,但那是 O(n)。

3.2 辅助栈方案

两个栈_elem 存所有数据,_min 存当前的最小值序列。

核心规则:

  • push(x) 时:x 一定进 _elem;如果 _min 为空,或者 x <= _min.top(),则 x 也进 _min
  • pop() 时:先弹 _elem;如果被弹出的值等于 _min.top()_min 也弹一个。
  • getMin() 时:直接返回 _min.top()

为什么 push 用 <= 而不是 <

因为可能有重复的最小值。比如压入 [3, 1, 1]_min 里应该是 [3, 1, 1],这样 pop 掉一个 1 之后,_min 还剩 [3, 1],最小值仍然是 1。如果用 <,第二个 1 不会进 _min,pop 掉第一个 1_min 也跟着弹,getMin() 就会返回错误的 3

cpp 复制代码
class MinStack
{
public:
    void push(int x)
    {
        _elem.push(x);

        // 空栈,或者 x 小于等于当前最小值,才压入 _min
        if (_min.empty() || x <= _min.top())
            _min.push(x);
    }

    void pop()
    {
        // 如果弹出的是当前最小值,_min 也要同步弹出
        if (_elem.top() == _min.top())
            _min.pop();

        _elem.pop();
    }

    int top()
    {
        return _elem.top();
    }

    int getMin()
    {
        return _min.top();
    }

private:
    stack<int> _elem;  // 数据栈
    stack<int> _min;   // 最小值栈
};

走一遍示例:

操作序列:push(5), push(3), push(7), push(3), pop(), pop()

cpp 复制代码
操作         _elem          _min        getMin()
push(5)    [5]            [5]             5
push(3)    [5,3]          [5,3]           3
push(7)    [5,3,7]        [5,3]           3  (7 > 3,不进 _min)
push(3)    [5,3,7,3]      [5,3,3]         3  (3 <= 3,进 _min)
pop()      [5,3,7]        [5,3]           3  (弹出的 3 == _min.top(),同步弹)
pop()      [5,3]          [5,3]           3  (弹出的 7 != _min.top(),_min 不动)

空间上,_min 最坏情况和 _elem 一样大(每个元素都递减时),平均情况远小于 _elem


四、queue 是什么

4.1 概念与结构

队列(queue)是**先进先出(FIFO, First In First Out)**的数据结构。

生活中最直觉的比喻就是排队:先来的人先出去,后来的只能在队尾等。

4.2 接口一览

函数 说明
queue() 构造空队列
empty() 检测是否为空
size() 返回元素个数
front() 返回队头元素的引用
back() 返回队尾元素的引用
push(val) 在队尾插入元素
pop() 弹出队头元素(无返回值)
cpp 复制代码
#include <queue>
#include <iostream>
using namespace std;

int main()
{
    queue<int> q;

    q.push(1);
    q.push(2);
    q.push(3);

    while (!q.empty())
    {
        cout << q.front() << " ";
        q.pop();
    }
    // 输出:1 2 3(先进先出)

    return 0;
}

4.3 queue 的底层实现

queue 同样是容器适配器,默认底层是 deque

cpp 复制代码
template <class T, class Container = deque<T>>
class queue
{
protected:
    Container c;

public:
    bool empty()             { return c.empty(); }
    size_t size()            { return c.size(); }
    T& front()               { return c.front(); }
    T& back()                { return c.back(); }
    void push(const T& val)  { c.push_back(val); }  // 队尾入
    void pop()               { c.pop_front(); }      // 队头出
};

queue 需要两端都能高效操作:队尾 push_back,队头 pop_front。vector 的 pop_front 是 O(n)(要整体移动),所以 queue 不能用 vector 做底层 ,必须用 dequelist

手写 queue:

cpp 复制代码
namespace Jianyi
{
    template <class T, class Container = std::deque<T>>
    class queue
    {
    public:
        void push(const T& val)
        {
            _con.push_back(val);
        }

        void pop()
        {
            _con.pop_front();
        }

        T& front()
        {
            return _con.front();
        }

        const T& front() const
        {
            return _con.front();
        }

        T& back()
        {
            return _con.back();
        }

        bool empty() const
        {
            return _con.empty();
        }

        size_t size() const
        {
            return _con.size();
        }

    private:
        Container _con;
    };
}

五、经典问题:用两个栈实现队列

LeetCode 232,也是面试里的高频题,考的是对 stack 和 queue 本质差异的理解。

5.1 思路

stack 是 LIFO,queue 是 FIFO,方向相反。两个 LIFO 叠在一起可以变成 FIFO。

设两个栈:_pushSt(专门入队)和 _popSt(专门出队)。

规则:

  • push(x):直接压入 _pushSt
  • pop() / front():如果 _popSt 为空,将 _pushSt 所有元素倒入 _popSt;然后对 _popSt 操作。

为什么这样正确?

假设入队顺序是 1, 2, 3:_pushSt 从底到顶是 [1, 2, 3],top 是 3。

_pushSt 全部倒入 _popSt:[3, 2, 1],top 是 1。

现在对 _popSt 做 pop(),弹出的是 1------正是最早入队的,符合 FIFO。

cpp 复制代码
class MyQueue
{
public:
    void push(int x)
    {
        _pushSt.push(x);
    }

    int pop()
    {
        // _popSt 为空时才搬运,不是每次 pop 都搬
        if (_popSt.empty())
            _move();

        int val = _popSt.top();
        _popSt.pop();
        return val;
    }

    int peek()  // 返回队头,不弹出
    {
        if (_popSt.empty())
            _move();

        return _popSt.top();
    }

    bool empty()
    {
        // 两个栈都空,队列才真的空
        return _pushSt.empty() && _popSt.empty();
    }

private:
    void _move()
    {
        // 把 _pushSt 全部倒入 _popSt
        while (!_pushSt.empty())
        {
            _popSt.push(_pushSt.top());
            _pushSt.pop();
        }
    }

    stack<int> _pushSt;
    stack<int> _popSt;
};

时间复杂度分析:

push 是严格 O(1)。pop 均摊 O(1)------每个元素最多被搬运一次(从 _pushSt_popSt),一旦搬过去之后所有 pop 都是 O(1),直到 _popSt 耗尽才触发下一次搬运。

关键点:不要每次 pop 都触发搬运,只在 _popSt 为空时才搬。这是均摊 O(1) 的前提。


六、deque:被遗忘的底层主力

stack 和 queue 默认都用 deque,它值得单独说一说。

6.1 deque 的结构

deque(double-ended queue,双端队列)和 vector 的最大区别在于内存布局:

  • vector:一块连续内存,扩容时整体搬移。
  • deque:分段连续,有一个"中控数组"(map),每个槽指向一个固定大小的内存块(buffer)。

这种结构让 deque 两端的 push/pop 都是 O(1),且不需要搬移已有数据。代价是随机访问比 vector 慢(需要两次间接寻址),也更难做到缓存友好。

6.2 deque 的短板

正因为这个结构,deque 有几个明显弱点:

  • 随机访问效率低于 vector(有额外的地址计算)。
  • 中间插入/删除效率低(和 vector 一样要移动数据)。
  • 不适合大量随机访问的场景。

所以 STL 没有把 deque 单独推荐为"通用容器",它主要的价值就是作为 stack 和 queue 的底层,以及 priority_queue 不需要它但 queue 需要它。


七、priority_queue:加了优先级的队列

7.1 是什么

priority_queue(优先队列)每次弹出的不是最先入队的元素,而是优先级最高的元素 。默认情况下是最大堆,即 top() 返回当前最大值。

cpp 复制代码
#include <queue>
#include <iostream>
using namespace std;

int main()
{
    // 默认最大堆
    priority_queue<int> pq;
    pq.push(3);
    pq.push(1);
    pq.push(4);
    pq.push(1);
    pq.push(5);

    while (!pq.empty())
    {
        cout << pq.top() << " ";
        pq.pop();
    }
    // 输出:5 4 3 1 1

    return 0;
}

7.2 接口

函数 说明
top() 返回堆顶元素(最大/最小值)
push(val) 插入元素,自动维护堆序
pop() 弹出堆顶,自动维护堆序
empty() 是否为空
size() 元素个数

7.3 最小堆写法

cpp 复制代码
// 方法1:仿函数
priority_queue<int, vector<int>, greater<int>> minHeap;

// 方法2:插入时取负值(hack,不推荐)
priority_queue<int> hackyMinHeap;
hackyMinHeap.push(-val);  // 存的时候取负
int top = -hackyMinHeap.top();  // 取的时候再取负

7.4 自定义类型的比较

cpp 复制代码
struct Task
{
    int priority;
    string name;
};

// 仿函数:按 priority 降序(数字大的先出)
struct CmpTask
{
    bool operator()(const Task& a, const Task& b)
    {
        return a.priority < b.priority;  // 注意:和 sort 的 cmp 语义一致
    }
};

priority_queue<Task, vector<Task>, CmpTask> pq;

底层实现: priority_queue 的底层是 (heap),默认用 vector 存数据,通过 push_heap / pop_heap 维护堆序。它和 stack/queue 一样是容器适配器,但底层容器是 vector 而不是 deque。


八、对比与选型总结

stack queue priority_queue
顺序 LIFO FIFO 按优先级
默认底层 deque deque vector(堆)
可用底层 deque / vector / list deque / list vector / deque
top 操作 O(1) O(1) O(1)
push / pop O(1) O(1) O(log n)
典型场景 括号匹配、DFS、表达式求值 BFS、任务调度 任务优先级、Dijkstra、Top K

选底层容器的原则:

  • 如果操作频繁在两端进行 → deque
  • 如果只在尾部操作 → vector(更缓存友好)
  • 如果需要任意位置插入/删除 → list(但随机访问差)

九、常见坑和注意事项

1. pop() 之前一定要判空

stackqueuepriority_queuepop()top() 在容器为空时行为是未定义的(UB),不会抛异常,而是直接崩溃或产生随机结果。

cpp 复制代码
// 错误写法
st.pop();  // 不确认 empty

// 正确写法
if (!st.empty())
    st.pop();

2. 用两个栈实现队列,empty() 要同时判断两个栈

cpp 复制代码
// 错误
return _pushSt.empty();  // 可能 _popSt 里还有数据

// 正确
return _pushSt.empty() && _popSt.empty();

3. priority_queue 的比较器方向和 sort 的比较器一致,但效果相反

sortcmp(a, b) 返回 true 表示 a 排在 b 前面。priority_queuecmp(a, b) 返回 true 表示 a 的优先级低于 b(a 比 b 晚出队)。

这是因为 priority_queue 默认是最大堆,堆的内部排序和"谁先出"的方向是反的。记忆方法:想要谁先出,就让比较器在比较它时返回 false。

4. stack 没有迭代器,无法用范围 for

cpp 复制代码
stack<int> st;
for (auto x : st)  // 编译错误!stack 没有 begin()/end()
    cout << x;

// 只能通过 top() + pop() 遍历(会破坏原栈)
while (!st.empty())
{
    cout << st.top();
    st.pop();
}

十、延伸思考

Q1:为什么 STL 要设计"容器适配器"这个概念,而不是直接写 stack 和 queue 的完整实现?

因为 stack 和 queue 的逻辑非常简单,核心是"接口限制"而不是"数据结构创新"。如果自己实现,需要重新处理内存管理、扩容、迭代器等一堆事情,而这些已经被 vector/deque/list 解决了。适配器模式让代码复用做到极致,同时给用户提供了换底层容器的灵活性。这是 STL 设计哲学的一个缩影:算法与容器分离,通用性优先

Q2:deque 两端都是 O(1),为什么不直接用 deque,而要有 stack 和 queue?

deque 的接口太丰富了:随机访问、中间插入、两端操作全支持。直接用 deque 的话,调用者可能不小心(或者故意)做了不符合"栈/队列"语义的操作(比如从队头 pop 一个 stack)。stackqueue 通过限制接口,让代码的意图更清晰,也让编译器帮你检查不合法的操作。这是类型系统即文档的思想。

Q3:如果要实现"双端栈"(两个栈共享一块数组,从两端往中间增长),该怎么做?

这是一个内存利用率更高的设计,常见于嵌入式或内存受限场景:

cpp 复制代码
template <class T, size_t N>
class DoubleStack
{
public:
    void pushLeft(const T& val)
    {
        if (_left + 1 == _right) throw overflow_error("full");
        _data[_left++] = val;
    }

    void pushRight(const T& val)
    {
        if (_left + 1 == _right) throw overflow_error("full");
        _data[--_right] = val;
    }

    // ... pop/top 类似

private:
    T _data[N];
    size_t _left = 0;       // 左栈栈顶(从左往右增长)
    size_t _right = N;      // 右栈栈顶(从右往左增长)
};

两个栈共用同一块内存,只有当它们加起来的元素数超过 N 时才会溢出,单独用其中一个栈时可以用满整块空间。


小结

  • stackqueue 不是独立实现的数据结构,而是对底层容器的接口适配
  • 默认底层是 dequestack 也可以用 vector,但 queue 不能(因为需要 pop_front)。
  • 最小栈 的核心是辅助栈同步维护最小值,push 用 <= 而不是 <
  • 两个栈实现队列 的核心是懒搬运:只有 _popSt 为空时才把 _pushSt 的数据倒过来。
  • priority_queue 是堆,push/pop 是 O(log n),自定义比较器时注意方向。
  • 三者都没有迭代器,体现了"限制接口 = 明确语义"的设计哲学。
相关推荐
gCode Teacher 格码致知2 小时前
Python教学:正则表达式的寻找、匹配、替换、删除 四种模式案例-由Deepseek产生
开发语言·python·正则表达式
Zfox_2 小时前
【LangGraph】持久化(Persistence)
开发语言·人工智能·redis·langchain·ai编程·langgraph
通信仿真爱好者2 小时前
【无标题】
人工智能·算法·机器学习
blxr_2 小时前
MySql锁机制
java·开发语言
誰能久伴不乏2 小时前
从底层看透音视频架构:FFmpeg 实时视频推流深度解析
linux·c++·tcp/ip·ffmpeg
此生决int2 小时前
C++快速上手java备战期末考——初识java
java·c++·期末复习
落羽的落羽2 小时前
【算法札记】练习 | Week3
linux·服务器·数据结构·c++·人工智能·算法·动态规划
计算机安禾2 小时前
【c++面向对象编程】第13篇:继承(三):同名隐藏与作用域覆盖
开发语言·c++·iphone
ch.ju2 小时前
Java Programming Chapter 3——Dynamic acquisition of array
java·开发语言