連結 :點擊開啟連結
好吧,這是搬運Matrix67的第二篇博文了……搬運理由嘛……同前一篇。。。所謂溫故而知新,老知識看看神牛寫的同樣能學到新知識。
持續的:前人V5,後人奮進啊啊啊啊啊
===================================
4月7日買起來看,前幾天才看完。這可以說明很多問題,比如,學習很緊張,沒有時間;書本身很好,很有看頭;看書看得很細心,很有耐心。
打算大致寫一下書裡的內容。
Data Structures and Algorithm Analysis in C, Second Edition,機械工業出版社。封面很醜,一個黑底版,上面有些大理石花紋,正中間生硬的擺一個原版封面,同樣醜。一共12章,近400頁。
400多頁是很多的。我們必須要“把厚書讀薄”,厚的變薄的,薄的變一頁,一頁變一行,一行變成一個字。因此,我要在有限的字數內把整本書說完。
演算法分析,就是複雜度的問題。複雜度只算“最要命的”,比如,執行n^2的演算法前來個快排根本不拖速度,n^2多的都豁出去了不在乎區區一個nlogn。書裡對複雜度進行了嚴格的定義,包括O()、o()、Θ()、Ω()四種符號。簡單地說,O(n^2)就是頂破天了搞個n^2次;o(n^2)就是天花板不到n^2,比n^2矮一點(比如希爾排序就是o(n^2),因為它再倒黴也達不到n^2);Ω(n^2)就是說某個演算法隨便怎麼至少都要耗費n^2,比如所有基於比較的排序都是Ω(nlogn);Θ(n^2)就是說它即是O(n^2)又是Ω(n^2),被天花板和水泥地夾在中間了,動不了了,就是它了。這裡面有一個經典的例子,就是最大子序列(找數列中連續一段數之和最大)的四種演算法,複雜度分別為O(n^3)、O(n^2)、O(nlogn)和O
(n)。這書一個特色在於,對每種資料結構都有嚴格的演算法複雜度證明,這往往是看上去最頭痛的部分。
表、棧和隊列是三個基本的資料結構。說穿了表就是把資料找起來排排坐吃果果,找什麼東西都來把整個隊伍找一遍。棧就是一個桶,後放進去的先拿出來,它下面本來有的東西要等它出來之後才能出來,就好像你看到了一個醜人不可能今天的中飯還沒吐出來就先把早飯吐出來了。棧是拿來類比多個過程的調用的(比如遞迴),實際點的用途就是運算式計算。隊列好比堵車,先進去的先出來。先進隊先買票,不能插隊。常拿來實現廣搜。
樹,是一種植物,有很多枝枝丫丫。不同的是這裡的樹是倒著的,樹枝朝下長。最上面叫根,尖尖的地方叫樹葉,生出樹葉的是他爸,他爸生的是他兒子。不管是根是樹葉還是兒子還是兒子他爸都叫節點。我們常常把資料儲存在節點上,並且以後還要不斷地插入、改變和刪除資料。
二叉樹就是每個分叉的地方最多分兩個岔,而且還分得清左右。二叉尋找樹就是說把資料存在節點上,而且左邊的都比他爸小,右邊的都比他爸大,以後要找哪個數就可以只找其中的一邊,一半一半地扔掉。在二叉尋找樹裡也可以插入一個數,刪掉一個數(只有一個兒子好辦,有兩個就把右邊的最小的一個拿來替代這個),找最小的數(一直往左走),找最大的數(一直往右走),但是容易搞著搞著的樹就變畸形了,比如說左邊猛起長右邊萎縮導致以後往左邊走要走很久。我們就需要一種方法來讓樹左右差不多一樣多而且左邊的值仍然比右邊的小。事實上這種方法已經找到了,而且不只一種方法,而是一卡車的方法,比如AVL、Splay、紅/黑樹狀結構、Treap等。幾種方法都靠一個叫“旋轉”的技巧,就是把幾個節點怎麼個一轉,左邊的就跑到右邊去了一點。看下面這個圖你就明白了。
① ②
/ \ 旋轉 / \
② ZZ ——> XX ①
/ \ / \
XX YY YY ZZ
這樣一來左邊就少了,如果左邊的XX本來很多的話就可以往上提一層從而平衡。同樣地,右邊多了反過來做就是了。這隻是最簡單的“單旋轉”,事實上還有很多其它的較複雜的旋轉方法。Splay樹就是把剛才訪問過的節點轉啊轉啊轉啊轉轉到最頂上去,Treap就是每個節點附加一個隨機的數,隨時通過旋轉保持兒子的這些隨機數比他爸大,其餘的有點複雜。這些方法都能使二叉尋找樹相對地平衡一些,防止畸變導致的時間浪費。
B-樹和二叉尋找樹有兩個不同,一個是節點不存資料,資料全在樹葉子上,二個是它不一定是二叉。資料仍然左邊小右邊大方便尋找。每個節點最多的兒子數有限制,最多三叉的叫2-3樹,最多四叉的叫2-3-4樹。因為只有樹葉上有資料,所以可以遞迴地用分裂的方法處理新插入後出現的分叉比規定的最多的兒子個數時還多的情況。比如,2-3樹中如果哪裡分了四個岔,就把它重新分成兩個兩個的岔。我們還規定,除了根以外,每個節點最少的兒子數是規定的最多兒子數的一半,除不盡取上整。容易想到,刪除的話可以把插入時的分裂反過來做,什麼時候只剩一個兒子了就和旁邊的合并起來。
Hash表又叫散列表,一般用於判斷有沒有重複。比如我想找我們班有沒有兩個一天生的,我們不必每兩個人都來比較一次,而是準備一個年曆,讓人一個一個上去在他的生日那天那裡畫一個圈,如果誰要畫圈時發現那裡已經有一個圈了,就找到了一對。這個很簡單,不說了。
那天班上流行一個心裡測試,當時我還真發現了一個和我一天生的,女的。
堆,就是一陀一陀的東西。頭重腳輕不算堆,要上面小下面大才算一個堆。堆是一棵二叉樹,滿足下面的始終比上面的大。它和二叉尋找樹比較起來既有好的又有不好的:好的就是要想知道資料裡的最小值時根本就不用找了,直接就是最頂上的那個了;不好的就是堆除了這個以外基本上不能做別的事了。除了最頂上的那個以外,你幾乎沒辦法控制其餘的部分。當然,插入和刪除資料這種基本操作還是可以做的。插入就是把資料暫時先放在最下面的某個位置,然後通過與它上面一個進行比較、交換不斷往上冒直到已經到了自己的位置不能再向上為止。刪除反起來,通過不斷交換往下沉一直沉到底。因為是往下走,所以要考慮到一個把左邊的放上來還是把右邊的放上來的問題。當然,為了保證堆上小下大的性質,應該把小的一邊換上來。剛才說過,由於你只能“看”到最頂上的東西,不知道中間部分是什麼樣,我們通常只刪除最小的(最上面的)那個節點。其實堆還有一個最大的好處:容易寫代碼。因為我們可以有意讓資料把樹“排得滿滿的”,滿到它是一行一行挨著排下來的。這叫做“完全二叉樹”。我們可以給完全二叉樹編個號,從上到下從左至右挨著數下來。根是1,找左兒子就乘2,找右兒子就乘2加1,找它爸就 div
2。以後叫誰就是誰,很方便。這樣整個樹就可以用一個數組實現了。由於堆基本上只用來找最小,因此如果某個問題要求很複雜的話,最好還是用成二叉尋找樹;當然,如果問題只要求插入、刪除和找最小三種操作,你應該毫不猶豫地選擇堆,畢竟找最小時堆方便得多,寫起又簡單。什麼時候出現這種問題呢?比如說,我的女友排起隊的,我每次要選一個最純潔的,就是受那些的影響最小的人。每當我遇見了一個新的美女,我就把她放在這個隊伍裡合適的位置供我以後娛樂。這時,我只關心每次插入、取最小和刪最小。這個隊伍就可以用一個堆來最佳化。因此,堆還有一個形象的名字叫優先隊列。如果誰問題目要求不找最小找最大怎麼辦,那人肯定是個傻子,把堆變通一下,上大下小不就完了嗎?
研究堆麻煩的地方就是堆的合并。如何把兩個堆合并成一個堆?這個解決了很有用,至少上面的這些操作跟著全部統一了:插入就是與一個單節點的堆合并,刪除根就是把根不要了,把根的左右兩邊(顯然還是堆)合并起來。一個簡單的辦法就是遞迴地不斷把根大的堆往根小的堆的右邊合并,把新得到的堆替換原來的右兒子。注意遞迴過程中哪個根大哪個根小是不停在改變的。這樣下來的結果就是典型的“右傾錯誤”,而且破壞了完全二叉樹的完美。為此,我們想要隨時保證堆的最右邊盡量少。於是,乾脆不要完全二叉樹了,不過是多寫幾行代碼嘛。這個不存在像二叉尋找樹那樣“某一邊越做越多”的退化問題,因為對於一個堆來說,反正我只管最頂上的東西,下面平不平衡無所謂,只要不擋我合并的道就行。於是,我們想到人為下一個能讓堆盡量往左邊斜的規定。這個規定就是,對於左右兩個兒子來說,左邊那個離它下面最近的兩個兒子不全(有可能一個都沒有)的節點的距離比右邊那個的遠。這規定看著麻煩,其實還真有效,最右邊的路徑的長比想像中的變得短得多。這就叫左式堆(左偏樹)。這下合并倒是方便了,但合并著合并著要不了多少次右邊又多了。解決的辦法就是想辦法隨時保持左式堆的性質。辦法很簡單,你合并不是遞迴的嗎?每次遞迴一層後再看看左右兩邊兒子離它下面沒有兩個兒子的節點哪個遠,如果右邊變遠了就把左邊右邊調一下。由於我們已經沒有用數組實現這玩意了,因此鏈表搞起很簡單。這個對調左右的方法給了我們一個啟發:哪裡還要管什麼到沒有兩個兒子的節點的距離嘛,既然我每次都在往右合并,我為什麼不每次合并之後都把它對調到左邊去呢?這種想法是可行的,事實上它還有一個另外的名字,叫斜堆。
二項堆更強,它也是堆,也能合并,不過它已經超越了堆的境界了:它不是一個堆,而是滿屋子的堆。也就是說,找最小值不能再一下子找到了,而是要把二項堆中的每個堆的頂部都看一下。二項堆的合并也很強,直接把根大的堆放在根小的堆的下面。這意味著二項堆的每個堆都可能不是二叉樹了。這增加了編程的難度,不過可以用一個叫做“左兒子右兄弟”的技巧來解決問題。這個技巧,說穿了就是仍然用二叉樹來表示多叉樹:把樹畫好,然後規定節點的左兒子是下一層的最左邊那個,右兒子就是它右邊那個。就是說,左兒子才是真正的兒子,右兒子不過是一起生出來的。為了讓二項堆好看些,讓堆的個數和大小保持在一個能快速控制項目的數目和比例內,二項堆作出了一個明智的規定:每個堆的大小(總的節點個數)只能是1、2、4、8、16…中的一個,且每種大小的堆只能有一個。若干個互不相同的2的冪足以表示任意一個正整數,因此這個規定可以保證不管多大的二項堆都能表示出來。保持這個性質很簡單,遇到兩個大小相等的堆就合并起來成為一個大一號的堆。由於總是兩個大小相等的堆在合并,因此二項堆中的每一個堆都有一個奇妙的樣子,看看本文結束後下面附的一個大小為16的堆的,再看一下,再看一下,你就能體會到了。圖下面有一個用“左兒子右兄弟”法表示的同樣的樹,其中,往下走的線是左兒子,往右走的線是右兒子。
最後簡單說一下Fibonacci堆。保持一個跟著變的數組記錄現在某個節點在堆中的位置,我們還是可以對堆裡的資料進行一些操作的,至少像刪除、改變數值等操作是完全可以的。但這個也需要耗費一些時間。Fibonacci堆相當開放,比二項堆更開放,它可以不花任何時間減少(只能是減少)某個節點的值。它是這樣想的:你二項堆都可以養一屋子的堆,我為什麼不行呢?於是,它懶得把減小了的節點一點一點地浮上去,而是直接就把它作為根拿出來當成一個新的堆。每次我要查最小值時我就再像二項堆一樣(但不要求堆的大小了)一個個合并起來還原成一個堆。當然,這樣的做法是有適用範圍的,就是前面說的數值只能是減少。在什麼時候需要一個數值只減少不增加的堆結構呢?莫過於Dijkstra一類的圖論演算法了。所以說,這些圖論演算法用Fibonacci堆最佳化可以進一步提速。
有一個女人的男人很幸福。事實上,這是片面的。應該說,有不止一個女人的男人更幸福。但是,這樣會壞了我的人品,而且被女的知道了也不好。兩個耍得好的女人話很多,秘密在女人中傳得很快。於是,我打算不同時和兩個耍得好的女的耍朋友。後來我意識到,這樣也不行。女人太無敵了,即使A與B耍得好,B與C耍得好,A和C的訊息也是互連的。哪怕只有一個朋友關係也能把兩群人聯絡在一起。我不得不改變策略,使得我的女朋友之間沒有任何渠道傳遞資訊。也就是說,在上面的A、B、C三個人中,雖然A和C沒有直接的聯絡,但我也不能同時和A、C耍。不久後,我想知道,某兩個女人是否可以通過某條“朋友鏈”傳遞資訊。這就是所謂的等價關係——基本上算是判斷一個無向圖的連通性。就像很多個集合,每次選兩個並成一個,而且我們隨時想知道某兩個元素經過前面的合并後是否在同一個集合內。怎麼辦呢?後來有一天,我發現那些小女生喜歡玩些認親戚的遊戲,什麼誰是誰媽,誰是誰姐,誰是誰女兒之類的(不知道為什麼這些瘋女人喜歡搞這些)。我突然恍然大悟,我的問題可以用樹結構來完成。親戚的親戚還是親戚,但有一點總相同:所有親戚的始祖總是一樣的。始祖一樣的都是一夥的。因此,把兩個集合并在一起,只要讓其中一個集合的根成為另一個集合中的某個元素的一個兒子就行了,這種家譜關係的改變將使前面的集合中所有的元素擁有和後面那個集合一樣的鼻祖,而這將成為這些元素的“標誌”。這個想法的靈感是來自女人世界的,因此女人還是有一定的作用。
這就叫並查集,又叫不相交集。它可以合并兩個集合并且查詢兩個元素是否在同一集合。我們有一個很有效剪枝:遞迴時順便把路上經過的祖祖輩輩全部變成根的兒子。這樣的程式只用2行來解決。
function find_set(x:integer):integer;
begin
if x<>p[x] then p[x]:=find_set(p[x]);
exit(p[x]);
end;
p[x]表示元素x的父親的位置。一開始,p[x]都等於x自己,表示自己一個人是一個集合。函數find_set(x)將返回x所在集合(一棵樹)的根。
並查集還有些其它的剪枝和一些很複雜的效率分析問題,這裡不多說了。
寫到這裡,《資料結構與演算法分析》中的幾個大塊內容算是說清楚了。由於本文的敘述調整了原書各章節的順序且至此還沒有涉及書裡的一些小問題,因此這裡想把遺漏下的一些小東西提一下。
有一些樹結構可能要求同時滿足多個要求。比如一個簡單的問題:如果要求構造一個堆使得既能尋找最小元素又能尋找最大元素怎麼辦?這時,我們可以用一個特殊的方法來實現:樹的單數層滿足一種性質,樹的雙數層滿足另一種性質。我們用一個叫做最小-最大堆的東西來實現前面說的問題。這個堆的雙數層的資料小於它爸大於它爸的爸,單數層的資料反過來,大於它爸小於它爸的爸。用類似的方法,我們還可以設計一個二叉尋找樹,使得它能夠支援含有2種不同類型元素的資料。在單數層按其中一種操作,在雙數層按另一種操作,這樣可以方便的尋找同時位於兩個不同類元素的指定區間內的資料。這種二叉尋找樹叫做2-d樹。擴充2-d 樹,我們可以得到k-d樹。這些資料結構的具體實現方法這裡不說了,書上本來也是作為一個習題介紹的。
書裡的第7章花了近50頁介紹並分析各種排序演算法,分析得很全。其中第11節花了10頁介紹外部排序。所謂外部排序,就是說怎樣快速地把一個根本無法全部讀入記憶體的大檔案進行排序。很多排序之所以可行是因為它們可以隨意讀寫任意一個指定的數。但在大檔案裡,我們無法實現“第1234567890個元素和第 123個元素交換位置”,更無法實現遞迴之類的操作,而只能像磁帶一樣“過一遍”,從頭到尾掃一遍,由於檔案太大記憶體不能接受,因此必須要讀一截扔一截。於是,外部排序產生了。不要以為這個限制會把排序速度拖得很慢。事實上,外部排序同樣突破了O(n^2)的界限。它藉助了歸併排序中的“合并兩個已經有序的數組”的思想,因為這個操作可以邊讀就邊做。把檔案先拆成兩個檔案,再把每個檔案處理成一段一段的等長有序序列(一段多大取決於記憶體能一次處理多大),然後不斷從兩個檔案中各取一段出來合并。可以看到,每段有序序列的長度變長了,變成了2倍長。過不了幾次,這個長度將變成檔案的總長。注意,我們必須要讓每次合并時為下次合并做好準備(就是說合并後的結果仍然要是兩個分了段的檔案)。一個好的方法是將合并的結果交替存在兩個不同的新檔案中。
第9章講圖論演算法。講了圖的遍曆(廣搜和深搜)、AOV、AOE、Dijkstra、網路流、Prim、Kruskal和NP問題。在講深搜時,我學到了兩個新東西,用線性時間尋找割點(去掉了的話圖就不連通了的點)和強分支(有向圖中的一個分支滿足其中任兩個點之間都可以互相到達)。後來發現黑書上也有,又覺得這個東西很不好說,因此這裡不想說了。說到了黑書還想順便補一句:黑書真的看不得——太多錯誤了。不是說LRJ怎麼了,LRJ在真正的大問題上有他的思想和經驗,但很多細節的概念他也是昏的,這不利於初學者接受知識。不信哪天我還要寫一篇日誌糾正黑書的錯誤。引用政治書上抨擊“人性自私論”的經典語言:“從理論到實踐都是錯的”。
第10章講“演算法設計技巧”,大概是些貪心啊,分治啊,動規啊,回溯啊,隨機化啊之類的。調度問題、Huffman樹、裝箱問題近似演算法、最近點距分治演算法、最優二叉尋找樹、Floyd-Warshall、跳躍表、Miller-Rabin素性測試、博弈演算法等都在這章中有講,並且講得相當好。由於這不是本書的重點內容,這裡也不說了。
第11章整章都在講攤還分析。這是一個相當複雜的問題,是分析時間複雜度的一個有力工具。它的分析告訴我們的不是某一個操作的複雜度,而是重複執行某一個操作的平均複雜度。研究這個是很有必要的,因為我們會遇到一些“越變越慢”的退化情形和“自我保持不變”的自調整性等資料結構,單個操作並不能反映它真正的效率。
到這裡,這本書的所有東西都已經介紹完了。總的來說,這本書很值得一看(雖然有些地方翻譯得很差)。它的理論性很強,證明過程完整(再複雜的分析它也證明得很清楚,滿足那些刨根問底的人);整本書自成一個體系,前後呼應;習題具有研究性,與課文互相補充。事實上,這些都是國外教材共有的特點。這算是我完整讀過的第一本國外教材,今後我還會讀一些。這幾天在看《組合數學》(仍然是這個出版社出版的),看完後也打算寫一下“對《組合數學》一書中部分內容的形象理解”。讀一本國外教材,你會發現它與國內書籍的不同並會從中獲益更多。
這篇文章就寫到這裡了。號稱是一個5000字縮寫,沒想到寫著寫著已經超過8000字了。而且,這個並不是縮寫,而是一些簡單的、系統的、清晰的、形象化的思想和理解。這篇文章或許對已經知道一些有關知識的人有用,但不適合一點也沒有接觸過資料結構與演算法分析的人。如果有一個人能從中收穫一件東西,我寫這個的目的也就達到了。
(完)
Matrix67