C++中stack、queue、priority queue类的部分简单实现

前言

回顾前几篇文章,我们实现的 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 及以上

完整代码仓库:见评论区

如果本文对你有帮助,欢迎点赞、收藏、关注------你的支持是我持续更新的最大动力!如有疑问,欢迎在评论区留言讨论~

相关推荐
hixiong1231 小时前
纯C# Paddle2Onnx 转换器
1024程序员节
愤怒火龙果1 小时前
AIShield 靶场 WP
人工智能·网络安全·1024程序员节
软件派1 小时前
Eden AI 保姆级介绍:一站式 AI 聚合网关,一个 API 调用上百种大模型与视觉语音能力
1024程序员节·统一 api·多模态 api
ai_coder_ai1 小时前
论平台工程(平台化架构)在云原生系统中的设计与实践
1024程序员节
惊讶的猫1 小时前
mysql索引
mysql·1024程序员节
周小董1 小时前
[1372]mac安装git
1024程序员节
LabVIEW开发1 小时前
ESO 极大望远镜主动光学:LabVIEW 实时模块 + 多核 COTS 替代专用硬件的路径
labview·1024程序员节·labview知识·labview功能·labview程序
夜不会漫长1 小时前
C++ 多态学习笔记
1024程序员节
爱读书的小胖1 小时前
LeetCode 45 跳跃游戏 II C 语言解题分析
1024程序员节