前言
回顾前几篇文章,我们实现的 string、vector、list,都是底层数据结构的实现------我们需要自己管理内存、自己处理迭代器、自己面对扩容和失效。
这一篇我们要实现三个新的容器:stack(栈)、queue(队列)、priority_queue(优先队列)。
它们与前三个容器有着本质的区别------它们不需要自己从头管理内存,而是复用已经写好的容器 ,对底层的接口进行限制和包装 。在 C++ 中,这种设计模式叫做适配器(Adapter)。
1 stack 与 queue 的实现
1.1 适配器模式与模板参数
stack 和 queue 的代码非常短,因为它们自己不存数据,数据全交给底层的容器去存:
cpp
template<class T, class Container = deque<T>>
class stack
{
// ...
private:
Container _con; // 底层容器
};
T:元素类型;Container:底层容器的类型,默认是std::deque<T>。
为什么默认选 deque 而不是 vector 或 list?
- 如果用
vector:stack没问题,但queue需要pop_front(头删),vector做这个要挪整个数组,效率极低; - 如果用
list:虽然都能 O(1) 头尾操作,但链表的每个节点都有额外指针开销,且内存不连续,CPU 缓存命中率低; deque(双端队列) :头尾插入删除都是 O(1),同时底层是分段连续空间,兼顾了效率和缓存友好度。所以标准库把deque作为两者的默认底层容器。
1.2 stack 的核心接口
栈是后进先出(LIFO)------只能在一端操作。所以它的接口全是对底层容器的封装:
cpp
void push(const T& val)
{
_con.push_back(val); // 入栈:尾插
}
void pop()
{
_con.pop_back(); // 出栈:尾删
}
const T& top() const
{
return _con.back(); // 取栈顶:尾部的元素
}
size_t size() const
{
return _con.size();
}
bool empty() const
{
return _con.empty();
}
注意 :stack 没有迭代器,因为栈的语义就是"只能看栈顶",不支持遍历。
1.3 queue 的核心接口
队列是先进先出(FIFO)------两端都能操作(一端进、一端出):
cpp
void push(const T& val)
{
_con.push_back(val); // 入队:尾插
}
void pop()
{
_con.pop_front(); // 出队:头删
}
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();
}
这就是为什么 queue 不能用 vector 做底层 ------pop 需要 pop_front(),而 vector 没有这个接口。
1.4 为什么不需要写拷贝构造和析构?
前几篇我们反复强调:"只要类里管理了动态内存,就必须自己写深拷贝和析构。"
但 stack 和 queue 不需要------因为它们内部只有一个成员变量:Container _con。
- 默认的拷贝构造 会自动调用
Container的拷贝构造------而Container(如deque或vector)已经实现了正确的深拷贝; - 默认的析构函数 会自动调用
Container的析构------Container会负责释放内存; - 赋值运算符同理。
这就是组合(Composition)优于继承 的体现------我们把复杂的底层操作"委托"给了底层容器,适配器层只需关心"怎么限制接口"。
2 priority_queue 的实现
priority_queue(优先队列)虽然名字里带有"队列",但它的行为和我们刚才讲的 FIFO 队列完全不同------它每次出队的,永远是当前优先级最高(或最低)的元素。
它的底层不再是简单的"哪端进哪端出",而是使用堆(Heap) 这种数据结构来维护元素的优先级。
2.1 底层存储与堆结构
priority_queue 默认使用的底层容器是 vector:
cpp
template<class T, class Container = vector<T>, class Compare = Less<T>>
class priority_queue
{
// ...
private:
Container _con; // 底层容器,默认是 vector
};
为什么用 vector 而不是 list 或 deque?
因为堆是一棵完全二叉树 ,而在 C++ 中,完全二叉树最经典、最高效的实现方式就是用数组(或 vector)来存储 ------通过下标计算父子关系,内存连续,随机访问 O(1)。如果用 list,完全无法实现下标随机访问,也就没办法高效地做堆调整了。
延伸阅读 :关于二叉树的数组表示、父子节点的下标推导公式(
left = 2*parent + 1,right = 2*parent + 2),以及堆的向上调整(adjust_up)和向下调整(adjust_down)的完整图解和推导,我在之前的博客中已经详细写过,这里不再重复。详情请见 :二叉树的实现(使用C语言)
所以在这里,我们只需要知道:priority_queue 的所有核心操作,都是通过调用 _con 的 push_back / pop_back,再配合 adjust_up / adjust_down 来维护堆的性质。
2.2 仿函数(Functor)与自定义比较逻辑
在讲接口之前,我们得先解决一个关键问题:如何决定这是一个大堆还是小堆?
标准库的 priority_queue 默认是大堆(每次取最大值)。如果你想建小堆,就要在模板参数里显式传入一个"比较器"。
这个"比较器",在 C++ 中通常用仿函数(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;
}
};
对于 Less<T> com; 这个对象,你可以直接写 com(a, b),就像调用函数一样。编译器会把它展开为 com.operator()(a, b)。
为什么用仿函数,而不是直接用 < 或 > 运算符?
因为 < 和 > 是写死的。如果我们想把比较逻辑换成自定义的(比如"比较两个学生的总分"),或者想动态切换大堆/小堆,就必须把比较方式也参数化 ------这正是仿函数的作用:它把"比较逻辑"变成了一个可以传递的参数。
在 priority_queue 里,我们就是通过 Compare 这个模板参数,把比较逻辑注入到堆调整函数中。
比较逻辑是怎么注入到堆调整里的?
通过 Compare 模板参数,我们将比较逻辑直接传给了堆调整函数:
cpp
void adjust_down(int parent)
{
Compare com; // 根据模板参数实例化出对应的仿函数对象
size_t child = 2 * parent + 1;
while (child < _con.size())
{
// 选出大的孩子(具体是大是小,由 com 决定)
if (child + 1 < _con.size() && com(_con[child], _con[child + 1]))
{
++child;
}
if (com(_con[parent], _con[child]))
{
std::swap(_con[parent], _con[child]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
- 如果
Compare = Less<T>,com(parent, child)相当于parent < child------如果父节点小于子节点就交换,所以建出的是大堆。 - 如果
Compare = Greater<T>,com(parent, child)相当于parent > child------如果父节点大于子节点就交换,所以建出的是小堆。
这样就实现了用同一套堆调整代码,适配不同的大小堆需求。
2.3 priority_queue 的核心接口
有了仿函数的基础,我们来看 priority_queue 的三个核心接口:push、pop、top。
① push------入堆(向上调整)
cpp
void push(const T& val)
{
_con.push_back(val); // 先将元素尾插到底层容器
adjust_up(_con.size() - 1); // 再从最后一个位置开始,向上调整维护堆
}
- 插入位置 :数组末尾(
push_back),这样能保证完全二叉树的性质; - 调整方向 :从新插入的节点开始,不断与父节点比较,向上调整(
adjust_up)。
② pop------出堆(向下调整)
cpp
void pop()
{
std::swap(_con[0], _con[_con.size() - 1]); // 1. 将堆顶与最后一个元素交换
_con.pop_back(); // 2. 删除最后一个元素(原堆顶)
adjust_down(0); // 3. 从新的堆顶开始,向下调整
}
- 为什么先交换再删? 因为堆顶(
_con[0])是我们要移除的,但它不能直接删------直接删会破坏整棵树的结构。交换到末尾再pop_back,就能在移除堆顶的同时维持"前 n-1 个元素依然近似有序"的状态; - 调整方向 :从新的堆顶开始,不断与较大的(或较小的)子节点比较,向下调整 (
adjust_down)。
③ top------取堆顶
cpp
const T& top() const
{
return _con[0]; // 直接返回底层容器的第一个元素
}
- 堆顶永远在数组下标
0处; - 通常返回
const T&避免拷贝
3 测试代码一瞥
测试代码主要验证了 stack、queue 的基本出入栈/队,以及 priority_queue 的建堆和大小堆切换。
3.1 基础验证
cpp
void test_stack()
{
stack<int> st;
st.push(1);
st.push(2);
cout << st.top() << endl; // 输出 2
}
void test_queue()
{
queue<int> q;
q.push(1);
q.push(2);
cout << q.front() << endl; // 输出 1
}
stack 和 queue 的接口非常直观------只暴露了特定方向的访问 (栈顶 top、队头 front、队尾 back),不支持遍历。
3.2 priority_queue 的大堆与小堆
cpp
void test_priority_queue1()
{
priority_queue<int> pq; // 默认 Less<int> ------ 大堆
pq.push(7);
pq.push(3);
pq.push(2);
pq.push(9);
pq.push(6);
cout << pq.top() << endl; // 输出 9(最大值)
pq.pop();
cout << pq.top() << endl; // 输出 7
}
void test_priority_queue2()
{
priority_queue<int, vector<int>, Greater<int>> pq; // 小堆
pq.push(7);
pq.push(3);
pq.push(2);
pq.push(9);
pq.push(6);
cout << pq.top() << endl; // 输出 2(最小值)
}
- 默认
Less<int>:建的是大堆,top()返回最大值; - 显式传入
Greater<int>:建的是小堆,top()返回最小值。
3.3 迭代器区间构造
cpp
void test_priority_queue5()
{
vector<int> v = { 6, 5, 7, 2, 3, 1, 6, 8, 4 };
priority_queue<int, vector<int>, Greater<int>> pq(v.begin(), v.end());
while (!pq.empty())
{
cout << pq.top() << ' '; // 输出升序序列
pq.pop();
}
}
priority_queue 也支持用一段迭代器区间来构造------它在构造函数内部调用 adjust_down 建堆,而不是逐个 push 进去(这样建堆是 O(N),逐个 push 是 O(N log N))。
4 写在最后
到这里,stack、queue 和 priority_queue 的实现就完成了。和前几篇相比,这一篇的代码短得多------因为它不涉及底层内存管理,而是复用已有容器。
这一篇最核心的思想是适配器模式------不重复造轮子,而是复用已有的容器,通过限制接口来达到新的语义:
stack限制了只能在一端进出,实现了后进先出;queue限制了两端进出,实现了先进先出;priority_queue则在容器的基础上加入了堆算法和仿函数,实现了按优先级出队。
通过这三者的实现,你会发现:C++ 的容器设计不仅仅是从零封装内存,还包括"站在已有容器的肩膀上"去构建新功能。用最少的代码,组合出强大的数据结构,这正是泛型编程和适配器模式的魅力所在。
希望这篇文章对你有帮助。
本文代码环境:Visual Studio 2022 / C++11 及以上
完整代码仓库:见评论区
如果本文对你有帮助,欢迎点赞、收藏、关注------你的支持是我持续更新的最大动力!如有疑问,欢迎在评论区留言讨论~