C++ 容器适配器源码探秘:手撕 stack - queue - priority_queue

引言

C++ 标准模板库(STL)中,stackqueue priority_queue 是三个非常实用的容器。初学者往往只知道怎么用——push、pop、top,却很少思考它们的底层是怎么工作的。

实际上,它们并不真正"存储"数据,而是站在其他容器的肩膀上,通过限制接口和封装来实现特定行为。这种设计模式叫做容器适配器(Container Adapter)。

比如 stack 默认依赖 deque,但也可以换成 vector 或 list;queue 默认也是 deque,却不能直接使用 vector(因为 pop_front 效率太低);而 priority_queue 则不同,它默认基于 vector,并在其上构建了堆结构来保证每次取出的都是最大(或最小)元素。
理解这三者的底层实现,是吃透 C++ 容器适配器的关键。本文将从 deque 的存储结构讲起,再到堆的向上/向下调整算法,最后带你一步步手撕 stack、queue priority_queue 的完整代码。

deque:双端队列

什么是 deque

deque(double-ended queue,双端队列)是一种可以在头尾两端进行常数时间插入和删除操作的序列容器。它在 C++ STL 中的声明在 <deque> 头文件中。
deque 结合了 vector 的随机访问能力和 list 的头尾操作高效优点,是 stack queue 默认底层容器的理想选择。
deque 并不是一块连续的大内存,而是由一段段固定大小的连续缓冲区(buffer)组成,并通过一个中控器(map)来管理这些缓冲区。
![[Pasted image 20260722203558.png]]

  • 中控器(map):一个指针数组,每个元素指向一块缓冲区
  • 缓冲区(buffer):一段连续的固定长度内存,默认大小通常为 512 字节 / 元素大小
  • 迭代器:deque 的迭代器内部维护了四个指针——cur(当前元素)、first(缓冲区首)、last(缓冲区尾)、node(指向中控器中的位置),实现跨缓冲区遍历
    deque 的迭代器深入解析

迭代器的内部结构
deque 的迭代器不是普通的指针,而是一个自定义类,内部维护了四个指针:

struct __deque_iterator {
    T* cur;      // 当前指向的元素
    T* first;    // 当前缓冲区的起始位置
    T* last;     // 当前缓冲区的结束位置(末尾哨兵)
    map_pointer node; // 指向中控器中当前缓冲区的指针
};
  • cur:迭代器当前指向的具体元素
  • first / last:当前所在缓冲区的[first, last) 范围,用来判断是否走到缓冲区边界
  • node:指向中控器(map)中对应的元素,即 &map[pos],用于跨缓冲区跳转
    在这里插入图片描述

迭代器如何在缓冲区内部移动

前移(++)

self& operator++() {
    ++cur;                // 先移动到下一个元素
    if (cur == last) {    // 是否走到了当前缓冲区的末尾?
        set_node(node + 1); // 切换到下一个缓冲区
        cur = first;      // cur 指向新缓冲区的开头
    }
    return *this;
}

后移(–it):

self& operator--() {
    if (cur == first) {    // 是否走到了当前缓冲区的开头?
        set_node(node - 1); // 切换到上一个缓冲区
        cur = last;        // cur 指向新缓冲区的末尾
    }
    --cur;                 // 再往前走一个元素
    return *this;
}

跨缓冲区跳转(set_node):

void set_node(map_pointer new_node) {
    node = new_node;
    first = *new_node;     // 新缓冲区的起始
    last = first + buffer_size(); // 新缓冲区的末尾
}

![[Pasted image 20260722204135.png]]

随机访问(+n / -n)
deque 迭代器支持 it + n 的随机访问,计算比 vector 复杂得多:

self& operator+=(difference_type n) {
    difference_type offset = n + (cur - first);
    if (offset >= 0 && offset < difference_type(buffer_size()))
        cur += n;   // 没跨缓冲区,直接偏移
    else {
        // 跨了缓冲区,需要计算跳到哪个缓冲区
        difference_type node_offset = 
            offset > 0 ? offset / buffer_size()
                       : -((-offset - 1) / buffer_size()) - 1;
        set_node(node + node_offset);
        // 计算在新缓冲区中的偏移
        cur = first + (offset - node_offset * buffer_size());
    }
    return *this;
}

这也是为什么 dequeoperator[]O(1) 但常数比 vector 大——需要做一次除法和取模计算目标位置所在的缓冲区和偏移。

stack 的模拟实现

什么是 stack
栈(stack)是一种先进后出(FILO)的数据结构。元素只能从栈顶一端插入和删除,就像一摞盘子——你永远只能拿走最上面的那个。
STL 的 stack 本身并不存储数据,而是适配到底层容器上,通过限制接口来实现栈的行为。
容器适配器的概念

template <class T, class Con = deque<T>>
class stack { ... };

第二个模板参数 Con 就是底层容器,默认是 deque<T>。这意味着 stack 并不关心数据怎么存,它只是把底层容器的某些接口"包装"成自己的语义:

  • push→ 调用底层容器的 push_back
  • pop → 调用底层容器的 pop_back
  • top → 调用底层容器的 back
    你也可以传入其他容器,只要它支持 push_back、pop_back、back 等操作:
    stack<int, vector<int>> st; // 用 vector 做底层
    stack<int, list<int>> st; // 用 list 做底层
    完整代码实现
#pragma once
#include <deque>

namespace zy
{
    template <class T, class Con = deque<T>>
    class stack
    {
    public:
        stack()
        {}

        void push(const T& x)
        {
            _c.push_back(x);
        }

        void pop()
        {
            _c.pop_back();
        }

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

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

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

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

    private:
        Con _c;
    };
}

关键点说明

  • push:直接尾插——栈顶就是底层容器的尾部
  • pop:尾删——移除栈顶元素
  • top:返回 back()——即底层容器的最后一个元素
  • 为什么用 deque 而不是 vector:
    虽然 vector 也能做同样的操作,但 deque 在扩容时不需要搬移旧数据,性能更优;并且如果用户想用 list 替换,deque 的接口天然兼容

queue 的模拟实现

什么是 queue
队列(queue)是一种先进先出(FIFO)的数据结构。元素从队尾入队,从队头出队,就像排队买票——先到的人先服务。
与 stack 的不同
queue 需要在两端操作:

  • push:队尾插入 → push_back
  • pop:队头删除 → pop_front
    这就排除了 vector 作为默认容器的可能,因为 vector 没有 pop_front,即使手动 erase 头部也是 O(n) 的。
    完整代码实现
#pragma once
#include <deque>

namespace zy
{
    template <class T, class Con = deque<T>>
    class queue
    {
    public:
        queue()
        {}

        void push(const T& x)
        {
            _c.push_back(x);
        }

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

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

        const T& back() const
        {
            return _c.back();
        }

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

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

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

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

    private:
        Con _c;
    };
}

关键点说明

  • backfront 分别对应底层容器的 back() front(),因为 queue 需要同时访问队头和队尾
  • 默认不能用 vector:vector erase( begin() ) O(n),不符合 queueO(1) 要求
  • 可以换成 list:list 同时支持 push_back、pop_front、front、back,完全胜任

stack 与 queue 对比小结

stackqueue
数据流向一端进出(后进先出)两端操作(先进先出)
pushpush_backpush_back
poppop_backpop_front
访问top() = back()front() + back()
默认容器dequedeque
可选容器vector / list / dequelist / deque(不能用 vector)

堆的向上调整与向下调整

priority_queue(优先级队列)的底层核心就是堆。在正式看代码之前,必须先理解堆的这两个关键操作。
什么是堆
堆是一棵完全二叉树,用数组存储时满足:

  • 大堆(大根堆):任意节点的值 ≥ 其子节点的值,根节点是最大值
  • 小堆(小根堆):任意节点的值 ≤ 其子节点的值,根节点是最小值
    父子节点的下标关系(数组从 0 开始):
    parent = (kid - 1) / 2 leftKid = parent * 2 + 1 rightKid = parent * 2 + 2``
    向上调整(adjust_up
    场景:向堆中插入一个新元素(插入到数组末尾),需要将其调整到正确位置以维持堆结构。
    过程:
  1. 从新插入的节点(最后一个元素)开始
  2. 与父节点比较:如果当前节点比父节点"更优先"(大堆中更大/小堆中更小),则交换
  3. 向上走到父节点位置,重复步骤 2
  4. 直到到达根节点或不再需要交换
    代码实现:
void adjust_up(int kid)
{
    int parent = (kid - 1) / 2;
    while (kid > 0)
    {
        if (comp(_c[parent], _c[kid]))  // 如果父节点"小于"子节点,则交换
        {
            std::swap(_c[kid], _c[parent]);
            kid = parent;
            parent = (kid - 1) / 2;
        }
        else
        {
            break;
        }
    }
}

向下调整(adjust_down)
场景:

  1. 删除堆顶元素(最大值/最小值):将最后一个元素移到堆顶,然后向下调整
  2. 从一个无序数组构建堆:从最后一个非叶子节点开始向下调整
    过程(以大堆为例):
  • 从指定节点(通常是根节点)开始
  • 找出当前节点及其左右子节点中最大的那个
  • 如果当前节点不是最大的,则与最大子节点交换
  • 向下走到交换后的子节点位置,重复上述步骤
  • 直到到达叶子节点或当前节点就是最大的

代码实现:

void adjust_down()
{
    int parent = 0;
    int kid = parent * 2 + 1;      // 默认先选左孩子
    while (kid < _c.size())
    {
        // 如果右孩子存在且右孩子比左孩子"更优先",则选右孩子
        if (kid + 1 < size() && comp(_c[kid], _c[kid + 1]))
        {
            kid++;
        }
        // 如果父节点"小于"子节点,则交换
        if (comp(_c[parent], _c[kid]))
        {
            std::swap(_c[kid], _c[parent]);
            parent = kid;
            kid = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

两个操作的时间复杂度都是 *O(log n)*,这也是堆排序和优先级队列高效的关键。

这里补充一下无序建堆
从最后一个非叶子节点开始向前做向下调整,时间复杂度 O(n)。
无序数组快速建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
adjust_down(i); }`
向下调整:从底部的非叶子节点开始,往前走到根 → 先把底层的小堆建好,上层再往下调整时,下面的子树已经合法了
adjust_down(i) 的前提是:i 的左右子树已经是堆

仿函数(Functor)

什么是仿函数
仿函数是一个重载了operator()的类对象,它可以像函数一样被调用。在 priority_queue 中,仿函数用于决定元素的优先级比较规则。
为什么需要仿函数
如果直接用 >< 硬编码比较逻辑,那么最大堆和最小堆需要写两份重复的代码。使用仿函数作为模板参数,可以在不修改算法代码的前提下,通过传入不同的仿函数来改变比较行为。
实现 Greater 和 Less

// Less:判断 a < b,用于构建大堆(父节点"小于"子节点时交换)
template <class T>
class Less
{
public:
    bool operator()(const T& a, const T& b)
    {
        return a < b;
    }
};

// Greater:判断 a > b,用于构建小堆
template <class T>
class Greater
{
public:
    bool operator()(const T& a, const T& b)
    {
        return a > b;
    }
};

使用示例

Less<int> ls;
cout << ls(3, 5) << endl;   // 输出 1(true),因为 3 < 5

Greater<int> gt;
cout << gt(3, 5) << endl;   // 输出 0(false),因为 3 > 5 为假
在 priority_queue 中的使用
template <class T, class Container = vector<T>, class Compare = Less<T>>
class priority_queue
{
    // ...
    Compare comp;  // 仿函数成员
    // ...
};

比较时用 comp(a, b) 代替 a < b:

  • comp = Less<T> → comp(a, b) 等价于 a < b → 大堆(小的往下沉,大的浮上来)
  • comp = Greater<T> → comp(a, b) 等价于 a > b → 小堆(大的往下沉,小的浮上来)

之所以仿函数能内联,是因为 comp(a, b) 在编译期就确定了具体类型(Less<T> Greater<T>),编译器可以直接内联 operator() 的调用;而函数指针需要运行时间接寻址。

priority_queue 的模拟实现

什么是 priority_queue
优先级队列(priority_queue)是一种允许以任意顺序插入元素,但总是取出优先级最高元素的容器适配器。它的底层数据结构就是堆——默认是大堆,堆顶始终是最大值。
当调用 pop() 时,堆顶元素被移除并重新调整堆;当调用 top() 时,返回堆顶元素。
完整代码实现

#pragma once

#include <vector>
#include <functional>

namespace zy
{
    // 仿函数:用于比较两个元素
    template <class T>
    class Less
    {
    public:
        bool operator()(const T& a, const T& b)
        {
            return a < b;
        }
    };

    template <class T>
    class Greater
    {
    public:
        bool operator()(const T& a, const T& b)
        {
            return a > b;
        }
    };

    template <class T, class Container = vector<T>, class Compare = Less<T>>
    class priority_queue
    {
    public:
        priority_queue()
        {}

        // 迭代器区间构造函数:快速建堆(O(n) 向下调整)
        template <class InputIterator>
        priority_queue(InputIterator first, InputIterator last)
            : _c(first, last)
        {
            for (int i = (_c.size() - 1 - 1) / 2; i >= 0; --i)
            {
                adjust_down(i);
            }
        }

        void push(const T& x)
        {
            _c.push_back(x);            // 尾插
            adjust_up(_c.size() - 1);   // 向上调整
        }

        void pop()
        {
            std::swap(_c[0], _c[_c.size() - 1]); // 堆顶与最后一个元素交换
            _c.pop_back();                       // 删除堆顶(原最后一个元素)
            adjust_down(0);                      // 从根开始向下调整
        }

        T& top()
        {
            return _c[0];
        }

        const T& top() const
        {
            return _c[0];
        }

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

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

    private:
        void adjust_up(int kid)
        {
            int parent = (kid - 1) / 2;
            while (kid > 0)
            {
                if (comp(_c[parent], _c[kid]))
                {
                    std::swap(_c[kid], _c[parent]);
                    kid = parent;
                    parent = (kid - 1) / 2;
                }
                else
                {
                    break;
                }
            }
        }

        void adjust_down(int parent)
        {
            int kid = parent * 2 + 1;
            while (kid < _c.size())
            {
                if (kid + 1 < _c.size() && comp(_c[kid], _c[kid + 1]))
                {
                    ++kid;
                }
                if (comp(_c[parent], _c[kid]))
                {
                    std::swap(_c[kid], _c[parent]);
                    parent = kid;
                    kid = parent * 2 + 1;
                }
                else
                {
                    break;
                }
            }
        }

    private:
        Container _c;
        Compare comp;
    };
}

关键点说明

  1. 默认模板参数 Compare = Less<T>
    Less 作为大堆(最大堆):当 _c[parent] < _c[kid] 时交换,大的元素向上浮。
    // Less:a < b 返回 true → 父小于子就交换 → 大堆
    priority_queue<int> pq; // 大堆
    priority_queue<int, vector<int>, Greater<int>> pq; // 小堆

  2. pop() 的三步操作
    第一步:堆顶与末尾交换
    第二步:删除末尾
    第三步:向下调整根节点

  3. 迭代器区间构造函数

template <class InputIterator>
priority_queue(InputIterator first, InputIterator last)
    : _c(first, last)      // 直接用数据初始化底层容器
{
    // 从最后一个非叶子节点开始向下调整
    for (int i = (_c.size() - 1 - 1) / 2; i >= 0; --i)
    {
        adjust_down(i);
    }
}

先用传入区间构造 vector,再统一向下调整建堆。时间复杂度 O(n),比一个个 push O(n log n) 更优。

总结

从 deque 的底层结构到堆的调整算法,再到三个容器适配器的完整实现,贯穿其中的设计思想是复用与适配:

  • stack 和 queue 复用了 deque 的线性操作,通过限制接口得到特定的数据结构
  • priority_queue 复用了 vector 的随机访问能力,在上面构建了堆
  • 仿函数将比较策略抽象出来,让同一份算法代码同时支持大堆和小堆

理解容器的底层,才能更好地使用容器。 希望这篇文章能帮你彻底搞懂这三个适配器的来龙去脉。谢谢大家观看

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值