gzip,zlib,以及图形格式png,使用的是同一个压缩算法deflate。我们通过对gzip源码的分析来对deflate压缩算法做一个详细的说明。我阅读的gzip版本为 gzip-1.2.4。我们对算法做三种程度的说明。第一种程度,对gzip所使用压缩算法基本原理的说明。第二种程度,对gzip压缩算法实现方法的说明。第三种程度,对gzip实现源码级的说明。
如果你有时间的话,我建议你先不要看下面的内容,自己尝试通过读
gzip
源码,来了解它的压缩解压缩是如何实现的,这将会是一个非常有趣的智力游戏,千万不要错过。当一个又一个的谜被解开时,那感觉就像唐伯虎同志所说的,
“
慷慨然诺杯酒中
”
。(小唐的诗,除了另一个倒霉蛋曹雪芹外,好像不太被人提。)
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个字符。当碰到编码字符串时,解压算法使用<指针>,和<长度>,字段将编码替换成实际的正文字符串。