堆、优先级队列和堆排序

导言

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

二叉堆

完全二叉树

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



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

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


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

堆的存储方式

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

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

调整堆

操作解析

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

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

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

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

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

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

伪代码

代码实现

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 的结点作为根的子树统统搞成堆即可。加上我们在刚才已经写了筛选法的代码,现在这件事情就变得简单了。

代码实现

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

时间复杂度

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

优先级队列

按照优先级出队列

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

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

优先级队列结构体定义

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

typedef struct HeapStruct
{
    int size;
    ElemType data[MAXSIZE];
}*PriorityQueue;

入队列操作

上滤

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

模拟入队列

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

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


伪代码

代码实现

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 下沉到合适的位置,完成操作。

伪代码

代码实现

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 中为我们封装好了最小优先队列和最大优先队列,包含于头文件:

#include<queue>

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

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

容器定义形式

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

STL 库使用例

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

运行结果如下:

情景应用:修理牧场

情景需求

情景模拟

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

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

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

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





解法分析

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

代码实现

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

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

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

伪代码

代码实现

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)。相比原来的算法,效率的提高非常明显!

堆排序

代码实现

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

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 @ 2020-03-22 20:18  乌漆WhiteMoon  阅读(1407)  评论(0编辑  收藏  举报