關于最大堆
什么是最大堆和最小堆?最大(小)堆是指在樹中,存在一個結點而且該結點有兒子結點,該結點的data域值都不小于(大于)其兒子結點的data域值,并且它是一個完全二叉樹(不是滿二叉樹)。注意區分選擇樹,因為選擇樹(selection tree)概念和最小堆有些類似,他們都有一個特點是“樹中的根結點都表示樹中的最小元素結點”。同理最大堆的根結點是樹中元素最大的。那么來看具體的看一下它長什么樣?(最小堆這里省略)
這里需要注意的是:在多個子樹中,并不是說其中一個子樹的父結點一定大于另一個子樹的兒子結點。最大堆是樹結構,而且一定要是完全二叉樹。
最大堆ADT
那么我們在做最大堆的抽象數據類型(ADT)時就需要考慮三個操作:
(1)、創建一個最大堆;
(2)、最大堆的插入操作;
(3)、最大堆的刪除操作;
最大堆ADT如下:
struct Max_Heap {
object: 由多個元素組成的完全二叉樹,其每個結點都不小于該結點的子結點關鍵字值
functions:
其中heap∈Max_Heap,n,max_size∈int,Element為堆中的元素類型,item∈ Element
Max_Heap createHeap(max_size) := 創建一個總容量不大于max_size的空堆
void max_heap_insert(heap, item ,n) := 插入一個元素到heap中
Element max_heap_delete(heap,n) := if(heap不為空) {return 被刪除的元素 }else{return NULL}
}
///其中:=符號組讀作“定義為”
最大堆內存表現形式
我們只是簡單的定義了最大堆的ADT,為了能夠用代碼實現它就必須要考慮最大堆的內存表現形式。從最大堆的定義中,我們知道不管是對最大堆做插入還是刪除操作,我們必須要保證插入或者刪除完成之后,該二叉樹仍然是一個完全二叉樹?;诖?,我們就必須要去操作某一個結點的父結點。
??第一種方式,我們使用鏈表的方式來實現,那么我們需要添加一個額外的指針來指向該結點的父結點。此時就包括了左子結點指針、右子結點指針和父結點指針,那么空鏈的數目有可能是很大的,比如葉子結點的左右子結點指針和根結點的父結點指針,所以不選擇這種實現方式(關于用鏈表實現一般二叉樹時處理左右子結點指針的問題在線索二叉樹中有提及)。
??第二種方式,使用數組實現,在二叉樹進行遍歷的方法分為:先序遍歷、中序遍歷、后序遍歷和層序遍歷。我們可以通過層序遍歷的方式將二叉樹結點存儲在數組中,由于最大堆是完全二叉樹不會存在數組的空間浪費。那么來看看層序遍歷是怎么做的?對下圖的最大堆進行層序遍歷:
??那么對于數組我們怎么操作父結點和左右子結點呢?對于完全二叉樹采用順序存儲表示,那么對于任意一個下標為i(1 ≤ i ≤ n)的結點:
(1)、父結點為:i / 2(i ≠ 1),若i = 1,則i是根節點。
(2)、左子結點:2i(2i ≤ n), 若不滿足則無左子結點。
(3)、右子結點:2i + 1(2i + 1 ≤ n),若不滿足則無右子結點。
最終我們選擇數組作為最大堆的內存表現形式。
基本定義:
#define MAX_ELEMENTS 20
#define HEAP_FULL(n) (MAX_ELEMENTS - 1 == n)
#define HEAP_EMPTY(n) (!n)
typedef struct {
int key;
}element;
element heap[MAX_ELEMENTS];
下面來看看最大堆的插入、刪除和創建這三個最基本的操作。
最大堆的插入
最大堆的插入操作可以簡單看成是“結點上浮”。當我們在向最大堆中插入一個結點我們必須滿足完全二叉樹的標準,那么被插入結點的位置的是固定的。而且要滿足父結點關鍵字值不小于子結點關鍵字值,那么我們就需要去移動父結點和子結點的相互位置關系。具體的位置變化,可以看看下面我畫的一個簡單的圖。
void insert_max_heap(element item ,int *n){
if(HEAP_FULL(*n)){
return;
}
int i = ++(*n);
for(;(i != 1) && (item.key > heap[i/2].key);i = i / 2){/// i ≠ 1是因為數組的第一個元素并沒有保存堆結點
heap[i] = heap[i/2];/// 這里其實和遞歸操作類似,就是去找父結點
}
heap[i] = item;
}
由于堆是一棵完全二叉樹,存在n個元素,那么他的高度為:log2(n+1),這就說明代碼中的for循環會執行O(log2(n))次。因此插入函數的時間復雜度為:O(log2(n))。
最大堆的刪除
最大堆的刪除操作,總是從堆的根結點刪除元素。同樣根元素被刪除之后為了能夠保證該樹還是一個完全二叉樹,我們需要來移動完全二叉樹的最后一個結點,讓其繼續符合完全二叉樹的定義,從這里可以看作是最大堆最后一個結點的下沉(也就是下文提到的結點1
)操作。例如在下面的最大堆中執行刪除操作:
現在對上面??最大堆做刪除,對于最大堆的刪除,我們不能自己進行選擇刪除某一個結點,我們只能刪除堆的根結點。(??????)
- 第一步,我們刪除上圖中的根結點20;
- 當刪除根結點20之后明顯不是一個完全二叉樹,更確切地說被分成了兩棵樹。
- 我們需要移動子樹的某一個結點來充當該樹的根節點,那么在(15,2,14,10,1)這些結點中移動哪一個呢?顯然是移動結點1,如果移動了其他結點(比如14,10)就不再是一個完全二叉樹了。
對上面三步圖示如下:
顯然現在看來該二叉樹雖然是一個完全二叉樹,但是它并不符合最大堆的相關定義,我們的目的是要在刪除完成之后,該完全二叉樹依然是最大堆。因此就需要我們來做一些相關的操作!
1)、此時在結點(15,2)中選擇較大的一個和1做比較,即15 > 1的,所以15上浮到之前的20的結點處。
2)、同第1步類似,找出(14,10)之間較大的和1做比較,即14>1的,所以14上浮到原來15所處的結點。
3)、因為原來14的結點是葉子結點,所以將1放在原來14所處的結點處。
element delete_max_heap(int *n){
int parent, child;
element temp, item;
temp = heap[--*n];
item = heap[1];
parent = 1,child=2;
for(;child <= *n; child = child * 2){
if( (child < *n) && heap[child].key < heap[child+1].key){/// 這一步是為了看當前結點是左子結點大還是右子結點大,然后選擇較大的那個子結點
child++;
}
if(temp.key >= heap[child].key){
break;
}
heap[parent] = heap[child];///這就是上圖中第二步和第三步中黃色部分操作
parent = child;/// 這其實就是一個遞歸操作,讓parent指向當前子樹的根結點
}
heap[parent] = temp;
return item;
}
同最大堆的插入操作類似,同樣包含n個元素的最大堆,其高度為:log2(n+1),其時間復雜度為:O(log2(n))。
總結:由此可以看出,在已經確定的最大堆中做刪除操作,被刪除的元素是固定的,需要被移動的結點也是固定的,這里我說的被移動的元素是指最初的移動,即最大堆的最后一個元素。移動方式為從最大的結點開始比較。
最大堆的創建
為什么要把最大堆的創建放在最后來講?因為在堆的創建過程中,有兩個方法。會分別用到最大堆的插入和最大堆的刪除原理。創建最大堆有兩種方法:
(1)、先創建一個空堆,然后根據元素一個一個去插入結點。由于插入操作的時間復雜度為O(log2(n)),那么n個元素插入進去,總的時間復雜度為O(n * log2(n))。
(2)、將這n個元素先順序放入一個二叉樹中形成一個完全二叉樹,然后來調整各個結點的位置來滿足最大堆的特性。
現在我們就來試一試第二種方法來創建一個最大堆:假如我們有12個元素分別為:
{79,66,43,83,30,87,38,55,91,72,49,9}
將上訴15個數字放入一個二叉樹中,確切地說是放入一個完全二叉樹中,如下:
但是這明顯不符合最大堆的定義,所以我們需要讓該完全二叉樹轉換成最大堆!怎么轉換成一個最大堆呢?
??最大堆有一個特點就是其各個子樹都是一個最大堆,那么我們就可以從把最小子樹轉換成一個最大堆,然后依次轉換它的父節點對應的子樹,直到最后的根節點所在的整個完全二叉樹變成最大堆。那么從哪一個子樹開始調整?
我們從該完全二叉樹中的最后一個非葉子節點為根節點的子樹進行調整,然后依次去找倒數第二個倒數第三個非葉子節點...
具體步驟
在做最大堆的創建具體步驟中,我們會用到最大堆刪除操作中結點位置互換的原理,即關鍵字值較小的結點會做下沉操作。
- 1)、就如同上面所說找到二叉樹中倒數第一個非葉子結點
87
,然后看以該非葉子結點為根結點的子樹。查看該子樹是否滿足最大堆要求,很明顯目前該子樹滿足最大堆,所以我們不需要移動結點。該子樹最大移動次數為1。
- 2)、現在來到結點
30
,明顯該子樹不滿足最大堆。在該結點的子結點較大的為72
,所以結點72和結點30進行位置互換。該子樹最大移動次數為1。
- 3)、同樣對結點
83
做類似的操作。該子樹最大移動次數為1。
- 4)、現在來到結點
43
,該結點的子結點有{87,38,9}
,對該子樹做同樣操作。由于結點43可能是其子樹結點中最小的,所以該子樹最大移動次數為2。
- 5)、結點
66
同樣操作,該子樹最大移動次數為2。
- 6)、最后來到根結點
79
,該二叉樹最高深度為4,所以該子樹最大移動次數為3。
自此通過上訴步驟創建的最大堆為:
所以從上面可以看出,該二叉樹總的需要移動結點次數最大為:10。
代碼實現
void create_max_heap(void){
int total = (*heap).key;
/// 求倒數第一個非葉子結點
int child = 2,parent = 1;
for (int node = total/2; node>0; node--) {
parent = node;
child = 2*node;
int max_node = 2*node+1;
element temp = *(heap+parent);
for (; child <= total; child *= 2,max_node = 2*parent+1) {
if (child+1 <= total && (*(heap+child)).key < (*(heap+child+1)).key) {
child++;
}
if (temp.key > (*(heap+child)).key) {
break;
}
*(heap+parent) = *(heap+child);
parent = child;
}
*(heap+parent) = temp;
}
}
/**
*
* @param heap 最大堆;
* @param items 輸入的數據源
* @return 1成功,0失敗
*/
int create_binary_tree(element *heap,int items[MAX_ELEMENTS]){
int total;
if (!items) {
return 0;
}
element *temp = heap;
heap++;
for (total = 1; *items;total++,(heap)++,items = items + 1) {
element ele = {*items};
element temp_key = {total};
*temp = temp_key;
*heap = ele;
}
return 1;
}
///函數調用
int items[MAX_ELEMENTS] = {79,66,43,83,30,87,38,55,91,72,49,9};
element *position = heap;
create_binary_tree(position, items);
for (int i = 0; (*(heap+i)).key > 0; i++) {
printf("binary tree element is %d\n",(*(heap + i)).key);
}
create_max_heap();
for (int i = 0; (*(heap+i)).key > 0; i++) {
printf("heap element is %d\n",(*(heap + i)).key);
}
上訴代碼在我機器上能夠成功的構建一個最大堆。由于該完全二叉樹存在n個元素,那么他的高度為:log2(n+1),這就說明代碼中的for循環會執行O(log2(n))次。因此其我理解的平均運行時間為:O(log2(n))。而其上界為當該二叉樹為滿二叉樹時其時間復雜度為O((n)。
堆排序
堆排序要比空間復雜度為O(n)的歸并排序要慢一些,但是要比空間復雜度為O(1)的歸并排序要快!
??通過上面最大堆創建一節中我們能夠創建一個最大堆。出于該最大堆太大,我將其進行縮小以便進行畫圖演示。
最大堆的排序過程其實是和最大堆的刪除操作類似,由于最大堆的刪除只能在根結點進行,當將根結點刪除完成之后,就是將剩下的結點進行整理讓其符合最大堆的標準。
- 1)、把最大堆根結點
91
“刪除”,第一次排序圖示:
進過這一次排序之后,91
就處在最終的正確位置上,所以我們只需要對余下的最大堆進行操作!這里需要注意一點:
??????注意,關于對余下進行最大堆操作時:
并不需要像創建最大堆時,從倒數第一個非葉子結點開始。因為在我們只是對第一個和最后一個結點進行了交換,所以只有根結點的順序不滿足最大堆的約束,我們只需要對第一個元素進行處理即可
-
2)、繼續對結點
87
進行相同的操作:
同樣,87
的位置確定。 -
3)、現在我們來確定結點
83
的位置:
-
4)、經過上訴步驟就不難理解堆排序的原理所在,最后排序結果如下:
經過上訴多個步驟之后,最終的排序結果如下:
[38、43、72、79、83、87、91]
很明顯這是一個正確的從小到大的順序。
編碼實現
這里需要對上面的代碼進行一些修改!因為在排序中,我們的第0個元素是不用去放一個哨兵的,我們的元素從原來的第一個位置改為從第0個位置開始放置元素。
void __swap(element *lhs,element *rhs){
element temp = *lhs;
*lhs = *rhs;
*rhs = temp;
}
int create_binarytree(element *heap, int items[MAX_SIZE], int n){
if (n <= 0) return 0;
for (int i = 0; i < n; i++,heap++) {
element value = {items[i]};
*heap = value;
}
return 1;
}
void adapt_maxheap(element *heap ,int node ,int n){
int parent = node - 1 < 0 ? 0 : node - 1;
int child = 2 * parent + 1;/// 因為沒有哨兵,所以在數組中的關系由原來的:parent = 2 * child => parent = 2 * child + 1
int max_node = max_node = 2*parent+2 < n - 1 ? 2*parent+2 : n - 1;
element temp = *(heap + parent);
for (;child <= max_node; parent = child,child = child * 2 + 1,max_node = 2*parent+2 < n - 1 ? 2*parent+2 : n - 1) {
if ((heap + child)->key <= (heap + child + 1)->key && child + 1 < n) {
child++;
}
if ((heap + child)->key < temp.key) {
break;
}
*(heap + parent) = *(heap + child);
}
*(heap + parent) = temp;
}
int create_maxheap(element *heap ,int n){
for (int node = n/2; node > 0; node--) {
adapt_maxheap(heap, node, n);
}
return 1;
}
void heap_sort(element *heap ,int n){
///創建一個最大堆
create_maxheap(heap, n);
///進行排序過程
int i = n - 1;
while (i >= 0) {
__swap(heap+0, heap + i);/// 將第一個和最后一個進行交換
adapt_maxheap(heap, 0, i--);///將總的元素個數減一,適配成最大堆,這里只需要對首元素進行最大堆的操作
}
}
調用:
/// 堆排序
int n = 7;
int items[7] = {87,79,38,83,72,43,91};
element heap[7];
create_binarytree(heap, items, n);
heap_sort(heap, n);///38,43,72,79,83,87,91
在實現堆排序時最需要注意的就是當沒有哨兵之后,父結點和左右孩子結點之間的關系發生了變化:
parent = 2 * child + 1;///左孩子
parent = 2 * child + 2;///右孩子
關于對排序相關的知識點已經整理完了。其時間復雜度和歸并排序的時間時間復雜度是一樣的O(N*LogN)。
結束語
當我們在做和完全二叉樹有關的操作時,對于完全二叉樹采用順序存儲表示,需要記住對于任意一個下標為i(1 ≤ i ≤ n)的結點:父結點為:i / 2(i ≠ 1),若i = 1,則i是根節點。左子結點:2i(2i ≤ n), 若不滿足則無左子結點。右子結點:2i + 1(2i + 1 ≤ n),若不滿足則無右子結點。
??關于最大堆的相關操作(插入、刪除、創建和排序)已經一一學習完畢。這些操作中,刪除、創建和排序思想非常類似,都是操作結點下沉。而插入操作相反,類似上浮的操作!