堆與堆排序(一)
堆與堆排序(一)
上一篇博文 淺談優先佇列 介紹了什麼是優先佇列,文末提到了一種資料結構——“堆”,基於“堆”實現的優先佇列,出隊和入隊的時間複雜度都為 O(logN)
.
這篇博文我們就走進“堆”,看看它到底是什麼結構。
此堆非彼堆
值得注意的是,這裡的“堆”不是記憶體管理中提到的“堆疊”的“堆”。前者的“堆”——準確地說是二叉堆,是一種類似於完全二叉樹的資料結構;後者的“堆”是一種類似於連結串列的資料結構。
堆的結構性質
二叉堆在邏輯結構上是一棵完全二叉樹。什麼是完全二叉樹呢?即樹的每一層都是滿的,除了最後一層最右邊的元素有可能缺位。
如下圖所示,打錯號的兩個不是完全二叉樹,其他都是。
對於一個有 N 個節點的完全二叉樹,我們可以為它的每個節點指定一個索引,方法是從上至下,從左到右,從1開始連續編號,如下圖黑色數字所示。瞭解二叉樹的朋友一定看出來了,這就是二叉樹的層序遍歷。
可以看出,對於一個有 N 個節點的完全二叉樹,索引值和元素是一一對應的。所以完全二叉樹可以用一個陣列來表示而不需要指標:索引值就是陣列的下標,元素的值就是節點的關鍵字。
如下圖,是一個完全二叉樹和陣列的相互關係。
如果你繼續觀察,就會發現另一個規律:對於陣列任一位置 i
上的元素,其左兒子在位置 2i
上,右兒子在2i+1
上,它的父親則在位置
以節點 D 為例,D 的下標是 4.
B
是它的父節點,B
的下標是2(=4/2),如圖中黑色的線;H
是它的左孩子,H
的下標是8(=4*2),如圖中藍色的線;I
是它的右孩子,I
的下標是9(=4*2+1),如圖中紅色的線;
堆序性質
二叉堆一般分為兩種:最大堆和最小堆。
最大堆:也叫做大根堆。每一個節點的值(或者說關鍵字)都要大於或等於它孩子的值(對於任何葉子我們認為這個條件都是自動滿足的)。下圖就是一個最大堆。
最小堆:也叫做小根堆。每一個節點的值(或者說關鍵字)都要小於或等於它孩子的值(對於任何葉子我們認為這個條件都是自動滿足的)。下圖就是一個最小堆。
值得注意的是:以大根堆為例,在任何從根到某個葉子的路徑上,鍵值的序列是遞減的(如果允許相等的鍵存在,則是非遞增的)。然而,鍵值之間並不存在從左到右的次序。也就是說,在樹的同一層節點之間,不存在任何關係,更一般地來說,在同一節點的左右子樹之間也沒有任何關係。
堆的重要特性
以大根堆為例,把堆的重要特性總結如下。
只存在一棵 n 個節點的完全二叉樹。它的高度等於
\lfloor \log _2 n \rfloor堆的根總是包含了堆的最大元素
堆的一個節點以及該節點的子孫也是一個堆
可以用陣列來實現堆,方法是用從上到下、從左到右的方式來記錄堆的元素。為了方便起見,可以在這種陣列從 1 到 n 的位置上存放堆的元素,留下
H[0]
,要麼讓它空著,要麼在其中放一個限位器,它的值大於堆中任何一個元素。在 4 的表示法中:
1) 父母節點的鍵將會位於陣列的前
\lfloor n/2 \rfloor個位置中,而葉子節點的鍵將會佔據後\lceil n/2 \rceil個位置。2) 在陣列中,對於一個位於父母位置
i
的鍵來說,它的子女將會位於2i
和2i+1
. 相應地,對於一個位於i
的鍵來說,它的父母將會位於\lfloor i/2 \rfloor
對於上面提到的父母節點的鍵將會位於陣列的前
設一個堆共有N個元素。判斷一個索引為 i
的節點是不是父母節點,可以看它有沒有孩子。如果它有孩子,那麼2i
一定小於等於N
,換句話說,如果2i
大於N
,則可以斷定它是葉子節點,在它位置之後的節點(如果有的話)也一定是葉子節點,因為從 2i > N
可以推出 2(i+1) > N
,2(i+2) > N,
…
所以,只要求解不等式 2i > N
, 取i
的最小值,就得到第一個葉子節點的位置。
經過演算,i
的最小值是
如何構造一個堆
針對給定的一列鍵值,如何構造一個堆呢?
方法一:自底向上堆構造
假設要構造一個大根堆,步驟如下:
- 在初始化一棵包含 n 個節點的完全二叉樹時,按照給定的順序來放置鍵;
- 按照下面的方法,對樹進行“堆化”
- 從最後一個父母節點開始,到根為止,該演算法檢查這些節點的鍵是否滿足父母優勢的要求。如果該節點不滿足,就把該節點的鍵 K 和它子女的最大鍵進行交換,然後再檢查在新的位置上,K 是否滿足父母優勢要求。這個過程一直繼續到對 K 的父母優勢要求滿足為止(最終它必須滿足,因為對每個葉子中的鍵來說,這條件是自動滿足的)。
- 對於以當前父母節點為根的子樹,在完成它的“堆化”以後,對該節點的直接前趨(陣列中此節點的前一個節點)進行同樣的操作。在對樹的根完成這種操作以後,該演算法就停止了。
如果該節點不滿足父母優勢,就把該節點的鍵 K 和它子女的最大鍵進行交換,然後再檢查在新的位置上,K 是否滿足父母優勢要求。這個過程一直繼續到對 K 的父母優勢要求滿足為止——這種策略叫做下濾(percolate down)。
假設有一列鍵(共10個):4,1,3,2,16,9,10,14,8,7
那麼,按照上面給定的鍵值順序,對應的完全二叉樹如下圖。
最後一個父母節點是5(=10/2),我們從5號節點開始對這個二叉樹進行堆化。
看完這些圖,相信你已經知道如何構建大根堆了。下面就用C語言來實現。
遞迴解法
根據上文的演算法描述,很容易想到用遞迴來實現。我們先設計一個函式——下濾函式。
先寫幾個巨集。給定一個位置為 i
的節點,很容易算出它的左右孩子的位置和父母的位置。
#define LEFT(i) (2*i) // i 的左孩子
#define RIGHT(i) (2*i+1) // i 的右孩子
#define PARENT(i) (i/2) // i 的父節點
假定以 LEFT(t)
和 RIGHT(t)
為根的子樹都已經是大根堆,下面的函式調整以 t
為根的子樹,使之成為大根堆。
// 下濾函式(遞迴解法)
// 假定以 LEFT(t) 和 RIGHT(t) 為根的子樹都已經是大根堆
// 調整以 t 為根的子樹,使之成為大根堆。
// 節點位置為 1~n,a[0]不使用
void percolate_down_recursive(int a[], int n, int t)
{
#ifdef PRINT_PROCEDURE
printf("check %d\n", t);
#endif
int left = LEFT(t);
int right = RIGHT(t);
int max = t; //假設當前節點的鍵值最大
if(left <= n) // 說明t有左孩子
{
max = a[left] > a[max] ? left : max;
}
if(right <= n) // 說明t有右孩子
{
max = a[right] > a[max] ? right : max;
}
if(max != t)
{
swap(a + max, a + t); // 交換t和它的某個孩子,即t下移一層
#ifdef PRINT_PROCEDURE
printf("%d NOT satisfied, swap it and %d \n",t, max);
#endif
percolate_down_recursive(a, n, max); // 遞迴,繼續考察t
}
}
//交換*a和*b, 內部函式
static void swap(int* a, int* b)
{
int temp = *a;
*a = *b;
*b = temp;
}
有了上面的函式,我們就可以從最後一個父母節點開始,到根為止,逐個進行“下濾”。
非遞迴解法
以上程式碼是用“交換法”(第26行)對節點進行下濾。一次交換需要3條賦值語句,有沒有更好的寫法呢?有,就是“空穴法”(我自己起的名字)。我們先說明空穴法的原理,然後附上程式碼。
以上圖中“檢查1號節點,不滿足”這個地方開始,對1號節點進行下濾。
// 非遞迴且不用交換
void percolate_down_no_swap(int a[], int n, int t)
{
int key = a[t]; // 用key記錄鍵值
int max_idx;
int heap_ok = 0; // 初始條件是父母優勢不滿足
#ifdef PRINT_PROCEDURE
printf("check %d\n", t);
#endif
// LEFT(t) <= n 成立則說明 t 有孩子
while(!heap_ok && (LEFT(t) <= n))
{
max_idx = LEFT(t); // 假設左右孩子中,左孩子鍵值較大
if(LEFT(t) < n) // 條件成立則說明有2個孩子
{
if(a[LEFT(t)] < a[RIGHT(t)])
max_idx = RIGHT(t); //說明右孩子的鍵值比左孩子大
}//此時max_idx指向鍵值較大的孩子
if(key >= a[max_idx])
{
heap_ok = 1; //為 key 找到了合適的位置,跳出迴圈
}
else
{
a[t] = a[max_idx]; //孩子上移一層,max_idx 被空出來,成為空穴
#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;
}
如果在編譯的時候定義巨集PRINT_PROCEDURE
,則可以看到堆化過程和上文的六張圖相符。假設原始檔名是 max_heap.c,在編譯的時候用-D巨集名稱
可以定義巨集。
gcc max_heap.c -DPRINT_PROCEDURE
方法二:自頂向下堆構造
除了上面的演算法,還有一種演算法(效率較低)是通過把新的鍵連續插入預先構造好的堆,來構造一個新堆。有的人把它稱作自頂向下堆構造。
- 首先,把一個鍵值為 K 的新節點附加在當前堆的最後一個葉子後面;
- 然後,拿 K 和它父母的鍵做比較。如果 K 小於等於它的父母,那麼演算法停止;否則交換這兩個鍵,並把 K 和它的新父母做比較。
- 重複2,一直持續到 K 不大於它的父母,或者 K 成為樹根為止。
這種策略叫做上濾(percolate up)。
依然以4,1,3,2,16,9,10,14,8,7
這列鍵為例,用圖說明上濾的過程。
細心的讀者應該已經看出來了:下濾法構造的堆,其對應的陣列是
[16,14,10,8,7,9,3,2,4,1]
而上濾法構造的堆,其陣列是
[16,14,10,8,7,3,9,1,4,2]
所以得出結論:對於同一列鍵,用下濾法和上濾法構造出來的堆,不一定完全相同。
囿於篇幅,“堆”就說到這裡,上濾法的程式碼,我們們下次說。
參考資料
相關文章
- 看懂堆排序——堆與堆排序(三)排序
- 線性建堆法與堆排序排序
- 與堆和堆排序相關的問題排序
- PHP 實現堆, 堆排序以及索引堆PHP排序索引
- 大根堆和堆排序的原理與實現排序
- 二叉堆及堆排序排序
- 堆的基本操作及堆排序排序
- 《演算法筆記》4. 堆與堆排序、比較器詳解演算法筆記排序
- 堆、堆排序和優先佇列的那些事排序佇列
- 高階資料結構---堆樹和堆排序資料結構排序
- 第三章:查詢與排序(下)----------- 3.16堆的概念及堆排序思路排序
- PHP面試:說下什麼是堆和堆排序?PHP面試排序
- 資料結構之堆 → 不要侷限於堆排序資料結構排序
- 堆排序排序
- 資料結構與演算法:堆排序資料結構演算法排序
- 【資料結構與演算法】堆排序資料結構演算法排序
- 資料結構與演算法——堆排序資料結構演算法排序
- The Stack and the Heap棧與堆__RustRust
- 淺析堆與垃圾回收
- 堆與優先佇列佇列
- 二叉樹與堆二叉樹
- 堆的原理與實現
- python 堆排序Python排序
- js堆排序JS排序
- 淺談堆-Heap(一)
- 演算法與資料結構之原地堆排序演算法資料結構排序
- 堆排序 Heap Sort排序
- 堆排序詳解排序
- 堆排序(C++)排序C++
- Java堆記憶體Heap與非堆記憶體Non-HeapJava記憶體
- Python實現堆疊與佇列Python佇列
- JVM 堆的定義與詳解JVM
- 二叉堆、BST 與平衡樹
- 一文探討堆外記憶體的監控與回收記憶體
- js 實現堆排序JS排序
- 資料結構與演算法-堆資料結構演算法
- 堆和堆傻傻分不清?一文告訴你 Java 集合中「堆」的最佳開啟方式Java
- 五分鐘看懂一個高難度的排序:堆排序排序