读书笔记——数据压缩入门(柯尔特·麦克安利斯)中
文章目录
- 数据压缩入门汇总
- 第六章 自适应统计编码
- 6.1 位置对熵的重要性
- 6.2 自适应VLC编码
- 6.2.1 动态创建VLC表
- 6.2.2 字面值
- 6.2.3 重置
- 6.2.4 何时重置
- 6.3 自适应算术编码
- 6.4 自适应哈夫曼编码
- 6.5 现代的选择
- 第七章 字典转换
- 7.1 基本字典转换
- 7.2 LZ算法
- 第八章 上下文数据转换
- 8.1 RLE 游程编码
- 8.2 增量编码
- 8.2.1 XOR增量编码
- 8.2.2 参照系增量编码
- 8.2.3 修正的参照系增量编码
- 8.2.4 压缩增量编码后的数据
- 8.3 MTF前移编码
- 8.4 BWT
数据压缩入门汇总
读书笔记——数据压缩入门(柯尔特·麦克安利斯)上
读书笔记——数据压缩入门(柯尔特·麦克安利斯)中
读书笔记——数据压缩入门(柯尔特·麦克安利斯)下
第六章 自适应统计编码
6.1 位置对熵的重要性
第5章的统计编码算法,在编码开始之前都需要遍历一次数据。如果是相对较小的数据集,那么没啥问题。然而,随着要压缩的数据集变大,统计编码的结果与熵的偏差也会越来越大,这是因为数据集的不同部分有着不同的概率特征。如果处理的是流数据,比如视频流或音频流,由于整个数据集没有“结尾”,因此就不能“遍历两次”;
在数据流中,字符Q可能会在前三分之一部分出现很多次,而在后三分之二部分则一次也没有出现。统计编码算法的概率表无法处理字符Q分布的这种局部性。如果字符Q出现的概率为0.01,那么通常我们会期望它在整个数据流中均匀分布,也就是说,大约每100个字符中就有1个是Q。
然而实际数据的情况并非如此,数据中总会存在某种类型的局部偏态(locality-dependent skewing),将某些符号、想法或者单词集中在数据集的某个子区间里;
结果是统计编码算法生成的编码比根据熵生成的更臃肿,这是因为其所依据的概率信息没有考虑统计上的局部变化。例如,如果将数据流分为多个块并且每块都单独压缩,那么得到的结果可能会比将数据流整体压缩得到的结果小(如果数据流中存在很多局部偏态的情况的话);
对于这样一个数据集AAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBCDEFGHIJKLMNOPQRSTUVWXYZ
,该数据集的熵约为3.48,说明平均每个符号需要使用3.48个二进制位的空间,整个数据集压缩后的大小为198.36个二进制位。如果使用哈夫曼编码,编码后的大小为202个二进制位,也就是每个符号约使用3.54个二进制位,这样的结果看起来还不错;
这里可以清楚地看到整个字符串的前一半高度重复,只由两个字符组成。实际上,遇到这种情况我们会去想办法分割整个字符串,这样,对字符串的前一半,就会有更好的编码方法。与将整个字符串转换为VLC相比,将字符串分成两半,前一半每个符号平均只需要1个二进制位,后一半平均需要5个二进制位,难道这样的结果不是更令人满意吗?分割为两半后,整个字符串共需要122个二进制位,平均每个符号需要2.1个二进制位。(这里需要指出的是,这样的结果已经超越了香农,而且远远超越。)
挑战在于如何以最佳的方式分割数据流。扫描再分段明显效率不高,并且会有一种解决NP完全问题(NP-complete problem)的感觉。因此,我们编码时不再提前扫描并去找合适的分割点,而是允许统计编码算法自动“重置”。
所以,在编码时,如果“期望”的熵与“实际的符号平均二进制位数”之间出现显著差异,那么统计编码算法会重置概率表,并使用重置后的概率表进行编码;
这种具有适应数据流熵的局部特性能力的统计编码算法,通常被称为**“动态”或“自适应”统计编码算法**。这些算法变体构成了大多数重要的、高性能的、高压缩率的多媒体数据流(如图片、视频及音频)压缩算法的基础。
6.2 自适应VLC编码
一般来说,统计压缩有3个步骤:
(1) 遍历数据流并计算各个符号的出现概率;
(2) 根据概率为符号生成VLC;
(3) 再次遍历数据流并输出对应的码字。
从上面可以看出,压缩时需要遍历(或者说扫描)数据流两次,并且整个数据集只有一套VLC表。这里的问题是,VLC表是静态的。
而在自适应的压缩算法中,这3个步骤简化为仅遍历一次数据集,但是过程要更复杂。关键是符号码字对应表并非必须一成不变,相反,可以根据读到的符号更新它。自适应统计编码的关键在于其符号码字对应表并非一成不变,相反,可以根据读到的符号动态地生成VLC。这一过程的动态性质,让我们可以根据需要对VLC表进行修改,比如对其重置。
6.2.1 动态创建VLC表
动态创建VLC表的原理如下。在编码器处理数据流时,每读取一个符号,编码器都会问:
- 这个符号之前出现过吗?
- 如果出现过,那么输出当前分配的码字,并更新其出现的概率;
- 如果没有,则进行一些特殊处理(稍后会讲到这个部分)。
假设目前已有的VLC表如下:
接下来,需要从输入流中读取下一个符号,这个符号恰好是B,于是进行下面的操作:
(1) 输出B当前对应的码字10。
(2) 更新相应的概率,因为B出现的可能性现在变大了一些(其他符号出现的概率变小了一些),更新后的表如下所示
(3) 继续读取下一个符号,还是B,因此再次输出10,并继续更新相关符号的概率。再次更新后的表如下所示。
注意,这里有一件重要的事情发生。由于B已经成为数据流中最可能出现的符号,因此将最短的码字分配给它,如果下一次读到的符号还是B,那么相应的输出会是0,而不是之前的10;
编码:
(1) 从输入流中读取符号
(2) 输出该符号对应的码字到输出流中
(3) 更新符号的出现概率并重新生成码字。
解码:
(1) 从输入流中读取码字
(2) 输出该码字对应的符号到输出流中
(3) 更新符号的出现概率并重新生成码字。
这就是自适应统计编码算法工作的大致流程。编码器和解码器都会动态更新符号的出现概率及相应的码字,这通常以积极的方式影响压缩。
6.2.2 字面值
现在我们仍然面临以下两个问题。
- 在开始编码前,最初的 VLC表是什么样子?
- 在解码过程中,如果读到一个 VLC表中不存在的符号该怎么办?
其实是字面值词条(literaltokens)的问题,书上对字面值的定义的解释难懂,但是后面那个例子很好:
数据流AAAAABCABC
编码后的二进制流为00 1010 01 00 00 00 01 1011 01 1100 00 10 11
具体步骤如下:
①开始编码时,我们还没有读任何符号,此时表如下所示:
②读取第一个符号A,符号A不在VLC表中,因此需要输出字面值词条LIT的码字(即00),然后是A对应的4个二进制位表示:1010;将符号A添加到VLC表中,并根据符号的出现频次更新表。由于A和LITERAL各出现一次,因此两者的出现概率均为0.5,并为两个符号分配码字:LIT =00,A = 01;
此时,编码后的二进制流为00 1010
;
③继续读下一个符号,还是A,由于A已经在表中,因此输出其对应的码字01;更新VLC表,符号A已成为最经常出现的符号,因此需要重新分配相应的码字:A = 00,LIT = 01;
此时,编码后的二进制流为00 1010 01
;
④继续读取下一个符号,还是A,输出A对应的码字00 并更新VLC表中的概率;
此时,编码后的二进制流为00 1010 01 00
;
⑤继续读取下一个符号,还是A,输出A对应的码字00 并更新VLC表中的概率;
此时,编码后的二进制流为00 1010 01 00 00
;
⑥继续读取下一个符号,还是A,输出A对应的码字00 并更新VLC表中的概率;
此时,编码后的二进制流为00 1010 01 00 00 00
;
⑦ 继续读取下一个符号,是B,B不在VLC表中,因此需要输出字面值词条LIT的码字(即01),然后是B的4个二进制位表示:1011;将B添加到VLC表中并更新表,A = 00,LIT = 01,B = 10;
此时,编码后的二进制流为00 1010 01 00 00 00 01 1011
;
⑧继续读取下一个符号,是C;C不在VLC表中,因此需要输出字面值词条LIT的码字(即01),然后是C的4个二进制位表示:1100;将C添加到VLC表中并更新表,A = 00,LIT = 01,B = 10,C = 11;
此时,编码后的二进制流为00 1010 01 00 00 00 01 1011 01 1100
;
⑨继续读取下一个符号,是A,输出A对应的码字00 并更新VLC表中的概率;
此时,编码后的二进制流为00 1010 01 00 00 00 01 1011 01 1100 00
;
⑩继续读取下一个符号,是B,输出B对应的码字10 并更新VLC表中的概率;
此时,编码后的二进制流为00 1010 01 00 00 00 01 1011 01 1100 00 10
;
最后读取下一个符号,是C,输出C对应的码字11 并更新VLC表中的概率,最终编码后的二进制流为:00 1010 01 00 00 00 01 1011 01 1100 00 10 11
;
解码就是对编码后的二进制流再来一遍,因为字面值已经记录了遇到的每一个新值,所以自然可以反着推出来;
6.2.3 重置
自适应统计编码的真正强大之处在于,当输出流的熵要变大失控时,它能重置输出流;以[AAABBBBBCCCCCC] 为例,并将符号放在尖括号中来表示相应的字面值,如<A >
。对上述字符串编码后,得到如下的输出:< A > ,0,0, < B > ,1,1,1,0, < C > ,11,11,11,11,0;
注意,最后的码字0对应的是最后一个符号C,此时C的出现概率已经足够大,因而需要重新为其分配码字。从中可以看到,更多的符号以及特定符号的更多出现是如何影响最终输出的平均二进制位数的。当然,如果遇到符号C时就能重置VLC表,从而得到用码字0表示所有的C这样理想的结果就更好了,此时得到如下的输出:
< A > ,0,0, < B > ,1,1,1,0, < C > , < RESET > 0,0,0,0,0
结果表明,我们完全可以采用与字面值相同的策略,创建一个< RESET > 词条,如下示例表所示。当解码器遇到这个词条时,它就会重置符号表并重新开始解码。编码与解码的工作原理与前面介绍的相同。
< RESET >和< LITERAL > 会一直在符号表中存在(像其他符号一样),但随着时间的推移,这两个符号出现得越来越少,因此其出现概率也变得越来越小。
6.2.4 何时重置
为了做出重置的决定,需要做以下3件事:
- 为重置设定一个阈值,也就是说,当符号平均二进制位数(bits-per-symbol,BPS)为某个值时,放弃现有的 VLC表并重新开始;
- 大致计算一下当前输出流的 BPS,并与设定的阈值比较;
- 计算当前已读取的输入流的熵;
当输出流的BPS超过设定的阈值时,例如比BPS大5个二进制位,就可以认为数据流已经发生了显著变化,应该重置概率值;具体来说,如果一直关注输入符号的熵,我们就会发现输出流的二进制位数通常要比根据熵计算出来的大,用公式表示就是熵×目前已读的符号数< 输出流的二进制位数
;
值得指出的是,在实际场景中没有人会使用这种简版的自适应VLC算法。同样的问题也存在于静态版的VLC算法中。相反,大多数的现代压缩工具已完全采用自适应版的哈夫曼编码器与算术编码器,它们都可以动态生成概率表,并实时更新符号对应的码字;
6.3 自适应算术编码
要将算术编码变成自适应的很容易,这主要是因为其编码步骤与概率表之间的交互很简单。只要编码器与解码器在更新概率的正确顺序上达成一致,我们就能根据需要更新概率表;比如当前概率表如下:
(1) 读取下一个输入符号,假定是字母G
(2) 按G当前的概率对其进行编码
(3) 根据新信息更新概率表。(由于不知道之前的输入流,因此这里假定下表所示的就是更新后的概率值。)
(4) 根据概率表重新分配区间。
解码器以相反的方式工作。给定当前的概率,找出与当前输出值对应的符号,然后更新概率表,再重新分配各符号的区间。增加字面值与重置词条后,其工作原理仍然与自适应VLC相同。我们可以将这些词条指定为附加符号,并相应地调整它们的权重。
6.4 自适应哈夫曼编码
因此,自适应哈夫曼算法没有每次都重新生成完整的树,而是在读取和处理符号时调整现有的树。这就让情况变得稍微有些复杂,因为每读取一个符号都必须要做以下这些事情:
- 更新概率;
- 对树的大量结点变换位置并重新排序,以使它们与概率的变化同步;
- 使树的结构满足哈夫曼树的要求;
自适应哈夫曼算法的最初版本是由Faller(参见Newton Faller的论文An AdaptiveSystem for Data Compression,载于Record of the 7th Asilomar Conferenceon Circuits, Systems,and Computers(lEEE,1973),第593-597页)于1973年提出的,1985年高德纳(参见高德纳的论文Dynamic Huffman Coding,载于Journal of Algorithms,1985年第6期,第163-180页)又对此算法做出重大改进,但是所有现代的版本都是建立在Vitter(参见effrey S. vitter的论文Design andAnalysis of Dynamic Huffman Codes,载于Journal of the ACM,1987年10月,第34期,第825页)于1987年提出的方法之上;
6.5 现代的选择
相比静态的方法,这些动态的改进有以下优点:
- 有生成符号码字对应表的能力,无须将符号码字对应表显式地存储在数据流中。数据流变小后,计算性能就能有所提高,但更重要的是下面两个优点;
- 有实时压缩数据的能力,无须再将整个数据集作为一个整体来处理。这让我们可以有效地处理更大的数据集,甚至都不用事先知道要处理的数据集有多大;
- 有适应信息局部性的能力,即邻近的符号会对码字的长度有影响,这可以显著提高压缩率;
如果处理的是少量的数据,那么简单的静态统计编码算法就可以工作得很好,并且可以让我们以较低的复杂度实现熵。如果处理的是大量的数据或者多媒体数据,而且运行时的性能很重要,那么采用自适应统计编码算法无疑是正确的选择;
第七章 字典转换
对于文本的压缩,仍存在很大的挑战;
字典转换(dictionary transforms),它完全改变了人们对数据压缩的认知。突然间,压缩变成了一种对各种类型的数据都有用的算法。它的应用范围非常广泛,事实上今天所有的主流压缩算法(比如GZIP或者7-Zip)都会在核心转换步骤中使用字典转换;
7.1 基本字典转换
真实数据的基本属性:上下文及词语的组合,或者简单地说就是短语。
例如,对短语“TO BE OR NOT TO BE”,不必将每个字母都当作一个符号去编码,而将实际的英语单词当作符号去编码。这样一来,创建的符号码字对应表就会如下表所示(忽略单词间的空格)
这样编码后,得到的结果为000110110011。按原来的方式对每个字母编码,最终的结果需要104个二进制位;而按现在这种方式对每个单词编码,最终的结果只需要12个二进制位。如果考虑的对象不再是单个的符号,而是一组相邻的符号,我们就走出了统计压缩的世界,来到了字典转换的世界。
字典转换的工作方式也正如你期望的那样:给定源数据流,首先构建出单词字典(而不是符号字典),然后再将统计压缩应用到字典中的单词上;
因此,字典转换实际是一个数据流的预处理阶段,经过这样的预处理后,生成的数据集会更小,比源数据流压缩率更高。当能识别出那些经常重复使用的长字符串,并为它们分配最短的码字时,字典转换的效率最高;
那么如何构建符号字典,如何找出这些最佳的单词 => 分词过程;如果根据单个符号的值也就是“字母”去分词,示例数据流会是什么样子,结果如下表所示:
根据“字母”去分词,“O”和“T”重复出现次数最多,最终得出熵为2.38;不再用最短的符号即单个“字母”,而是用字串中重复出现的最长的子串来分词,所得结果如下表所示。
其中最长的子串是“TOBEORNOT”,它在示例数据中出现两次。如果为它分配一个单独的码字,这样分词后字符串的熵约为2.5,比根据字母去分词还大,因此对这样的数据来说,这不是一个好结果。熵增加的原因是,分词之后数据中没有明显出现一个“单词”占主导地位的情况;
也可以根据最常出现的子串去分析,这样分词的结果是TOBEOR和NOT成为了“单词”,如下所示。分词后的熵为2.2:
另一种方法是通过找出长度大于1的最短“单词”来分词,如下表所示,TO、BE、OR和NOT是切分出来的单词。分词后的熵为1.98,这是目前得到的最好结果。
一种暴力方法是读取一组符号(如“TO”)并搜索字符串的剩余部分来确定该组符号的出现频次。如果出现频次与现有的符号表匹配得很好,那么算法就继续读取下一组符号并重复这一过程。否则,算法就会尝试读取一组不同的符号(比如“TOB”)。可惜的是,对所有真实的数据流而言,这样做不仅需要大量的内存,同时还需要花费很长的时间。因此,它不适用于任何类型的实时处理;
7.2 LZ算法
由Lempel和Ziv提出的LZ77和LZ78算法产生了一系列的衍生算法,包括GIF图像格式中使用的LZW(即Lempel-Ziv-Welch)算法,应用于7-Zip、xz等压缩工具的LZM(即Lempel-Ziv-Markov chain)算法。这些算法也同样应用于DEFLATE这样的压缩算法中,而DEFLATE又应用于PNG图像格式、PKZIP、GZIP等压缩工具及zlib库中。
LZ算法尝试通过在读取的字符串中寻找当前单词的匹配来分词。与读取一组符号然后向后查找它是否重复出现不同,LZ算法向前查找当前单词是否出现过。这样做会对编码过程产生如下两个重要影响;
- 在数据流的前半部分,由于我们见过的单词很少,因此出现新单词的可能性很大;而在数据流的后半部分,由于已经有了很大的缓冲区,因此出现匹配的可能性更大。
- 向前寻找匹配可以让我们找出“最长的匹配词”。
搜索缓存区
LZ算法的工作原理是将数据流分成如下两部分。
- 数据流的左半部分通常被称为“搜索缓冲区”(search buffer),包含的是已经读过并处理过的符号。
- 数据流的右半部分则被称为“先行缓冲区”(look ahead buffer),包含的是将要编码的符号。
因此,当前“读取”的位置就位于两个缓冲区之间
找出匹配
对于先行缓冲区的短字符串,在搜索缓冲区从头开始寻找;
现在读下一个符号B,仍能找出匹配;继续读下一个符号E,还是能找出匹配,但当接着读下一个符号T时,就找不到匹配了,因此找出了这个符号序列的最长匹配;
滑动窗口
在实际实现时,数据流中可能有上百万个词条,我们不可能去搜索所有处理过的符号。因为如果不对搜索缓冲区的长度加以限制,就会遇到内存及性能方面的问题,所以搜索缓冲区通常只会包含32 KB已经处理过的字符。因此,当移动当前位置时,搜索缓冲区的“滑动窗口”(slidingwindow)也会跟着移动:
在找出匹配并编码后,将“当前位置”移到先行缓冲区中最长匹配单词之后,此时滑动窗口也会跟着移动到新的当前位置;
有了滑动窗口,查找匹配的性能要求也就有了上限。它同样也考虑到了局部性原理,即在给定的数据集中相关的数据很可能分布在相似的局部区域;
一般来说,搜索缓冲区滑动窗口的长度大概为几万个字节,而先行缓冲区的长度则只有几十个字节。
(偏移量,长度)作为标记
当匹配最终确定下来,编码器就会生成一个固定长度的记号并将它写入输出流。该记号主要由两部分组成:偏移量和长度;
偏移量
:该值表示的是搜索缓冲区中匹配单词的起始位置,从当前位置向前数;
长度值
:该值表示的是匹配单词的长度。在本例中,匹配单词的长度为4(即包含4个符号)。具体到本例中,找到的匹配位于当前位置9个符号前,且其长度为4,因此将二元组[9,4] 写入输出流;
解码器会以非常简单的方法逆转换这些值:
(1) 读取下一个词条;
(2) 以当前位置为起点,在搜索缓冲区中往前数偏移量个符号;
(3) 抓取长度值个符号并添加到数据流后面。
没有找到匹配时
在一些情况下,无法在搜索缓冲区中找到先行缓冲区中出现符号的匹配。此时,需要输出一些信息来表示这个新出现的单词,这样解码器才能正确地还原它。因此,需要对输出的记号进行修改,表明输出的是字面值,这样解码器就能读取并恢复源数据流。
不过,怎样构造该记号完全取决于具体的LZ算法实现。一种最基本的做法是,将修改后的记号表示为三部分,前两部分与前面介绍的相同,还是偏移量和长度值,只不过取值都为0,即[0,0],最后一部分则是符号的字面值,如图7-12所示。
走一遍整个流程,以“TOBEORNOTTOBE”为例:
解码过程:
整个解码过程全靠读取输入的记号:
- 当解码器读到字面值记号时,就将该值输出到恢复缓冲区;
- 当解码器读到“匹配”的记号时,会从当前的位置往前数偏移量个符号,以此为起点将长度值个符号添加到恢复缓冲区的结尾。具体过程如下表所示。
然而还不止如此,LZ算法真正吸引人的地方还在于它可以和统计编码结合使用。可以将记号中的偏移量、长度值以及字面值分开后,再按照类型合并,组成单独的偏移量集、长度值集和字面值集,然后再对这些数据集进行统计压缩。
例如,可以将前面例子输出的记号集[0,0,T][0,0,O][0,0,B][0,0,E][3,1][0,0,R][0,0,N][3,1][8,1][9,4] 分成以下3个数据集。偏移量集 0,0,0,0,3,0,0,3,8,9长度值集 0,0,0,0,1,0,0,1,1,4字面值集 T,O,B,E,R,N这三个数据集的性质不同,相应的处理方法也不同。
- 对于偏移量集,移量永远都是在[插图]这个范围之内,其中[插图]是搜索缓冲区的长度值。在最坏的情况下,偏移量也可以用[插图]位来编码,这就允许我们对滑动窗口内的任意字节进行索引;
- 对于长度值,利用重复符号通过统计编码算法来进一步压缩数据。长度值的分布取决于输入流的内容以及语言的类型;
- 对于字面值集:与偏移量集和长度值相比,字面值集好像也没有什么更好的压缩方法。不过,随着输入流增大,由于可能会存在重复的字面值(这是因为有滑动窗口),因此字面值流的熵也会略微变小。当然,这种情况是否会发生还取决于搜索缓冲区的大小;
LZ算法的变体:
①LZ77基本算法(有时也被称为LZ1算法)的工作原理,与前面介绍的大致相同。唯一的区别是,它会将先行缓冲区中下一个符号的字面值作为第三个值输出???
②LZSS:在LZ77算法中,字典引用可能会比其替换的字符串还长;而在LZSS中,如果被替换的字符串长度值小于“收支平衡”点,那么这样的引用就会被忽略。此外,LZSS还会用一个标志位来区分后面的数据是字面值还是偏移量–长度值二元组这样的引用。很多流行的压缩工具比如PKZip、ARJ、RAR、ZOO和LHarc使用LZSS算法,并将其作为主要的压缩算法;
③LZ78:LZ78算法的工作原理与前面描述的基本相同,不过它不用距搜索缓冲区结尾的偏移量来指示匹配的位置,而是根据输入流创建字典然后再引用。
④LZW算法:它采用了LZ78算法的思想,其工作原理如下:
(1) LZW算法将字典初始化为包含所有可能的输入字符,如果用到了清空和停止符号(clear and stopcodes),那么这两个符号也包括在其中;
(2) 该算法扫描输入字符串以寻找更长的连续子串,直到它发现该子串在字典中不存在
(3) 当发现这样的子串时,去掉它的最后一个符号(这样它就变成当前字典中最长的子串),然后从字典中找出其索引并输出
(4) 将该子串(此时包括最后一个符号)加入字典作为新的词条
(5) 将该子串的最后一个符号作为起点,重新扫描下一个子串。
用这种方法,连续更长的子串就会作为新的词条加入字典,同时也让后续字符串编码为单值输出成为可能。该算法最适用于那些连续出现重复的数据,因为在数据的初始部分,基本看不到什么压缩,但是随着数据的增多,压缩率逐渐趋于最大值。
LZW算法成为首个在计算机中广泛采用的通用数据压缩方法。公共领域程序“compress”也采用了LZW算法,并在1986年前后就基本成为UNIX系统的标准应用程序。此后,“compress”就从很多UNIX分发中消失了,一方面是因为它侵犯了LZW的专利权,另一方面是因为GZIP的压缩率更高(GZIP使用的是基于LZ77的DEFLATE算法);
其实,对数据越了解越熟悉,越能够选择出合适的LZ变换;
第八章 上下文数据转换
统计编码算法工作时会为每个符号分配一个长度可变的码字,压缩主要来自于为越频繁出现的符号分配越短的码字。而字典转换的分词过程会识别出数据集中最长且最频繁出现的那些符号。实际上,只有分词过程找出了那些最适合的符号,编码的效率才能提高。从技术上来说,我们完全可以通过分词过程识别出那些最适合编码的符号,然后再将所得结果交给统计编码算法处理以得到压缩的效果。然而,LZ算法的真正威力体现在我们没有那样去做,而是用熵值较低的记号二元组来表示匹配的信息,然后再去压缩这些记号二元组。
除了字典变换之外,还有一整套其他的变换都是按照同样的原理工作的:给定一组相邻的符号集,对它们进行某种方式的变换使其更容易压缩。我们通常称这样的变换为“上下文变换”(contextualtransform),因为在思考数据的理想编码方式时,这些方法考虑到了邻近符号的影响。
这些变换的目标是一致的,即通过对这些信息进行某种方式的变换,使统计编码算法对其进行压缩时更高效。
变换数据的方法有很多种,但其中有3种对现代的数据压缩来说最为重要,即行程编码(run-lengthencoding,RLE)、增量编码(delta coding)和伯罗斯–惠勒变换(Burrows-Wheelertransform,BWT)。
8.1 RLE 游程编码
RLE主要针对的是连续出现的相同符号聚类的现象,它会用包含符号值及其重复出现次数的元组,来替换某个符号一段连续的“行程”(run)。例如,如图8-1所示,字符串AAAABBBBBBBBCCCCCCCC就可以编码为[A,4][B,8][C,8];
然而,并非所有的数据都像前面举的例子那样规律一致。根据RLE的算法,字符串AAAABCCCC会被编码为[A,4][B,1][C,4]。由于字符串中间只有1个B,因此编码后B由一个字符变成字符及其长度值的二元组,反而变大了,如图8-2所示。
对这类符号,可能需要将它们单独留在数据流中。例如,可以只对那些行程长度大于2的符号编码;有了这样的假定,字符串AAAABCCCC就可以编码为[A,4]B[C,4]。因此,如果很多字符不是连续重复出现,就没必要使用计数器。这样处理带来的问题是,解码时很可能会出现歧义。如果将编码后的数据流转换为二进制,那么最终得到的结果为1000001|100|1000010|1000011|100(每个字面值都是7位),这样就无法分清字符B从哪里结束以及字符C从哪里开始。一般来说,数据流中交错出现字面值是会出问题的;
通常采用的方法是,在数据集中增加一个二进制位流,来表示某个给定的符号流中各个符号是否连续重复出现,因此,要在100000110010000101000011100之前加上二进制位流101,它表示第一个符号连续重复出现,第二个符号没有连续重复出现,第三个符号又连续重复出现。这样,通过为每个行程(符号)增加1个二进制位的标志位,就节省了存储短行程所需的额外开销。
RLE算法最适用于大多数符号都连续重复出现的数据集。如果要处理的数据集没有这样的性质,那么RLE算法并不适用;
常认为RLE是单字符上下文模型,也就是说,对任何给定的符号,在编码时我们都只考虑它的前一个符号,如果这两个符号是相同的,那么行程继续;如果不相同,那么当前行程终止。虽然RLE在现代压缩工具中用得并不多,但还是有人在研究更高效的RLE。例如,最近就出现了一种新的RLE压缩工具TurboRLE,号称是速度最快、效率最高的RLE编码器。
8.2 增量编码
从压缩角度来说,数值型数据算是最令人讨厌的数据类型之一,这是因为大多数时候,我们找不到可以利用的统计信息。
增量编码,其实就是将一组数据转换为各个相邻数据之间的相对差值(即增量)的过程。它背后的思想是,给定一组数据,相关的或相似的数据往往会集中在一起。如果这样,有了两个相邻值之间的差,就可以用其中一个值以及该差值来表示另外一个值。一般来说,我们会用当前值减去前一个值,然后将差值写入输出流。
增量编码可以说是现代计算技术中最重要的算法之一。在数值型数据这样普遍而其熵值又如此偏高的情况下,增量编码提供了一种不依靠统计的转换。事实上,它依靠的是相邻性,所以最适用于处理时间序列数据(比如每10秒检测一次温度的传感器所产生的数据),以及音频和图像数据这类多媒体数据,因为这类数据中邻近的数据之间存在着时间上的关联。
比如:[1,3,6,8,10]从第二个数开始,用当前数减去前一个数,得到了增量编码之后的数据:[1,3–1,6–3,8–6,10–8] → [1,2,3,2,2];
一般来说,增量编码的目的就是缩小数据集的变化范围。更确切地说,是为了减少表示数据集中的每个值所需要的二进制位数。这也就意味着,当相邻数值之间的差相对较小时,增量编码最有效。如果差值变大,情况就会变糟。
比如:[1,2,10,256]增量编码之后,得到了如下的结果:[1,2–1,10–2,256–10] → [1,1,8,246];
[1,3,10,8,6]增量编码后,你可能会想哭:[1,3–1,10–3,8–10,6–8] → [1,2,7,–2,–2];
需要更多的位数来存放更大的值(246),需要增加一位符号位来存储负数;
这样的情况很常见,也正好是增量编码的弱项。遇到这样的数据,增量编码的效率自然不会高。不过,也不用灰心,无论需要处理的是什么样的数据,我们还是有不少方法可以改进增量编码这一算法,让它变得更加健壮。
下面讲几种改进方法:
8.2.1 XOR增量编码
可以通过使用按位异或运算(bitwise exclusive OR,XOR)代替减法运算来解决这一问题;XOR完全绕开了负数出现的问题,因为整数之间的XOR根本不可能产生负数;
虽然这同样没能缩小数值的变化范围,因为存储每个值还是需要4个二进制位的空间,但它的确保证了无论数值之间的相互顺序是怎样的,编码后的每个值都是正的,效果一般;
8.2.2 参照系增量编码
参照系方法通过让其他数减去最小的数来解决上面这个问题,比如[107,108,110,115,120,125,132,132,131,135]中每个数都减去107 => [0,1,3,8,13,18,25,25,24,28] ,从每个数8个二进制位减少到每个数5个二进制位;
“参照系”(frame of reference,FOR)中那个“参照数”(frame)的选取,与将转换恰当地应用到数据集上有关,因此需要将数据集细分为更小的数据组。例如,可以将前面那组数拆分成以下两组数:[107,108,110,115,120] 和[125,132,132,131,135]使用参照系增量编码处理之后,得到:[107,0,1,3,8,13] 和[125,0,7,7,6,10]为数据集确定了参照数后,数值的变化范围缩小了,因此表示每个值所需要的二进制位数也变小了。
8.2.3 修正的参照系增量编码
继续考虑以下数据:[1,2,10,256]增量编码后,得到:[1,2–1,10–2,256–10] = [1,1,8,246]这组数据中的离群值246基本上破坏了对其余数据的压缩。
修正的参照系增量编码步骤:
选择合适的b值
一般是从1开始,看看数据集中有多少数小于21,高于90%就将b设为1,否则尝试b=2,看有多少数小于22;
怎样处理离群值
根据原始论文Super-ScalarRAM-CPU Cache Compression 的叙述,PFOR没有将整个的离群值原样扔进一个新的列表中,而是将最低的“b”位留在源数据流中,并将剩下的部分存储在离群值列表中;例如,在下面这组数中,除了一个数以外,其余数最高的三位是0:
1010010 0001010 0001100 0001011;
处理这组数时,不用将101,000,000,000这些值都存储在离群值列表中,而是一眼就能看出只有第一个值才是需要关注的离群值。结果是得到了如下的三组新数据:
第一组就是原始数据的最低4位,即[0010,1010,1100,1011];紧接着第二组则是离群值所在的位置,即[0];第三组则是这些位置上离群值的实际异常位,即[101];
8.2.4 压缩增量编码后的数据
注意,到目前为止实际上还没有进行任何压缩,只是对数据进行了某种方式的转换,使它变得更容易压缩。如果增量编码能做到以下两点,那么我们就可以认为它生成的数据更容易压缩:
- 将数据集中的最大值变小,因此缩小了数值的变化范围;
- 生成了许多重复值,可以让统计压缩的效率更高。
一般来说,将增量编码后的结果交给统计编码算法处理,会产生良好的压缩效果。
对于文本数据,可能不会那么有效。我的意思是,它可以工作,但是由于英语文本中使用最多的是还是字母表中两头的字母,因此得到的数据中会出现很多正负数交替的情况。另外,对于英语文本,像LZ这样的算法可能会做得更好。
8.3 MTF前移编码
上下文数据转换背后的基本思想是,数据的排列次序中包含着一些有助于编码未来符号的信息,前移编码(move-to-front coding,MTF)利用的也是这样的信息。然而,与RLE和字典编码器只考虑直接相邻的符号不同,MTF考虑更多的是在较短的窗口内某个特定符号的出现次数。
MTF反映了如下的预期:如果一个符号在输入流中出现了,那么它很有可能会出现多次,或者至少短时间内会成为常见的符号。MTF是局部自适应的,因为它会根据输入流中局部区域符号的出现频次进行调整。如果输入流满足了这样的预期,换句话说,如果输入流中出现了相同符号集中的情况(即输入流中符号的局部频次会出现显著的变化),那么MTF会产生好的结果。
跟之前的排列有点像,当从数据流中读取一个值时,我们会找出该值在SortedArray中的索引并将此索引值输出,然后更新SortedArray,将该值移到最前面,即让其索引变为0;
为了简单起见,假定数据中的符号都是小写的ASCII字符,输入字符串为“banana”,初始状态下SortedArray中的字符是按字母顺序排列的。
(1) 读取字母“b”
(2)“b”在SortedArray中的索引为1,因此将1写入输出流
(3) 将“b”移动到SortedArray的最前面
(4) 读取字母“a”,由于其现在的索引为1,因此输出1,同时将“a”重新移到最前面
(5) 对其余的字母,按照顺序重复上面的过程,具体如下表所示。
最终的输出结果为1,1,14,1,1,1,其信息熵为0.65,与源数据的信息熵1.46相比小很多。解码器按照相反的步骤操作就能恢复源数据流。恢复时,输入流为[1,1,14,1,1,1],初始状态的SortedArray为[a,b,…,z],解码器每从输入流中读取一个符号,就将SortedArray中该索引对应的字符输出,再将该字符移到SortedArray的最前面。
捣乱符:MTF存在的一个问题是,有一些捣乱的符号会打乱前面存在的符号流。这个问题比较严重,因为会极大地破坏编码,而且在真实数据中普遍存在。
向前移动k个位置
一种解决方法是,不是一读到某个符号就将它移到最前面,而是采取一些探索式方法慢慢地将它移到最前面;就是与当前符号匹配的元素不直接移到SortedArray的最前面,而是将它前移k个位置;k有两种选取方案,①令k=n(n为符号个数),原始的MTF就这样;②令k=1,即每个符号被读取一次,它的位置就前移一位;
将k取为1时,可能会对降低那些具有较好局部相关性的输入流性能,但是能更好地处理其他类型的输入流。而且,k=1时算法实现起来也很简单,因为在更新SortedArray时只需要将当前读取的符号与前一个符号交换位置。这样设定之后,也能更好地处理那些捣乱符号,因为这些符号现在需要慢慢地移动到最前面,而不是一下子就移动到最前面。
出现C次再移动
采用这种方法时,SortedArray中的元素只有在输入流中出现过C次之后(并非必须连续出现),才会移动到最前面。SortedArray中的每个元素都有一个计数器,记录该元素出现的次数。这样我们就可以为符号移动到最前面设定一个出现次数的阈值。当应用到文本上时,我们就可以通过最终生成的SortedArray,来反映所编码的语言各个字母的使用频率。
MTF生成的输出流的熵通常要比源数据流小,这让它的输出成为传递给统计压缩器进行进一步压缩的首选对象。由于MTF生成的输出流中有很多的0和1,因此简单的统计编码算法就可以工作得很好。
MTF的独特之处在于,符号在短时间内重复出现时,MTF会重新分配一个较小的值。而RLE会将最短的编码分配给那些连续重复出现的符号。实际上,MTF是最简单的动态统计转换形式之一。
8.4 BWT
有规则就有例外,BWT就属于例外。从前面的介绍中可以看出,所有其他的压缩算法通常可以归为两类:统计压缩(即VLC)和字典压缩(如LZ78),这两类算法从不同的角度利用了给定数据流中存在的统计冗余信息。
熵作为度量单位,它的一个问题是没有考虑符号之间的顺序。不管如何打乱符号集[1234567890] 中符号的顺序,它的熵始终是4。但是我们知道,事实上符号之间的顺序很重要
而纯粹的排序是单方向的,也就是说,在对数据排序后,如果没有更多额外的信息指明它是如何变化的,我们无法让数据重新回到未排序的状态。
BWT会打乱数据流中符号的顺序,并试图让相同的符号簇彼此靠近,这一行为通常称为字典序排列(lexicographical permutation)。或者更确切地说,通过BWT,我们可以找出原始数据集的一种排列,根据其顺序,该排列可能更容易压缩。
其中最值得关注的一点是:通过BWT,在编码与解码时无须增加太多的额外信息
工作原理
假定输入流是BANANA,首先循环把最右边的字符串提到首位,每次移位都记录下来,直到对所有字符都操作了一遍;
BANANA
ABANAN
NABANA
ANABAN
NANABA
ANANAB
BANANA
接下来,BWT会按字典顺序对表中的字符串进行排序,即先比较每个字符串的首字母,字母顺序小的字符串放前面,相同的则比较第二个字母,依次这么下去;
ABANAN
ANABAN
ANANAB
BANANA
NABANA
NANABA
然后,每个字符串的末尾字母组合在一起就成了字符串NNBAAA
,这是BANANA
的一种排列,且能够更好地聚合相同字符;其次,观察排序后的表格,你会发现输入字符串的索引为3;
在BWT的解码阶段,我们需要该索引值,因为它将使我们从更易压缩的排列回到源字符串上;
BWT的逆操作
BWT最引人注目的特点并不在于它能生成更易压缩的输出(普通排序也能做到这一点),而在于只需要极小的数据开销,它所进行的变换操作就是可逆的(reversible);
验证它的正确性,所给的条件是字符串NNBAAA和行索引3;
首先需要做的是重新生成排列表格
第一步将输出字符串写入下表中,它表示的是每一行的最后一个字符。
如果对这一列排序,其结果与原来的排序后表格的第一列相同,如下表所示:
接下来将这两列合并,这样每行就都有两个字符了:
NA
NA
BA
AB
AN
AN
按字典顺序对每行排序,结果如下:
AB
AN
AN
BA
NA
NA
接着,将原始的输出字符串(NNBAAA)按顺序添加每行字符串的最前面(每行添加1个字符),所得结果如下所示:
NAB
NAN
BAN
ABA
ANA
ANA
然后,再次对每行字符串按字典顺序按列排序,继续将原始字符串按顺序添加到每行字符串的最前面并按列排序,直到整个输出矩阵的宽度等于输出字符串的长度,具体过程如下表所示:
观察最后输出的矩阵,你很快就能发现如下两个神奇的性质。
- 最后的矩阵与在编码器中生成的排序后的置换矩阵完全相同。这意味着,即使只给出排序后矩阵的最后一列,NNBAAA,我们也能利用它来恢复生成其整个排序后的矩阵;
- 从矩阵中取出索引号为 3的行(注意索引是从 0开始的,因此索引 3对应的是第四行),就能恢复源输入字符串 BANANA;
虽然拥有如此多的优点,但遗憾的是,不能在50GB大小的文件上进行BWT,因为这样的话整个符号矩阵需要的空间太大;
因此,我们通常将BWT称为块排序变换,具体实现时,它会将整个文件分为许多1 MB大小的数据块,然后在每个数据块上分别应用该算法。这样一来,大多数现代设备就能满足该算法对内存的要求,同时该算法也能获得较好的性能。
压缩BWT后的数据
显然,BWT本身不压缩数据,它只是转换数据。为了让BWT真正起作用,还需要应用其他的转换来生成熵值更小的数据流,然后再对其压缩。
最常见的算法是将BWT的输出作为MTF的输入,经过处理后接着用统计编码算法处理。这基本上就是BZIP2的内部工作原理。
1.为什么使用MTF而不是RLE呢?
这里需要记住的是RLE对干扰符号十分敏感,而BWT不能生成足够长的连续行程以确保RLE始终处于理想状态。相反,MTF则对干扰符号这类问题不敏感。
2.为什么不使用LZ算法呢?
下面来看一个简单的例子。需要记住的是,当字符串中出现较长的重复子串时,LZ算法才工作得最好。
LZ算法在字符串TOBEORNOTTOBEORTOBEORNOT上工作得很好,因为TOBEORNOT是其中最长的重复子串,但是,LZ算法在其他类似的字符串上工作得并不好。
假定经过变化后得到的结果为“OBTTTTTTOOEER”。
(1) LZ算法看到第一个T时会将其编码为字面值。
(2) 第二个T会被编码为前一个字符的匹配,其长度为1。
(3)遇到第三个T时会向前看一个符号,并将TT这组符号当成前两个字符的匹配,其长度为2。结果是,6个字符“T”会被编码为字面值T以及< –1,1 > 、< –2,2 > 、< –2,2 > 这3个标记。
之后,如果再遇到一串很长的“T”值,我们可以期望出现一段较长的匹配。然而不幸的是,由于BWT使这些符号出现得这么分散,因此匹配的距离值可能会比较大,而这会影响我们对数据流的编码。
读书笔记——数据压缩入门(柯尔特·麦克安利斯)中相关推荐
- 读书笔记——数据压缩入门(柯尔特·麦克安利斯)下
文章目录 数据压缩入门汇总 第九章 数据建模 9.1 马尔科夫链 9.2 部分匹配预测算法PPM 9.2.1 单词查找树 9.2.2 字符的压缩 9.2.3 选择一个合理的N值 9.2.4 处理未知的 ...
- 读书笔记——数据压缩入门(柯尔特·麦克安利斯)上
文章目录 数据压缩入门汇总 前言 第一章 概述 1.1 克劳德 • 香农 1.2 数据压缩必备知识 第二章 深入研究信息论 第三章 突破熵 3.1 理解熵 3.2 熵的用处 3.3 理解概率 3.4 ...
- 《终结拖延症》读书笔记,作者威廉·克瑙斯
序 引言 所有的行为背后都有其目的. 那我拖延背后的目的是什么呢?畏难情绪?缺乏完成任务的相关知识和技巧?逃避旷日持久的人任务? 三个方法克服拖延 认知方法 改变错误的思维 情绪方法 发展对不舒适的忍 ...
- 克里斯·麦克切斯尼《高效能人士的执行4原则》读书笔记
克里斯·麦克切斯尼:肖恩·柯维:吉姆·霍林 原则1:聚焦最重要目标 要事第一,全神贯注 原则2:关注引领性指标 分解目标,落实行动 原则3:坚持激励性记分表 记分衡量,一目了然 原则4:建立规律问责制 ...
- 读书笔记 -- 算法入门
14天阅读挑战赛 努力是为了不平庸~ 算法学习有些时候是枯燥的,这一次,让我们先人一步,趣学算法!欢迎记录下你的那些努力时刻(算法学习知识点/算法题解/遇到的算法bug/等等),在分享的同时加深对于算 ...
- 【读书笔记】商业自传-耐克科技,鞋狗:耐克创始人菲尔.奈特亲笔自传_2020.06.01
[概述] 书名:鞋狗:耐克创始人菲尔.奈特亲笔自传 作者:美.菲尔.奈特 日期:2020年6月01日 读书用时:960页,15小时. [读书笔记] 关于读书,从来都是三分钟热度,兴致来了,可以每天下班 ...
- 【《WebGL编程指南》读书笔记-WebGL入门】
<WebGL编程指南>读书笔记 目录链接:https://blog.csdn.net/floating_heart/article/details/124001572 第二章 WebGL入 ...
- MDX Step by Step 读书笔记 - 个人专题(一) 如何理解 MDX 查询中WHERE 条件如何对应Cube 中的切片轴 Slicer Axis...
这篇文章原本应该写在第四章的读书笔记里, 但是篇幅太长,而且主要示例和图解都是基于我自己的理解, 所以单独成文(可以先看看第四章读书笔记内容). 这一部分基础内容我个人觉得非常重要, 之前看过一次 M ...
- 【读书笔记】《肖申克的救赎》——小说比电影更有蕴含自由的力量
安迪在一九四八年到肖申克时是三十岁,他属于五短身材,长得白白净净,一头棕发,双手小而灵巧.他戴了一副金边眼镜,指甲永远剪得整整齐齐.干干净净,我最记得的也是 那双手,一个男人给人这种印象还满滑稽的,但 ...
最新文章
- java双目运算符重载,c++类的单目和双目运算符的重定义
- 如何用c语言ics文件,大一下学ics,书里在linux上用C编程,刚安系统老师就留了几个作业...
- linux g命令,【Linux】常用命令大全
- Android中使用软引用和弱引用避免OOM的方法
- python 学习第四十七天shelve模块
- 吴恩达深度学习 —— 3.1 神经网络概览
- Hadoop集成环境搭建
- 30 秒?!Chrome 插件带你速成编程学习 | 程序员硬核评测
- String 类 的 使用
- java thread already started_自定义类加载器
- CocosCreator中TiledMap简单使用
- 微信小程序人脸识别功能(wx.faceDetect)、带扫脸动画、人脸图片获取(upng.js)及位置展示
- LibreOJ - 10015 扩散
- 各地发布防病提示,秋冬不注意腹泻来敲门
- 密码学之PRP/PRF转换引理
- 【计算方法笔记】四阶Runge-Kutta法
- LintCode python入门题
- TLE星历以及轨道计算方法
- word里公式后面标号怎么对齐_如何使Word中公式与文字对齐
- rhel 5.8 安装iotop CONFIG_TASK_DELAY_ACCT not enabled in kernel, cannot determine