【数据结构与算法分析——C语言描述】第六章:优先队列(堆)
标签(空格分隔):【数据结构与算法】
##6.2 一些简单的实现
一棵高度为 $ h $ 的完全二叉树有 $ 2^h $ 到 $ 2^{h+1} - 1 $ 个节点,这意味着,对于拥有 $ N $ 个节点的完全二叉树而言,它的高度为 $ \lfloor logN \rfloor $ ,显然为 $ O(logN) $.
十分重要的一点,由于完全二叉树具有规律性,所以它可以用数组来表示.可以代替指针.如下.
| |A|B|C|D|E|F|J|H|I| J| | | |
–|--|
|0|1|2|3|4|5|6|7|8|9|10|11|12|13|
对于数组中任意一个位置 $ i $ 上的元素,其左儿子在位置 $ 2i $ 上,右儿子在位置 $ 2i + 1 $ 上,它们的父亲在位 置$ \lfloor \frac{i}{2} \rfloor $ 上.
用数组表示的优点不再赘述,但有一个缺点需要提醒,使用数组表示二叉堆需要实现估计堆的大小,但这对于典型情况不成问题.
//优先队列的声明
struct HeapStruct;
typedef struct HeapStruct *PriorityQueue;
struct HeapStruct{
int Capacity;
int Size;
ElementType *Elements;
};
PriorityQueue Initialize( int MaxElements);
void Destroy( PriorityQueue H);
void MakeEmpty( PriorityQueue H);
void Insert( ElementType X, PriorityQueue H);
ElementType DeleteMin( PriorityQueue H);
ElementType FindMin( PriorityQueue H);
int IsEmpty( PriorityQueue H);
int IsFull( PriorityQueue H);
PriorityQueue Initialize( int MaxElements){
PriorityQueue H;
if( MaxElements < MinPQSize)
Error(" Priority queue size is too small");
H = malloc( sizeof( struct HeapStruct));
if( H = NULL)
FatalError(" Out of space");
H->Elements = malloc( ( MaxElements + 1) * sizeof( ElementType));
if( H->Elements == NULL)
FatalError(" Out of space");
H->Capacity = MaxElements;
H->Size = 0;
H->Elements[0] = MinData;//标记
return H;
}
###6.3.2 堆序性质
###6.3.3 基本的堆操作
void Insert( ElementType X, PriorityQueue H){
int i;
if( IsFull( H)){
Error(" Priority queue is full");
return ;
}
for( i = ++H->Size; H->Elements[i/2] > X; i/=2)
H->Elements[i] = H->Elements[i/2];
H->Elements[ i] = X;
}
如果插入的元素是新的最小值,那么它将被推到顶端.在顶端, $ i = 1 $ ,我们可以跳出循环,可以在 $ i = 0 $ 处放置一个很小的值使得循环中止.这个值我们称之为标记(sentinel),类似于链表中头节点的使用,通过添加一条哑信息(dummy piece of infomation),避免每次循环都要执行一次测试,从而节省了一点时间.
如果插入的元素是新的最小元,已知过滤到根部,那么这种插入的时间复杂度显然是 $ O(logN) $ .
其中需要注意的一点是,当堆中存在偶数个元素时,此时将会遇到一个节点只有一个儿子的情况,我们不必须保证节点不总有两个儿子,因此这就涉及到一个附加节点的测试.
ElementType DeleteMin( PriorityQueue H){
int i, Child;
ElementType MinElement, LastElement;
if( IsEmpty( H)){
Error(" Priority queue is empty");
return H->Elements[0];
}
MinElement = H->Elements[1];
LastElement = H->Elements[ H->Size--];
for( i=1; i*2 <= H->Size; i = Child){
Child = i * 2;
if( Child != H->Size && H->Elements[ Child + 1] < H->Elements[ Child])
Child++;
//if语句用来测试堆中含有偶数个元素的情况
if( LastElement > H->Elements[ Child])
H->Elements[i] = H->Elements[ Child];
else break;
}
H->Elements[i] = LastElement;
return MinElement;
}
这种算法的最坏运行时间为 $ O(logN) $ ,平均而言,被放到根除的元素几乎下滤到堆的底层.
###6.3.4 其他堆的操作
如果我们假设通过某种其他方法得知每一个元素的位置,那么有几种其他的操作的开销将变小.
下列三种这样的操作均以对数最坏情形时间运行.
DecreaseKey 降低关键字的值
DecreaseKey(P, $ \Delta $ , H)操作将降低在位置 P 处关键字的值,降低的幅度为 $ \Delta $( $ \Delta > 0 $ ),由于这可能会破坏堆的序,因此必须通过上滤对堆进行调整.该操作堆系统管理程序是游泳的,系统管理程序能够使它们的程序以最高的优先级来运行.
IncreaseKey 增加关键字的值
IncreaseKey(P, $ \Delta $ , H)操作将增加在位置 P 处关键字的值,增值的幅度为 $ \Delta $( $ \Delta > 0 $ ).可以通过下滤来完成.许多调度程序自动地降低正在过多小号CPU时间的进程的优先级.
Delete 删除
Delete(P, H)操作删除堆中位置 P 上的节点.这通过首先执行DecreaseKey(P, $ \infty $ , H),然后再执行 DeleteMin(H).当一个进程被用户中止(非正常中止)时,它不许从优先队列中除去.
BuildHeap 构建堆
BuildHeap(H)操作把 N 个关键字作为输入并把它们放到空堆中.显然,这可以使用 N 个 Insert 操作来完成.由于每个 Insert 操作将会花费平均 $ O(1) $ 时间和最坏 $ O(logN) $ 时间,因此该操作的总的运行时间是 $ O(N) $ 平均时间而不是 $ O(NlogN) $ 最坏情形时间.由于这是一种特殊的执行,没有其他操作的干扰,也让且我们已经直到了该指令能够以线性平均时间运行,因此,期望能够保证线性时间界的考虑是合乎情理的.
下面我们看平均时间是怎么得到的.
一般的算法是将 N 个关键字以任意顺序放入树中,保持结构特性.此时,如果 percolateDown(i) 从节点 i 下滤,那么执行下列代码创建一棵具有堆序的树(heap-ordered tree)
for( i = N/2; i>0; i--)
PercolateDown(i);
为了确定 BuildHeap 的运行时间的界,我们确定虚线条数的界,这可以通过计算树中所有节点的高度和来得到,它是虚线的最大条数,现在我们说明,该和为$ O(N) $
高度 | 节点数量 |
---|---|
b | 1 |
b-1 | 2 |
… | … |
1 | 2 b − 1 2^{b-1} 2b−1 |
0 | 2 b 2^{b} 2b |
则所有节点的高度和 $ S = 2^{0} * b + 2^{1} *( b - 1 ) + … + 2^{b-1} * 1+ 2^{b} * 0 = $
利用裂项相消,得到
$ S = 2^{b+1} - 1 - ( b + 1 ) $
##6.4 优先队列的应用
###6.4.1 选择问题
选择问题(selection problem):从一组 N 个数而要确定其中第 k 个最大者.
###6.4.2 时间模拟
##6.5 d-堆
##6.6 左式堆
###6.6.1 左式堆的性质
在图示中,右边的树并不是左式堆,因为他不满足左式堆的性质。
左式堆的性质显然更加使树向左增加深度。确实有可能存在由左节点形成的长路径构成的树(实际上这更加便于合并操作),故此,我们便有了左式堆这个名称。
定理:
在右路径上有 $ r $ 个节点的左式树必然至少有 $ 2^r - 1 $ 个节点。
证明:数学归纳法。
若$ r = 1 $ ,则必然至少存在一个树节点;
假设定理对 $ r = k $ 成立,考虑在右路径上有 $ k + 1 $ 个节点的左式树,此时,根具有在右路径上含 $ k $ 个节点的右子树,以及在右路径上至少包含 $ k $ 个节点的左式树(否则它便不是左式树)。对这两个子树应用归纳假设,得知每棵子树上最少含有 $ 2^k - 1 $ 个节点,再加上根节点,于是这颗树上至少有有 $ 2^{k+1} - 1 $ 个节点。
原命题得证。
推广:从上述定理我们立即可以得到,$ N $ 个节点的左式树有一条右路径最多包含 $ \lfloor log(N+1) \rfloor $ 个节点。
###6.6.1 左式堆的操作
如果这两个堆中有一个是空的,那么我们可以直接返回另一个非空的堆。
否则,想要合并两个堆,我们需要比较它们的根。回想一下,最小堆中根节点小于它的两个儿子,并且子树都是堆。我们将具有大的根值得堆与具有小的根值得堆的右子树合并。在本例中,我们递归地将 $ H_2 $ 与 $ H_1 $ 中根在 8 处的右子堆合并,得到下图:
注意,因为这颗树是通过递归形成的,我们有理由说,合成的树依然是一棵左式树。现在,我们让这个新堆成为 $ H_1 $ 中根的右儿子。如下图:
最终得到的堆依然满足堆序的性质,但是,它并不是左式堆。因为根的左子树的零路径长为 1 ,而根的右子树的零路径长为 2 .左式树的性质在根处遭到了破坏。不过,很容易看到,树的其余部分必然是左式树。这样一来,我们只要对根部进行调整即可。
方法如下:只要交换根的做儿子和右儿子,如下图,并更新零路径长,就完成了 Merge . 新的零路径长是新的右儿子的零路径长加 1 .注意,如果零路径长不更新,那么所有的零路径长将都是 0 ,而堆也不再是左式堆,只是随机的。
struct TreeNode;
typedef struct TreeNode *PriorityQueue;
struct TreeNode{
ElementType Element;
PriorityQueue Left;
PriorityQueue Right;
int Npl;
};
PriorityQueue Initialize( void);
ElementType FindMin( PriorityQueue H);
int IsEmpty( PriorityQueue H);
PriorityQueue Merge( PriorityQueue H1, PriorityQueue H2);
PriorityQueue Merge1( PriorityQueue H1, PriorityQueue H2)
#define Insert( X, H)( H = Insert1( ( X), H)
//宏Insert 完成一次与二叉堆兼容的插入操作
PriorityQueue Insert1( ElementType X, PriorityQueue H);
//Insert1 左式堆的插入例程
PriorityQueue DeleyeMin1( PriorityQueue H);
//合并
PriorityQueue Merge( PriorityQueue H1, PriorityQueue H2){
if( H1 == NULL)
return H2;
if( H2 == NULL)
return H1;
if( H1->Element < H2->Element);
return Merge1( H1, H2);
else
return Merge1( H2, H1);
}
PriorityQueue Merge1( PriorityQueue H1, PriorityQueue H2){
if( H1->Left == NULL)
H1->Left = H2;
else{
H1->Right = Merge( H1->Right, H2);
if( H1->Left->Npl < H1->Right->Npl)
SwapChildren( H1);
H1->Npl = H1->Right->Npl + 1;
}
return H1;
}
//插入
PriorityQueue Insert1( ElementType X, PriorityQueue H){
PriorityQueue SingleNode;
SingleNode = malloc( sizeof( struct TreeNode));
if( SingleNode == NULL)
FatalError(" Out of space");
else{
SingleNode->Element = X;
SingleNode->Npl = 0;
SingleNode->Left = SingleNode->Right = NULL;
H = Merge( SingleNode, H);
}
return H;
}
//删除
PriorityQueue DeleyeMin1( PriorityQueue H){
PriorityQueue LeftHeap, RightHeap;
is( IsEmpty( H)){
Error(" Priority queue is empty");
return H;
}
LeftHeap = H->Left;
RightHeap = H->Right;
free( H);
return Merge( LeftHeap,RightHeap);
}
##6.7 斜堆
##6.8 二项队列
###6.8.1 二项队列结构
从图中看到,二项树 $ B_k $ 由一个带有儿子 $ B_0 , B_1, B_2,…, B_{k-1} $ 的根组成。高度为 k 的二项树恰好有 $ 2^k $ 个节点,而在深度 d 处的节点数为 $ C_k^d $ .
###6.8.2 二项队列的操作
FindMin:可以通过搜索所有树的树根找出。由于最多有 $ logN $ 棵不同的树,因此找到最小元的时间复杂度为 $ O(logN) $ . 另外,如果我们记住当最小元在其他操作期间变化时更新它,那么我们也可保留最小元的信息并以 $ O(1) $ 时间执行该操作。
Merge:合并操作基本上是通过将两个队列加到一起来完成的。考虑两个二项队列 $ H_1,H_2 $ ,他们分别具有六个和七个元素,见下图。
令 $ H_3 $ 是新的二项队列。
由于 $ H_1 $ 没有高度为 0 的二项树而 $ H_2 $ 拥有,因此我们就用 $ H_2 $ 中高度为 0 的二项树作为 $ H_3 $ 的一部分。
由于 $ H_1、H_2 $ 都拥有高度为 1 的二项树,因此我们令二者合称为 $ H_3 $ 中高度为 2 的二项树。
现存有三棵高度为 2 的树,我们选择其中两个和合成高度为 3 的树,另外一棵放到 $ H_3 $ 中。
考虑 Merge 操作的时间复杂度,由于几乎使用任意合理的实现方法合并两棵二项树均花费常数时间,而总存在 $ O(logN) $ 棵二项树,因此合并在最坏情形下花费时间为 $ O(logN) $ .为了使操作更高效,我们需要将这些树放到按照高度排血的二项队列中。
Insert:插入操作实际上是特殊情形的合并,我们只需要创建一棵单节点树并执行一次合并操作。这种操作的最坏运行时间也是 $ O(logN) $ .更加准确地说,如果元素将要插入的那个优先队列不存在的最小的 $ B_k $ ,那么运行时间与 i+1 成正比.
DeleteMin:通过首先找出一棵具有最小根的二项树来完成。令该树为 $ B_k $ ,并令原始的优先队列为 $ H $ ,我们从 H 的树的森林中除去二项树 $ B_k $ ,形成新的二项树队列 $ H’ $ ,再除去 $ B_k $ 的根,得到一些二项树 $ B_0 , B_1, B_2,…, B_{k-1} $ ,它们共同形成优先队列 $ H’’ $ .合成 $ H’ $ 与 $ H’’ $ ,操作结束。
例如,假设有二项队列 $ H_3 $ ,如下图:
其中最小的根是 12,因此我们得到两个优先队列 $ H’ $ 和 $ H’’ $ ,如下图:
最后,合并 $ H’ $ 和 $ H’’ $ ,完成 DeleteMin 操作。
分析时间复杂度,注意,DeleteMin 操作将原队列一分为二,找出含有最小元素的树并创建队列 $ H’ $ 和 $ H’’ $ 花费时间为 $ O(logN) $ 时间,合并 $ H’ $ 和 $ H’’ $ 又花费时间为 $ O(logN) $ 时间。因此,整个 DeleteMin 操作的时间复杂度为 $ O(logN) $ .
###6.8.3 二项队列的实现
//声明
typedef struct BinNode *Position;
typedef struct Collection *BinQueue;
struct BinNode{
ElementType Element;
Position LeftChild;
Position NextSibling;
};
struct Collection{
int CurrentSize;
BinTree TheTrees[ MaxTrees];
};
//合并两颗同样大小的二项树
BinTree CombineTrees( BinTree T1, BinTree T2){
if( T1->Element > T2->Element)
return ConbinTrees( T2, T1);
T2->NextSibling = T1->LeftChild;
T1->LeftChild = T2;
return T1;
}
//合并
BinQueue Merge( BinQueue H1, BinQueue H2){
BinTree T1, T2, Carry = NULL;
int i,j;
if( H1->CurrentSize + H2->CurrentSize > Capacity)
Error("Merge would exceed capacity");
H1->CurrentSize += H2->CurrentSize;
for( i = 0, j = 1; j <= H1->CurrentSize; i++){
T1 = H1->TheTrees[i];
T2 = H2->TheTrees[i];
switch( !!T1 + 2 * !!T2 + 4 * !!Carry){//Carry 是上一步骤得到的树
case 0: //No trees;
case 1:
break;
case 2:
H1->TheTrees[i] = T2;
H2->TheTrees[i] = NULL;
case 4: //Only Carry
H1->TheTrees[i] = Carry;
break;
case 3:
Carry = CombineTrees( T1, T2);
H1->TheTrees[i] = H2->TheTrees[i] = NULL;
break;
case 5:
Carry = CombineTrees( T1, T2);
H1->TheTrees[i] = NULL;
break;
case 6:
Carry = CombineTrees( T1, T2);
H2->TheTrees[i] = NULL;
break;
case 7: //All three
H1->TheTrees[i] = Carry;
Carry = CombineTrees( T1, T2);
H2->TheTrees[i] = NULL;
break;
}
}
return H1;
}
//删除最小元并返回
ElementType DeleteMin( BinQueue H){
int i,j;
int MinTree;
BinQueue DeletedQueue;
Position DeletedTree, OldRoot;
ElementType MinItem;
if( IsEmpty( H)){
Error(" Empty binimial queue");
return -Infinity;
}
MinItem = Infinity;
for( i = 0; i< MaxTrees; i++){
if( H->TheTrees[i] && H->TheTrees[i]->Element < MinItem){
MinItem = H->TheTrees[i]->Element;
MinTree = i;
}
}
DeletedTree = H->TheTrees[MinTree];
OldRoot = DeletedTree;
DeletedTree = DeletedTree->LeftChild;
free(OldRoot);
DeletedQueue = Initialize();
DeletedQueue->CurrentSize = ( 1<< MinTree) - 1;
for( j = MinTree - 1; j >= 0; j--){
DeletedQueue->TheTrees[j] = DeletedTree;
DeletedTree = DeletedTree->NextSibling;
DeletedQueue->TheTrees->NextSibling = NULL;
}
H->TheTrees[ MinTree] = NULL;
H->CurrentSize -= DeletedQueue->CurrentSize + 1;
Merge( H, DeletedQueue);
return MinItem;
}
##我的微信公众号