注:這裏的堆仍是以小根堆爲例。算法
咱們想要設計一種堆能像二叉堆那樣高效地支持合併操做,也就是$O\left( n \right)$時間處理一次Merge,並且只使用一個數組看起來很困難對吧,畢竟合併操做須要把一個數組複製到另一個數組中去,對於相同大小的堆這會花費$\theta \left( n \right)$。也正因如此,咱們能夠看到,在此前全部支持高效合併的高級數據結構都須要用到指針。可是!在實踐中就有一些問題,它會使操做效率變低,由於處理指針通常而言比乘除法操做更耗費時間。數組
那該怎麼作呢?二叉堆進化——左式堆,又叫左偏樹。像二叉堆那樣,左式堆也具備結構特性和有序性,有着和二叉堆相同的heap property。他和二叉堆的惟一區別是:左式堆不是理想平衡的,而是趨向於極其不平衡,在拓撲形態上更傾向於向左傾斜的。那麼,爲何要引入這一新的變種呢?讓咱們從它的設計動機以及結構定義提及。前面說了,咱們的目的是完成高效合併,現有的不少方法已經足夠合併了,可是太慢。而數據結構的精髓就是不斷優化性能,因此須要引入新的結構來完成這個目的。下面先來逐一分析各類拍腦殼的算法,而後逐漸逼近此次的主角。數據結構
最平凡的思路是以大的爲基礎,把小的堆裏元素逐一取出插入到A中,B爲空時就完成了。性能
能夠一句話歸納:Insert(DeleteMax(B),A);優化
但這太慢了,簡直龜速。分析一下,咱們把兩個堆的規模記做n和m,不失通常性地,假設A不小於B,也就是:$\left| A \right|\; =\; n\; \; \geq \; m\; =\; \left| B \right|$。因此整個算法迭代$m$次,在每一次裏deleteMax(B)要花費$\log m$的時間,把這個元素匯入A中花費$\log \left( n\; +\; m \right)$的時間。因此一共是$O\left( \; m\; \cdot \; \left( \log m\; +\; \log \left( n\; +\; m \right) \right) \right)\; =\; O\left( m\; \cdot \; \log \left( n\; +\; m \right) \right)\; =\; O\left( m\cdot \; \log n \right)$的時間。恐怕你本身恐怕都不知足這個效率,由於的確有改進的空間。咱們會再想起Floyd批量建堆算法,嗯沒錯,更高效的辦法是先把兩個堆混合起來,而後經過下濾,維護整個堆的結構特性,把它整理爲一個徹底二叉堆。歸納一下就是BuildHeap(n+m, union(A,B));而Floyd算法只須要線性時間,也就是總共$O\left( n\; +\; m \right)$,這個效率就高一些了。ui
但這還不能使人滿意,緣由在於:Floyd算法的輸入默認是無序的,而咱們的兩堆分別都是有序的,剛纔這個算法沒有利用到咱們已知的信息,若是利用上了這部分有序的信息,就能夠加速執行了。從這個角度出發,咱們有理由相信:必定存在更高效的數據結構和相應算法。的確如此,Clark Allan Crane歷經探索,發明了一個新的結構:左式堆,並於1972年以此發表了他的博士論文$Linear\; Lists\; and\; Priority\; Queues\; as\; Balanced\; \mbox{Bi}nary\; T\mbox{re}es$。這種結構在保持堆序的前提下附加少許條件,就能在合併過程當中只須要調整不多的節點,插入和刪除都僅須要$O\left( \log n \right)$的時間。比剛纔的$O\left( n\cdot \log n \right)$和$O\left( n \right)$都有了長足的進步。他的這個新條件就是「單側傾斜」,節點分佈都偏向左側,而算法高效的訣竅是合併操做只涉及右側,而右側節點不多。spa
好比這就是左式堆的典型圖解,左長右短,它能夠把右側節點嚴格控制在$O\left( \log n \right)$之內,這也印證了上面說的合併操做的複雜度在O(logn)範圍內。這也引起出一個定理:在右側路徑上有 $r$ 個節點的左式堆必然至少有 $2^{r\; }-\; 1$ 個節點。設計
那它是怎麼作到這麼快的呢?如今討論這個還爲時過早,由於首先要回答另外一個問題。前面第三天然段提到過:它不是理想平衡的,而是趨向於極其不平衡。不平衡的話結構性就蕩然無存了,但咱們須要明白的是:對於Heap,堆序纔是本質特徵,其餘的都無所謂,在必要時刻均可以犧牲掉,畢竟,計算機科學就是一門關於權衡的學問。3d
如今咱們來討論一下左式堆的性質,引入一個概念:零路徑長(null path length,npl)定義爲從某個節點X到一個葉子的最短路徑長。上圖中節點內標示的就是。所以具備0 or 1個兒子的節點的npl是0,定義$npl\; \left( \; null\; \right)\; =\; -1$。那很天然,對於每一個節點的npl有以下計算公式:指針
$npl\left( \; x\; \right)\; =\; \; 1\; +\; \min \left( \; npl\left( \; lc\; \right)\; ,\; npl\; \left( \; rc\; \right)\; \right)$
看上去和某個公式有點眼熟啊,求樹高度的算法,和這個很相似,就是把min換成了max,經過類比咱們或許對這兩個概念能有更深的認識。
有了這個指標,咱們就能夠以此來度量堆結構的傾斜性。若是左孩子的npl不小於右孩子,就稱之爲左傾(政治上追求進步2333),若是每一個節點都符合這個性質,就稱爲左傾堆or左式堆。又由於npl定義是在兩個孩子中取一個小值+1,那麼咱們只考慮右邊就好了。總結以下:
左傾:對任何節點 $x$,都有$npl\left( \; x->\; lc\; \right)\; \geq \; npl\left( \; x->rc\; \right)$
推論:對任何節點 $x$,都有$npl\left( \; x\; \right)\; =\; 1\; +\; npl\left( \; x->rc\; \right)$
咱們也能夠推論:左式堆的任何一個子堆也必然是左式堆。第三天然段說過左式堆傾向於節點向左傾斜,但這只是大體的傾向,實際狀況不必定都向左。
下面討論實現,先說合並,而後是插入和刪除。
先說一下類型聲明
#ifndef LeftHeap_h #define LeftHeap_h struct TreeNode; typedef struct TreeNode *LefHeap; LefHeap Init(); int FindMin(LefHeap H); LefHeap Merge(LefHeap H1,LefHeap H2); //#define Insert(X,H) (H=Insert1((X),H)) void Insert(int x,LefHeap H); int DeleteMin(LefHeap H); LefHeap Insert1(int x,LefHeap H); LefHeap DeleteMin1(LefHeap H); #endif /* LeftHeap_h */ struct TreeNode{ int value; LefHeap left; LefHeap right; int npl; };
採用遞歸的模式能夠很是簡明的描述合併算法,對於通常情形:
能夠藉助遞歸將a、b兩個堆合併的問題轉化爲這樣一個問題:
具體來講也就是咱們要將a的右子堆取出,而且遞歸地與剛纔的堆b完成合並,合併所得的結果繼續做爲a的右子堆。固然,爲了保證a在此後繼續知足左傾性,在此次合併返回以後,咱們還須比較a_L與合併以後這個堆的npl值,若是有必要,咱們還需令兩者互換位置。遞歸寫法以下
void swap(LefHeap h1,LefHeap h2){ LefHeap temp=h1; h1=h2; h2=temp; } static LefHeap Merge1(LefHeap H1,LefHeap H2); void SwapChildren(LefHeap H1){ swap(H1->left, H1->right); } LefHeap Merge(LefHeap a,LefHeap b){ //遞歸基 if(!a) return b; if (!b) return a; /*執行到這一句以後就說明兩個堆都不爲空,此時咱們要比較兩個根節點在數值上的大小,若是有必要應將兩者互換名稱。從而保證在數值上a老是不小於b,以便在後續遞歸的過程當中將b做爲a的後代。 */ if (a->value < b->value) swap(a, b); //通常狀況下首先確保a更大,而後執行合併 a->right=Merge(a->right, b); //以後咱們要保證a的左傾性: if(!a->left || a->left->npl < a->right->npl) //若是有必要,咱們就交換a的左右子堆,以確保右子堆的npl更小 SwapChildren(a); //而後更新a的npl a->npl=a->right->npl+1; return a;//返回合併後的堆頂 }
具體例子以下:
最終:
要注意的是,在合併以後,原始的兩個堆就別再碰了,由於他們自己的變化會影響合併的結果。執行合併的時間與右側路徑的長度之和成正比,由於在遞歸期間每個被訪問節點執行的是常數工做量。所以合併的時間界限爲$O\left( \log n \right)$,也能夠分兩趟用非遞歸的方式來作:第一趟,經過合併2個堆的右路徑創建一顆新樹。爲此咱們要以升序(or降序,反正要保持有序)安排a,b右路徑上的節點,保持左孩子不變。在這個例子中,新的右路徑是3,6,7,8,18。第二趟構成左式堆,在那些性質被破壞的節點上進行交換,交換這些節點的兩個孩子。
對於插入,能夠經過把插入項看做單節點堆並執行一次Merge。
void Insert(int x,LefHeap H){ LefHeap fresh; fresh=malloc(sizeof(struct TreeNode)); fresh->value=x; fresh->npl=0; fresh->left=fresh->right=NULL; H=Merge(fresh, H); }
刪除的話,就是除掉根獲得兩個堆,而後再合併,所以時間仍是$O\left( \log n \right)$
int DeleteMin(LefHeap H){ LefHeap l=H->left; LefHeap r=H->right; int t=H->value;//前三句都是鋪墊,對相關的數據做備份而已。 free(H);//根節點的物理摘除由這一句來完成。 //此後只需將此時被隔離開的左子堆與右子堆從新地合併起來。 H=Merge(l, r); return t; }
能夠看到,按照這一方式,不管是左式堆的刪除仍是剛纔的插入操做,實質的計算無非都集中在合併接口上。此前介紹過,合併能夠高效率地在$O\left( \log n \right)$的時間內完成,那如此實現的刪除以及剛纔實現的插入操做也能達到這樣的計算效率。一樣的計算效率,更爲簡明的實現方法,咱們還有什麼理由不採用這種方式呢?
實際上關於分合之道,左式堆的發明者Crane堪稱箇中高手。除了左式堆,他還針對其它的許多數據結構給出了高效的合併算法。好比對於咱們已經熟悉的AVL樹,Crane也給出了一個高效的合併算法,有興趣不妨找找相關文章。
下一篇文章討論二項隊列,與以往不一樣的是,它並不是是一顆堆序的樹,而是森林。
p.s. 這段時間要備考託福,因此下篇文章大概會在11月左右發。