zoukankan      html  css  js  c++  java
  • Data Compression

    数据压缩

    introduction

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

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

    data-compression-basic-model

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

    binary-stdin

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

    binary-stdout

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

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

    data-compression-example

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

    test-bitestream

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

    run-length Coding

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

    run-length-example

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

    public class RunLength {
        private final static int R = 256;    // maximum run-length conut
        private final static int lgR = 8;    // number of bits per conut
    
        public static void compress() {
            char cnt = 0;
            boolean b, old = false;
            while (!BinaryStdIn.isEmpty()) {
                b = BinaryStdIn.readBoolean();
                if (b != old) {
                    BinaryStdOut.write(cnt);
                    cnt = 0;
                    old = !old;
                } else {
                    if (cnt == 255) {
                        BinaryStdOut.write(cnt);
                        cnt = 0;
                        BinaryStdOut.write(cnt);
                    }
                }
                cnt++;
            }
            BinaryStdOut.write(cnt);
            BinaryStdOut.close();
        }
    
        public static void expand() {
            boolean bit = false;
            while (!BinaryStdIn.isEmpty()) {
                int run = BinaryStdIn.readInt(lgR); // read 8-bit conut from standard input
                for (int i = 0; i < run; i++)
                    BinaryStdOut.write(bit);        // write 1 bit to standard output
                bit = !bit;
            }
            BinaryStdOut.close();                  // pad 0s for byte alignment
        }
    }
    

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

    bitmap

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

    Huffman Compression

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

    morse-code

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

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

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

    prefix-free-code-trie-representation

    Trie Node

    private static class Node implements Comparable<Node> {
        private final char ch;    // used only for leaf nodes
        private final int freq;   // used only for compress
        private final 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;
        }
    
        private boolean isLeaf() {
            return left == null && right == null;
        }
    
        public int compareTo(Node that) {
            return this.freq - that.freq;
        }
    }
    

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

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

    trie-write

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

    trie-read

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

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

    huffman-demo

    Constructing Huffman Trie

    private static Node buildTrie(int[] freq) {
        MinPQ<Node> pq = new MinPQ<Node>();
        for (char i = 0; i < R; i++)
            if (freq[i] > 0)
                pq.insert(New Node(i, freq[i], null, null));
    
        // merge two smallest tries
        while (pq.size() > 1) {
            Node x = pq.delMin();
            Node y = pq.delMin();
            Node parent = new Node('', x.freq + y.freq, x, y);
            pq.insert(parent);
        }
    
        return pa.delMin();
    }
    

    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 开始。

    lzw-compression

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

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

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

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

    Expand Example

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

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

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

    lzw-expand

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

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

    lzw-tricky

    压缩字符串 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) 即可。

    代码:

    public static void expand() {
        int i;                                      // 当前更新符号表要加入的编码
        String[] st = new String[L];
    
        for (i = 0; i < R; i++)
            st[i] = "" + (char) i;
        st[i++] = " ";                             // 例图中表示文件结束的 0x80
    
        int codeword = BinaryStdIn.readInt(W);
        String val = st[codeword];
        while (true) {
            BinaryStdOut.write(val);
            codeword = BinaryStdIn.readInt(W);    // 预读的编码
            if (codeword == R) break;
            String s = st[codeword];
            if (i == codeword)                   // 要加入的编码和预读的编码相同
                s = val + val.charAt(0);
            if (i < L)
                st[i++] = val + s.charAt(0);
            val = s;
        }
        BinaryStdOut.close();
    }
    

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

  • 相关阅读:
    VS-Visual Studio-IIS Express 支持局域网访问
    JAVA和C# 3DES加密解密
    Js调用Java方法并互相传参
    Cannot find SS.INI file for user *** 解决方法
    $.ajax()方法参数详解
    HANA Studio打开系统显示Secure storage is locked
    C#通过ODBC查询HANA数据库数据
    IIS7发布asp.net mvc提示404.0
    CentOS 搭建git服务
    解决用navicat远程连接数据库出现1045 access denied for user 'root'@'localhost' using password yes
  • 原文地址:https://www.cnblogs.com/mingyueanyao/p/9516423.html
Copyright © 2011-2022 走看看