一、概述
1、算法簡述
- 衆所周知,計算機中數據的存儲和傳輸的最小單位是字節(byte),一個ASCII 碼佔用 1 個字節, 每個字節爲 8 個比特位(Bit);例如,字符 ‘e’ 的二進制表示爲 01100101;
- 進程間通信傳輸字節流的過程中,爲了節省帶寬,往往會對傳輸的數據進行壓縮。
- 壓縮算法有很多,今天介紹一種比較好理解的貪心算法 - 霍夫曼編碼;
- 霍夫曼編碼的本質就是對每個出現過的 ASCII 字符,通過一個壓縮字典,映射成另一個字符,映射後的字符是二進制比特串:001、0101、00 等等;
- 解壓縮就是這個過程的逆過程;
2、引例
源字節流
- 首先,一個字符串 “HelloWorld”,在沒有進行壓縮的情況下采用 ASCII 編碼,佔用字節總數爲 10,即 10 * 8 = 80 個比特位。
壓縮字典
- 然後,通過霍夫曼算法生成壓縮字典如下(具體生成過程是霍夫曼樹的構造過程,下文會詳細講述):
字符 |
壓縮前編碼(ASCII) |
壓縮後編碼 |
d |
01100100 |
001 |
e |
01100101 |
010 |
l |
01101100 |
10 |
o |
01101111 |
111 |
r |
01110010 |
011 |
H |
01001000 |
1101 |
W |
01010111 |
000 |
編碼
- 繼而,遍歷源字節流,對對應的字符進行編碼替換,得到新的比特流;
H |
e |
l |
l |
o |
W |
o |
r |
l |
d |
1101 |
010 |
10 |
10 |
111 |
000 |
111 |
011 |
10 |
001 |
規範化
- 最後,將比特流再轉換成字節流,由於編碼後比特流長度不一定是8的倍數,所以最後的 XXXX 是補足位(補足位具體的值下文會接着探討);
第1字節 |
第2字節 |
第3字節 |
第4字節 |
11010101 |
01011100 |
01110111 |
0001XXXX |
- 觀察一下就會發現,每個壓縮後的編碼長度都是小於8的,所以壓縮後的總位數一定是會小於壓縮前的;壓縮前 80 個比特位,壓縮後 32 個比特位,壓縮率爲 40%;
解碼
- 解碼過程是霍夫曼樹的路徑查找過程,瞭解霍夫曼樹的構造過程,這個解碼過程就一目瞭然了,下文也會詳細介紹;
二、概念
1、變長編碼
- 每個 ASCII 字符佔用字節數都爲1,所以它是一種定長編碼;區別於定長編碼,霍夫曼編碼是一種變長編碼,即每個 ASCII 碼進行編碼映射後的二進制比特串的長度不相等;它是根據字符出現的概率來構造平均長度最短的編碼,換言之,如果一個字符在文檔中出現的次數多,那麼它的映射後的二進制比特串就應該相對較短;反正,如果出現的次數少,那麼它的映射後的二進制比特串就應該相對較長;
2、前綴編碼
- 前綴編碼的含義是:編碼集合中,任意兩個編碼 A 和 B,A 不能是 B 的前綴;
- 這是爲了保證在傳輸過程中,不用增加分隔符來區分兩個編碼值;
- 霍夫曼編碼是一種前綴編碼,爲什麼呢?下文繼續講;
3、比特串
- 簡單認爲就是數組,數組的最小單位是二進制比特位,也就是隻有兩種值:0或1;
4、壓縮率
- 壓縮率 = 壓縮後字節數 / 壓縮前字節數;
- 壓縮率的值越小越好;
- 壓縮率取決於字符集合,字符集合越大,壓縮率越大(壓縮率越小越好),256個字符都有的話,等於不壓縮,因爲基本所有的編碼後的字符長度都是8,和原字符一致,甚至有可能更高;
- 如果壓縮字符裏面存在中文,不適合採用 霍夫曼壓縮 (還是基於字符集合,中文會佔用負數的 ASCII 碼);
- 瞭解算法以後再來看壓縮率,會更加直觀;
- 如果所有字符都平均分佈,則字符個數和壓縮率的實測結果如下;
字符個數 |
壓縮率(越低越好) |
1 |
12.50% |
2 |
18.77% |
4 |
28.14% |
8 |
39.08% |
16 |
50.80% |
32 |
62.91% |
64 |
75.21% |
128 |
87.61% |
256 |
100.00% |
三、算法詳解
1、算法要求
- 1、編碼後的碼值是二進制的比特串;
- 2、編碼後的任意兩個比特串 A 和 B, A 不能是 B 的前綴,這是爲了保證在傳輸過程中,不用增加分隔符來區分兩個編碼值;
- 3、出現頻率高的字符,編碼後的比特串相對較短;出現頻率低的字符,編碼後的比特串相對較長;
- 4、編碼完要保證編碼總長度最小;
2、算法簡述
- 1、統計字節流中出現的字符次數(頻率);
- 2、按照字符頻率放入一個小頂優先隊列中(小頂堆);
- 3、如果優先隊列中只剩一個結點,則這個結點爲霍夫曼樹的根結點;否則,取出頻率最小和次小的兩個,進行合併,產生一個新的結點再塞回優先隊列中;其中,這個新的結點的左右子樹分別是頻率最小和次小的那兩個結點,新的結點的頻率值爲兩個子樹頻率值的和;
- 4、重複3的過程,構造出來的樹就是霍夫曼樹;
壓縮前的串
HelloWorld
頻次統計
字符 |
壓縮前編碼 |
出現頻次 |
d |
01100100 |
1 |
e |
01100101 |
1 |
l |
01101100 |
3 |
o |
01101111 |
2 |
r |
01110010 |
1 |
H |
01001000 |
1 |
W |
01010111 |
1 |
- 啓發:出現頻次多的字符,編碼後的比特串應該儘量短;
霍夫曼樹構造
- 觀察可得:
-
- 葉子結點爲每個未編碼的 ASCII 字符,非葉子結點上的數字代表的是該結點爲根的子樹下所有字符的頻率和;
-
- 樹上任何一個非葉子結點一定有左右兩棵子樹(這是由構造算法決定的);
-
- 左子樹的邊權爲0,右子樹的邊權爲1;
-
- 根結點到葉子結點路徑上的邊權集合就是對應字符的編碼後的比特串,比如字符 r 編碼後的比特串爲 011;
-
- 圖中的 … 代表其他字符(總共 256 個字符集合),這棵樹深度還很深,因爲其它字符沒有出現,所以頻率都爲0;
霍夫曼編碼
- 根結點到葉子結點的樹邊上的路徑,就是對應字符的編碼值,也正是上文提到的壓縮字典;
字符 |
壓縮前編碼 |
出現頻次 |
壓縮後編碼 |
d |
01100100 |
1 |
001 |
e |
01100101 |
1 |
010 |
l |
01101100 |
3 |
10 |
o |
01101111 |
2 |
111 |
r |
01110010 |
1 |
011 |
H |
01001000 |
1 |
1101 |
W |
01010111 |
1 |
000 |
其它 |
- |
0 |
- |
霍夫曼壓縮
- 然後就比較好理解了,通過映射關係將每個字符替換爲二進制比特串;由於計算機中存儲和傳輸的基本單位是字節,所以替換過程是通過位運算來完成的,具體實現方式下文會詳細介紹;
H |
e |
l |
l |
o |
W |
o |
r |
l |
d |
1101 |
010 |
10 |
10 |
111 |
000 |
111 |
011 |
10 |
001 |
第1字節 |
第2字節 |
第3字節 |
第4字節 |
11010101 |
01011100 |
01110111 |
0001XXXX |
不可達字符
- 然後來分析下上文提到的 XXXX,也就是最後一個字節的補齊字符;
- XXXX 必定是不會出現在壓縮後編碼集合中的字符,仔細想一下,編碼集合256個字符,霍夫曼樹中深度最深的那個葉子結點,它的編碼長度一定是超過 8 的(想想爲什麼?),所以只要取這個比特串的高 8 位作爲不可達字符即可;
- 根本原因就是 前綴編碼,只要是一個編碼的前綴,必定不在編碼集合中,它的前綴可以放心用來做 不可達字符;
霍夫曼解壓縮
-
1、準備一個指向霍夫曼樹根結點的指針 searchNode;
-
2、遍歷被壓縮串的每個比特位,如果是 0 ,則將 searchNode 指向它的左子樹;如果是1,則將 searchNode 指向它的右子樹;判斷3;
-
3、當 searchNode 爲葉子結點,則說明這是一個完整的字符,提取出結點上的字符(ASCII 碼值)寫入到解壓縮緩衝區,searchNode 繼續指向 霍夫曼樹的根結點,重複步驟2;
-
如圖,待解壓串 110101010…,通過下面的霍夫曼樹從根結點一直往下找 1101 後遇到葉子結點,所以解出來的第一個字符爲 ‘H’;然後再回到根結點,周而復始,直到整個待解壓串遍歷完畢就獲得了完整的源字節流;
四、數據結構和接口設計
1、壓縮結構
typedef struct CompressdData
{
char* compress_result;
unsigned int capacity;
unsigned int size;
unsigned char _cache_bit;
unsigned char _cache_bit_index;
} CompressdData;
-
i. compress_result 用於存儲壓縮或者解壓縮的字節流,支持動態擴展;
-
ii. capacity 代表了 compress_result 實際在堆上的空間;
-
iii. size 代表壓縮、或者解壓縮的字節流的實際大小;
-
iv. _cache_bit 表示某次編碼(壓縮)過程中,沒有寫入 compress_result 的剩餘部分(高位);
-
v. _cache_bit_index 代表 _cache_bit 的位數;
-
例如,圖中 11010101 組成一個字節,這時候 _cache_bit = 010, _cache_bit_index = 3;
- 當加入一個字符o以後,_cache_bit = 010111,_cache_bit_index = 6;
H |
e |
l |
l |
o |
1101 |
010 |
10 |
10 |
111 |
2、編碼字典
struct huffman_encoded_data
{
unsigned long long value;
unsigned char length;
};
- 編碼字典 huffman_encoded_data[256] 存儲了每個字符編碼後的值,以及長度;
比如 ASCII 值爲 200 的字符,編碼以後變成 17,二進制表示爲 10001;
那麼 value 的值就是 17, length 的值爲 5;
- 令當前需要編碼的字符 i;
- 編碼後的值 V = huffman_encoded_data[i].value ;
- 編碼後的值 V 的長度 L = huffman_encoded_data[i].length;
- 則需要寫入 compress_result 的數據需要按照下標進行;
長度範圍 |
寫入數據類型 |
0< L <=8 |
L個Bit |
8< L <=16 |
L-8個Bit、1個byte |
16< L <=24 |
L-16個Bit、1個short |
24< L <=32 |
L-24個Bit、1個char、1個short |
32< L <=40 |
L-32個Bit、1個int |
40< L <=48 |
L-40個Bit、1個char,1個int |
48< L <=56 |
L-48個Bit、1個short、1個int |
56< L <=64 |
L-56個Bit、1個char、1個short,1個int |
3、編碼寫入
i. 寫入完整 Bit
void writeChar_Internal(unsigned char value)
{
if (size + 1 > capacity)
{
expand(capacity);
}
*(compress_result + size) = value;
size += 1;
}
- writeChar_Internal 這個接口表示寫入完整的一個字節 value;
- 並且在寫入的過程中判斷容量,超過容量就進行倍增;
- expand 函數就是在堆上分配 2*capacity 的內存,然後將 compress_result 原來的數據拷貝到新的內存,再刪掉原來的內存;
ii. 寫入非完整 Bit
void writeBit(unsigned char value, int fromBit, int bitSize)
{
int leftBitIndex = 8 - _cache_bit_index;
if (bitSize < leftBitIndex)
{
_cache_bit |= (((unsigned char)((value << fromBit) & _MINI_BIT_MASK[bitSize])) >> _cache_bit_index);
_cache_bit_index += bitSize;
}
else
{
_cache_bit |= (((unsigned char)(value << fromBit)) >> _cache_bit_index);
this->writeChar_Internal(_cache_bit);
fromBit += leftBitIndex;
bitSize -= leftBitIndex;
_cache_bit_index = bitSize;
_cache_bit = (((unsigned char)(value << fromBit)) & _MINI_BIT_MASK[bitSize]);
}
}
- writeBit 表示寫入的比特位不一定是8位,而是 bitSize 位;
- _MINI_BIT_MASK 是個二進制掩碼,用來作 位與(&),去掉低位的0,如下;
下標 |
二進制值 |
十進制值 |
0 |
00000000 |
0 |
1 |
10000000 |
128 |
2 |
11000000 |
192 |
3 |
11100000 |
224 |
4 |
11110000 |
240 |
5 |
11111000 |
248 |
6 |
11111100 |
252 |
7 |
11111110 |
254 |
8 |
11111111 |
255 |
- leftBitIndex 代表本次寫入還可以寫多少個Bit,這裏的 left 是剩餘的意思,不是
左
,如果沒有理解,很容易理解成左
,那後面就更難理解了;
直接寫入
- 需要寫入的位數 bitSize 小於 剩餘位數 leftBitIndex,則直接寫入 _cache_bit,並且更新 _cache_bit_index;
- 爲了更加容易理解,舉個例子,某種情況下的變量值如下:
變量類型 |
變量名 |
值 |
含義 |
成員 |
_cache_bit_index |
2 |
當前字節的2個位被佔用了 |
成員 |
_cache_bit |
11000000 |
當前字節的2個被佔用的位都是1 |
傳參 |
bitSize |
3 |
寫入的字節只需要3個位 |
傳參 |
value |
00000101 |
需要寫入的字節的三個位是 101 |
傳參 |
fromBit |
5 |
8 減去寫入的位數 |
- 那麼我們的目的就是要把 value 的3個位‘101’放到_cache_bit的2個‘11’後面;
操作 |
值 |
功能 |
a=(value << fromBit) |
10100000 |
將101提到高位 |
b = a & _MINI_BIT_MASK[bitSize] |
10100000 |
剔除101以外的所有位 |
c = b >> _cache_bit_index |
00101000 |
空出兩個位給_cache_bit |
_cache_bit = _cache_bit 位或 c |
11101000 |
11和101合體 |
超出字節
- 需要寫入的位數 bitSize 大於等於 剩餘位數 leftBitIndex,則必須先寫一個字節,然後再緩存 _cache_bit;
- 還是舉例說明吧;
變量類型 |
變量名 |
值 |
含義 |
成員 |
_cache_bit_index |
2 |
當前字節的2個位被佔用了 |
成員 |
_cache_bit |
11000000 |
當前字節的2個被佔用的位都是1 |
傳參 |
bitSize |
7 |
寫入的字節需要7個位 |
傳參 |
value |
00100101 |
需要寫入的字節的7個位是 0100101 |
傳參 |
fromBit |
1 |
8 減去寫入的位數 |
- 操作完的結果是 11010010 寫入壓縮數據,剩下的 10000000 寫入_cache_bit;
操作 |
值 |
功能 |
a=(value << fromBit) |
01001010 |
將0100101提到高位 |
b = a>>_cache_bit_index |
00010010 |
空出兩個位給_cache_bit |
_cache_bit = _cache_bit 位或 b |
11010010 |
11和00010010合體 |
- 寫入前半個字節:writeChar_Internal(_cache_bit);
操作 |
值 |
fromBit = fromBit + leftBitIndex |
7 |
bitSize = bitSize - leftBitIndex |
1 |
_cache_bit_index = bitSize |
1 |
a = (value << fromBit) |
001001010000000 |
_cache_bit = a & _MINI_BIT_MASK[bitSize] |
10000000 |
iii. 寫入 byte
- 寫入 byte,其實就是寫入8個位,當然,舉一反三,寫 short 和 int 也雷同;
void writeChar(unsigned char value)
{
writeBit(value, 0, 8);
}
iv. 寫入 short
void writeShort(unsigned short value)
{
unsigned char hValue = (value >> 8) & 0xff;
unsigned char lValue = (value) & 0xff;
writeChar(hValue);
writeChar(lValue);
}
iv. 寫入 int
void writeInt(unsigned int value)
{
unsigned char value1 = (value >> 24) & 0xff;
unsigned char value2 = (value >> 16) & 0xff;
unsigned char value3 = (value >> 8) & 0xff;
unsigned char value4 = value & 0xff;
writeChar(value1);
writeChar(value2);
writeChar(value3);
writeChar(value4);
}