演算法導論
這個文件是學習“演算法設計與分析”課程時做的筆記,文件中包含的內容包括課堂上的一些比較重要的知識、例題以及課後作業的題解。主要的參考資料是 Introduction to algorithms-3rd(Thomas H.)(對應的中文版《演算法導論第三版》),除了這本書,還有的參考資料就是 Algorithms design techniques and analysis (M.H. Alsuwaiyel)。
貪心法
與動態規劃一樣,貪心法通常用於解決最優解的問題。但是與動態規劃不同的是,貪心法通常由試圖找到區域性最優解的迭代過程組成。
貪心法與動態規劃的另一個區別在於,貪心法是迭代的,而動態規劃是遞迴的。
貪心法得到的解並不總是全域性的最優解。
貪心法得到的解決方案通常是一步一步的(step by step),每一步都是在區域性範圍內在很少計算的基礎上作出正確的猜測,而不用擔心未來。
能夠使用貪心法解決的問題通常具備兩種性質:
- 最優子結構
- 貪心選擇性
其中最優子結構性質和動態規劃問題中的最優子結構性質是一樣的。
而貪心選擇性則是指一個問題的全域性最優解可以透過區域性最優解得到,或者說透過貪心法得到的解決方案是正確的,而這部分的證明通常是設計貪心演算法最難的部分。
下面介紹一個簡單的例子,揹包問題。
假設現在有 n 件物品,每件物品的大小為 \(s_1,...,s_n\) 每個物品的價值為 \(v_1, ..., v_n\),現在有一個容量為 C 的揹包來裝這些物品,如何選擇使得在揹包容量的限制下裝取的物品的價值總和最大?
這個問題使用貪心法就很好解決,先計算物品的價效比,\(r_i = v_i / s_i\),然後將所有物品按照價效比由高到低排序,然後按照這個排序進行選擇,直到揹包容量已滿。
上面的這個方法就體現了貪心演算法的區域性最優性,迭代進行,每次都選擇當前剩餘物品中價效比最高的物品。
Minimum Cost Spanning Trees(Kruskal)
貪心法一個比較經典的使用場景是最小生成樹。
最小生成樹的定義:設 G = (V, E) 是一個無向連通圖(connected undirected graph),並且每條邊都有其權重。G 的一個生成樹 (V, T) 是 G 的一個子圖,並且是一個樹,也就是說沒有迴路。如果 T 中每條邊的權重之和是最小的,那麼就稱 (V, T) 是最小代價生成樹(Minimum Cost Spanning Trees),或者最小生成樹(Minimum Spanning Trees)。
Kruskal 的方法原理是,維護一個由幾個生成樹組成的森林,然後將森林中的生成樹逐漸合併,直到將所有生成樹合併成一個樹,這個樹就是最小生成樹。
演算法的具體步驟分為兩步:
- 將 G 中所有的邊按遞增的順序排序;
- 迭代選擇圖中的邊,每次都選擇最短的一條邊:對於有序列表中的每一條邊,如果這條邊沒有與 T 中的邊構成迴路,就將其放入 T 中;否則就丟棄這條邊。
下面給出一個列子:
實現
Kruskal 的最小生成樹演算法的實現需要使用並查集(Disjoint Set) 來判斷當前的樹中是否包含迴路。
並查集相當於是一個森林,通常由陣列實現,disjoint_set[i]
表示元素 i 的父節點,如果父節點是其本身,那麼就意味著這個節點是其所在的樹的根節點。初始階段,每個節點的父節點都是其本身,然後透過合併而形成一個樹。
並查集常用的有兩種操作:查,找到目標元素所在的樹的根節點;並,將兩個兩個不同的樹合併成一個樹。
Kruskal's algorithm:
上圖給出了該演算法實現的虛擬碼,其中的 MAKESET() 是指初始化一個並查集。
正確性
下面證明 Kruskal 演算法的正確性。
需要使用到的定理:
If T is a tree with n vertices, then
(a) Any two vertices of T are connected by a unique path.
(b) T has exactly n − 1 edges.
(c) The addition of one more edge to T creates a cycle.
使用數學歸納法求證,對最小生成樹所包含的邊的集合 T 的大小進行歸納:
貪心問題的貪心選擇性的證明常用數學歸納法求證。
最初,T = { } ,那麼 T 顯然是 T* 的一個子集。其中 T* 是最小生成樹 G* = (V, T*) 中的邊的集合。
假設 \(T\subset T^*\)。隨後,在使用Kruskal演算法新增一條邊 e = (x, y) 到 T 中之前,令 X 為包含節點 x 的子樹節點的集合。
令 \(T' = T\cup \{e\}\),可以證明 T' 依然是 T* 的一個子集:
i. 如果 \(e\in T^*\),那麼 \(T'\subset T^*\) 顯然成立。
ii. 如果 \(e\notin T^*\),則:
根據上面的定理,\(T^* \cup \{ e\}\) 中會包含一個迴路,而 e 正是這個迴路中的一條邊。而 e = (x, y) 連線了 X 中的一個節點和 V-X 中的一個節點。那麼 T* 中也必然存在一條邊 e' = (w, z) ,其中 \(w\in X, z \in V-X\),才能在 T* 中形成迴路。
那麼有 \(cost(e') \ge cost(e)\),否則根據Kruskal演算法 e' 會先於 e 加入 T。現在構造 \(T^{**} = (T^* - \{e'\}) \cup \{e\}\),那麼 \(T'\subset T^{**}\)。
此外,\(T^{**}\) 也是包含最小生成樹中所有邊的集合,因為 e 是連線 X 中的節點與連線 V - X 中的節點的最短路徑。
即 \(T\cup \{e\}\) 依然是最小生成樹中所有邊的集合的子集。
那麼根據歸納法,透過Kruskal演算法新增邊得到的集合一定都是最小生成樹所有邊的集合的子集,直到這個集合的邊能夠連通 V 中的所有節點,得到最小生成樹。
Minimum Cost Spanning Trees(Prim)
解決最小生成樹問題還有另一個演算法——Prim's algorithm。這個演算法與Kruskal的演算法完全不同,但同樣也是使用貪心的策略進行決策。
Prim的演算法以圖中的任意一點為起點來生成最小生成樹。令 G=(V, E) 為一個連通無向圖,將 V 中節點編號為 {1, ..., n},初始時,令 X = {1}, Y = {2, ..., n},演算法依然是迭代進行,每次迭代新增一條邊,而這條邊是節點 \(x\in X, y\in Y\) 的最短路徑 (x, y)。新增這條邊到最小生成樹中所有邊的集合 T 中,並且將節點 y 從集合 Y 中移除並加入到集合 X。不斷迭代,直到集合 Y 為空集,得到最小生成樹。
步驟如下:
- \(T\gets \{\}, X\gets \{1\}, Y\gets V - \{1\}\)
- 令 (x,y) 為集合 X 到集合 Y 中節點的最短路徑,則 \(X\gets X\cup \{y\}, Y\gets Y-\{y\}, T\gets T\cup \{(x,t)\}\)
- 重複步驟2,直到 \(Y=\{\}\)
下面給出一個例子:
實現
Prim的演算法使用鄰接矩陣來實現,即矩陣 c[i,j]
表示邊 (i, j) 的長度。然後使用 boolean
型別的一維陣列 X[n], Y[n]
來表示集合 X, Y:
上圖為Prim演算法的虛擬碼。
正確性
下面證明Prim演算法的正確性。
同樣的,使用歸納法進行證明。對集合 T 的大小進行歸納,可以證明 (X, Y) 是圖 G 最小生成樹的一個子樹。
起初,T = { },上面的結論顯然成立。
假設上面的結論在新增一條邊 e = (x, y) 到集合 T 之前都是成立的,即 (X, T) 是最小生成樹的一個子樹,其中 \(x\in X, y\in Y\)。令 \(X' = X\cup \{y\}, T' = T\cup \{e\}\),那麼可以證明,{X', T'} 依然是圖 G 最小生成樹的一個子樹。
首先證明 (X', T') 是一個樹。因為 \(y\notin X\),並且 (X, T) 本身是一個樹,所以邊 e = (x, y) 不會在 T' 中構成迴路,即 (X', T') 是一個樹。
現在證明 \(T' = T\cup \{e\}\) 是 G 的最小生成樹的一個子樹。設圖 G 的最小生成樹為 G* = (V, T*)。
i. 若 \(e\in T^*\) ,那麼結論顯然成立。
ii. 若 \(e\notin T^*\),那麼根據前面的定理,\(T^*\cup \{e\}\) 必然包含一個迴路。
這也就意味著 T* 中包含一條邊 \(e' = (w, z), w\in X, z\in Y\)。
那麼根據 Prim 演算法,只有\(cost(e') \le cost(e)\) 才能夠使 \(e'\in T^*\),而這與 e 是集合 X 與集合 Y 節點間最短路徑的前提矛盾。所以必然有 \(e\in T^*\)。
因此 \(T' = T\cup \{e\} \subset T^*\)
綜上,使用Prim演算法得到的集合 (X, T) 是圖 G 最小生成樹的一個子樹,直到 Y = {} 時,得到最小生成樹。
Huffman編碼
貪心法的另一個比較經典的使用案例是用於檔案壓縮的Huffman編碼。
假設現在有一個檔案,由字串組成。現在希望儘可能多地壓縮這個檔案,並且能夠輕易還原。假設檔案中的字符集合為 \(C = \{c_1, ..., c_n\}\),令 \(f(c_i)\) 為字元 \(c_i\) 在檔案中出現的頻率,也就是出現的次數。
以固定長度的位元串來表示每個字元,也就是字元編碼,那麼檔案的大小就取決於檔案包含的字元總數。
然而,為了減小檔案的大小,因為檔案中某些字元出現的頻率會遠高於其他字元,所以可以考慮使用變長(variable-length)的編碼方式,並且為出現次數比較高的字元分配更短的位元進行編碼。
在變長編碼中,需要注意某個字元的編碼一定不能是另一個字元編碼的字首,這樣的編碼方式稱為字首編碼(prefix codes)。比如,如果對字元 'a' 的編碼為 "10", 對字元 'b' 的編碼為 "101" 那麼在掃描檔案的時候就會出現二義性,也就是說 "10" 應該解釋為字元 'a' 還是字元 'b' 的字首呢?
如果編碼方式滿足了這樣的限制,那麼掃描檔案時可以用下面的方式進行正確解碼。使用一個完全二叉樹,每個節點連線子節點的兩條邊分別標記為0,1,樹的葉子節點就表示一個字元,而從根節點到葉子節點的路徑上的01序列就是該字元的編碼。
透過Huffman演算法,能夠構造出滿足非字首編碼的編碼方式,並且能夠將檔案的大小壓縮到最小。
Huffman演算法也是迭代進行的,令 C 表示所有字元的集合,選擇出現頻率最低的兩個字元 \(c_i, c_j\),創造一個新的節點 c 作為這個節點父節點,並且令 c 的出現頻率為這兩個子節點出現頻率之和。然後從集合 C 中移除節點 \(c_i, c_j\),並加入節點 c。重複這樣的步驟,直到集合 C 只包含一個根節點。
上圖為Huffman演算法的虛擬碼。
下面看一個例子,假設一個檔案由字元 'a', 'b', 'c', 'd', 'e' 組成,並且假設每個字元出現的頻率分別為:f(a) = 20, f(b) = 7, f(c) = 10, f(d) = 4, f(e) = 18,找到一種編碼方式使檔案的大小壓縮到最小。
使用Huffman演算法可以構造出如下的完全二叉樹:
從圖中可以看出每個字元的編碼:e(a) = 00, e(b) = 111, e(c) = 10, e(d) = 110, e(e) = 01,這樣的變長編碼能夠滿足非字首編碼限制,所以也不存在二義性,能夠被正確解碼。
如果使用固定長度的編碼方式,那麼每個字元至少需要3位元才能編碼,那麼檔案的長度也就是 3*(20 + 7 + 10 + 4 + 18) = 177 bits;如果使用這裡得到的編碼方式進行編碼,那麼檔案的長度就是 2 * 20 + 3 * 7 + 2 * 10 + 3 * 4 + 2 * 18 = 129 bits,這樣可以將檔案壓縮27%。