前言
在 C++ 标准模板库(STL)中,stack、queue 和 priority_queue不像vector和list一样是类,而是被称为容器适配器 。它们并非从零开始实现所有数据操作,而是通过复用已有容器的能力,对外提供一组受限但语义清晰的接口,将底层的通用容器"打扮"成符合特定数据结构的模样。
这种设计思想,正是设计模式中经典的适配器模式。
本文将带你从零模拟实现这三个容器适配器,并在这个过程中深入理解类模板、仿函数、堆算法等核心知识点。建议配合代码食用,边看边敲。
一、适配器模式:换个马甲,我也认识你
在开始写代码之前,先理解一个关键概念。
什么是适配器模式?
想象你有一把瑞士军刀(底层容器),它有锯、有刀、有螺丝刀。但现在你只希望用户把它当"锯子"来用------你给这把军刀套上一个锯子的外壳,只露出锯片,把刀片和螺丝刀藏起来。这就是适配器。
cpp
// 适配器模式的核心思想:
// 将一个类的接口,转换成客户希望的另一个接口
// 让原本不兼容的类能在一起工作
stack 不需要自己管理内存,不需要自己实现 push_back、pop_back------它只需要"借用"一个现成容器(如 vector 或 deque),然后把那些不符合"栈"语义的操作隐藏起来,只暴露 push、pop、top 这几个接口。
这种"拿来主义"就是 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_back、pop_back、back、size、empty 这几个方法,它就能成为 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) 时,发生了什么?
Less<int>()构造了一个匿名对象(1, 2)调用了这个对象的operator()(int, int)- 返回
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) 的随机访问
六、知识体系梳理
读完本文,你应该能回答以下问题:
- 什么是适配器模式? 用一个类的接口封装另一个类,对外暴露受限但语义更明确的操作集合。
- 模板参数如何赋予灵活性? 通过模板参数指定底层容器和比较规则,在编译期确定类型,零运行时开销。
- 什么是仿函数?为什么用它而不是函数指针? 仿函数是重载了
operator()的类,可以被内联优化,且能携带状态,比函数指针更适合做模板参数。 - 堆的向上调整和向下调整有什么区别? 向上调整用于插入(新元素从底部"冒泡"),向下调整用于删除(堆顶元素从顶部"下沉")。
- deque 为什么是 vector 和 list 的"缝合怪"? 它用小数组 + 中控指针数组的结构,兼顾了头尾高效插入和随机访问,但中间插入仍是短板。
- vector vs deque vs list 如何选择? 只需尾端操作选 vector,需要头尾操作选 deque,需要频繁任意位置插入删除选 list。
写在最后
栈、队列、优先级队列这三个容器的核心代码都不长,但背后的设计思想------适配器模式、仿函数、模板编程------却是 C++ 面向对象编程和泛型编程的精华所在。
理解这些不在于背代码,而在于理解为什么这样设计 。当你下次看到 stack<int> 时,脑海中能浮现出它底下那个默默干活的 deque<int>,这篇文章的目的就达到了。
本文参考了 C++ STL 源码(SGI 版本)和《STL 源码剖析》(侯捷 著),并结合个人编码实践总结而成。如有疏漏,欢迎指正。