第十二章 哈夫曼树和哈夫曼编码
一、哈夫曼树
给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近
1.基本术语
WPL 最小的二叉树是赫夫曼树
- 路径和路径长度:
在一棵树中,从上一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为 1,则从根结点到第 L 层结点的路径长度为 L - 1 - 结点的权及带权路径长度:
若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积 - 树的带权路径长度:
树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为 WPL(Weight Path Length)
2.构建思路
假设有 n 个权值,则构造出的哈夫曼树有 n 个叶子结点。n 个权值分别设为 w1、w2、w3、…、wn
- 将 w1、w2、w3、…、wn 看成是有 n 棵树的森林(每棵树仅有一个结点)
- 在森林中选出根结点的权值最小的两棵树进行合并,作为一棵新树的左、右子树,且新树的根结点权值为其左右子树根结点的权值之和
- 从森林中删除选取的两棵树,并将新树加入森林
- 重复 2 和 3,直到森林中只剩一棵树为止,该树即为所求的哈夫曼树
以 {5,6,7,8,15} 为例
- 创建森林,森林包括 5 棵树,这 5 棵树的权值分别是5,6,7,8,15
- 在森林中,选择根结点权值最小的两棵树(5 和 6)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 11,将 5 和 6 从森林删除,添加新树 11
- 在森林中,选择根结点权值最小的两棵树(7 和 8)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 15,将 7 和 8 从森林删除,添加新树 15
- 在森林中,选择根结点权值最小的两棵树(11 和 15)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 26,将 11 和 15 从森林删除,添加新树 26
- 在森林中,选择根结点权值最小的两棵树(15 和 26)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 41,将 15 和 26 从森林删除,添加新树 41
此时森林中只剩一棵树 41,该树即为所求的哈夫曼树
3.代码实现
package com.sisyphus.huffmantree;
import java.util.ArrayList;
import java.util.Collections;
/**
* @Description: 哈夫曼树$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/24$
*/
public class HuffmanTree {
public static void main(String[] args) {
int arr[] = {13,7,8,3,29,6,1};
createHuffmanTree(arr);
}
//创建哈夫曼树的方法
public static Node createHuffmanTree(int[] arr){
//第一步为了操作方便
//1.遍历 arr 数组
//2.将 arr 的每个元素构成 Node
//3.将 Node 放入到 ArrayList 中
ArrayList<Node> nodes = new ArrayList<>();
for(int value : arr){
nodes.add(new Node(value));
}
while(nodes.size() > 1){
//排序,Nodes实现了 Comparable 接口
Collections.sort(nodes);
System.out.println("nodes = " + nodes);
//取出根结点权值最小的两棵二叉树
//(1)取出权值最小的结点(二叉树)
Node leftNode = nodes.get(0);
//(2)取出权值第二小的结点(二叉树)
Node rightNode = nodes.get(1);
//(3)构建一棵新的二叉树
Node parent = new Node(leftNode.value + rightNode.value);
parent.left = leftNode;
parent.right = rightNode;
//(4)从数组中删除处理过的二叉树
nodes.remove(leftNode);
nodes.remove(rightNode);
//(5)将 parent 加入 nodes
nodes.add(parent);
}
System.out.println("nodes = " + nodes);
//返回哈夫曼树的根结点
return nodes.get(0);
}
}
//创建结点类
//为了让 Node 对象支持排序,让 Node 实现 Comparable 接口
class Node implements Comparable<Node>{
int value; //结点权值
Node left; //指向左子结点
Node right; //指向右子结点
public Node(int value){
this.value = value;
}
@Override
public String toString() {
return "Node{" +
"value=" + value +
'}';
}
@Override
public int compareTo(Node o) {
//表示从小到大排序
return this.value - o.value;
}
}
三、哈夫曼编码
1.引入
从狭义上来讲,把人类能看懂的各种信息,转换成计算机能够识别的二进制形式,被称为编码
编码的方式可以有很多种,我们大家最熟悉的编码方式就属 ASCII 码
在ASCII码当中,把每一个字符表示成特定的8位二进制数,比如:
显然,ASCII码是一种等长编码,也就是任何字符的编码长度都相等
等长编码的优点很明显,因为每个字符对应的二级制编码长度相等,所以很容易设计,也很方便读写。但是计算机的存储空间以及网络传输的带宽是有限的,等长编码最大的缺点就是编码结果太长,会占用过多资源
假如一段信息当中,只有 A,B,C,D,E,F 这6个字符,如果使用不定长编码,比如:
如此一来,给定的信息 “ABEFCDAED”,就可以编码成二进制的 “0 00 10 11 01 1 0 10 1”,编码的总长度只有 14
但是这样的编码设计会带来歧义,A 的编码是 0,B 的编码是 00,那么二进制 000 既可能是 AB,又可能是 BA,还可能是 AAA。因此,不定长编码是不能随意设计的,如果一个字符的编码恰好是另一个字符编码的前缀,就会产生歧义
哈夫曼编码也是不定长编码,并且哈夫曼编码可以保证编码不存在二义性
2.介绍
哈夫曼编码(Huffman Coding)实现了两个重要目标:
- 任何一个字符编码都不是其他字符编码的前缀
- 信息编码的总长度最小
哈夫曼编码并非一套固定的编码,而是根据给定信息中各个字符出现的频次,动态生成最优的编码
使用需要传送的字符构造字符集C = {c1, c2, … cn},并根据字符出现的频率构建概率集W = {w1, w2, … wn}。哈夫曼编码的流程如下:
- 将字符集 C 作为叶子结点
- 将频率集 W 作为叶子结点的权值
- 使用 C 和 W 构造哈夫曼树
- 哈夫曼树的每一个结点包括左、右两个分支,二进制的每一位有 0、1 两种状态,我们可以把这两者对应起来,结点的左分支当做 0,结点的右分支当做 1
哈夫曼树的根结点到每一个叶子结点的路径就是一段二进制编码
上述过程借助哈夫曼树所生成的二进制编码,就是哈夫曼编码
需要注意,哈夫曼树根据排序方法不同,对应的哈夫曼编码也不完全一样,但是 WPL 一定是一样的
这样生成的编码有没有前缀问题带来的歧义呢?
因为每一个字符对应的都是哈夫曼树的叶子结点,从根结点到这些叶子结点的路径并没有包含关系,最终得到的二进制编码自然也不会是彼此的前缀
这样生成的编码能保证总长度最小吗?
哈夫曼树的重要特性,就是所有叶子结点的(权重 X 路径长度)之和最小
放在信息编码的场景下,叶子结点的权重对应字符出现的频次,结点的路径长度对应字符的编码长度
所有字符的(频次 X 编码长度)之和最小,自然就说明总的编码长度最小
3.代码实现哈夫曼编码综合案例
功能如下:
- 生成字符串对应的哈夫曼编码
- 对字符串压缩
- 解压压缩后的字符串
- 压缩文件
- 解压文件
package com.sisyphus.huffmancode;
import java.io.*;
import java.util.*;
/**
* @Description: 哈夫曼编码$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/24$
*/
public class HuffmanCode {
public static void main(String[] args) {
//测试压缩字符串
String str = "The relationship between Java and JavaScript is like Zhou Yang and Zhou Yangqing.Neither of them has any similarities";
//获取原始字符串的字节数组
byte[] contentBytes = str.getBytes();
System.out.println("压缩前的长度为:" + contentBytes.length);
byte[] huffmanCodesBytes = huffmanzip(contentBytes);
System.out.println("压缩后的结果为:" + Arrays.toString(huffmanCodesBytes));
System.out.println("压缩后的长度为:" + huffmanCodesBytes.length);
byte[] sourceBytes = decode(huffmanCodes, huffmanCodesBytes);
System.out.println("原来的字符串:" + new String(sourceBytes));
//测试压缩文件
String srcFile = "C:\\Users\\admin\\Desktop\\src.png";
String dstFile = "C:\\Users\\admin\\Desktop\\dst.zip";
zipFile(srcFile,dstFile);
File zip = new File("C:\\Users\\admin\\Desktop\\dst.zip");
if (zip.exists()){
System.out.println("文件压缩成功!");
}
//测试解压文件
String zipFile = "C:\\Users\\admin\\Desktop\\dst.zip";
String dstFile1 = "C:\\Users\\admin\\Desktop\\src1.png";
unZip(zipFile,dstFile1);
File src1 = new File("C:\\Users\\admin\\Desktop\\src1.png");
if (src1.exists()){
System.out.println("文件解压成功!");
}
}
//编写一个方法,完成对压缩文件的解压
/**
*
* @param zipFile 准备解压的文件
* @param dstFile 将文件解压到哪个路径
*/
public static void unZip(String zipFile,String dstFile){
//定义文件输入流
InputStream is = null;
//定义一个对象输入流
ObjectInputStream ois = null;
//定义文件输出流
OutputStream os = null;
try {
//创建文件输入流
is = new FileInputStream(zipFile);
//创建一个和 is 关联的对象输入流
ois = new ObjectInputStream(is);
//读取 byte 数组 huffmanBytes
byte[] huffmanBytes = (byte[])ois.readObject();
//读取哈夫曼编码表
Map<Byte,String> huffmanCodes = (Map<Byte, String>)ois.readObject();
//解码
byte[] bytes = decode(huffmanCodes,huffmanBytes);
//将 bytes 数组写入到目标文件
os = new FileOutputStream(dstFile);
//写数据到 dstFile
os.write(bytes);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}finally {
try {
os.close();
ois.close();
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//编写方法,将一个文件进行压缩
/**
*
* @param srcFile 你传入的希望压缩的文件的全路径
* @param dstFile 我们压缩后将压缩文件放到哪个目录
*/
public static void zipFile(String srcFile,String dstFile){
//创建输出流
OutputStream os = null;
ObjectOutputStream oos = null;
//创建文件的输入流
FileInputStream is = null;
try {
is = new FileInputStream(srcFile);
//创建一个和源文件大小一样的 byte[]
byte[] b = new byte[is.available()];
//读取文件
is.read(b);
//直接堆源文件压缩
byte[] huffmanBytes = huffmanzip(b);
//创建文件的输出流,存放压缩文件
os = new FileOutputStream(dstFile);
//创建一个和文件输出流关联的 ObjectOutputStream
oos = new ObjectOutputStream(os);
//把哈夫曼编码后的字节数组写入压缩文件
oos.writeObject(huffmanBytes);//先把
//这里我们以对象流的方式写入哈夫曼编码,是为了以后我们解压的时候恢复源文件使用
oos.writeObject(huffmanCodes);
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
oos.close();
os.close();
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//完成数据的解压
//思路
//1.先转成哈夫曼编码对应的二进制字符串
//2.对照哈夫曼编码转换为字符串
/**
* 将一个 byte 转成一个二进制的字符串
* @param flag 如果是 true 则需要补高位,如果是 false 则不补
* @param b 传入的 byte
* @return 是该 b 对应的二进制的字符串,(注意是按补码返回的)
*/
private static String byteToBitString(boolean flag,byte b){
//使用变量保存 b
int temp = b; //将 b 转成 int
//如果是正数,我们还存在补高位的问题
if (flag) {
temp |= 256; //按位或 256(1 0000 0000) | 1(0000 0001) => 1 0000 0001
}
String str = Integer.toBinaryString(temp); //返回的是 temp 对应的二进制的补码
if (flag) {
return str.substring(str.length() - 8);
}else{
return str;
}
}
//编写一个方法,完成对压缩数据的解码
/**
*
* @param huffmanCodes 哈夫曼编码 map
* @param huffmanBytes 哈夫曼编码得到的字节数组
* @return 就是原来的字符串对应的数组
*/
private static byte[] decode(Map<Byte,String> huffmanCodes,byte[] huffmanBytes){
//1.先得到 huffmanBytes 对应的二进制的字符串
StringBuilder stringBuilder = new StringBuilder();
//将 byte 数组转成二进制的字符串
for (int i = 0; i < huffmanBytes.length; i++) {
byte b = huffmanBytes[i];
//判断是不是最后一个字节
boolean flag = (i == huffmanBytes.length - 1);
stringBuilder.append(byteToBitString(!flag,b));
}
//把字符串按照指定的哈夫曼编码进行解码
//把哈夫曼编码进行调换,因为需要反向查询
Map<String,Byte> map = new HashMap<>();
for (Map.Entry<Byte,String> entry : huffmanCodes.entrySet()) {
map.put(entry.getValue(),entry.getKey());
}
//创建一个集合,存放 byte
ArrayList<Byte> list = new ArrayList<>();
//i 可以理解成就是索引,扫描 stringBuilder
for (int i = 0; i < stringBuilder.length();) {
int count = 1; //小的计数器
boolean flag = true;
Byte b = null;
while (flag) {
//递增地取出字节数组中的 ’1‘ 或者 ’0‘
String key = stringBuilder.substring(i,i+count);//i 不动,让 count 移动,指定匹配到一个字符
b = map.get(key);
if (b == null){ //说明没有匹配到
count++;
}else{
//匹配到了
flag = false;
}
}
list.add(b);
i += count; //i 直接移动到 count,左闭右开
}
//for 循环结束后 list 就存放了所有字符
//把 list 中的数据放入到 byte[] 并返回
byte b[] = new byte[list.size()];
for (int i = 0; i < b.length; i++) {
b[i] = list.get(i);
}
return b;
}
//使用一个方法,将所有的方法封装起来,便于我们调用
/**
*
* @param bytes 原始的字符串对应的字节数组
* @return 经过哈夫曼编码处理后的字节数组(压缩后的数组)
*/
private static byte[] huffmanzip(byte[] bytes){
List<Node> nodes = getNodes(bytes);
//创建哈夫曼树
Node huffmanTreeRoot = createHuffmanTree(nodes);
//根据哈夫曼树创建对应的哈夫曼编码
Map<Byte, String> huffmanCodes = getCodes(huffmanTreeRoot);
//根据生成的哈夫曼编码亚索,得到压缩后的哈夫曼编码字节数组
byte[] huffmanCodeBytes = zip(bytes,huffmanCodes);
return huffmanCodeBytes;
}
//编写一个方法,将字符串对应的 byte[] 数组,通过生成的哈夫曼编码表,返回一个哈夫曼编码压缩后的 byte[]
/**
*
* @param bytes 原始的字符串对应的 byte[]
* @param huffmanCodes 生成的哈夫曼编码 map
* @return 返回哈夫曼编码处理后的 byte[],即 8 位对应一个 byte,存放在 bute[] 数组中,需要注意的是 byte 存放的是二进制数的补码
*/
private static byte[] zip(byte[] bytes,Map<Byte,String> huffmanCodes){
//1.利用 huffmanCodes 将 bytes 转成哈夫曼编码对应的字符串
StringBuilder stringBuilder = new StringBuilder();
//遍历 bytes 数组
for (byte b : bytes){
stringBuilder.append(huffmanCodes.get(b));
}
//统计返回 bytep[] huffmanCodeBytes 长度
//一句话搞定 int len = (stringBuilder.length() + 7) / 8;
int len;
if (stringBuilder.length() % 8 == 0){
len = stringBuilder.length() / 8;
}else{
len = stringBuilder.length() / 8 + 1;
}
//创建存储压缩后的 byte 数组
byte[] huffmanCodeBytes = new byte[len];
int index = 0;//记录是第几个 bute
for (int i = 0; i < stringBuilder.length(); i += 8) { //因为是每 8 位对应一个 byte,所以步长 +8
String strByte;
if (i + 8 >stringBuilder.length()){ //不够 8 位
strByte = stringBuilder.substring(i);
}else {
strByte = stringBuilder.substring(i,i + 8);
}
//将 strByte 转成一个 byte,放入到 huffmanCodeBytes
huffmanCodeBytes[index] = (byte) Integer.parseInt(strByte,2);
index++;
}
return huffmanCodeBytes;
}
//生成哈夫曼树对应的哈夫曼编码
//思路:
//1.将哈夫曼编码表存放在 Map<Byte,String>
static Map<Byte,String> huffmanCodes = new HashMap<>();
//2.在生成哈夫曼编码表时,需要去拼接路径,定义一个StringBuilder 存储某个叶子结点的路径
static StringBuilder stringBuilder = new StringBuilder();
//为了调用方便,我们重载 getCodes
private static Map<Byte,String> getCodes(Node root){
if (root == null){
return null;
}
//处理 root 的左子树
getCodes(root.left,"0",stringBuilder);
//处理 root 的右子树
getCodes(root.right,"1",stringBuilder);
return huffmanCodes;
}
/**
* 得到传入的 node 结点的所有叶子结点的哈夫曼编码,并放入到 huffmanCodes 集合
* @param node 传入结点
* @param code 路径:左子结点 0,右子结点 1
* @param stringBuilder 用于拼接路径
*/
private static void getCodes(Node node, String code, StringBuilder stringBuilder){
StringBuilder stringBuilder2 = new StringBuilder(stringBuilder);
//将 code 加入到 stringBuilder2
stringBuilder2.append(code);
if (node != null){ //如果 node === null 不处理
//判断当前 node 是叶子结点还是非叶子节点
if (node.data == null){ //非叶子节点
//递归处理
//向左递归
getCodes(node.left,"0",stringBuilder2);
//向右递归
getCodes(node.right,"1",stringBuilder2);
}else{ //说明是一个叶子结点
//就表示找到某个叶子节点了
huffmanCodes.put(node.data,stringBuilder2.toString());
}
}
}
//前序遍历的方法
private static void preOrder(Node root){
if (root != null){
root.preOrder();
}else{
System.out.println("哈夫曼树为空,无法遍历");
}
}
/**
*
* @param bytes 接收字节数组
* @return 返回的是 List 形式
*/
private static List<Node> getNodes(byte[] bytes){
//1.创建一个 ArrayList
ArrayList<Node> nodes = new ArrayList<Node>();
//遍历 bytes,统计每一个 byte 出现的次数 -> map[key,value]
HashMap<Byte,Integer> counts = new HashMap<>();
for (byte b : bytes) {
Integer count = counts.get(b);
if (count == null){ ///Map 还没有这个字符数据,第一次加入
counts.put(b,1);
}else{
counts.put(b,count + 1);
}
}
//把每一个键值对转成一个 Node 对象,并加入到 nodes 集合
//遍历 map
for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
nodes.add(new Node(entry.getKey(),entry.getValue()));
}
return nodes;
}
//通过 List 创建对应的哈夫曼树
private static Node createHuffmanTree(List<Node> nodes){
while(nodes.size() > 1){
//排序,从小到大
Collections.sort(nodes);
//取出第一棵最小的二叉树
Node leftNode = nodes.get(0);
//取出第二棵最小的二叉树
Node rightNode = nodes.get(1);
//创建一棵新的二叉树,它的根结点没有 data,只有权值
Node parent = new Node(null,leftNode.weight + rightNode.weight);
parent.left = leftNode;
parent.right = rightNode;
//将已经处理的两棵二叉树从 nodes 删除
nodes.remove(leftNode);
nodes.remove(rightNode);
//将新的二叉树加入到 nodes
nodes.add(parent);
}
//最后的结点就是哈夫曼树的根结点
return nodes.get(0);
}
}
//创建 Node
class Node implements Comparable<Node>{
Byte data; //存放数据(字符)本身,比如 'a' => 97
int weight; //权值,表示字符出现的次数
Node left;
Node right;
public Node(Byte data, int weight) {
this.data = data;
this.weight = weight;
}
@Override
public int compareTo(Node o) {
//从小到大排序
return this.weight - o.weight;
}
@Override
public String toString() {
return "Node{" +
"data=" + data +
", weight=" + weight +
'}';
}
//前序遍历
public void preOrder(){
System.out.println(this);
if (this.left != null){
this.left.preOrder();
}
if (this.right != null){
this.right.preOrder();
}
}
}