章节目录:
一、赫夫曼树
1.1 基本介绍
- 给定 n 个权值作为 n 个叶子结点,构造一棵二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树/霍夫曼树(Huffman Tree)。
- 赫夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
1.2 重要概念
- 路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为 1,则从根结点到第 L 层结点的路径长度为 L-1。
- 节点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。
- 树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为 WPL(weighted pathlength) ,权值越大的结点离根结点越近的二叉树才是最优二叉树。
- WPL 最小的就是赫夫曼树。
- 示意图:
1.3 创建思路
需求:将数列 {13, 7, 8, 3, 29, 6, 1},要求转成一颗赫夫曼树。
- 从小到大进行排序, 将每一个数据,每个数据都是一个节点 , 每个节点可以看成是一颗最简单的二叉树;
- 取出根节点权值最小的两颗二叉树;
- 组成一颗新的二叉树, 该新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和;
- 再将这颗新的二叉树,以根节点的权值大小 再次排序, 不断重复 1-2-3-4 的步骤,直到数列中,所有的数据都被处理,就得到一颗赫夫曼树。
1.4 代码示例
- 节点及属性:
public class Node implements Comparable<Node> {
/**
* 存放数据(字符)本身,比如'a' => 97 ; ' ' => 32 ...
*/
private Byte data;
/**
* 节点权值。
*/
private final int weight;
/**
* 左子节点。
*/
private Node left;
/**
* 右子节点。
*/
private Node right;
public Node(int weight) {
this.weight = weight;
}
public Node(Byte data, int weight) {
this.data = data;
this.weight = weight;
}
/**
* 前序遍历。
*/
public void preOrder() {
System.out.println(this);
if (null != this.left) {
this.left.preOrder();
}
if (null != this.right) {
this.right.preOrder();
}
}
@Override
public int compareTo(Node node) {
return this.weight - node.weight;
}
public Byte getData() {
return data;
}
public int getWeight() {
return weight;
}
public Node getLeft() {
return left;
}
public void setLeft(Node left) {
this.left = left;
}
public Node getRight() {
return right;
}
public void setRight(Node right) {
this.right = right;
}
@Override
public String toString() {
return "Node{" +
"data=" + data +
", weight=" + weight +
'}';
}
}
- 创建赫夫曼树:
public class HuffmanTree {
public static void main(String[] args) {
int[] arr = {13, 7, 8, 3, 29, 6, 1};
List<Node> nodes = getNodes(arr);
Node root = createHuffmanTree(nodes);
System.out.println("rootWeight=" + root.getWeight());
// rootWeight=67
System.out.println();
System.out.println("前序遍历结果如下:");
preOrder(root);
// 前序遍历结果如下:
// weight=67
// weight=29
// weight=38
// weight=15
// weight=7
// weight=8
// weight=23
// weight=10
// weight=4
// weight=1
// weight=3
// weight=6
// weight=13
}
/**
* 前序遍历。
*
* @param node 节点
*/
public static void preOrder(Node node) {
if (null == node) {
throw new NullPointerException();
}
node.preOrder();
}
/**
* 将整型数组的元素创建为新节点。
*
* @param array 需要创建成哈夫曼树的数组
* @return {@link List}<{@link Node}>
*/
private static List<Node> getNodes(int[] array) {
// 将数组元素构建成新的Node并存入集合(方便进行操作)。
List<Node> nodes = new ArrayList<>();
for (int i : array) {
nodes.add(new Node(i));
}
return nodes;
}
/**
* 创建哈夫曼树。
*
* @return {@link Node} 创建好后的赫夫曼树的 root 节点
*/
public static Node createHuffmanTree(List<Node> nodes) {
// 循环处理。
while (nodes.size() > 1) {
// 从小到大排序。
Collections.sort(nodes);
// 取出权值最小的节点。
Node leftNode = nodes.get(0);
// 取出权值倒数第二的节点。
Node rightNode = nodes.get(1);
// 构建新树。
// 新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和。
int parentWeight = leftNode.getWeight() + rightNode.getWeight();
Node parent = new Node(null, parentWeight);
parent.setLeft(leftNode);
parent.setRight(rightNode);
// 移除已经处理过的二叉树。
nodes.remove(leftNode);
nodes.remove(rightNode);
// 将节点加入集合。
nodes.add(parent);
}
// 返回 root 节点。
return nodes.get(0);
}
}
二、赫夫曼编码
2.1 基本介绍
-
哈夫曼编码(Huffman Coding),又称霍夫曼编码,是一种编码方式,哈夫曼编码是可变字长编码(VLC)的一种。Huffman于1952年提出一种编码方法,该方法完全依据字符出现概率来构造异字头的平均长度最短的码字,有时称之为最佳编码,一般就叫做Huffman编码(有时也称为霍夫曼编码)。
-
赫夫曼编码是赫哈夫曼树在电讯通信中的经典的应用之一。
-
赫夫曼编码广泛地用于数据文件压缩。其压缩率通常在 20%~90% 之间。
2.2 原理说明
- 假设传输的字符串为:“i like like like java do you like a java”
- 各个字符对应出现的次数为:d:1、y:1、u:1、j:2、v:2、o:2、l:4、k:4、e:4、i:5、a:5、" ":9
- 需求:按照上面字符出现的次数构建一颗赫夫曼树,次数作为权值。
- 示意图:
- 根据赫夫曼树,给各个字符规定编码(前缀编码),向左的路径为
0
向右的路径为1
,编码如下:
j:0000、v:0001、l:001、" ":01
o:1000、u:10010、d:100110、y:100111
i:101、a:110、k:1110、e:1111
- 原字符"i like like like java do you like a java",完整编码后得到
1010100110111101111010011011110111101001101111011110100001100001110011001111000011001111000100100100110111101111011100100001100001110
- 长度由原来的359变为133,压缩率为62.9%。
- 说明:此编码满足前缀编码,即字符的编码都不能是其它字符编码的前缀,不会造成匹配的多义性赫夫曼编码是无损处理方案。
- 注意:赫夫曼树根据排序方法不同,也可能不太一样,这样对应的赫夫曼编码也不完全一样,但是
wpl
是一样的都是最小的,最后生成的赫夫曼编码的长度是一样。
2.3 数据压缩与解压
需求:使用赫夫曼编码来对字符串进行编码及逆向解码操作。
- 代码示例:
/**
* 生成赫夫曼树对应的赫夫曼编码。
*/
static Map<Byte, String> huffmanCodes = new HashMap<>();
/**
* 存储某个叶子节点的路径。
*/
static StringBuilder pathBuilder = new StringBuilder();
@Test
public void test01HuffmanCodesAndDecode() {
String content = "i like like like java do you like a java";
List<Node> nodes = getNodes(content.getBytes());
Node root = HuffmanTree.createHuffmanTree(nodes);
System.out.println("编码操作, 生成的赫夫曼编码表: " + getCodes(root));
// 编码操作, 生成的赫夫曼编码表: {32=01, 97=100, 100=11000, 117=11001, 101=1110, 118=11011, 105=101, 121=11010, 106=0010, 107=1111, 108=000, 111=0011}
byte[] huffmanCodesBytes = huffmanZip(content.getBytes());
byte[] sourceBytes = decode(huffmanCodes, huffmanCodesBytes);
System.out.println("解码操作, 原来的字符串: " + new String(sourceBytes));
// 解码操作, 原来的字符串: i like like like java do you like a java
}
/**
* 重载 getCodes()。
*
* @param root 根节点
* @return {@link Map}<{@link Byte}, {@link String}>
*/
private static Map<Byte, String> getCodes(Node root) {
if (null == root) {
return Collections.emptyMap();
}
// 处理root的左子树。
getCodes(root.getLeft(), "0", pathBuilder);
// 处理root的右子树。
getCodes(root.getRight(), "1", pathBuilder);
return huffmanCodes;
}
/**
* 将传入的 node 节点的所有叶子节点的赫夫曼编码得到,并放入到huffmanCodes集合。
*
* @param node 节点
* @param pathCode 路径: 左子节点是 0, 右子节点 1
* @param pathBuilder 用于拼接路径
*/
private static void getCodes(Node node, String pathCode, StringBuilder pathBuilder) {
StringBuilder builder = new StringBuilder(pathBuilder);
builder.append(pathCode);
if (null != node) {
// 非叶子节点时,进行递归处理。
if (null == node.getData()) {
// 向左递归。
getCodes(node.getLeft(), "0", builder);
// 向右递归。
getCodes(node.getRight(), "1", builder);
} else {
// 就表示找到某个叶子节点的最后。
huffmanCodes.put(node.getData(), builder.toString());
}
}
}
/**
* 将字节数组的元素创建为新节点。
*
* @param bytes 字节
* @return {@link List}<{@link Node}>
*/
private static List<Node> getNodes(byte[] bytes) {
List<Node> nodes = new ArrayList<>();
Map<Byte, Integer> counts = new HashMap<>(8);
// 遍历 bytes , 统计每一个byte出现的次数->map[key,value]
for (byte data : bytes) {
// 不断对字符出现次数,进行累加计数。
counts.merge(data, 1, Integer::sum);
}
Set<Map.Entry<Byte, Integer>> entries = counts.entrySet();
for (Map.Entry<Byte, Integer> entry : entries) {
// [Node[date=97 ,weight=5], Node[date=32 , weight=9]......]
Byte data = entry.getKey();
Integer weight = entry.getValue();
// 创建新节点并添加至集合。
nodes.add(new Node(data, weight));
}
return nodes;
}
/**
* 将一个 byte 转成一个二进制的字符串。
*
* @param b 传入的 byte
* @param flag 标志是否需要补高位。 1.true: 表示需要补高位;2.false:表示不补;3.如果是最后一个字节,无需补高位;
* @return {@link String} 该 byte 补码对应的二进制的字符串
*/
private static String byteToBitString(byte b, boolean flag) {
// 将 byte 转为 int。
int temp = b;
if (flag) {
// 按位与 256。
temp |= 256;
}
// 返回的是temp对应的二进制的补码。
String str = Integer.toBinaryString(temp);
if (flag) {
return str.substring(str.length() - 8);
} else {
return str;
}
}
/**
* 对压缩数据的解码。
*
* @param huffmanCodes 霍夫曼编码表
* @param huffmanBytes 赫夫曼编码得到的字节数组
* @return {@link byte[]} 解码后字符串对应的数组
*/
private static byte[] decode(Map<Byte, String> huffmanCodes, byte[] huffmanBytes) {
StringBuilder builder = new StringBuilder();
// 将byte数组转成二进制的字符串。
for (int i = 0; i < huffmanBytes.length; i++) {
byte b = huffmanBytes[i];
// 判断是不是最后一个字节。
boolean flag = (i == (huffmanBytes.length - 1));
String str = byteToBitString(b, !flag);
builder.append(str);
}
// 把赫夫曼编码表进行调换,因为反向查询。
Map<String, Byte> map = new HashMap<>(8);
Set<Map.Entry<Byte, String>> entries = huffmanCodes.entrySet();
for (Map.Entry<Byte, String> entry : entries) {
map.put(entry.getValue(), entry.getKey());
}
List<Byte> list = new ArrayList<>();
// 指定索引对可变字符串进行扫描。
for (int i = 0; i < builder.length(); ) {
int count = 1;
Byte b = null;
boolean flag = true;
while (flag) {
// 递增的取出 key。
// i 不动,让 count 移动,指定匹配到一个字符。
String key = builder.substring(i, i + count);
b = map.get(key);
if (null == b) {
count++;
} else {
// 说明匹配到了。
flag = false;
}
}
list.add(b);
// i 直接移动到 count。
i += count;
}
// 将集合中的数据放入到字节数组中并返回。
byte[] bytes = new byte[list.size()];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = list.get(i);
}
return bytes;
}
/**
* 将字符串对应的字节数组,通过生成的赫夫曼编码表,返回一个赫夫曼编码[压缩后]的字节数组。
*
* @param bytes 原始的字符串对应的字节数组
* @param huffmanCodes 生成的赫夫曼编码表
* @return 压缩后的字节数组
*/
private static byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
// 将字节数组转成 赫夫曼编码对应的字符串。
StringBuilder builder = new StringBuilder();
for (byte b : bytes) {
builder.append(huffmanCodes.get(b));
}
// 统计返回字节数组 huffmanCodeBytes 长度。
int len;
if (builder.length() % 8 == 0) {
len = builder.length() / 8;
} else {
len = builder.length() / 8 + 1;
}
// 创建存储压缩后的字节数组。
byte[] huffmanCodeBytes = new byte[len];
// 记录是第几个字节。
int index = 0;
// 因为是每8位对应一个字节,所以步长 +8。
for (int i = 0; i < builder.length(); i += 8) {
String strByte;
// 不够8位。
if (i + 8 > builder.length()) {
strByte = builder.substring(i);
} else {
strByte = builder.substring(i, i + 8);
}
// 存入压缩字节数组。
huffmanCodeBytes[index] = (byte) Integer.parseInt(strByte, 2);
index++;
}
return huffmanCodeBytes;
}
/**
* 过程封装:1.创建赫夫曼树 >> 2.得到赫夫曼编码 >> 3.生成压缩赫夫曼编码字节数组。
* 目的:便于调用。
*
* @param bytes 原始的字符串对应的字节数组
* @return 是经过 赫夫曼编码处理后的字节数组(压缩后的数组)
*/
private static byte[] huffmanZip(byte[] bytes) {
List<Node> nodes = getNodes(bytes);
// 根据 nodes 创建的赫夫曼树。
Node huffmanTreeRoot = HuffmanTree.createHuffmanTree(nodes);
// 对应的赫夫曼编码。
Map<Byte, String> huffmanCodes = getCodes(huffmanTreeRoot);
// 根据生成的赫夫曼编码,压缩得到压缩后的赫夫曼编码字节数组。
return zip(bytes, huffmanCodes);
}
2.4 文件压缩与解压
需求:通过赫夫曼编码完成文件的压缩及解压操作。
压缩思路:读取文件-> 得到赫夫曼编码表 -> 完成压缩。
解压思路:读取压缩文件(数据和赫夫曼编码表)-> 完成解压(文件恢复)。
- 代码示例:
@Test
public void test02ZipAndUnzip() {
zipFile("D:\\test.txt", "D:\\test.zip");
// total number of bytes:111852
System.out.println("zip success!");
// zip success!
unZipFile("D:\\test.zip", "D:\\test1.txt");
System.out.println("unZip success!");
}
/**
* 文件进行压缩。
*
* @param srcFile 源文件的全路径
* @param dstFile 输出文件的全路径
*/
private static void zipFile(String srcFile, String dstFile) {
try (
// 文件的输入流。
FileInputStream is = new FileInputStream(srcFile);
// 文件输出流。
OutputStream os = new FileOutputStream(dstFile);
ObjectOutputStream oos = new ObjectOutputStream(os)
) {
byte[] bytes = new byte[is.available()];
// 读取文件及字节数大小。
int read = is.read(bytes);
System.out.println("total number of bytes:" + read);
// 对源文件压缩后写出。
byte[] huffmanBytes = huffmanZip(bytes);
oos.writeObject(huffmanBytes);
oos.writeObject(huffmanCodes);
} catch (IOException e) {
System.out.println("zipFile error:" + e.getMessage());
}
}
/**
* 对压缩文件的解压。
*
* @param zipFile 准备解压文件的全路径
* @param dstFile 输出文件的全路径
*/
@SuppressWarnings("unchecked")
public static void unZipFile(String zipFile, String dstFile) {
try (
// 文件的输入流。
InputStream is = new FileInputStream(zipFile);
ObjectInputStream ois = new ObjectInputStream(is);
// 文件输出流。
OutputStream os = new FileOutputStream(dstFile)
) {
// 读取字节数组。
byte[] huffmanBytes = (byte[]) ois.readObject();
// 读取赫夫曼编码表。
Map<Byte, String> huffmanCodes = (Map<Byte, String>) ois.readObject();
// 解码。
byte[] bytes = decode(huffmanCodes, huffmanBytes);
// 写出。
os.write(bytes);
} catch (IOException | ClassNotFoundException e) {
System.out.println("zipFile error:" + e.getMessage());
}
}
2.5 注意事项
- 如果文件本身就是经过压缩处理的,那么使用赫夫曼编码再压缩效率不会有明显变化,比如视频,
ppt
等文件。 - 赫夫曼编码是按字节来处理的,因此可以处理所有的文件(二进制文件、文本文件),比如
.xml
文件。 - 如果一个文件中的内容,重复的数据不多,压缩效果也不会很明显。
三、结束语
“-------怕什么真理无穷,进一寸有一寸的欢喜。”
微信公众号搜索:饺子泡牛奶。