模拟实现 C++ 栈和队列——从适配器模式看懂 STL 容器之美

前言

在 C++ 标准模板库(STL)中,stackqueuepriority_queue不像vectorlist一样是类,而是被称为容器适配器 。它们并非从零开始实现所有数据操作,而是通过复用已有容器的能力,对外提供一组受限但语义清晰的接口,将底层的通用容器"打扮"成符合特定数据结构的模样。

这种设计思想,正是设计模式中经典的适配器模式

本文将带你从零模拟实现这三个容器适配器,并在这个过程中深入理解类模板、仿函数、堆算法等核心知识点。建议配合代码食用,边看边敲。

一、适配器模式:换个马甲,我也认识你

在开始写代码之前,先理解一个关键概念。

什么是适配器模式?

想象你有一把瑞士军刀(底层容器),它有锯、有刀、有螺丝刀。但现在你只希望用户把它当"锯子"来用------你给这把军刀套上一个锯子的外壳,只露出锯片,把刀片和螺丝刀藏起来。这就是适配器。

cpp 复制代码
// 适配器模式的核心思想:
// 将一个类的接口,转换成客户希望的另一个接口
// 让原本不兼容的类能在一起工作

stack 不需要自己管理内存,不需要自己实现 push_backpop_back------它只需要"借用"一个现成容器(如 vectordeque),然后把那些不符合"栈"语义的操作隐藏起来,只暴露 pushpoptop 这几个接口。

这种"拿来主义"就是 STL 中容器适配器的设计哲学。

二、Stack:用适配器包装出的"先进后出"

2.1 核心接口

栈(Stack)是一种**后进先出(LIFO)**的线性数据结构,就像一摞盘子------你总是先拿最上面的那个。

一个标准的栈应该支持:

接口 功能
push(x) 将元素 x 压入栈顶
pop() 弹出栈顶元素
top() 获取栈顶元素的引用
size() 返回栈中元素个数
empty() 判断栈是否为空

2.2 完整实现

cpp 复制代码
#pragma once
namespace jys
{
    // Container 默认为 vector<T>
    // 这就是适配器模式的核心------用一个成熟的容器来"装"数据
    template<class T, class Container = vector<T>>
    class stack
    {
    public:
        void push(const T& x)
        {
            _con.push_back(x);   // 借用 vector 的尾插 → 压栈
        }

        void pop()
        {
            _con.pop_back();     // 借用 vector 的尾删 → 弹栈
        }

        const T& top()const
        {
            return _con.back();  // 借用 vector 的 back → 访问栈顶
        }

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

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

    private:
        Container _con;  // 底层容器,被适配者
    };
}

2.3 关键知识点

知识点一:模板的按需实例化

模板实例化遵循"用哪部分,编译哪部分"的原则。只要接口写对了,即使你只用了其中几个成员函数,编译器也不会报未使用函数的错误。这意味着:模板类的成员函数直到被真正调用时才实例化,写错的代码只要不调用就不会暴露。

知识点二:为什么默认用 vector 而不用 list

  • vector 的尾插尾删是 O(1),完全满足栈的需求
  • vector 内存连续,高速缓存命中率高,实际性能更好
  • 栈只需要操作一端(顶),不需要 list 的任意位置 O(1) 插删能力

知识点三:Container 的模板参数给了什么自由度?

你可以这样使用:

cpp 复制代码
jys::stack<int, vector<int>>  st1;   // 用 vector 做底层
jys::stack<int, list<int>>    st2;   // 用 list 做底层
jys::stack<int, deque<int>>   st3;   // 用 deque 做底层

只要底层容器提供了 push_backpop_backbacksizeempty 这几个方法,它就能成为 stack 的"底座"。这种面向接口编程的思想,让代码拥有极强的灵活性和复用性。

三、Queue:先进先出的排队艺术

3.1 核心接口

队列(Queue)是一种**先进先出(FIFO)**的数据结构,就像食堂排队打饭------先来的人先被服务。

队列和栈最大的不同在于:栈只在一端操作,而队列一端进、另一端出 。这意味着底层容器必须同时支持高效的 push_back(队尾入队)和 pop_front(队头出队)。

单看这两个需求,vector 就出局了------vector 没有 pop_front,头删效率是 O(N)。而完美匹配这个需求的,正是 STL 中的"缝合怪"------deque

3.2 deque:vector 和 list 的"缝合怪"

在实现 queue 之前,有必要先认识一下 deque。

容器 头插头删 尾插尾删 随机访问 中间插入
vector ❌ 慢 ✅ 快 ✅ O(1) ❌ O(N)
list ✅ O(1) ✅ O(1) ❌ 不支持 ✅ O(1)
deque ✅ 快 ✅ 快 ✅ 较快 ❌ O(N)

deque 的实现思路很巧妙:它不是一整块连续内存,而是由很多固定大小的小数组 拼接而成,这些数组的指针存储在一个中控数组中。当头部或尾部的小数组满了,就开新数组,在中控数组的对应端记录新指针;当中控数组满了,就扩容中控数组。

复制代码
 deque 的内存模型(简化示意)
 中控数组:[ ptr0 | ptr1 | ptr2 | ptr3 | ... ]
             ↓      ↓      ↓      ↓
 小数组:   [___]  [___]  [___]  [___]
           ↑头                       ↑尾

这就解释了为什么 deque 既能高效头插头删,又能高效尾插尾删,还能支持下标随机访问(虽然比 vector 略慢,因为需要两次指针跳转)。

3.3 完整实现

cpp 复制代码
#pragma once
#include <deque>
namespace jys
{
    template<class T, class Container = deque<T>>
    class queue
    {
    public:
        void push(const T& x)
        {
            _con.push_back(x);   // 队尾入队
        }

        void pop()
        {
            _con.pop_front();    // 队头出队(vector 做不到这点!)
        }

        const T& front()const
        {
            return _con.front(); // 访问队头
        }

        const T& back()const
        {
            return _con.back();  // 访问队尾
        }

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

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

    private:
        Container _con;
    };
}

3.4 deque 排序性能对比

deque 的随机访问虽然比 vector 慢,但它仍然是支持随机访问迭代器的------这意味着 sort 可以对它进行排序。不过效率如何呢?来看两组测试:

测试一:deque vs vector 直接排序

cpp 复制代码
void test_op1()
{
    srand(time(0));
    const int N = 1000000;
    deque<int> dq;
    vector<int> v;

    for (int i = 0; i < N; ++i)
    {
        auto e = rand() + i;
        v.push_back(e);
        dq.push_back(e);
    }

    int begin1 = clock();
    sort(v.begin(), v.end());
    int end1 = clock();

    int begin2 = clock();
    sort(dq.begin(), dq.end());
    int end2 = clock();

    printf("vector:%d\n", end1 - begin1);
    printf("deque:%d\n", end2 - begin2);
}

测试二:deque 直接排序 vs 先拷到 vector 排序再拷回来

cpp 复制代码
void test_op2()
{
    srand(time(0));
    const int N = 1000000;
    deque<int> dq1;
    deque<int> dq2;

    for (int i = 0; i < N; ++i)
    {
        auto e = rand() + i;
        dq1.push_back(e);
        dq2.push_back(e);
    }

    int begin1 = clock();
    sort(dq1.begin(), dq1.end());   // 直接在 deque 上排序
    int end1 = clock();

    int begin2 = clock();
    vector<int> v(dq2.begin(), dq2.end());  // 拷贝到 vector
    sort(v.begin(), v.end());               // 在 vector 上排序
    dq2.assign(v.begin(), v.end());         // 拷贝回 deque
    int end2 = clock();

    printf("deque sort:%d\n", end1 - begin1);
    printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
}

结论: deque 直接排序比 vector 慢不少。更夸张的是------先把 deque 数据拷贝到 vector,排序完再拷回 deque,总耗时仍然比直接在 deque 上排序更快! 原因在于 deque 内存不连续,每次访问元素都需要通过中控数组间接跳转,这在高频访问场景下(如排序的大量比较和交换)被成倍放大。而 vector 的连续内存能让 CPU 的预取机制和高速缓存大显身手。

四、PriorityQueue:披着队列外衣的堆

4.1 它到底是个什么?

优先级队列(Priority Queue)虽然名字带"队列",但它的底层实现不是线性表,而是一个堆(Heap) 。它的出队顺序不是按入队时间,而是按优先级:每次弹出的是当前容器中"最大"(或"最小")的元素。

C++ 标准库中默认为大顶堆 (每次弹出最大值)。如果想变成小顶堆,需要传入 greater 比较器。

4.2 仿函数:让一个对象像函数一样被调用

在写 PriorityQueue 之前,必须先掌握仿函数这个概念,因为它是优先级队列的灵魂。

什么是仿函数?

仿函数(Functor),又叫函数对象,就是重载了 operator() 的类。这个类的对象可以像普通函数一样被调用,但它比普通函数更强大------因为它是一个对象,可以携带状态,可以作为模板参数传递。

下面用冒泡排序来感受仿函数的妙用:

cpp 复制代码
template<class T>
class Less
{
public:
    bool operator()(const T& x, const T& y)
    {
        return x < y;
    }
};

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

// 一个通用的冒泡排序,排序规则由 Compare 决定
template<class Compare>
void BubbleSort(int* a, int n, Compare com)
{
    for (int j = 0; j < n; j++)
    {
        int flag = 0;
        for (int i = 1; i < n - j; i++)
        {
            if (com(a[i], a[i - 1]))   // 通过仿函数控制比较逻辑
            {
                swap(a[i - 1], a[i]);
                flag = 1;
            }
        }
        if (flag == 0) break;
    }
}

int main()
{
    Less<int> LessFunc;
    cout << LessFunc(1, 2) << endl;            // 像函数一样调用
    cout << LessFunc.operator()(1, 2) << endl; // 等价写法

    int a[] = { 9, 1, 2, 5, 7, 4, 6, 3 };
    BubbleSort(a, 8, LessFunc);          // 传入已有对象 → 升序
    BubbleSort(a, 8, Greater<int>());    // 传入匿名对象 → 降序
}

当你写 Less<int>()(1, 2) 时,发生了什么?

  1. Less<int>() 构造了一个匿名对象
  2. (1, 2) 调用了这个对象的 operator()(int, int)
  3. 返回 1 < 2 的结果,即 true

仿函数 vs 函数指针:

函数指针 仿函数
本质 指针 对象(可以有成员变量)
模板参数 不友好 天然适配
内联优化 编译器难以内联 编译器容易内联
使用方式 func(x, y) obj(x, y)

仿函数作为一种模板参数传递时,STL 可以在编译期确定调用目标,从而轻松内联------这就是为什么 STL 中的排序、优先级队列都用仿函数而不用函数指针。

4.3 堆的核心算法:向上调整与向下调整

优先级队列的核心是维护一个。两个关键算法足以搞定一切:

向上调整(AdjustUp)------用于插入

新元素插到堆的末尾,然后不断与父节点比较、交换,直到满足堆性质。

cpp 复制代码
void AdjustUp(int child)
{
    Compare com;
    int parent = (child - 1) / 2;   // 二叉树父子下标关系
    while (child > 0)
    {
        if (com(_con[parent], _con[child]))  // 不满足堆序,就交换
        {
            swap(_con[child], _con[parent]);
            child = parent;
            parent = (child - 1) / 2;
        }
        else
        {
            break;   // 满足堆序了,停止
        }
    }
}

向下调整(AdjustDown)------用于删除

弹出堆顶后,把最后一个元素放到堆顶,然后不断与较大(或较小)的子节点比较、交换,向下"沉"到正确位置。

cpp 复制代码
void AdjustDown(int parent)
{
    size_t child = parent * 2 + 1;  // 左孩子下标
    Compare com;

    while (child < _con.size())
    {
        // 找出两个子节点中"更优"的那个
        if (child + 1 < _con.size() && com(_con[child], _con[child + 1]))
        {
            ++child;  // 右孩子更优,选右孩子
        }

        if (com(_con[parent], _con[child]))
        {
            swap(_con[child], _con[parent]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

关键细节:父子节点下标关系

用数组存储完全二叉树时:

  • 下标为 i 的节点,其左孩子 下标为 2i + 1
  • 下标为 i 的节点,其右孩子 下标为 2i + 2
  • 下标为 i 的节点(i > 0),其父节点 下标为 (i - 1) / 2

这个公式是堆操作的基石,建议背下来------不是死记硬背,而是画一棵数组索引的二叉树就一目了然。

4.4 完整实现

cpp 复制代码
#pragma once
#include <vector>

namespace jys
{
    template<class T>
    class Less
    {
    public:
        bool operator()(const T& x, const T& y)
        {
            return x < y;
        }
    };

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

    // 三个模板参数:元素类型、底层容器、比较器
    template<class T, class Container = std::vector<T>, class Compare = Less<T>>
    class PriorityQueue
    {
    public:
        void AdjustUp(int child)
        {
            Compare com;
            int parent = (child - 1) / 2;
            while (child > 0)
            {
                if (com(_con[parent], _con[child]))
                {
                    swap(_con[child], _con[parent]);
                    child = parent;
                    parent = (child - 1) / 2;
                }
                else
                {
                    break;
                }
            }
        }

        void push(const T& x)
        {
            _con.push_back(x);               // 先放到末尾
            AdjustUp(_con.size() - 1);       // 再向上调整到正确位置
        }

        void AdjustDown(int parent)
        {
            size_t child = parent * 2 + 1;
            Compare com;
            while (child < _con.size())
            {
                if (child + 1 < _con.size() && com(_con[child], _con[child + 1]))
                {
                    ++child;
                }
                if (com(_con[parent], _con[child]))
                {
                    swap(_con[child], _con[parent]);
                    parent = child;
                    child = parent * 2 + 1;
                }
                else
                {
                    break;
                }
            }
        }

        void pop()
        {
            swap(_con[0], _con[_con.size() - 1]);  // 堆顶和末尾交换
            _con.pop_back();                        // 删除末尾(原堆顶)
            AdjustDown(0);                          // 向下调整新堆顶
        }

        T& top()
        {
            return _con[0];    // 堆顶就是优先级最高的元素
        }

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

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

    private:
        Container _con;
    };
}

4.5 为什么 pop 要先把堆顶和末尾交换?

这是一个很巧妙的设计。如果直接删除堆顶元素(_con[0]),vector 从头部删元素需要把后面所有元素前移一位,时间复杂度是 O(N)。

而先把堆顶和末尾交换,再 pop_back() 删除末尾------pop_back 是 O(1) 的。然后只需要对新堆顶做一次 O(logN) 的向下调整就恢复了堆性质。总时间复杂度 O(logN),优秀。

4.6 验证我们的 PriorityQueue

cpp 复制代码
int main()
{
    jys::PriorityQueue<int, vector<int>> pq;  // 默认大顶堆
    pq.push(4);
    pq.push(1);
    pq.push(5);
    pq.push(7);
    pq.push(9);
    pq.push(6);

    while (!pq.empty())
    {
        cout << pq.top() << " ";
        pq.pop();
    }
    cout << endl;
}

// 输出:9 7 6 5 4 1

如果想改成小顶堆,只需要改第三个模板参数:

cpp 复制代码
jys::PriorityQueue<int, vector<int>, jys::Greater<int>> pq;
// 输出:1 4 5 6 7 9

一个比较器,两种表现。这就是仿函数作为模板参数带来的优雅与灵活。

五、三个容器适配器的对比总结

stack queue priority_queue
数据进出 LIFO(后进先出) FIFO(先进先出) 按优先级出
默认底层容器 vector deque vector
核心操作 push/pop/top push/pop/front/back push/pop/top
底层数据结构 线性表 线性表 堆(Heap)
是否用仿函数
操作端 一端 两端 一端(堆顶)

关于底层容器的选择逻辑:

  • stack :只需一端操作 → vector 的尾插尾删完全够用,内存连续、缓存友好
  • queue :需要两端操作 → deque 的头尾插删都是 O(1),是天然选择(其实 list 也行,但内存不连续,性能较差)
  • priority_queue :需要随机访问来做堆调整 → vector 是完美选择,父子下标计算需要 O(1) 的随机访问

六、知识体系梳理

读完本文,你应该能回答以下问题:

  1. 什么是适配器模式? 用一个类的接口封装另一个类,对外暴露受限但语义更明确的操作集合。
  2. 模板参数如何赋予灵活性? 通过模板参数指定底层容器和比较规则,在编译期确定类型,零运行时开销。
  3. 什么是仿函数?为什么用它而不是函数指针? 仿函数是重载了 operator() 的类,可以被内联优化,且能携带状态,比函数指针更适合做模板参数。
  4. 堆的向上调整和向下调整有什么区别? 向上调整用于插入(新元素从底部"冒泡"),向下调整用于删除(堆顶元素从顶部"下沉")。
  5. deque 为什么是 vector 和 list 的"缝合怪"? 它用小数组 + 中控指针数组的结构,兼顾了头尾高效插入和随机访问,但中间插入仍是短板。
  6. vector vs deque vs list 如何选择? 只需尾端操作选 vector,需要头尾操作选 deque,需要频繁任意位置插入删除选 list。

写在最后

栈、队列、优先级队列这三个容器的核心代码都不长,但背后的设计思想------适配器模式、仿函数、模板编程------却是 C++ 面向对象编程和泛型编程的精华所在。

理解这些不在于背代码,而在于理解为什么这样设计 。当你下次看到 stack<int> 时,脑海中能浮现出它底下那个默默干活的 deque<int>,这篇文章的目的就达到了。


本文参考了 C++ STL 源码(SGI 版本)和《STL 源码剖析》(侯捷 著),并结合个人编码实践总结而成。如有疏漏,欢迎指正。

相关推荐
hehelm15 小时前
AI大模型接入SDK—通用模块设计
linux·开发语言·c++
前端工作日常15 小时前
我学习到的Java类和对象区别
java·后端
前端工作日常15 小时前
我学习到的Java类完整结构
java·后端
什巳16 小时前
JAVA练习309- 二叉树的层序遍历
java·数据结构·算法·leetcode
宠友信息16 小时前
消息序号如何保证即时通讯源码聊天记录稳定加载
java·spring boot·redis·python·mysql·uni-app
圣光SG16 小时前
Java Web入门基础知识笔记
java·前端·笔记
行思理17 小时前
微信支付“商家转账用户确认模式”,新手教程
java·开发语言·微信
空中湖17 小时前
Spring AI RAG 完整实战:从零搭建企业知识库问答系统
java·人工智能·spring
Ai拆代码的曹操18 小时前
K8s Pod Pending 逐层排查:从 FailedScheduling 到 PVC StorageClass 不存在的实战记录
java·linux·kubernetes