堆、优先级队列和堆排序

导言#

“聚沙成塔,集腋成裘”,使我们非常熟悉的名言警句,其中“聚沙成塔意思”是聚细沙成宝塔,原指儿童堆塔游戏,后比喻积少成多。如果我们把这座塔抽象成一个数据结构的话,那么每一粒沙子都是结构中的元素,而这些元素不断地往上堆积,最终形成了沙堆,对于这个沙堆来说,如果我们把沙堆按高度分成多层,那么每一层的沙子数量都各不相同,上层的沙子数小于下层的沙子数。这样的描述就和能够大致地理解我们要提的堆结构。

二叉堆#

完全二叉树#

完全二叉树的特点在于,二叉树生成结点的顺序必须严格按照从上到下,从左往右的顺序来生成结点,例如下面3张图片都是完全二叉树。



而这种二叉树就不属于完全二叉树。

#

一个数据结构是堆结构,需要满足两个条件:第一,结构是一个完全二叉树;第二,每个父结点的值都不小于其子结点的值,这样的堆结构被称为大顶堆,而每个父结点的值都不大于其子结点的值的堆为小顶堆。例如图一是大顶堆,图二是小顶堆:


这样的结构又叫做二叉堆,其根结点叫做堆顶,无论是大顶堆还是小顶堆,都决定了堆顶元素的值是整个堆中的最大或最小元素。

堆的存储方式#

我们在描述二叉堆时,虽然它是完全二叉树,但它适合的的存储方式并不是链式存储,而是顺序存储,我们可以用一个数组来组织。这是因为如果我们按照从上到下,从左到右的顺序遍历完全二叉树时,顺序是这样的:

那么我们就会发现,设父结点的序号为 k,则子结点的序号会分别为 2k 和 2k + 1,子结点的序号和父结点都是相互对应的,因此我们可以用顺序存储结构来描述二叉堆,例如上文的大顶堆:

调整堆#

操作解析#

首先我们先来解决数据调整的问题,也就是说当我们去掉堆顶的元素之后,我们需要保证剩下的元素能够重构成一个新的堆。直接放个例子,假设有如图所示大顶堆:

下面我们把堆顶贬到最底层,然后根据插入的规则从上到下,从左到右来选择替换的结点,得到这样的状态:

此时我们就需要进行堆的调整,由于此时除根结点外,其余结点均满足堆的两个条件,由此仅需由上向下调整一条路径的结点即可。首先以堆顶元素 5 和其左、右子树根结点的值进行比较,由于左子树根结点的值 8 大于右子树根结点的值 7 且大于根结点的值,因此进行操作使 5 下沉,8上浮。由于 8 替代了 5 之后破坏了左子树的子堆,因此需从上向下进行和上述相同的调整:

我们需要重复执行直至叶子结点,调整后得到新的堆:

现在请你自行模拟一遍上述大顶堆堆顶退出的过程,得到的新堆为:

在模拟中,我们使用了从上到下,层层筛选,合适的元素上浮,不合适的元素下沉,就像筛子一样把合适的数据筛选出来一样,这种调整堆的方式被称为筛选法

伪代码#

代码实现#

Copy Highlighter-hljs
void Heapify(SqList &L,int s,int m) { //假设线性表的 data 成员中,data[s + 1…m] 已经是堆,现将 data[s…m] 调整为以 data[s] 为根的大顶堆 data_root = L.data[s]; for(i = 2*s; i <= m; i *= 2) //沿着 key 较大的子结点向下筛选 { if(i < m && L.data[i].key < L.data[i].key) //i 记录 key 较大的下标 i++; if(data_root.key >= L.data[i].key) //找到 data_root 的插入位置 break; L.data[s] = L.data[i]; s = i; } L.data[s] = data_root; }

时间复杂度#

调整堆需要比较 log2n 次,因此时间复杂度为 O(logN)

建初堆#

操作解析#

现在我们有个顺序表,这个顺序表的数据是无序的,因此要把这个顺序表描述为堆结构,就必须令其满足上述两个条件,即完全二叉树中的每一个结点的子树都要是一个堆结构。对于一个完全二叉树来说,所有序号大于 n / 2 的结点都是子叶,而只有 1 个结点的树显然是堆,因此建初堆的操作本质上就是一个所有非叶子节点依次下沉的过程。例如有如图顺序表:

首先我们需要操作的是 1 号结点,1 号结点小于它的子结点,因此它需要下沉:

接下来是 7 号结点,7 号结点小于它的子结点,因此它需要下沉:

接下来是 9 号结点,7 号结点不小于它的子结点,因此它不需要下沉。接下来是 4 号结点,4 号结点小于它的子结点,因此它需要下沉:

此时 4 号结点仍小于它的子结点,因此它需要继续下沉:

现在我们就把一个无序的完全二叉树整理为一个堆结构了。

伪代码#

其实思路是很明确的,我们只需要吧序号为 n / 2、n / 2 - 1、…、1 的结点作为根的子树统统搞成堆即可。加上我们在刚才已经写了筛选法的代码,现在这件事情就变得简单了。

代码实现#

Copy Highlighter-hljs
void CreatHeap(SqList &L) { //现以无序序列 data[1…n] 建立大顶堆 for(int i = L.length / 2; i > 0; i--) { Heapify(L,i,L.length); } }

时间复杂度#

此处时间复杂度可以用级数来推导。我的能力有限,这里引用 “知乎@吴献策” 的推导过程,原文链接

优先级队列#

按照优先级出队列#

对于一个队列结构而言,队列中的元素遵循着先进先出,后进后出的规则,元素只能队列尾进入,出队列则是队列头的元素。而我们现在要谈的优先级队列则是队列不再遵循先入先出的原则,而是分为两种情况:最大优先队列,无论入队顺序,当前最大的元素优先出队。最小优先队列,无论入队顺序,当前最小的元素优先出队。例如这个队列,我们设这个队列是个最小优先队列,因此当这个队列执行出队操作的时候,出队的元素为 1。要满足如此的需求,我们利用线性表的基本操作同样可以实现,但是这么做最坏时间复杂度O(n),也就是我们要遍历这个队列,显然这并不是最佳的方式。

我们来回忆一下二叉堆,对于一个二叉堆而言,大顶堆的堆顶是整个堆中的最大元素,小顶堆的堆顶是整个堆中的最小元素。因此,当我们使用大顶堆来实现最大优先队列时,入队列操作就是堆的插入操作,出队列操作就是删除堆顶节点。假设我们有如图所示大顶堆:

优先级队列结构体定义#

同顺序表,不过我们的目的是用顺序存储结构描述堆结构。

Copy Highlighter-hljs
typedef struct HeapStruct { int size; ElemType data[MAXSIZE]; }*PriorityQueue;

入队列操作#

上滤#

入队列操作一般使用的策略叫做上滤(percolate up,即新元素在堆中上滤直到找出正确的位置(设堆为 H,待插入的元素为 e,首先在 size + 1 的位置建立一个空穴,然后比较 e 和空穴的父结点的大小,把较小的父亲换下来,以此推进,最后把 e 放到合适的位置,该算法时间复杂度为O(㏒n)。

模拟入队列#

假设要在上述大顶堆插入结点 10,首先我们直接将结点按照完全二叉树的规则入堆:

接着我们将结点依次上浮到合适的位置:


伪代码#

代码实现#

Copy Highlighter-hljs
void Insert( ElemType e, PriorityQueue H ) { if (H->Size == MAXSIZE) { cout << "Priority queue is full" ; return ; } for (int i = ++H->size; H->data[i / 2] < e; i /= 2) //查找合适的位置 H->data[i] = H->Elements[i / 2]; //上浮操作 H->data[i] = e; //插入元素 }

出队列操作#

下滤#

出队列的算法就是直接将堆顶元素出队列,然后将堆顶的元素替换为在完全二叉树中对应最后一个元素,接着使用筛选法,逐层推进把较大的子结点换到上层,该算法时间复杂度为O(㏒n)。

模拟出队列#

直接将堆顶元素出堆即可。

接下来令完全二叉树的最后一个结点成为堆顶,即结点 4。

然后利用筛选法将结点 4 下沉到合适的位置,完成操作。

伪代码#

代码实现#

Copy Highlighter-hljs
ElemType DeleteMax( PriorityQueue H ) { int Child; ElemType Max, LastElem; if ( H->size == 0 ) { cout << "Priority queue is empty!"; return H->data[0]; } Max = H->data[1]; //最大元素出队列 LastEleme = H->data[H->Size--]; //最后一个结点替代堆顶 for (int i = 1; i * 2 <= H->size; i = Child ) { Child = i * 2; //定位到下一层,寻找更大的子结点 if ( Child != H->Size && H->data[Child + 1] > H->data[Child] ) Child++; if ( LastElem > H->data[Child] ) //结点下沉 H->data[i] = H->data[Child]; else //下沉结束 break; } H->data[i] = LastElem; return Max; }

C++ STL priority_queue#

STL 真是 C++ 为我们提供的神兵利器,STL 中为我们封装好了最小优先队列和最大优先队列,包含于头文件:

Copy Highlighter-hljs
#include<queue>

优先队列具有队列的所有特性,只是在这基础上添加了优先级出队列的机制,它本质是一个堆实现的,不过能够使用队列的基本操作:

方法 操作
top 访问队头元素
empty 判断是否为空队列
size 返回队列内元素个数
push 元素从队尾入队列(并排序)
emplace 构造一个结点并入队列
pop 队头元素出队里
swap 交换元素内容

容器定义形式#

Copy Highlighter-hljs
priority_queue<Type, Container, Functional>
参数 作用
Type 数据类型
Container 容器类型,必须是用数组实现的容器,例如vector(默认)、deque,不能用 list
Functional 比较的方式
这些参数当我们需要用自定义的数据类型时才需要传入,使用基本数据类型时只需要传入数据类型,默认使用大顶堆实现优先级队列。例如以下两种建法:
Copy Highlighter-hljs
priority_queue <int,vector<int>,greater<int> > q; //升序队列 priority_queue <int,vector<int>,less<int> >q; //降序队列

STL 库使用例#

要求构造两个优先级队列,分别是最小优先队列和最大优先队列,随机输入 5 个数字,分别用大顶堆和小顶堆进行组织,之后将两个队列输出。

运行结果如下:

情景应用:修理牧场#

情景需求#

情景模拟#

为了使费用最省,我们使用贪心算法的思想,每一次选择最小的两段木头拼回去,直到将所有木头拼成一段完整的木头,每次一拼接都计算一次费用。我们发现,优先级队列也是可以实现贪心算法的。

我们首先需要先把这个队列修改成小顶堆,方便我们实现优先级队列。

接下来令两个元素出队列,计算一次费用,然后将两个元素之和的数字入队列。

重复上述操作,使的队列只剩一个元素。





解法分析#

在这里我们可以看出优先级队列是可以解决问题的,此时的问题是我该怎么控制堆中的数据元素个数?通过观察,每一次是出堆 2 个元素,入堆 1 个元素,也就是说每次的净出堆元素是 1 个,那么就在堆中元素为 1 时结束这个流程就行了。
接下来就是如何调整堆的问题了,比较粗暴的方式是每次更改之后都建初堆,但是这样效率和哈夫曼树差不多,优化不是很明显。根据我们刚刚的分析,既然有 2 次出堆,那么在出堆之后保证剩下的元素也是堆就可以了,那么只需要 2 次调整堆。比较方便的操作是第一次出堆时,拿堆的最后一个元素到堆顶调整堆,第二次出堆时直接把入堆元素填充到堆顶调整堆。

代码实现#

Copy Highlighter-hljs
#include<iostream> using namespace std; void heapify(int a_heap[], int idx1, int idx2); void creatHeap(int a_heap[], int n); int main() { int a_heap[10001]; int count; //堆中剩余元素个数 int heap_top; //暂时保存第一个堆顶 int money = 0; //总费用 cin >> count; for (int i = 1; i <= count; i++) { cin >> a_heap[i]; } creatHeap(a_heap, count); //建初堆 while (count != 1) { heap_top = a_heap[1]; //提取第一个最小花费 a_heap[1] = a_heap[count--]; heapify(a_heap, 1, count); //调整堆寻找第二个最小花费 a_heap[1] += heap_top; //提取第二个最小花费,将花费相加,入堆 money += a_heap[1]; //更新总花费 heapify(a_heap, 1, count); // 调整堆,准备下一次拼接 } cout << money; return 0; } void heapify(int a_heap[], int idx1, int idx2) //调整堆,细节见上文 { int insert_node = a_heap[idx1]; for (int i = 2 * idx1; i <= idx2; i *= 2) { if (i < idx2 && a_heap[i] > a_heap[i + 1]) { i++; } if (insert_node <= a_heap[i]) { break; } a_heap[idx1] = a_heap[i]; idx1 = i; } a_heap[idx1] = insert_node; } void creatHeap(int a_heap[], int n) //建初堆,细节见上文 { for (int i = n / 2; i > 0; i--) { heapify(a_heap, i, n); } }

堆优化 Dijkstra 算法#

优化前的时间复杂度#

算法中添加顶点的循环执行 n - 1 次,每次执行的时间复杂度为 O(n),所以总时间复杂度为 O(n2)。如果用带权的邻接表来存储,则虽然修改 D 数组的时间可以被降下来,但由于在 D 中选择最小分量的时间不变,所以时间复杂度仍为O(n2)。我们往往只希望找到从源点到某一个特定终点的最短路径,但是这个问题和求源点到其他所有顶点的最短路径一样复杂,也得用迪杰斯特拉算法来解决。

优化思路#


我们先观察下从 v0 出发的最短路径的推导过程,我们可以观察到其实每一次添加的点都是所谓的最短边的点,然后在下一轮拿这个点来继续运作算法。其实这就给了我们一个启示——既然我需要每一轮去探测最短的点,为什么不能直接把这个点弹出来呢?
此时我们就想到了使用最栈或者优先级队列,这样就是直接把确定了最短路径的点取出来就行了。那么堆中应该存储什么样的数据?因为每轮循环都需要根据当前顶点修正路径,因此我们考虑让修正后的路径入堆,然后下一轮就可以直接在堆顶找到下一个分析的点进行操作了。除了第一轮循环需要建初堆以外,接下来的路径引入都只需要调整堆即可。

算法实现#

辅助结构#

使用邻接表实现,首先要定义两个数据类型:

  1. 表示边的二元组结构体
Copy Highlighter-hljs
typedef struct Edge { int vt; int cost; }Edge;
  1. 描述堆顶边的二元组,使用 STL 库的 pair 容器,这个主要用在建立优先级队列:
Copy Highlighter-hljs
typedef pair<int,int> Path; //first 为起点,second 为权值

接下来是实现算法的辅助结构:

  1. 一维数组 D[i]:记录点 v0 到终点 vi 的当前最短路径长度。初始化的时候若 v0 到 vi 有弧,则 D[i] 为弧上的权值,否则为 ∞;
  2. 二叉小顶堆 que:存储所有添加入的边,并且每次弹出最短的边;

伪代码#

代码实现#

Copy Highlighter-hljs
void ShortestPath_DIJ_HEAP(AdjGraph*& G,int v0) { priority_queue<Path,vector<Path>,greater<Path>> que; //用 STL 建优先级队列 Path a_path; //存储弹出的边的二元组 int v; //存储弹出的边的起点 ArcNode* ptr; for(int i = 0; i < G.n; i++) //初始化数组 D { D[i] = INF; } D[v0] = 0; que.push(Path(0,v0)); //v0 自回路入队列 while(!que.empty()) { a_path = que.top(); //提取堆顶 que.pop(); v = a_path.first; if (D[v] < a_path.second) //若新的边没有小于当前最短距离 { continue; //跳过这条路径,排除重复路径的干扰 } ptr = G->adjlist[v].firstarc; while (ptr) //扫描对应的边表 { if (D[ptr->jvex] > D[v] + ptr->info) //判断是否要修正 { D[ptr->jvex] = D[v] + ptr->info; que.push(Path(D[ptr->jvex],ptr->info)); //修正的路径加入堆 } } } }

优化后的时间复杂度#

每个顶点当前的最短距离加入二叉小顶堆,因此在更新最短距离时,堆结构应当自动更新当前的所有结点,使得堆顶表示的边是最短边。每次从堆中取出的堆顶就是下一次要用的顶点,用邻接表存储的话这样堆中的元素共有 O(v)个,调整堆的操作有 e 次,结合调整堆的时间复杂度,算法的复杂度是 O(elogv)。相比原来的算法,效率的提高非常明显!

堆排序#

代码实现#

有了前面这么多铺垫,相信理解堆排序就很容易了。所谓堆排序,就是建初堆后,反复进行交换和调整堆

Copy Highlighter-hljs
void HeapSort(SqList &L) { int heap_top; creatHeap(L); //建初堆 for(int i = L.length; i > 1; i--) { heap_top = L.r[1]; //拷贝堆顶元素,0 元素不使用 L.r[1] = L.r[i]; L.r[i] = heap_top; //交换堆中无序部分的最后一个元素和堆顶元素 Heapify(L, 1, i - 1); //调整堆 } }

其实你也能观察到,如果你是用大顶堆来存的,那么最后会得到一个有序小顶堆。当然你可以做一个小小的改装,就是每次出堆过后直接用另一个线性表存起来,这样就不用交换数据元素了,不过思想还是一样的。

复杂度分析#

时间复杂度#

堆排序的时间复杂度来源于重复的建初堆操作。由于建初堆的时间复杂度为 O(n),这个操作执行一次,更改堆元素后调整堆时间复杂度 O(logn),这个操作执行 n - 1 次。合计时 O(n) + O(nlogn) ~ O(nlogn)

空间复杂度#

由于只需要一个顺序表,无需其他辅助空间,因此时间复杂度为 O(1)

算法特点#

  1. 堆排序只应用于顺序存储结构,不适用于链式存储结构;
  2. 堆排序是不稳定的排序;
  3. 数据元素较少时不适合使用,因为建初堆的比较次数较多;
  4. 最坏情况时间复杂度为 O(nlogn),相比于快速排序的最坏时间复杂度而言更佳,尤其是在数据元素较多时。

参考资料#

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社
《数据结构与算法分析 (C语言描述)》—— Mark Allen Weiss著
二叉堆
堆排序(heapsort)
c++优先队列(priority_queue)用法详解
使用优先队列优化的Dijkstra算法
堆排序中建堆过程时间复杂度O(n)怎么来的?
堆排序及其时间复杂度
优先队列(堆) - C语言实现(摘自数据结构与算法分析 C语言描述)
漫画:什么是优先队列?
树、二叉树(完全二叉树、满二叉树)概念图解
dijkstra算法详解(普通算法和堆优化算法)

posted @   乌漆WhiteMoon  阅读(1518)  评论(0编辑  收藏  举报
编辑推荐:
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示
CONTENTS