本文將介紹哈夫曼壓縮算法(Huffman compression)。
1. 前文回顧
在字符串算法—字符串排序(上篇)和字符串算法—字符串排序(下篇)中,我們講述了字符串的排序方法;
在字符串算法—字典樹中,我們講述了如何在一堆字符串中尋找某個字符串的方法;
在字符串算法—字符串搜索和字符串算法—正則表達式中,我們講述了如何在一堆字符(如文章)中尋找某個特定的或符合某個規律的字符串的方法。
著名的壓縮算法有很多,這里將介紹兩個:哈夫曼壓縮算法(Huffman compression)和LZW壓縮算法(LZW compression)。
而本文將先講述哈夫曼壓縮算法(Huffman compression)。
2. 為什么要進行數據壓縮
在這個每天都會誕生大量數據的時代,數據壓縮扮演着重要的角色,如數據傳輸,傳輸壓縮過的數據肯定會比原數據快。
數據壓縮的重要性大家都懂,這里不多說,直接介紹如何進行數據壓縮。
本文介紹的壓縮算法是無損壓縮,保證壓縮解壓后,數據無丟失。
3. 哈夫曼壓縮算法(Huffman compression)
眾所周知,計算機存儲數據時,實際上存儲的是一堆0和1(二進制)。
如果我們存儲一段字符:ABRACADABRA!
那么計算機會把它們逐一翻譯成二進制,如A:01000001;B: 01000010; !: 00001010.
每個字符占8個bits, 這一整段字符則至少占12*8=96 bits。
但如果我們用一些特殊的值來代表這些字符,如:

圖中,0代表A; 1111代表B;等等。此時,存儲這段字符只需30bits,比96bits小多了,達到了壓縮的目的。
我們需要這么一個表格來把原數據翻譯成特別的、占空間較少的數據。同時,我們也可以用這個表格,把特別的數據還原成原數據。
首先,為了避免翻譯歧義,這個表格需滿足一個條件:任何一個字符用的值都不能是其它字符的前綴。
我們舉個反例:A: 0; B: 01;這里,A的值是B的值的前綴。如果壓縮后的數據為01xxxxxx,x為0或者1,那么這個數據應該翻譯成A1xxxxxx, 還是Bxxxxxxx?這樣就會造成歧義。
然后,不同的表格會有不同的壓縮效果,如:

這個表格的壓縮效果更好。
那么我們如何找到最好的表格呢?這個我們稍后再講。
為了方便閱讀,這個表格是可以寫成一棵樹的:

這棵樹的節點左邊是0,右邊是1。任何含有字符的節點都沒有非空子節點。(即上文提及的前綴問題。)
這棵樹是在壓縮的過程中建成的,這個表格是在樹形成后建成的。用這個表格,我們可以很簡單地把一段字符變成壓縮后的數據,如:
原數據:ABRACADABRA!
表格如上圖。
令壓縮后的數據為S;
第一個字符是A,根據表格,A:11,故S=11;
第二個字符是B,根據表格,B:00,故S=1100;
第三個字符是R,根據表格,R:011,故S=1100011;
如此類推,讀完所有字符為止。
壓縮搞定了,那解壓呢?很簡單,跟着這棵樹讀就行了:

壓縮后的數據S=11000111101011100110001111101
記住,讀到1時,往右走,讀到0時,往左走。
令解壓后的字符串為D;
從根節點出發,第一個數是1,往右走:

第二個數是1,往右走:

讀到有字符的節點,返回此字符,加到字符串D里。D:A;
返回根節點,繼續讀。

第三個數是0,往左走:

第四個數是0,往左走:

讀到有字符的節點,返回此字符,加到字符串D里。D:AB;
返回根節點,繼續讀。
第五個數是0,往左走:

第六個數是1,往右走:

第七個數是1,往右走:

讀到有字符的節點,返回此字符,加到字符串D里。D:ABR;
返回根節點,繼續讀。
如此類推,直到讀完所有壓縮后的數據S為止。
壓縮與解壓都搞定了,現在看如何構建這個表格:
我們需要先把原數據讀一遍,並把每個字符出現的次數記錄下來。如:
ABRACADABRA!中,A出現了5次;B出現了2次;C出現了1次;D出現了1次;R出現了2次;!出現了1次。
理論上,出現頻率越高的字符,我們給它一個占用空間越小的值,這樣,我們就可以有最佳的壓縮率。
我們把這些字符,按次數的多少排成遞增的順序:(括弧中的數字為出現次數)

然后,我們把最小的兩個字符找出來,並新建一個節點作為它們的父節點(誰左誰右不重要,隨意):

父節點的出現次數為子節點之和,新節點加入數組;(PS:這里提到了找出最小值,我們自然而然會想起了最小堆,不了解的,建議去補一下。用最小堆,我們可以高效地找出最小值,加入新元素也不需對數組重新排序)
然后,我們把最小的兩個字符找出來,並從數組中移除,並新建一個節點作為它們的父節點(誰左誰右不重要,隨意),新節點加入數組:

把最小的兩個字符找出來,並從數組中移除,並新建一個節點作為它們的父節點(誰左誰右不重要,隨意),新節點加入數組:

把最小的兩個字符找出來,並從數組中移除,並新建一個節點作為它們的父節點(誰左誰右不重要,隨意),新節點加入數組:

把最小的兩個字符找出來,並從數組中移除,並新建一個節點作為它們的父節點(誰左誰右不重要,隨意),新節點加入數組:

數組中只剩下一個元素了,構建表格結束。
把這棵樹轉成表格:
從左到右讀過去(先序遍歷)。給每個節點新建一個整數變量int C; 用C來記住每個節點對應的值。新建節點數組T來把含字符的節點記錄下來。
從根節點出發,先看左子節點(左邊是0,右邊是1),發現含有字符A,故A對應的值為0。

然后返回上一個父節點,再看右子節點,發現右子節點不含字符,此節點的值為1:

去看此節點的左子節點,發現此子節點不含字符,此節點的值為父節點的值+0,即10:(為了方便觀看,我把代表頻率的括弧里的值隱藏,把節點對應的值寫在節點上)

去看此節點的左子節點,發現此子節點含符號 !,此節點的值為父節點的值+0,即100:

然后返回上一個父節點,再看右子節點,發現右子節點不含字符,此節點的值為父節點的值+1,即101:

去看此節點的左子節點,發現此子節點含符號 C,此節點的值為父節點的值+0,即1010:

然后返回上一個父節點,再看右子節點,發現右子節點含字符D,此節點的值為父節點的值+1,即1011:

然后返回上一個父節點,左右子節點都看過了,再返回上一個父節點:

然后返回上一個父節點,再看右子節點,發現右子節點不含字符,此節點的值為父節點的值+1,即11:

去看此節點的左子節點,發現此子節點含符號 R,此節點的值為父節點的值+0,即110:

然后返回上一個父節點,再看右子節點,發現右子節點含字符B,此節點的值為父節點的值+1,即111:

然后一路返回父節點,去尋找有沒還沒看的子節點,結果沒有,建表完成。
這個表格跟上述的例子用的表格不相同,如果用這個表格進行壓縮,會發現壓縮后的數據只有28bits。這個表格是最佳壓縮表。
這個建表就是一個遞歸的過程。
到目前為止,我們已經講了如何壓縮、解壓、建表。完事了嗎?不,我們還需要把表格用二進制存儲起來,並且能從二進制中讀取表格。
存儲表格跟上面的建表過程差不多,也是先序遍歷(從左讀到右)。
令表格存儲的數據為Y;
首先從根節點開始:

此節點不含字符,故加一個0給Y,Y:0;
然后讀此節點的左子節點:(為了避免歧義,我們把不含字符的點的值隱藏)

此節點含字符A,A的二進制為01000001,由於此節點含有字符,加一個1給Y,(這個1是標志着此節點含有字符),再把A的二進制加給Y,Y:0101000001;
然后返回上一個父節點,再看右子節點,發現右子節點不含字符,加一個0給Y,Y:01010000010;

讀此節點的左子節點,發現左子節點不含字符,加一個0給Y,Y:010100000100;

讀此節點的左子節點:

此節點含符號 !,!的二進制為00001010,由於此節點含有字符,加一個1給Y,再把 ! 的二進制加給Y,Y:010100000100100001010;
然后返回上一個父節點,再看右子節點,發現右子節點不含字符,加一個0給Y,Y:0101000001001000010100;

讀此節點的左子節點:

此節點含符號 C,C的二進制為01000011,由於此節點含有字符,加一個1給Y,再把C的二進制加給Y,Y:0101000001001000010100101000011;
然后返回上一個父節點,再看右子節點,發現右子節點含字符D,D的二進制為01000100,由於此節點含有字符,加一個1給Y,再把D的二進制加給Y,Y:0101000001001000010100101000011101000100;

然后返回上一個父節點,左右子節點都看過了,再返回上一個父節點:

然后返回上一個父節點,再看右子節點,發現右子節點不含字符,加一個0給Y,Y:01010000010010000101001010000111010001000:

讀此節點的左子節點:

此節點含符號 R,R的二進制為01010010,由於此節點含有字符,加一個1給Y,再把R的二進制加給Y,Y: 01010000010010000101001010000111010001000101010010:
然后返回上一個父節點,再看右子節點,發現右子節點含字符B,B的二進制為01000010,由於此節點含有字符,加一個1給Y,再把B的二進制加給Y,Y: 01010000010010000101001010000111010001000101010010101000010;

然后一路返回父節點,去尋找有沒還沒看的子節點,結果沒有,存儲表格完成。
從二進制中讀取表格也是用先序遍歷(先左再右);
我們存儲的表格數據為:Y: 01010000010010000101001010000111010001000101010010101000010;
第一個數字為0,說明這是不含字符節點,新建節點:

Y: 01010000010010000101001010000111010001000101010010101000010;
接下來新建的兩個節點依次為此節點的左右節點;
第二個數字為1,說明這是含字符節點,讀接下來的8個數字,得到對應的字符A,新建含A的節點;
Y: 01010000010010000101001010000111010001000101010010101000010;
下一個數字為0,說明這是不含字符節點,新建節點:

含A的節點沒子節點;新建的不含字符的節點將把接下來新建的兩個節點依次為此節點的左右節點;
Y: 01010000010010000101001010000111010001000101010010101000010;
下一個數字為0,說明這是不含字符節點,新建節點:(未知節點的存在只是為了強調另一個節點為左節點)

Y: 01010000010010000101001010000111010001000101010010101000010;
新建的不含字符的節點將把接下來新建的兩個節點依次為此節點的左右節點;
下一個數字為1,說明這是含字符節點,讀接下來的8個數字,得到對應的字符 ! ,新建含 ! 的節點;

Y: 01010000010010000101001010000111010001000101010010101000010;
下一個數字為0,說明這是不含字符節點,新建節點:

Y: 01010000010010000101001010000111010001000101010010101000010;
新建的不含字符的節點將把接下來新建的兩個節點依次為此節點的左右節點;
下一個數字為1,說明這是含字符節點,讀接下來的8個數字,得到對應的字符 C,新建含 C的節點;

Y: 01010000010010000101001010000111010001000101010010101000010;
下一個數字為1,說明這是含字符節點,讀接下來的8個數字,得到對應的字符 D,新建含 D的節點;

Y: 01010000010010000101001010000111010001000101010010101000010;
下一個數字為0,說明這是不含字符節點,新建節點,把節點放在未知節點處,(由於這個過程里,代碼是使用遞歸,故自動把節點放在未知節點處)

Y: 01010000010010000101001010000111010001000101010010101000010;
新建的不含字符的節點將把接下來新建的兩個節點依次為此節點的左右節點;
下一個數字為1,說明這是含字符節點,讀接下來的8個數字,得到對應的字符 R,新建含 R的節點;

Y: 01010000010010000101001010000111010001000101010010101000010;
下一個數字為1,說明這是含字符節點,讀接下來的8個數字,得到對應的字符 B,新建含B的節點;

Y: 01010000010010000101001010000111010001000101010010101000010;
讀完,讀表結束。
綜上所述,哈夫曼壓縮算法結束。
代碼實現:
實現節點:

建立表格:

存儲表格:

讀取表格:

解壓:

4. 算法缺點
哈夫曼壓縮算法效率不錯,美中不足之處為它建立表格時,需要先把原數據讀一遍,從而來記錄字符出現次數。這難免會對效率有所影響,我們是否會有更好的壓縮算法呢?
是的,LZW壓縮算法(LZW compression)將在下一篇隨筆中介紹。
