最近整理Github上之前胡亂寫的代碼,發現本身還寫過壓縮算法,大概是不知道何時用來練手的。裏面我實現了哈夫曼樹,LZW字典和算數編碼三種壓縮算法,時隔幾年幾乎沒什麼印象了,尤爲是後兩種連原理都基本忘了,因此把它們拎出來整理一下,也算是逼本身作個回憶。算法
本篇先講LZW算法,Wiki裏給出的介紹和樣例其實還不錯,不過我在網上並無找到不少其它的比較清晰的講解,不少都是貼貼代碼和流程圖了事,因此這裏我用我我的的理解,把LZW的原理再整理一遍。segmentfault
LZW編碼 (Encoding) 的核心思想其實比較簡單,就是把出現過的字符串映射到記號上,這樣就可能用較短的編碼來表示長的字符串,實現壓縮,例如對於字符串:markdown
ABABAB
能夠看到子串AB在後面重復出現了,這樣咱們能夠用一個特殊記號表示AB,例如數字2,這樣原來的字符串就能夠表示爲:編碼
AB22
這裏咱們稱2是字串AB的記號(Symbol)。那麼A和B有沒有記號來表示?固然有,例如咱們規定數字0表示A,數字1表示B。實際上最後獲得的壓縮後的數據應該是一個記號流 (Symbol Stream) :spa
0122
這樣咱們就有一個記號和字符串的映射表,即字典 (Dictionary) :code
Symbol | String |
---|---|
0 | A |
1 | B |
2 | AB |
有了壓縮後的編碼0122,結合字典,就可以很輕鬆地解碼 (Decoding) 原字符串ABABAB。blog
固然在真正的LZW中A和B不會用數字0和1表示,而是它們的ASCII值。實際上LZW初始會有一個默認的字典,包含了全部256個8bit字符,單個字符的記號就是它自身,用數字表示就是ASCII值。在此基礎上,編碼過程當中加入的新的記號的映射,從256開始,稱爲擴展表(Extended Dictionary)。在這個例子裏是爲了簡單起見,只有兩個基礎字符,因此規定0表示A,1表示B,從記號2開始就是擴展項了。圖片
這裏有一個問題:爲何第一個AB不也用2表示?即表示爲222,這樣不又節省了一個記號?這個問題實際上引出的是LZW的一個核心思想,即壓縮後的編碼是自解釋 (self-explaining) 的。什麼意思?即字典是不會被寫進壓縮文件的,在解壓縮的時候,一開始字典裏除了默認的0->A和1->B以外並無其它映射,2->AB是在解壓縮的過程當中一邊加入的。這就要求壓縮後的數據本身能告訴解碼器,完整的字典,例如2->AB是如何生成的,在解碼的過程當中還原出編碼時用的字典。ip
用上面的例子來講明,咱們能夠想象ABABAB編碼的過程:字符串
以上過程只是一個概述,並不是真正LZW編碼過程,只是爲了表示它的思想。能夠看出最前面的A和B是用來生成表項2->AB的,因此它們必須被保留在壓縮編碼裏,做爲表項2->AB生成的「第一現場」。這樣在解碼0122的時候,解碼器首先經過01直接解析出最前面A和B,而且生成表項2->AB,這樣才能將後面出現的2都解析爲AB。實際上解碼器是本身還原出了編碼時2->AB生成的過程。
編碼和解碼都是從前日後步步推動的,同時生成字典,因此解碼的過程也是一個不斷還原編碼字典的過程。解碼器一邊解碼,向後推動,一邊在以前已經解出的原始數據上重現編碼的過程,構建出編碼時用的字典。
下面給出完整的LZW編碼和解碼的過程,結合一個稍微複雜一點的例子,來講明LZW的原理,重點是理解解碼中的每一步是如何對應和還原編碼中的步驟,並恢復編碼字典的。
編碼器從原字符串不斷地讀入新的字符,並試圖將單個字符或字符串編碼爲記號 (Symbol)。這裏咱們維護兩個變量,一個是P (Previous),表示手頭已有的,尚未被編碼的字符串,一個是C (current),表示當前新讀進來的字符。
1. 初始狀態,字典裏只有全部的默認項,例如0->a,1->b,2->c。此時P和C都是空的。 2. 讀入新的字符C,與P合併造成字符串P+C。 3. 在字典裏查找P+C,若是: - P+C在字典裏,P=P+C。 - P+C不在字典裏,將P的記號輸出;在字典中爲P+C創建一個記號映射;更新P=C。 4. 返回步驟2重複,直至讀完原字符串中全部字符。
以上表示的是編碼中間的通常過程,在收尾的時候有一些特殊的處理,即步驟2中,若是到達字符串尾部,沒有新的C讀入了,則將手頭的P對應的記號輸出,結束。
編碼過程的核心就在於第3步,咱們須要理解P到底是什麼。P是當前維護的,能夠被編碼爲記號的子串。注意P是能夠被編碼爲記號,但還並未輸出。新的字符C不斷被讀入並添加到P的尾部,只要P+C仍然能在字典裏找到,就不斷增加更新P=P+C,這樣就能將一個儘量長的字串P編碼爲一個記號,這就是壓縮的實現。當新的P+C沒法在字典裏找到時,咱們沒有辦法,輸出已有的P的編碼記號,併爲新子串P+C創建字典表項。而後新的P從單字符C開始,從新增加,重複上述過程。
這裏用一個例子來講明編碼的過程,之因此用小寫的字符串是爲了和P,C區分。
ababcababac
初始狀態字典裏有三個默認的映射:
Symbol | String |
---|---|
0 | a |
1 | b |
2 | c |
開始編碼:
Step | P | C | P+C | P+C in Dict ? | Action | Output |
---|---|---|---|---|---|---|
1 | - | a | a | Yes | 更新P=a | - |
2 | a | b | ab | No | 添加3->ab,更新P=b | 0 |
3 | b | a | ba | No | 添加4->ba,更新P=a | 1 |
4 | a | b | ab | Yes | 更新P=ab | - |
5 | ab | c | abc | No | 添加5->abc,更新P=c | 3 |
6 | c | a | ca | No | 添加6->ca,更新P=a | 2 |
7 | a | b | ab | Yes | 更新P=ab | - |
8 | ab | a | aba | No | 添加7->aba,更新P=a | 3 |
9 | a | b | ab | Yes | 更新P=ab | - |
10 | ab | a | aba | Yes | 更新P=aba | - |
11 | aba | c | abac | No | 添加8->abac,更新P=c | 7 |
12 | c | - | - | - | - | 2 |
注意編碼過程當中的第3-4步,第7-8步以及8-10步,子串P發生了增加,直到新的P+C沒法在字典中找到,則將當前的P輸出,P則更新爲單字符C,從新開始增加。
輸出的結果爲0132372,完整的字典爲:
Symbol | String |
---|---|
0 | a |
1 | b |
2 | c |
3 | ab |
4 | ba |
5 | abc |
6 | ca |
7 | aba |
8 | abac |
這裏用一個圖來展現原字符串是如何對應到壓縮後的編碼的:
--
解碼的過程比編碼複雜,其核心思想在於解碼須要還原出編碼時的用的字典。所以要理解解碼的原理,必須分析它是如何對應編碼的過程的。下面首先給出算法:
解碼器的輸入是壓縮後的數據,即記號流 (Symbol Stream)。相似於編碼,咱們仍然維護兩個變量pW (previous word) 和cW (current word),後綴W的含義是word,實際上就是記號 (Symbol),一個記號就表明一個word,或者說子串。pW表示以前剛剛解碼的記號;cW表示當前新讀進來的記號。
注意cW和pW都是記號,咱們用Str(cW)和Str(pW)表示它們解碼出來的原字符串。
1. 初始狀態,字典裏只有全部的默認項,例如0->a,1->b,2->c。此時pW和cW都是空的。 2. 讀入第一個的符號cW,解碼輸出。注意第一個cW確定是能直接解碼的,並且必定是單個字符。 3. 賦值pW=cW。 4. 讀入下一個符號cW。 5. 在字典裏查找cW,若是: a. cW在字典裏: (1) 解碼cW,即輸出 Str(cW)。 (2) 令P=Str(pW),C=Str(cW)的**第一個字符**。 (3) 在字典中爲P+C添加新的記號映射。 b. cW不在字典裏: (1) 令P=Str(pW),C=Str(pW)的**第一個字符**。 (2) 在字典中爲P+C添加新的記號映射,這個新的記號必定就是cW。 (3) 輸出P+C。 6. 返回步驟3重複,直至讀完全部記號。
顯然,最重要的是第5步,也是最難理解的。在這一步中解碼器不斷地在已經破譯出來的數據上,模擬編碼的過程,還原出字典。咱們仍是結合以前的例子來講明,咱們須要從記號流
0 1 3 2 3 7 2
解碼出:
a b ab c ab aba c
這裏我用空格表示出了記號是如何依次對應解碼出來的子串的,固然在解碼開始時咱們根本不知道這些,咱們手裏的字典只有默認項,即:
Symbol | String |
---|---|
0 | a |
1 | b |
2 | c |
解碼開始:
首先讀取第一個記號cW=0,解碼爲a,輸出,賦值pW=cW=0。而後開始循環,依此讀取後面的記號:
Step | pW | cW | cW in Dict ? | Action | Output |
---|---|---|---|---|---|
1 | 0 | 1 | Yes | P=a,C=b,P+C=ab,添加3->ab | b |
2 | 1 | 3 | Yes | P=b,C=a,P+C=ba,添加4->ba | ab |
3 | 3 | 2 | Yes | P=ab,C=c,P+C=abc,添加5->abc | c |
好,先解碼到這裏,咱們已經解出了前5個字符 a b ab c。一步一步走下來咱們能夠看出解碼的思想。首先直接解碼最前面的a和b,而後生成了3->ab這一映射,也就是說解碼器利用前面已經解出的字符,如實還原了編碼過程當中字典的生成。這也是爲何第一個a和b必須保留下來,而不能直接用3來編碼,由於解碼器一開始根本不知道3表示ab。而第二個以及之後的ab就能夠用記號3破譯出來,由於此時咱們已經創建了3->ab的關係。
仔細觀察添加新映射的過程,就能夠看出它是如何還原編碼過程的。解碼步驟5.a中,P=Str(pW),C=Str(cW)的第一個字符,咱們能夠用下圖來講明:
注意P+C構成的方式,取前一個符號pW,加上當前最新符號cW的第一個字符。這正好對應了編碼過程當中遇到P+C不在字典中的狀況:將P編碼爲pW輸出,並更新P=C,P從單字符C開始從新增加。
到目前爲止,咱們只用到瞭解碼步驟5.a的狀況,即每次新讀入的cW都能在字典裏找到,只有這樣咱們才能直接解碼cW輸出,並拿到cW的第一個字符C,與P組成P+C。但實際上還有一種可能就是5.b中的cW不在字典裏。爲何cW會不在字典裏?回到例子,咱們此時已經解出了5個字符,繼續往下走:
Step | pW | cW | cW in Dict ? | Action | Output |
---|---|---|---|---|---|
4 | 2 | 3 | Yes | P=c,C=a,P+C=ca,添加6->ca | ab |
5 | 3 | 7 | No | P=ab,C=a,P+C=aba,添加7->aba | aba |
6 | 7 | 2 | Yes | P=aba,C=c,P+C=abac,添加8->abac | c |
好到此爲止,後面的 ab aba c 也解碼出來了,解碼過程結束。這裏最重要的就是Step-5,新讀入一個cW爲7,可7此時並不在字典裏。固然咱們事實上知道7最終應該對應aba,但是解碼器應該如何反推出來?
爲何解碼進行到這一步7->aba尚未被編入字典?由於解碼比編碼有一步的延遲,實際上aba正是由當前的P=ab,和那個還未知的cw=7的第一個字符C組成的,因此cW映射的就是這個即將新加入的子串P+C,也所以cW的第一個字符就是pW的第一個字符a,cW就是aba。
咱們看到解碼器在這裏作了一個推理,既然cW到目前爲止尚未被加入字典,可解碼卻恰恰遇到了,說明cW的映射並非很早以前加入的,而是就在當前這一步。對應到編碼的過程,就是新的cW映射,即7->aba剛被寫進字典,緊接着後面的一個字串就用到了它。讀者能夠對照後半部分 ab aba c 編碼的過程,對比解碼過程反推,理解它的原理。這也是解碼算法中最難的部分。
好了,LZW的編碼和解碼過程到此就講解完畢了。其實它的思想自己是簡單的,就是將原始數據中的子串用記號表示,相似於編一部字典。編碼過程當中如何切割子串,創建映射的方式,其實並非惟一的,可是LZW算法的嚴格之處在於,它提供了一種方式,使得壓縮後的編碼可以惟一地反推出編碼過程當中創建的字典,從而沒必要將字典自己寫入壓縮文件。試想,若是字典也須要寫入壓縮文件,那它佔據的體積自己就會很大,可能到最後起不到壓縮的效果。下一章我會講解另外一種壓縮算法,算數編碼。