轮廓
gzip 压缩 的核心思想有两个,一是指代重复的内容,二是哈夫曼编码。指代重复内容,就是把重复出现的内容用二元组(distance ,length )替代。distance 是指相互匹配的两块内容之间的距离,length 是指匹配的长度。完成了指代重复内 容的工作后,原始数据就被分成了三类:literal (没被匹配的字节)、distance 和length ,接下来就对它们进行哈夫曼编码了。最后把输入文件的 编码发送到压缩文件中就可以了。
寻找重复的内容
要替换掉重复的内 容,首先就要把重复的内容找到。怎么去找呢?
Gzip 采用的方法类似于 Rabin-Karp 字符串匹配算法。
R-K 算法的基本思路是 这样的:1 、计算模式串的哈希值;2 、依次计算文本串中各个子串( 仅限于长度与模式串相同的那些子串)的哈希值,如果与模式串的哈希值相同就把这个子串与模式串作 进一步比较,看是否真的匹配。
在gzip 中,所谓的模式串就是以指针strstart 所指为起点长度为3 字节的串(strstart 指示当前处理到输入文件的哪个位置),而要被匹配的文本串则是strstart 之前的内容。当模式串在得到了自己的哈希值后,就把自身的位置存储到哈希表里,顺便看看以前有没 有在某个地方得到过相同的哈希值,如果有就去仔细比较。这样如果以前出现过相同的串,就可以比较快捷地找到了。之后,strstart 指针向后移一位,指向下一个字节,对新模式串进行同样的操作。
下面具体展开。哈希表依靠两个数组实现:head 和prev ,prev 数组能形成哈希冲突链。这里没有用到指针,仅靠一个数组却能把冲突链串起来,怎么做到的呢?举例 来说明吧,假如已经有head[8]=10 ,说明最近插入的一个哈希值是8 的串的位置在10 处。假设现在计算出一个模式串的哈希值恰好又是8 ,而这个模式串的位置是20 ,就把20 插入到哈希表相应的表项中,插入操作就是几个赋值语句,形成了head[8]=20, prev[20]=10 。如果待会儿又有位置为30 的模式串计算出哈希值为8 ,则哈希表就变为head[8]=30, prev[30]=20, prev[20]=10 。再结合head 与prev (previous )本身的意思,我们就可以看出了,head 和prev 中的元素本身额外地充当了指针的功能,所以能形成链。这样实现的好处除了节省空间外,还可隐性地删除链中结点,只要对prev 的某个元素重新赋值,立刻该元素就从一条链删除,而插入到了另一条链中去了,这样就神不知鬼不觉 地去掉了过旧的冲突。
在插入到哈希表之 后,我们可以开始寻找重复内容了,顺着prev 的指点,从近往远依次比较那些“疑似”匹配串,挑最长匹配串作为最后结果,然后把模式串以 (distance ,length )的形式存储起来。如果模式串发现自己是第一个到某个head 元素处报到的(即该head 元素的值为空),那么它就没希望被匹配上,直接被作为literal 存储。有的模式串很不幸,顺着prev 找了一遍后却没找到匹配,那么它第一个字节也会被作为literal 存储。
依靠标志数组 flag_buf ,literal 、distance 、length 这三类数据是被有序地记录了下来的,所以待会儿就可以有序地把它们的编码发送到压缩文件中。
哈夫曼编码
在存储 literal 、 length 和 distance 时, gzip 统计这些数据(结点)的频率。每个类型所有树结点被存放在一个相应的类型为 ct_data 的数组中, ct_data 的定义如下:
typedef struct ct_data { union { ush freq; /* frequency count */ ush code; /* bit string */ } fc; union { ush dad; /* father node in Huffman tree */ ush len; /* length of bit string */ } dl; } ct_data;
freq 和 dad 域在构造树的时候用到,而 code 与 len 域在生成编码时用到:用两个共用体表示,节省空间,互不冲突。在这个结构体中我们没 有看到关于结点的信息,比如这个结点表示字符‘ a’ ,我们拿什么表示呢?原来,结点的信息存放在数组的下标中,‘ a’ 结点就是数组的第 97 个元素( 97 是‘ a’ 的 ASCII 码)。
我们在课本里学到 的哈夫曼编码是从哈夫曼树的根节点出发,向左拐编码为0 ,向右拐编码为1 ,总之是要“钻”到树里,给每个结点一比特一比特地编码。但gzip 做的很精巧,省去了这些费时的麻烦。
gzip 根据各结点的编码 长度(如何得到:本结点的编码长度是其父结点长度+1 ,根结点的编码长度为1 ,迭代可得到所有结点的编码长度),再得到每种编码长度各有多少结点,进而得到各种长度的结点的起始编码(按编码长度 从小到大 迭代处理,本编码长度的起始编码,等于上一个编码长度的起始编码+上一个编码长度的结点数,再左移 一位),将其存放在数组next_code 中,给结点编码时,当用某个next_code 元素给一个相应编码长度的结点赋编码后,该next_code 元素自加1 ,为下一个同样编码长度的结点作好准备。所有结点的编码一气呵成。这个方法巧妙地利用了结点以及 结点的顺序都是固定的这一事实。(说得有些乱,看源码则一目了然)
所以,我们仅仅依靠各结 点的编码长度就完成了编码,反过来说,我们只要在压缩文件中额外存放各结点的编码长度,就可以推算出各结点的编码了。所以,把各结点的编码长度依次直接发送到 压缩文件中就可以了,但gzip 觉得这样会太费空间,就把编码长度再加以哈夫曼编码。这样要恢复出整棵哈夫曼树,只要在压缩文件 中放编码长度的编码就可以了。
有必要提一下给编 码长度编码的过程。用同样的数据结构ct_data 收集编码长度的频率信息,但gzip 做了一些改进。为了说明改进的理由先给一组数据:为literal 、length 建哈夫曼树(两者是放在一起编码的)时结点最多有286 个,而编码长度的最大值是15 。也就是说这286 个编码长度的可取值只有0 到15 这16 个整数。我们要有序地把每个结点的编码长度存放在压缩文件中,那么有很大的可能性会连续出现相同 的长度,如3 9 0 0 0 8 7 7 7 7 7... ... 这里连续出现了3 个0 ,5 个7 。对于这种情况gzip 采用“游程编码”来进一步压缩数据。通过额外增加REP_3_6 、REPZ_3_10 、 REPZ_11_138 这三个结点,来分别表示重复前一个值 3 到 6 次,重复零 3 到 10 次,重复零 11 到 138 次。这三个结点跟普通结点混在一起被编码。最终, 0 0 0 在压缩文件中表示为 code(REPZ_3_10) 0 , 0 明确了重复次数( 3 + 0 )次,而最后 5 个 7 被表示成 code(7) code(REP_3_7) 1 , 1 明确了重复( 3 + 1 )次,否则的话, 0 0 0 和 7 7 7 7 7 会被表示成 code(0) code(0) code(0) 以及 code(7) code(7) code(7) code(7) code(7) code(7) ,其中 code(x) 代表 x 的编码。可见,游程编码起了一定的压缩作用。
向压缩文件发送原文件的编码
需要澄清一点,gzip 并不对length 和distance 这两类数据直接进行哈夫曼编码。因为直接编码涉及的结点数太多了,时间开销可能会很大。gzip 只是取它们二进制表示的高若干位编码,低位不参与编码,所以实际参与编码的length 只有29 个结点,distance 只有30 个结点。这样在向压缩文件发送编码时,把原数据的高若干位用编码顶替,而低位原样发送。这样做固然不利于提高压缩率,但对提高程序的速度是大有裨益的。
其实gzip 中除了动态哈夫曼编码外,还有静态的哈夫曼编码,以及裸拷贝。在工作过程中,是以块(block )为单位压缩的,一个文件可能被分为若干块而分别被压缩,哪种方法压缩效果最好,就取哪种方法。
总结一下,如果采用动态哈夫曼编码,压缩文件中将会有这三类相互之间有层次关系数据: A、编码长度的编码(有序地排列); B 、各种结点的编码长度 (用A中的相应数据表示) ;C、 原输入文件的编码(用B中的相应数据表示 )。
如果用静态哈夫曼编码,因为编码永远固定,所以少了 A 、B 两类数据,但由于静态编码是盲目的,压缩效果肯定不如动态编码,所以谁更胜一筹就不好说了,比了才知道。
相关实现
*与压缩有关的代码几乎都在 deflate.c和 trees.c这两个文件中。
*“主函数”是 deflate( ) / deflate_fast( )。其中 deflate( )用延迟匹配技术( lazy match)做了优化。
*计算模式串哈希值的哈希函数:
#define UPDATE_HASH(h,c) (h = (((h)<
其中的 h 就是哈希值。
*pqdownheap( ) 维护优先队列( pq : priority queue )。在构建哈夫曼树时,从优先队列中取出频率最小的两个结点,作其父结点插入优先队 列(但只做两次维护操作:)
*extra_lbits[ ] 、 extra_dbits[ ] 分别规定了 length 和 distance 的二进制表示的低多少位不参与哈夫曼编码。由此可推出怎样把 length 映射成 29 个结点,把 distance 映射成 30 个结点。
。。。 。。。
后记
强烈推荐刚学编程的朋友们阅读一下 gzip ,起点不高,只要学过数据结构就可以了。
感受一下其优雅、轻盈的代码(尤其是哈夫曼编码这一段),学习一 下其明晰的命名、注释(不然我怎能挖出这些好东西),一定会受益匪浅的。
( gzip 写的年代较早,用的是旧式的 C 语言风格,但不碍阅读。阅读工具我用 ctags + gvim ,感觉不错:)
参考: http://blog.csdn.net/imquestion/archive/2004/03/15/16439.aspx