GZIP压缩简介
gzip是若干种文件压缩程序的简称,通常指GNU计划的实现,此处的gzip代表GNU zip。也经常用来表示gzip这种文件格式。软件的作者是Jean-loup Gailly和Mark Adler。在1992年10月31日第一次公开发布,版本号0.1,1993年2月,发布了1.0版本。
gzip的基础是DEFLATE,DEFLATE是LZ77与哈夫曼编码的一个组合体。 gzip 对于要压缩的文件,首先使用LZ77算法的一个变种进行压缩,对得到的结果再使用Huffman编码的方法(实际上gzip根据情况,选择使用静态Huffman编码或者动态Huffman编码,本文中只介绍静态编码)进行压缩。所以明白了LZ77算法和Huffman编码的压缩原理,也就明白了gzip的压缩原理。我们来对LZ77算法和Huffman编码做一个简单介绍。
基于Hhuffman树的压缩算法
原理
这个算法的原理就是对字符进行再编码。 我们把文件中一定位长的值看作是符号,比如把8位长的256的值,也就是字节的256种值看作是符号。我们根据这些符号在文件中出现的频率,对这些符号重新编码。对于出现次数非常多的,我们用较少的位来表示,对于出现次数非常少的,我们用较多的位来表示。这样一来,文件的一些部分位数变少了,一些部分位数变多了,由于变小的部分比变大的部分多,所以整个文件的大小还是会减小,所以文件得到了压缩。
实现过程
知道了原理,那么实现的过程也就清楚了,我们需要做的就是将思路捋清,然后一步一步的实现:
整体思路
压缩:
- 先读取整个文件,得到每个字符的出现次数
- 根据出现的次数,建立huffman树
- 根据huffman树得到huffman编码
- 重新读取文件,用字符对应的huffman编码替换字符,输出到压缩文件中
- 由于还需要进行解压缩,因此,应该将对应的字符信息、原文件后缀等也写入压缩文件中,这里是将这些信息当作头信息,写入压缩文件的开始。
解压缩:
- 读取压缩文件头部信息,获取字符信息,还原huffman树
- 开始读取数据,根据huffman树,将huffman编码还原为字符
详细实现
压缩
- 统计字符次数:自定义结构体类型
CharInfo
用来保存数据信息,结构体包含三个成员变量:字符
字符出现次数
huffman编码
,创建一个类型为CharInfo
,大小为256
的结构体数组数组。字符的ASCII码对应数组的下标。从文件中读取数据,通过对读取数据的遍历,统计每个字符出现的次数,存储在数组中。这里使用unsigned char
这里存储读取数据的数组类型也需要定义为unsigned char
,因为涉及到汉字的文件,ASCII编码可能为负值,如果不加以处理,访问数组可能会出错。 - 获取huffman编码:这里首先要创建huffman树,
节点的权值为字符出现的次数
。由于huffman树的创建,每次都需要从huffman森林中取出两个权值最小的树组成新的树,因此这里考虑使用堆来获取两个最小值。在STL中priority_queue
的底层实现为堆,这里我们要创建小堆。priority_queue
默认的比较函数为less
创建的是大堆
。因此,我们需要自行传入比较器,这里可以使用仿函数的方式传入。创建好小堆之后,将有效节点插入堆中(有效节点是指权值大于0的节点)。根据huffman树的创建方法创建huffman树,并获取huffman编码。 - 生成压缩文件:根据原文件名,创建压缩文件,将原文件的
后缀
,字符信息
先写入压缩文件,再读取原文件,将原文件中的每个字符用huffman编码替换。
解压缩
- 获取头部信息: 从压缩文件中读取头部信息,获取原文件
后缀
字符信息
。 - 还原huffman树: 根据获取到的字符信息重新创建huffman树,过程同压缩。
- 还原文件 :
a. 根据压缩文件名, 及读取到的原文件后缀,创建还原文件。
b. 创建指针,指向huffman树root位置。
c. 读取压缩文件数据,将读取到的字符右移与一获取每个bit位信息,如果是0
向左走,如果是1
向右走,遍历huffman树,直得到对应字符,存入还原文件中。如果遍历完当前字符的8
个bit位,还未走到叶子节点,回到步骤b
d. 回到步骤b
一直循环,直到所有字符都还原完。
注意事项
- 首先说说缺点,很明显,因为我们是用huffman编码替换的字符,而每个字符又是8位,因此,如果文件太大,导致字符种类数和出现次数过多,就可能导致huffman编码大于8位,反而起不到压缩的效果。
- 在读取字符信息时,因为我们使用字符的ASCII码当作数组的下标,因此应该注意,汉字的ASCII码可能为负数,应该处理。
- 在压缩文件时,可能最后一次写完bit位,不足8位,应该注意在最后,将不足的几位补为
0
,即左移补0
。- 与上面对应,因为将最后一次可能不够的位进行了补
0
,因此,需要主要在解压缩的时候,注意字符的个数,当还原的字符个数足够是,即应该停止还原。
基于LZ77的压缩算法
原理
LZ77算法是由 Lempel-Ziv 在1977发明的,也是GBA内置的压缩算法。它是一种基于字典的、“滑动窗”的无损压缩算法,广泛应用于通信、计算机文件存档等方面。
LZ77算法通过使用编码器或者解码器中已经出现过的相应匹配数据信息替换当前数据从而实现压缩功能。这个匹配信息使用称为长度-距离对的一对数据进行编码,它等同于“每个给定长度个字符都等于后面特定距离字符位置上的未压缩数据流。”(“距离”有时也称作“偏移”。)
当压缩开始时,每当读取到一个新的字符,就往前查找,如果找到了,就用距离键值对替换即可。
实现过程
整体思路
压缩
- 我们从文件中读取数据,并将其存储在一段连续的空间内,空间也被称为窗口,空间的大小一般为32K(不固定)。
- 挨个遍历这片空间,每当遇见一个字符,我们都向前查找一段距离。如果在这个字符的前面,找到和它相同的字符,那么,开始向后匹配,看从当前位置开始能匹配到多少相同的字符,如果匹配到的字符数大于<距离,长度>对所占的空间,就说明能够压缩数据。
- 从找到的位置再往前找,看是否有相同的字符,如果有,那么重复2 ,3.一直到找不多,或者超出查找的范围为止。这样,就能得到一个最长匹配串,用对应的<距离,长度>对,替换字符,即可达到压缩的目的。
- 由于原数据和压缩数据是混合存入压缩文件的,因此,为了方便解压缩,应该再存入一段数据,进行标记,哪个字节是原数据,哪个字节是距离长度对。
解压缩
- 先从压缩文件中读取关于原文件的相关信息。
- 读取压缩文件的数据部分,同时,读取标记部分,如果标记是原数据,就直接写入,如果是<距离,长度>对,就往前找,进行匹配,然后向解压文件中写入。
- 重复2 直到解压完成。
具体实现
压缩
- 向前查找相同字符:从原文件中读取数据,保存每个字符的位置信息,方便后面遇到的字符查询是否出现过。这里,因为要多次查找,判断某个字符是否在前面出现过,因此,这里使用
hash表
来存储数据的位置信息。这里用开散列的方式,不过如果使用链表的方式,需要不断地申请与释放空间,因此这里的开散列是用数组实现的。我们定义的hash表
的大小为WSIZE
* 2。并且用两个指针维护,一个称为_prev
另一个为_head
,其中_head
才是真正的hash表
,_prev
是模仿实现的开散列哈希桶,_head
指向的空间存储的是上次字符出现的时候在窗口中的下标,因为表示的是下标,因此初始值为-1
,_prev
指向的空间存储的是更早之前此字符出现过的位置。当需要插入时,我们先将_head
中的值,放到以本次插入值为下标的_prev
指向的空间,再将本次插入的值存入对应的_head
指向的空间即可。这样就可以通过_head
中的数据,得到一个链,这个链中的数据就是对应字符在之前出现过的位置。我们用图来解释:
通过这样的操作,只要我们通过hash地址
访问hash表
即可在_head
管理的空间中得到一个头
数据,通过这个头数据即可访问到一整条的之前出现下标的数据。这样就可以进行匹配替换。
-
得到最长匹配串:我们通过hash地址可以获得该字符之前出现的下标链,同时,该下标链的结
尾值为-1
。这样我们就可以对每个下标都进行向后匹配,比较谁和当前字符匹配到的相同字符最多,获取到最长的那个,就可以返回,进行替换了。
这里有个新的问题,那就是,最多能向前查找多少、最多能匹配多少个字符、以及匹配多少字符后,才进行压缩?
我们对这个问题进行分析:我们先分析距离长度对应该设计为多少个字节:
首先,它起码需要占两个字节,因为需要一个字符表示距离,一个字符表示匹配的长度。但是如果是两个字节的话,我们之前说过,建议的读取文件的窗口大小为32K
,也就是2 ^15
,如果我们只用一个字节来表示向前查找的距离,那么它最多可查找距离为255
,也就是2^8 - 1
,有一半的数据放在查找缓冲区中,没有进行查找,这就造成了浪费。因此,我们在这里将距离用两个字节表示,那么,距离长度对下来就有3个字节
的大小。确定了这个之后,我们在进行向前查找的时候,就需要进行判断,如果正在匹配的下标小于当前字符下标减去最大距离,那么就应该直接返回,因为如果再大,距离长度对就不能表示该距离了,这样就可能造成文件损坏。
我们再看至少需要匹配多少个字符后,才能进行压缩,我们距离长度对为3个字节
,如果匹配个数少于3,那用三个字节的距离长度对替换之后,反而会变大。因此,我们至少要匹配到3个字节
才能进行替换。同时,因为我们用一个字节来表示了匹配的字符数,因此,字符数最多可匹配255
个。 -
替换后进行写入 :这里只需要进行最基本的文件写入即可:如果返回的最长匹配串满足要求,那么我们就用距离长度对替换字符,将其写入到压缩文件中。
这里也会出现一个新的问题:我们将替换后的距离长度对写入到了压缩文件中,那么当我们进行解压缩的时候,我们怎么区别原数据与距离长度对呢?
这时候,我们就需要一个新的东西来帮助我们,那就是标记,我们可以设计为,如果当前写入的数据为原数据,我们就写入一个0
, 如果是距离长度对我们就将其置为1
,这里需要注意的是,一个标记1
对应的是三个字节。即,每遇到一个1
我们都需要向压缩文件中写入3
个字节的数据。至于标记的保存,因为标记只有0
和1
, 因此,我们可以参考huffman中的实现,用bit位表示。每8次,
这些标记信息,我们可以暂时写到一个临时文件中,等到文件压缩完成后,再将它存入压缩文件。 -
写入标记信息:这里进行的就是将临时文件中的标记信息,添加到压缩文件的末尾。同时,为了保证能读取到准确的标记信息,不影响压缩文件,我们应该在最后面再写入标记的大小,保证当进行标记的读取的时候,不会影响其他数据。在标记文件大小的后面我们也可以写入原文件的大小,这样更有利于我们。
-
写入头部信息:头部信息包含原文件的后缀等信息,在压缩文件的一开始就写入。等到解压缩时,直接获取到,就知道了原文件后缀。
-
滑动窗口:之前我们说的几点,都是针对小文件的压缩,比如:如果文件小于
32K
那么只需要一次将待压缩数据全部读入窗口,然后进行压缩即可。但是如果压缩的数据过大呢?就有可能出现下面的情况:
当我们匹配到第二个1
的时候,本来还可以匹配7
个。但是由于窗口中的元素不足,从而无法匹配,为此,我们需要对算法进行升级,让它可以满足大文件的压缩。
首先,我们将窗口的大小变为WSIZE * 2
,当前正压缩数据下标为_start
,未压缩数据为LOOKAHEAD
,最大匹配长度为MAXMATCH = 255
,最少未压缩数据MINLOOKAHEAD = MAXMATCH + 1
, 最远查找距离为MAXDISTANT = WSIZE - MINLOOKAHEAD
。这样,最大匹配长度和最远查找距离的和就为一个窗口的大小。最少未压缩数据比最大匹配长度多1
是为了保证,当本次匹配进行完成后,在窗口中至少还剩下一个字符待匹配,不会越界。
我们用图示说明它们之间的关系。
理顺了它们关系之后,我们就可以这样来理解:当开始压缩时,首先读取WSIZE * 2 的数据,然后开始从头开始向后压缩,每遇到一个字符开始获取最长匹配串,它最多能向前查找WSIZE - MIN_LOOKAHEAD
的距离,也就是说,在它之前出现的相同字符的下标,必须大于_START - (WSIZE - MIN_LOOKAHEAD)
。当它找到一个下标后,开始向后匹配,最多能匹配MAXMATCH
位,也就是255
位,同时,如果本次匹配成功了,下次的开始位置就是_start + 匹配长度
,如果我们都用边界值来测试的话,极限情况下,它活动的范围就是一个窗口的大小也就是WSIZE
,这与我们之前的没有区别。
下面我们来看不同之处,我们在这里需要考虑到匹配的最好情况,即匹配了255
位,同时还要匹配完成后剩下1
位。也就是完成一次最优的匹配需要至少剩下266位
(当然如果是数据真正只剩下不足256位
就不考虑下面的问题)。当剩余数据小于266
,即LOOKAHEAD < MINLOOKAHEAD
时,它就不能达到可能的最优匹配,我们这个时候,就需要对窗口移动。因为之前说了,对于每个字符来说,它的活动范围不会超过WSIZE
因此,在_start - MAXDISTAN
之前的数据都已经没有用了。我们这个时候,就可以考虑,将数据从_start - MAXDISTAN
处开始往后移动到窗口的开始,然后重新补充数据。
总结来说就是:我们的窗口包含两个WSIZE,如果此刻正在压缩第二个WSIZE中的数据,并且剩余的数据不够达到最大匹配长度,也就是LOOKAHEAD < MINLOOKAHEAD ,那么就将第二个WSIZE中的数据挪入第一个WSIZE,再给第二个WSIZE中重新读入数据。最后更新_start 、LOOKAHEAD的值。
这样,就能保证,如果数据足够,那么一定可以满足最大的匹配长度。如果数据挪入前面的窗口中后,还是不够达到最大匹配长度,那就说明数据不足了,就不进行挪数据。这就是模拟出来的滑动窗口。
我们可以画图表示:
-
更新hash表:滑动窗口补充完数据之后,应该对hash进行更新,因为hash表里面的值与字符在窗口中的下标有关,因此应该对它们的值进行更新:如果值大于WSIZE 说明要被挪到第一个WSIZE,因此应该减去WSIZE。如果本来就小于WSIZE,说明是被覆盖的数据,直接变为
-1
即可。
解压缩
- 读取头部信息:即获取原文件的后缀,因为在压缩的时候,直接保存在了第一行,因此,直接使用getline() 获取值即可。
- 读取标记信息:在压缩的时候,我们将标记的大小写在了压缩文件的最后,同时,在标记信息的后面,还多了两个数据,一个是标记大小,一个是数据大小。我们只需要知道它们的类型,就可以从里面将它们读取出来,并根据标记的大小,设置文件指针偏移,指向标记信息。
- 根据标记信息,读取压缩文件数据部分,并根据标记的含义向解压缩文件中写入相应的数据:读取一份标记信息,判断是原数据还是距离长度对,如果是原数据,那么直接写入,如果是距离长度对,那么从解压文件的末尾向前偏移获取的距离大小的偏移量,再向后匹配长度个数的数据,就可以完成还原。一直循环,直到解压缩完成。
注意事项
- 解压缩读取最后部分的标记大小、文件大小时的类型需要和压缩时的相同。
- 在存入hash表时,因为加入了滑动窗口机制,因此,下标可能会超过prev的返回,因此在给_prev存储单元里赋值时应该给
下标&WSIZE - 1
保证下标不越界。同时,这样造成的后果就是可能会导致返回的下标链出现错误,比如返回其它字符的下标,或者可能造成跳转错误,因此,应该控制查找的次数,我这里给的是,最多查找255次。- 当匹配到字符,将距离长度对写入文件中时,应注意将这些匹配的字符应该也存入hash表,以供后面的字符查找。不能因为它们被替换就不写入hash表。
源码地址
最后附上实现代码:Mmmmmmi
以上即是本片所有内容,不足之处还望指正!