哈夫曼樹與哈夫曼編碼
術語:
i)路徑和路徑長度
在一棵樹中,從一個結點往下可以達到的孩子或孫子結點之間的通路,稱為路徑。 路徑中分支的數目稱為路徑長度。若規定根結點的層數為1,則從根結點到第L層結點的路徑長度為L-1。
ii)結點的權及帶權路徑長度
若對樹中的每個結點賦給一個有着某種含義的數值,則這個數值稱為該結點的權。 結點的帶權路徑長度為:從根結點到該結點之間的路徑長度與該結點的權的乘積。
iii)樹的帶權路徑長度
樹的帶權路徑長度:所有葉子結點的帶權路徑長度之和,記為WPL。
先了解一下哈夫曼樹,之后再構造一棵哈夫曼樹,最后分析下哈夫曼樹的原理。
1)哈夫曼樹
哈夫曼樹是這樣定義的:給定n個帶權值的節點,作為葉子節點,構造一顆二叉樹,使樹的帶權路徑長度達到最小,這時候的二叉樹就是哈夫曼樹,也叫最優二叉樹。
哈夫曼樹具有如下性質:
1)帶權路徑長度最短
2)權值較大的結點離根較近
2)構造哈夫曼樹
構造哈夫曼樹的步驟如下:
假設有n個權值,則構造出的哈夫曼樹有n個葉子結點。 n個權值分別設為 w1、w2、…、wn,則哈夫曼樹的構造規則為:
1) 將w1、w2、…,wn看成是有n 棵樹的森林(每棵樹僅有一個結點);
2) 在森林中選出兩個根結點的權值最小的樹合並,作為一棵新樹的左、右子樹, 且新樹的根結點權值為其左、右子樹根結點權值之和
3)從森林中刪除選取的兩棵樹,並將新樹加入森林
4)重復2)、3)步,直到森林中只剩一棵樹為止,該樹即為所求得的哈夫曼樹
根據如上規則,可以按部就班的寫出代碼,Go 語言的描述如下:
package main import ( "fmt" "errors" "os" ) type BNode struct { key string value float64 ltree, rtree *BNode } func getMinNodePos(treeList []*BNode) (pos int, err error) { if len(treeList) == 0 { return -1, errors.New("treeList length is 0") } pos = -1 for i, _ := range treeList { if pos < 0 { pos = i continue } if treeList[pos].value > treeList[i].value { pos = i } } return pos, nil } func get2MinNodes(treeList []*BNode) (node1, node2 *BNode, newlist []*BNode) { if len(treeList) < 2 { } pos, err := getMinNodePos(treeList) if nil != err { return nil, nil, treeList } node1 = treeList[pos] newlist = append(treeList[:pos], treeList[pos + 1 :]...) pos, err = getMinNodePos(newlist) if nil != err { return nil, nil, treeList } node2 = newlist[pos] newlist = append(newlist[:pos], newlist[pos + 1 :]...) return node1, node2, newlist } func makeHuffmanTree(treeList []*BNode) (tree *BNode, err error) { if len(treeList) < 1 { return nil, errors.New("Error : treeList length is 0") } if len(treeList) == 1 { return treeList[0], nil } lnode, rnode, newlist := get2MinNodes(treeList) newNode := new(BNode) newNode.ltree = lnode newNode.rtree = rnode newNode.value = newNode.ltree.value + newNode.rtree.value newNode.key = newNode.ltree.key + newNode.rtree.key; newlist = append(newlist, newNode) return makeHuffmanTree(newlist) } func main() { keyList := []byte {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'} valueList := []float64 {0.12, 0.4, 0.29, 0.90, 0.1, 1.1, 1.23, 0.01} treeList := []*BNode {} for i, x := range keyList { n := BNode{key:string(x), value:valueList[i]} treeList = append(treeList, &n) } tree, err := makeHuffmanTree(treeList) if nil != err { fmt.Println(err.Error()) } //TODO you can make it yourself //showTree(tree) }
得到的哈夫曼樹如下:
其中的橙色結點都是數據中的權值結點。
計算一下這棵樹的帶權路徑長度:
WPL=0.9x2 + 0.4x3 + 0.01x6 + 0.01x6 + 0.1x6 + 0.12x5 + 0.29x4 + 1.1x2 + 1.23x2 = 10.08
計算好了,但是這個帶權路徑是最小的嗎?下面就看一下理論依據。
3)哈夫曼樹的證明
設有t片葉子,權值分別為W1,W2,W3,...,Wt。 定義二叉樹的權值為W(T)=∑Wi*L(vi),其中Vi是帶權為Wi的葉子, L(vi)是葉子Vi的路徑長度,接下來我們就求W(T)的最小值。
1)權值最小的葉子節點距離樹根節點的距離不比其它葉子節點到樹根結點的距離近
不失一般性,我們不妨設W1≤W2≤W3≤...≤Wt,並且W1和W2的葉子是兄弟。 先隨意給出一棵符合條件的二叉樹,再逐步把它調整到最佳。 設S是非葉子結點中路徑最長的一點,假設S的兒子不是V1和V2,而是其他的Vx和Vy, 那么L(Vx)≥L(V1),L(Vx)≥L(V2),L(Vy)≥L(V1), L(Vy)≥L(V1),注意到Vx,Vy≥V1,V2, 所以我們交換Vx和V1,Vy和V2,固定其他的量不變,則我們得到的二叉樹的權值差為 [V1L(Vx)+ V2L(Vy)+ VxL(V1)+ VyL(V2)]- [V1L(V1)+ V2L(V2)+ VxL(Vx)+ VyL(Vy)]=(V1- Vx)(L(Vx)- L(V1))+(V2-Vy)(L(Vy)-L(V2))≤0,所以調整后權值減小了。 故S的兒子必定為v1和v2。
2)哈夫曼樹是最優的
設Tx是帶權W1,W2,W3,...,Wt的二叉樹,在Tx中用一片葉子代替W1,W2這兩片樹葉和它們的雙親組成的子樹,並對它賦權值為W1+W2,設Tx'表示帶權W1+W2,W3,W4,...,Wt的二叉樹,則顯然有W(Tx)=W(Tx')+W1+W2,所以若Tx是最優樹,則Tx'也是最優樹,所以逐步往下調整可以把帶有t個權的最優樹簡化到t-1個,再從t-1個簡化到t-2個,...,最后降到帶有2個權的最優樹
4)哈夫曼編碼
哈夫曼編碼是可變字長編碼(VLC)的一種,Huffman於1952年提出的編碼方法, 該方法完全依據字符出現概率來構造異字頭的平均長度最短的碼字, 有時稱之為最佳編碼,一般就叫做Huffman編碼。
1951年,哈夫曼和他在MIT信息論的同學需要選擇是完成學期報告還是期末考試。 導師Robert M. Fano給他們的學期報告的題目是,尋找最有效的二進制編碼。 由於無法證明哪個已有編碼是最有效的,哈夫曼放棄對已有編碼的研究, 轉向新的探索,最終發現了基於有序頻率二叉樹編碼的想法, 並很快證明了這個方法是最有效的。由於這個算法,學生終於青出於藍, 超過了他那曾經和信息論創立者香農共同研究過類似編碼的導師。 哈夫曼使用自底向上的方法構建二叉樹, 避免了次優算法Shannon-Fano編碼的最大弊端──自頂向下構建樹。
1952年,David A. Huffman在麻省理工攻讀博士時發表了《一種構建極小多余編碼的方法》 (A Method for the Construction of Minimum-Redundancy Codes)一文, 它一般就叫做Huffman編碼。
Huffman在1952年根據香農(Shannon)在1948年和范若(Fano) 在1949年闡述的這種編碼思想提出了一種不定長編碼的方法, 也稱霍夫曼(Huffman)編碼。霍夫曼編碼的基本方法是先對圖像數據掃描一遍, 計算出各種像素出現的概率,按概率的大小指定不同長度的唯一碼字, 由此得到一張該圖像的霍夫曼碼表。編碼后的圖像數據記錄的是每個像素的碼字, 而碼字與實際像素值的對應關系記錄在碼表中。
哈夫曼樹就是為生成哈夫曼編碼而構造的。哈夫曼編碼的目的在於獲得平均長度最短的碼字, 所以下面我么以一個簡單的例子來演示一下, 通過哈夫曼編碼前后數據占用空間對比,來說明一下哈夫曼編碼的應用。
4.1)編碼
這里有一片英文文章《If I Were a Boy Again》,我們首先統計其中英文字符和標點符號出現的頻率。(按照字符在字母表中的順序排序)
字符 | 頻數 | 比例 |
---|---|---|
換行 | 36 | 2.236 |
空格 | 271 | 16.832 |
" | 4 | 0.248 |
, | 21 | 1.304 |
. | 15 | 0.932 |
; | 2 | 0.124 |
F | 1 | 0.062 |
I | 23 | 1.429 |
L | 1 | 0.062 |
N | 1 | 0.062 |
T | 1 | 0.062 |
W | 1 | 0.062 |
a | 98 | 6.087 |
b | 23 | 1.429 |
c | 31 | 1.925 |
d | 38 | 2.360 |
e | 143 | 8.882 |
f | 36 | 2.236 |
g | 25 | 1.553 |
h | 43 | 2.671 |
i | 80 | 4.969 |
k | 11 | 0.683 |
l | 69 | 4.286 |
m | 31 | 1.925 |
n | 89 | 5.528 |
o | 109 | 6.770 |
p | 20 | 1.242 |
q | 1 | 0.062 |
r | 80 | 4.969 |
s | 67 | 4.161 |
t | 105 | 6.522 |
u | 45 | 2.795 |
v | 16 | 0.994 |
w | 34 | 2.112 |
y | 39 | 2.422 |
接下來構造一棵哈夫曼樹:
我們依然使用本文最開始使用的代碼進行哈夫曼樹的構造。 以每個字符為葉子節點,字符出現的次數為權值,構造哈夫曼樹。
構造出的哈夫曼樹圖片有點兒大,這個頁面放不下,有興趣的同學到這里看看。
獲取葉節點的哈夫曼編碼的Go語言代碼如下:
//葉子結點的哈夫曼編碼存儲在map m里面 func getHuffmanCode(m map[string]string, tree *BNode){ if nil == tree { return } showHuffmanCode(m, tree, "") } func showHuffmanCode(m map[string]string, node *BNode, e string) { if nil == node { return } //左右子結點均為nil,則說明此結點為葉子節點 if nil == node.ltree && nil == node.rtree { m[node.key] = e } //遞歸獲取左子樹上葉子結點的哈夫曼編碼 showHuffmanCode(m, node.ltree, e + "0") //遞歸獲取右子樹上葉子結點的哈夫曼編碼 showHuffmanCode(m, node.rtree, e + "1") }
根據哈夫曼樹得出的每個葉子節點的哈夫曼編碼如下(按照頻數排序):
字符 | 頻數 | 哈夫曼編碼 |
---|---|---|
W | 1 | 10110011110 |
F | 1 | 10110011111 |
L | 1 | 1011001001 |
N | 1 | 1011001000 |
q | 1 | 1011001010 |
; | 2 | 1011001110 |
" | 4 | 101100110 |
k | 11 | 1011000 |
. | 15 | 1110000 |
v | 16 | 1110001 |
p | 20 | 010100 |
, | 21 | 010101 |
I | 23 | 011110 |
b | 23 | 011111 |
g | 25 | 101101 |
m | 31 | 101111 |
c | 31 | 101110 |
w | 34 | 111001 |
換行 | 36 | 111111 |
f | 36 | 111110 |
d | 38 | 00100 |
y | 39 | 00101 |
h | 43 | 01011 |
u | 45 | 01110 |
s | 67 | 11101 |
l | 69 | 11110 |
r | 80 | 0100 |
i | 80 | 0011 |
n | 89 | 0110 |
a | 98 | 1000 |
t | 105 | 1001 |
o | 109 | 1010 |
e | 143 | 000 |
空格 | 271 | 110 |
這里頻數就是權值,可以看到,權值越小的距離根結點越遠,編碼長度也就越大。
比如W在整篇文章中只出現了一次,頻數是1,權重很小,而它的編碼是10110011110,很大吧。
編碼替換
下一步開始進行數據壓縮,就是根據上表,把文章中出現的所有字符替換成對應的哈夫曼編碼。 不是以字符串形式的"010101",而是二進制形式的"010101",就是bit位操作, 不過這里為了簡便,就省略了bit操作的步驟,而是以01字符串來表示二進制的01 bit流。。
進行內容替換的Go語言代碼如下:
func HuffmanCode(m map[string]string, tree *BNode, strContent string) string { if nil == tree{ return "" } strEncode := "" for _, v := range strContent { strEncode += m[string(v)] } return strEncode }
下面是一些統計數據:
原文章內容:1610字節
壓縮后長度:886字節(885.375)
壓縮率:54.99%
當然,這只是內容的數據部分,我們還需要存儲剛剛生成的"字符-編碼"對照表, 所以綜合的壓縮率不會這么大。當前的程序是基礎的使用哈夫曼編碼進行數據壓縮的方法, 還可以在基礎的方法之上進行改進,壓縮率會更大。
4.2)解碼
解碼是編碼的逆過程。讀取加密的數據流,當接到一個bit的時候, 將當前的bit數組去和"字符-編碼"表中的編碼進行比較,如果匹配成功, 則將其替換成編碼對應的字符,當前bit數組清空,繼續讀取字節流並記錄。
下面是一個段解碼的代碼片段:
func HuffmanDecode(mapTable map[string]string, str string) { //把"字符-編碼"的map反轉一下,變成"編碼-字符"的map,便於查找比對。 mapRTable := make(map[string]string) for k, v := range mapTable { mapRTable[v] = k } var strCode string getWord := func (b byte) (strWord string, r bool){ strCode += string(b) strWord = mapRTable[strCode] if "" == strWord { return "", false } strCode = "" return strWord, true } strDecode := "" for _, v := range []byte(str) { //每讀取一個bit位都要進行一次搜索,目前效率有點兒低哈~.~ if strWord, b := getWord(v); b { //如果匹配成功,則把匹配到的字符追加到結尾 strDecode += strWord } } fmt.Printf("decode : [%s]\n", strDecode) }
同步發表:http://www.fengbohello.top/blog/p/lkvq