堆与堆排序(一)
堆與堆排序(一)
上一篇博文 淺談優(yōu)先隊(duì)列 介紹了什么是優(yōu)先隊(duì)列,文末提到了一種數(shù)據(jù)結(jié)構(gòu)——“堆”,基于“堆”實(shí)現(xiàn)的優(yōu)先隊(duì)列,出隊(duì)和入隊(duì)的時(shí)間復(fù)雜度都為 O(logN).
這篇博文我們就走進(jìn)“堆”,看看它到底是什么結(jié)構(gòu)。
此堆非彼堆
值得注意的是,這里的“堆”不是內(nèi)存管理中提到的“堆棧”的“堆”。前者的“堆”——準(zhǔn)確地說(shuō)是二叉堆,是一種類(lèi)似于完全二叉樹(shù)的數(shù)據(jù)結(jié)構(gòu);后者的“堆”是一種類(lèi)似于鏈表的數(shù)據(jù)結(jié)構(gòu)。
堆的結(jié)構(gòu)性質(zhì)
二叉堆在邏輯結(jié)構(gòu)上是一棵完全二叉樹(shù)。什么是完全二叉樹(shù)呢?即樹(shù)的每一層都是滿(mǎn)的,除了最后一層最右邊的元素有可能缺位。
如下圖所示,打錯(cuò)號(hào)的兩個(gè)不是完全二叉樹(shù),其他都是。
對(duì)于一個(gè)有 N 個(gè)節(jié)點(diǎn)的完全二叉樹(shù),我們可以為它的每個(gè)節(jié)點(diǎn)指定一個(gè)索引,方法是從上至下,從左到右,從1開(kāi)始連續(xù)編號(hào),如下圖黑色數(shù)字所示。了解二叉樹(shù)的朋友一定看出來(lái)了,這就是二叉樹(shù)的層序遍歷。
可以看出,對(duì)于一個(gè)有 N 個(gè)節(jié)點(diǎn)的完全二叉樹(shù),索引值和元素是一一對(duì)應(yīng)的。所以完全二叉樹(shù)可以用一個(gè)數(shù)組來(lái)表示而不需要指針:索引值就是數(shù)組的下標(biāo),元素的值就是節(jié)點(diǎn)的關(guān)鍵字。
如下圖,是一個(gè)完全二叉樹(shù)和數(shù)組的相互關(guān)系。
如果你繼續(xù)觀察,就會(huì)發(fā)現(xiàn)另一個(gè)規(guī)律:對(duì)于數(shù)組任一位置 i 上的元素,其左兒子在位置 2i 上,右兒子在2i+1上,它的父親則在位置 ?i/2??i/2?上。
以節(jié)點(diǎn) D 為例,D 的下標(biāo)是 4.
- B是它的父節(jié)點(diǎn),B的下標(biāo)是2(=4/2),如圖中黑色的線(xiàn);
- H是它的左孩子,H的下標(biāo)是8(=4*2),如圖中藍(lán)色的線(xiàn);
- I是它的右孩子,I的下標(biāo)是9(=4*2+1),如圖中紅色的線(xiàn);
堆序性質(zhì)
二叉堆一般分為兩種:最大堆和最小堆。
最大堆:也叫做大根堆。每一個(gè)節(jié)點(diǎn)的值(或者說(shuō)關(guān)鍵字)都要大于或等于它孩子的值(對(duì)于任何葉子我們認(rèn)為這個(gè)條件都是自動(dòng)滿(mǎn)足的)。下圖就是一個(gè)最大堆。
最小堆:也叫做小根堆。每一個(gè)節(jié)點(diǎn)的值(或者說(shuō)關(guān)鍵字)都要小于或等于它孩子的值(對(duì)于任何葉子我們認(rèn)為這個(gè)條件都是自動(dòng)滿(mǎn)足的)。下圖就是一個(gè)最小堆。
值得注意的是:以大根堆為例,在任何從根到某個(gè)葉子的路徑上,鍵值的序列是遞減的(如果允許相等的鍵存在,則是非遞增的)。然而,鍵值之間并不存在從左到右的次序。也就是說(shuō),在樹(shù)的同一層節(jié)點(diǎn)之間,不存在任何關(guān)系,更一般地來(lái)說(shuō),在同一節(jié)點(diǎn)的左右子樹(shù)之間也沒(méi)有任何關(guān)系。
堆的重要特性
以大根堆為例,把堆的重要特性總結(jié)如下。
只存在一棵 n 個(gè)節(jié)點(diǎn)的完全二叉樹(shù)。它的高度等于?log2n??log2?n?
堆的根總是包含了堆的最大元素
堆的一個(gè)節(jié)點(diǎn)以及該節(jié)點(diǎn)的子孫也是一個(gè)堆
可以用數(shù)組來(lái)實(shí)現(xiàn)堆,方法是用從上到下、從左到右的方式來(lái)記錄堆的元素。為了方便起見(jiàn),可以在這種數(shù)組從 1 到 n 的位置上存放堆的元素,留下H[0],要么讓它空著,要么在其中放一個(gè)限位器,它的值大于堆中任何一個(gè)元素。
在 4 的表示法中:
1) 父母節(jié)點(diǎn)的鍵將會(huì)位于數(shù)組的前?n/2??n/2?個(gè)位置中,而葉子節(jié)點(diǎn)的鍵將會(huì)占據(jù)后?n/2??n/2?個(gè)位置。
2) 在數(shù)組中,對(duì)于一個(gè)位于父母位置 i 的鍵來(lái)說(shuō),它的子女將會(huì)位于2i和2i+1. 相應(yīng)地,對(duì)于一個(gè)位于i的鍵來(lái)說(shuō),它的父母將會(huì)位于?i/2??i/2?
對(duì)于上面提到的父母節(jié)點(diǎn)的鍵將會(huì)位于數(shù)組的前?n/2??n/2?個(gè)位置中,而葉子節(jié)點(diǎn)的鍵將會(huì)占據(jù)后?n/2??n/2?個(gè)位置。這一點(diǎn)我覺(jué)得很有意思,咱們不嚴(yán)格證明,僅簡(jiǎn)單分析一下為什么會(huì)這樣。
設(shè)一個(gè)堆共有N個(gè)元素。判斷一個(gè)索引為 i 的節(jié)點(diǎn)是不是父母節(jié)點(diǎn),可以看它有沒(méi)有孩子。如果它有孩子,那么2i一定小于等于N,換句話(huà)說(shuō),如果2i大于N,則可以斷定它是葉子節(jié)點(diǎn),在它位置之后的節(jié)點(diǎn)(如果有的話(huà))也一定是葉子節(jié)點(diǎn),因?yàn)閺?2i > N 可以推出 2(i+1) > N,2(i+2) > N,…
所以,只要求解不等式 2i > N, 取i的最小值,就得到第一個(gè)葉子節(jié)點(diǎn)的位置。
經(jīng)過(guò)演算,i 的最小值是 i=?N/2?+1i=?N/2?+1,所以,最后一個(gè)父母節(jié)點(diǎn)的位置是 ?n/2??n/2?
如何構(gòu)造一個(gè)堆
針對(duì)給定的一列鍵值,如何構(gòu)造一個(gè)堆呢?
方法一:自底向上堆構(gòu)造
假設(shè)要構(gòu)造一個(gè)大根堆,步驟如下:
如果該節(jié)點(diǎn)不滿(mǎn)足父母優(yōu)勢(shì),就把該節(jié)點(diǎn)的鍵 K 和它子女的最大鍵進(jìn)行交換,然后再檢查在新的位置上,K 是否滿(mǎn)足父母優(yōu)勢(shì)要求。這個(gè)過(guò)程一直繼續(xù)到對(duì) K 的父母優(yōu)勢(shì)要求滿(mǎn)足為止——這種策略叫做下濾(percolate down)。
假設(shè)有一列鍵(共10個(gè)):4,1,3,2,16,9,10,14,8,7
那么,按照上面給定的鍵值順序,對(duì)應(yīng)的完全二叉樹(shù)如下圖。
最后一個(gè)父母節(jié)點(diǎn)是5(=10/2),我們從5號(hào)節(jié)點(diǎn)開(kāi)始對(duì)這個(gè)二叉樹(shù)進(jìn)行堆化。
看完這些圖,相信你已經(jīng)知道如何構(gòu)建大根堆了。下面就用C語(yǔ)言來(lái)實(shí)現(xiàn)。
遞歸解法
根據(jù)上文的算法描述,很容易想到用遞歸來(lái)實(shí)現(xiàn)。我們先設(shè)計(jì)一個(gè)函數(shù)——下濾函數(shù)。
先寫(xiě)幾個(gè)宏。給定一個(gè)位置為 i 的節(jié)點(diǎn),很容易算出它的左右孩子的位置和父母的位置。
#define LEFT(i) (2*i) // i 的左孩子 #define RIGHT(i) (2*i+1) // i 的右孩子 #define PARENT(i) (i/2) // i 的父節(jié)點(diǎn)假定以 LEFT(t) 和 RIGHT(t) 為根的子樹(shù)都已經(jīng)是大根堆,下面的函數(shù)調(diào)整以 t 為根的子樹(shù),使之成為大根堆。
// 下濾函數(shù)(遞歸解法) // 假定以 LEFT(t) 和 RIGHT(t) 為根的子樹(shù)都已經(jīng)是大根堆 // 調(diào)整以 t 為根的子樹(shù),使之成為大根堆。 // 節(jié)點(diǎn)位置為 1~n,a[0]不使用 void percolate_down_recursive(int a[], int n, int t) { #ifdef PRINT_PROCEDUREprintf("check %d\n", t); #endifint left = LEFT(t);int right = RIGHT(t); int max = t; //假設(shè)當(dāng)前節(jié)點(diǎn)的鍵值最大if(left <= n) // 說(shuō)明t有左孩子 {max = a[left] > a[max] ? left : max;}if(right <= n) // 說(shuō)明t有右孩子 {max = a[right] > a[max] ? right : max;}if(max != t){ swap(a + max, a + t); // 交換t和它的某個(gè)孩子,即t下移一層 #ifdef PRINT_PROCEDUREprintf("%d NOT satisfied, swap it and %d \n",t, max); #endifpercolate_down_recursive(a, n, max); // 遞歸,繼續(xù)考察t} }//交換*a和*b, 內(nèi)部函數(shù) static void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp; }有了上面的函數(shù),我們就可以從最后一個(gè)父母節(jié)點(diǎn)開(kāi)始,到根為止,逐個(gè)進(jìn)行“下濾”。
非遞歸解法
以上代碼是用“交換法”(第26行)對(duì)節(jié)點(diǎn)進(jìn)行下濾。一次交換需要3條賦值語(yǔ)句,有沒(méi)有更好的寫(xiě)法呢?有,就是“空穴法”(我自己起的名字)。我們先說(shuō)明空穴法的原理,然后附上代碼。
以上圖中“檢查1號(hào)節(jié)點(diǎn),不滿(mǎn)足”這個(gè)地方開(kāi)始,對(duì)1號(hào)節(jié)點(diǎn)進(jìn)行下濾。
// 非遞歸且不用交換 void percolate_down_no_swap(int a[], int n, int t) {int key = a[t]; // 用key記錄鍵值int max_idx;int heap_ok = 0; // 初始條件是父母優(yōu)勢(shì)不滿(mǎn)足 #ifdef PRINT_PROCEDURE printf("check %d\n", t); #endif // LEFT(t) <= n 成立則說(shuō)明 t 有孩子while(!heap_ok && (LEFT(t) <= n)){ max_idx = LEFT(t); // 假設(shè)左右孩子中,左孩子鍵值較大if(LEFT(t) < n) // 條件成立則說(shuō)明有2個(gè)孩子{if(a[LEFT(t)] < a[RIGHT(t)])max_idx = RIGHT(t); //說(shuō)明右孩子的鍵值比左孩子大}//此時(shí)max_idx指向鍵值較大的孩子if(key >= a[max_idx]){heap_ok = 1; //為 key 找到了合適的位置,跳出循環(huán)}else{ a[t] = a[max_idx]; //孩子上移一層,max_idx 被空出來(lái),成為空穴 #ifdef PRINT_PROCEDURE printf("use %d fill %d \n", max_idx, t);printf("%d is empty\n", max_idx); #endif t = max_idx; //令 t 指向空穴 } }a[t] = key; // 把 key 填入空穴 #ifdef PRINT_PROCEDURE printf("use value %d fill %d \n", key, t);#endif return; }如果在編譯的時(shí)候定義宏P(guān)RINT_PROCEDURE,則可以看到堆化過(guò)程和上文的六張圖相符。假設(shè)源文件名是 max_heap.c,在編譯的時(shí)候用-D宏名稱(chēng)可以定義宏。
gcc max_heap.c -DPRINT_PROCEDURE方法二:自頂向下堆構(gòu)造
除了上面的算法,還有一種算法(效率較低)是通過(guò)把新的鍵連續(xù)插入預(yù)先構(gòu)造好的堆,來(lái)構(gòu)造一個(gè)新堆。有的人把它稱(chēng)作自頂向下堆構(gòu)造。
這種策略叫做上濾(percolate up)。
依然以4,1,3,2,16,9,10,14,8,7這列鍵為例,用圖說(shuō)明上濾的過(guò)程。
細(xì)心的讀者應(yīng)該已經(jīng)看出來(lái)了:下濾法構(gòu)造的堆,其對(duì)應(yīng)的數(shù)組是
[16,14,10,8,7,9,3,2,4,1]而上濾法構(gòu)造的堆,其數(shù)組是
[16,14,10,8,7,3,9,1,4,2]所以得出結(jié)論:對(duì)于同一列鍵,用下濾法和上濾法構(gòu)造出來(lái)的堆,不一定完全相同。
囿于篇幅,“堆”就說(shuō)到這里,上濾法的代碼,咱們下次說(shuō)。
參考資料
https://blog.csdn.net/guoweimelon/article/details/50904346
總結(jié)
- 上一篇: xpath安装与下载
- 下一篇: by截取字段 group_深入理解 gr