哈夫曼树与哈夫曼编码
术语:
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] ", strDecode) }
同步发表:http://www.fengbohello.top/blog/p/lkvq