1951年,Huffman在MIT信息论的同学需要选择是完成学期报告还是期末考试。导师Robert M. Fano给他们的学期报告的题目是,寻找最有效的二进制编码。由于无法证明哪个已有编码是最有效的,Huffman放弃对已有编码的研究,转向新的探索,最终发现了基于有序频率二叉树编码的想法,并很快证明了这个方法是最有效的。由于这个算法,学生终于青出于蓝,超过了他那曾经和信息论创立者香农共同研究过类似编码的导师。Huffman使用自底向上的方法构建二叉树,避免了次优算法Shannon-Fano编码的最大弊端──自顶向下构建树。
以上是我从百度百科上的摘抄。我们大致可以从中了解赫夫曼编码的历史以及其根本作用——有效地压缩数据。需要多说一句的是,对于Huffman编码的中文译名有大概三种:“哈夫曼”、“霍夫曼”、“赫夫曼”。其实都是同一个人,同一种编码了。本文中,我采用《算法导论》中的译名——赫夫曼。
好了,言归正传。先看看如果需要对字符型的数据文件编码,在赫夫曼编码产生之前是怎样做的。假设现在有一个10万字符的数据文件,它只出现了6个不同的字符:a,b,c,d,e,f,他们出现的频率,经统计,如下表所示:
a | b | c | d | e | f | |
---|---|---|---|---|---|---|
频率(千次) | 45 | 13 | 12 | 16 | 9 | 5 |
定长编码 | 000 | 001 | 010 | 011 | 100 | 101 |
变长编码 | 0 | 101 | 100 | 111 | 1101 | 1100 |
既然是有6个字符,那么,根据二进制编码的规律,3bit的二进制串就可以把这6个字符完全表示出来,因为3bit的二进制串最多可以表示8个互不相同的项目。表示出来的编码形式就是表中“定长编码”那一行所示。
这么一来,通过计算得知,我们按照这种方法编码文件,一共生成了300000个bit. 再来看看表格最后一行的变长编码,它的基本逻辑是这样:对字符采取不定长的编码方式,其中,对于字符频率越高的字符,用相对较短的编码;而对于字符频率越低的字符,则采用较长的编码。
这样,即便变长编码时,有些字符的编码长度超过了定长编码的情况(比如这里e和f的编码长度都是4,大于3),但总体来说,还是相当节省空间的。比如,表格中的例子,经过计算,变长编码的文件大小是224000bit,比起定长编码,大约节省了1/4.
好了,接下来,进入主题,这种高效的变长编码,也就是赫夫曼编码是如何实现的?
首先,补充一个概念——前缀码。它指的是这样一种编码方式:没有任何字符是其他字符的前缀。所以,从语义上理解,好像叫“非前缀码”要更贴切一点,但是这个名字已经是业内标准,也只能这么叫了。赫夫曼编码正是这样一种前缀码。也因为它的这种性质,我们在对赫夫曼编码的文件解码时,不会对首个字符产生歧义,以此类推,就能顺利解码了。
这里,说解码的原因是告诉大家,不用担心这种变长编码的解码问题。
至于如何构造赫夫曼编码,关键就在于构造赫夫曼树。赫夫曼树,是一棵满的二叉树(所谓“满”是指它的任何一个非叶节点都有两个孩子),它采用的是一种从底置顶,自下而上的构建方式。具体如下:
(1) 拿上面表格中的数据为例,我们现将所有字符根据其频率按升序排序:我在这里干脆写成了Python中字典的形式,方便观察。
{'f': 5, 'e': 9, 'c': 12, 'b': 13, 'd': 16, 'a': 45}
(2) 为这个排好序的字典的每个条目,生成一个树节点,树节点类这样构造:
class HuffmanTreeNode(object):
def __init__(self, s, freq):
self.val = s
self.freq = freq
self.left, self.right = None, None
也就是说,每个赫夫曼树的节点除了左右指针之外,还有两个属性:val表示节点代表的字符s,freq代表这个字符出现的频率。
这样,将上面的字典就转化成了每个元素都是赫夫曼树节点的最小优先队列,为方便描述,把这个队列叫做orderedNodeList
。
(3) 现在,将这个队列的头两个元素删除出队列(也就是删除了频率最低的两个字符’f’和’e’所在的节点),实施合并,合并的结果是生成了一个新的赫夫曼树节点,这个节点的freq值为’f’和’e’的频率和,s值为空,左右孩子分别是之前’f’和’e’所在的节点,如图Fig.1所示。然后将这个新建的节点按其freq值得大小插入原先的OrderedNodeList
,以此类推,对于最开始有n个节点的队列,进行n - 1次合并操作,最终,队列中也就只有1个节点了,这也就是整个赫夫曼树的根节点。
实现代码如下:
class HuffmanTreeNode(object):
def __init__(self, s, freq):
self.val = s
self.freq = freq
self.left, self.right = None, None
def genOrderedNodeList(charDict):
"""return the the minimum priority queue"""
orderedNodeList = []
for i in sorted(charDict.items(), key=lambda x: x[1]):
orderedNodeList.append(HuffmanTreeNode(i[0], i[1]))
return orderedNodeList
def allocate(node1, node2):
"""allocate the 2 nodes"""
new_freq = node1.freq + node2.freq
new_node = HuffmanTreeNode(None, new_freq)
new_node.left, new_node.right = node1, node2
return new_node
def insertNode(orderedNodeList, new_node):
"""insert the new node to orderedNodeList"""
for ele in orderedNodeList:
if new_node.freq < ele.freq:
orderedNodeList.insert(orderedNodeList.index(ele), new_node)
return
orderedNodeList.append(new_node)
def huffman(orderedNodeList):
"""generate the huffman tree, and return the tree root"""
n = len(orderedNodeList)
for i in range(n - 1):
# pop the minimum 2 nodes
node1 = orderedNodeList.pop(0)
node2 = orderedNodeList.pop(0)
# allocate the 2 nodes
new_node = allocate(node1, node2)
# insert the new node to orderedNodeList
insertNode(orderedNodeList, new_node)
return orderedNodeList[0]
其中,各个函数的作用我已经写在了函数文档串以及注释中。
把整个赫夫曼树的结构用图Fig.2表示出来,也许能更清楚:
个人感觉先对赫夫曼树进行深度优先搜索,生成在文章一开始的时候那个字符与码字的对照表,然后对需要编码的文件的每个字符按照对照表查找编码要比直接在赫夫曼树种寻找字符要快。
生成对照表的算法可以使用非常经典的“深度优先搜索”,对这个算法还有疑问的话,请参照我的博客二叉树的所有路径,那篇博客里介绍的问题与今天的生成对照表是几乎一样的。这里,我只给出代码:
def genCharCodeTable(root):
"""return the char-code table"""
path = ""
charCodeTable = {}
huffmanDFS(root, path, charCodeTable)
return charCodeTable
def huffmanDFS(root, path, charCodeTable):
"""auxiliary function of genCharCodeTable which used DFS"""
if root is None:
return
if root.left is None and root.right is None:
charCodeTable[root.val] = path
if root.left:
path += '0'
huffmanDFS(root.left, path, charCodeTable)
if root.right:
path = path[:-1] + '1'
huffmanDFS(root.right, path, charCodeTable)
这样,就能对一组“字符-频率”型的数据进行编码,得到每个字符对应的码字了。把上面的所有代码放在一起,构成一个脚本。最底下写一个测试函数,如下:
if __name__ == "__main__":
termFreq = {'a': 45, 'b': 13, 'c': 12, 'd': 16, 'e': 9, 'f': 5}
orderedNodeList = genOrderedNodeList(termFreq)
root = huffman(orderedNodeList)
print(genCharCodeTable(root))
结果正是我们在文章一开头看到的对照表:{'a': '0', 'f': '1100', 'b': '101', 'e': '1101', 'd': '111', 'c': '100'}
赫夫曼树的表示形式是一种方便的解码工具,可以直接拿过来用。实现的方法也很简单,就是二叉树的遍历。
直接给出代码:
def huffmanDecode(text, root):
result = ""
cur = root
for i in text:
if not isLeaf(cur) and i == "0":
cur = cur.left
elif not isLeaf(cur) and i == "1":
cur = cur.right
if isLeaf(cur):
result += cur.val
cur = root
return result
def isLeaf(node):
return node.left is None and node.right is None
用以下函数测试:
if __name__ == "__main__":
termFreq = {'a': 45, 'b': 13, 'c': 12, 'd': 16, 'e': 9, 'f': 5}
orderedNodeList = genOrderedNodeList(termFreq)
root = huffman(orderedNodeList)
text = "001011101"
print(huffmanDecode(text, root))
结果是”aabe”,和我们直接查找对照表是一致的。
有关这个项目的完整代码,请访问我的github主页:
https://github.com/guoziqingbupt/Huffman-Coding
github上对于项目模块的划分和设计比博客里讲的会更加合理。博客上的代码只是展示算法原理和逻辑。