Fibonacci 堆演算法毀了我的生活

至秦發表於2015-10-31

幾個星期以前,我用 Clojure 實現了 Dijkstra 演算法。這個核心演算法只有 25 行。這些程式碼是我匆匆做出來的,當時我正在和黑客學校的一些人混在咖啡店裡。我在一個資料集上執行了我的程式,這個資料集是一張密集交織的圖,裡面有 200 個節點。在 200 毫秒內,這個程式產生了一個起始節點到圖中其它所有節點的最佳路徑。

我關掉筆記本,吃完花生醬、香蕉和蜂蜜三明治,和朋友告別,然後在這個下午剩下的時光裡,我徘徊在下東城塵土飛揚的陽光下。

到週一晚上,我的生活開始崩潰了。

Dijkstra 演算法用來計算圖中的一個節點到另一個節點的最短路徑。如果把節點比作英國的城市,那公路就是各個節點間的連線,Dijkstra 可以用來規劃倫敦到愛丁堡的最短路徑。這裡的關鍵是“規劃”。這個演算法是先勘查,再上路。

想象你就是這個演算法。你首先檢查所有和倫敦直接相連的城市,也就是那些只通過一段路就能到達倫敦的城市。記錄下倫敦和每個直連城市之間的距離。並記住你已經考察過倫敦了。然後你關注公路上距離倫敦最近的城市,比如說布萊頓。再檢查每個直接連到布萊頓的城市。記住要跳過倫敦,因為你已經考察過它了。緊接著,針對每個城市,你記錄(更新)倫敦到布萊頓再到這個城市的距離。然後記住你已經考察過布萊頓了。你現在關注下一個未被考察且距離倫敦最近的城市。像這樣繼續,直到你的目的地(愛丁堡)作為下一個關注的城市出現為止 。此時,你就找到一條通往愛丁堡的最短路徑。

週一早上,在黑客學校的簽到會上,我高興地報告了我將利用後面幾天用 Clojure 去實現一個 Fibonacci 堆。

我讀過 Dijkstra 維基上的一篇文章,說有兩個人發明了 Fibonacci 堆,並用它做節點選擇,從而減少 Dijkstra 演算法的執行時間。Dijkstra 演算法還是做路徑規劃。Fibonacci 堆只是幫著快速找到下一個被考察的城市。

查詢是一個費時的過程。你必須仔細查詢列表中所有未被考察的英國城市,找到一個距離倫敦最近的城市。我在咖啡店裡所實現的 Dijkstra演算法,程式碼遍歷了整個列表才返回一個距離最短的。這是很慢的。

Fibonacci 堆利用另外一種方法解決了這個問題。它按照距離倫敦的遠近對這些城市進行排序。因此當你想要找到下一個考察的城市,你只要選頭一個就好了。

為了解釋,我必須把城市和公路的比喻拋到腦後。非常遺憾,我找不出一個新的比喻來代替。堆不是很像家譜圖,也不是很像生長的綠色樹木。而是這樣一幅圖:

這是一個普通的堆。Fibonacci 堆只是更復雜一點,但思想是一樣的。每個圓圈 —— 每個節點 —— 擁有零個或者更多的子節點。而且,每個節點包含一個數字標記。這是它的排序數值,或稱為鍵。這些節點都連線到一個樹上,最低鍵值的節點作為根節點。

我開始通過畫圖來演示 Fibonacci 堆的關鍵操作是如何被應用在虛構的 Fibonacci 堆上。在反覆閱讀了這篇 Wikipedia 文章後,我畫出了每一個操作。

到週一結束時,我畫出了一片森林。我已經掌握了這個演算法,我有信心把它解釋給 Vera 和 Pepijn。(譯者注:Vera 和 Pepijn 是作者的兩個合作者,後面會提到

那天晚上我躺在床上,等著入睡,我意識到我正在思考 Fibonacci 堆。我的噩夢開始了。

週二我去黑客學校,又花費了一天時間在筆記本上寫寫畫畫。這時我已經得出了這個核心演算法的虛擬碼框圖。

早上我搭乘 G 線火車,換乘 C 線火車後到達學校。我已經閱讀過《如何解決它》,這是一本經典的數學讀物,介紹了一種非正式的解決問題的通用方法。對於《演算法導論》,我也曾半生不熟地思考過,這本書裡面研究了操作列表、樹、堆和圖表的演算法。
 

回首那段日子,並意識到你當時沉浸在你所做的事情裡,是一種很棒的感覺。你的工作浸入到你的旅行,你的談話和你的人際關係當中。我並不是說你抬頭看到樹木在風中嘎嘎作響,就一定要彷彿像看到倒掛的 Fibonaccci 堆一樣。比那樣簡單得多。你一直在思考你的工作,無論當你站在 Sunburnt Cow 外擁擠的人行道上,抑或是當你和你爸爸討論《高架橋下的車行道》中砂岩的筆觸如何展示出太陽光的耀眼的時候。你的工作只是輕輕地與你同在,就像一個背景故事或是一個看不見的朋友。(譯者注:《高架橋下的車行道》是梵高所做,布面油畫 32.7 x 41.0 cm 巴黎1887年春

因此我在接下來的兩週裡都沉浸在數學和堆裡。我在淋浴時思考它們。我在 G 線火車上思考它們。我在這個酒吧思考它們。(關於這個酒吧,)我和其他一些黑客學校的人一起來來這裡參加一個巴薩諾瓦晚會,起因是之前在等火車的時候,我聽了幾分鐘 James 的 iPod,以為巴薩諾瓦是一種鼓聲沉重的,類似薩爾薩舞曲的音樂,結果發現它實際上比較輕、更加靈巧、聽上去像一個蹦蹦跳跳的理髮店四重奏。(譯者注:斷句,斷句,斷句,重要的事情說三遍!

在2011 年 JSConf US 會議上,我在講演中談到了程式碼和生活在 Pistol Slut 上是如何相映成趣的。但這次,程式碼帶給我憂心超過了怡趣。我開始睡不好,不知道要花多長時間來完成這件事讓我百爪撓心。這些討厭的問題不是間隔幾個月,而是被壓縮到幾周內。這些程式碼已經不是一個編外專案,而成了我的全職工作了。我用 Clojure 編寫,而不是用相對簡單的 JavaScript 編寫,這真的讓我腦洞開啟,說多了都是淚啊。

我開始編寫程式碼。我很快發現樹的資料結構對於 Clojure 這種不可變狀態的語言來說顯得太複雜了。因為改變一個節點需要重新構建這個樹中的大部分。想象你給樹遠端的一個節點增加一個子節點。你必須複製包含這個父節點祖先在內的所有節點和分支。這是很花時間的,而且佔用了大量的記憶體。

Pepijn 建議我嘗試一個解決方案:一種叫做 zipper 的概念。這種結構把你當前關注的節點作為一個樹,樹中的剩餘部分都是透過當前節點觀察的。以這個樹為例:

一個聚焦在 J 節點的zipper看上去像這樣:

回想這個假想的母節點。由於這個樹是模擬一個zipper,所以很容易生成。我準備建立一個新的zipper,它融合了前一個子zipper的可重用部分和代表這次改動的新部分。所需的時間和記憶體取決於原始狀態有多少可以使用。這個例子裡,我可以重用路徑和左右的上下文。所以我必須創造的新東西只是一個由新節點 L 組成的新焦點,由節點 L 和它的父節點 J 組成:

如果你想要更加了解zipper的概念,我推薦你閱讀這篇很棒的文章,前面的例子就選自這裡。

繼續這個故事。

在接下來的兩天,Vera和我埋在程式碼裡。當黑客學校展示日到來時,我們沒有什麼東西可以演示。我們只有談論 Fibonacci 堆演算法的工作方式。

Vera 解釋說 Fibonacci 堆不是一個堆(樹),而是一個自我包含的子堆(樹)列表。她說有一個最小指標指向排序值最小的子堆根節點。我不記得她是否用了地圖裡城市的比喻,如果用了的話,那麼在這個比喻中,節點上的鍵就是起點城市到節點的距離。 

她解釋了 Fibonacci 堆的核心操作。

她說合並是一個新節點加入 Fibonacci 堆的方式。這個節點作為一個新子堆的根節點被插入。如果這個新節點的排序值比當前最小指標指向的節點要小,就需要更新指標。

她說查詢最小是讓指標指向最小的根節點,跟隨它並返回這個節點本身。

她說提取最小是讓指標指向最小的根節點,跟隨它並在它所屬的子堆中去除這個節點。 

她演示了提取最小如何整理這些子堆,即遍歷這個節點的所有子節點並確保每一個進入一個新的獨立堆。 

她展示了提取最小如何通過合併進一步整理這些子堆,即將有相同數量直系後代的子堆配對組合在一起。  

她解釋了減鍵操作是如何工作的。第一步是簡單地減少一個節點的排序值。如果這個減法讓這個鍵比父節點的還要小,那必須從這個子堆去除這個節點,讓它作為新子堆的根節點。還要更新這個最小指標讓它指向新的最小根節點。  

她解釋說,如果任何節點在一次減鍵操作中失去不止一個孩子,那這個節點自己也要成為一個新的子堆。  

第二天是星期天,我整個下午都坐在一間陽光下的咖啡店裡,稍稍從混亂中得到一些喘息。我編寫的程式碼脫離了主要的問題。它根據一個簡明的樹結構生成 Fibonacci 堆。這樣更容易編寫測試,因為它容易產生在某種測試場景下所需要的Fibonacci 堆。

第二週的週一和週二 ,Vera和我重新回到混亂中,我們開始實現減鍵以完成 Fibonacci 堆。我們在週六(演示日)把它放到我實現的 Dijkstra 演算法中。我們慢慢發現一個可怕的現實 。無論是過去還是現在我可以說 ,不變狀態語言編寫的 Fibonacci 堆 不適用於 Dijkstra 演算法。事實上,它不適用於任何一個演算法,只要這個演算法依賴與 Fibonacci 堆不同卻共享相同資訊的資料結構。Pepijn在他的部落格中談到了這個普遍的難題,命名為雙索引問題。

Fibonacci 堆實現的 Dijkstra中,有兩個資料結構。第一個是Fibonacci 堆。另一個是節點的圖表,它告訴你哪個節點連到哪個城市(靠,我們又回到地圖的比喻)。檢查你當前關心城市的鄰居可能會迫使你減小為鄰居所儲存的最佳路徑距離。但這並不簡單。當你得到附近城市的列表時,你正在檢視這個圖表的資料結構,它代表你手上有的圖表節點。儘管這些圖表的節點像 Fibonacci 堆中的節點一樣代表相同的城市,它們在計算機裡是完全不同的實體。因此去減少一個城市(堆節點)的鍵(距離),你必須從堆中得到代表這個城市的節點。這是很花時間的操作,因為要搜尋整個堆 。

我將導演一場虛擬的問答,一個是假象的你,另一個是真正的我。

你:為什麼對於可變值的語言來說,這不是一個問題呢?

我:因為圖中的節點可以只包含一個指向 Fibonacci 堆中對應部分的指標。

你:什麼是指標?

我:它就像一個箭頭標記,給你一條通向計算機所代表東西的直接快速路線。

你:為什麼你不能使用這種指標?

我:我們可以,但是它們沒有想要的效果。

你:為什麼?

我:因為在不可變語言裡,當你改變了資料中的一部分,你就需要一個完整的新副本。

你:天啊!這很煩人啊,為什麼我不問你都不說細節呢。為什麼完整的新副本會是一個問題?

我:抱歉。這的確是一個問題,因為當你更新一個節點的新距離時,作為改動的一部分你複製了這個節點。這個資料的其他部分可能還有指向這個節點的指標,但是它們現在指向的是舊版本 。在可變語言中 ,你可以只更新資料共享的一部分,而不用複製其他的。這樣所有指向資料的指標都還是合法的。

你:明白了。

我們實現了 Fibonacci 堆的 查詢操作。這個操作是給定一個節點,然後在 Fibonacci 堆中找到它。這個操作和 Fibonacci 堆的出發點正好相反。當我們使用這個操作是,原始版本的 Dijkstra 演算法要比這個 Fibonacci 堆版本快兩倍。

在黑客學校演示日上展示這些結果,是有點沮喪和滑稽的。但在對完成 Fibonacci 堆後的溫暖和舒緩回味中,這些感覺都緩和下來的,不,都消失了。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

Fibonacci 堆演算法毀了我的生活 Fibonacci 堆演算法毀了我的生活

相關文章