一、哈夫曼编码
- 哈夫曼编码简介
哈夫曼编码可以有效地压缩数据,通常可以节省20%~90%的空间,具体压缩率依赖于数据的特性。我们将待压缩数据看作字符序列。根据每个字符的出现频率,哈夫曼贪心算法构造出字符最优的二进制表示。
- 假定我们希望压缩一个十万个字符的数据文件,设文中只有6个不同字符,每个字符的频次、定长编码、变长编码如下表所示:
信息 | a | b | c | d | e | f |
---|---|---|---|---|---|---|
频次(千次) | 45 | 13 | 12 | 16 | 9 | 5 |
定长编码 | 000 | 001 | 010 | 011 | 100 | 101 |
导管 | 0 | 101 | 100 | 111 | 1101 | 1100 |
- 说明:以上这么多个字符的文件,只包含1~6个不同字符。如果用传统的定长编码来表示一个字符,一个字符用三位二进制表示,则文件编码长度是30万位,如用变长编码表示,则仅需224000位。
- 变长编码可以达到比定长编码好得多的压缩率,其思想是赋予高频字符短码字,低频字符长码字。实际上,这种编码方法是此文件的最优字符编码,也就是哈夫曼编码。
- 前缀码
所谓前缀码就是没有任何码字是其他码字的前缀。比如,a的码字是0,而0不是任何b~f一个字母对应码字的前缀(从上表可以看出其他码字的第一位均为1);b的码字是101,而101也不是任何其他字母对应码字的前缀。可以证明前缀码确实可以保证达到最优数据压缩率。
任何二进制的字符吗的编码过程很简单,只要将表示每个字符的码字链接起来即可完成文件压缩。比如,采用上表的变长前缀码,我们可以将三个字符的文件abc编码成0+101+100=0101100
前缀码还有一个作用就是可以简化解码的过程。由于没有码字是其他码字的前缀,编码文件的开始码字是是无歧义的。我们可以简单地识别出开始码字,将其转为原字符,然后对编码文件其余部分重复这种解码过程。
- 前缀码解码方式举例:我们现在要根据上表解码0101100.首先扫描到字符0,对应a,现在不用继续往后扫描了,因为根据前缀码的无歧义性质,不可能有01,00,0101等码字有对应的字符了。现在解101100,扫描到1,没有对应字符,继续扫描到10,也没有,继续扫描到101,对应b,停止本次扫描;下一轮扫描到100,对应c.所以这种二进制串可以唯一对应一串字符。
二、哈夫曼树
- 概念引入
解码的过程需要前缀码的一种方便的表示形式,以便我们可以很容易地截取开始码字。一种二叉树可以满足这种需求,其叶子结点为给定的字符,字符的二进制码字用从根节点到该字符的叶子结点的简单路径表示,其中0意味着转向左孩子,1意味着转向右孩子。下图给出了两种编码方法的二叉树表示:
- 举例:在定长编码方法中,看二叉树,从根节点到c,先转向左孩子,再转向右孩子,再转向左孩子,对应编码010.以此类推。
可以看到,文件的最优编码方案总是对应一颗满二叉树,即这个二叉树的结点要么为叶子结点,要么有两个孩子。所以我们的重点就放在这个满二叉树上。设C为字母表且所有字符的出现频率均为正数,则最优前缀码对应的数恰好有|C|个叶子结点,每个叶子结点对应字母表中的一个字符,而且恰好有|C|-1个内部结点。这种满二叉树就是哈夫曼树
- 计算哈夫曼树T编码一个文件所需的二进制位数
对字母表C中的每个字符c,令属性c.freq表示c在文件中出现的频率,令dT©表示c的叶子结点在树中的深度,也是字符c的码字长度。则编码文件需要的二进制位数B(T)为
B ( T ) = ∑ c ∈ C c . f r e q ⋅ d T ( c ) B(T) = \sum\limits_{c\in C}c.freq·d_T(c) B(T)=c∈C∑c.freq⋅dT(c)
这里我们将B(T)定义为T的代价。
三、哈夫曼算法
- 算法描述
哈夫曼设计了一个贪心算法来构造最优前缀码,称为哈夫曼算法。它的正确性的证明也依赖于贪心的选择性质和最优子结构。接下来我们看看哈夫曼算法是怎么设计的。
假定C是一个n个字符的几个,而其中的每个字符c∈C都是一个对象,其属性c.freq给出了字符的出现频率。算法自底向上地构造除了对应最优编码的二叉树。
HuffmanCode(C){
Q = C; //初始化最小优先队列Q
for(i = 1 ~ n - 1){ //循环n-1次
Node z; //新节点z
z.left = x = getMin(Q);
z.right = y = getMin(Q); //分别提取频次的两个最小值,x,y作为z的左右孩子
z.freq = x.freq + y.freq; //新节点频次等于两个原来的最小频次之和
insert(Q,z) //将z插入到Q中,取代x,y,更新节点Q队列
}
return HFMroot; //返回哈夫曼树的根节点
}
- 说明:从|C|个叶子结点开始,执行|C|-1个“合并”操作创建出最终的二叉树。算法使用一个以属性freq为关键字最小优先队列Q,以识别两个最低频次的对象将其合并。当合并两个对象时,得到的新对象的频次设为原来两个对象的频率之和。
- 对上文给出的例子,哈夫曼树的执行过程如(a)~(f)所示。初始队列大小为n = 6,需要合并五次来构造二叉树,最终的二叉树表示的就是最优前缀编码,一个字母与的码字为根节点到该字母叶子结点的简单路径上边标签的序列。
- 说明分析:对上图给出的频次执行哈夫曼算法的过程。每一个部分显示了优先队列的内容,已经按照频率递增顺序排好序。在每个步骤,频率最低的两棵树进行合并。叶子结点用矩形表示,每个叶子结点包含一个字符及其频率。内部结点用圆圈表示,包含其孩子结点的频率之和。内部结点指向左孩子的边标记为0,指向右孩子的边标记为1。一个字母的码字对于从根节点到其叶子结点的路径上的边的标签序列。
(a)将个字符的频次递增排序
(b)~(f)找出前两个最小的频次元素,分别作为左右孩子(左右顺序随意),合并成一个新节点,频次为14,取代5,9,放入优先队列Q中,现在Q为:[12,13,14(带2节点),16,45];再从中取出12,13,分别作为左右孩子,合并是频次为25的节点,取代放入优先队列Q,现在Q为[14(带2节点),16,25(带2节点),45],以此类推…
PS:优先队列Q也可以用有序表代替
四、哈夫曼算法的Java语言实现
import java.util.ArrayList;
import java.util.HashMap;
/**
* 哈夫曼树节点类
* @author hyn
*/
class HFM_Node{
int frequency; //频次
HFM_Node left; //左孩子
HFM_Node right;//右孩子
char aChar; //结点字符(叶子结点才有)
String hfmCode = "";//结点的哈夫曼编码
/**
* 第一个构造方法
* @param frequency 频次
* @param left 左孩子
* @param right 右孩子
*/
public HFM_Node(int frequency, HFM_Node left, HFM_Node right){
this.frequency = frequency;
this.left = left;
this.right = right;
}
/**
* 第二个构造方法
* @param frequency 频次
* @param left 左孩子
* @param right 右孩子
* @param aChar 字符
*/
public HFM_Node(int frequency, HFM_Node left, HFM_Node right, char aChar){
this.frequency = frequency;
this.left = left;
this.right = right;
this.aChar = aChar;
}
}
/**
* 哈夫曼树类
* @author hyn
*/
public class HFMTree {
private ArrayList<HFM_Node> freqs = new ArrayList<>(); //节点队列Q
private HFM_Node root; //树根节点
private int nodeNum; //节点总数
HashMap<Integer,Character> hashMap; //频次-字符字典
/**
* 用构造器直接构造哈夫曼树
* @param map 用户传入的频次-字符 哈希表字典
*/
public HFMTree(HashMap<Integer,Character> map){
creatFreqArray(map);
this.root = creatHFMTree();
}
/**
* 创建频次有序表
* @param map 储存频次及其对应字符的字典
*/
private void creatFreqArray(HashMap<Integer,Character> map){
this.hashMap = map;
nodeNum = map.size();
for (HashMap.Entry<Integer,Character> entry:map.entrySet()) {
HFM_Node node = new HFM_Node(entry.getKey(),null,null,entry.getValue());
freqs.add(node);
}
sort(); //更新表后排序
}
/**
* ArrayList插入排序,使得数组内部结点关键字按升序排列
*/
private void sort(){
int n = freqs.size();
for(int i = 1;i < n;i++){
for(int j = i;j>0;j--){
if(freqs.get(j).frequency<freqs.get(j - 1).frequency){
HFM_Node temp = freqs.get(j);
freqs.set(j,freqs.get(j - 1));
freqs.set(j - 1,temp);
} else break;
}
}
}
/**
* 创建哈夫曼树
* @return 哈夫曼树根节点
*/
private HFM_Node creatHFMTree(){
for(int i = 1;i < nodeNum;i++){
System.out.println("合并!");
HFM_Node nodeX = freqs.remove(0); //当前最小的结点(就在队首)出队
HFM_Node nodeY = freqs.remove(0); //当前最小的结点(就在队首)出队
int newFreq = nodeX.frequency + nodeY.frequency; //新的节点的频次
HFM_Node nodeZ = new HFM_Node(newFreq,nodeX,nodeY); //生成新节点,左右孩子别指向之前出队的两个节点
freqs.add(nodeZ);
sort(); //更新排序
}
this.root = freqs.get(0); //最后freqs队列就剩一个元素(哈夫曼树根节点)
return root;
}
/**
* 获取根节点
* @return 哈夫曼树根节点
*/
public HFM_Node getRoot() {
return root;
}
/**
* 先序遍历哈夫曼树进行哈夫曼编码并输出
* 除了根节点,逐次求每一个结点的哈夫曼编码,以便求出最后叶子结点的哈夫曼编码
* @param root 哈夫曼树根节点
*/
private void preOrder(HFM_Node root){
if(root != null){
if (root.left != null) { //如果左节点非空。则对左节点进行编码并递归先序遍历
root.left.hfmCode = root.hfmCode+0; //向左走,字符串末尾加0
preOrder(root.left);
}
if(root.left == null && root.right == null){ //输出叶子结点的字符及其哈夫曼编码
System.out.println(hashMap.get(root.frequency)+":"+root.hfmCode);
}
if(root.right != null) { //如果右节点非空。则对右节点进行编码并递归先序遍历
root.right.hfmCode = root.hfmCode+1;//向右走,字符串末尾加1
preOrder(root.right);
}
}
}
public void preOrder(){
preOrder(root);
}
public static void main(String[] args) {
HashMap<Integer,Character> map = new HashMap<>();
map.put(45,'a');
map.put(13,'b');
map.put(12,'c');
map.put(16,'d');
map.put(9,'e');
map.put(5,'f');
HFMTree t = new HFMTree(map);
t.preOrder();
}
}
说明:
- 定义了两个类:哈夫曼树节点类和哈夫曼树类
- 在哈夫曼节点类中定义了五个属性:字符及其频次和哈夫曼编码,左右孩子。以及2个有参构造方法。
- 哈夫曼树类中有4个属性:节点队列Q,树根节点,节点总数,以及频次对应字符的哈希表。
- 构造方法用来传入哈希表后直接构造哈夫曼树。
- creatFreqArray方法用于把传入的哈希表的键值对逐个存入到Q中,用到ForEach循环进行哈希表遍历,存储完毕之后就对Q按频次进行插入排序。
- creatHFMTree方法用来进行哈夫曼树的构造,参考上文的伪代码。
- preOrder方法用递归的方式对哈夫曼树进行先序遍历,并进行哈夫曼编码操作并输出结果,编码方法是边遍历结点边对结点逐个编码,参考注释。
- 运行结果:
OVER,如有更好的方法或有不足之处,欢迎指正!!!