堆,很essential的數據結構,可惜嚴老大那本書沒有怎麼重視的感覺,但《算法導論》上有。可能嚴老大有自己的考慮。但從個人體會來看,應該還是非常有必要好好研究研究這個數據結構的,且不說堆是實現優先級隊列的基礎設施,更不說堆是衆多圖算法——如Dijkstra最短路徑算法、Prim算法——的實現利器,單就堆排序及一些選擇性算法——如尋找最大的K個值——而言,堆就不可忽視。
我們學習和研究一種數據結構,思維曲線一般可以遵從以下三步驟:
這裏,我說的是針對學習和研究,在實際解決問題的過程中,往往是先分析需求,這裏側重的指複雜度需求,然後思考進行什麼樣的操作滿足這種需求,再接下來,我們可能就要想,要實現這種操作,我們需要怎樣組織信息呢?這個,就是數據模型了。另外,在學習與研究中,有時候並不能完全區分第二步和第三步,我想這個應該也不必多說爲什麼。在本系列文章中,一些明顯的複雜度我就不分析了,一大坨數學符號看了頭大,追求形式化理論化不免曲高和寡。但對於比較重要也比較複雜的複雜度分析,我將給出分析過程。
確定了思維曲線,接下來就是確定具體內容。堆其實有很多變種,都是在現實中遇到了麻煩,然後先輩牛人們就想出了新的變種堆。本文主要學習和研究基本二叉堆及其泛型擴展——d堆,二項堆,斐波那契堆,楊氏圖表、笛卡爾樹等等,Wikipedia上列出的堆的變種,我都爭取進行分析和研究,這個過程,都是一個厚積薄發的過程。
學習和研究的目的,不應該僅僅滿足於知識的掌握,這是常識,儘管這同樣非常重要——這是我們在以後分析具體問題時的知識儲備。我們還應該儘可能從一次學習和研究中提升自己建立模型的能力,這個過程可能很緩慢,不會像林志玲的臉蛋,望一眼就讓人神魂顛倒靈魂出竅,但是意義卻是不言自明的吧。所以這篇文章除了依據圖1的思維曲線來學習研究堆,也希望可以總結出一些提升個人獨立建立模型能力的感悟。最後我們上點乾貨,給出堆的C++、Python實現代碼(文中以C++代碼爲解說,附件爲C++與Python的完整實現),並具體解決幾個具體問題。
1. 二叉堆與d堆
1.1模型的建立
我們認識堆基本上都是從堆排序算法開始,至少我是。根據《算法導論》,堆最早可能確實就是基於排序的需求,J. W. J.Williams發明了堆排序算法,但是Williams那篇論文我在網上搜不到(如果誰有,希望可以分享一下,嘿嘿),所以W爺爺到底是基於一個什麼樣具體的問題(需求)的“折磨”想出了這麼好的辦法,我也不得而知,所以只好猜猜了。
根據堆最經典的應用之一——堆排序的特點,我琢磨着應該還是基於對選擇排序的不滿。選擇排序簡單直觀,每次從遍歷序列從中找出最大值放入已排序序列中,顯然,不平凡也不甘平凡的發現了,這個遍歷做的事情顯然太少了,一個(i=0; i<n; i++){foo(i);}都可以優化成for(i=0; i<n; i+=2){foo(i); foo(i+1);},憑什麼選擇排序這個大尾巴狼就沒得優化空間?選擇排序的基因思想是,每次從(剩餘的)源序列中找出最大值。打個比方,這就像是比賽,如果你能從第一局打到最後一局,那你就是最強者(最大值),我們選出這個最強者(最大值)的過程就是選擇的過程,選出來之後,將它標記,然後再從剩下的選手中以同樣的方法選出第二強的,依次下去,直到只剩一個選手(邊界)爲止。從這個比方中,我們差不多也看出這種辦法的毛病了:它浪費了中間很多次比賽的結果記錄。還是舉個例子,對於選手序列S={C, B, E, A, D},並設定強弱關係爲A>B>C>D>E。
1) 第一輪,C和B比賽一場,B勝利了;於是B就繼續和E賽一場,B勝了;B又和A賽一場,A勝利;A再和D賽一場,A再次勝出,A就被選出來。
2) 第二輪,C又和B賽一場,於是B就繼續和E賽一場, B勝利,於是B繼續和D賽一場;
3) ……
看出來了吧,這裏C和B的比賽多餘了,同樣的,B和E的比賽又重複了。
問題找到了,怎麼想辦法克服呢?哈哈,you got it! 保存下這個結果!再繼續往下想,怎麼保存?就我不深的計算機學習經驗,對於“大與小”、“勝與負”等等這種簡明的二元關係,比較容易聯想到的就是樹了。我們知道,樹的本質思想就是分治,是二元。對於樹,父節點可以抽象爲一個指定判斷標準下的被比較對象,相應地,左子節點抽象爲與父節點相比較後達該判斷標準的對象,右子節點爲未達標對象。在排序中,左子節點就是比俺嫩的,右子節點就是比俺熟的,基於這種思想,我們就有了二叉排序樹(二叉搜索樹);另外一種二元分治思想的具體化方式就是,凡是子節點就是比俺嫩的(或是比俺熟的),基於這種思想的就是我們的堆了。
言歸正傳,從這個具體的例子,我們可以抽象到選擇排序,選擇排序就是白白浪費了許多具有明顯意義的中間結果,每次我們再去找下一個最大值的時候,都要重新老老實實重新遍歷一遍整個剩餘序列,programmers就開始琢磨着,如果再去找下一個最大值的時候,可以直接取出來就好了!有人就跳起來了,那不就是已排好序的序列了嗎?好吧,那我退一步,我看能不能做到不用遍歷整個剩餘序列的辦法就取出來呢?從O(N)降低到O(logN),行不?對此我們想到了要保存這些結果,再進一步地,我們想到了可以藉助樹的思想。對於樹,除了前面的二元分治思想,另一種本質性思想是遞歸,遞歸性。這個應該很好理解,遞歸的本質又可以理解爲任何一個小局部都服從某種規則,構成同樣服從該規則的大局部,也就是整體。說了這麼多,我們差不多可以引入堆的概念了。
(二叉)堆,如圖2所示(本圖摘自機械工業出版社《算法導論》第二版73頁),它可以被視爲一棵完全二叉樹。完全二叉樹的主要特點是,樹的每一層都是滿的,最後一層可能除外,而最後一層從一個節點的左子樹開始填充。
再具體看堆的特點,堆之所以爲堆的特點。對於根節點16的兩個兒子,14和10都比根節點16小,這就是一種二元思想的體現了,存儲在父節點與子節點之間的關係,就是在比較中獲得的大小關係。再看遞歸思想,14的子節點8、7又比自己小,10的節點9、3也比自己小,以此類推。
堆從具體內存佈局上看,是一種數組對象——完全二叉樹嘛!顯然,這棵完全二叉樹中每個節點都與表示二叉堆的數組中元素存在某種對應關係,這在圖2中也已體現出來。假設表示二叉堆的數組爲A,那麼A[0]表示樹根,也就是堆頂,那麼,對於某個數組下標爲i的節點Ni與其父節點爲Pi、左子節Li、右子節點Ri(假設左子節點或右子節點存在)之間的關係如下:
Pi = A[i/2], Li = A[2*i], Ri = A[2*i+1].
再次回到選擇排序,堆排序的過程,就是在遍歷數組時,將數組轉換爲具有圖2中堆特性的數組:
A[i/2]>=A[2*i]
且A[i/2]>= A[2*i+1]
這樣,最大值的時候,我們就知道是A[0]了,取出A[0]後,彈出來,修改一下數組的元素次序,下次最大值還是在A[0]處。這裏的問題在於,取出A[0]後修改數組中的元素次序,這個複雜度會比老老實實再遍歷一次剩餘數組的開銷低嗎?
這就是對該模型的操作問題,以及操作的複雜性問題。帶着這個問題,我們可以進入第二步了。
1.2針對模型的操作及複雜度分析
1.2.1 構造——建堆
任何數據結構模型,都無外乎構造、插入、刪除、查找(讀取)、修改等等。
從前述中我們得知,堆可以看作在“精化(refinement)”的完全二叉樹,完全二叉樹的數組表示中,數組後半段A[n/2]~A[n-1]都輸樹的葉子節點,因此,每個葉子節點都可以看做是隻有一個元素的堆,建立堆的過程,就是從葉子節點的父節點開始,逐步上溯(遞歸思想),使得整個數組具備堆的特點,爲此我們先引入一個輔助函數,它完成對指定的數組段得“堆化”的過程,這個函數就叫heapify。
//A[]爲待“堆化”的數組,start和len確定了待“堆化”的子數組
template<typename T>
inline void heapify(T A[], start, int len){
int son = start>>1+1; // 左子節點索引,因爲下標從0開始
T item = A[start];
while(son <= len){
if(son < len-1 &&(A[son] < A[son+1])) ++son; // 移至右子節點
if(item >= A[son]) break; // (*)
a[son>>1] = a[son]; //a[son>>1]就是父節點了,(*)行沒有break出去,說明違反堆性質
son <<=1; //孫子節點
}
a[son>>1] = item;
}
讀懂代碼往往是需要想象力的,尤其是算法性質的代碼。而想象力最好的體現辦法就是畫圖,請見圖3(本圖摘自《算法導論》第75頁)。
每次循環中,從A[i], A[son], A[son+1]三者中選出最大的,將其“上移”。其實就這麼簡單。而這個複雜度應該也是很明顯的O(logN)吧。
有了heapify,建堆的過程應該就很簡單了,前面說了,從葉子節點的父節點開始,逐步上溯(遞歸思想),使得整個數組具備堆的特點,具體操作上,就是對從葉子節點的父節點開始的數組前半段,依次調用heapify。
template<typename T>
for(int i = len>>1; i; --i)
heapify(A, i, len-1);
}
圖我就不畫了,請見《算法導論》第77頁圖6-3。
這個建堆的過程是一個典型的遞歸過程——是的,你懂的,遞歸可不一定就要一個勁自己個調用自己個,循環也可以。每次循環中都不斷檢查子樹是否滿足堆性質,不滿足就調整,調整完就將循環下標前移。
1.2.2 插入與刪除
插入與刪除其實也簡單,主要是有一種情況要考慮到,就是新插入的元素和刪除的元素可能改變現有的堆結構性質。所以要進行相關的驗證,驗證失敗後就要再次heapify了。
template<typename T>
bool insert_heap(T A[], T item, int& heapsize)
{
int index = ++heapsize; // 初始時當然就是插入到數組尾部了
if(index == MAX_SIZE){
std::cerr<<"heap size exceeded/n";
return false;
}
A[index] = item;
heapify(A, 0, heapsize-1);
return true;
}
下面給出的是不使用heapify的insert_heap()版本,基本上沒什麼區別,我寫出來主要是爲了擴展下思維和視野,意識到解決問題的方案永遠不止一種,很非常好滴呀;另外,STL中使用的是這個版本的思路。不過我個人還是偏向於heapify版本的實現,簡潔,複用性也體現了。
template<typename T>
bool insert_heap(T A[], T item, int& heapsize)
{
int index = ++heapsize; // 初始時當然就是插入到數組尾部了
if(index == MAX_SIZE){
std::cerr<<"heap size exceeded/n";
return false;
}
while(index > 0 && A[index >>1] < item){
A[index] = A[index >>1];
index >>=1;
}
A[index] = item;
return true;
}
初始時當然默認新插入的元素在數組尾部了,在侯捷老師《STL源碼剖析》中將其抽象爲一個hole,嗯,我覺得很好,下面是從這本書的繁體版174頁圖4-21。看着這個圖,插入的過程應該是非常好理解的。
在這裏想提一個小插曲,其實建堆的過程,我們也可以使用insert函數,就是每次從讀取一個數據,然後插入到堆中,《算法導論》第六章習題就提到了這一點。
對於堆的刪除操作,一般來說,刪除的僅僅是堆頂元素,很少有指定一個索引值刪除某元素或是指定元素值刪除。所以正文部分就不討論了,不過這個操作我們也實現一番,留到第三小節去,,下面是堆頂元素的刪除函數。
template<typename T>
bool delete(T A[], int& heapsize){
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
T tmp = A[0];
A[0] = A[heapsize];
A[heapsize--] = tmp;
heapify(A, 0, heapsize);
}
上面這段代碼很簡單,先將堆頂元素與堆尾元素交換,然後使堆的有效長度減1,然後重新對堆進行heapify。與插入操作的版本一樣,下面給出的是仿STL的實現,如果您對STL源碼沒什麼興趣,可以不看。
template<typename T>
bool delete(T A[], int& heapsize){
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
T item = A[heapsize - 1]; // 保存下最後一片葉子的值
A[heapsize - 1] = A[0]; //原來堆頂的值放到數組尾部
--heapsize; //因爲原來的item已delete出去了,所以數組實際長度減1
int index = 0;
int child_index = index*2 + 2; //取右子節點
while(child_index < heapsize){
if(A[child_index] < A[child_index - 1]) //取子節點數值最大者
--child_index;
A[index] = A[child_index];
index = child_index;
child_index = index*2 + 2;
}
//處理邊界情況,完全二叉樹最後一片葉子爲左子節點
if(child_index == heapsize){
A[index] = A[child_index - 1];
index = child_index - 1;
}
insert_heap(A, item, index);
}
1.2.3查找(讀取)、修改與合併
與刪除操作一樣,查找(讀取)操作應該是讀取堆頂元素,這個O(1)時間就可以實現了,也非常簡單,直接返回A[0]就百事OK;對於修改操作,一般的也還是修改堆頂元素,變大或者變小,根據情況重新heapify一下就可以,如果是指定一個索引值修改某元素或是指定元素值進行修改,我想,現實中也不會完全沒這種可能,所以我們在1.3節也給出相應的實現。下面先給出一般的讀取與修改的代碼。
template<typename T>
inline bool getTop(T A[], int heapsize, const T& res)
{
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
res = A[0];
return true;
}
template<typename T>
bool modify(T A[], int heapsize, T dest)
{
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
A[0] = dest;
// 我們一直都假設堆爲最大堆
if( dest < A[0])
heapify(A, 0, heapsize-1);
return true;
}
對於合併(merge)操作,一般書上講到二叉堆不會提到合併操作,因爲這不是二叉堆所擅長的,在本系列的後面的部分中專門分析二項堆和斐波那契堆的時候還會提到,這裏提到合併操作,既是考慮到思維的全面性,也是爲了後面分析二項堆/斐波那契堆的時候有個對比。單獨考慮二叉堆的合併,假設第一個堆的大小爲N,第二個爲M,主要有兩個辦法, 一種是依次取出第二個堆中的元素插入到第一個堆中,這種辦法的複雜度爲O(MlogN);另一種是先將第二個堆中的元素拷貝到第一個堆中,再執行一個heapify(),這種辦法的複雜度爲O(M+M+N),一般認爲第二種情況下的複雜度優於第一種情況,事實上《算法導論》這是這麼說的。下面是基本代碼。
Merge操作的實現請見1.3節。
1.3二叉堆的測試
限於篇幅,完整的C++實現只能上傳到CSDN下載頻道去,本節主要給出二叉堆的接口,當然,我們學習數據結構,其實學的就是實現,關於這一切,我都上傳到了這裏,裏面包含了相應的測試文件。下面僅給出接口部分。
template<typename T, typename Compare = heap_aux::greater_equal<T> >
class binaryheap{
public:
binaryheap(const Compare& _comp = Compare());
binaryheap(int size, const Compare& _comp = Compare());
binaryheap(std::vector<T> _coll, const Compare& _comp = Compare());
~binaryheap(){}
public:
inline const int get_heapsize() const;
inline const T& getTop() const;
inline const T& get_elem(int index) const;
public:
void insert(T item);
void delete_heap(int index = 0);
void modify_top(T dest);
void merge(const binaryheap& other);
private:
inline void make_heap();
inline void heapify(int start, int len);
private:
std::vector<T> coll;
int heapsize;
Compare comp;
};
在我的測試中,分爲正確性測試與耗時測試兩部分,都比較好懂。值得一提的是,與STL的make_heap相比較,binaryheap不知道爲什麼快了那麼多,最快都快了50.4%,如下圖所示。難道STL精深的封裝與嵌套,竟要如此耗費了如此大的開銷??
下期預告:以上基本上都還是一些理論性質的東西,下期我們將給出完整的二叉堆和D堆的C++實現,Python實現留待以後,因爲我現在時間並不是非常充足。下期還將給出幾個和堆關係緊密的現實算法問題及ACM訓練題。所謂“古來學問無餘力,少壯工夫老始成。紙上得來終覺淺,絕知此事要躬行”。
走,我們編程去!~
PS. 個人聯繫方式:
微博: http://t.sina.com.cn/g7tianyi
del.icio.us: http://delicious.com/fairyroad
豆瓣:http://www.douban.com/people/Jackierasy/
e-mail: [email protected]