本文首發於我的公眾號 Linux雲計算網絡(id: cloud_dev) ,專注於干貨分享,號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,歡迎大家關注,二維碼文末可以掃。
一、概述
此處所說的堆為數據結構中的堆,而非內存分區中的堆。堆通常可以被看做是樹結構,滿足兩個性質:1)堆中任意節點的值總是不大於(不小於)其子節點的值;2)堆是一棵完全樹。正是由於這樣的性質,堆又被稱為優先隊列。根據性質一,將任意節點不大於其子節點的堆稱為最小堆或最小優先隊列,反之稱為最大堆或最大優先隊列。優先隊列在操作系統作業調度的設計中有着舉足輕重的作用。之前寫了一篇優先隊列的文章,詳見算法導論第六章優先隊列。
常見的堆結構,有二叉堆、左傾堆、斜堆、二項堆、斐波那契堆等。斐波那契堆在前文算法導論第十九章 斐波那契堆已經作了講述。本文主要對其余幾種堆結構做宏觀上的概述,並說明它們的異同點。
二、二叉堆
二叉堆是最簡單的堆結構,本質是一棵完全的二叉樹,一般由數組實現,其父節點和左右子節點之間存在着這樣的關系: 索引為i(i從0開始)的左孩子的索引是 (2*i+1); 右孩子的索引是 (2*i+2); 父結點的索引是 floor((i-1)/2)。詳細請看我之前寫的一篇文章:算法導論第六章堆排序(一)。眾所周知,二叉堆在排序算法中應用甚廣,特別是涉及到大數據的處理中,如Topk算法。
三、二項堆
某些應用中需要用到合並兩個堆的操作,這個時候二叉堆的性能就不是很好,最壞情況下時間復雜度為O(n)。為了改善這種操作,算法研究者就提出了性能更好的可合並堆,如二項堆和斐波那契堆,當然還有左傾堆,斜堆等。二項堆對於該操作能在最壞情況Θ(lgn)下完成,而斐波那契堆則更進一步,能在平攤時間為Θ(1)下完成(注意是平攤時間),見下表。除了Union操作,二叉堆各操作的性能都挺好,而二項堆對此也僅僅是改善了Union操作,對於Minimum操作甚至還不如二叉堆,我想這大概是《算法導論》第三版沒有把二項堆再作為一章的原因之一吧。而斐波那契堆各個操作都有極好的性能,除了Extract_min和Delete操作。
說回到二項堆,二項堆是由一組的二項樹組成,二項樹B_k是一種遞歸定義的有序樹,其具有以下的性質:
1)共有2^k個節點;
2)樹的高度為k;
3)在深度 i 處恰有C^i_k個節點,因為C^i_k為二項系數,正因如此,才稱其為二項樹;
4)根的度數為k(即孩子節點個數),它大於任何其他節點的度數。
二項樹B_0只包含一個節點,二項樹B_k由兩棵二項樹B_k-1連接而成,其中一棵樹的根是另一棵樹的根的最左孩子。如下圖:
二項堆H由一組滿足下面二項堆性質的二項樹組成:
1)H中的每個二項樹滿足最小堆性質(說明二叉堆中最小節點在二項樹的根中);
2)對任意非負整數k,H中至多有一棵二項樹的根具有度數k(說明在包含n個節點的二項堆中,至多有floor(lgn)+1棵二項樹)。
不同於斐波那契堆采用雙循環鏈表來連接根節點和孩子節點,二項堆中采用的是單鏈表,每個節點有指向父節點的指針,孩子節點指針和兄弟節點指針,如:
1 struct BinomialHeapNode { 2 BinomialHeapNode *parent; 3 BinomialHeapNode *child; 4 BinomialHeapNode *sibling; 5 6 unsigned int degree; 7 KeyType key; 8 };
下面列上自己寫的動態集合操作的代碼:

#ifndef _BINOMIAL_HEAP_ #define _BINOMIAL_HEAP_ //#define NOT_IN_HEAP UINT_MAX // the node of binomial tree // struct heap_node { // struct heap_node* parent; // struct heap_node* child; // struct heap_node* sibling; // // unsigned int degree; // int key; // const void* value; // }; template<typename KeyType> class BinomialHeap { public: struct BinomialHeapNode { BinomialHeapNode *parent; BinomialHeapNode *child; BinomialHeapNode *sibling; unsigned int degree; KeyType key; }; public: BinomialHeap() { _head_list = NULL; } ~BinomialHeap() { BinomialHeapNode *tmp = _head_list; while (tmp) { BinomialHeapNode *next = tmp->sibling; _DeleteTree(tmp); tmp = next; } } public: //在二項堆中插入一個新節點 void BinomialInsert(KeyType new_key) { BinomialHeap new_heap; new_heap._head_list = new BinomialHeapNode(); new_heap._head_list->parent = new_heap._head_list->child = new_heap._head_list->sibling = NULL; new_heap._head_list->degree = 0; new_heap._head_list->key = new_key; this->BinomialUnion(new_heap); } //獲取二項堆中最小元素的值 //用一個列表存根鏈表的值,然后找最小值,O(lgn),或者用一個指針指向最小值,O(1) KeyType Minimum() const { vector<KeyType> values_in_head_list; BinomialHeapNode *node = _head_list; while (node != NULL) { values_in_head_list.push_back(node->key); node = node->sibling; } return *min_element(values_in_head_list.begin(), values_in_head_list.end()); } bool CompVector( BinomialHeapNode *left, BinomialHeapNode *right ) { return left->key < right->key; } //彈出二項堆中的最小元素,並獲取該最小元素的值 KeyType ExtractMinimum() { vector<BinomialHeapNode *> head_nodes; BinomialHeapNode *hl = _head_list; while ( hl ) { head_nodes.push_back( hl ); hl = hl->sibling; } / // auto min_ele = min_element(head_nodes.begin(), head_nodes.end(), [](BinomialHeapNode *left, BinomialHeapNode *right) // { // return left->key < right->key; // }); vector<BinomialHeapNode *>::iterator min_ele = min_element(head_nodes.begin(), head_nodes.end()/*, CompVector*/); int min_index = min_ele - head_nodes.begin(); KeyType min_value = ( *min_ele )->key; BinomialHeapNode *min_node = head_nodes[min_index]; //根鏈表上去掉最小結點,更新兄弟節點的值,注意頭尾的處理 if ( min_index == 0 ) { if ( head_nodes.size() > 1 ) _head_list = head_nodes[1]; else _head_list = NULL; //根鏈表上只有一個元素 } else if ( min_index == head_nodes.size() - 1 ) head_nodes[min_index - 1]->sibling = NULL; else head_nodes[min_index - 1]->sibling = head_nodes[min_index + 1]; //處理最小結點的孩子節點 BinomialHeap new_head; new_head._head_list = min_node->child; BinomialHeapNode *x = new_head._head_list; //更新所有孩子節點上的兄弟節點 while ( x ) { x->parent = NULL; x = x->sibling; } //把獨立出來的節點合並到原鏈表上 this->BinomialUnion( new_head ); delete min_node; min_node = NULL; return min_value; } //減小一個節點的值到某個值 void Decrease( BinomialHeapNode *x, KeyType k ) { if ( k > x->key ) throw exception("只能減少不能增大"); x->key = k; BinomialHeapNode *y = x; BinomialHeapNode *z = y->parent; while ( z && y->key < z->key ) { swap(y->key, z->key); y = z; z = y->parent; } } //刪除一個結點 void Delete( BinomialHeapNode *node ) { if ( node ) { //將要刪除的結點減小到最小值,然后在刪除 Decrease( node, numeric_limits<KeyType>::min() ); ExtractMinimum(); } } //查找一個值為key的結點 //所有的堆堆查找操作的支持都很差,時間復雜度為O(n) BinomialHeapNode* Search( KeyType key ) const { BinomialHeapNode *tree = _head_list; //遍歷根鏈 while ( tree ) { BinomialHeapNode *node = _SearchInTree( tree, key ); if ( node ) return node; tree = tree->sibling; } return NULL; } //聯合另外一個二項堆,當聯合操作完成后,other的二項堆中的數據將無效 void BinomialUnion( BinomialHeap &other ) { vector<BinomialHeapNode *> nodes; BinomialHeapNode *l = _head_list; BinomialHeapNode *r = other._head_list; while ( l ) { nodes.push_back( l ); l = l->sibling; } while ( r ) { nodes.push_back( r ); r = r->sibling; } if ( nodes.empty() ) return; //排序並合並兩個二項堆 sort( nodes.begin(), nodes.end()); for ( size_t i = 0; i < nodes.size() - 1; ++ i ) nodes[i]->sibling = nodes[i + 1]; nodes[nodes.size()-1]->sibling = NULL; //重置根鏈表 this->_head_list = nodes[0]; //銷毀待合並的鏈表 other._head_list = NULL; if ( this->_head_list == NULL ) return; //將具有相同度的二項樹進行合並 BinomialHeapNode *prev_x = NULL; BinomialHeapNode *cur_x = _head_list; BinomialHeapNode *next_x = cur_x->sibling; while ( next_x ) { if ( cur_x->degree != next_x->degree || ( next_x->sibling != NULL && next_x->sibling->degree == cur_x->degree ) ) { prev_x = cur_x; cur_x = next_x; } else if ( cur_x->key < next_x->key ) { cur_x->sibling = next_x->sibling; _Link( next_x, cur_x ); } else { if ( prev_x == NULL ) _head_list = next_x; else prev_x->sibling = next_x; _Link( cur_x, next_x ); cur_x = next_x; } next_x = cur_x->sibling; } } //判斷二項堆當前狀態是否為空 bool IsEmpty() const { return _head_list == NULL; } //得到二項堆的根鏈表 BinomialHeapNode *GetHeadList() { return _head_list; } //顯示二項堆 void Display() const { //stringstream ss; cout << "binomial_heap:" << "{" << endl; BinomialHeapNode *node = _head_list; if ( node ) cout << "rootlist->" << node->key << ";" << endl; while ( node ) { _DisplayTree( node ); if ( node->sibling ) cout << " " << node->key << "->" << node->sibling->key << ";" << endl; node = node->sibling; } cout << "}" << endl; } private: //刪除一棵二項樹 void _DeleteTree(BinomialHeapNode *tree) { if (tree && tree->child) { BinomialHeapNode *node = tree->child; while (node) { BinomialHeapNode *next = node->sibling; _DeleteTree(next); node = next; } delete tree; tree = NULL; } } //將D(k-1)度的y結點連接到D(k-1)度的z結點上去,使得z成為一個D(k)度的結點 void _Link(BinomialHeapNode *y, BinomialHeapNode *z) { y->parent = z; y->sibling = z->child; z->child = y; ++(z->degree); } //在二項樹中搜索某個結點 BinomialHeapNode * _SearchInTree( BinomialHeapNode *tree, KeyType key ) const { if ( tree->key == key ) return tree; BinomialHeapNode *node = tree->child; while( node ) { BinomialHeapNode *n = _SearchInTree( node, key ); if ( n ) return n; node = node->sibling; } return NULL; } //畫一棵二項樹 void _DisplayTree( BinomialHeapNode *tree ) const { if ( tree ) { BinomialHeapNode *child = tree->child; if ( child ) { vector<BinomialHeapNode *> childs; while ( child ) { childs.push_back( child ); child = child->sibling; } // for_each( childs.begin(), childs.end(), [&]( BinomialHeapNode *c ){ // ss << " " << c->key << "->" << tree->key << ";" << endl; // _DisplayTree( ss, c ) // } ); for ( vector<BinomialHeapNode *>::iterator it = childs.begin(); it != childs.end(); ++ it ) { cout << " " << (*it)->key << "->" << tree->key << ";" << endl; _DisplayTree( *it ); } } } } private: BinomialHeapNode *_head_list; //根鏈表 }; #endif//_BINOMIAL_HEAP_
四、左傾堆
左傾堆(leftist tree 或 leftist heap),又稱為左偏樹。這種結構《算法導論》上沒有提及,大概是因為太簡單了吧。因為其本質是一棵二叉樹,不像二項堆和斐波那契堆一樣,具有復雜的結構。我想歷史的演進過程大概是先提出左傾堆以及接下來要說的斜堆,然后才提出的二項堆和斐波那契堆這種復雜的結構,由易到難嘛,又或者同時,相反,anyway。
二叉堆是非常平衡的樹結構,左傾堆,從名字上來看,這種堆結構應該是整體往左傾,不平衡,那是什么導致它往左傾,孩子節點個數?不可能,比如下面這棵樹:
如果只從孩子節點個數來評判它往哪傾,則從整體上看,根節點的右孩子節點要多於左孩子節點,但是這棵樹是左傾堆。那到底通過什么來評判一棵樹為左傾堆?要引入一個屬性:零距離(英文名NPL,Null Path Length)——表示的是從一個節點到一個“最近的不滿節點”的路徑長度(不滿節點:兩個子節點至少有一個為NULL)。一個葉節點的NPL為0,一個NULL節點的NPL為-1。因此,左傾的意思主要是看每個節點的NPL是否滿足左孩子的NPL >= 右孩子的NPL,這也是左傾堆的性質一。當然啦,左傾堆既然叫堆,自然滿足堆的性質:即每個節點的優先級大於子節點的優先級,這是性質二。還有性質三就是:節點的NPL = 它的右孩子的NPL + 1。如上圖:每個節點旁邊的標號即為它們的NPL值。
如前所述,這些堆結構的提出,主要是解決簡單二叉堆中Union操作的低性能的。左傾堆的Union操作相對上述兩種比較簡單,基本思想如下:
1)如果一個空左傾堆與一個非空左傾堆合並,返回非空左傾堆;
2)如果兩個左傾堆都非空,那么比較兩個根節點,取較小堆的根節點為新的根節點。然后將“較小堆的根節點的右孩子”和“較大堆”進行合並;
3)如果新堆的右孩子的NPL > 左孩子的NPL,則交換左右孩子;
4)設置新堆的根節點的NPL = 右子堆NPL + 1。
上面的Union算法遞歸調用了自身,由於我們沿着右側路徑遞歸,所以時間復雜度為lgn量級。至於其他操作,比如insert,delete_min都可以在Union的基礎上實現,如insert:將新增節點與一個已有的左傾堆做Union;delete_min刪除根節點,將剩余的左右子堆合並。具體的就不再詳述,如有疑問,可以參考這篇圖文並茂的博客:左傾堆(一)之 圖文解析 。(站在別人的肩膀上學習,避免重復造輪子^-^)
五、斜堆
斜堆(skew heap),又稱自適應堆,它是左傾堆的一個變種。相比於左傾堆,斜堆的節點沒有“NPL”這個屬性,合並操作也相對簡單了,但同樣能實現lgn的量級。具體算法過程的前兩步和左傾堆是一樣的,只是第三步不像左傾堆要比較左右孩子的NPL大小才交換,而是合並后就直接交換。
六、總結
最常用的堆結構還是要屬二叉堆,前面也提到過,如果沒有Union操作,二叉堆的性能還是令人滿意的。對於一些復雜的問題場景,則相應需要用到復雜的數據結構,此時斐波那契堆是最佳選擇,如求最小生成樹問題和求單源點最短路徑問題的實現,如果基於斐波那契堆,則能得到非常好的性能。但這只是從理論上來說,《算法導論》上也說了,如果從實際應用角度來看,除了某些需要管理大量數據的應用外,對於大多數應用,斐波那契堆的常數因子和編程復雜性使得它比起普通二叉堆並不那么適用。因此,斐波那契堆的研究主要出於理論興趣。這個時候,如果權衡一下,那就只有左傾堆和斜堆這等堆結構更適用了。是這樣嗎?不禁陷入了深深的思考中......
我的公眾號 「Linux雲計算網絡」(id: cloud_dev),號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,分享的內容包括但不限於 Linux、網絡、雲計算虛擬化、容器Docker、OpenStack、Kubernetes、工具、SDN、OVS、DPDK、Go、Python、C/C++編程技術等內容,歡迎大家關注。