优先级队列的简单模拟实现

什么是优先级队列

在 C++ 中,std::priority_queue 是一个优先级队列的实现,它提供了按照优先级进行元素存储和访问的功能。与 Python 中的 PriorityQueue 类似,C++ 的 std::priority_queue 使用堆数据结构来实现。

创建一个优先级队列:

1
2
3
#include <queue>

std::priority_queue<int> pq; // 创建一个存储 int 类型的优先级队列

添加元素到队列:

1
2
3
pq.push(5);  // 添加 5 到队列
pq.push(3); // 添加 3 到队列
pq.push(8); // 添加 8 到队列

获取并移除最高优先级的元素:

1
2
int topElement = pq.top();  // 获取最高优先级的元素(不移除)
pq.pop(); // 移除最高优先级的元素

检查队列是否为空:

1
bool isEmpty = pq.empty();  // 检查队列是否为空

返回队列中元素的个数:

1
int size = pq.size();  // 返回队列中元素的个数

默认情况下,std::priority_queue 的元素是按照降序排列的。也就是说,最大的元素拥有最高的优先级。如果想要实现最小值优先的队列,可以传递一个比较函数对象作为模板参数。

堆(预备知识)

什么是堆?称为堆的前提得是一棵完全二叉树,且堆中某个结点的值总是不大于或不小于其父结点的值。

类型

最大堆(大根堆):根结点最大的堆。
最小堆(小根堆):根结点最小的堆。

[图片生成自'https://algo.hufeifei.cn/Heap.html']如图是一个小根堆。 堆的存储方式采用顺序存储(常用数组),从上到下,从左到右,这样的存储方式非常适合找到父节点或者孩子节点,其符合规律:

任何一个节点*2就能得到它的左孩子下标,再+1就能得到它的右孩子下标(前提是孩子都存在)。
任何一个节点除2再向下取整就能得到父节点下标。
使用该规律前提是下标从1开始!

如何建堆?

总结为:倒着建。从数组的最后往前走,每一个叶子结点都是一个独立的小堆,那么直接从最后一个非叶结点开始调整。为什么要倒着来?因为每检查到一个节点,它的子节点包括子节点的子节点都已经调整完毕了(随着父节点变动可能子节点还会有调整,在代码部分细讲)。
图中的最后一个节点是145,不难看出它的父节点12就是最后一个非叶结点。如果12的左子节点比它还大,就该进行调整,右子节点比他小也该进行调整。调整的准则就是目前节点为根的堆必须满足是大根堆或者小根堆。当这样一轮调整结束,最大(最小)的那个节点就被成功移到了堆顶。

构建最大堆:将数组重新组织成一个最大堆。

建堆过程

将堆顶元素(最大值)与堆的最后一个元素交换。
减小堆的大小(排除已经排好序的最大元素)。
重新调整堆,使其满足最大堆性质。
重复上述步骤,直到堆的大小为1。

对数组 [3, 7, 2, 4, 1] 构建大根堆:

初始数组对应的完全二叉树表示

1
2
3
4
5
      3
/ \
7 2
/ \ /
4 1

先计算最后一个非叶子节点的索引,对于长度为 n = 5 的数组,最后一个非叶子节点索引是 n / 2 - 1 = 1(对应元素 7 这个节点)。

从最后一个非叶子节点(索引为1,值为7)开始调整

  • 节点 7 的左子节点是 4,右子节点是 1,因为 7 大于 41,满足大根堆性质,无需调整。此时状态依然是:
    1
    2
    3
    4
    5
          3
    / \
    7 2
    / \ /
    4 1
  • 接着处理索引为 0 的节点(值为 3),它的左子节点是 7,右子节点是 2。比较 372,发现 7 最大,所以交换 37 的位置,得到:
    1
    2
    3
    4
    5
          7
    / \
    3 2
    / \ /
    4 1
  • 交换后,以 3 为根的子树可能不满足大根堆性质了,继续检查调整。此时 3 的左子节点 4 大于 3,所以再次交换 34,得到:
    1
    2
    3
    4
    5
          7
    / \
    4 2
    / \ /
    3 1

此时整个二叉树就满足大根堆性质了,对应的数组 [7, 4, 2, 3, 1] 就是构建好的大根堆。

如果要进行整个数组的排序,还要进行多次调整。目前只是把7作为最大的数筛选出来,而其他元素还没有完成排序。

交换堆顶元素和最后一个元素

  • 初始大根堆对应的树状结构为:
    1
    2
    3
    4
    5
          7
    / \
    4 2
    / \ /
    3 1
    对应的数组为 [7, 4, 2, 3, 1]
  • 交换堆顶元素 7 和最后一个元素 1,交换后的树状结构变为:
    1
    2
    3
    4
    5
          1
    / \
    4 2
    / \ /
    3 7
    对应的数组变为 [1, 4, 2, 3, 7]。此时最大元素 7 已经在数组末尾位置了,后续要对前面的元素(除了已确定位置的 7)重新调整为大根堆来继续确定次大元素等。

调整剩余元素为大根堆

  • 把交换后的数组中前面 n - 1 个元素(这里 n = 5,即对前 4 个元素 [1, 4, 2, 3])重新调整为大根堆。
  • 从根节点(索引为 0 ,值为 1)开始,其左子节点是 4,右子节点是 2,比较发现 4 最大,交换 14,得到:
    1
    2
    3
    4
    5
          4
    / \
    1 2
    / \ /
    3 7
    对应的数组变为 [4, 1, 2, 3, 7]
  • 接着以 1 为根节点检查调整,1 的左子节点 3 大于 1,交换 13,得到:
    1
    2
    3
    4
    5
          4
    / \
    3 2
    / \ /
    1 7
    对应的数组变为 [4, 3, 2, 1, 7],此时前面 4 个元素又构成了大根堆。

重复上述交换和调整步骤

  • 再次交换堆顶元素(此时堆顶元素为 4)和当前最后一个未确定位置的元素(即索引为 3 的元素 1),交换后的树状结构为:
    1
    2
    3
    4
    5
          1
    / \
    3 2
    / \ /
    4 7
    对应的数组变为 [1, 3, 2, 4, 7]
  • 然后对前面 3 个元素([1, 3, 2])重新调整为大根堆,从根节点(索引为 0 ,值为 1)开始,其左子节点 3 大于 1,交换 13,得到:
    1
    2
    3
    4
    5
          3
    / \
    1 2
    / \ /
    4 7
    对应的数组变为 [3, 1, 2, 4, 7],此时 1 的左子节点 4 大于 1,但右子节点 2 小于 1,无需再交换,前面 3 个元素构成大根堆。

继续重复

  • 交换堆顶元素(此时堆顶元素为 3)和当前最后一个未确定位置的元素(即索引为 2 的元素 2),交换后的树状结构为:
    1
    2
    3
    4
    5
          2
    / \
    1 3
    / \ /
    4 7
    对应的数组变为 [2, 1, 3, 4, 7]
  • 对前面 2 个元素([2, 1]),因为 2 大于 1,本身就满足大根堆性质。

最后一次交换

  • 交换堆顶元素(此时堆顶元素为 2)和当前最后一个未确定位置的元素(即索引为 1 的元素 1),交换后的树状结构为:
    1
    2
    3
    4
    5
          1
    / \
    2 3
    / \ /
    4 7
    对应的数组变为 [1, 2, 3, 4, 7],至此整个数组完成升序排序。

总结:

  1. 首先将给定数组构建成大根堆。
  2. 然后重复交换堆顶元素(最大元素)和当前未排序部分的最后一个元素,接着对交换后除去最后已确定位置元素的剩余元素重新调整为大根堆,不断循环这个交换和调整过程,直到所有元素都确定了最终位置,数组就完成了升序排序。

模拟实现

官方定义:

1
2
3
4
5
template <
class T,
class Container = std::vector<T>,
class Compare = std::less<typename Container::value_type>
> class priority_queue;

T 是存储在优先级队列中的元素类型;Container 是用于存储元素的底层容器类型,默认是 std::vector;Compare 是一个比较对象类型,用于确定元素的优先级顺序,默认是 std::less,它会按照元素的小于关系来确定优先级,即值越大优先级越高,也可以自定义比较函数或函数对象来改变优先级的判定规则。

queue里可以塞入各种类型,所以比较大小还得借助模版参数来进行优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
};

这样就完成了最大值排列和最小值排列。

建堆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141

// 定义优先级队列模板类,有三个模板参数
// T表示存储在优先级队列中的元素类型
// Container表示用于存储元素的底层容器类型,默认是vector<T>
// Compare表示用于确定元素优先级顺序的比较对象类型,默认是less<T>,用于按小于关系确定优先级(通常意味着值越大优先级越高)
template<class T, class Container = vector<T>, class Compare = less<T>>
class priority_queue
{
private:
// 向下调整函数,用于维护堆的性质(大根堆为例),从给定的父节点开始向下调整元素位置
void AdjustDown(int parent)
{
// 创建比较对象,用于比较元素的优先级
Compare com;

// 计算父节点parent的左孩子节点索引,在完全二叉树中,左孩子索引为 2 * parent + 1,因为下标此时从0开始
size_t child = parent * 2 + 1;
// 只要孩子节点索引小于底层容器中元素个数,说明存在孩子节点,继续循环调整
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 AdjustUp(int child)
{
// 创建比较对象,用于比较元素的优先级
Compare com;

// 计算孩子节点child的父节点索引,在完全二叉树中,父节点索引为 (child - 1) / 2
int parent = (child - 1) / 2;
// 只要孩子节点索引大于0,说明不是根节点,继续向上循环调整
while (child > 0)
{
// 如果通过比较对象判断父节点的优先级小于当前孩子节点的优先级(不符合堆性质)
if (com(_con[parent], _con[child]))
{
// 交换父节点和当前孩子节点的值,以维护堆性质
swap(_con[child], _con[parent]);
// 更新孩子节点索引为当前父节点的索引,继续向上检查交换后的子树是否满足堆性质
child = parent;
// 重新计算新的父节点索引,继续向上调整过程
parent = (child - 1) / 2;
}
else
{
// 如果当前父节点和孩子节点满足堆性质,不需要再调整,直接退出循环
break;
}
}
}

public:
// 默认构造函数,不做任何特殊初始化操作,底层容器会使用其默认构造进行初始化
priority_queue()
{}

// 范围构造函数,接受两个迭代器,表示一个范围,用于初始化优先级队列
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last)
{
// 遍历给定的迭代器范围,将每个元素插入到底层容器_con中
while (first!= last)
{
_con.push_back(*first);
++first;
}

// 构建堆,从倒数第一个非叶节点开始,对每个非叶节点调用AdjustDown函数进行向下调整,以构建大根堆
for (int i = (_con.size() - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(i);
}
}

// 弹出函数,用于移除优先级队列中的最高优先级元素(队首元素)
void pop()
{
// 将队首元素(根节点)和最后一个元素交换位置,方便移除最高优先级元素
swap(_con[0], _con[_con.size() - 1]);
// 移除最后一个元素,即原来的队首元素(最高优先级元素)
_con.pop_back();

// 对新的根节点调用AdjustDown函数进行向下调整,以维护堆的性质
AdjustDown(0);
}

// 插入函数,用于向优先级队列中插入一个新元素
void push(const T& x)
{
// 将新元素插入到底层容器的末尾
_con.push_back(x);

// 对新插入的元素(在容器末尾,即最后一个位置)调用AdjustUp函数进行向上调整,以维护堆的性质
AdjustUp(_con.size() - 1);
}

// 获取最高优先级元素(队首元素)的函数,返回元素的常引用,不允许通过此引用修改元素
const T& top()
{
return _con[0];
}

// 判断优先级队列是否为空的函数,如果底层容器为空则返回true,表示队列为空,否则返回false
bool empty()
{
return _con.empty();
}

// 获取优先级队列中元素个数的函数,返回底层容器中元素的数量
size_t size()
{
return _con.size();
}

private:
// 用于存储元素的底层容器,类型由模板参数Container指定,默认是vector<T>
Container _con;
};