哈夫曼編碼雖然在acm上用到的似乎很少,但其經常作爲一種基礎算法出現在計算機類的書籍上。
而我對哈夫曼編碼的理解也僅僅侷限在其用於編碼領域,可以提高數據傳輸效率,或者是用於壓縮文件?這些可能並不準確,我沒有細細的去查證。
哈夫曼編碼可以通過構建哈夫曼樹來得到。
舉例
我們用一個簡單的例子,來簡單描述下哈夫曼編碼是什麼?有什麼好處?
場景:X地區需要向Y地區發送一些文本,兩地之間通過電纜(或者通過電報)連接,要求用最少的二進制流傳遞信息:ABACDAAB
可以看到該信息中一共出現4個A,2個B,C、D各1個
1. 如果用常見的二進制形式編碼,那麼A:00,B:01,C:10,D:11;信息轉二進制流爲:0001001011000001,一共是16位。
2. 如果用哈夫曼編碼,則其的一種爲A:1,B:01,C:000,D:001;信息轉二進制流爲:10110000011101,一共是14位,比普通的少2位。
這時,我們可能就明白了哈夫曼編碼不就是頻率出現越高,其編碼越短嗎?
我們嘗試這樣來賦值,A:0,B:1,C:01,D:10;二進制流爲:0100110001,這個只有10位,比哈夫曼還少呢!
上面的說法似乎有道理,但是我們忽略了哈夫曼的用途可能是信息傳輸和壓縮。當我們把編碼規則和二進制流告訴接收方時,他們需要把這些二進制流還原爲看得懂的信息。普通編碼和哈夫曼編碼都可以順利還原,而第三種則不可能還原,0100....到底是ABAA....還是CAA....呢?
所以哈夫曼編碼必須是前綴碼,而且還是最優前綴碼(前綴碼定義:在一個字符集中,任何一個字符的編碼都不是另一個字符編碼的前綴)
因此,必須達到以上兩點構造出的編碼纔是哈夫曼碼。
求解
下面我們還是通過這個例子,簡單來看看當獲得ABACDAAB時,如何通過構建哈夫曼樹求每個字符的哈夫曼編碼:
步驟一:獲得每個字符出現的次數作爲該字符節點的權值;
步驟二:每次選取權值最小的兩個節點,合併成有共同的父節點後放回;不斷重複,直至只剩下一個節點;此時按照左分支爲0,右分支爲1進行編碼;
具體的過程詳見下圖:
代碼的實現
步驟一可以通過哈希遍歷來獲得每個字母的出現次數;
步驟二可用二叉鏈表模擬建樹來實現;
步驟二中每次從集合中彈出兩權值最小的節點合併爲同一父節點後還要放回,顯然需要在普通隊列上加段每次插入後都排序的代碼;如果自己造輪子想必還要費點時間,好在C++標準庫提供了“優先隊列”可以滿足我們的需求。(在優先隊列中,元素被賦予優先級;當訪問元素時,具有最高優先級的元素最先出隊列)
下面我們來看下具體的代碼:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<queue>
using namespace std;
typedef struct node{
char ch; //存儲該節點表示的字符,只有葉子節點用的到
int val; //記錄該節點的權值
struct node *self,*left,*right; //三個指針,分別用於記錄自己的地址,左孩子的地址和右孩子的地址
friend bool operator <(const node &a,const node &b) //運算符重載,定義優先隊列的比較結構
{
return a.val>b.val; //這裏是權值小的優先出隊列
}
}node;
priority_queue<node> p; //定義優先隊列
char res[30]; //用於記錄哈夫曼編碼
void dfs(node *root,int level) //打印字符和對應的哈夫曼編碼
{
if(root->left==root->right) //葉子節點的左孩子地址一定等於右孩子地址,且一定都爲NULL;葉子節點記錄有字符
{
if(level==0) //“AAAAA”這種只有一字符的情況
{
res[0]='0';
level++;
}
res[level]='\0'; //字符數組以'\0'結束
printf("%c=>%s\n",root->ch,res);
}
else
{
res[level]='0'; //左分支爲0
dfs(root->left,level+1);
res[level]='1'; //右分支爲1
dfs(root->right,level+1);
}
}
void huffman(int *hash) //構建哈夫曼樹
{
node *root,fir,sec;
for(int i=0;i<26;i++) //程序只能處理全爲大寫英文字符的信息串,故哈希也只有26個
{
if(!hash[i]) //對應字母在text中未出現
continue;
root=(node *)malloc(sizeof(node)); //開闢節點
root->self=root; //記錄自己的地址,方便父節點連接自己
root->left=root->right=NULL; //該節點是葉子節點,左右孩子地址均爲NULL
root->ch='A'+i; //記錄該節點表示的字符
root->val=hash[i]; //記錄該字符的權值
p.push(*root); //將該節點壓入優先隊列
}
//下面循環模擬建樹過程,每次取出兩個最小的節點合併後重新壓入隊列
//當隊列中剩餘節點數量爲1時,哈夫曼樹構建完成
while(p.size()>1)
{
fir=p.top();p.pop(); //取出最小的節點
sec=p.top();p.pop(); //取出次小的節點
root=(node *)malloc(sizeof(node)); //構建新節點,將其作爲fir,sec的父節點
root->self=root; //記錄自己的地址,方便該節點的父節點連接
root->left=fir.self; //記錄左孩子節點地址
root->right=sec.self; //記錄右孩子節點地址
root->val=fir.val+sec.val;//該節點權值爲兩孩子權值之和
p.push(*root); //將新節點壓入隊列
}
fir=p.top();p.pop(); //彈出哈夫曼樹的根節點
dfs(fir.self,0); //輸出葉子節點記錄的字符和對應的哈夫曼編碼
}
int main()
{
char text[100];
int hash[30];
memset(hash,0,sizeof(hash)); //哈希數組初始化全爲0
scanf("%s",text); //讀入信息串text
for(int i=0;text[i]!='\0';i++)//通過哈希求每個字符的出現次數
{
hash[text[i]-'A']++; //程序假設運行的全爲英文大寫字母
}
huffman(hash);
return 0;
}
因爲在其被合併時,需要將自己的地址傳遞給父節點的孩子指針。而我們每次壓入隊列的是一個節點變量,並不是該節點的地址,所以必須有域記錄該節點的地址。那如果我們改一下優先隊列的定義,讓其中存儲的從變量改成地址呢?
priority_queue<struct node *> p;
p.push(root);
答案仍然是否定的,優先隊列的比較函數重載時不能接受地址,原因詳見“liuzhanchen1987”的博客,感謝這位博主的分享。
演示程序
之前我有用VC6寫過兩個小程序,分別用哈夫曼編碼實現編碼發送信息和解碼還原信息。
如果有需要可以“點擊下載”,程序目前還只能傳輸大寫英文字符,數字和少量標點;如果要傳輸更多的字符,擴大哈希數組的長度即可。
程序只提供源碼和運行的截圖,見諒^_^