要学习赫夫曼树和赫夫曼编码,先来看一下问题的提出:
一、问题引入。
下面一段程序用来给学生考试成绩划分等级:
这段程序的判断过程如图:
图1
不过这样的判断算法效率可能有问题,因为对于一般的考卷,学生成绩在5个等级上的分布规律如下表:
分数 | 0 ~ 59 | 60 ~ 69 | 70 ~ 79 | 80 ~ 89 | 90 ~ 100 |
所占比例 | 5% | 15% | 40% | 30% | 10% |
仔细观察,中等成绩(70 ~ 79)比例最高,其次是良好(80 ~ 89),不及格所占比例最少。80%的分数都要经过三次及以上的比较。于是把图中的表示判断过程的二叉树重新调整如下图:
图2
看起来判断效率肯定是提高了,但具体提高多少未知。下面就来看看赫夫曼先生是如何说的。
赫夫曼树的定义与原理
首先把上面两颗二叉树简化为叶子结点带权的二叉树(注:树结点之间的边相关的数叫做权(weight))。其中A表示不及格,B表示及格,C表示中等,D表示良好,E表示优秀。
图3
从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称作路径长度。如上图中左边的二叉树,根节点到D的路径长度为4,而右边的二叉树根节点到D的路径长度为2。树的路径长度就是从根节点到每一结点的路径长度之和。上图中左边的二叉树的路径长度为1+1+2+2+3+3+4+4=20,右边的二叉树的路径长度为1+2+2+3+3+1+2+2=16。
如果考虑到带权的结点,结点的带权路径长度就是从该结点到根节点之间的路径长度与结点上权的乘积。树的带权路径长度就是树中所有叶子结点的带权路径长度之和。假设有n个权值{w1,w2,…wn},构造一棵有n个叶子结点的二叉树,每个叶子结点带权wk,每个叶子的路径长度为lk,则其中带权路径长度WPL最小的二叉树称作赫夫曼树或最优二叉树。如图T38中左边二叉树的带权路径长度为WPL=5*1 + 15*2 + 40*3 + 30*4 + 10*4 = 315,右边二叉树的WPL=5*3 + 15*3 + 40*2 + 30*2 + 10*2 = 220。这样就可以看出右边的二叉树的性能要比左边的二叉树的性能高上很多。那右边的二叉树是否是最优的赫夫曼树呢?赫夫曼树是如何构造出来的呢?看看下面的解决办法:
1 先把有权值的叶子结点按照从小到大的顺序排列:A5,E10,B15,D30,C40。
2 取头两个最小权值的结点作为一个新结点N1的两个孩子,相对较小的是左孩子。新结点的权值为两个叶子权值的和。如下图:
3 将N1替换A和E,新序列为:N115,B15,D30,C40。
4 重复步骤2,将N1与B作为新结点N2的两个孩子,N2的权值为15+15=30。如下图:
5 将N2替换N1和B,新序列为:N230,D30,C40。
6 重复步骤2。将N2和D作为新结点N3的两个孩子,N3的权值为30+30=60,如下图:
7 将N3替换N2和D,新序列为:C40,N360。
8 重复步骤2,将C与N3作为新结点T的两个孩子,T是根节点,至此完成赫夫曼树的构造。如下图:
图4
图4中的二叉树的WPL = 40*1 + 30*2 + 15*3 + 10*4 + 5*4 = 205。经过上面步骤构造出来的二叉树就是最优的赫夫曼树。
由此得出赫夫曼树的构造算法描述(贪婪算法):
1 根据给定的n个权值{w1,w2,…wn}构成n棵二叉树的集合F={T1,T2,…Tn},其中每棵二叉树Ti中只有一个带权为wi的根节点,其左右子树为空。
2 在F中选取两棵根节点的权值最小的树作为左右子树构造一棵新的二叉树,且新置的二叉树的根节点的权值为其左右子树根节点的权值之和。
3 在F中删除这两棵树,同时将新得到的二叉树加入F中。
4 重复2和3步骤,直到F只含一棵树为止,这棵树就是赫夫曼树。
赫夫曼编码
赫夫曼在研究这种最优二叉树时的主要目的是解决当年远距离通信(主要是电报)的数据传输的最优化问题。比如传输一串字符“BADCADFEED”,采用二进制数据表示,如下表:
字母 | A | B | C | D | E | F |
二进制字符 | 000 | 001 | 010 | 011 | 100 | 101 |
编码之后的二进制数据流为“001000011010000011101100100011”,对方接收时同样按照3位一组解码。现在假设这6个字母出现的频率不同,A 27%,B %8,C 15%,D 15%,E 30%,F 5%。下面将27、8、15、15、30、5分别作为A、B、C、D、E、F的权值构造赫夫曼树,如下图:
图5
将图5中赫夫曼树的权值左分支改为0,右分支改为1,如下图:
图6
现在将这6个字母用从根节点到叶子所经过路径的0或1来编码,得到的编码表如下:
字母 | A | B | C | D | E | F |
二进制字符 | 01 | 1001 | 101 | 00 | 11 | 1000 |
将“BADCADFEED”再次编码得到“1001010010101001000111100”,共25个字符,与之前编码得到的30个字符相比大约节约了17%的存储和传输成本。
在解码时,用同样的赫夫曼树,即发送方和接收方约定好同样的赫夫曼编码规则。当接收方接收到“1001010010101001000111100”时,比对图T44中的赫夫曼树,由1001正好走到字母B,如下图:
图7
然后是01,则从根结点走到字母A,如下图:
图8
其余的字母也可相应成功解码。
仔细观察上面的赫夫曼编码表中各个字母的编码会发现,不存在容易与1001、1000混淆的10、100等编码。这就说明若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称作前缀编码。
下面是赫夫曼编码的定义:
一般的,设需要编码的字符集为{d1,d2,…,dn},各个字符在电文中出现的次数或频率集合为{w1,w2,…,wn},以d1,d2,…dn作为叶子结点,以w1,w2,…wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根节点到叶子节点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是赫夫曼编码。
二、赫夫曼树的应用场景。
其实赫夫曼树使用场景还真不少,例如apache负载均衡的按权重请求策略的底层算法、咱们生活中的路由器的路由算法、利用哈夫曼树实现汉字点阵字形的压缩存储
(http://www.cnki.com.cn/Article/CJFDTotal-LYGY200504016.htm)、快速检索信息等等底层优化算法,其实核心就是因为目标带有权重、长度远近这类信息才能构建赫夫曼模型。
三、赫夫曼编码的Java实现。
Huffman编码算法主要用到的数据结构是二叉树(full binary tree)和优先级队列。后者用的是java.util.PriorityQueue
,前者自己实现(都为内部类),代码如下:
- static class Tree {
- private Node root;
- public Node getRoot() {
- return root;
- }
- public void setRoot(Node root) {
- this.root = root;
- }
- }
- static class Node implements Comparable<Node> {
- private String chars = "";
- private int frequence = 0;
- private Node parent;
- private Node leftNode;
- private Node rightNode;
- @Override
- public int compareTo(Node n) {
- return frequence - n.frequence;
- }
- public boolean isLeaf() {
- return chars.length() == 1;
- }
- public boolean isRoot() {
- return parent == null;
- }
- public boolean isLeftChild() {
- return parent != null && this == parent.leftNode;
- }
- public int getFrequence() {
- return frequence;
- }
- public void setFrequence(int frequence) {
- this.frequence = frequence;
- }
- public String getChars() {
- return chars;
- }
- public void setChars(String chars) {
- this.chars = chars;
- }
- public Node getParent() {
- return parent;
- }
- public void setParent(Node parent) {
- this.parent = parent;
- }
- public Node getLeftNode() {
- return leftNode;
- }
- public void setLeftNode(Node leftNode) {
- this.leftNode = leftNode;
- }
- public Node getRightNode() {
- return rightNode;
- }
- public void setRightNode(Node rightNode) {
- this.rightNode = rightNode;
- }
- }
static class Tree {
private Node root;
public Node getRoot() {
return root;
}
public void setRoot(Node root) {
this.root = root;
}
}
static class Node implements Comparable<Node> {
private String chars = "";
private int frequence = 0;
private Node parent;
private Node leftNode;
private Node rightNode;
@Override
public int compareTo(Node n) {
return frequence - n.frequence;
}
public boolean isLeaf() {
return chars.length() == 1;
}
public boolean isRoot() {
return parent == null;
}
public boolean isLeftChild() {
return parent != null && this == parent.leftNode;
}
public int getFrequence() {
return frequence;
}
public void setFrequence(int frequence) {
this.frequence = frequence;
}
public String getChars() {
return chars;
}
public void setChars(String chars) {
this.chars = chars;
}
public Node getParent() {
return parent;
}
public void setParent(Node parent) {
this.parent = parent;
}
public Node getLeftNode() {
return leftNode;
}
public void setLeftNode(Node leftNode) {
this.leftNode = leftNode;
}
public Node getRightNode() {
return rightNode;
}
public void setRightNode(Node rightNode) {
this.rightNode = rightNode;
}
}
统计数据
既然要按频率来安排编码表,那么首先当然得获得频率的统计信息。我实现了一个方法处理这样的问题。如果已经有统计信息,那么转为Map<Character,Integer>
即可。如果你得到的信息是百分比,乘以100或1000,或10000。总是可以转为整数。比如12.702%乘以1000为12702,Huffman编码只关心大小问题。统计方法实现如下:
- public static Map<Character, Integer> statistics(char[] charArray) {
- Map<Character, Integer> map = new HashMap<Character, Integer>();
- for (char c : charArray) {
- Character character = new Character(c);
- if (map.containsKey(character)) {
- map.put(character, map.get(character) + 1);
- } else {
- map.put(character, 1);
- }
- }
- return map;
- }
public static Map<Character, Integer> statistics(char[] charArray) {
Map<Character, Integer> map = new HashMap<Character, Integer>();
for (char c : charArray) {
Character character = new Character(c);
if (map.containsKey(character)) {
map.put(character, map.get(character) + 1);
} else {
map.put(character, 1);
}
}
return map;
}
构建树
构建树是Huffman编码算法的核心步骤。思想是把所有的字符挂到一颗完全二叉树的叶子节点,任何一个非页子节点的左节点出现频率不大于右节点。算法为把统计信息转为Node存放到一个优先级队列里面,每一次从队列里面弹出两个最小频率的节点,构建一个新的父Node(非叶子节点), 字符内容刚弹出来的两个节点字符内容之和,频率也是它们的和,最开始的弹出来的作为左子节点,后面一个作为右子节点,并且把刚构建的父节点放到队列里面。重复以上的动作N-1次,N为不同字符的个数(每一次队列里面个数减1)。结束以上步骤,队列里面剩一个节点,弹出作为树的根节点。代码如下:
- private static Tree buildTree(Map<Character, Integer> statistics,
- List<Node> leafs) {
- Character[] keys = statistics.keySet().toArray(new Character[0]);
- PriorityQueue<Node> priorityQueue = new PriorityQueue<Node>();
- for (Character character : keys) {
- Node node = new Node();
- node.chars = character.toString();
- node.frequence = statistics.get(character);
- priorityQueue.add(node);
- leafs.add(node);
- }
- int size = priorityQueue.size();
- for (int i = 1; i <= size - 1; i++) {
- Node node1 = priorityQueue.poll();
- Node node2 = priorityQueue.poll();
- Node sumNode = new Node();
- sumNode.chars = node1.chars + node2.chars;
- sumNode.frequence = node1.frequence + node2.frequence;
- sumNode.leftNode = node1;
- sumNode.rightNode = node2;
- node1.parent = sumNode;
- node2.parent = sumNode;
- priorityQueue.add(sumNode);
- }
- Tree tree = new Tree();
- tree.root = priorityQueue.poll();
- return tree;
- }
private static Tree buildTree(Map<Character, Integer> statistics,
List<Node> leafs) {
Character[] keys = statistics.keySet().toArray(new Character[0]);
PriorityQueue<Node> priorityQueue = new PriorityQueue<Node>();
for (Character character : keys) {
Node node = new Node();
node.chars = character.toString();
node.frequence = statistics.get(character);
priorityQueue.add(node);
leafs.add(node);
}
int size = priorityQueue.size();
for (int i = 1; i <= size - 1; i++) {
Node node1 = priorityQueue.poll();
Node node2 = priorityQueue.poll();
Node sumNode = new Node();
sumNode.chars = node1.chars + node2.chars;
sumNode.frequence = node1.frequence + node2.frequence;
sumNode.leftNode = node1;
sumNode.rightNode = node2;
node1.parent = sumNode;
node2.parent = sumNode;
priorityQueue.add(sumNode);
}
Tree tree = new Tree();
tree.root = priorityQueue.poll();
return tree;
}
编码
某个字符对应的编码为,从该字符所在的叶子节点向上搜索,如果该字符节点是父节点的左节点,编码字符之前加0,反之如果是右节点,加1,直到根节点。只要获取了字符和二进制码之间的mapping关系,编码就非常简单。代码如下:
- public static String encode(String originalStr,
- Map<Character, Integer> statistics) {
- if (originalStr == null || originalStr.equals("")) {
- return "";
- }
- char[] charArray = originalStr.toCharArray();
- List<Node> leafNodes = new ArrayList<Node>();
- buildTree(statistics, leafNodes);
- Map<Character, String> encodInfo = buildEncodingInfo(leafNodes);
- StringBuffer buffer = new StringBuffer();
- for (char c : charArray) {
- Character character = new Character(c);
- buffer.append(encodInfo.get(character));
- }
- return buffer.toString();
- }
public static String encode(String originalStr,
Map<Character, Integer> statistics) {
if (originalStr == null || originalStr.equals("")) {
return "";
}
char[] charArray = originalStr.toCharArray();
List<Node> leafNodes = new ArrayList<Node>();
buildTree(statistics, leafNodes);
Map<Character, String> encodInfo = buildEncodingInfo(leafNodes);
StringBuffer buffer = new StringBuffer();
for (char c : charArray) {
Character character = new Character(c);
buffer.append(encodInfo.get(character));
}
return buffer.toString();
}
- private static Map<Character, String> buildEncodingInfo(List<Node> leafNodes) {
- Map<Character, String> codewords = new HashMap<Character, String>();
- for (Node leafNode : leafNodes) {
- Character character = new Character(leafNode.getChars().charAt(0));
- String codeword = "";
- Node currentNode = leafNode;
- do {
- if (currentNode.isLeftChild()) {
- codeword = "0" + codeword;
- } else {
- codeword = "1" + codeword;
- }
- currentNode = currentNode.parent;
- } while (currentNode.parent != null);
- codewords.put(character, codeword);
- }
- return codewords;
- }
private static Map<Character, String> buildEncodingInfo(List<Node> leafNodes) {
Map<Character, String> codewords = new HashMap<Character, String>();
for (Node leafNode : leafNodes) {
Character character = new Character(leafNode.getChars().charAt(0));
String codeword = "";
Node currentNode = leafNode;
do {
if (currentNode.isLeftChild()) {
codeword = "0" + codeword;
} else {
codeword = "1" + codeword;
}
currentNode = currentNode.parent;
} while (currentNode.parent != null);
codewords.put(character, codeword);
}
return codewords;
}
解码
因为Huffman编码算法能够保证任何的二进制码都不会是另外一个码的前缀,解码非常简单,依次取出二进制的每一位,从树根向下搜索,1向右,0向左,到了叶子节点(命中),退回根节点继续重复以上动作。代码如下:
- public static String decode(String binaryStr,
- Map<Character, Integer> statistics) {
- if (binaryStr == null || binaryStr.equals("")) {
- return "";
- }
- char[] binaryCharArray = binaryStr.toCharArray();
- LinkedList<Character> binaryList = new LinkedList<Character>();
- int size = binaryCharArray.length;
- for (int i = 0; i < size; i++) {
- binaryList.addLast(new Character(binaryCharArray[i]));
- }
- List<Node> leafNodes = new ArrayList<Node>();
- Tree tree = buildTree(statistics, leafNodes);
- StringBuffer buffer = new StringBuffer();
- while (binaryList.size() > 0) {
- Node node = tree.root;
- do {
- Character c = binaryList.removeFirst();
- if (c.charValue() == '0') {
- node = node.leftNode;
- } else {
- node = node.rightNode;
- }
- } while (!node.isLeaf());
- buffer.append(node.chars);
- }
- return buffer.toString();
- }
public static String decode(String binaryStr,
Map<Character, Integer> statistics) {
if (binaryStr == null || binaryStr.equals("")) {
return "";
}
char[] binaryCharArray = binaryStr.toCharArray();
LinkedList<Character> binaryList = new LinkedList<Character>();
int size = binaryCharArray.length;
for (int i = 0; i < size; i++) {
binaryList.addLast(new Character(binaryCharArray[i]));
}
List<Node> leafNodes = new ArrayList<Node>();
Tree tree = buildTree(statistics, leafNodes);
StringBuffer buffer = new StringBuffer();
while (binaryList.size() > 0) {
Node node = tree.root;
do {
Character c = binaryList.removeFirst();
if (c.charValue() == '0') {
node = node.leftNode;
} else {
node = node.rightNode;
}
} while (!node.isLeaf());
buffer.append(node.chars);
}
return buffer.toString();
}
测试以及比较
以下测试Huffman编码的正确性(先编码,后解码,包括中文),以及Huffman编码与常见的字符编码的二进制字符串比较。代码如下:
- public static void main(String[] args) {
- String oriStr = "Huffman codes compress data very effectively: savings of 20% to 90% are typical, "
- + "depending on the characteristics of the data being compressed. 中华崛起";
- Map<Character, Integer> statistics = statistics(oriStr.toCharArray());
- String encodedBinariStr = encode(oriStr, statistics);
- String decodedStr = decode(encodedBinariStr, statistics);
- System.out.println("Original sstring: " + oriStr);
- System.out.println("Huffman encoed binary string: " + encodedBinariStr);
- System.out.println("decoded string from binariy string: " + decodedStr);
- System.out.println("binary string of UTF-8: "
- + getStringOfByte(oriStr, Charset.forName("UTF-8")));
- System.out.println("binary string of UTF-16: "
- + getStringOfByte(oriStr, Charset.forName("UTF-16")));
- System.out.println("binary string of US-ASCII: "
- + getStringOfByte(oriStr, Charset.forName("US-ASCII")));
- System.out.println("binary string of GB2312: "
- + getStringOfByte(oriStr, Charset.forName("GB2312")));
- }
- public static String getStringOfByte(String str, Charset charset) {
- if (str == null || str.equals("")) {
- return "";
- }
- byte[] byteArray = str.getBytes(charset);
- int size = byteArray.length;
- StringBuffer buffer = new StringBuffer();
- for (int i = 0; i < size; i++) {
- byte temp = byteArray[i];
- buffer.append(getStringOfByte(temp));
- }
- return buffer.toString();
- }
- public static String getStringOfByte(byte b) {
- StringBuffer buffer = new StringBuffer();
- for (int i = 7; i >= 0; i--) {
- byte temp = (byte) ((b >> i) & 0x1);
- buffer.append(String.valueOf(temp));
- }
- return buffer.toString();
- }
学生成绩 <60, 60-70, 70-80, 80-90, >90 分布概率分别为:5,15,40,30,10。请编制一个将要百分制转换成五分制的程序,<60, 60-70, 70-80, 80-90, >90 分别的分是:bad,pass,general,good,excellent。请注意效率。