数据压缩

introduction

压缩数据可以节省存储数据需要的空间和传输数据需要的时间,虽然摩尔定律说集成芯片上的晶体管每 18-24 个月翻一倍,帕金森定律说数据会自己拓展来填满可用空间,但数据压缩还是最经济的做法。

数据压缩的基本模型如下,很简单,压缩和解压,压缩率即 C(B) 和 B 的比特数之比。

数据压缩的对象本质上是二进制文件,抽象层次是比特流,所以有必要贴下课程里怎么读写二进制文件。

大多数系统的输入输出系统,像 Java,是基于 8 位的字节流,上面输入的数据可以不用和字节边界对齐。

输出的 colse() 方法会在比特流的最后一个字节用 0 补齐,以保证和文件系统的兼容性。

这是一个数据压缩的简单例子,用三种不同方式来表示日期 12/31/1990。第一种把日期当做字符串,每位用一个字节的字符类型表示,需要 80 位。第二种用三个 int 类型,需要 96 位。第三种的编码是变长的,像月份只要 4 位就能编码,而且最后补了 3 个 0 来兼容字节流,总计 24 位。这是最粗糙的数据压缩方式。

转储(dump)表示的是比特流的一种可供人类阅读的形式,用于帮助我们在调试的时候检查比特流或者字节流的内容。下图是一些例子:BinaryDump 将比特流按 0 和 1 输出来;HexDump 将比特流组织成 8 位并用两位的 16 进制数表示;PictureDump 则将比特流变为 Picture 对象,其中白色像素表示 0,黑色像素表示 1。

最后我们需要认识到一点:通用数据压缩算法是不存在的。这其实也很好理解,要是存在这样的算法,那意味着我们可以再对压缩文件进行压缩,循环往复到文件大小为零,显然是不合理的。

run-length Coding

比特流中最简单的冗余就是一长串重复的比特,游程编码(run-length coding)就利用这冗余来压缩数据。例子:

原理也很简单,就是统计重复的个数,直接记总数而不是全部写出来。上图的例子中用了 4 位计数,下面的代码里用了 8 位,最多能计到 255。要是有 300 个 1,计满 255 后再插入 8 位 0,表示有 0 个 0,然后再继续计剩下的 45 位就好。代码:

  1. public class RunLength {
  2. private final static int R = 256; // maximum run-length conut
  3. private final static int lgR = 8; // number of bits per conut
  4. public static void compress() {
  5. char cnt = 0;
  6. boolean b, old = false;
  7. while (!BinaryStdIn.isEmpty()) {
  8. b = BinaryStdIn.readBoolean();
  9. if (b != old) {
  10. BinaryStdOut.write(cnt);
  11. cnt = 0;
  12. old = !old;
  13. } else {
  14. if (cnt == 255) {
  15. BinaryStdOut.write(cnt);
  16. cnt = 0;
  17. BinaryStdOut.write(cnt);
  18. }
  19. }
  20. cnt++;
  21. }
  22. BinaryStdOut.write(cnt);
  23. BinaryStdOut.close();
  24. }
  25. public static void expand() {
  26. boolean bit = false;
  27. while (!BinaryStdIn.isEmpty()) {
  28. int run = BinaryStdIn.readInt(lgR); // read 8-bit conut from standard input
  29. for (int i = 0; i < run; i++)
  30. BinaryStdOut.write(bit); // write 1 bit to standard output
  31. bit = !bit;
  32. }
  33. BinaryStdOut.close(); // pad 0s for byte alignment
  34. }
  35. }

这种策略对实际应用中经常出现的几种比特流十分有效,游程编码的一个应用是压缩位图(bitmap),位图被广泛用于保存图片和扫描文档。简单起见,我们将二进制位图数据组织为将像素按行排列的比特流。可以看到,右边压缩后的比特流显然小了很多。

游程编码不适用于含有大量短游程的输入,而不是所有我们希望压缩的比特流都含有较长的游程,所以下面来介绍两种适用于多种类型的文件压缩算法。

Huffman Compression

哈夫曼压缩和第一个年月日的例子一样,都采取变长编码,哈夫曼的原理就是用较短的编码来表示频率较高的字符,从而达到节省空间的目的。而变长编码存在多义性(ambiguity)的问题,用摩斯密码举例说明:

...---... 是最常用的摩斯密码,表示 SOS,但是从上表来看,也可以解读为 V7、IAMIE 和 EEWNI,所以实际上密码之间还有一定的间隙隔开,以避免错误的解读。

多义性的本质原因是有些字符的编码是其它字符编码的前缀,所以才可能会有不同的解读。而有种特殊的变长编码——前缀码(prefix-free code),字符编码肯定不是其它字符编码的前缀,也就不存在多义性的问题。

前缀码可以很自然地用 Trie 来表示,被编码的字符都在叶子结点上,也就没有谁是谁的前缀。同时,也可以发现前缀码不是唯一的,那也就存在一个最优的前缀码,使得压缩后的比特流最短。

Trie Node

  1. private static class Node implements Comparable<Node> {
  2. private final char ch; // used only for leaf nodes
  3. private final int freq; // used only for compress
  4. private final Node left, right;
  5. public Node(char ch, int freq, Node left, Node right) {
  6. this.ch = ch;
  7. this.freq = freq;
  8. this.left = left;
  9. this.right = right;
  10. }
  11. private boolean isLeaf() {
  12. return left == null && right == null;
  13. }
  14. public int compareTo(Node that) {
  15. return this.freq - that.freq;
  16. }
  17. }

字符出现频率在下面生成最优前缀码的时候会用到。

用 Trie 表示的前缀码对文件进行压缩后,还要把 Tire 附上,解压(展开,expand)时才知道怎么做。所以得把 Trie 写入比特流,解压时再从比特流中读出来,这边按前序遍历的顺序来读写。

到叶子结点都会先输出个 true(比特 1),内部结点则是 0,为读做了记号的感觉。当压缩文件很大时,附在开头的 Trie 相对就会显得很小,没有什么关系。

在比特流中碰到 1,说明接下来 8 比特是叶子结点的字符,于是读入 8 位。

现在,我们大概知道了要用 Trie 来压缩,以及怎么传输 Trie 好用于解压,关键的如何构造 Trie 还没说,特别得是构造最优前缀码的 Trie。实际上,哈夫曼的做法很好描述:首先你要知道字符出现的频率,然后每次挑两个最小的加起来,加起来的值再和原来的那些一起重复挑两个最小的加起来,从下往上接成 Trie。

Constructing Huffman Trie

  1. private static Node buildTrie(int[] freq) {
  2. MinPQ<Node> pq = new MinPQ<Node>();
  3. for (char i = 0; i < R; i++)
  4. if (freq[i] > 0)
  5. pq.insert(New Node(i, freq[i], null, null));
  6. // merge two smallest tries
  7. while (pq.size() > 1) {
  8. Node x = pq.delMin();
  9. Node y = pq.delMin();
  10. Node parent = new Node('\0', x.freq + y.freq, x, y);
  11. pq.insert(parent);
  12. }
  13. return pa.delMin();
  14. }

Optimal

首先,需要个标准来判断各前缀码优劣。有个概念叫 Trie 的 加权外部路径长度,等于所有叶子结点的频率和其深度的乘积总和,也就是压缩后字符集的长度。最优前缀码 Trie T 的加权外部路径长度最小,记为 \(B(T)\)。

证明: 对规模为 n (不小于 2)的字符集(至少两个不同字符),哈夫曼算法可以构造出一个最优的前缀码 Trie。

  • 当 n = 2 时,两个字符只能分别用 0 和 1 表示,显然成立。

  • 假设哈夫曼算法对规模为 K(大于 2)的字符集能构造出一个最优前缀码 Trie。

  • 现在考虑规模为 K + 1 的字符集 \(C = \{x_{1}, x_{2},...,x_{k + 1}\}\),其中 \(x_{1}, x_{2} \in C\) 是频率最小的两个字符。

    令 \(C^{'} = (C - \{x_{1}, x_{2}\}) \cup \{z\}\),其中 \(f_{z} = f_{x_{1}} + f_{x_{2}}\)(\(f_{x}\) 为字符 x 出现的频率)。

    根据假设,哈夫曼算法可以构造出规模为 K 的字符集 \(C^{'}\) 的一个最优前缀码 Trie,不妨记做 \(T^{'}\)。对 \(T^{'}\) 中表示 \(z\) 的叶子结点添加两个孩子 \(x_{1}\) 和 \(x_{2}\) 得到的新 Trie 记做 \(T\)(相当于直接对 \(C\) 用哈夫曼构造出来的)。

    用反证法证明 \(T\) 就是字符集 \(C\) 的一个最优前缀码 Trie,为此需要先了解下面两个引理。

    1. \(B(T^{'}) = B(T) - (d + 1)(f_{x_{1}} + f_{x_{2}}) + d(f_{x_{1}} + f_{x_{2}}) = B(T) - (f_{x_{1}} + f_{x_{2}})\)(d 为深度)。

    2. 存在 \(C\) 的一个最优前缀码 Trie,\(x_{1}\) 和 \(x_{2}\) 是最深叶子且为兄弟。这个不难证明,稍作计算就可以发现把 \({x_{1}}, x_{2}\) 和最深兄弟叶子结点交换不会增加加权外部路径长度。所以只要有一个最优,就能交换成上面的形式,也就肯定存在。

    假设字符集 \(C\) 存在着更优的 \(T^{*}\),即有 \(B(T^{*}) < B(T)\)。且根据引理二,不妨认为 \(T^{*}\) 即为 \({x_{1}}, x_{2}\) 是最深兄弟叶子结点的形式。从这样的 \(T^{*}\) 里,类似地去掉 \({x_{1}}, x_{2}\) 得到 \(T^{*'}\)。由引理一有:

    \(B(T^{*'}) = B(T^{*}) - (f_{x_{1}} + f_{x_{2}}) < B(T) - (f_{x_{1}} + f_{x_{2}}) = B(T^{'})\)

    和 \(T^{'}\) 是字符集 \(C^{'}\) 的最优前缀码 Trie 矛盾,故 \(T\) 是字符集 \(C\) 的最优前缀码 Trie。

    所以哈夫曼构造了规模为 K + 1 的字符集 C 的最优前缀码 Trie T,归纳得证。

参考链接:点我

LZW-compression

LZW 压缩算法是自适应性的(adaptive)模型,在读入文本的时候学习并更新模型,不需要将模型附在比特流中用于解压,但解压的时候只能从文本开头开始。

算法原理并不复杂,直接来看压缩和展开的例子。

Compression Example

压缩可以概括成这几个步骤:

  1. 创建符号表,键为字符串,值为字符串对应的定长编码。
  2. 初始化符号表,加入单个字符的键值对。
  3. 在符号表键中找文本未扫描部分的最长前缀 s,输出 s 对应的值(编码)。
  4. 预读文本下一个字符 c,更新符号表,新键为字符串 s + c。
  5. 重复上两步直到文本结束。

例图中编码长度为 8 位,用两位 16 进制表示,单字符编码即 7 位标准 ASCII 中的值,像 A 是 41(0100 0001)。编码 80 保留为表示文本结束,新键 s + c 的编码从 81 开始。

文本未扫描部分在符号表键中最长前缀,一开始是 A,于是编码成 41,接着预读下一位 B,往符号表中加入新键值对 (AB, 81);下一个最长前缀是 B,编码 42,预读并加入新键值对 (BR, 82);... 读完 D 之后,最长前缀为 AB,编码 81,预读并加入新键值对 (ABR, 88);... 文本结束编码为 80。

最长前缀用 Trie 来获取,代码用了 TST:

  1. public static void compress() {
  2. // R 表示字符总数,例图是 0x80
  3. // L 表示编码最大值,例图是 0xFF
  4. // W 表示编码宽度,例图是 8 位
  5. String input = BinaryStdIn.readString();
  6. TST<Integer> st = new TST<Integer>();
  7. for (int i = 0; i < R; i++)
  8. st.put("" + (char) i, i);
  9. int code = R + 1; // 新键编码从 0x81 开始
  10. while (input.lenght() > 0) {
  11. String s = st.longestPrefixOf(input);
  12. BinaryStdOut.write(st.get(s), W);
  13. int t = s.length();
  14. if (t < input.length() && code < L)
  15. st.put(input.substring(0, t + 1), code++); // 更新符号表
  16. input = input.substring(t);
  17. }
  18. BinaryStdOut.write(R, W); // 写上 0x80 表示文件结束
  19. BinaryStdOut.close();
  20. }

LZW 算法就是这样用定长的编码来表示越来越长的字符串来节省空间的,当编码都用完了,在例子中就是有字符串被编码为 FF 时,就全部丢掉重新开始,或是当不再有效时丢掉,这里不展开讨论。

Expand Example

展开和压缩类似,有下面几个步骤:

  1. 创建符号表,但这次编码为键,对应的字符串为值。
  2. 初始化符号表,加入单个字符的键值对。
  3. 从压缩文件读入 W 位的编码,输出编码对应的字符串。
  4. 预读下一个编码,得到下个字符,类似地更新符号表。
  5. 重复上两步直到读入结束编码。

例图即展开上面压缩形成的编码。

一开始读入 8 位编码 41,从符号表可知对应字符串 A,输出 A 后预读下一个编码 42,对应 B,于是往符号表中加入新键值对 (81, AB);现在读到编码 42,输出 B 并预读 52 得到 R,所以加入 (82, BR) ... 直到读入编码 80,表示文件结束。

似乎展开和压缩差不多,甚至更简单,因为不需要找最长前缀,符号表直接用数组简单实现。但是,展开有时会碰到一个特殊的情况:

压缩字符串 ABABABA 编码成 41 42 81 83 80,现在对这编码进行展开。编码 41 输出 A,预读 42 后加入 (81, AB) 更新符号表;编码 42 输出 B,预读 81 知道下个字符是 A,加入 (82, BA);编码 81 输出 AB,预读 83 卡住,因为符号表中还没有这个键。

但是,这种时候我们还是可以知道 AB 的下一个字符是什么的。假设 AB 后面的字符分别为 \(c_{1}\),\(c_{2}\),\(c_{3}\),卡住的时候(更新要加入的编码和预读到的编码一样)肯定有 AB\(c_{1}\) = \(c_{1}c_{2}c_{3}\),所以下个字符即 A,加入 (83, ABA) 即可。

代码:

  1. public static void expand() {
  2. int i; // 当前更新符号表要加入的编码
  3. String[] st = new String[L];
  4. for (i = 0; i < R; i++)
  5. st[i] = "" + (char) i;
  6. st[i++] = " "; // 例图中表示文件结束的 0x80
  7. int codeword = BinaryStdIn.readInt(W);
  8. String val = st[codeword];
  9. while (true) {
  10. BinaryStdOut.write(val);
  11. codeword = BinaryStdIn.readInt(W); // 预读的编码
  12. if (codeword == R) break;
  13. String s = st[codeword];
  14. if (i == codeword) // 要加入的编码和预读的编码相同
  15. s = val + val.charAt(0);
  16. if (i < L)
  17. st[i++] = val + s.charAt(0);
  18. val = s;
  19. }
  20. BinaryStdOut.close();
  21. }

可以看到不用传输模型就能展开压缩的编码文件。

Data Compression的更多相关文章

  1. SQL SERVER ->> Data Compression

    最近做了一个关于数据压缩的项目,要把整个SQL SERVER服务器下所有的表对象要改成页压缩.于是趁此机会了解了一下SQL SERVER下压缩技术. 这篇文章几乎就是完全指导手册了 https://t ...

  2. Programming Assignment 5: Burrows–Wheeler Data Compression

    编程作业五 作业链接:Burrows-Wheeler Data Compression & Checklist 我的代码:MoveToFront.java & CircularSuff ...

  3. Data Compression Category

    Data Compression is an approach to compress the origin dataset and save spaces. According to the Eco ...

  4. dimensionality reduction动机---data compression(使算法提速)

    data compression可以使数据占用更少的空间,并且能使算法提速 什么是dimensionality reduction(维数约简)    例1:比如说我们有一些数据,它有很多很多的feat ...

  5. Hive 压缩技术Data Compression

    Mapreducwe 执行流程 :input > map > shuffle > reduce > output 压缩执行时间,map 之后,压缩,数据存储在本地磁盘,减少磁盘 ...

  6. 吴恩达机器学习笔记48-降维目标:数据压缩与可视化(Motivation of Dimensionality Reduction : Data Compression & Visualization)

    目标一:数据压缩 除了聚类,还有第二种类型的无监督学习问题称为降维.有几个不同的的原因使你可能想要做降维.一是数据压缩,数据压缩不仅允许我们压缩数据,因而使用较少的计算机内存或磁盘空间,而且它也让我们 ...

  7. Toward Scalable Systems for Big Data Analytics: A Technology Tutorial (I - III)

    ABSTRACT Recent technological advancement have led to a deluge of data from distinctive domains (e.g ...

  8. Codeforces 650C Table Compression

    传送门 time limit per test 4 seconds memory limit per test 256 megabytes input standard input output st ...

  9. codeforces Codeforces Round #345 (Div. 1) C. Table Compression 排序+并查集

    C. Table Compression Little Petya is now fond of data compression algorithms. He has already studied ...

随机推荐

  1. composer windows安装

    一.下载安装包安装 https://getcomposer.org/download/(由于墙的限制,可能下载可执行文件失败,即使成功,由于网络的原因,安装的时候也可能会失败,所以建议用第二种方法) ...

  2. jQuery如何根据元素值删除数组元素

    用到的方法$.inArry(); $.inArray( value, array [, fromIndex ] ) value 任意类型 用于查找的值. array Array类型 指定被查找的数组. ...

  3. 【转】Cookie深度解析

    Cookie简介 众所周知,Web协议(也就是HTTP)是一个无状态的协议(HTTP1.0).一个Web应用由很多个Web页面组成,每个页面都有唯一的URL来定义.用户在浏览器的地址栏输入页面的URL ...

  4. SpringBoot(五) Web Applications: MVC

    统一异常处理 SpringBoot的默认映射 /error 码云: commit: 统一异常处理+返回JSON格式+favicon.ico 文档: 28.1.11 Error Handling 参考 ...

  5. MongoDB2.x升级到3.x解决方案

    MongoDB2.x版本Maven配置 <!-- mongodb --> <dependency> <groupId>org.springframework.dat ...

  6. 编译gRPC Go版本使用的 ProtoBuffer 文件

    本篇文章主要解决mac下安装ProtoBuffer,编译go版本gRPC用的.proto文件 安装 protoc 注意,gRPC 需要用到 proto3, 而目前 Release 的版本是 2.6.1 ...

  7. python 查询数据库返回的数据类型

    self.conn=MySQLdb.connect(host='localhost',port=3306, user='keystone', passwd='OptValley@4312', db=s ...

  8. Hibernate的多对多实例

    在完成了一对多的实例的基础上,继续做多对多实例.例子是老师和学生,一个老师教多个学生,一个学生也有多个老师. 文档结构如图:

  9. axios上传图片(及vue上传图片到七牛))

    浏览器上传图片到服务端,我用过两种方法: 1.本地图片转换成base64,然后通过普通的post请求发送到服务端. 操作简单,适合小图,以及如果想兼容低版本的ie没办法用此方法 2.通过form表单提 ...

  10. JSON学习笔记-3

    JSON 对象 1.对象语法 JSON 对象使用在大括号({})中书写. 对象可以包含多个 key/value(键/值)对. key 必须是字符串,value 可以是合法的 JSON 数据类型(字符串 ...