1. 程式人生 > >哈夫曼樹及哈夫曼編碼

哈夫曼樹及哈夫曼編碼

哈夫曼樹

哈夫曼樹,最優二叉樹,帶權路徑長度(WPL)最短的樹。它沒有度為1的點,是一棵嚴格的二叉樹(滿二叉樹)。

何謂‘帶權路徑長度’

瞭解哈夫曼樹,我們首先要知道樹的幾個相關術語,並瞭解什麼是WPL。

  • 路徑:從樹中一個結點到另一個結點之間的分支構成兩個結點之間的路徑
  • 路徑長度:路徑上的分支數目
  • 樹的路徑長度:從樹根到每一個結點的路徑長度之和
  • 樹的帶權路徑長度:樹中所有葉子結點的帶權路徑之和WPL=k=1nwklkWPL=\sum_{k=1}^n{w_kl_k},其中wkw_k為第k個結點的權值
  • 最優二叉樹(哈夫曼樹):WPL最小的二叉樹

注:樹的WPL這個概念非常重要,這個公式直接產生了哈夫曼編碼資料壓縮的應用

哈夫曼演算法

  1. 根據給定的n個權值{w1w_1,w2w_2,···,wnw_n}構成n棵二叉樹的集合F={T1T_1,T2T_2,···,TnT_n},其中每棵二叉樹TiT_i中只有一個帶權為wiw_i的根結點,其左右子樹均空
  2. 在F中選取兩棵根結點的權值最小的樹作為左右子樹構造一棵新的二叉樹,且置新的二叉樹的根結點的權值為其左。右子樹上根結點的權值之和。
  3. 在F中刪除這兩棵樹,同時將新得到的二叉樹加入F中。
  4. 重複(2)(3),直到F只含一棵樹為止。這棵樹即為哈夫曼樹

哈夫曼樹構建過程

哈夫曼編碼

在談論哈夫曼編碼之前,我們先來了解一下編碼的相關概念。

等長的二進位制編碼

對於一個無記憶離散信源中每一個符號,若採用相同長度的不同碼字代表相應的符號,就稱為等長編碼。如:A/B/C/D採用00/01/10/11編碼就是一個等長的編碼。

不等長的二進位制編碼

在資料傳輸過程中,我們需要壓縮檔案,就可以通過不等長編碼的方式。

哈夫曼編碼就是一種不等長的編碼,它根據字頻分析結果決定每個字元的編碼長度,出現概率高的字元編碼短,出現概率低的編碼長。哈夫曼研究這種最優樹的目的是為了解決當年遠距離通訊(主要是電報)的資料傳輸的最優化問題。

但是檔案壓縮了之後我們需要解壓才能得到想要的檔案,編碼了之後我們還需解碼。下面我們來看一下一個不等長編碼的二進位制編碼的示例:同樣我們給對A/B/C/D編碼,分別為0/00/1/01,很容易看出如果我們收到電文0000,我們則無法得到唯一的譯文(0000可以翻譯成AAAA、BB等等

)

一般來說,若要實現無失真的編碼,這不但要求信源符號與碼字是一一對應的,而且要求碼符號序列的反變換也是唯一的。也就是說,一個碼的任意一串有限長的碼符號序列(碼字)只能被唯一地翻譯成所對應的信源符號序列。不然我們的譯文就會有二義性,這樣的編碼是沒有意義的。

為了解決不等長二進位制編碼譯碼的二義性問題,我們需要引入一個概念字首碼

字首碼是一種不等長的編碼,它保證任何一個字元的編碼都不是另一個編碼的字首。比如A(0),B(10),C(110),D(1111)就是一組字首碼。

哈夫曼樹構造哈夫曼編碼

在編寫一個具體的演算法之前,我們總要先思考演算法的載體——資料如何被儲存。哈夫曼演算法的關鍵資料結構為哈夫曼樹,所以接下來我們來思考哈夫曼樹的儲存。

哈夫曼樹的儲存

我們採用孩子雙親表示法儲存哈夫曼樹

typedef struct{
	unsigned int weight;
	unsigned int parent, lchild, rchild;
} HTNode, *HuffmanTree; //動態分配陣列儲存哈夫曼樹

定義一個char型二級指標儲存哈夫曼編碼

typedef char **HuffmanCode;

哈夫曼樹的構造

在這裡插入圖片描述

哈夫曼樹的構造思想就是利用二叉樹的非葉子結點來實現二叉樹的結構(:我在說什麼

void HuffmanCoding(HuffmanTree &HT, HuffmanCode &HC, int *w, int n)
{
	if(n<=1) return;
	// 初始化哈夫曼樹
	m = 2*n-1;
	HT = (HuffmanTree)malloc((m+1)*sizeof(HTNode));
	HuffmanTree p; 
	int i; 
	for(p = HT, i = 1; i <= n; ++i, ++p, ++w) *p = {*w, 0, 0, 0};
	for(; i <= m; ++i; ++p) *p = {0, 0, 0, 0};
	// 構造哈夫曼樹
	for(i = n+1; i <= m; ++i)
	{
		select(HT, i-1, s1, s2); //選擇parent為0且權值最小的兩個結點s1,s2
		HT[s1].parent = i; HT[s2].parent = i;
		HT[i].lchild = s1; HT[i].rchild = s2;
		HT[i].weight = HT[s1].weight + HT[s1].weight;
	}
}

下面對這段程式碼的一些細節略加解釋:

  1. 傳參時使用引用實現了直接對函式外變數的修改,解決了C函式按值傳遞且只有單返回值的弊端
  2. *w表示待編碼的字元陣列,n表示字元個數,m=2n-1表示哈夫曼樹的結點個數,至於分配m+1個結點的空間,則是為了將結點標號和陣列下標對應起來,陣列的0號元素棄置不用(樹的常見儲存
  3. malloc函式的返回值為void*,需要將其強轉成HTNode的指標,即HuffmanTree型別
  4. HuffmanTree p是為遍歷並初始化動態分配的記憶體塊而設的HTNode指標int i是為了迴圈而設的輔助變數

哈夫曼編碼

以下程式碼依據從葉子到根逆向求每個字元的哈夫曼編碼

// 哈夫曼編碼
HC = (HuffmanCode)malloc((n+1)*sizeof(char *));
cd = (char*)malloc(n*sizeof(char));
cd[n-1] = "\0";
for(int i = 1; i <= n; ++i)
{
	start = n-1;
	for(int c = i, f = HT[i].parent; f != 0; c = f, f = HT[f].parent)
		if(HT[f].lchild == c) cd[--start] = "0";
		else cd[--start] = "1";
	HC[i] = (char*)malloc((n-start)*sizeof(char));
	strcpy(HC[i], &cd[start]);
}
free(cd);

註記:

  1. 同理,開闢 n+1個char* 的空間為了將陣列下標i與第i個待編碼的字元對應起來
  2. cd為輔助陣列,用於給單個字元編碼,由於一開始不知道每個字元的編碼長度,所以使用cd開足夠大的編碼空間n,最後將編碼完成後就將確定長度的編碼串拷貝到哈夫曼表HC

最後獻上Huffman編碼演算法的完整程式碼

typedef struct{
	unsigned int weight;
	unsigned int parent, lchild, rchild;
} HTNode, *HuffmanTree; //哈夫曼樹結點和根結點

typedef char **HuffmanCode; //哈夫曼編碼表

void HuffmanCoding(HuffmanTree &HT, HuffmanCode &HC, int *w, int n)
{
	if(n<=1) return;
	// 初始化哈夫曼樹
	m = 2*n-1;
	HT = (HuffmanTree)malloc((m+1)*sizeof(HTNode));
	HuffmanTree p; 
	int i; 
	for(p = HT, i = 1; i <= n; ++i, ++p, ++w) *p = {*w, 0, 0, 0};
	for(; i <= m; ++i; ++p) *p = {0, 0, 0, 0};
	// 構造哈夫曼樹
	for(i = n+1; i <= m; ++i)
	{
		select(HT, i-1, s1, s2); //選擇parent為0且權值最小的兩個結點s1,s2
		HT[s1].parent = i; HT[s2].parent = i;
		HT[i].lchild = s1; HT[i].rchild = s2;
		HT[i].weight = HT[s1].weight + HT[s1].weight;
	}
	// 哈夫曼編碼
	HC = (HuffmanCode)malloc((n+1)*sizeof(char *));
	cd = (char*)malloc(n*sizeof(char));
	cd[n-1] = "\0";
	for(int i = 1; i <= n; ++i)
	{
		start = n-1;
		for(int c = i, f = HT[i].parent; f != 0; c = f, f = HT[f].parent)
			if(HT[f].lchild == c) cd[--start] = "0";
			else cd[--start] = "1";
		HC[i] = (char*)malloc((n-start)*sizeof(char));
		strcpy(HC[i], &cd[start]);
	}
	free(cd);
}
習題

例1 電文的譯碼:分解電文中字串,從根結點出發,按字元0/1確定左、右孩子,直到葉子結點。

char text[maxn] = "101110110111";

for(int i = 0; i < text.length(); i++)
{
	HuffmanTree p; // 輔助變數指向哈夫曼樹首地址
	HuffmanCoding(p, HC, text, text.length()); //HC為哈夫曼編碼表
	p += text.length(); // 將指標指向哈夫曼樹頭結點
	while(p->lchild || p->rchild)
	{
		if(text[i] == 0) p = p->lchild;
		else p = p->rchild;
		j += 1;
	}
}

例2 已知某系統在通訊聯絡中只可能出現8種字元,其概率分佈為0.05, 0.29, 0.07, 0.08, 0.14, 0.23, 0.03, 0.11, 試設計哈夫曼編碼

分析:首先設8個字元的權分別為w={5, 29, 7, 8, 14, 23, 3, 11}, n = 8, 則m = 15, 即在有8個葉子結點的哈夫曼樹上有15個結點,然後構造一棵哈夫曼樹,最後就得到哈夫曼編碼。

哈夫曼樹的應用場景

其實哈夫曼樹使用場景還真不少,例如apache負載均衡的按權重請求策略的底層演算法、咱們生活中的路由器的路由演算法、利用哈夫曼樹實現漢字點陣字形的壓縮儲存、快速檢索資訊等等底層優化演算法,其實核心就是因為目標帶有權重、長度遠近這類資訊才能構建赫夫曼模型。

ps:哈夫曼樹最優子結構性質的證明可以詳見這篇博文演算法學習之哈夫曼編碼演算法