數據結構與算法之“貪心算法”

  • 2020-6-27
    有志者,事竟成,破釜沉舟,百二秦關終屬楚;苦心人天不負,臥薪嚐膽,三千越甲可吞吳。

一、概述

貪心算法,由名稱就知道,每一步的決策都選擇當前最優的方案(每一步的貪心),期望最後達成的結果是全局最優的方案。

所以,貪心算法適於求解的場景:局部最優的累計會達到全局最優。

貪心算法的最難的一塊是如何將要解決的問題抽象成貪心算法模型,只要這一步搞定之後,貪心算法的編碼一般都很簡單。

貪心算法有很多經典的應用,比如霍夫曼編碼(Huffman Coding)、Prim 和 Kruskal 最小生成樹算法、還有 Dijkstra 單源最短路徑算法。

二、貪心算法解決問題的步驟

第一步,當我們看到這類問題的時候,首先要聯想到貪心算法:針對一組數據,我們定義了限制值和期望值,希望從中選出幾個數據,在滿足限制值的情況下,期望值最大。

第二步,我們嘗試看下這個問題是否可以用貪心算法解決:每次選擇當前情況下,在對限制值同等貢獻量的情況下,對期望值貢獻最大的數據。

第三步,我們舉幾個例子看下貪心算法產生的結果是否是最優的。大部分情況下,舉幾個例子驗證一下就可以了。嚴格地證明貪心算法的正確性,是非常複雜的,需要涉及比較多的數學推理。而且,從實踐的角度來說,大部分能用貪心算法解決的問題,貪心算法的正確性都是顯而易見的,也不需要嚴格的數學推導證明。

實際上,用貪心算法解決問題的思路,並不總能給出最優解。

我來舉一個例子。在一個有權圖中,我們從頂點 S 開始,找一條到頂點 T 的最短路徑(路徑中邊的權值和最小)。貪心算法的解決思路是,每次都選擇一條跟當前頂點相連的權最小的邊,直到找到頂點 T。按照這種思路,我們求出的最短路徑是 S->A->E->T,路徑長度是 1+4+4=9。
在這裏插入圖片描述
但是,這種貪心的選擇方式,最終求的路徑並不是最短路徑,因爲路徑 S->B->D->T 纔是最短路徑,因爲這條路徑的長度是 2+2+2=6。爲什麼貪心算法在這個問題上不工作了呢?

在這個問題上,貪心算法不工作的主要原因是,前面的選擇,會影響後面的選擇。如果我們第一步從頂點 S 走到頂點 A,那接下來面對的頂點和邊,跟第一步從頂點 S 走到頂點 B,是完全不同的。所以,即便我們第一步選擇最優的走法(邊最短),但有可能因爲這一步選擇,導致後面每一步的選擇都很糟糕,最終也就無緣全局最優解了。

三、貪心算法實戰分析

3.1、分糖果

我們有 m 個糖果和 n 個孩子。我們現在要把糖果分給這些孩子喫,但是糖果少,孩子多(m<n),所以糖果只能分配給一部分孩子。

每個糖果的大小不等,這 m 個糖果的大小分別是 s1,s2,s3,……,sm。除此之外,每個孩子對糖果大小的需求也是不一樣的,只有糖果的大小大於等於孩子的對糖果大小的需求的時候,孩子纔得到滿足。假設這 n 個孩子對糖果大小的需求分別是 g1,g2,g3,……,gn。

我的問題是,如何分配糖果,能儘可能滿足最多數量的孩子?

我們可以把這個問題抽象成,從 n 個孩子中,抽取一部分孩子分配糖果,讓滿足的孩子的個數(期望值)是最大的。這個問題的限制值就是糖果個數 m。

我們現在來看看如何用貪心算法來解決。對於一個孩子來說,如果小的糖果可以滿足,我們就沒必要用更大的糖果,這樣更大的就可以留給其他對糖果大小需求更大的孩子。另一方面,對糖果大小需求小的孩子更容易被滿足,所以,我們可以從需求小的孩子開始分配糖果。因爲滿足一個需求大的孩子跟滿足一個需求小的孩子,對我們期望值的貢獻是一樣的。

我們每次從剩下的孩子中,找出對糖果大小需求最小的,然後發給他剩下的糖果中能滿足他的最小的糖果,這樣得到的分配方案,也就是滿足的孩子個數最多的方案。

3.2、錢幣找零

這個問題在我們的日常生活中更加普遍。假設我們有 1 元、2 元、5 元、10 元、20 元、50 元、100 元這些面額的紙幣,它們的張數分別是 c1、c2、c5、c10、c20、c50、c100。我們現在要用這些錢來支付 K 元,最少要用多少張紙幣呢?

在生活中,我們肯定是先用面值最大的來支付,如果不夠,就繼續用更小一點面值的,以此類推,最後剩下的用 1 元來補齊。

在貢獻相同期望值(紙幣數目)的情況下,我們希望多貢獻點金額,這樣就可以讓紙幣數更少,這就是一種貪心算法的解決思路。直覺告訴我們,這種處理方法就是最好的。實際上,要嚴謹地證明這種貪心算法的正確性,需要比較複雜的、有技巧的數學推導,我不建議你花太多時間在上面,不過如果感興趣的話,可以自己去研究下。

3.3、區間覆蓋

假設我們有 n 個區間,區間的起始端點和結束端點分別是 [l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我們從這 n 個區間中選出一部分區間,這部分區間滿足兩兩不相交(端點相交的情況不算相交),最多能選出多少個區間呢?
在這裏插入圖片描述
這個問題的處理思路稍微不是那麼好懂,不過,我建議你最好能弄懂,因爲這個處理思想在很多貪心算法問題中都有用到,比如任務調度、教師排課等等問題。

這個問題的解決思路是這樣的:我們假設這 n 個區間中最左端點是 lmin,最右端點是 rmax。這個問題就相當於,我們選擇幾個不相交的區間,從左到右將 [lmin, rmax] 覆蓋上。我們按照起始端點從小到大的順序對這 n 個區間排序。

我們每次選擇的時候,左端點跟前面的已經覆蓋的區間不重合的,右端點又儘量小的,這樣可以讓剩下的未覆蓋區間儘可能的大,就可以放置更多的區間。這實際上就是一種貪心的選擇方法。
在這裏插入圖片描述

3.4、實現霍夫曼編碼

假設我有一個包含 1000 個字符的文件,每個字符佔 1 個 byte(1byte=8bits),存儲這 1000 個字符就一共需要 8000bits,那有沒有更加節省空間的存儲方式呢?

假設我們通過統計分析發現,這 1000 個字符中只包含 6 種不同字符,假設它們分別是 a、b、c、d、e、f。而 3 個二進制位(bit)就可以表示 8 個不同的字符,所以,爲了儘量減少存儲空間,每個字符我們用 3 個二進制位來表示。那存儲這 1000 個字符只需要 3000bits 就可以了,比原來的存儲方式節省了很多空間。不過,還有沒有更加節省空間的存儲方式呢?

霍夫曼編碼就要登場了。霍夫曼編碼是一種十分有效的編碼方法,廣泛用於數據壓縮中,其壓縮率通常在 20%~90% 之間。

霍夫曼編碼不僅會考察文本中有多少個不同字符,還會考察每個字符出現的頻率,根據頻率的不同,選擇不同長度的編碼。霍夫曼編碼試圖用這種不等長的編碼方法,來進一步增加壓縮的效率。如何給不同頻率的字符選擇不同長度的編碼呢?根據貪心的思想,我們可以把出現頻率比較多的字符,用稍微短一些的編碼;出現頻率比較少的字符,用稍微長一些的編碼。

對於等長的編碼來說,我們解壓縮起來很簡單。比如剛纔那個例子中,我們用 3 個 bit 表示一個字符。在解壓縮的時候,我們每次從文本中讀取 3 位二進制碼,然後翻譯成對應的字符。但是,霍夫曼編碼是不等長的,每次應該讀取 1 位還是 2 位、3 位等等來解壓縮呢?這個問題就導致霍夫曼編碼解壓縮起來比較複雜。爲了避免解壓縮過程中的歧義,霍夫曼編碼要求各個字符的編碼之間,不會出現某個編碼是另一個編碼前綴的情況。

在這裏插入圖片描述
假設這 6 個字符出現的頻率從高到低依次是 a、b、c、d、e、f。我們把它們編碼下面這個樣子,任何一個字符的編碼都不是另一個的前綴,在解壓縮的時候,我們每次會讀取儘可能長的可解壓的二進制串,所以在解壓縮的時候也不會歧義。經過這種編碼壓縮之後,這 1000 個字符只需要 2100bits 就可以了。
在這裏插入圖片描述
儘管霍夫曼編碼的思想並不難理解,但是如何根據字符出現頻率的不同,給不同的字符進行不同長度的編碼呢?這裏的處理稍微有些技巧。

我們把每個字符看作一個節點,並且輔帶着把頻率放到優先級隊列中。我們從隊列中取出頻率最小的兩個節點 A、B,然後新建一個節點 C,把頻率設置爲兩個節點的頻率之和,並把這個新節點 C 作爲節點 A、B 的父節點。最後再把 C 節點放入到優先級隊列中。重複這個過程,直到隊列中沒有數據。
在這裏插入圖片描述
現在,我們給每一條邊加上畫一個權值,指向左子節點的邊我們統統標記爲 0,指向右子節點的邊,我們統統標記爲 1,那從根節點到葉節點的路徑就是葉節點對應字符的霍夫曼編碼。
在這裏插入圖片描述

四、參考資料

  • 王爭 – 《極客時間|數據結構與算法之美》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章