1 gzip 所使用压缩算法的基本原理
gzip 对于要压缩的文件,首先使用 lz77 算法进行压缩,对得到的结果再使用 huffman 编码的方法进行压缩。所以我们分别对 lz77 和 huffman 编码的原理进行说明。
1.1 ... 1.2 ...
2 gzip 压缩算法实现方法
2.1 LZ77 算法的 gzip 实现
首先, gzip 从要压缩的文件中读入 64KB 的内容到一个叫 window 的缓冲区中。为了简单起见,我们以 32KB 以下文件的压缩为例做说明。对于我们这里使用 32KB 以下文件, gzip 将整个文件读入到 window 缓冲区中。然后使用一个叫 strstart 的变量在 window 数组中,从 0 开始一直向后移动。 strstart 在每一个位置上,都在它之前的区域中,寻找和当前 strstart 开始的串的头 3 个字节匹配的串,并试图从这些匹配串中找到最长的匹配串。
如果当前的 strstart 开始的串,可以找到最少为 3 个字节的匹配串的话,当前的 strstart 开始的匹配长度那么长的串,将会被一个 < 匹配长度 , 到匹配串开头的距离 > 对替换。
如果当前的 strstart 开始的串,找不到任何的最少为 3 个字节的匹配串的话,那么当前 strstart 的所在字节将不作改动。
为了区分是一个 < 匹配长度 , 到匹配串开头的距离 > 对,还是一个没有被改动的字节,还需要为每一个没有被改动的字节或者 < 匹配长度 , 到匹配串开头的距离 > 对,另外再占用一
位,来进行区分。这位如果为 1 ,表示是一个 < 匹配长度 , 到匹配串开头的距离 > 对,这位如果为 0 ,表示是一个没有被改动的字节。
现在来说明一下,为什么最小匹配为 3 个字节。这是由于, gzip 中, < 匹配长度 , 到匹配串开头的距离 > 对中, " 匹配长度 " 的范围为 3-258 ,也就是 256 种可能值,需要 8bit 来保存。 " 到匹配串开头的距离 " 的范围为 0-32K ,需要 15bit 来保存。所以一个 < 匹配长度 , 到匹配串开头的距离 > 对需要 23 位,差一位 3 个字节。如果匹配串小于 3 个字节的话,使用 < 匹配长度 , 到匹配串开头的距离 > 对进行替换,不但没有压缩,反而还会增大。所以保存 < 匹配长度 , 到匹配串开头的距离 > 对所需要的位数,决定了最小匹配长度至少要为 3 个字节。
下面我们就来介绍 gzip 如何实现寻找当前 strstart 开始的串的最长匹配串。
如果每次为当前串寻找匹配串时,都要和之前的每个串的至少 3 个字节进行比较的话,那么比较量将是非常非常大的。为了提高比较速度, gzip 使用了哈希表。这是 gzip 实现 LZ77 的关键。这个哈希表是一个叫 head 的数组(后面我们将看到为什么这个缓冲区叫 head )。 gzip 对 windows 中的每个串,使用串的头三个字节,也就是 strstart,strstart 1,strstart 2 ,用一个设计好的哈希函数来进行计算,得到一个插入位置 ins_h 。也就是用串的头三个字节来确定一个插入位置。然后把串的位置,也就是 strstart 的值,保存在 head 数组的第 ins_h 项中。我们马上就可以看到为什么要这样做。 head 数组在没有插入任何值时,全部为 0 。
当某处的当前串的三个字节确定了一个 ins_h ,并把当时当前串的位置也就是当时的 strstart 保存在了 head[ins_h] 中。之后另一处,当另一处的当前串的头三个字节,再为那三个字节时,再使用那个哈希函数来计算,由于是同样的三个字节,同样的哈希函数,得到的 ins_h 必然和前面得到的 ins_h 是相同的。于是就会发现 head[ins_h] 不为 0 。这就说明了,有一个头三个字节和自己相同的串把自己的位置保存在了这里,现在 head[ins_h] 中保存的值,也就是那个串的开始位置,我们就可以找到那个串,那个串至少前 3 个字节和当前串的前 3 个字节相同(稍后我们就可以看到这种说法不准确,这里是为了说明方便),我们可以找到那个串,做进一步比较,看到底能有多长的匹配。
我们现在来说明一下,相同的三个字节,通过哈希函数得到的 ins_h 必然是相同的。而不同的三个字节,通过哈希函数有没有可能得到同一个 ins_h ,我没有对这个哈希函数做研究,并不清楚,不过一般的哈希函数都是这样的,所以极大可能这里的也会是这种情况,即不同的三个字节,通过哈希函数有可能得到同一个 ins_h ,不过这并不要紧,我们发现有可能是匹配串之后,还会进行串的比较。
一个文件中,可能有很多个串的头三个字节都是相同的,也就是说他们计算得到的 ins_h 都是相同的,如何能保证找到他们中的每一个串呢? gzip 使用一个链把他们链在一起。 gzip 每次把当前串的位置插入 head 的当前串头三个字节算出的 ins_h 处时,都会首先把原来的 head[ins_h] 的值,保存到一个叫 prev 的数组中,保存的位置就在现在的 strstart 处。这样当以后某处的当前串计算出 ins_h ,发现 head[ins_h] 不空时,就可以到 prev[ head[ins_h] ] 中找到更前一个的头三个字节相同的串的位置。对此我们举例说明。
例,串
0abcdabceabcfabcg
^^^^^^^^^^^^^^^^^
01234567890123456
整个串被压缩程序处理之后。
由 abc 算出 ins_h 。
这时的 head[ins_h] 中为 13, 即 "abcg" 的开始位置。
这时 prev[13] 中为 9 ,即 "abcfabcg" 的开始位置。
这时 prev[9] 中为 5 ,即 "abceabcfabcg" 的开始位置。
这时 prev[5] 中为 1 ,即 "abcdabceabcfabcg" 的开始位置。
这时 prev[1] 中为 0 。
我们看到所有头三个字母为 abc 的串,被链在了一起,从 head 可以一直找下去,直到找到 0 。
现在我们也就知道了,三个字节通过哈希函数计算得到同一 ins_h 的所有的串被链在了一起, head[ins_h] 为链头, prev 数组中放着的更早的串。这也就是 head 和 prev 名称的由
来。
gzip 寻找匹配串的另外一个值得注意的实现是,延迟匹配。会进行两次尝试。比如当前串为 str, 那么 str 发生匹配以后,并不发生压缩,还会对 str 1 串进行匹配,然后看哪种
匹配效果好。
例子 ...
从这个例子中我们就看到了做另外一次尝试的原因。如果碰到的一个匹配就使用了的话,可能错过更长匹配的机会。现在做两次会有所改善。
...
2.2 问题讨论
我在这里对 gzip 压缩算法做出了一些说明,是希望可以和对 gzip 或者压缩解压缩感兴趣的朋友进行交流。
我对 gzip 的了解要比这里说的更多一些,也有更多的例子。如果哪位朋友愿意对下面的问题进行研究,以及其他压缩解压缩的问题进行研究,来这里 http://jiurl.cosoft.org.cn/forum/ 和我交流的话,我也愿意就我知道的内容进行更多的说明。
下面是几个问题
这种匹配算法,即用 3 个字节 ( 最小匹配 ) 来计算一个整数,是否比用串比较来得高效,高效到什么程度。
哈希函数的讨论。不同的三个字节,是否可能得到同一个 ins_h 。 ins_h 和计算它的三个字节的关系。
几次延迟尝试比较好?
用延迟,两次尝试是否对压缩率的改善是非常有限的?
影响 lz77 压缩率的因素。
压缩的极限。
2.3 ...
3 gzip 源码分析
main() 中调用函数 treat_file() 。
treat_file() 中打开文件,调用函数 zip() 。注意这里的 work 的用法,这是一个函数指针。
zip() 中输出 gzip 文件格式的头,调用 bi_init , ct_init , lm_init ,
其中在 lm_init 中将 head 初始化清 0 。初始化 strstart 为 0 。从文件中读入 64KB 的内容到 window 缓冲区中。
由于计算 strstart=0 时的 ins_h ,需要 0,1,2 这三个字节和哈希函数发生关系,所以在 lm_init 中,预读 0,1 两个字节,并和哈希函数发生关系。
然后 lm_init 调用 deflate() 。
deflate() gzip 的 LZ77 的实现主要 deflate() 中。
二.LZ77算法
1977年,Jacob Ziv和Abraham Lempel描述了一种基于滑动窗口缓存的技术,该缓存用于保存最近刚刚处理的文本(J. Ziv and A. Lempel, “A Universal Algorithm for Sequential Data Compression”, IEEE Transaction on Information Theory, May 1977)。这个算法一般称为IZ77。
LZ77和它的变体发现,在正文流中词汇和短语(GIF中的图像模式)很可能会出现重复。当出现一个重复时,重复的序列可以用一个短的编码来代替。压缩程序扫描这样的重复,同时生成编码来代替重复序列。随着时间的过去,编码可以重用来捕获新的序列。算法必须设计成解压程序能够在编码和原始数据序列推导出当前的映射。
在研究LZ77的细节之前,先看一个简单的例子(J. Weiss and D. Schremp, “Putting Data on a Diet”, IEEE Spectrum, August 1993)。考虑这样一句话:
the brown fox jumped over the brown foxy jumping frog
这个短语的长度总共是53个八位组 = 424 bit。算法从左向右处理这个文本。初始时,每个字符被映射成9 bit的编码,二进制的1跟着该字符的8 bit ASCII码。在处理进行时,算法查找重复的序列。当碰到一个重复时,算法继续扫描直到该重复序列终止。换句话说,每次出现一个重复时,算法包括尽可能多的字符。碰到的第一个这样的序列是the brown fox。这个序列被替换成指向前一个序列的指针和序列的长度。在这种情况下,前一个序列的the brown fox出现在26个字符之前,序列的长度是13个字符。对于这个例子,假定存在两种编码选项:8 bit的指针和4 bit的长度,或者12 bit的指针和6 bit的长度。使用2 bit的首部来指示选择了哪种选项,00表示第一种选项,01表示第二种选项。因此,the brown fox的第二次出现被编码为 <00b><26d><13 d >,或者00 00011010 1101。
压缩报文的剩余部分是字母y;序列<00b><27d><5 d >替换了由一个空格跟着jump组成的序列,以及字符序列ing frog。
图03-05-3演示了压缩映射的过程。压缩过的报文由35个9 bit字符和两个编码组成,总长度为35 x 9 + 2 x 14 = 343比特。和原来未压缩的长度为424比特的报文相比,压缩比为1.24。
图03-05-3 LZ77模式例
(一)压缩算法说明
LZ77(及其变体)的压缩算法使用了两个缓存。滑动历史缓存包含了前面处理过的N个源字符,前向缓存包含了将要处理的下面L个字符(图03-05-4(a))。算法尝试将前向缓存开始的两个或多个字符与滑动历史缓存中的字符串相匹配。如果没有发现匹配,前向缓存的第一个字符作为9 bit的字符输出并且移入滑动窗口,滑动窗口中最久的字符被移出。如果找到匹配,算法继续扫描以找出最长的匹配。然后匹配字符串作为三元组输出(指示标记、指针和长度)。对于K个字符的字符串,滑动窗口中最久的K个字符被移出,并且被编码的K个字符被移入窗口。
图03-05-4(b)显示了这种模式对于我们的例子的运行情况。这里假定了39个字符的滑动窗口和13个字符的前向缓存。在这个例子的上半部分,已经处理了前面的40个字符,滑动窗口中是未压缩的最近的39个字符。剩下的源字符串在前向窗口中。压缩算法确定了下一个匹配,从前向窗口将5个字符移入到滑动窗口中,并且输出了这个匹配字符串的编码。经过这些操作的缓存的状态显示在这个例子的下半部分。
(a)通用结构
(b)例子
图03-05-4 LZ77模式
尽管LZ77是有效的,对于当前的输入情况也是合适的,但是存在一些不足。算法使用了有限的窗口在以前的文本中查找匹配,对于相对于窗口大小来说非常长的文本块,很多可能的匹配就会被丢掉。窗口大小可以增加,但这会带来两个损失:(1)算法的处理时间会增加,因为它必须为滑动窗口的每个位置进行一次与前向缓存的字符串匹配的工作;(2)<指针>字段必须更长,以允许更长的跳转。
(二)压缩算法描述
为了更好地说明LZ77算法的原理,首先介绍算法中用到的几个术语:
输入数据流(input stream):待压缩处理的字符序列。 | |
字符(character):输入数据流中的基本单元。 | |
编码位置(coding position):输入数据流中当前要编码的字符位置,指前向缓冲器中的开始字符。 | |
前向缓冲器(lookahead buffer):存放从编码位置到输入数据流结束的字符序列的存储器。 | |
窗口(Window):指包含W个字符的窗口,字符是从编码位置开始向后数也就是最后处理的字符数。 | |
指针(Pointer):指向窗口中的匹配串且含长度的指针。 |
LZ77编码算法的核心是查找从前向缓冲器开始的最长的匹配串。算法的具体执行步骤如下:
把编码位置设置到输入数据流的开始位置。 | |
查找窗口中最长的匹配串。 | |
输出(Pointer, Length) Characters,其中Pointer是指向窗口中匹配串的指针,Length表示匹配字符的长度,Characters是前向缓冲器中的第1个不匹配的字符。 | |
如果前向缓冲器不是空的,则把编码位置和窗口向前移Length+1个字符,然后返回到步骤2。 |
例:待编码的数据流如表03-05-1所示,编码过程如表03-05-2所示。现作如下说明:
“步骤”栏表示编码步骤。 | |
“位置”栏表示编码位置,输入数据流中的第1个字符为编码位置1。 | |
“匹配”栏表示窗口中找到的最长的匹配串。 | |
“字符”栏表示匹配之后在前向缓冲存储器中的第1个字符。 | |
“输出”栏以(Back_chars, Chars_length) Explicit_character格式输出。其中(Back_chars, Chars_length)是指指向匹配串的指针,告诉译码器“在这个窗口中向后退Back_chars个字符然后拷贝Chars_length个字符到输出”,Explicit_character是真实字符。例如,表3-13中的输出“(5,2) C”告诉译码器回退5个字符,然后拷贝2个字符“AB” |
表03-05-1 待编码的数据流
位置 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
字符 | A | A | B | C | B | B | A | B | C |
表03-05-2 编码过程
步骤 | 位置 | 匹配串 | 字符 | 输出 |
1 | 1 | -- | A | (0,0) A |
2 | 2 | A | B | (1,1) B |
3 | 4 | -- | C | (0,0) C |
4 | 5 | B | B | (2,1) B |
5 | 7 | A B | C | (5,2) C |
(三)解压算法
对于LZ77压缩文本的解压很简单。解压算法必须保存解压输出的最后N个字符。当碰到编码字符串时,解压算法使用<指针>,和<长度>,字段将编码替换成实际的正文字符串。