zip的压缩原理与实现(二)(转)

来源:互联网 发布:招商局金融科技 知乎 编辑:程序博客网 时间:2024/05/21 00:14
2007-01-18 19:52
转自:http://hi.baidu.com/qtlinux/blog/item/ec7ee7dc715d21a1cd1166c5.html
4) 重复 2、3 两步直到压缩文件处理完毕。

从 lzw 的压缩过程,我们可以归纳出它不同于 lz77 算法的一些主要特点:
1) 对于一段短语,它只输出一个数字,即字典中的序号。(这个数字的位数决定了字典的最大容量,当它的位数取得太大时,比如 24 位以上,对于短匹配占多数的情况,压缩率可能很低。取得太小时,比如 8 位,字典的容量受到限制。所以同样需要取舍。)
2) 对于一个短语,比如 abcd ,当它在待压缩文件中第一次出现时,ab 被加入字典,第二次出现时,abc 被加入字典,第三次出现时,abcd 才会被加入字典,对于一些长匹配,它必须高频率地出现,并且字典有较大的容量,才会被最终完整地加入字典。相应地,lz77 只要匹配在“字典区域”中存在,马上就可以直接使用。
3) 设 lzw 的“字典序号”取 n 位,它的最大长度可以达到 2 的 n 次方;设 lz77 的“匹配长度”取 n 位,“匹配距离”取 d 位,它的最大长度也是 2 的 n 次方,但还要多输出 d 位(d 至少不小于 n),从理论上说 lzw 每输出一个匹配只要 n 位,不管是长匹配还是短匹配,压缩率要比 lz77 高至少一倍,但实际上,lzw 的字典中的匹配长度的增长由于各匹配互相打断,很难达到最大值。而且虽然 lz77 每一个匹配都要多输出 d 位,但 lzw 每一个匹配都要从单字节开始增长起,对于种类繁多的匹配,lzw 居于劣势。
可以看出,在多数情况下,lz77 拥有更高的压缩率,而在待压缩文件中占绝大多数的是些简单的匹配时,lzw 更具优势,GIF 就是采用了 lzw 算法来压缩背景单一、图形简单的图片。zip 是用来压缩通用文件的,这就是它采用对大多数文件有更高压缩率的 lz77 算法的原因。

接下来 zip 算法将要解决在“字典区域”中如何高速查找最长匹配的问题。

(注:以下关于技术细节的描述是以 gzip 的公开源代码为基础的,如果需要完整的代码,可以在 gzip 的官方网站 www.gzip.org 下载。下面提到的每一个问题,都首先介绍最直观简单的解决方法,然后指出这种方法的弊端所在,最后介绍 gzip 采用的做法,这样也许能使读者对 gzip 看似复杂、不直观的做法的意义有更好的理解。)
最直观的搜索方式是顺序搜索:以待压缩部分的第一个字节与窗口中的每一个字节依次比较,当找到一个相等的字节时,再比较后续的字节…… 遍历了窗口后得出最长匹配。gzip 用的是被称作“哈希表”的方法来实现较高效的搜索。“哈希(hash)”是分散的意思,把待搜索的数据按照字节值分散到一个个“桶”中,搜索时再根据字节值到相应的“桶”中去寻找。短语式压缩的最短匹配为 3 个字节,gzip 以 3 个字节的值作为哈希表的索引,但 3 个字节共有 2 的 24 次方种取值,需要 16M 个桶,桶里存放的是窗口中的位置值,窗口的大小为 32K,所以每个桶至少要有大于两个字节的空间,哈希表将大于 32M,作为 90 年代开发的程序,这个要求是太大了,而且随着窗口的移动,哈希表里的数据会不断过时,维护这么大的表,会降低程序的效率,gzip 定义哈希表为 2 的 15 次方(32K)个桶,并设计了一个哈希函数把 16M 种取值对应到 32K 个桶中,不同的值被对应到相同的桶中是不可避免的,哈希函数的任务是 1.使各种取值尽可能均匀地分布到各个桶中,避免许多不同的值集中到某些桶中,而另一些是空桶,使搜索的效率降低。2.函数的计算尽可能地简单,因为每次 “插入”和“搜寻”哈希表都要执行哈希函数,哈希函数的复杂度直接影响程序的执行效率,容易想到的哈希函数是取 3 个字节的左边(或右边)15 位二进制值,但这样只要左边(或右边)2 个字节相同,就会被放到同一个桶中,而 2 个字节相同的概率是比较高的,不符合“平均分布”的要求。gzip 采用的算法是:A(4,5) + A(6,7,8) ^ B(1,2,3) + B(4,5) + B(6,7,8) ^ C(1,2,3) + C(4,5,6,7,8) (说明:A 指 3 个字节中的第 1 个字节,B 指第 2 个字节,C 指第 3 个字节,A(4,5) 指第一个字节的第 4,5 位二进制码,“^”是二进制位的异或操作,“+”是“连接”而不是“加”,“^”优先于“+”)这样使 3 个字节都尽量“参与”到最后的结果中来,而且每个结果值 h 都等于 ((前1个h << 5) ^ c)取右 15 位,计算也还简单。
哈希表的具体实现也值得探讨,因为无法预先知道每一个“桶”会存放多少个元素,所以最简单的,会想到用链表来实现:哈希表里存放着每个桶的第一个元素,每个元素除了存放着自身的值,还存放着一个指针,指向同一个桶中的下一个元素,可以顺着指针链来遍历该桶中的每一个元素,插入元素时,先用哈希函数算出该放到第几个桶中,再把它挂到相应链表的最后。这个方案的缺点是频繁地申请和释放内存会降低运行速度;内存指针的存放占据了额外的内存开销。有更少内存开销和更快速的方法来实现哈希表,并且不需要频繁的内存申请和释放:gzip 在内存中申请了两个数组,一个叫 head[],一个叫 pre[],大小都为 32K,根据当前位置 strstart 开始的 3 个字节,用哈希函数计算出在 head[] 中的位置 ins_h,然后把 head[ins_h] 中的值记入 pre[strstart],再把当前位置 strstart 记入 head[ins_h]。随着压缩的进行,head[]里记载着最近的可能的匹配的位置(如果有匹配的话,head[ins_h]不为 0),pre[]中的所有位置与原始数据的位置相对应,但每一个位置保存的值是前一个最近的可能的匹配的位置。(“可能的匹配”是指哈希函数计算出的 ins_h 相同。)顺着 pre[] 中的指示找下去,直到遇到 0,可以得到所有匹配在原始数据中的位置,0 表示不再有更远的匹配。
接下来很自然地要观察 gzip 具体是如何判断哈希表中数据的过时,如何清理哈希表的,因为 pre[] 里只能存放 32K 个元素,所以这项工作是必须要做的。
gzip 从原始文件中读出两个窗口大小的内容(共 64K 字节)到一块内存中,这块内存也是一个数组,称作 Window[];申请 head[]、pre[] 并清零;strstart 置为 0。然后 gzip 边搜索边插入,搜索时通过计算 ins_h,检查 head[] 中是否有匹配,如果有匹配,判断 strstart 减 head[] 中的位置是否大于 1 个窗口的大小,如果大于 1 个窗口的大小,就不到 pre[] 中去搜索了,因为 pre[] 中保存的位置更远了,如果不大于,就顺着 pre[] 的指示到 Window[] 中逐个匹配位置开始,逐个字节与当前位置的数据比较,以找出最长匹配,pre[] 中的位置也要判断是否超出一个窗口,如遇到超出一个窗口的位置或者 0 就不再找下去,找不到匹配就输出当前位置的单个字节到另外的内存(输出方法在后文中会介绍),并把 strstart 插入哈希表,strstart 递增,如果找到了匹配,就输出匹配位置和匹配长度这两个数字到另外的内存中,并把 strstart 开始的,直到 strstart + 匹配长度 为止的所有位置都插入哈希表,strstart += 匹配长度。插入哈希表的方法为:
pre[strstart % 32K] = head[ins_h];
head[ins_h] = strstart;
可以看出,pre[] 是循环利用的,所有的位置都在一个窗口以内,但每一个位置保存的值不一定是一个窗口以内的。在搜索时,head[] 和 pre[] 中的位置值对应到 pre[] 时也要 % 32K。当 Window[] 中的原始数据将要处理完毕时,要把 Window[] 中后一窗的数据复制到前一窗,再读取 32K 字节的数据到后一窗,strstart -= 32K,遍历 head[],值小于等于 32K 的,置为 0,大于 32K 的,-= 32K;pre[] 同 head[] 一样处理。然后同前面一样处理新一窗的数据。
分析:现在可以看到,虽然 3 个字节有 16M 种取值,但实际上一个窗口只有 32K 个取值需要插入哈希表,由于短语式重复的存在,实际只有 < 32K 种取值插入哈希表的 32K 个“桶”中,而且哈希函数又符合“平均分布”的要求,所以哈希表中实际存在的“冲突”一般不会多,对搜索效率的影响不大。可以预计,在“一般情况”下,每个“桶”中存放的数据,正是我们要找的。哈希表在各种搜索算法中,实现相对的比较简单,容易理解,“平均搜索速度”最快,哈希函数的设计是搜索速度的关键,只要符合“平均分布”和“计算简单”,就常常能成为诸种搜索算法中的首选,所以哈希表是最流行的一种搜索算法。但在某些特殊情况下,它也有缺点,比如:1.当键码 k 不存在时,要求找出小于 k 的最大键码或大于 k 的最小键码,哈希表无法有效率地满足这种要求。2.哈希表的“平均搜索速度”是建立在概率论的基础上的,因为事先不能预知待搜索的数据集合,我们只能“信赖”搜索速度的“平均值”,而不能“保证”搜索速度的“上限”。在同人类性命攸关的应用中(如医疗或宇航领域),将是不合适的。这些情况及其他一些特殊情况下,我们必须求助其他“平均速度”较低,但能满足相应的特殊要求的算法。(见《计算机程序设计艺术》第3卷排序与查找)。幸而“在窗口中搜索匹配字节串”不属于特殊情况。

时间与压缩率的平衡:
gzip 定义了几种可供选择的 level,越低的 level 压缩时间越快但压缩率越低,越高的 level 压缩时间越慢但压缩率越高。
不同的 level 对下面四个变量有不同的取值:

nice_length
max_chain
max_lazy
good_length

nice_length:前面说过,搜索匹配时,顺着 pre[] 的指示到 Window[] 中逐个匹配位置开始,找出最长匹配,但在这过程中,如果遇到一个匹配的长度达到或超过 nice_length,就不再试图寻找更长的匹配。最低的 level 定义 nice_length 为 8,最高的 level 定义 nice_length 为 258(即一个字节能表示的最大短语匹配长度 3 + 255)。

max_chain:这个值规定了顺着 pre[] 的指示往前回溯的最大次数。最低的 level 定义 max_chain 为 4,最高的 level 定义 max_chain 为 4096。当 max_chain 和 nice_length 有冲突时,以先达到的为准。

max_lazy:这里有一个懒惰匹配(lazy match)的概念,在输出当前位置(strstart)的匹配之前,gzip 会去找下一个位置(strstart + 1)的匹配,如果下一个匹配的长度比当前匹配的长度更长,gzip 就放弃当前匹配,只输出当前位置处的首个字节,然后再查找 strstart + 2 处的匹配,这样的方式一直往后找,如果后一个匹配比前一个匹配更长,就只输出前一个匹配的首字节,直到遇到前一个匹配长于后一个匹配,才输出前一个匹配。
gzip 作者的思路是,如果后一个匹配比前一个匹配更长,就牺牲前一个匹配的首字节来换取后面的大于等于1的额外的匹配长度。
max_lazy 规定了,如果匹配的长度达到或超过了这个值,就直接输出,不再管后一个匹配是否更长。最低的4级 level 不做懒惰匹配,第5级 level 定义 max_lazy 为 4,最高的 level 定义 max_lazy 为 258。

good_length:这个值也和懒惰匹配有关,如果前一个匹配长度达到或超过 good_length,那在寻找当前的懒惰匹配时,回溯的最大次数减小到 max_chain 的 1/4,以减少当前的懒惰匹配花费的时间。第5级 level 定义 good_length 为 4(这一级等于忽略了 good_length),最高的 level 定义 good_length 为 32。

分析:懒惰匹配有必要吗?可以改进吗?
gzip 的作者是无损压缩方面的专家,但是世界上没有绝对的权威,吾爱吾师,更爱真理。我觉得 gzip 的作者对懒惰匹配的考虑确实不够周详。只要是进行了认真客观的分析,谁都有权利提出自己的观点。
采用懒惰匹配,需要对原始文件的更多的位置查找匹配,时间肯定增加了许多倍,但压缩率的提高在总体上十分有限。在几种情况下,它反而增长了短语压缩的结果,所以如果一定要用懒惰匹配,也应该改进一下算法,下面是具体的分析。
1. 连续3次以上找到了更长的匹配,就不应该单个输出前面的那些字节,而应该作为匹配输出。
2. 于是,如果连续找到更长的匹配的次数大于第一个匹配的长度,对于第一个匹配,相当于没有做懒惰匹配。
3. 如果小于第一个匹配的长度但大于2,就没有必要作懒惰匹配,因为输出的总是两个匹配。
4. 所以找到一个匹配后,最多只需要向后做 2 次懒惰匹配,就可以决定是输出第一个匹配,还是输出1(或 2)个首字节加后面的匹配了。
5. 于是,对于一段原始字节串,如果不做懒惰匹配时输出两个匹配(对于每个匹配,距离占15位二进制数,长度占8位二进制数,加起来约占3字节,输出两个匹配约需要6字节),做了懒惰匹配如果有改进的话,将是输出1或2个单字节加上1个匹配(也就是约4或5字节)。这样,懒惰匹配可以使某些短语压缩的结果再缩短1/3到1/6。
6. 再观察这样一个例子:
1232345145678[当前位置]12345678
不用懒惰匹配,约输出6字节,用懒惰匹配,约输出7字节,由于使用了懒惰匹配,把更后面的一个匹配拆成了两个匹配。(如果 678 正好能归入再后面的一个匹配,那懒惰匹配可能是有益的。)
7. 综合考虑各种因素(匹配数和未匹配的单双字节在原始文件中所占的比例,后一个匹配长度大于前一个匹配长度的概率,等等),经过改进的懒惰匹配算法,对总的压缩率即使有贡献,也仍是很小的,而且也仍然很有可能会降低压缩率。再考虑到时间的确定的明显的增加与压缩率的不确定的微弱的增益,也许最好的改进是果断地放弃懒惰匹配。

gzip 在完成短语式压缩后,将转入编码式压缩的阶段。这个阶段的实现是很复杂的,对最终的压缩率至关重要,我会详细解说 gzip 的做法。gzip 是开放源代码的无损压缩程序中最著名的,其中的种种技巧很有启发意义,但是他是比较早期的程序,现在有很多的程序已经在压缩率上超过了它,所以我会根据自己对无损压缩的基本规律的理解提出对它的改进。

编码式压缩的几点考虑:
1. huffman 算法压缩率的关键是各节点值的差异要大,这样就要求分段编码输出。因为某些段落中某些节点的出现频率较高,另一些段落中这些节点出现频率较低,如果不分段输出,频率的差异会被彼此抵消,而不同段落中,节点的出现频率不同是常有的。
要决定分段的大小,必须解决一对矛盾:上面的分析似乎要求段落越小越好,但由于要保存码表以对 huffman 压缩结果进行解压,每个段落都要保存一份不同的码表,所以段落取得太小,保存了码表后得不偿失,这样,似乎又要求段落要尽量大,使码表的保存份数尽量少。
gzip 采取了这样的策略来确定段落的大小:lz77 压缩每产生 4k(小)的数据,就判断现在对未编码部分进行编码输出是否合适,最多积压到 32k(大)的时候,必定进行强制输出,因为平庸的数据积压得太多,后面即使有好的数据,频率统计在一起,也会被平庸化。
判断当前输出合适与否的条件是:1)用预先设定好的各节点长度和各节点实际的出现次数,计算压缩结果的大概值,看这个值是否小于未压缩时的 1/2。2)看目前为止的匹配数是否小于未匹配的字节数,因为 lz77 压缩产生的数据包括“匹配”和“未匹配的原始字节”,段落间的节点频率差异主要体现在“未匹配的原始字节”中。
上面的判断只是一种“猜测”,真正的精确的计算需要花费更多的时间。
我觉得 gzip 的策略可以改进,我的策略是:1)输出的时机是压缩率的关键之一,现在计算机的速度和九十年代时已经今非昔比,现在完全有条件采用真正的建 huffman 树的方法得到各节点的码长,作精确的判断。2)不应该与未压缩的原始数据比较,而应该与 lz77 输出的数据比较,否则计算出的压缩比很大一部分是短语式压缩的功劳。3)由于采用了真正的建 huffman 树的方法,不用再去做匹配数与未匹配的字节数的比较,因为那只是一种猜测。4)每 4k 的数据都单独统计频率,如果是合适的,就先输出之前的积压(如果有的话),再输出当前的 4k,这样可以避免当前的数据被积压的数据平庸化。如果不合适,就把当前的频率归入到积压的数据(如果有)的频率中,再判断是否合适,如仍不合适就暂缓输出,否则一起输出,这和 gzip 的作法是一样的。说明:几段差的数据积压到一起仍有可能成为好的数据,比如 01、 02、……积压在一起,0 的频率逐渐高出了其他字节。5)如果愿意付出更多的时间,在把当前的频率归入之前的频率时,可以先和之前 4k 的频率合并,如果不合适,和之前 8k 的频率合并,这样逐渐往前合并 4k,避免前面不好的数据拖累合并后的好的数据。6)有了前面的机制,32k 的强制输出点可以取消。7)进一步的改进:当要输出时,只输出积压的不好的部分,好的数据先留着,等后面的 4k,如果新的加入后,仍是好的数据,就再等,如果会降低压缩率,才输出好的部分。这样,让好的数据大段的输出,可以减少码表的保存份数。8)再进一步的改进:坏的数据放在一起可能会提高压缩率,好的数据放在一起也可能更好,当然,两种情况也都有可能降低压缩率,所以前面判断“好”还是“不好”,“合适” 还是“不合适”的标准应该从某一个固定的压缩率标准改变为:提高了压缩率还是降低了压缩率。(提高的幅度应该至少抵消多保存一份码表的损失;降低的幅度也应该至少抵消少保存一份码表的得益)9)综合前面的分析,确定分段大小的策略最终调整为:当新的数据和前面的未切分数据放在一起时,两者中任何一方受到损失,都应该设置切分点,积累了两个分段后,通过计算,当切分带来的收益大于少保存一份码表时,才输出前一段,否则取消它们之间的切分点。这个策略实际上可以涵盖前面提到的所有改进,因为每个实际的分段之中的数据或者相互促进,或者彼此稍有妨害,但好过多保存一份码表;而每两个相邻的分段之间的数据彼此妨害,抵消了少保存一份码表的收益。这个策略简单直观地体现了我们设置分段的初衷:就是分段输出必须能提高压缩率。

2. 如果不考虑码表,huffman 算法能得到最短的编码式压缩结果,但是这种算法必须保存码表以便解压缩,所以不能保证结果是最佳的。gzip 预先拟定了一套通用的静态的编码,当要输出一个段落时,比较 huffman 压缩结果加码表的长度和静态编码的压缩结果长度,再决定用哪种方法输出这个段落。静态编码不需要建树,计算压缩结果长度时耗时很少。如果各节点的频率的差异很小,huffman 压缩结果加码表反而增大了结果,静态编码也不合适,同样增大了结果,gzip 就直接保存 lz77 的原始输出。由于输出一个段落时,增加了静态编码的方案,使输出的实际长度和之前确定分段点时计算的值可能不同,那么前面计算出的这个分段点是否仍是正确的?前面的分段策略是否需要调整?
分析:1)静态编码的各节点编码是不变的,对于段落的合并是无所谓的,两个连续段落即使都采用静态编码,也不用合并,因为合并后结果长度是不会变的。 2)所以只对一种情况可能有影响:一个段落中拆分出一些部分用 huffman 编码,另一些部分用静态编码,压缩结果更好。当这种情况发生时,则必有一些部分的优势节点(频率高的节点)与静态编码预先拟定的优势节点相近,采用静态编码后有稍许改善,其他部分则与静态编码预先拟定的优势节点有一定分歧,采用静态编码后会有稍许不利。之所以说“稍许”,是因为我们已知同一个段落里的各部分数据或者互相促进,或者仅有稍许妨害,说明它们的优势节点是大致趋同的。考虑到拆分后可能要多保存几份码表,拆分带来收益的可能性和程度是很小的,而且计算的复杂度较大,所以前面的拆分策略可以不作调整。
至于直接保存 lz77 的原始输出,可以看作静态编码的一种特殊形式,只不过它假定各节点的频率相近,没有优势节点。它可以套用静态编码的分析,来证明不影响前面已经制定的分段策略。

3.采用 huffman 编码,必须深入研究码表的保存方式。
只要计算一下采用简单的方式来保存码表,需要多大的空间,就知道这是一个挑战。
简单地保存码表的方法是顺序地保存每一个值的码长和编码。之所以要保存码长,是因为编码是不定长的,没有码长,解压时无法正确读取编码。码长必须是定长的,也就是说必须限制 huffman 树的最大层数,使码长的位数能恰好表示这个层数。限制 huffman 树的最大层数的方法是:如果规定的最大层数为 n,则在 n - 1 层找到一个叶子节点 a(如果 n - 1 层没有叶子节点,就逐层地往上寻找,直到找到一个叶子节点),在节点 a 的位置放一个非叶子节点 A,使 a 成为 A 的子节点,把某个超过 n 层的叶子节点 b 提上来作为 A 的另一个子节点,此时 b 的父节点 B 只剩下一个子节点 c,取消 B,把 c 放在 B 的位置,重复这样的过程,直到所有 n 层以下的节点都被提上来。之所以要从 n - 1 层开始逐层往上找,是因为下层的节点频率小,码长变化后的影响小。假设每一层节点的频率相近,那么上层父节点的频率是其下层子节点的两倍,第 11 层节点的频率只有第一层节点频率的 1 / 1024,所以应该从下往上找。
现在就开始计算码表大小:
对于 256 个原始字节值,限制它的 huffman 树的层数为 0 - 15,码长就需要 4 位,256 个码长需要 4 bit * 256 = 128 字节;而 256 个新编码需要至少 256 字节。(当二叉树的所有叶子节点都放在第 8 层 —— 不算根节点一层,正好能放下 2 的 8 次方 = 256 个叶子节点,其中任何一个叶子节点往上升,至少造成两个叶子节点往下降。换一个角度说,如果在第 8 层以上存在一个叶子节点 a,在节点 a 的位置放一个非叶子节点 A,使 a 成为 A 的子节点,把某个超过 8 层的叶子节点 b 提上来作为 A 的另一个子节点,此时 b 的父节点 B 只剩下一个子节点 c,取消 B,把 c 放在 B 的位置,此时 a 增长了一位,c 缩短了一位,b 缩短了至少一位,编码的平均位长缩短。所以,当第 8 层以上不存在叶子节点,所有叶子节点都放在第 8 层时,编码的平均位长达到最短 —— 8位。)这套码表共需至少 128 + 256 = 384 字节。
256 个“匹配长度”的情况与原始字节值相同,两套码表共需至少 384 * 2 = 768 字节。
对于 32k 个“匹配距离”,如果限制该 huffman 树的层数为 0 - 31,保存每个值的码长需要 5 位,新编码的平均长度超过 15 位。(因为所有叶子节点都放在第 15 层 —— 不算根节点一层,正好能放下 2 的 15 次方 = 32k 个叶子节点。)这套码表要超过80k 字节( (5 + 15) * 32k / 8 = 80k )。
前面讨论分段策略时已经说过,为了避免个段落间节点频率差异被互相抵消,要求段落划分尽量细致、准确,最小的段落可以仅为 4k,而采用上面这种简单的方式,码表要超过 80k,显然是无法接受的。
对码表的保存方式的深入研究,确实是个无法绕开的挑战,如果不攻克这个难关,编码式压缩无法进行下去!挑战会带来乐趣,困难会激发豪情。我们所要做的是:观察 gzip 如何一步步地通过繁复但又巧妙的做法解决这个难题,对其中的做法的道理务求知其然、知其所以然,通过观察、思考,把握无损压缩内在的、深层的、本质的规律!事实上,对 gzip 的这些做法进行阅读(源代码)、分析、挖掘其中的智慧,本身就是一个对智慧、耐力、乃至决心的长期的挑战,我接受了这个挑战,并把它描述、解释出来,读者面对的挑战是花费较长期的时间去阅读、理解,希望读者完全有耐力、豪情、兴趣来接受这个挑战,深化自己的技术层次、思维层次。

3.1 只保存码长,并增加一些特殊的值。

3.1.1 把 huffman 树的每一层上的叶子节点都换到该层的左边,按照其原始值从小到大依次排列,非叶子节点则集中在该层右边,这时仍是一棵二叉树,得到的编码仍符合前缀编码的要求。每个叶子节点的编码长度不变,所以压缩率也不变。仅需要按照原始值从小到大依次保存每个值的码长,解压时就可以还原这套编码表,还原方法是:码长为 n 的第一个值的编码是码长为 n - 1 的最后一个值的编码加 1,并左移一位(也就是说,在编码最后加个 0),而码长为 n 的其他值的编码是前一个码长为 n 的值的编码加 1。从上面所说的树的角度来解释,每一层的第一个叶子节点是其上层最后一个叶子节点的右边一个节点的左子节点,所以它的编码是上层最后一个叶子节点的编码加 1 并左移一位,而每一层上的叶子节点都紧密排列,所以除了第一个叶子节点外,其他叶子节点的编码都是前一个叶子节点的编码加 1。编程上的实现方法是:遍历码表,得到每个码长(n)上有多少个值,计算出每个码长上第一个值的编码,放在数组 bit_len[]中,再次遍历码表,依次根据每个值的码长(n),赋予它的编码为该码长上的前一个值的编码 (bit_len[n]) 加 1,bit_len[n] ++。
由于只需要保存码长,现在码表由超过 80k 字节减小到约 20k 字节。

3.1.2 如何只保存在段落中出现过的节点(有效节点)的编码?
一个 ASCⅡ文本,128 以后的值是不会在文件中出现的,按照 3.1.1 的方法,码表中后半部分(都是 0)在解压缩时是用不到的。为了避免这类浪费,只保存有效节点(码长不为 0 的节点),一种方法是保存有效节点的原始值和新编码的码长,当有效节点超过所有节点的1/4,这种方法保存的码表的大小会超过 3.1.1 的方法。
gzip 采用的方法是:在 3.1.1 的基础上,于若干种码长之外,增加一些特殊的值,他们表示当前为之前一个码长或 0 码长(无效节点)的重复,遇到这种值,那后面的一个数字表示重复的次数。第一种值代表当前为之前一个码长的重复 3 - 6 次,后面跟着 2 bit 为具体的重复次数;第二种值代表当前为 0 码长的重复 3 - 10 次,后面跟着 3 bit 为具体的重复次数;第三种值代表当前为 0 码长的重复 11 - 138 次,后面跟着 7 bit 为具体的重复次数。限制最小重复次数为 3,可以确保这种方法得到的码表不会大过 3.1.1。第一种值限制最大重复次数为 6,是因为连续 6 个值以上的码长相等(说明频率十分接近)的情况不常见,做这个限制可以节省附加 bit;第二第三种值区分重复次数的范围,也是为了节省附加 bit。在只有少数有效节点的情况下,这种方法只需要保存较少的数据,同时也具有简单的去重复的作用。
如果最大码长是 15,0 - 15 共 16 种值,一个码长需要 4 位,加上上面 3 种值,共 19 种值,需要 5 位,在重复不多时,加了这 3 种值,是不是会增大码表?其实不用担心,gzip 会对码表再进行一次 huffman 压缩,根据这 19 种值的频率分配给它们可变码长的编码,不会造成浪费,由于涉及到一些其他情况,对码表的再编码压缩在后面还会详细介绍!

3.2 把原始字节值和匹配长度值建在一棵树上。
现在先考虑另一个问题:如何使解压时能区分当前是一个未匹配的字节,还是一个匹配?未匹配字节值和匹配长度、匹配距离是三棵不同的 huffman 树,它们的编码互相不符合前缀编码的要求,部分节点甚至可能编码相同,解压时如何区分?
第一种方法是用标志位。输出压缩结果时,除了输出每一段的码表、重新编码后的数据流,还要保存对应于这一段数据的标志位流,流中的每一位 0 或 1 表示当前是一个未匹配的字节,还是一个匹配。
第二种方法是给原始字节值和匹配长度值不同的编码,并符合前缀编码的要求。最好的做法是把它们建在一棵树上,以确保它们符合前缀编码的要求,并由它们的频率来确定各自的码长。
第一种方法相当于原始字节值和匹配长度值的编码都增长一位。
第二种方法中这两套节点的码长变化要根据具体节点各自的频率而定。
经过分析,第二种方法更好,因为第一种方法可以看作是第二种方法的变种,相当于简单地在两棵 huffman 树的根节点上再加一个父节点,这样显然是不能保证最佳的结果的。

3.3 把匹配长度、匹配距离变为长度范围、距离范围,减少节点。
经过上面对保存码表的方法的改进后,现在码表还有多大?
由于有了上面介绍的去重复机制,码表的实际大小和节点的重复情况有关,如果有很多连续 3 个以上节点的码长相等的情况出现,或有很多连续 3 个以上的无效节点的情况出现,码表可能是很小的,但作为通用的无损压缩算法,必须考虑重复不多的情况。“匹配距离”是码表中最主要的部分,我们来分析一下它的重复情况,“匹配距离”共有 32k 个取值,如果一个段落不到 32k,“匹配距离”的有效节点数当然是不可能到 32k 的,思考一下,可以知道,它的有效节点数和这样几个因素有关:一段有多长,段落中匹配数和未匹配数的比例,决定了它有多少个值,再加上这些值的重复性,决定了它有多少个有效节点。再分析一下这些值的重复性:不同于原始字节和“匹配长度”都只有 256 个取值,它有 32k 个取值,相同的匹配有相同的匹配长度但不一定有相同的匹配距离,所以它的去值范围广,重复率低,有效节点多。虽然实际的情况无法预测,但我们可以做一些 “大致合理”的假设,以便对码表的大小有一个基本的概念,假如短语式压缩的输出段落的大小为 90k 字节,其中未匹配字节数和匹配数的比例为 3 : 1,每个未匹配字节占 8 位;每个匹配中,长度占 8 位,距离占 15 位,共 23 位,约为未匹配字节的 3 倍,所以匹配占了 90k 字节中的约 45k 字节,匹配数约 15k 个,也就是说有 15k 个距离值,假如距离值的平均节点频率为 3,那么去掉重复后有 5k 个有效距离值节点,保存到码表时每个码长需要 5 位,保存 5k 个码长需要 5k * 5 / 8 约 3k 字节,算上无效节点、码长的重复的因素,原始字节值、匹配长度的保存,最终码表约 5k 字节,为 90k 的 18 分之一。当段落减小时,有效节点趋于稀疏,无效节点容易连成片,去重复机制能发挥更大的作用;当段落增大时,无效节点密度减小,可能无法大片连接,去重复机制的效用降低,码表的比例可能会增大。一旦“匹配距离”需要保存的码长数达到了 32k个,码表达到最大,之后段落再增大也不会增大码表,于是码表的比例又会逐渐下降。当然段落通常不会达到这么大,使得“匹配距离”需要保存的码长数能有机会达到 32k。
gzip 以牺牲压缩率的代价来换取码表的进一步的大幅度减小。我们先描述一下它的具体做法,再来分析其利弊。
gzip 把匹配长度划成 29 个范围,把匹配距离划成 30 个范围,根据每个范围中节点的总频率,为 29 个长度范围加 258 个字节值建 huffman 树:l_tree,为 30 个距离范围建 huffman 树:d_tree。输出一个值时先输出该值所在范围的编码,再输出附加码,即它是该范围中的第几个值。这样码表中只需保存范围的码长。范围的大小都是 2 的乘方,所以范围大小和附加码的位长是互相决定的。
29 个长度范围的附加码位长是:
{0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0};
30 个距离范围的附加码位长是:
{0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13};
可以看出:范围的划分是从小到大的。为什么不平均划分呢?
如果仍以单个节点的角度来看,被分到同一范围的节点相当于被赋予了相同的码长:范围编码的码长加附加码的码长。若频率差别很大的节点因划分入同一个范围而拥有相同的码长,就不符合 huffman 编码的初衷,会对压缩率产生不良影响。因此要求划分后,范围里的节点频率相近,以尽量降低同一个范围里不同节点间的相互影响。
“匹配长度”从短到长,频率会逐渐衰减,而且衰减的幅度有从大到小的特点,这个特点是在大多数原始文件中“自然存在”的。比如在 google 上搜索,2 个字的短语和 22 个字的短语,搜到的结果数差别巨大,200 个字和 220 个字,搜到的结果数差别就没有那么大。频率大致上单向地逐步变化,所以划分范围后,范围内节点的频率较接近;变化速度由大到小,所以范围的划分应该从小到大。
“匹配距离”也有类似的特点,对大多数文件来说,匹配发生在 1k 以内比发生在 5k 左右的可能性要大得多,但发生在 28k 处附近的可能性和发生在 32k 处附近的可能性的差别就没那么明显。所以范围划分也应该是从小到大。
“未匹配的原始字节”不具有频率衰减或递增的单向变化的规律,它们的频率分布往往是参差不齐、难以预测的,不可能用预先设定的范围表对它们进行大致合理的划分,就像“匹配长度”和“匹配距离”那样。虽然也可以通过计算分析,对它们进行不设定范围数量和大小的划分,以求每个范围中的各节点频率大致相近,但 1) “匹配距离”的划分已经大幅度地缩小了码表的大小;2) 由于不具有频率单向变化的趋向,要强行划出节点频率相近并且节点数是 2 的乘方的范围太勉强,难度也大;3) 未匹配的字节数一般要大于“匹配数”(注意:不是“匹配字节数”),强行划分造成的不良反应较大。所以 gzip 保留了这套节点,没去拆分。
长度范围的最后一个附加码位长是 0,是因为长度大于 258 的匹配都被截断到 258,所以 258 的频率可能会高出前面的节点,单独划为一个范围。
如果一个范围里的节点频率相同,节点数是 2 的乘方,且没有无效节点,那么这个范围可以看作 huffman 树中的一棵子树,范围的编码可以看作这棵子树的根的编码,这样的划分是不会影响压缩率的。
对压缩率的损害来自频率不一致,以及无效节点的存在。范围里的有效节点如果没有过半,“附加码”的位数就至少有一位浪费了,也就是说,范围里所有有效节点的码长无端增长了一位,如果有效节点没有过 1/4,至少就有 2 位附加码浪费。
划分范围的收益是使码表减小到不足 0.2k,加上后面会介绍的对码表的第二次压缩,码表的最终大小是微不足道的。
现在我们来近似地估计一下划分范围在“一般情况”下对压缩率的损害的情况,以便有一个大致的概念,仍举前面的例子:段落大小为 90k,设其中未匹配字节数和匹配数的比例为 3:1,未匹配字节有 45k 个,匹配距离值和匹配长度值各 15k 个,有效距离值节点为 5k个(节点平均频率为 3),无效距离值节点为 32k - 5k = 27k 个,有效距离值节点的平均密度为 5/32,不到 1/6。范围的划分是前小后大,有效节点频率是前大后小,无效节点是前少后多。距离值有 15k 个,设前面有效节点频率高、密度较大的部分占一半,约 7k 个值,这个部分中无效节点带来的损害较小,而且范围划分细,节点间频率不一致带来的损害也小,姑且不去计算。后面的范围划分大、有效节点密度小的部分损害较大,这个部分占了约 7k 个值,由于前面的部分有效节点密度大,所以假设这个部分有效节点密度为 1/8(也就是说,约一半的匹配发生在 1k 距离以内,且 1k 以内无效节点很少,那么 4k / 31k 约等于 1/8),附加码浪费了 3 位,7k 个值浪费 3 位,共浪费了 21k bit 约等于 3k 字节。
再看频率不一致带来的损害:huffman 编码如果要达到 50% 的压缩率,需要节点间频率的差异达到几百倍。读者可以虚拟一些节点频率,试着建一下 huffman 树,会发现当节点频率差异在几十倍甚至只有几倍的时候,压缩率其实微乎其微。经过上面这样合理地划分范围,范围内的节点频率差异一般不会那么大,所以我们假设频率不一致造成的损害为 1k - 2k。
匹配长度值的取值范围只有 258 个,而且匹配长度可能很少会超过 20 字节,而前 20 字节的范围划分是很细的,所以无效节点的损害和频率不一致的损害都较小。
这样,在这个例子中,划分范围带来的损害约在 5k - 6k,和不划分范围时码表的大小非常相似,至少也是在一个数量级上。
再来看看损害比例变化的趋势:当段落很小时,范围中的有效值稀疏,损害比例会加大。而不划分范围时,码表的去重复机制会有更大作用,无效节点连成片,损害比例减小。反之,段落增大,范围里有效节点密度大,损害比例降低,而不划分范围时,无效节点可能无法大片连接,去重复机制的效用降低,损害比例增大。
由于划分范围能使 huffman 树的节点从最多 32k 减到不足 320 个,从而使压缩速度显著改善。综上所述,段落小(比如不到 10k),不宜划分范围,否则划分范围是有益的。

3.4 对码表进行第二次压缩。
目前为止,码表中只需要保存各个节点经过 huffman 编码后的新编码的码长。共两棵树,l_tree: 256 个原始字节值加 29 个长度范围值加 1 个段落中止符,共 286 个节点,段落中止符用来在解压时标示一个段落的终结。d_tree: 30 个距离范围值。也就是说,共需要保存 286 + 30 = 316 个编码的码长。gzip 限制 huffman 树的最大层数为 15,这样,码长就有 0 - 15 共 16 种值,再加上前面介绍过的去重复机制使用的 3 种特殊值,共 19 种值,如果就这样保存码表的话,每个码长都需要 5 位,才能表示 19 种值。我们观察一下,316 个码长,一共只有 19 种值,码长值的重复是必然的,而且由于 huffman 树上每层的节点数不同,所以各个码长值的频率也不一样。所以还可以为这 19 种值再建 huffman 树,进行第二次编码。这棵树只有 19 个节点,限制它的层数为 0 - 7,可以用 3 个 bit 表示这 19 个节点的“长度”。这样,用新的“码长的编码”来保存 316 个码长,另需额外保存 3 * 19 = 57 bit,就可以解压出这 19 个“码长的编码”。(至于这 57 bit,就没有必要再作第 3 次编码了)


4. 解决了码表的问题,现在再回过头来看静态编码。
静态编码是 gzip 预先设定的编码方案,它的码表是固定的。
该如何合理设计这套编码?作为 huffman 编码的补助,它的耗时应尽量少,前面说过,lz77 输出一个分段之前,要比较 huffman 编码和静态编码的压缩结果,为了直接利用 lz77 输出时做的匹配长度范围、匹配距离范围的频率的统计,静态编码采用了同样的范围-附加码的方案,这样可以快速得到静态编码的压缩结果大小。
静态编码的码长的分配是这样的:29 个长度范围中前 24 个范围的码长为 7,后 5 个范围的码长为8。原始字节值中 0 - 143 的码长为 8,144 - 255 的码长为 9。而 30 个距离范围的码长为 5。根据这些预先设定的码长建立静态的 l_tree 和 d_tree,编码也就产生了。结合前面提到的附加码位数的定义:
29 个长度范围的附加码位长:
{0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0};
30 个距离范围的附加码位长:
{0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13};
读者可以知道每一个值的实际码长。长度范围值和原始字节值建在一棵树上,节点多所以码长较长,30 个距离范围值只需要 5 位二进制数表示。短匹配的长度范围值位长较短,字节值 0 - 143 的位长中等,其他字节值和长匹配的长度范围值较长。这样的分配反映了 gzip 作者对“大多数”文件中各种值的频率的粗略估计。作为一个通用的压缩算法,无法预先知道一个文件的实际情况,不可能做精确的估计。
进一步的思考:静态编码有必要吗?静态编码采用了和 huffman 编码相同的范围-附加码的方案,在码长的分配上不可能超过 huffman 编码,如果能“获胜”,那就是胜在不需要保存码表上,而前面分析过,码表是很小的,对压缩率没有多大影响,所以 gzip 设计的这个静态编码方案应该是可有可无的。

5. 关于堆排序算法。
似乎已经解决了所有的难题,但是对于没有学过数据结构的读者,仍然有一个会对程序效率产生影响的问题需要关注,那就是“排序”。
已经讲过,huffman 算法就是从一个节点序列中,不断找出两个最小的节点,为它们建一个父节点,值为这两个节点之和,然后从节点序列中去除这两个节点,加入它们的父节点到序列中,不断重复这样的步骤,直到节点序列中只剩下一个节点。如何快速地找出最小的元素呢?
在普通的线性罗列的数据结构中,从 N 个元素中找出最小的元素的时间和 N 成正比,如果数据以我们所要介绍的“堆”的结构存储,时间和 lg N 成正比(注:lg 以 2 为底数,如 lg 256 = 8,lg 1024 = 10 ...)。集合中的元素越多,堆排序算法的优势越突出,而且堆排序非常适合于在数据序列中不断地取走最小的元素并加入新的元素。

5.1 什么是堆?
堆首先是一棵“完全二叉树”,即所有的叶子节点都在树的最低二层,最低一层的节点依次靠左排列的二叉树。如图:

完全二叉树

+----------○----------+
|                     |
+-------○------+          +---○---+
|              |          |       |
+---○---+      +---○---+    +-○-+   +-○-+
|       |      |       |    |   |   |   |
+-○-+   +-○-+  +-○-+   +-○-+  ■   ■   ■   ■
|   |   |   |  |   |   |   |
■   ■   ■   ■  ■   ■   ■   ■


堆分大根堆和小根堆,大根堆的所有子节点都小于它的父节点,小根堆的所有子节点都大于它的父节点。下面就是一个小根堆:

小根堆

+-----------2----------+
|                      |
+-------3------+          +----8---+
|              |          |        |
+---6---+      +---4---+    +-15-+   +-18-+
|       |      |       |    |    |   |    |
+-8-+   +-9-+  +-5-+   +-5-+  16  20   19   20
|   |   |   |  |   |   |   |
9   9  11  13  6   8   6   6

5.2 堆如何在内存中存储?
堆存放在一个数组中,存放的顺序是:从根开始,依次存放每一层从左至右的节点。
5.3 如何寻找任意节点的子节点和父节点?
数组中第 k 个元素,它的左子节点是第 2k 个元素,右子节点是第 2k + 1 个元素。它的父节点是┖ k/2 ┚(注:┖ X ┚表示小于等于 X 的最大整数)。
5.4 如何建立堆?
先把 n 个元素依次放入数组中,令变量 k = ┖ n/2 ┚,这时第 k 个元素是最后一个元素的父节点,从第 k 个元素的两个子节点中找出较小的一个与 k 元素比较,如果小于 k 元素,就和 k 元素交换一下位置,换位后的原先的 k 元素再和新的子节点比较(如果有子节点的话),直到它不再小于新的子节点或没有子节点。令 k = k - 1。再重复上面的做法直到 k < 1,一个堆就建成了。
5.5 如何从堆中找出第二个最小的元素?
把堆中第一个元素(最小的元素)存放到其他地方,把第 n 个元素(最后一个)放到第一个的位置,再用前面的方法和下层节点交换直到它放到合适的位置,这时数组仍然是一个堆,第一个元素是最小的节点,数组的最后一个有效节点是第 n - 1 个元素。
花费的时间和交换的次数成正比,最大的可能的交换次数是: 堆的层数 - 1 =┏ lg (元素数 + 1) ┒- 1(注:┏ X ┒表示大于等于 X 的最小整数)。
现在可以看到,堆之所以采用完全二叉树的形式,是为了树的层数尽可能少。
而抽出最后一个元素放到树根,而不是抽出第二层的元素,是为了维持完全二叉树的结构!
5.6 如何加入新的元素到堆中?
把第一个元素存放到其他地方,把新的元素放到第一个的位置,再用前面的方法和下层节点交换,直到它被放到合适的位置,此时数组中仍然是一个堆。

6. 建 huffman 树和编码的算法:
如果现在有 n 个待编码的节点,按照原始数值从小到大存放在数组 tree[n] 中,那么,将要建立的 huffman 树总共会有 2n -1 节点,包括叶子节点和非叶子节点。申请一块内存,大小是能放下 huffman 树的所有节点,先把 n 个待编码节点放入这块内存的左端,然后用“堆排序”算法先把它们建成一个堆。
然后不断用“堆排序”算法取出频率最小的节点,把它们从右到左、从小到大排放在内存块的右端,每当取出两个节点,给它们生成一个父节点,频率等于它们之和,加入堆中。这样直到堆中只剩下一个根节点,这时,内存中从左到右存储的是频率从大到小的所有节点,一棵 huffman 树其实也就建成了,层数小的节点在前,层数大的节点在后,每一层的节点又是按频率从大到小依次排列。
申请两个数组:bl_count[],bl_base[]。置根节点的码长为 0,从左至右,所有节点的码长(len)为它的父节点的码长 + 1,如果是叶子节点,bl_count[len]++,得到了每一层上的叶子节点数目。令变量 code = 0,然后根据 bl_count[] 生成 bl_base[]:码长 len 从 1 开始递增,bl_base[len] = code = (code + bl_count[len - 1]) << 1,得到了每一层上第一个叶子节点的编码。
现在所有待编码节点都被赋予了码长,遍历待编码节点,根据它们的码长得到它们的编码:序号 n 递增,tree[n].code = bl_base[ tree[n].len ] ++。
注意:我们前面讨论码表的时候说过,gzip 对 huffman 编码进行了改进,只需要得到每一个叶子节点(待编码节点)的码长,就可以进行编码,而不需要关心它的父节点的编码是什么。而保存码表时,只需要保存码长。

动态 huffman 压缩和解压的整个流程:
压缩:
lz77 的压缩过程中输出未匹配的单双字节,和匹配,并统计各字节值和匹配长度范围、匹配距离范围的频率,根据这些频率建立两棵 huffman 树:ltree、dtree,得到这两棵树上所有节点的长度和编码。
统计这两棵树节点长度的使用频率,对各节点长度建立 huffman 树:bl_tree,得到 bl_tree 的长度和编码。
存储 bl_tree 的节点长度数组。
再用 bl_tree 的编码存储 ltree、dtree 的节点长度数组。
再用 ltree 的编码存储各字节值和匹配长度范围(及附加码)的流;用 dtree 的编码存储匹配距离范围(及附加码)的流。
解压:
先根据 bl_tree 的节点长度数组得到 bl_tree 的编码。
再用这些编码得到 ltree、dtree 的节点长度数组,进而得到 ltree、dtree 的编码。
再根据 ltree、dtree 的编码及附加码的定义,得到 lz77 的输出的原始结果:各字节值和匹配长度的流,匹配距离的流。

后记:
写作本文花费了超过一年的业余时间,其实看懂 gzip 源码只用了一个半月的业余时间,等真正开始写这篇文章的时候,发现深入分析无损压缩算法要投入的心力会远超过我原来的想象,不是光靠“毅然决然的态度”和“拼搏精神”就可以完成的。只有耐心地去付出。
经过了一年多的时间,终于有了现在这样质量的这篇文章。这期间,我的工作已经从应用工程师转变到了研究员,应该说,写作这篇文章对促成我把今后的工作转变为搞研究是有影响的。所以这篇文章对我自己的人生道路当然是有重要的意义,我也希望它会促成读者投身研究的决心。
巴甫洛夫说:“科学研究需要的是伟大的热情和艰苦的劳作”,从看到这句话起,我就一直很喜欢它,常常会想起这句话。希望这篇文章能使读者联想到“长久的热情和耐心的劳作”,并在生活和工作中贯彻这种精神。
一篇文章发布以后,它的全部价值就在于读者的阅读,感谢读者诸君。

 

感谢这位老兄的工作,很有益处

原创粉丝点击