1. 堆的概念
堆又名優先隊列,是一種特殊的隊列結構(儘管實現可能和隊列毫無相似之處)。它的特點如下:
- 入隊: 和正常隊列一樣把元素插入到數據結構中
- 出隊: 將最小/大的元素出隊
根據出隊元素是最大的還是最小的元素又可以把堆分類爲大頂堆和小頂堆兩類。
堆的應用很廣泛,最典型的例子就是帶有優先級的排隊,例如對中斷的響應問題:優先響應高優先級的中斷,同等優先級的按照順序排隊等。
堆的實現方式有很多,比如可以就用一個列表,然後每次插入的時候按照插入排序的方式來進行,不過這樣做很浪費時間,這裏我們介紹一種最常見的實現方式——二叉堆。
2. 二叉堆
2.1 概念
二叉堆使用一顆完全二叉樹來實現的堆,既然是完全二叉樹,那就可以使用一個數組來實現。這裏我們從下標 1 開始按照二叉樹的結構來填充數組,顯然對編號爲 x 的結點而言:
- 它的子結點爲 和
- 它的父節點爲
二叉堆有一個重要的性質,在一顆小頂堆中,任意一個結點的值都小於等於其左右子結點的值。若是在大頂堆中則反之。
有了這個性質,就最小/大元素總可以在根節點的位置取到。
以下是一顆二叉堆的 ADT:
template<typename Cmp = less<int>>
class Heap
{
public:
explicit Heap(int capacity = 100);
void Insert(int val);
bool Empty();
int Top();
void Pop();
private:
void AdjustTop(int root);
void CheckIfFull();
int size;
vector<int> vec;
Cmp lessThan;
};
其中利用 Cmp
可以指定比較器,從而來決定是大頂堆還是小頂堆。Insert
用於入隊,Pop
可以做出隊操作,Top
則是獲取隊首元素,Empty
用於判斷是否爲空。
由於是用 vector
來實現,所以額外增加一個 CheckIfFull
用於擴容。
除了出入隊操作外,其他幾個都很簡單,實現如下:
explicit Heap(int capacity = 100)
{
size = 0;
vec.reserve(capacity);
}
bool Empty()
{
return size == 0;
}
int Top()
{
if (Empty())
throw 0;
return vec[1];
}
void CheckIfFull()
{
if (size + 1 == vec.size())
vec.resize(vec.size() * 2);
}
2.2 入隊操作
插入操作需要小心地維護原本堆的結構,從而保證始終滿足堆的性質。一般而言我們的做法如下:
將新的 value 插入到二叉樹最末尾的新結點(空穴)中
Loop
比較 value 和父節點的值
if 滿足堆的性質
插入完成, break loop
else
交換當前結點和父節點的位置
goto Loop
整個流程如下:
這個過程聽起來很複雜,其實很簡單,因爲仔細一想,這個過程和插入排序的邏輯十分類似,其實都是沿着一個方向有序地插入元素。只不過插入排序裏是從前往後,堆的插入裏是從葉子節點往根節點方向。
下面是它的實現代碼,仔細觀察會發現和插入排序的實現很類似。
/**
* 在堆中插入一個元素,算法有點像插入排序,從末尾出發,找到合適的位置
* 首先在末尾的空位處插入這個元素,然後不斷同父節點比較
* 如果不滿足堆的性質就把父子結點交換,直到滿足爲止
* 這樣,新的結點就會不斷上浮到最佳的位置
*/
void Insert(int val)
{
//首先檢查以下空間夠不夠,非算法部分
CheckIfFull();
int last;
//從末尾的空穴結點開始,類似插入排序的操作,只不過是在二叉樹上進行
for (last = size + 1; last > 1 && lessThan(val, vec[last / 2]); last /= 2)
vec[last] = vec[last / 2];
//找到了合適的位置,插入
vec[last] = val;
++size;
}
2.3 出隊操作
出隊的操作比較麻煩了,我們需要在刪除頂部元素,卻又要保證堆的結構合理。這可以看作是這麼一個過程:
刪除根部結點 -> 找到一個新的根節點滿足堆的性質
一個最直白的思路是:從根節點的兩個子結點(也可能只有一個)中選一個更小的作爲新的根節點。
這是一個好的想法,但存在着一個問題。試想,如果直接把子結點往上移動變成新的根節點,那麼原來的子結點的位置就空下來了,所以我們需要遞歸的對每一個子結點做一個 Pop
操作。
當我們一路向下,不斷地把子結點往上提升爲父節點時,最終會來到葉子節點。葉子節點是沒有子結點,這就意味着必然有葉子結點會變成空結點——而這很有可能會破壞完全二叉樹的結構!
所以上面的思路雖然簡單但存在很大的問題,問題的根源在於這種算法無法明確最終哪個位置的結點會被刪除。而要滿足完全二叉樹的性質,就必須保證每次都刪除最末尾的那個葉子節點。
明確這點,我們知道該怎麼做了,很簡單:
- 刪除根節點
- 把最末尾的葉子節點移動到根節點的位置作爲新的根
- 新的二叉樹不一定滿足二叉堆的性質,需要對二叉樹進行調整(AdjustHeap)
- 從子結點中選一個更小的和當前父節點交換,然後遞歸地調整子結點
- 當前節點滿足堆的性質時,算法結束
Pop
的代碼實現如下:
void Pop()
{
if (Empty())
throw 0;
//末尾結點替代根節點
vec[1] = vec[size--];
//調整二叉樹
Adjust(1);
}
Adjust
實現了調整以 root
爲根的子樹的功能。要求只有 root
的位置不滿足堆的性質,其他都滿足。算法的實現依舊很像插入排序的邏輯,只不過這次的是從上往下了。
/**
* 調整以 root 爲根節點的子樹,其中 root 不滿足堆的性質,需要調整
* 這個操作依然和插入排序很相似
*/
void Adjust(int root)
{
//取樹根結點的值方便比較
int rootVal = vec[root];
//從上到下把根節點下沉
//循環條件是: root * 2 <= size,即有子樹
//當前根節點調整完畢後,root = child,繼續往下調整子樹
for (int child; root * 2 <= size; root = child)
{
//左孩子
child = root * 2;
//如果有兩個子結點,就選它們當中比較小的那一個
if (child != size && lessThan(vec[child + 1], vec[child]))
child = child + 1;
//不滿足堆的性質,就用子結點替代父節點
if (lessThan(vec[child], rootVal))
vec[root] = vec[child];
else
break;
}
//找到了合適的位置
vec[root] = rootVal;
}
2.4 測試
測試小頂堆
Heap<> h;
//priority_queue 使用 less 是大頂堆,使用 greater 是小頂堆
priority_queue<int, vector<int>, greater<int>> q;
//priority_queue<int, vector<int>> q;
//插入數據
int n;
while (cin >> n)
{
if (n == 0)
break;
h.Insert(n);
q.push(n);
}
cout << "優先隊列:\n";
while (!q.empty())
{
n = q.top(); q.pop();
cout << n << " ";
}
cout << endl;
cout << "堆:\n";
while (!h.Empty())
{
n = h.Top(); h.Pop();
cout << n << " ";
}
運行:
2 9 8 7 -12 2 3 -4 2 1 0
優先隊列:
-12 -4 1 2 2 2 3 7 8 9
堆:
-12 -4 1 2 2 2 3 7 8 9
測試大頂堆
Heap<greater<int>> h;
//priority_queue 使用 less 是大頂堆,使用 greater 是小頂堆
// priority_queue<int, vector<int>, greater<int>> q;
priority_queue<int, vector<int>, less<int>> q;
//插入數據
int n;
while (cin >> n)
{
if (n == 0)
break;
h.Insert(n);
q.push(n);
}
cout << "優先隊列:\n";
while (!q.empty())
{
n = q.top(); q.pop();
cout << n << " ";
}
cout << endl;
cout << "堆:\n";
while (!h.Empty())
{
n = h.Top(); h.Pop();
cout << n << " ";
}
2 9 8 7 -12 2 3 -4 2 1 0
優先隊列:
9 8 7 3 2 2 2 1 -4 -12
堆:
9 8 7 3 2 2 2 1 -4 -12