霍夫曼树
1 介绍
1.1 什么是霍夫曼树
霍夫曼树(Huffman Tree),又称最优二叉树或哈夫曼树,是一种具有特定性质的二叉树结构,广泛应用于数据压缩领域。
霍夫曼树是一种带权路径长度最短的二叉树。在霍夫曼树中,每个叶子节点都代表一个字符,其权值通常表示该字符在数据中出现的频率或概率。非叶子节点的权值则是其子节点权值的和。霍夫曼树通过构建过程,确保树的带权路径长度(Weighted Path Length, WPL)最小,即所有叶子节点到根节点的路径长度与各自权值的乘积之和最小。
1.2 带权路径长度说明
解析过程
a 树的带权路径 = 7 * 2 + 5 * 2 + 4 * 2 + 2 * 2 = 36
b 树的带权路径 = 4 * 2 + 5 * 3 + 7 * 3 + 2 * 1 = 46
c 树的带权路径 = 7 * 1 + 5 * 2 + 2 * 3 + 4 * 3 = 35
c的带权路径长度最短
1.3 霍夫曼编码说明
在上图的最优二叉树中我们给每一条边加上一个权值,指向左子节点的边我们标记为0,指向右子节点的边标记为1,那从根节点到叶节点的路径就是我们说的哈夫曼编码;
所以图c的赫夫曼树对应的编码就是:
A:0
B:10
C:110
D:111
2 有什么用
- 文本文件压缩:由于文本中某些字符出现频率远高于其他字符,通过霍夫曼编码可将这些高频字符用较短的二进制序列表示,从而实现整个文本文件的高效压缩。
- 图像文件格式:在JPEG和PNG等图像格式中,霍夫曼编码用于压缩DCT变换后得到的量化系数,显著减少图像数据量。
- 通信协议:在网络传输过程中,为了节省带宽资源,可以先对要传输的数据进行霍夫曼编码,以更紧凑的形式发送数据,进而提高传输效率。
- 流媒体服务:实时音频或视频流通常会使用类似霍夫曼编码的熵编码方法来压缩数据,以便快速且有效地在网络上传输。
- 数据库与存档:在数据库系统或大型存档应用中,针对频繁查询但又占空间较大的字段,可以考虑采用霍夫曼编码或其他压缩算法来减少存储空间需求。
2.1 数据压缩
霍夫曼编码是霍夫曼树最直接的应用之一。霍夫曼编码是一种广泛使用的熵编码方法,由美国计算机科学家戴维·A·霍夫曼于1952年提出。该方法基于字符出现的频率,为每个字符分配一个可变长度的二进制代码。具体过程包括:
- 统计字符频率:首先统计文本中所有不同字符及其出现次数。
- 构造霍夫曼树:根据字符频率创建一个优先级队列(或称为最小堆),将字符和它们的频率作为节点加入队列中。然后,每次从队列中取出两个频率最低的节点合并成一个新的节点,新节点的频率为两个子节点频率之和,并将新节点再次放入队列中。重复此过程直到队列中只剩下一个节点,这个节点即为霍夫曼树的根节点。
- 生成编码:从霍夫曼树的根节点开始,沿着到各个叶子节点(代表具体字符)的路径记录下所经过的左右分支,左分支记为0,右分支记为1。这样,每个字符就对应了一条从根节点到达该字符所在叶子节点的路径,这条路径上的0和1序列就是该字符的霍夫曼编码。
压缩说明:由于高频字符被分配了较短的编码,而低频字符则对应较长的编码,因此霍夫曼编码能够有效地减少数据表示所需的比特数,从而实现数据压缩。这种压缩方法是无损的,即解码后可以完全恢复原始数据。
2.2 决策优化
霍夫曼树的特性还可以用于一些关于最小代价问题的决策上。例如,在资源分配、路径规划等场景中,可以将不同选项的代价视为节点的权值,通过构建霍夫曼树来找到最优的决策路径。这种方法虽然不直接称为“霍夫曼编码”,但体现了霍夫曼树在优化问题中的应用潜力。
3 构建霍夫曼树
构建霍夫曼树的基本步骤
- 每次取数值(权重)最小的两个节点,将之组成为一颗子树。
- 移除原来的两个点
- 然后将组成的子树放入原来的序列中
- 重复执行1 2 3 直到只剩最后一个点
3.1 图解示例
3.2 代码示例
private void createTree(List<MyHuffmenNode<T>> nodeList) {
// 如果集合中只有一个节点,则当前节点为最终节点,直接返回
if (nodeList.size() <= 1){
this.root = nodeList.get(0);
return;
}
// 对节点进行排序
Collections.sort(nodeList);
// 移除最小的两个节点
MyHuffmenNode<T> firstNode = nodeList.remove(0);
MyHuffmenNode<T> secondNode = nodeList.remove(0);
// 根据最小的两个节点构建新的节点
MyHuffmenNode<T> newNode = new MyHuffmenNode<>(firstNode.weight + secondNode.weight, null, firstNode, secondNode);
firstNode.parent = newNode;
secondNode.parent = newNode;
// 将新的节点添加到集合中
nodeList.add(newNode);
// 递归创建霍夫曼树
createTree(nodeList);
}
4 获取霍夫曼编码
方式一:从叶子节点向上遍历,非递归
/**
* 从叶子节点向上遍历获取霍夫曼编码
* @param map
* @return
*/
public Map<T, String> code(Map<T, String> map) {
// nodeList为叶子节点集合
for (MyHuffmenNode<T> node : nodeList) {
MyHuffmenNode<T> temp = node;
while (temp.parent != null){
String code = temp.parent.left == temp ? "0" : "1";
map.put(node.code, map.get(node.code) == null ? code : code + map.get(node.code)); // 注意顺序,因为我们是从从节点开始遍历的,所以当前code需要放前面 [ code + map.get(node.code) ]
temp = temp.parent;
}
}
return map;
}
方式二:从根节点向下遍历,递归
public Map<String, String> code(){
if (root == null) return null;
HashMap<String, String> codeMap = new HashMap<>();
getCode(root ,codeMap, "");
return codeMap;
}
private void getCode(MyHuffmenNode<String> node, HashMap<String, String> codeMap,String code) {
if (node.left == null && node.right == null){
codeMap.put(node.code, code);
return;
}
if (node.left != null){
StringBuffer leftCode = new StringBuffer(code);
leftCode.append("0");
getCode(node.left, codeMap, leftCode.toString());
}
if (node.right != null){
StringBuffer rightCode = new StringBuffer(code);
rightCode.append("1");
getCode(node.right, codeMap, rightCode.toString());
}
}
5 完整示例代码
节点类【MyHuffmenNode】
package cn.zxc.demo.leetcode_demo.advanced_data_structure.hfm_tree;
public class MyHuffmenNode<T> implements Comparable<MyHuffmenNode>{
public MyHuffmenNode left;
public MyHuffmenNode right;
public MyHuffmenNode parent;
public Integer weight;
public T code;
public MyHuffmenNode(Integer weight, T code) {
this.weight = weight;
this.code = code;
}
public MyHuffmenNode(Integer weight, T code, MyHuffmenNode left, MyHuffmenNode right) {
this.weight = weight;
this.code = code;
this.left = left;
this.right = right;
}
@Override
public String toString() {
return "MyHuffmenNode{" +
"weight=" + weight +
", code=" + code +
'}';
}
@Override
public int compareTo(MyHuffmenNode o) {
return this.weight - o.weight;
}
}
霍夫曼树【MyHuffmenTree】,使用非递归的方式实现
package cn.zxc.demo.leetcode_demo.advanced_data_structure.hfm_tree;
import java.util.*;
/**
* 霍夫曼树
* 霍夫曼树创建:
* 1、对每一个节点根据权重进行排序,并添加到集合中
* 2、创建新的节点:获取最小的两个节点 a1 , a2,并将 a1.权重 + a2.权重 作为新节点的权重,将a1,a2作为新节点的左右节点
* 3、将新节点添加到集合中
* 4、重复步骤 1,2,3,知道集合中的节点只剩下一个
* 获取霍夫曼编码:
* 1、前置说明:指针向左移动,则编码为0,向右移动,则编码为1
* 示例:
* a
* / \
* b c
* / \ / \
* d e f g
* d: 00 e: 01 f: 10 g: 11
* 2、从从节点开始遍历,避免使用递归
*/
public class MyHuffmenTree2<T> {
private MyHuffmenNode<T> root;
private List<MyHuffmenNode<T>> nodeList = new ArrayList<>(); // 全部数据节点
public void createTree(Map<T, Integer> map){
for (Map.Entry<T, Integer> entry : map.entrySet()) {
nodeList.add(new MyHuffmenNode<>(entry.getValue(), entry.getKey()));
}
List<MyHuffmenNode<T>> tempList = new ArrayList<>();
tempList.addAll(nodeList);
createTree(tempList);
}
/**
* 从叶子节点向上遍历获取霍夫曼编码
* @param map
* @return
*/
public Map<T, String> code(Map<T, String> map) {
// nodeList为叶子节点集合
for (MyHuffmenNode<T> node : nodeList) {
MyHuffmenNode<T> temp = node;
while (temp.parent != null){
// 指针向左移动,编码为0,向右移动,编码为1
String code = temp.parent.left == temp ? "0" : "1";
map.put(node.code, map.get(node.code) == null ? code : code + map.get(node.code)); // 注意顺序,因为我们是从从节点开始遍历的,所以当前code需要放前面 [ code + map.get(node.code) ]
temp = temp.parent;
}
}
return map;
}
private void createTree(List<MyHuffmenNode<T>> nodeList) {
// 如果集合中只有一个节点,则当前节点为最终节点,直接返回
if (nodeList.size() <= 1){
this.root = nodeList.get(0);
return;
}
// 对节点进行排序
Collections.sort(nodeList);
// 移除最小的两个节点
MyHuffmenNode<T> firstNode = nodeList.remove(0);
MyHuffmenNode<T> secondNode = nodeList.remove(0);
// 根据最小的两个节点构建新的节点
MyHuffmenNode<T> newNode = new MyHuffmenNode<>(firstNode.weight + secondNode.weight, null, firstNode, secondNode);
firstNode.parent = newNode;
secondNode.parent = newNode;
// 将新的节点添加到集合中
nodeList.add(newNode);
// 递归创建霍夫曼树
createTree(nodeList);
}
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("a", 7);
map.put("b", 5);
map.put("c", 2);
map.put("d", 4);
MyHuffmenTree2<String> demo = new MyHuffmenTree2();
demo.createTree(map); // 创建霍夫曼树
Map<String,String> codeMap = new HashMap<>();
System.out.println(demo.code(codeMap)); // 获取霍夫曼编码
}
}
6 应用实现–压缩数据
说明:使用霍夫曼树实现一个简单的文件压缩demo
下列程序中使用的霍夫曼树为如上【节点5 完整示例代码】
6.1 压缩文件
- 读取待压缩的文件,将文件内容读取为字节数组。
- 统计字节数组中每个字节出现的次数,并封装成
Node
对象,字节出现的次数作为节点的权重(weight
)。 - 将
Node
对象放入集合中,并排序,排序规则:按照Node
对象的weight
进行排序。 - 创建霍夫曼树得到霍夫曼编码:
- 使用
Node
节点集合创建霍夫曼树。 - 得到霍夫曼树编码,作为密码本,解压时需要用到。
- 使用
- 通过密码本将
byte
数组转换为二进制字符串,作为压缩后的结果。 - 记录压缩后的结果长度,解压的时候需要用到。
- 将压缩后的结果转化为二进制数据,然后将二进制数据写入到文件中,完成压缩。
/**
* 对文件进行压缩
* @param filePath 待压缩文件
* @param zipPath 压缩后的文件目录
* @param codeMap 用于存放密码本【霍夫曼编码】
* @return
*/
public static Integer zip(String filePath,String zipPath,Map<Byte, String> codeMap){
// 读取文件信息
byte[] bytes = FileUtil.readBytes(filePath);
// 对文件中的信息进行统计,统计每一个字符出现的次数,将次数作为每一个字符的权重
Map<Byte,Integer> map = new HashMap<>();
for (byte s : bytes) {
map.put(s, map.getOrDefault(s,0)+1);
}
MyHuffmenTree2<Byte> myHuffmenTree2 = new MyHuffmenTree2();
// 根据文件中的信息创建霍夫曼树
myHuffmenTree2.createTree(map);
// 获取霍夫曼树编码
myHuffmenTree2.code(codeMap);
// 得到压缩后的信息 并将结果输出到文件中
StringBuffer binaryBuffer = new StringBuffer();
for (byte s : bytes) {
binaryBuffer.append(codeMap.get(s));
}
byte[] binaryInfo = binaryStringToByteArray(binaryBuffer.toString());
FileUtil.writeBytes(binaryInfo,zipPath);
return binaryBuffer.toString().length();
}
6.2 文件解压
- 读取压缩后的文件,将文件内容读取为字节数组
- 将字节数组转换为二进制字符串,然后根据密码本将二进制字符串转换为byte数组,然后写入到文件中【完成解压】
/**
* 对文件进行解压
* @param codeMap 霍夫曼编码
* @param zipPath 压缩文件路径
* @param filePath 解压后文件的地址
* @param len 压缩文件长度
*/
private static void unzip( Map<Byte, String> codeMap, String zipPath, String filePath,Integer len) {
// 将密码本反转
Map<String, Byte> reversalMap = mapReversal(codeMap);
Set<String> keySet = reversalMap.keySet();
List<Byte> unzipBytes = new ArrayList<>();
// 读取压缩文件
byte[] bytes = FileUtil.readBytes(zipPath);
// 得到压缩后的密码原文
String byteArrayToBinaryString = byteArrayToBinaryString(bytes, len);
int index = 0;
StringBuffer tempBuffer = new StringBuffer();
// 根据密码本将密码原文转换为原文
while (index < byteArrayToBinaryString.length()){
tempBuffer.append(byteArrayToBinaryString.substring(index, index + 1));
if (keySet.contains(tempBuffer.toString())){
unzipBytes.add(reversalMap.get(tempBuffer.toString()));
tempBuffer = new StringBuffer();
}
index++;
}
// 将原文输出到 文件
StringBuffer unzipBuffer = new StringBuffer();
for (Byte b : unzipBytes) {
// 将byte转为string
String str = new String(new byte[]{b});
unzipBuffer.append(str);
}
FileUtil.writeBytes(unzipBuffer.toString().getBytes(), filePath);
}
6.3 完整示例代码
package cn.zxc.demo.leetcode_demo.advanced_data_structure.hfm_tree;
import cn.hutool.core.io.FileUtil;
import java.util.*;
/**
* 使用霍夫曼树压缩数据
* 压缩流程说明:
* 1、读取待压缩的文件,将文件内容读取为字节数组
* 2、统计字节数组中每个字节出现的次数,并封装成Node对象,字节出现的次数作为节点的权重【weight】
* 3、将Node对象放入集合中,并排序,排序规则:按照Node对象的weight进行排序
* 4、创建霍夫曼树得到霍夫曼编码
* - 使用Node节点集合创建霍夫曼树
* - 得到霍夫曼树编码,作为密码本,解压是需要使用到
* 5、通过密码本将byte数组转换为二进制字符串,作为压缩后的结果
* 6、记录压缩后的结果长度,解压的时候需要用到
* 7、将压缩后的结果转化为二进制数据,然后将二进制数据写入到文件中【完成压缩】
* 解压流程说明:
* 1、读取压缩后的文件,将文件内容读取为字节数组
* 2、将字节数组转换为二进制字符串,然后根据密码本将二进制字符串转换为byte数组,然后写入到文件中【完成解压】
*/
public class ZipDemo {
public static void main(String[] args) {
// 密码本:霍夫曼编码,在压缩的过程中获取到
Map<Byte, String> codeMap = new HashMap<>();
// 对文件进行压缩,并将压缩结果输出,同时得到霍夫曼编码
Integer len = zip("src.txt", "test.zip",codeMap);
// 对文件进行解压,解压需要 压缩过程的密码本 和 压缩结果的长度
unzip(codeMap, "test.zip","test2.txt", len);
}
/**
* 将byte数组转换为二进制字符串
* 如:byte 13 -> 二进制:00001101 -> 字符串 00001101
* @param bytes
* @param length
* @return
*/
private static String byteArrayToBinaryString(byte[] bytes, int length) {
StringBuffer stringBuffer = new StringBuffer();
for (int i = bytes.length - 1; i >= 0; i--) {
stringBuffer.append(Integer.toBinaryString(bytes[i] & 0xff | 0x100).substring(1));
}
return stringBuffer.toString().substring(stringBuffer.toString().length() - length);
}
/**
* 对文件进行解压
* @param codeMap 霍夫曼编码
* @param zipPath 压缩文件路径
* @param filePath 解压后文件的地址
* @param len 压缩文件长度
*/
private static void unzip( Map<Byte, String> codeMap, String zipPath, String filePath,Integer len) {
// 将密码本反转
Map<String, Byte> reversalMap = mapReversal(codeMap);
Set<String> keySet = reversalMap.keySet();
List<Byte> unzipBytes = new ArrayList<>();
// 读取压缩文件
byte[] bytes = FileUtil.readBytes(zipPath);
// 得到压缩后的密码原文
String byteArrayToBinaryString = byteArrayToBinaryString(bytes, len);
int index = 0;
StringBuffer tempBuffer = new StringBuffer();
// 根据密码本将密码原文转换为原文
while (index < byteArrayToBinaryString.length()){
tempBuffer.append(byteArrayToBinaryString.substring(index, index + 1));
if (keySet.contains(tempBuffer.toString())){
unzipBytes.add(reversalMap.get(tempBuffer.toString()));
tempBuffer = new StringBuffer();
}
index++;
}
// 将原文输出到 文件
StringBuffer unzipBuffer = new StringBuffer();
for (Byte b : unzipBytes) {
// 将byte转为string
String str = new String(new byte[]{b});
unzipBuffer.append(str);
}
FileUtil.writeBytes(unzipBuffer.toString().getBytes(), filePath);
}
/**
* 将密码本反转
* @param codeMap
* @return
*/
private static Map<String, Byte> mapReversal(Map<Byte, String> codeMap) {
Map<String, Byte> reversalMap = new HashMap<>();
for (Map.Entry<Byte, String> entry : codeMap.entrySet()) {
reversalMap.put(entry.getValue(), entry.getKey());
}
return reversalMap;
}
/**
* 对文件进行压缩
* @param filePath 待压缩文件
* @param zipPath 压缩后的文件目录
* @param codeMap 用于存放密码本【霍夫曼编码】
* @return
*/
public static Integer zip(String filePath,String zipPath,Map<Byte, String> codeMap){
// 读取文件信息
byte[] bytes = FileUtil.readBytes(filePath);
// 对文件中的信息进行统计,统计每一个字符出现的次数,将次数作为每一个字符的权重
Map<Byte,Integer> map = new HashMap<>();
for (byte s : bytes) {
map.put(s, map.getOrDefault(s,0)+1);
}
MyHuffmenTree2<Byte> myHuffmenTree2 = new MyHuffmenTree2();
// 根据文件中的信息创建霍夫曼树
myHuffmenTree2.createTree(map);
// 获取霍夫曼树编码
myHuffmenTree2.code(codeMap);
// 得到压缩后的信息 并将结果输出到文件中
StringBuffer binaryBuffer = new StringBuffer();
for (byte s : bytes) {
binaryBuffer.append(codeMap.get(s));
}
byte[] binaryInfo = binaryStringToByteArray(binaryBuffer.toString());
FileUtil.writeBytes(binaryInfo,zipPath);
return binaryBuffer.toString().length();
}
/**
* 将二进制字符串转换为二进制数据
* 字符串:110111100100 -> 二进制:11011110 10010000 -> byte:-28 13
* @param binaryString
* @return
*/
public static byte[] binaryStringToByteArray(String binaryString) {
// 确保二进制字符串长度是8的倍数(如果不是,可以添加前导0)
if (binaryString.length() % 8 != 0) {
StringBuffer stringBuffer = new StringBuffer();
for (int i = 0; i < 8 - (binaryString.length() % 8); i++) {
stringBuffer.append("0");
}
binaryString = stringBuffer.append(binaryString).toString();
}
// 使用Long.parseLong和位移操作来构建字节
byte[] bytes = new byte[binaryString.length() / 8];
int start = binaryString.length() - 8;
int index = 0;
while (start >= 0){
String substring = binaryString.substring(start, start + 8);
long binaryLong = Long.parseLong(substring, 2);
byte[] temps = new byte[]{(byte) (binaryLong & 0xFF)}; // 只取低8位
// 添加到字节数组中
for (byte temp : temps) {
bytes[index++] = temp;
}
start -= 8;
}
return bytes;
}
}
6.4 测试&效果
说明:
src.txt : 内容高度一致的文本信息
test.zip : 压缩后的文件
test2.txt :解压后的文件