貪心演算法——Huffman 壓縮編碼的實現

seniusen發表於2018-12-17

1. 如何理解 “貪心演算法”

假設我們有一個可以容納 100 Kg 物品的揹包,可以裝各種物品。我們有以下 5 種豆子,每種豆子的總量和總價值都各不相同。怎樣裝才能讓揹包裡豆子的總價值最大呢?

這個問題其實很簡單,我們只需要計算出每種豆子的單價,然後按照單價從高到低依次來裝就好了。單價從高到低排列為:黑豆、綠豆、紅豆、青豆和黃豆,因此我們往揹包裡裝 20 Kg 黑豆、30 Kg 綠豆和 50 Kg 紅豆。

實質上,這就是貪心演算法的思想,用貪心演算法解決問題的步驟一般是這樣的。

第一步,當我們看到這類問題的時候,首先要聯想到貪心演算法。針對一組資料,我們定義了限制值和期望值,希望選出幾個資料,在滿足限制值的情況下,期望值最大。在剛才的問題中,限制值就是重量不超過 100 Kg,期望值就是豆子總價值。

第二步,我們嘗試看下這個問題是否可以用貪心演算法解決。每次選擇當前情況下,在對限制值同等貢獻的情況下,對期望值貢獻最大的資料。上例中,就是選取單價最高的豆子,也就是重量相等情況下對總價值貢獻最大的豆子。

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

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

在下面的有權圖中,我們需要找到一條從頂點 S 出發到頂點 T 的最短路徑,使得路徑中邊的權重和最小。貪心演算法的思路是每次選擇一條和當前頂點連線的權重最小的邊,最終答案是 S->A->E->T,權重和為 9。

但是,最優解實際上是 S->B->D->T,權重和為 6。在這個問題上,貪心演算法不工作的原因主要是,前面的選擇會影響後面的選擇。一旦第一步選擇了頂點 S 到頂點 A,第二步我們就和頂點 B、C 無關了。即使第一步最優,但是若因為這個選擇後面的選擇都很糟糕,那總體上也就不會取得最優解了。

2. 貪心演算法實戰分析

2.1. 分糖果

假設我們有 m 個糖果要分給 n 個孩子,因為糖果少孩子多(m<n),所以糖果只能分給一部分孩子。每個糖果的大小不等,每個孩子對糖果的需求也不同,只有糖果的大小大於等於孩子對糖果的需求時,孩子才能得到滿足。如何分配糖果,才能儘可能地滿足最多數量的孩子呢?

對於一個孩子來說,如果小的糖果可以滿足,我們就沒必要用更大的糖果,這樣更大的糖果就可以用來滿足需求更大的孩子。另一方面,對糖果需求小的孩子更容易滿足,因此我們可以從需求小的孩子開始分配糖果,因為滿足一個需求小的孩子和滿足一個需求大的孩子,對我們結果的貢獻是一樣的。

所以,我們就可以從剩下的孩子中,找出一個需求最小的,然後發給他剩餘糖果中能滿足他需求的最小的糖果。這樣的分配方案,最後就能滿足最多數量的孩子。

2.2. 錢幣找零

假設我們有面值分別為 1 元、2 元、5 元、10 元、20 元、50 元、100 元的鈔票若干,現在要用這些錢來支付 K 元,最少要用多少張紙幣呢?

在生活中,我們肯定首先用面值最大的來支付,如果不夠,我們繼續用更小一點面值的,以此類推,直到最後滿足為止。在貢獻相同期望值(紙幣數量)的情況下,我們肯定希望多貢獻點金額,這樣就可以讓紙幣數更少,這就是一種貪心的思想。

2.3. 區間覆蓋

假設我們有 n 個區間,區間的起始端點分別為 [l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我們從這 n 個區間中選出一部分割槽間,這部分割槽間滿足兩兩不相交(端點相交不算),最多能選出多少個區間呢。

這個問題的解決思路是這樣的,假設這 n 個區間的最左端點是 lmin,最右端點是 rmax。那麼這個問題就相當於,我們選擇幾個不相交的區間,從左到右將 [lmin, rmax] 覆蓋上。我們按照起始端點從小到大的順序對這 n 個區間進行排序,每次選擇的時候,左端點和前面已經覆蓋的區間不重合而右端點又儘量小的區間,就能讓剩下的未覆蓋區間儘量大,從而就可以放置更多的區間。

這實際上就是一種貪心的選擇方法,而且這種處理思想在任務排程、教師排課等問題中都有用到。

3. Huffman 壓縮編碼

假設有一個包含 1000 個字元的檔案,每個字元佔 1 個位元組 8 位,那麼儲存這個檔案就需要 8000 bits。如果通過統計分析我們發現這 1000 個字元只包含 6 個不同的字元,假定它們為 a, b, c, d, e, f。那我們只用 3 個二進位制位就可以表示 8 個不同的字元,所以儲存 1000 個字元就只需要 3000 bits 了,比原來省了很多空間。那還有沒有更加節省空間的儲存方式呢?

霍夫曼編碼就要登場了。霍夫曼編碼是一種非常有效的編碼方式,廣泛用於資料壓縮中,其壓縮率通常在 20% – 90% 之間。霍夫曼編碼不僅會考察文字中有多少個不同字元,還會考察每個字元出現的頻率,根據頻率的不同,選擇不同長度的編碼。根據貪心的思想,我們可以把出現頻率比較多的字元,用稍微短一點的編碼,而對出現頻率比較少的字元用稍微長一些的編碼。

對於等長的編碼來說,我們解壓縮起來很簡單,每次從文字中讀取固定長度的二進位制碼,然後翻譯成對應字元即可。但是,霍夫曼編碼是不等長的,我們每次應該讀取多少位的二進位制來進行解碼呢?為了避免解碼過程出現歧義,霍夫曼編碼要求各個字元的編碼之間,不會出現某個編碼是另一個編碼字首的情況。

假設這 6 個字元出現的頻率從高到低依次是:a、b、c、d、e、f,我們就把它編碼成下面這個樣子,任何一個字元的編碼都不是另一個的字首。在解壓縮的時候,我們每次會讀取儘可能長的可解碼的二進位制串,所以也不會出現歧義。這種編碼方式,儲存 1000 個字元就只需要 2100 bits 了.

那霍夫曼編碼是如何根據字元出現頻率的不同,給不同的字元進行不同長度的編碼的呢?

我們把每個字元看作一個節點,並且附帶著把頻率放到優先順序佇列中。然後,從佇列中取出頻率最小的兩個子節點 A、B,新建一個節點 C,使其頻率為 A、B 兩個節點的頻率之和,並把這個新節點 C 作為 A、B 節點的父節點。最後,再把 C 節點放入到優先順序佇列中,重複這個過程,直到佇列中沒有資料為止。

現在,我們給每一條邊畫一個權值,指向左子節點的邊統統標記為 0,指向右子節點的邊統統標記為 1,那麼從根節點到葉節點的路徑就是葉節點對應字元的霍夫曼編碼。

參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!

相關文章