数据压缩
Not all bits have equal value. -Carl Sagan
计算机通过01构造出了丰富多彩的世界,图片、文本、视频……无论是何种形式的呈现,都是将01按照某种特定的规范排列。
但是将数据展示给我们看是一回事,数据的保存和传输又是另一回事:展示给人们的数据需要满足人的视觉体验,而保存和传输的数据并不需要满足计算机的视觉体验~计算机要做的就是更多地存储数据、更快地传输数据,故用一种新的规范来重新组合01,使得原本的数据占用空间更小,更利于传输,这就是一种数据压缩的思想。而将数据从压缩的规范转换回其原始的规范表示,就是解压缩了。
![59c42e9c7936f867c6bb9a84c31e6ccd.png](https://i-blog.csdnimg.cn/blog_migrate/27ecb9e5e949c63f300bab4e10b6c7c0.png)
编码问题
在计算机中数据的每一个元素的01串表示就可以看作是该元素的编码,例如文本'A'
的ASCII码为65,那么其在计算机中的编码就是100 0001
。
要定义一个新的规范来实现数据的压缩,其实就是对数据中的每一个元素重新编码。反之,解压缩就是恢复编码(解码)的过程。
![40051deb0fa944bcfcaf08928ad1a17b.png](https://i-blog.csdnimg.cn/blog_migrate/55d5bf1341cebb72ceb2730f573e9b02.png)
例如:对于一串文本ABC
其ascii码表示为1000001 1000010 1000011
,假设为了压缩这段文本,我重新令A
的编码为00
、B的编码为01
、C
的编码为10
,那么按照这个新的规范重新表示ABC
就是000110
。这样就是一个简单的压缩了。
但是编码需要注意一个问题:编码间不混淆。试想如果A
的编码为110
,B
的编码为1101
,那么在解码时遇到这样1101...
的一串编码,我是读出一个A
呢,还是一个B
呢?
数据重新编码压缩后,必须能够重新解码,否则这种压缩和直接删除文件没有什么区别~为了解决这种混淆的问题,可以有三种解决方法:
定长编码。
ascii码就是定长编码,每一个字符长度为1byte,所以我们在解析的时候一个byte一个byte解析就好。但是定长的编码不够灵活,很难达到对整体压缩的效果。
不定长编码,每一个编码的结束使用特定的标识符。
这种方式需要给标识符一个特定的01编码,那么每一个编码之后都需要再加上一串标识符的编码,这种情况下还要实现压缩似乎就有些任重而道远……
不定长编码,使用原生不会混淆的编码。
即不会有任一个编码为另一个编码的前缀(prefix-free)。跟前几种比较起来,这种就是空间利用率最大的,也正是我们想要的。
获取prefix-free编码
为了获取这种有助于我们压缩的编码,大神前辈从二叉树中获取了灵感:
- 将数据元素存储在二叉树的叶子结点。
- 编码为根结点到该叶子结点的路径。由于二叉树非左即右,恰好切合计算机存储的非0即1,如果令左0右1,就可以用01表示根到叶子结点的路径作为编码。且叶子结点没有子结点,故这种编码不会存在前缀问题。
![a25d22679d1bb05eae196354f9eea31c.png](https://i-blog.csdnimg.cn/blog_migrate/23d1a87853a2b31ea66ff94220538792.png)
接下来的问题就是如何构建一棵二叉树,能够实现重新编码后的数据得到压缩。
Huffman树
一个文本就是256个字符的组合,这之中必然有部分字符出现的频率更高,一部分出现的频率更低。对于不定长的编码,一种压缩的思路就是出现频率高的字符编码尽量短,反之编码可以长一些。简而言之:非均匀地更加合理地分配空间。
对应到树上为频率高的字符更靠近根,频率低的字符更远离根。
哈夫曼树就是能够实现这种思路的一种满二叉树。
哈夫曼树的构建
哈夫曼树采用一种贪心的思想,自底向上构建。树上的每一个结点有一个频率
属性,用于比较;仅在叶子结点存储字符。假设当前文本的所有字符的集合为S,那么构建的过程如下:
- 每一次从S中选出两个频率最低的字符,分别创建成两个树上结点,接着将这两个字符的
频率
相加衍生出一个父结点,父结点再作为一个字符放回集合S中。 - 重复以上操作,直至S集合中只有一个结点。这个结点就是根,保存了所有字符出现频率的总和。
![5c2daf2cbf9391ab6321dd7cad760ea4.png](https://i-blog.csdnimg.cn/blog_migrate/547a9e846b083636da747a6c88327757.png)
![5bd173bb59d6122fff9f4f1c643ca349.png](https://i-blog.csdnimg.cn/blog_migrate/34ed051eb59b2932fc6c8dec8a428b6a.png)
![5085551092f93fb6292fcbe9a72f8f03.png](https://i-blog.csdnimg.cn/blog_migrate/148e0f5a46bb92e2932ff2b4e2a1a933.png)
![169bcf3b073d8807e6df87fb14ae4cb1.png](https://i-blog.csdnimg.cn/blog_migrate/e3de652c23c2b685a16b007ac7231eb4.png)
![4bbef1612202db3168aa2b30137e9df5.png](https://i-blog.csdnimg.cn/blog_migrate/ae56d2abc43af3149fce6c534c70cbab.png)
可以发现哈夫曼的构建是一个逐步合二为一的过程,所以每一个结点要么没有子结点、要么有两个子结点,故哈夫曼树是一棵满二叉树;同时由于每一次都是取出频率最小的两个结点,自底向上,故频率越小的结点会在越下边,这样就能够实现频率小编码长、频率高编码短的目的。
/**
* 哈夫曼树结点
*/
private static class Node implements Comparable<Node> {
private final char ch; // 字符,仅用于叶子结点
private final int freq; // 字符出现频率,仅用于叶子结点
private Node left, right;
public Node(char ch, int freq, Node left, Node right) {
this.ch = ch;
this.freq = freq;
this.left = left;
this.right = right;
}
public Node(char ch, int freq) {
this.ch = ch;
this.freq = freq;
}
public boolean isLeaf() {
return this.left == null && this.right == null;
}
@Override
public int compareTo(Node o) {
return this.freq - o.freq; // 通过freq(频率)来比较
}
}
/**
* @param freq 统计字符出现频率的数组
* @return huffman树的根
*/
private Node buildTrie(int[] freq) {
Queue pq = new PriorityQueue<>();
Node a, b, parent;for (char c = 0; c // R=256,总的字符集大小if (freq[c] > 0) {
pq.add(new Node(c, freq[c]));
}
}while (pq.size() > 1) {
a = pq.remove();
b = pq.remove();
parent = new Node('\0', a.freq + b.freq, a, b);
pq.add(parent);
}return pq.remove();
}
Huffman编码
构建完Huffman树之后,编码的获取就容易多了,只需遍历整个二叉树,一路上左0右1,到达叶子结点时构成的01串就是该叶子结点字符的编码。
// 构建字符-编码表
private Map buildCode(Node r, String code) {
Map map = new HashMap<>();
buildCode(map, r, code);return map;
}private void buildCode(Map codeMap, Node n, String code) {if (n == null) {
} else if (!n.isLeaf()) {
buildCode(codeMap, n.left, code + '0');
buildCode(codeMap, n.right, code + '1');
} else
codeMap.put(n.ch, code);
}
数据传输
有了哈夫曼编码表之后,原先的文件可以通过编码表一一转换成哈夫曼编码后传输。但是光传编码出去是不够的,别人对着你转换后一大串0101只能一脸蒙圈,所以还需要将解码的方法传出去(这也是一种难以避免的空间开销),两种方式:
传编码表。
对于这种方式我所能想到的是(字符ascii,编码)这样一对对地传,这样的话由于编码是不定长的,所以每一对的界限不清晰,对方并不能解析;
当然你也可以(字符ascii,频率)这样传,但是这样传频率至少需要一个
int
类型的大小来存,空间消耗有些大。并且频率的具体值并不是我们所关心的,我们关心的是频率的大小关系。所以我更趋向于第二种传输方式。传哈夫曼树。
树不是一种线性的结构,我们需要把其转换成线性的结构才能够用01串传输出去。将树以线性形式表示就是遍历了,所以传输的是哈夫曼树遍历的序列。
假设规定好传输前序遍历序列,以0表示分支结点,以1表示叶子结点,一旦遇到叶子结点就补上叶子结点的字符信息(8bit)。这样之后在解码的时候,只需要按照前序遍历的逻辑来分析01串:一旦遇到1,则后边8bit表示的是该叶子结点处的字符。
![35c771857351d01e2eb99cbd4efed9b5.png](https://i-blog.csdnimg.cn/blog_migrate/c7df3994ac4ec446d1121a6cd9e29775.png)
/**
* 前序遍历将Trie树输出
* 分支结点0/false,叶子结点1/true
*
* @param n 当前遍历到的结点
*/
private void writeTrie(Node n) {
if (n.isLeaf()) {
BinaryOutputUtil.write(true); // 此工具类将boolean类型以一个bit的形式写出
BinaryOutputUtil.write(n.ch, 8);
return;
}
BinaryOutputUtil.write(false);
writeTrie(n.left);
writeTrie(n.right);
}
private Node readTrie() {
boolean isLeaf = BinaryInputUtil.readBoolean();
if (isLeaf)
return new Node(BinaryInputUtil.readChar(), -1, null, null);
else
return new Node('\0', -1, readTrie(), readTrie());
}
实现了哈夫曼对文件的重新编码后,再加上对文件的读入输出,就可以实现一个简单的压缩软件了。大体的思路如下:
定义压缩文本的构成:哈夫曼树 编码后的文本
- 压缩:
- 读入文件内容
- 统计每一个字符的出现频率
- 根据频率构建哈夫曼树,获得哈夫曼编码
- 将哈夫曼树前序序列写入新的目标文件
- 根据编码表,将文本逐个字符重新编码,写入目标文件
- 压缩完成
- 解压缩:
- 读入哈夫曼树
- 读入重新编码后的文本
- 根据哈夫曼树进行解码,解码结果输出
- 解压缩完成
小总结
- 数据压缩就是用一种新的规范重新组织01串。
- 哈夫曼树是一棵最优满二叉树,哈夫曼编码是从根到存储数据的叶子结点的路径。
- 哈夫曼树的构建是贪心的过程,自底向上,每一次选出两个频率最小的结点衍生出一个父节点。
- 因此频率越小的结点,总是越远离根,使得频率低编码长,频率高编码短。
- 本质就是非均匀的更加合理地分配空间。
- END -完整代码
https://gitee.com/bankarian/data-structure/tree/master/src/main/java/com/beney/ds/trees/compress