数据结构与算法复习——4、二叉堆

4、二叉堆——前置知识复习1

一、堆简介

  堆是一种数据结构,要求有效地完成两种基本操作:Insert(插入元素)和Pop(删除最值),其中最值可能是最大值、最小值或者其它复杂的最值。在这两种基本操作中,必然包括Top(寻找最值),但基本的堆不要求其他复杂的操作,比如合并,或者寻找任意元素。如果能够高效地寻找任意元素,则还可能做这些操作:Decrese(减小某点权值)、Increase(增加某点权值)、Delete(删除某点)。

  堆的这些操作对操作系统有重要意义:操作系统一般在调度时为每个任务都会分配一个优先级,当任务冲突时,优先级最高的将会被优先执行。可见Insert即添加新任务,Pop即任务完成,Decrease、Increase即对某个任务的优先级调整(比如降低某些占用太多资源的任务),Delete即中止某项任务。当然,对于操作系统来讲,建立堆也是必不可少的。

  堆的实现有很多种,我们今天来复习最基本(但是也非常高效)的二叉堆

二、二叉堆

  二叉堆是一棵树,它的递归定义如下:

1、二叉堆是一棵完全二叉树

2、二叉堆根节点的值与它左、右子节点的值分别都满足同一个序(例如都小于、都大于)(没有子节点时默认满足);

3、二叉堆的左右两棵子树也都是二叉堆。

(如果不清楚完全二叉树是什么:作为二叉树,除了最后一层有可能例外,其余层都是满的)

完全二叉树有一个很好的性质:如果经过恰当的编号(根节点是1,之后向下每层从左向右编号),那么,节点$N$的父结点就是$N/2$(向下取整),左子结点就是$2N$,右子节点则是$2N+1$。这表明,我们不需要一棵真正的树,而只需要用数组来模拟就好;只要我们事先能控制好堆的最大规模。

二叉堆因此具有两个主体性质:结构性(是完全二叉树)、堆序性(最值在最上面)。我们下面用小根堆举例,介绍二叉堆怎么做基本操作:

Insert:空堆是平凡的。如果不是空堆,则新入的节点应该放在某个位置。为了维持堆的结构性,我们首先把它放在最后一层的末尾(即数组的末尾),然后通过交换元素来维持堆序。具体的方法称为向上过滤(上滤):对于这个新结点$X$,如果它的值比它的父节点还小,则交换它与它的父亲。很显然这种交换是合适的,因为它的(可能的)兄弟必然大于等于它的父亲,也就大于它。不断进行,直到不需要交换为止。从二叉堆的结构我们知道,这个操作最坏是$O(\log N)$的。但是有文章指出这个操作的平均复杂度实际上能够达到$O(1)$,但我目前无力证明。

这一操作可以简化,由于交换是一个比较浪费的操作,我们用空位置来代替:首先在末尾扩展一个空位置,检查这个空位置能不能放置新的节点$X$,如果能,则放入;如果不能,则将父节点移到这个空位置里,空位置则转移到了父节点的原本位置,重复直到新结点进入了空位置为止。Insert的某种实现如下:

 1 void Insert(int V) {
 2     if (hsize == 0) {
 3         val[root] = V;
 4         hsize++;
 5     } else {
 6         int empt = ++hsize;
 7         while (empt > root) {
 8             if (V < val[empt / 2]) {
 9                 val[empt] = val[empt / 2];
10                 empt /= 2;
11             } else {
12                 val[empt] = V;
13                 break;
14             }
15         }
16         val[empt] = V;
17     }
18     return;
19 }
insert

Pop:空堆仍然是平凡的,否则这样做:将堆顶删除,然后把最后一个元素放到堆顶,之后向下过滤(下滤):首先比较该节点与左右子节点的值,如果都是满足堆序的,就停止下滤;否则,将它与左右子节点中较小的交换,然后重复下滤直到停止或到叶子节点为止。

与上滤一样,下滤也可以简化,只要把应该被下滤的节点变成空位置,观察这个节点能不能放到空位置,若不能,将空位置的左右子节点的较小者放至空位置,空位置下移即可。Pop操作(包括下滤操作)的某种实现如下:

 1 void downAdjust(int node) {
 2     int empt = node;
 3     int tempv = val[node];
 4     int vl, vr;
 5     do {
 6         if (empt * 2 <= hsize)
 7             vl = val[empt * 2];
 8         else
 9             vl = 0x7fffffff;
10 
11         if (empt * 2 + 1 <= hsize)
12             vr = val[empt * 2 + 1];
13         else
14             vr = 0x7fffffff;
15 
16         if (vl >= tempv && vr >= tempv) {
17             val[empt] = tempv;
18             break;
19         } else {
20             if (vl < vr) {
21                 val[empt] = vl;
22                 empt *= 2;
23             } else {
24                 val[empt] = vr;
25                 empt *= 2;
26                 empt++;
27             }
28         }
29     } while (1);
30     return;
31 }
32 
33 void Pop() {
34     if (hsize == 0)
35         return;
36 
37     val[root] = val[hsize--];
38     downAdjust(root);
39     return;
40 }
pop

BuildHeap:从$N$个元素的数组开始建立一个堆,最简单的想法就是进行$N$次插入。它的最坏可能当然是$O(N \log N)$,不过鉴于有文章提到Insert的平均复杂度是$O(1)$,也可以期待对随机数组这样建堆达到$O(N)$。不过,建堆明显有一个更好的方法:

将数组直接建为完全二叉树(实际上不需要操作),然后从第一个有子节点的元素开始(明显是编号最大的叶子节点的父节点,也就是$N/2$),每个节点都下滤,就完成建堆。

看上去这个操作也会是$O(N \log N)$的,但是我们有如下定理告诉我们它实际上是$O(N)$:

定理:一棵有$2^{h+1} - 1$个节点的满二叉树,每个节点的高度和是$S = 2^{h+1} - 1 - (h + 1)$。

证明:高度是这样定义的:叶子节点的高度是$0$;每个节点的高度是它的子节点的高度$+1$。这样可以知道,定理描述的二叉树中,根节点的高度是$h$,第二层的两个节点的高度是$h-1$,以此类推。这样,我们应该有如下和式:

$S = \sum_{i=0}^{h-1} 2^i (h-i)$

为了求这个和,两边同乘$2$

$2S = \sum_{i=1}^{h} 2^i (h-i+1)$

上下相减就有了

$S = -h + \sum_{i=1}^{h-1} 2^i + 2^h$

$=2^{h+1}-2-h$

$=2^{h+1}-1 - (h+1)$

由于刚刚的下滤建堆操作将不会进行超过$O(S)$次比较和赋值,而二叉堆是一个完全二叉树,如果$N=2^h+m$,显然有$2^h-1-h \leq S \leq 2^{h+1}-1-(h+1)$,因此定理告诉我们,这种建堆的操作是$O(N)$的,可见十分优越。它的某种实现如下:

 1 void BuildHeap(int A[], int size) {
 2     if (size == 0)
 3         return;
 4 
 5     hsize = size;
 6     for (int i = 0; i <= size; i++)
 7         val[i + 1] = A[i];
 8 
 9     for (int i = hsize / 2; i; i--) {
10         downAdjust(i);
11     }
12     return;
13 }
buildheap

这就是最简单的堆——二叉堆的简单介绍。

posted @ 2021-02-01 17:47  Halifuda  阅读(152)  评论(0编辑  收藏  举报