数据结构与算法复习——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 }
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 }
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 }
这就是最简单的堆——二叉堆的简单介绍。