精华内容
下载资源
问答
  • 文件压缩算法

    千次阅读 2016-09-08 10:19:43
    gzip 、zlib以及图形格式png,使用的压缩算法都是deflate算法。从gzip的源码中,我们了解到了defalte算法的原理和实现。我阅读的gzip版本为 gzip-1.2.4。下面我们将要对deflate算法做一个分析和说明。首先简单介绍...

    gzip 、zlib以及图形格式png,使用的压缩算法都是deflate算法。从gzip的源码中,我们了解到了defalte算法的原理和实现。我阅读的gzip版本为 gzip-1.2.4。下面我们将要对deflate算法做一个分析和说明。首先简单介绍一下基本原理,然后详细的介绍实现。

    1 gzip 所使用压缩算法的基本原理

    gzip 对于要压缩的文件,首先使用LZ77算法的一个变种进行压缩,对得到的结果再使用Huffman编码的方法(实际上gzip根据情况,选择使用静态Huffman编码或者动态Huffman编码,详细内容在实现中说明)进行压缩。所以明白了LZ77算法和Huffman编码的压缩原理,也就明白了gzip的压缩原理。我们来对LZ77算法和Huffman编码做一个简单介绍。

    1.1 LZ77算法简介

    这一算法是由Jacob Ziv 和 Abraham Lempel 于 1977 年提出,所以命名为 LZ77。

    1.1.1 LZ77算法的压缩原理

    如果文件中有两块内容相同的话,那么只要知道前一块的位置和大小,我们就可以确定后一块的内容。所以我们可以用(两者之间的距离,相同内容的长度)这样一对信息,来替换后一块内容。由于(两者之间的距离,相同内容的长度)这一对信息的大小,小于被替换内容的大小,所以文件得到了压缩。

    下面我们来举一个例子。

    有一个文件的内容如下
    http://jiurl.yeah.net http://jiurl.nease.net

    其中有些部分的内容,前面已经出现过了,下面用()括起来的部分就是相同的部分。
    http://jiurl.yeah.net (http://jiurl.)nease(.net)

    我们使用 (两者之间的距离,相同内容的长度) 这样一对信息,来替换后一块内容。
    http://jiurl.yeah.net (22,13)nease(23,4)

    (22,13)中,22为相同内容块与当前位置之间的距离,13为相同内容的长度。
    (23,4)中,23为相同内容块与当前位置之间的距离,4为相同内容的长度。
    由于(两者之间的距离,相同内容的长度)这一对信息的大小,小于被替换内容的大小,所以文件得到了压缩。

    1.1.2 LZ77使用滑动窗口寻找匹配串

    LZ77算法使用"滑动窗口"的方法,来寻找文件中的相同部分,也就是匹配串。我们先对这里的串做一个说明,它是指一个任意字节的序列,而不仅仅是可以在文本文件中显示出来的那些字节的序列。这里的串强调的是它在文件中的位置,它的长度随着匹配的情况而变化。

    LZ77从文件的开始处开始,一个字节一个字节的向后进行处理。一个固定大小的窗口(在当前处理字节之前,并且紧挨着当前处理字节),随着处理的字节不断的向后滑动,就象在阳光下,飞机的影子滑过大地一样。对于文件中的每个字节,用当前处理字节开始的串,和窗口中的每个串进行匹配,寻找最长的匹配串。窗口中的每个串指,窗口中每个字节开始的串。如果当前处理字节开始的串在窗口中有匹配串,就用(之间的距离,匹配长度) 这样一对信息,来替换当前串,然后从刚才处理完的串之后的下一个字节,继续处理。如果当前处理字节开始的串在窗口中没有匹配串,就不做改动的输出当前处理字节。

    处理文件中第一个字节的时候,窗口在当前处理字节之前,也就是还没有滑到文件上,这时窗口中没有任何内容,被处理的字节就会不做改动的输出。随着处理的不断向后,窗口越来越多的滑入文件,最后整个窗口滑入文件,然后整个窗口在文件上向后滑动,直到整个文件结束。

    1.1.3 使用LZ77算法进行压缩和解压缩

    为了在解压缩时,可以区分“没有匹配的字节”和“(之间的距离,匹配长度)对”,我们还需要在每个“没有匹配的字节”或者“(之间的距离,匹配长度)对”之前,放上一位,来指明是“没有匹配的字节”,还是“(之间的距离,匹配长度)对”。我们用0表示“没有匹配的字节”,用1表示“(之间的距离,匹配长度)对”。

    实际中,我们将固定(之间的距离,匹配长度)对中的,“之间的距离”和“匹配长度”所使用的位数。由于我们要固定“之间的距离”所使用的位数,所以我们才使用了固定大小的窗口,比如窗口的大小为32KB,那么用15位(2^15=32K)就可以保存0-32K范围的任何一个值。实际中,我们还将限定最大的匹配长度,这样一来,“匹配长度”所使用的位数也就固定了。

    实际中,我们还将设定一个最小匹配长度,只有当两个串的匹配长度大于最小匹配长度时,我们才认为是一个匹配。我们举一个例子来说明这样做的原因。比如,“距离”使用15位,“长度”使用8位,那么“(之间的距离,匹配长度)对”将使用23位,也就是差1位3个字节。如果匹配长度小于3个字节的话,那么用“(之间的距离,匹配长度)对”进行替换的话,不但没有压缩,反而会增大,所以需要一个最小匹配长度。

    压缩:
    从文件的开始到文件结束,一个字节一个字节的向后进行处理。用当前处理字节开始的串,和滑动窗口中的每个串进行匹配,寻找最长的匹配串。如果当前处理字节开始的串在窗口中有匹配串,就先输出一个标志位,表明下面是一个(之间的距离,匹配长度) 对,然后输出(之间的距离,匹配长度) 对,然后从刚才处理完的串之后的下一个字节,继续处理。如果当前处理字节开始的串在窗口中没有匹配串,就先输出一个标志位,表明下面是一个没有改动的字节,然后不做改动的输出当前处理字节,然后继续处理当前处理字节的下一个字节。

    解压缩:
    从文件开始到文件结束,每次先读一位标志位,通过这个标志位来判断下面是一个(之间的距离,匹配长度) 对,还是一个没有改动的字节。如果是一个(之间的距离,匹配长度)对,就读出固定位数的(之间的距离,匹配长度)对,然后根据对中的信息,将匹配串输出到当前位置。如果是一个没有改动的字节,就读出一个字节,然后输出这个字节。

    我们可以看到,LZ77压缩时需要做大量的匹配工作,而解压缩时需要做的工作很少,也就是说解压缩相对于压缩将快的多。这对于需要进行一次压缩,多次解压缩的情况,是一个巨大的优点。


    1.2 Huffman编码简介
    1.2.1 Huffman编码的压缩原理

    我们把文件中一定位长的值看作是符号,比如把8位长的256种值,也就是字节的256种值看作是符号。我们根据这些符号在文件中出现的频率,对这些符号重新编码。对于出现次数非常多的,我们用较少的位来表示,对于出现次数非常少的,我们用较多的位来表示。这样一来,文件的一些部分位数变少了,一些部分位数变多了,由于变小的部分比变大的部分多,所以整个文件的大小还是会减小,所以文件得到了压缩。

    1.2.2 Huffman编码使用Huffman树来产生编码

    要进行Huffman编码,首先要把整个文件读一遍,在读的过程中,统计每个符号(我们把字节的256种值看作是256种符号)的出现次数。然后根据符号的出现次数,建立Huffman树,通过Huffman树得到每个符号的新的编码。对于文件中出现次数较多的符号,它的Huffman编码的位数比较少。对于文件中出现次数较少的符号,它的Huffman编码的位数比较多。然后把文件中的每个字节替换成他们新的编码。

    建立Huffman树:
    把所有符号看成是一个结点,并且该结点的值为它的出现次数。进一步把这些结点看成是只有一个结点的树。

    每次从所有树中找出值最小的两个树,为这两个树建立一个父结点,然后这两个树和它们的父结点组成一个新的树,这个新的树的值为它的两个子树的值的和。如此往复,直到最后所有的树变成了一棵树。我们就得到了一棵Huffman树。

    通过Huffman树得到Huffman编码:

    这棵Huffman树,是一棵二叉树,它的所有叶子结点就是所有的符号,它的中间结点是在产生Huffman树的过程中不断建立的。我们在Huffman树的所有父结点到它的左子结点的路径上标上0,右子结点的路径上标上1。

    现在我们从根节点开始,到所有叶子结点的路径,就是一个0和1的序列。我们用根结点到一个叶子结点路径上的0和1的序列,作为这个叶子结点的Huffman编码。

    我们来看一个例子。

    有一个文件的内容如下
    abbbbccccddde

    我们统计一下各个符号的出现次数,
    a b c d e
    1 4 4 3 1

    建立Huffman树的过程如图:


    通过最终的Huffman树,我们可以得到每个符号的Huffman编码。

    a 为 110
    b 为 00
    c 为 01
    d 为 10
    e 为 111

    我们可以看到,Huffman树的建立方法就保证了,出现次数多的符号,得到的Huffman编码位数少,出现次数少的符号,得到的Huffman编码位数多。

    各个符号的Huffman编码的长度不一,也就是变长编码。对于变长编码,可能会遇到一个问题,就是重新编码的文件中可能会无法如区分这些编码。
    比如,a的编码为000,b的编码为0001,c的编码为1,那么当遇到0001时,就不知道0001代表ac,还是代表b。出现这种问题的原因是a的编码是b的编码的前缀。
    由于Huffman编码为根结点到叶子结点路径上的0和1的序列,而一个叶子结点的路径不可能是另一个叶子结点路径的前缀,所以一个Huffman编码不可能为另一个Huffman编码的前缀,这就保证了Huffman编码是可以区分的。

    1.2.3 使用Huffman编码进行压缩和解压缩

    为了在解压缩的时候,得到压缩时所使用的Huffman树,我们需要在压缩文件中,保存树的信息,也就是保存每个符号的出现次数的信息。

    压缩:

    读文件,统计每个符号的出现次数。根据每个符号的出现次数,建立Huffman树,得到每个符号的Huffman编码。将每个符号的出现次数的信息保存在压缩文件中,将文件中的每个符号替换成它的Huffman编码,并输出。

    解压缩:

    得到保存在压缩文件中的,每个符号的出现次数的信息。根据每个符号的出现次数,建立Huffman树,得到每个符号的Huffman编码。将压缩文件中的每个Huffman编码替换成它对应的符号,并输出。

    2 gzip 所使用压缩算法的实现

    我们将gzip的实现分成很多个部分,一个个来说明,这样做的原因见本文最后一部分。
    gzip 中所使用的各种实现技巧的出处或者灵感,gzip 的作者在源码的注释中进行了说明。

    2.1 寻找匹配串的实现

    为一个串寻找匹配串需要进行大量的匹配工作,而且我们还需要为很多很多个串寻找匹配串。所以 gzip 在寻找匹配串的实现中使用哈希表来提高速度。

    要达到的目标是,对于当前串,我们要在它之前的窗口中,寻找每一个匹配长度达到最小匹配的串,并找出匹配长度最长的串。

    在 gzip 中,最小匹配长度为3,也就是说,两个串,最少要前3个字节相同,才能算作匹配。为什么最小匹配长度为3,将在后面说明。

    gzip 对遇到的每一个串,首先会把它插入到一个“字典”中。这样当以后有和它匹配的串,可以直接从“字典”中查出这个串。

    插入的时候,使用这个插入串的前三个字节,计算出插入的“字典”位置,然后把插入串的开始位置保存在这个“字典”位置中。查出的时候,使用查出串的前三个字节,计算出“字典”位置,由于插入和查出使用的是同一种计算方法,所以如果两个串的前三个字节相同的话,计算出的“字典”位置肯定是相同的,所以就可以直接在该“字典”位置中,取出以前插入时,保存进去的那个串的开始位置。于是查出串,就找到了一个串,而这个串的前三个字节和自己的一样(其实只是有极大的可能是一样的,原因后面说明),所以就找到了一个匹配串。

    如果有多个串,他们的前三个字节都相同,那么他们的“字典”位置,也都是相同的,他们将被链成一条链,放在那个“字典”位置上。所以,如果一个串,查到了一个“字典”位置,也就查到了一个链,所有和它前三个字节相同的串,都在这个链上。

    也就是说,当前串之前的所有匹配串被链在了一个链上,放在某个“字典”位置上。而当前串使用它的前三个字节,进行某种计算,就可以得到这个“字典”位置(得到了“字典”位置之后,它首先也把自己链入到这个链上),也就找到了链有它的所有匹配串的链,所以要找最长的匹配,也就是遍历这个链上的每一个串,看和哪个串的匹配长度最大。

    下面我们更具体的说明,寻找匹配串的实现。

    我们前面所说的“字典”,是一个数组,叫做head[](为什么叫head,后面进行说明)。
    我们前面所说的“字典”位置,放在一个叫做ins_h的变量中。
    我们前面所说的链,是在一个叫做prev[]的数组中。

    插入:

    当前字节为第 strstart 个字节。通过第strstart,strstart+1,strstart+2,这三个字节,使用一个设计好的哈希函数算出ins_h,也就是插入的位置。然后将当前字节的位置,即strstart,保存在head[ins_h]中。
    注意由 strstart,strstart+1,strstart+2,这三个字节(也就是strstart开始处的串的头三个字节,也就是当前字节和之后的两个字节)确定了ins_h。head[ins_h]中保存的又是strstart,也就是这个串开始的位置。

    判断是否有匹配:

    当前串的前三个字节,使用哈希函数算出ins_h,这时如果head[ins_h]的值不为空的话,那么head[ins_h]中的值,便是之前保存在这里的另一个串的位置,并且这个串的前三个字节算出的ins_h,和当前串的前三个字节算出的ins_h相同。也就是说有可能有匹配。如果head[ins_h]的值为空的话,那么肯定没有匹配。

    gzip所使用的哈希函数:

    gzip 所使用的哈希函数,用三个字节来计算一个ins_h,这是由于最小匹配为三个字节。

    对于相同的三个字节,通过哈希函数得到的ins_h必然是相同的。
    而不同的三个字节,通过哈希函数有可能得到同一个ins_h,不过这并不要紧,
    当gzip发现head[ins_h]不空后,也就是说有可能有匹配串的话,会对链上的每一个串进行真正的串的比较。

    所以一个链上的串,只是前三个字节用哈希函数算出的值相同,而并不一定前三个字节都是相同的。但是这样已经很大的缩小了需要进行串比较的范围。

    我们来强调一下,前三个字节相同的串,必然在同一个链上。在同一个链上的,不一定前三个字节都相同。

    不同的三个字节有可能得到同一个结果的原因是,三个字节,一共24位,有2^24种可能值。而三个字节的哈希函数的计算结果为15位,有2^15种可能值。也就是说2^24种值,与2^15种值进行对应,必然是多对一的,也就是说,必然是有多种三个字节的值,用这个哈希函数计算出的值都是相同的。

    而我们使用哈希函数的理由是,实际上,我们只是在一个窗口大小的范围内(后面将会看到)寻找匹配串,一个窗口的大小范围是很有限的,能出现的三个字节的值组合情况也是很有限的,将远远小于2^24,使用合适的哈希函数是高效的。

    前三个字节相同的所有的串所在的链:

    head[ins_h] 中的值,有两个作用。一个作用,是一个前三个字节计算结果为ins_h的串的位置。另一个作用,是一个在prev[]数组中的索引,用这个索引在prev[]中,将找到前一个前三个字节计算结果为ins_h的串的位置。即prev[head[ins_h]]的值(不为空的话)为前一个前三个字节计算结果为ins_h的串的位置。

    prev[]的值,也有两个作用。一个作用,是一个前三个字节计算结果为ins_h的串的位置。另一个作用,是一个在prev[]数组中的索引,用这个索引在prev[]中,将找到前一个前三个字节计算结果为ins_h的串的位子哈。即prev[]的值(不为空的话)为前一个三个字节计算结果为ins_h的串的位置。

    直到prev[]为空,表示链结束。

    我们来举一个例子,串,
    0abcd abce,abcf_abcg

    当处理到abcg的a时,由abcg的abc算出ins_h。
    这时的head[ins_h]中为 11,即串"abcf abcg"的开始位置。
    这时的prev[11]中为 6,即串"abce abcf abcg"的开始位置。
    这时的prev[6]中为 1,即串"abcd abce abcf abcg"的开始位置。
    这时的prev[1]中为 0。表示链结束了。

    我们看到所有头三个字母为abc的串,被链在了一起,从head可以一直找下去,直到找到0。

    链的建立:

    gzip在每次处理当前串的时候,首先用当前串的前三个字节计算出ins_h,然后,就要把当前的串也插入到相应的链中,也就是把当前的串的位置,保存到 head[ins_h] 中,而此时,head[ins_h] 中(不空的话)为前一个串的开始位置。所以这时候需要把前一个串的位置,也就是原来的head[ins_h]放入链中。于是把现在的head[ins_h]的值,用当前串的位置做索引,保存到 prev[] 中。然后再把 head[ins_h]赋值为当前串的位置。

    如果当前串的位置为strstart的话,那么也就是
    prev[strstart] = head[ins_h];
    head[ins_h] = strstart;

    就这样,每次把一个串的位置加入到链中,链就形成了。

    现在我们也就知道了,前三个字节计算得到同一ins_h的所有的串被链在了一起,head[ins_h]为链头,prev[]数组中放着的更早的串的位置。head数组和prev数组的名字,也正反应了他们的作用。

    链的特点:

    越向前(prev)与当前处理位置之间的距离越大。比如,当前处理串,算出了ins_h,而且head[ins_h]中的值不空,那么head[ins_h]就是离当前处理串距离最近的一个可能的匹配串,并且顺着prev[]向前所找到的串,越来距离越远。

    匹配串中的字节开始的串的插入:

    我们说过了,所有字节开始的串,都将被插入“字典”。对于确定了的匹配串,匹配串中的每个字节开始的串,仍要被插入“字典”,以便后面串可以和他们进行匹配。

    注意:

    对于文件中的第0字节,情况很特殊,它开始的串的位置为0。所以第0串的前三个字节计算出ins_h之后,在head[ins_h]中保存的位置为0。而对是否有可能有匹配的判断,就是通过head[ins_h]不为0,并且head[ins_h]的值为一个串的开始位置。所以第0字节开始的串,由于其特殊性,将不会被用来匹配,不过这种情况只会出现在第0个字节,所以通常不会造成影响,即使影响,也会极小。

    例如,文件内容为
    jiurl jiurl

    找到的匹配情况如下,[]所括部分。

    jiurl j[iurl]

    2.2 懒惰啊匹配(lazy match)

    对于当前字节开始的串,寻找到了最长匹配之后,gzip并不立即决定使用这个串进行替换。而是看看这个匹配长度是否满意,如果匹配长度不满意,而下一个字节开始的串也有匹配串的话,那么gzip就找到下一个字节开始的串的最长匹配,看看是不是比现在这个长。这叫懒惰啊匹配。如果比现在这个长的话,将不使用现在的这个匹配。如果比现在这个短的话,将确定使用现在的这个匹配。

    我们来举个例子,串

    0abc bcde abcde

    处理到第10字节时,也就是"abcde"的a时,找到最长匹配的情况如下,[]所括部分。

    0abc bcde [abc]de

    这时,再看看下一个字节,也就是第11字节的情况,也就是'abcde"的b,找到最长匹配的情况如下,[]所括部分。

    0abc bcde a[bcde]

    发现第二次匹配的匹配长度大,就不使用第一次的匹配串。我们也看到了如果使用第一次匹配的话,将错过更长的匹配串。

    在满足懒惰啊匹配的前提条件下,懒惰啊匹配不限制次数,一次懒惰啊匹配发现了更长的匹配串之后,仍会再进行懒惰啊匹配,如果这次懒匹配,发现了更长的匹配串,那么上一次的懒匹配找到的匹配串就不用了。

    进行懒惰啊匹配是有条件的。进行懒惰啊匹配必须满足两个条件,第一,下一个处理字节开始的串,要有匹配串,如果下一个处理字节开始的串没有匹配串的话,那么就确定使用当前的匹配串,不进行懒匹配。第二,当前匹配串的匹配长度,gzip不满意,也就是当前匹配长度小于max_lazy_match(max_lazy_match在固定的压缩级别下,有固定的值)。

    讨论:

    我们可以看到了做另外一次尝试的原因。如果当前串有匹配就使用了的话,可能错过更长匹配的机会。使用懒惰啊匹配会有所改善。
    不过从我简单的分析来看,使用懒惰啊匹配对压缩率的改善似乎是非常有限的。
    2.3 大于64KB的文件,窗口的实现

    窗口的实现:

    实际中,当前串(当前处理字节开始的串)只是在它之前的窗口中寻找匹配串的,也就是说只是在它之前的一定大小的范围内寻找匹配串的。有这个限制的原因,将在后面说明。

    gzip 的窗口大小为 WSIZE,32KB。

    内存中有一个叫window[]的缓冲区,大小为2个窗口的大小,也就是64KB。文件的内容将被读到这个window[]中,我们在window[]上进行LZ77部分的处理,得到结果将放在其他缓冲区中。

    gzip 对window[]中的内容,从开始处开始,一个字节一个字节的向后处理。有一个指针叫strstart(其实是个索引),指向当前处理字节,当当前处理字节开始的串没有匹配时,不做改动的输出当前处理字节,strstart向后移动一个字节。当当前处理字节开始的串找到了匹配时,输出(匹配长度,相隔距离)对,strstart向后移动匹配长度个字节。我们把strstart到window[]结束的这部分内容,叫做lookahead buffer,超前查看缓冲区。这样叫的原因是,在我们处理当前字节的时候,就需要读出之后的字节来进行串的匹配。在一个变量lookahead中,保存着超前查看缓冲区所剩的字节数。lookahead,最开始被初始化为整个读入内容的大小,随着处理的进行,strstart不断后移,超前查看缓冲区不断减小,lookahead的值也不断的减小。

    我们需要限制查找匹配串的范围为一个窗口的大小(这么做的原因后面说明),也就是说,只能在当前处理字节之前的32KB的范围内寻找匹配串。而,由于处理是在2个窗口大小,也就是64KB大小的缓冲区中进行的,所以匹配链上的串与当前串之间的距离是很有可能超过32KB的。那么gzip是如何来实现这个限制的呢?

    gzip 通过匹配时的判断条件来实现这个限制。当当前串计算ins_h,发现head[ins_h]值不为空时(head[ins_h]为一个串的开始位置),说明当前串有可能有匹配串,把这个值保存在 hash_head中。这时就要做一个限制范围的判断,strstart - hash_head <= 窗口大小,strstart-hash_head 是当前串和最近的匹配串之间的距离,(注意前面说过,链头和当前串的距离最近,越向前(prev)与当前处理位置之间的距离越大),也就是说要判断当前串和距离最近的匹配串之间的距离是否在一个窗口的范围之内。如果不是的话,那么链上的其他串肯定更远,肯定更不在一个窗口的范围之内,就不进行匹配处理了。如果是在一个窗口的范围之内的话,还需要在链上寻找最长的匹配串,在和每个串进行比较的时候,也需要判断当前串和该串的距离是否超过一个窗口的范围,超过的话,就不能进行匹配。

    实际中,gzip为了使代码简单点,距离限制要比一个窗口的大小还要小一点。

    小于64KB的文件:

    初始化的时候,会首先从文件中读64KB的内容到window[]中。对于小于64KB的文件,整个文件都被读入到window[]中。在window[]上进行LZ77的处理,从开始直到文件结束。

    大于64KB的文件:

    每处理一个字节都要判断 lookahead < MIN_LOOKAHEAD ,也就是window中还没有处理的字节是否还够MIN_LOOKAHEAD ,如果不够的话,就会导致 fill_window(),从文件中读内容到window[]中。由于我们一次最大可能使用的超前查看缓冲区的大小为,最大匹配长度(258个字节,后面进行说明)加上最小匹配长度,也就是下一个处理字节开始的串,可以找到一个最大匹配长度的匹配,发生匹配之后,还要预读一个最小匹配长度来计算之后的ins_h。

    不管是大于64KB的文件,还是小于64KB的文件,随着处理的进行,最终都要到文件的结束,在接近文件结束的时候,都会出现 lookahead < MIN_LOOKAHEAD ,对于这种情况,fill_window() 读文件,就再读不出文件内容了,于是fill_window()会设置一个标志eofile,表示文件就要结束了,之后肯定会接着遇到 lookahead < MIN_LOOKAHEAD ,不过由于设置了 eofile 标志,就不会再去试图读文件到window[]中了。
    压缩开始之前的初始化,会从文件中读入64KB的内容到window[]中,窗口大小为32KB,也就是读入2窗的内容到window[]中。我们把第一窗的内容叫做w1_32k,第二窗的内容叫做w2_32k。

    压缩不断进行,直到 lookahead < MIN_LOOKAHEAD,也就是处理到了64KB内容的接近结束部分,也就是如果再处理,超前查看缓冲区中的内容就可能不够了。由于 lookahead < MIN_LOOKAHEAD ,将执行 fill_window()。

    fill_window() 判断是否压缩已经进行到了2窗内容快用完了,该把新的内容放进来了。如果是的话,

    fill_window() 把第二窗的内容 w2_32k,复制到第一窗中,第一窗中的内容就被覆盖掉了,然后对match_start,strstart之类的索引,做修正。
    然后更新匹配链的链头数组,head[],从头到尾过一遍,如果这个头中保存的串的位置,在w2_32k中,就对这个串的位置做修正。
    如果这个头中保存的串的位置,在w1_32k中,就不要了,设为空,因为第一窗的内容我们已经覆盖掉了。
    然后更新prev[]数组,从头到尾过一遍,如果某项的内容,在w2_32k中,就做修正。如果这项的内容,在w1_32k中,就不要了,设为空,因为第一窗的内容我们已经覆盖掉了。

    最后fill_window()从文件中再读出一窗内容,也就是读出32KB的内容,复制到第二个窗中,注意第二个窗口中原来的内容,已经被复制到了第一个窗口中。

    就这样,一窗窗的处理,直到整个文件结束。

    分析:

    到第二窗文件内容也快要处理完的时候,才会从文件中读入新的内容。而这时,第一窗中的所有串,对于当前处理字节和之后的字节来说,已经超出了一个窗口的距离,当前处理字节和之后的字节不能和第一窗的串进行匹配了,也就是说第一窗的内容已经没有用了。所有插入字典的第一窗的串也已经没有用了。所以覆盖第一窗的内容是合理的,将字典中第一窗的串的开始位置都设为空也是合理的。

    将第二窗的内容复制到第一窗中,那么第二窗在字典中的所有索引都需要做相应的修正。

    由于第二窗的内容已经复制到了第一窗中,所以我们可以将新的内容读入到第二窗中,新的内容之前的32KB的内容,就是原来的第二窗中的内容。而这时,做过修正的字典中,仍然有原来第二窗中所有串的信息,也就是说,新的内容,可以继续利用前面一个窗口大小的范围之内的串,进行压缩,这也是合理的。

    2.4 其他问题1

    现在来说明一下,为什么最小匹配长度为3个字节。这是由于,gzip 中,(匹配长度,相隔距离)对中,"匹配长度"的范围为3-258,也就是256种可能值,需要8bit来保存。"相隔距离"的范围为0-32K,需要15bit来保存。所以一个(匹配长度,相隔距离)对需要23位,差一位3个字节。如果匹配串小于3个字节的话,使用(匹配长度,相隔距离)对进行替换,不但没有压缩,反而还会增大。所以保存(匹配长度,相隔距离)对所需要的位数,决定了最小匹配长度至少要为3个字节。

    最大匹配长度为258的原因是,综合各种因素,决定用8位来保存匹配长度,8位的最大值为255。实际中,我们在(匹配长度,相隔距离)对中的“匹配长度”保存的是,实际匹配长度-最小匹配长度,所以255对应的实际匹配长度为258。

    在进行匹配时,会对匹配长度进行判断,保证到达最大匹配长度时,匹配就停止。也就是说,即使有两个串的相同部分超过了最大匹配长度,也只匹配到最大匹配长度。

    保存相隔距离所用的位数和窗口大小是互相决定的,综合两方面各种因素,确定了窗口大小,也就确定了保存相隔距离所使用的位数。

    2.5 gzip 的 LZ77部分的实现要点

    gzip 的 LZ77 部分的实现主要在函数 defalte() 中。

    所使用的缓冲区

    window[] 用来放文件中读入的内容。

    l_buf[],d_buf[],flag_buf[] 用来放LZ77压缩得到的结果。
    l_buf[] 中的每个字节是一个没有匹配的字节,或者是一个匹配的对中的匹配长度-3。l_buf[]共用了inbuf[]。
    d_buf[] 中的每个unsigned short,是一个匹配的对中的相隔距离。
    flag_buf[] 中每位是一个标志,用来指示l_buf[]中相应字节是没有匹配的字节,还是一个匹配的对中的匹配长度-3。

    prev[],head[] 用来存放字典信息。实际上 head 为宏定义 prev+WSIZE。

    初始化过程中,调用 lm_init()。
    lm_init() 中,从输入文件中读入2个窗口大小,也就是64KB的内容到window[]中。lookahead 中为返回的读入字节数。使用window中的头两个字节,UPDATE_HASH,初始化ins_h。

    deflate() 中,一个处理循环中,首先 INSERT_STRING 把当前串插入字典,INSERT_STRING 是一个宏,作用就是用哈希函数计算当前串的ins_h,然后把原来的head[ins_h]中的内容,链入链中(放到prev中),同时把原来的head[ins_h]保存在hash_head变量中,用来后面进行匹配判断,然后把当前串的开始位置,保存在head[ins_h]中。

    判断hash_head中保存的内容不为空,说明匹配链上有内容。调用 longest_match () 寻找匹配链上的最长匹配。
    hash_head中保存的内容为空,说明当前字节开始的串,在窗口中没有匹配。
    由于使用了lazy match,使得判断的情况更复杂。

    匹配串的输出,或者是没有匹配的字节的输出,都是调用函数 ct_tally()。
    对于匹配串,输出之后,还需要为匹配串中的每个字节使用 INSERT_STRING,把匹配串中每个字节开始的串都插入到字典中。

    ct_tally()中,把传入的"没有匹配的字节"或者是"匹配长度-3"放到l_buf[]中,然后为以后的Huffman编码做统计次数的工作,如果传入的是匹配情况,传入的参数中会有相隔距离,把相隔距离保存在d_buf[]中。根据传入的参数,可以判断是哪种情况,然后设置一个变量中相应的标志位,每8个标志位,也就是够一个字节,就保存到flag_buf[]中。还有一些判断,我们将在后面进行说明。

    2.6 分块输出

    LZ77 压缩的结果放在,l_buf[],d_buf[],flag_buf[] 中。
    对于 LZ77 的压缩结果,可能使用一块输出或者分成多块输出(LZ77压缩一定的部分之后,就进行一次块输出,输出一块)。块的大小不固定。

    输出的时候,会对LZ77的压缩结果,进行Huffman编码,最终把Huffman编码的结果输出到outbuf[]缓冲区中。
    进行Huffman编码,并输出的工作,在 flush_block() 中进行。

    在ct_tally()中进行判断,如果满足一些条件的话,当从ct_tally()中返回之后,就会对现有的LZ77的结果,进行Huffman编码,输出到一个块中。
    在整个文件处理结束,deflate()函数要结束的时候,会把LZ77的结果,进行Huffman编码,输出到一个块中。

    在ct_tally()中,每当l_buf[]中的字节数(每个字节是一个没有匹配的字节或者一个匹配长度)增加0x1000,也就是4096的时候。将估算压缩的情况,以判断现在结束这个块是否比较好,如果觉得比较好,就输出一个块。如果觉得不好,就先不输出。

    而当l_buf[]满了的时候,或者d_buf[]满了的时候,将肯定对现有的LZ77压缩的结果,进行Huffman编码,输出到一个块中。

    决定输出一块的话,会只针对这一块的内容,建立Huffman树,这一块内容将会被进行Huffman编码压缩,并被输出到outbuf[]中。如果是动态Huffman编码,树的信息也被输出到outbuf[]中。输出之后,会调用init_block(),初始化一个新块,重新初始化一些变量,包括动态树的结点被置0,也就是说,将为新块将来的Huffman树重新开始统计信息。

    输出块的大小是不固定的,首先在进行Huffman编码之前,要输出的内容的大小就是不固定,要看情况,进行Huffman编码之后,就更不固定了。
    块的大小不固定,那么解压缩的时候,如何区分块呢。编码树中有一个表示块结束的结点,EOB,在每次输出块的最后,输出这个结点的编码,所以解压缩的时候,当遇到了这个结点就表明一个块结束了。

    每个块最开始的2位,用来指明本块使用的是哪种编码方式,00表示直接存储,01表示静态Huffman编码,10表示动态Huffman编码。接下来的1位,指明本块是否是最后一块,0表示不是,1表示是最后一块。

    输出一个块,对现在字典中的内容没有影响,下一个块,仍将用之前形成的字典,进行匹配。

    2.7 静态Huffman编码与动态Huffman编码

    静态Huffman编码就是使用gzip自己预先定义好了一套编码进行压缩,解压缩的时候也使用这套编码,这样不需要传递用来生成树的信息。
    动态Huffman编码就是使用统计好的各个符号的出现次数,建立Huffman树,产生各个符号的Huffman编码,用这产生的Huffman编码进行压缩,这样需要传递生成树的信息。

    gzip 在为一块进行Huffman编码之前,会同时建立静态Huffman树,和动态Huffman树,然后根据要输出的内容和生成的Huffman树,计算使用静态Huffman树编码,生成的块的大小,以及计算使用动态Huffman树编码,生成块的大小。然后进行比较,使用生成块较小的方法进行Huffman编码。

    对于静态树来说,不需要传递用来生成树的那部分信息。动态树需要传递这个信息。而当文件比较小的时候,传递生成树的信息得不偿失,反而会使压缩文件变大。也就是说对于文件比较小的时候,就可能会出现使用静态Huffman编码比使用动态Huffman编码,生成的块小。

    2.8 编码的产生

    deflate算法在Huffman树的基础上,又加入了几条规则,我们把这样的树称作deflate树,使得只要知道所有位长上的结点的个数,就可以得到所有结点的编码。这样做的原因是,减少需要存放在压缩压缩文件中的用来生成树的信息。要想弄明白,deflate如何生成Huffman编码,一定要弄明白一些Huffman树,和deflate树的性质,下面内容是对Huffman树和deflate树做了些简单研究得到的。

    Huffman树的性质

    1 叶子结点为n的话,那么整颗树的总结点为 2n-1。
    简单证明说明,先证,最小的树,也就是只有三个结点,一个根节点,两个叶子节点的树符合。然后在任何符合的树上做最小的添加得到的树也符合。所以都符合。

    2 最左边的叶子结点的编码为0,但是位长不一定。

    deflate中增加了附加条件的huffman树的性质

    1 同样位长的叶子结点的编码值为连续的,右面的总比左面的大1。

    2 (n+1)位长最左面的叶子结点(也就是编码值最小的叶子结点)的值为n位长最右面的叶子结点(也就是编码值最大的叶子结点)的值+1,然后变长一位(也就是左移1位)。

    3 n位长的叶子结点,最右面的叶子结点(也就是编码值最大的叶子结点)的值为最左面的叶子结点(也就是编码值最小的叶子结点)的值 加上 n位长的叶子结点的个数 减 1。

    4 (n+1)位长最左面的叶子结点(也就是编码值最小的叶子结点)的值 为 n位长最左面的叶子结点(也就是编码值最小的叶子结点)的值加上 n位长的叶子结点的个数,然后变长一位(也就是左移1位)。

    还有一些树的性质,比如,树的某一深度上最大可能编码数。

    从所有编码的位长,得到所有编码的编码:
    统计每个位长上的编码个数放在bl_count[]中。
    根据 bl_count[] 中的值,计算出每个位长上的最小编码值,放在 next_code[] 中。
    计算方法为,code = (code + bl_count[bits-1]) << 1;
    理由是deflate二叉树的性质,(n+1)位长最左面的叶子结点(也就是编码值最小的叶子结点)的值为 n位长最左面的叶子结点(也就是编码值最小的叶子结点)的值 加上 n位长的叶子结点的个数,然后变长一位(也就是左移1位)。

    然后按照代码值的顺序,为所有的代码编码。
    编码方法为,某一位长对应的next_code[n],最开始是这个位长上最左边的叶子结点的编码,然后++,就是下一个该位长上下一个叶子结点的编码,依次类推,直到把这个位长上的叶子结点编码完。实际上的编码为bi_reverse(next_code[])。
    这样编码的理由是,deflate二叉树的性质。

    2.9 5棵树

    一共有5棵树 static_ltree[],static_dtree[],dyn_ltree[],dyn_dtree[],bl_tree[]。
    对于所有的树,一个叶子结点表示的符号的值为n的话,那么这个符号对应的叶子结点放在 tree[n] 中
    比如 static_ltree 的叶子结点'a' 的值为十进制97,那么'a'的叶子结点就放在 static_ltree[97] 。

    static_ltree[] 静态Huffman编码时,用来对没有改动的字节和匹配长度进行编码的树。
    static_dtree[] 静态Huffman编码时,用来对相隔距离进行编码的树。
    dyn_ltree[] 动态Huffman编码时,用来对没有改动的字节和匹配长度进行编码的树。
    dyn_dtree[] 动态Huffman编码时,用来对相隔距离进行编码的树。
    bl_tree[] 动态Huffman编码时,用来对解压缩时用来产生dyn_ltree[]和dyn_dtree[]的信息进行编码的树。

    静态树在初始化的时候,为每个叶子结点直接产生编码。
    动态树,每次要输出一块的内容,就根据这一块的内容,生成动态树,再根据生成的动态树,为每个叶子结点产生编码。

    每次要输出一块的内容时,会计算用静态树编码得到的块的大小,和用动态树编码得到的块的大小,然后谁产生的块小就用谁。

    用静态编码的话,就使用 static_ltree[],static_dtree[],来进行编码输出。
    用动态编码的话,就使用 dyn_ltree[],dyn_dtree[],bl_tree[] 来进行编码输出。

    2.10 叶子结点

    ltree (用来对没有改动的字节和匹配长度进行编码的树,静态,动态都一样)的叶子结点
    一共 L_CODES 个,也就是286个。
    0-255 256个叶子结点,是字节的256个值
    256 1个叶子结点,是 END_BLOCK,用来表示块结束的叶子结点。
    257-285 29个叶子结点,是表示匹配长度的29个范围。

    dtree (用来对相隔距离进行编码的树,静态,动态都一样)的叶子结点
    一共 D_CODES 个,也就是30个。
    0-29 30个叶子结点,是表示相隔距离的30个范围。

    bl_tree 的叶子结点
    一共 BL_CODES 个,也就是19个。
    0-15 表示编码位长为 0-15。
    16 复制之前的编码长度3-6次。之后的两位指明重复次数。
    17 重复编码位长为0的,3-10次,之后的3位指明重复次数。
    18 重复编码位长为0的,11-138次,之后的7位指明重复次数。

    2.11 静态Huffman编码

    初始化base_length[],length_code[],base_dist[],dist_code[]。

    base_length[]为,29个 匹配长度 范围的,每个范围开始的长度值。
    length_code[]为,256 个可能的匹配长度 所属的范围。
    比如,base_length[9]=0xa,表示第9个范围的开始值为0xa。
    length_code[11]=9,表示匹配长度为11的匹配长度,属于第9个范围。

    base_dist[],30个 匹配距离 范围的,每个范围的开始的值,就是每个范围内最小的值。
    dist_code[],这个有点特殊,一共有32K个取值,这里把这32K种值,分成了两大类,
    0-255这256个值为一类,这时他们直接为dist_code[]的索引。
    256-32K为一类,这时他们的去掉低7位,和最高位,剩下的8位为索引,8位刚好索引256项。能这么做的原因是,首先最大32K的距离最大需要15位,所以16位的最高位总不会用,其次剩下这些范围的边界至少都为二进制1 000 0000 的整数倍。
    比如 匹配距离为 10,小于256,所以它属于类 dist_code[10]=6,第6类。
    匹配距离为 10K ,大于256,所以它属于类dist_code[256+10K>>7]=dist_code[256+10240>>7]=dist_code[256+80]=dist_code[336]=0x1a=26,属于26类,26类的范围为8193-12288,10240就是在这个范围内。

    指定了每个literal的位长。(一共将有288个literal。包括256个字节值+1个EOB+29个匹配长度范围=286个。多2个是为了满树。)并统计每个位长上的literal个数放在bl_count[]中。

    根据 bl_count[] 中的值,计算出每个位长上的最小编码值,放在 next_code[] 中。
    计算方法为,code = (code + bl_count[bits-1]) << 1;
    理由是deflate二叉树的性质,(n+1)位长最左面的叶子结点(也就是编码值最小的叶子结点)的值为 n位长最左面的叶子结点(也就是编码值最小的叶子结点)的值 加上 n位长的叶子结点的个数,然后变长一位(也就是左移1位)。

    然后从literal值的0,到literal值的最大。为每个literal编码。
    编码方法为,某一位长对应的next_code[n],最开始是这个位长上最左边的叶子结点的编码,然后++,就是下一个该位长上下一个叶子结点的编码,依次类推,直到把这个位长上的叶子结点编码完。
    实际上的编码为bi_reverse(next_code[])。
    比如
    tree[n].Code = bi_reverse(next_code[len]++, len);

    此时 next_code[len] 值为 二进制 00110000 即0x30
    tree[n].Code 最后被赋值为 二进制 00001100 即0x0c

    这样我们就得到了 static_ltree[],它以literal的值为索引,存放着literal对应的编码。
    比如 'a' 的值为十进制97, static_ltree[97].code=0x0089 static_ltree[97].len=8。
    说明a的编码为二进制 10001001。

    为static_dtree编码。这个编码很简单,由于所有结点都是5位长的(指定的),所以根据deflate二叉树性质,最左边的叶子节点编码为0,之后每次加1即可,直到编完所有叶子结点。注意这里也要bi_reverse()一下。也就是说,编码为"从树根开始到一个叶子结点的路径对应的位流"的逆位流。

    用Huffman编码对LZ77处理结果进行编码输出。

    这时,
    l_buf[]中每个字节为literal或者 匹配长度-MIN_MATCH。
    d_buf[]为匹配距离,每项为16位。
    flag_buf[]中每位为指示inbuf[]中对应字节为literal还是匹配长度-MIN_MATCH 的标志,比如
    flag_buf第i位为1,说明inbuf[i]为匹配长度-MIN_MATCH。

    读出flag_buf中的每一位,进行判断。
    如果为0,表示对应的l_buf中的那个字节为literal。
    如果为1,表示对应的l_buf中的那个字节为匹配长度-MIN_MATCH。

    对于literal,就用l_buf[]的这个值做索引,在static_ltree中得到编码,和编码长度,然后输出这个编码。

    对于 匹配长度-MIN_MATCH,就用l_buf[]的这个值做索引,在length_code[]中首先得到这个匹配长度所在的范围,一共有29个范围。也就是说匹配长度-MIN_MATCH 取值范围为 (3..258),一共有256种可能的值。这256种可能值,被分配在了29个范围中。

    我们用l_buf[]的这个值做索引,在length_code[]中得到这个匹配长度所在的范围。

    然后用 范围值+256+1 得到该范围所对应的 literal。用这个literal做索引,在static_ltree中得到编码,和编码长度,然后输出这个编码。

    然后用 范围值 做索引,在 extra_lbits[] 中得到该范围的附加位的位数,如果附加位位数不为0,
    就输出附加位。附加位为 inbuf[]中的那个值,就是 匹配长度-MIN_MATCH 减去 这个范围的对应的 base_length[]。

    然后从d_buf[]中取出,匹配距离。
    当匹配距离小于256时,用匹配距离做索引,在dist_code中取出对应的范围值。
    当匹配距离不小于256时,用匹配距离右移7位,也就是用高8位,做索引,在dist_code+256中取出对应的范围值。

    对匹配距离,匹配距离的取值范围为,(1..32,768),一共有32k种可能的值。
    分成了30个范围。由于匹配距离的取值范围为,(1..32,768),所以匹配距离使用15位。

    然后用距离的范围值做索引,在static_dtree[] 中得到编码,和编码长度。然后输出这个编码。
    然后用距离的范围值做索引,在extra_dbits[] 中得到该范围的附加位的位数,如果附加位位数不为0,
    就输出附加位。输出的附加位为 dist-base_dist[code]。

    比如,取出一个dist为10。dist_code[10]=6,说明属于第6个范围。
    然后查 extra_dbits,extra_dbits[6]=2,说明有两个extra bits。
    local int near extra_dbits[D_CODES] /* extra bits for each distance code */
    = {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};
    首先输出 static_dtree[6].len位的位流,static_dree[6].code。(static_dtree的位长都为5)
    然后输出 extra_dbits[6]位的位流,10-base_dist[6]=10-8=2=二进制的10。

    发送完inbuf中的每个字节之后,最后发送 END_BLOCK 的编码。

    2.12 动态Huffman编码

    确定所有literal,匹配长度范围,和匹配距离范围的出现次数。
    在进行LZ77压缩的过程中,每确定一个literal或者匹配,都会调用 ct_tally()。
    在 ct_tally() 中,如果是一个literal,就 dyn_ltree[lc].Freq++。
    如果是一个匹配,就 dyn_ltree[length_code[lc]+LITERALS+1].Freq++,dyn_dtree[d_code(dist)].Freq++。

    调用 build_tree() 建立 literal和匹配长度范围(也就是dyn_ltree的叶子结点,共286个) 的树,并为他们(literal和匹配长度范围)编码。
    生成树中,heap[]是用来辅助生成树的缓冲区。

    首先把tree[]中所有出现次数不为0的值(也就是索引,比如tree[0x61]就为'a'的对应项),放到heap[]中。

    tree[] 的元素个数为 2*L_CODES+1,L_CODES为叶子结点的个数,286。
    由Huffman二叉树性质,叶子结点为n,那么这棵树的总结点为2n-1。

    tree[] 将用来保存生成的树。tree[]的前L_CODES 项,用来存放叶子结点。比如'a'的结点信息,放在tree[0x61]中。L_CODES 之后的项用来放中间结点。

    heap[] 将用来放生成树的过程中产生的临时内容。heap[]的大小也为 2*L_CODES+1 。它的前 L_CODES 用来放
    生成树过程中的结点,最开始是叶子结点,随着生成树的进行,两个叶子结点被弄掉,放入他们的中间结点。后 L_CODES ,从后向前用。在生成树的过程中,所有结点(根,中间,叶子)都将按权值大小顺序放在这里。
    将来生成位长时,需要使用。

    pqdownheap(tree, SMALLEST); 的作用就是将heap中的结点中,找出freq最小的那个,放在heap[1]中。

    生成树的过程为,每次从heap中找出两个最小的结点,然后给他们弄一个父结点。并把他们的tree[]的相应内容指向他们的父结点。
    并在heap中删掉这两个结点,而把他们的父结点加入到heap中。

    heaplen 为heap中结点的个数,每次由于要删2个结点,加1个结点,所以每次会使heaplen--,也就是结点数变少一个。

    等到heaplen,也就是结点数,小于2时,说明树已经要弄好了。

    树生成好之后,tree[]中的域 freq 和 dad 都被设置好了,调用 gen_bitlen(),为他们生成位长。

    gen_bitlen()中,

    从根开始,根的位长为0,向下,为每个结点设置位长。

    判断是否为叶子结点,判断的方法是,看是否大于最大代码,这里最大代码是286。

    当遇到叶子结点时,进行动态编码整个文件的总位长的计算,和进行静态编码整个文件的总位长的计算。
    bl_count[bits]++; 用来一会儿产生编码。
    由于在叶子结点的freq域中保存着这个结点的出现次数,现在又有了位长,所以可以计算该结点的动态位长。
    而所有的结点的动态位长累加在一起就是总位长。
    有了出现次数,对于静态,结点位长是设定好的,也同样可以进行计算。

    最后调用 gen_codes(),为所有叶子结点产生编码。和静态Huffman中的方法是相同的。

    调用 build_tree() 建立 匹配距离范围(也就是dyn_dtree的叶子结点,共30个) 的树,并为他们(匹配距离范围)编码。和生成dyn_ltree的方法是相同的。

    调用 build_bl_tree() 为l(literal&匹配长度)和d(匹配距离)的位长数组 生成树,并为这些位长编码。

    调用scan_tree统计一个树中的编码长度情况。
    分别对dyn_ltree和dyn_dtree进行统计。

    scan_tree((ct_data near *)dyn_ltree, l_desc.max_code);
    scan_tree((ct_data near *)dyn_dtree, d_desc.max_code);

    统计结果放在 bl_tree[].Freq 中。

    弄明白了bl_tree[]中叶子结点的含义,就很容易理解scan_tree中所作的工作。
    比如 bl_tree[0].Freq 表示编码位长为0的编码个数。
    bl_tree[10].Freq 表示编码位长为10的编码个数。
    bl_tree[16].Freq 表示 连续几个编码长度的出现个数都相同,这种情况的出现次数。

    最后调用 build_tree() 建立位长情况(就是那19种情况)的树,并为他们(就是那19种情况)编码。

    发送用bl_tree编码的结点位长数组。
    defalte算法中,只要知道了一个树的每个叶子结点的位长,就可以得到该叶子结点的编码。
    所以我们需要发送ltree的286个叶子结点的位长,我们需要发送dtree的30个叶子结点的位长。

    首先发送三个树的最大叶子结点值的一个变形。
    send_bits(lcodes-257, 5); 发送ltree有效最大叶子结点值+1-257
    send_bits(dcodes-1, 5); 发送dtree有效最大叶子结点值+1-1
    send_bits(blcodes-4, 4); 发送bl_tree有效最大叶子结点值+1-4。
    ltree最大叶子结点值,就决定了我们将要发送的ltree的叶子结点位长数组的个数。只发送到有效最大叶子结点数就行了。
    比如,ltree有效最大叶子结点值为0x102的话,那么我们只需要发送ltree中前0x103个的位长,并告诉解压缩程序,发送了0x103个就行了。

    发送 bl_tree 的位长,注意发送的顺序是按 bl_order[] 中规定的顺序发送的。

    调用 send_tree() 先后发送 dyn_ltree,dyn_dtree 的位长。

    send_tree()中使用和scan_tree()中相同的方法,首先看这些位长属于bl_tree的19个叶子结点对应的19种情况中的哪一种,确定了是哪一种之后,
    就按这种情况对应的叶子结点,在bl_tree中的编码,发送这个编码。直到把这些位长都发完。

    用Huffman编码对LZ77处理结果进行编码输出。和静态Huffman编码时使用的方法是相同的。



    2.13 要点


    第一,省去了LZ77用来指明是"没有改动的字节"还是"匹配的信息对"的那个标志位。

    由于gzip实现中,把匹配长度的范围和字节值,做为不同的叶子结点进行编码。比如说,值为1的字节,和一个值为1的匹配长度,他们的值虽然相同,但是他们是不同的叶子结点,他们的编码也是不同的。这样一来,解压缩时,就可以直接区分,就不必再输出那个指示位了。

    这个节省对压缩率的改善应该有不小的帮助。

    静态Huffman编码时,编码本身不会起到什么压缩作用,但是还会从这个节省中获益。

    第二,叶子结点所表示的内容。

    我们看到gzip的实现中,叶子节点所代表的内容各种各样,不仅仅是一个固定的值,而且有些代表了一个值的范围,(然后用之后的更多的位来表示这个范围中的一个值),而且还有代表情况的。

    这个实现方法是相当不错的,非常值得借鉴。

    解压缩也不说了,原因看最后。

    2.14 匹配延伸到lookahead中

    可以进行这种压缩,与解压缩,关键是解压缩的处理中,做了特别的处理。

    例,串 0aaaaa

    进行lz77压缩时,当今行到下面位置时 0a 当前位置->aaaa
    匹配会延伸到lookahead中,结果就是 0a[匹配长度4,距离1]

    解压缩时,首先0a被做为没有改动的字节解压出来,
    然后解压发现[匹配长度4,距离1],
    这里将做一个判断,看有没有延伸到lookahead中,如果有的话,将做特别的处理,一个字节一个字节的进行复制。

    展开全文
  • abcd文件压缩算法

    2009-06-23 17:45:40
    文件压缩算法文件压缩算法文件压缩算法文件压缩算法文件压缩算法文件压缩算法
  • 压缩算法,几种比较全的文件压缩算法,包括Huffman,lz等 压缩算法,几种比较全的文件压缩算法,包括Huffman,lz等
  • 纯VB实现的文件压缩算法 如果电脑上没有装rar的话,可以用这个编译成功工具使用哦。学习之用。
  • 一个VC 文件压缩算法,适用C和VC,也可进行内存的压缩和解压,算法名称:LZARI压缩算法,本源码可视为这种算法的用法演示。不过本人没搞懂是如何用于文件压缩的,据说老外对此算法还是很有看法的。
  • GO实现文件压缩算法

    2021-01-20 13:37:11
    压缩文件头定义 type compressHead struct { srclen, dstlen, keymapLen uint32 //源文件字符个数 压缩文件字符个数 哈夫曼编码字符映射个数 patchBit uint8 //压缩后不足8bit补0个数 keysMap map[interface{}]...
  • 文件压缩算法源码,希望能对你有所帮助~~~
  • LZ77文件压缩算法

    2020-04-13 16:24:48
    LZ77压缩算法 LZ77是一种基于字典的算法,它将长字符串(也称为短语)编码成短小的标记, 用小标记代替字典中的短语,从而达到压缩的目的。 LZ77使用的是一个前向缓冲区和一个滑动窗口来维护字典。它首先将一部分...

    LZ77压缩算法

    1977由两个以色列人提出的基于重复语句层面的一种通用的压缩算法。
    通用:对文件没有要求最终是将重复语句替换成更短的<长度,距离,先行缓冲区匹配字符串的下- -个字符>对,以达到压缩的目的。

    mnoabczxyuvwabc 123456abczxydefgh

    mnoabczxyuvw(3, 9, 1)23456(6, 18, d)efgh

    找到一个重复子串后,需要将先行缓冲区匹配字符串的下一个字符按照源字符的方式写入压缩文件,下次如果匹配efg

    GZIP 中的LZ77思想

    GZIP: LZ77从重复语句层面压缩+ huffman从字节层面进行压缩

    在ZIP算法中,也使用到LZ77的算法思想,但是对其进行了改进,主要是对于短语标记的改进:只使用“长度+距离”的二元组进行表示,匹配的查找是在查找缓冲区中进行的,即字典。
    在这里插入图片描述

    1、从之前压缩过的部分找重复
    
    2、找到重复:将从夫出现的字符串使用(长度,距离)进行替换
     未找到重复:将该字节写在压缩文件中
    

    注意:查找缓冲区的数据是已经被扫描过,建立的字典中的数据,先行缓冲区即为带压缩数据

    查找缓冲区:

    1. 该部分的数据已近压缩写到压缩文件中
    2. 带压缩数据对应的-一个字符串将来要在该区域中找重复
    3. 随着压缩的进行,查找缓冲区在不断的增大

    先行缓冲区:

    1. 待压缩的数据
    2. 每次从该区域中取一个字符串,然后在查找缓冲区中进行匹配
    3. 随着压缩的进行,先行缓冲区在不断的缩小

    真正的数据压缩数据存储,长度,距离对不会用括号括起来,也不会用逗号隔开,因为会影响压缩比率。
    那如何区分是原字符还是长度距离对呢?

    采用标记位
    

    在这里插入图片描述

    重复字符串有几个时候进行长度距离对的替换?

    匹配字符串的长度用一个字节存储: [0, 255]

    为什么长度用一个字节表示: 一个字节可表示的最大值为255,255理论已经比较长,如果匹配长度超过255,长度必须要通过两个字节来进行存储,而正常文件中的匹配长度可能都小于255,- -个字节就可以存储,如果用两个字节存储时,对压缩率会有一定的影响。

    距离用几个字节来进行存储?

    就要必须知道缓冲区有多大?
    缓冲区越大,查找到重复概率就更高

    LZ77:缓冲区的大小—> 64K

    理论上:应该在整个查找缓冲区中找匹配但是实际不会这么做:根据实际情况,重复-般都是有局部原理性-- -重复-般都不会 太远虽然在整个查找缓冲区中进行查找,找到匹配的概率会更大,但是会严重增大查找的效率为了提升一点点的压缩比率,程序效率大大牺牲真正匹配范围不会超好WSIZE: 32K---->2^5*K—> 两个字节 [1,32768]

    <长度,距离对>总共占了三个字节,匹配串长度

    1个字符—>肯定不会找匹配,即不会压缩

    2个字符—>如果找到的匹配长度是2个字符,不会进行匹配,因为如果将2个字符用<长度,距离>对替换—>会使压缩文件变大3个字符或以上字符才开始替换。
    最小匹配长度 MIN MATCH LEN = 3;
    最大匹配长度 MAX_ MATCH LEN = 258;

    一个字节范围[0, 255]—>0表示长度3,1表示长度4…255长度258。
    如果某个匹配长度超过258,则拆成两个匹配来进行表示

    如何查找最长匹配串?

    1、 暴力求解

    该算法的性能比较差,是一个O(N^2)的算法,如果待压缩文件比较大,
    会严重影响压缩的速度。
    

    2、 采用哈希

    哈希思想查找最大匹配串

    使用哈希“桶”保存每三个相邻字符构成的字符串中首字符的窗口索引。
    压缩过程中每遇到新字符时,进行如下操作:

    1. 利用哈希函数计算该字符与紧跟其后的两个字符构成字符串的哈希地址
    2. 将该字符串中首字符在窗口中的索引插入上述计算出哈希位置的哈希桶中,返回插入之前该桶的状态
    3. 根据2返回的状态监测是否找到匹配串
      如果当前桶为空,说明未找到匹配,
      否则:可能找到匹配,再定位到匹配串位置详细进行匹配即可。
      利用哈希的思想,可大大提高查找匹配串的效率。

    关于"哈希桶",引发出以下问题:

    1. 哈希桶的大小分析:
      三个字符总共可以组成 种取值(即16M),桶的个数需要 个,而索引大小占2个字节,总共桶占32M字节,是一个非常大的开销。随着窗口的移动,表中的数据会不断过时,维护这么大的表,会降低程序
      运行的效率。因此本文哈希桶的个数设置为: (即32K)。

    哈希表的结构

    现在减少为2^15个哈希桶,必然会产生哈希冲突。如果采用开散列解决,链表中的节点要不断申请与释放,而且浪费空间,影响呈现效率。

    因此本文哈希表由一整块连续的内存构成,分为两个部分,每部分大小WSIZE(32K)。
    如下图所示:

    prev指向该字典整个内存的起始位置,head = prev + WSIZE,内存是连续的,所以prev和head可以看作两个数组,即prev[]和head[]。

    head数组用来保存三个字符串首字符的索引位置,head的索引为三个字符通过哈希函数计算的哈希值。而prev就是来解决冲突的。

    在这里插入图片描述
    abc---->计算出的哈希地址5,abc在原缓冲区中的下标3
    即: head[5] = 3

    第二个abc向哈希表中的插入过程:

    abc–>哈希函数—>哈希地址: 5, abc在原缓冲区中的下标12此时:
    发生哈希冲突

    解决: prev的空间专门 ]是用来解决哈希冲突

    常规哈希表中如果要进行查找,表格中的元素已经全部放进去了,
    再来进查找。

    哈希表: 一边向哈希表中插入内容,一边查找。

    查找匹配过程:假设拿到的是abc
    计算哈希地址hashAddr=3;

    第一次匹配: matchHead =_ head[hashAddr];
    matchHead:19进行一 次匹配,本次匹配不一定是最长的,继续匹配

    第二次匹配: matchHead =_ prev[matchHead];
    matchHead:10进行一次匹配, 本次匹配不- -定是 最长的,继续匹配

    第三次匹配: matchHead =_ prev[matchHead];
    matchHead:1进行一 次匹配,本次匹配不一定是最长的,继续匹配

    第四次匹配: matchHead =_ prev[matchHead]; matchHead:0说明没有相同字符串了,不匹配

    随着压缩的进行,start(表示当前 压缩到的位置) start会 大于WSIZE .

    进而引出新的问题
    向Hash表中插入字符的过程

    pos:代表abc首字符a在缓冲区中的下标(即: start)
    prev[pos] = head[hashAddr];
    _head[hashAddr] = pos;
    prev的大小32K–>WSIZE
    一但pos > WSIZE
    _prev[pos] 就越界了

    当pos超过WSIZE时,在插入函数中如果直接使用pos肯定会越界,因此需要与上WMASK,即
    _prev[pos & WMASK] = _head[hashAddr]

    但是该语句可能会破坏匹配链,让匹配链构成环而造成死循环,该情况如何处理?
    解决方式,匹配次数最多匹配255次。

    展开全文
  • 全文自动检索系统中的快速检索与索引文件压缩算法
  • 通过二进制流读入文件,然后以字节计算统计的方式进行文件的压缩,压缩算法使用huffman,
  • 1. 利用链表进行字符读取 2. 利用赫夫曼树进行编码、解码 3.具有详细的注释与输出
  • 哈夫曼编码,是一种数据压缩算法,通常用于无损数据压缩。该算法是由 David A. Huffman在麻省理工学院就读理学博士(Doctor of Science)的时候发明的,这位大佬在1952年发表了相关的一篇论文A Method for the ...

    哈夫曼编码,是一种数据压缩算法,通常用于无损数据压缩。该算法是由 David A. Huffman在麻省理工学院就读理学博士(Doctor of Science)的时候发明的,这位大佬在1952年发表了相关的一篇论文A Method for the Construction of Minimum-Redundancy Codes,有兴趣的朋友可以看看。


    最近,我完成了一个银行客户安全管理系统的小组项目,该系统可以对文件进行压缩/解压,加密/解密,在加密和压缩的文件中进行搜索功能,以及根据数据库文件进行排序功能。我实现的部分是哈夫曼编码的压缩(Compression),解压(Decompression)算法,和搜索(Search)算法。现在在这里分享一下自己的学习心得。.


    可变长度编码和前缀编码

    在提出哈夫曼编码之前,我们先了解一下什么是可变长度编码和前缀编码。

    当一个字符在文本中出现的频率比其他字符更频繁的时候,我们可以通过一种算法,这种算法可以使该字符以更少的位数(bit)表示相同的一段文本,这种方式即为可变长度编码(variable-length encoding)。比如,有些字符可以只用一一个二进制数(0或1)表示,有些字符用两个二进制数表示(01或10)。

    假设有一个字符串"aaabbc",其中字符’a’,‘b’,'c’出现的频率依次是3,2,1,我们根据频率先随机给这四个字符定义四种编码,如下所示:

    字符 编码
    a 0
    b 10
    c 010

    在编码后,这个字符串可表示为0001010010 (0 |0 |0 |10 |10 |010),看似对文本进行了压缩,当你对其解码的时候,会出现模棱两可的情形,如下所示:

    0|0|0|10|10|010
    aaabbc

    0|0|0|10|10|0|10
    aaabbab

    为什么会出现歧义呢?因为该编码没有满足前缀编码原则(prefix rule),即一个字符的编码不能是另一个字符编码的前缀,根据上表,由于字符a的编码0是字符c的编码的前缀,所以在解码过程中会产生歧义。当遇到010编码的时候,就可能解码成字符ab(0|10)或者c(010)。


    哈夫曼编码

    哈夫曼编码(Huffman-Coding)是一种同时满足可变长度编码和前缀编码原则的算法。它可以通过连接具有不同权重的不同节点来构造哈夫曼树(最优二叉树),其中权重最小的节点远离根节点,权重最大的节点更靠近根节点。权值是根据字符在文本中出现的频率来决定。


    算法步骤

    哈夫曼树的构造采用自下而上的方法。

    1. 初始化所有叶子节点,每个叶子结点代表一个字符,其权重表示每个字符在文本中出现的频率。
    2. 每次选择权值最低的两个节点组成一个新节点,新节点的权重等于左右两颗子树的权重之和。
    3. 将这个新节点与其他叶节点进行比较,选择权重最小的两个节点(包括该新节点但是不包括其孩子结点),再构造成一个新节点。
    4. 重复步骤3,直至得到一颗完整的哈夫曼树。

    举例

    假设字符’a’,‘b’,‘c’,‘d’,'e’在文本中出现的频率依次为6,9,7,3,5

    初始化所有叶节点如下:
    在这里插入图片描述
    选取两个最小权重的节点,生成一个新的节点,这里最小权重分别是3和5,因此生成一个权重为8的新节点。
    在这里插入图片描述

    接着继续选择两个最小权重的节点,由于3和5已经生成了新节点,所以不需要再拿3和5与其他节点进行比较。比较8,6,9,7,可知6和7最小,因此生成一个权重为13的新节点:

    在这里插入图片描述

    再拿剩下的节点8,13,9进行比较,发现8和9最小,因此生成一个权重为17的新节点:

    在这里插入图片描述

    最后将13和17生成一个权重为30的新节点,构成一颗完整的哈夫曼树如下,

    在这里插入图片描述

    根据哈夫曼编码”左0右1“的规则,依次表示路径和字符如下:

    在这里插入图片描述

    根据生成的哈夫曼树,我们可以通过根节点到叶子节点的路径得到每个字符的哈夫曼编码:

    字符 编码
    a 00
    b 11
    c 01
    d 100
    e 101

    因此,原始的字符串就可以根据这个哈夫曼编码字典一一生成对应的编码,获得压缩后的编码。


    代码运行

    完整项目代码可参考我的github,其中文件project_dev1.cproject_dev2.c分别为哈夫曼编码的压缩和解压算法。

    运行环境:
    Linux系统,C90语言规范。

    命令行指令:
    make:执行makefile文件里面所有源代码的编译指令。
    make clean:清除所有所有编译文件。
    make cleanf:清除所有项目运行中产生的中间文件,比如压缩或者加密后的文件。
    ./main.out: 执行主程序

    已给定的数据库文件:”Customer.txt",数据库文件内容如下所示:在这里插入图片描述

    运行步骤:

    1. 命令行输入make编译所有源代码。
    2. 命令行输入./main.out运行主程序。
    3. 在用户界面选择"1. I want to compress the file"
      在这里插入图片描述
    4. 根据提示选择"1. I Efficient Compression",由于选项二是行程长度压缩算法(Run Length Encoding),相对而言没有哈夫曼编码压缩效率高(尤其在应对重复字符时)。接着输入你要解压的文件,这里可以压缩示例文件"Customer.txt"。或者用户自己创建文件进行压缩。
      在这里插入图片描述
    5. 解压时终端会输出根据哈夫曼树生成的哈夫曼编码以及压缩前后的二进制数,如下所示:
      在这里插入图片描述
    6. 运行完后,你可以在文件夹目录下看到两个导出的文件,其中"Huffman_Codes.txt"是压缩后的哈夫曼编码,"HFT_Model"是哈夫曼树模型,这个模型在解压缩的时候用得上。
      在这里插入图片描述
    7. 回到用户界面,选择"3. I want to decompress the file",然后输入你要解压缩的文件名"Huffman_Codes.txt",如下所示:
      在这里插入图片描述
    8. 该程序会将解压后的文件内容输入到终端上:
      在这里插入图片描述
      并且导出解压缩后的文件"HFT_Decompression.txt"到目录中:
      在这里插入图片描述
    9. 回到用户界面,你可以使用其他功能或者直接输入-1退出程序。然后依次输入make clean 和 make cleanf清楚编译文件和程序运行中产生的中间文件"Huffman_Codes.txt",“HFT_Model"和"HFT_Decompression.txt”。
    展开全文
  • 最近自己实现了一个ZIP压缩数据的解压程序,觉得有必要把ZIP压缩格式进行一下详细总结,数据压缩是一门通信原理和计算机科学都会涉及到的学科,在通信原理中,一般称为信源编码,在计算机科学里,一般称为数据压缩,...

    原文地址:https://www.cnblogs.com/esingchan/p/3958962.html

    最近自己实现了一个ZIP压缩数据的解压程序,觉得有必要把ZIP压缩格式进行一下详细总结,数据压缩是一门通信原理和计算机科学都会涉及到的学科,在通信原理中,一般称为信源编码,在计算机科学里,一般称为数据压缩,两者本质上没啥区别,在数学家看来,都是映射。一方面在进行通信的时候,有必要将待传输的数据进行压缩,以减少带宽需求;另一方面,计算机存储数据的时候,为了减少磁盘容量需求,也会将文件进行压缩,尽管现在的网络带宽越来越高,压缩已经不像90年代初那个时候那么迫切,但在很多场合下仍然需要,其中一个原因是压缩后的数据容量减小后,磁盘访问IO的时间也缩短,尽管压缩和解压缩过程会消耗CPU资源,但是CPU计算资源增长得很快,但是磁盘IO资源却变化得很慢,比如目前主流的SATA硬盘仍然是7200转,如果把磁盘的IO压力转化到CPU上,总体上能够提升系统运行速度。压缩作为一种非常典型的技术,会应用到很多很多场合下,比如文件系统、数据库、消息传输、网页传输等等各类场合。尽管压缩里面会涉及到很多术语和技术,但无需担心,博主尽量将其描述得通俗易懂。另外,本文涉及的压缩算法非常主流并且十分精巧,理解了ZIP的压缩过程,对理解其它相关的压缩算法应该就比较容易了。

     

    1、引子

    压缩可以分为无损压缩和有损压缩,有损,指的是压缩之后就无法完整还原原始信息,但是压缩率可以很高,主要应用于视频、话音等数据的压缩,因为损失了一点信息,人是很难察觉的,或者说,也没必要那么清晰照样可以看可以听;无损压缩则用于文件等等必须完整还原信息的场合,ZIP自然就是一种无损压缩,在通信原理中介绍数据压缩的时候,往往是从信息论的角度出发,引出香农所定义的熵的概念,这方面的介绍实在太多,这里换一种思路,从最原始的思想出发,为了达到压缩的目的,需要怎么去设计算法。而ZIP为我们提供了相当好的案例。

    尽管我们不去探讨信息论里面那些复杂的概念,不过我们首先还是要从两位信息论大牛谈起。因为是他们奠基了今天大多数无损数据压缩的核心,包括ZIP、RAR、GZIP、GIF、PNG等等大部分无损压缩格式。这两位大牛的名字分别是Jacob Ziv和Abraham Lempel,是两位以色列人,在1977年的时候发表了一篇论文《A Universal Algorithm for Sequential Data Compression》,从名字可以看出,这是一种通用压缩算法,所谓通用压缩算法,指的是这种压缩算法没有对数据的类型有什么限定。不过论文我觉得不用仔细看了,因为博主作为一名通信专业的PHD,看起来也焦头烂额,不过我们后面可以看到,它的思想还是很简单的,之所以看起来复杂,主要是因为IEEE的某些杂志就是这个特点,需要从数学上去证明,这种压缩算法到底有多优,比如针对一个各态历经的随机序列(不用追究什么叫各态历经随机序列),经过这样的压缩算法后,是否可以接近信息论里面的极限(也就是前面说的熵的概念)等等,不过在理解其思想之前,个人认为没必要深究这些东西,除非你要发论文。这两位大牛提出的这个算法称为LZ77,两位大牛过了一年又提了一个类似的算法,称为LZ78,思想类似,ZIP这个算法就是基于LZ77的思想演变过来的,但ZIP对LZ77编码之后的结果又继续进行压缩,直到难以压缩为止。除了LZ77、LZ78,还有很多变种的算法,基本都以LZ开头,如LZW、LZO、LZMA、LZSS、LZR、LZB、LZH、LZC、LZT、LZMW、LZJ、LZFG等等,非常多,LZW也比较流行,GIF那个动画格式记得用了LZW。我也写过解码程序,以后有时间可以再写一篇,但感觉跟LZ77这些类似,写的必要性不大。

    ZIP的作者是一个叫Phil Katz的人,这个人算是开源界的一个具有悲剧色彩的传奇人物。虽然二三十年前,开源这个词还没有现在这样风起云涌,但是总有一些具有黑客精神的牛人,内心里面充满了自由,无论他处于哪个时代。Phil Katz这个人是个牛逼程序员,成名于DOS时代,我个人也没有经历过那个时代,我是从Windows98开始接触电脑的,只是从书籍中得知,那个时代网速很慢,拨号使用的是只有几十Kb(比特不是字节)的猫,56Kb实际上是这种猫的最高速度,在ADSL出现之后,这种技术被迅速淘汰。当时记录文件的也是硬盘,但是在电脑之间拷贝文件的是软盘,这个东西我大一还用过,最高容量记得是1.44MB,这还是200X年的软盘,以前的软盘容量具体多大就不知道了,Phil Katz上网的时候还不到1990年,WWW实际上就没出现,浏览器当然是没有的,当时上网干嘛呢?基本就是类似于网管敲各种命令,这样实际上也可以聊天、上论坛不是吗,传个文件不压缩的话肯定死慢死慢的,所以压缩在那个时代很重要。当时有个商业公司提供了一种称为ARC的压缩软件,可以让你在那个时代聊天更快,当然是要付费的,Phil Katz就感觉到不爽,于是写了一个PKARC,免费的,看名字知道是兼容ARC的,于是网友都用PKARC了,ARC那个公司自然就不爽,把哥们告上了法庭,说牵涉了知识产权等等,结果Phil Katz坐牢了。。。牛人就是牛人, 在牢里面冥思苦想,决定整一个超越ARC的牛逼算法出来,牢里面就是适合思考,用了两周就整出来的,称为PKZIP,不仅免费,而且这次还开源了,直接公布源代码,因为算法都不一样了,也就不涉及到知识产权了,于是ZIP流行开来,不过Phil Katz这个人没有从里面赚到一分钱,还是穷困潦倒,因为喝酒过多等众多原因,2000年的时候死在一个汽车旅馆里。英雄逝去,精神永存,现在我们用UE打开ZIP文件,我们能看到开头的两个字节就是PK两个字符的ASCII码。

     

    2、一个案例的入门思考

    好了,Phil Katz在牢里面到底思考了什么?用什么样的算法来压缩数据呢?我们想一个简单的例子:

    生,容易。活,容易。生活,不容易。

    上面这句话假如不压缩,如果使用Unicode编码,每个字会用2个字节表示。为什么是两个字节呢?Unicode是一种国际标准,把常见各国的字符,比如英文字符、日文字符、韩文字符、中文字符、拉丁字符等等全部制定了一个标准,显然,用2个字节可以最多表示2^16=65536个字符,那么65536就够了吗?生僻字其实是很多的,比如光康熙字典里面收录的汉字就好几万,所以实际上是不够的,那么是不是扩到4个字节?也可以,这样空间倒是变大了,可以收录更多字符,但一方面扩到4个字节就一定保证够吗?另一方面,4个字节是不是太浪费空间了,就为了那些一般情况都不会出现的生僻字?所以,一般情况下,使用2个字节表示,当出现生僻字的时候,再使用4个字节表示。这实际上就体现了信息论中数据压缩基本思想,出现频繁的那些字符,表示得短一些;出现稀少的,可以表示得长些(反正一般情况下也不会出现),这样整体长度就会减小。除了Unicode,ASCII编码是针对英文字符的编码方案,用1个字节即可,除了这两种编码方案,还有很多地区性的编码方案,比如GB2312可以对中文简体字进行编码,Big5可以对中文繁体字进行编码。两个文件如果都使用一种编码方案,那是没有问题的,不过考虑到国际化,还是尽量使用Unicode这种国际标准吧。不过这个跟ZIP没啥关系,纯属背景介绍。

    好了,回到我们前面说的例子,一共有17个字符(包括标点符号),如果用普通Unicode表示,一共是17*2=34字节。可不可以压缩呢?所有人一眼都可以看出里面出现了很多重复的字符,比如里面出现了好多次容易(实际上是容易加句号三个字符)这个词,第一次出现的时候用普通的Unicode,第二次出现的“容易。”则可以用(距离、长度)表示,距离的意思是接下来的字符离前面重复的字符隔了几个,长度则表示有几个重复字符,上面的例子的第二个“容易。”就表示为(5,3),就是距离为5个字符,长度是3,在解压缩的时候,解到这个地方的时候,往前跳5个字符,把这个位置的连续3个字符拷贝过来就完成了解压缩,这实际上不就是指针的概念?没有错,跟指针很类似,不过在数据压缩领域,一般称为字典编码,为什么叫字典呢,当我们去查一个字的时候,我们总是先去目录查找这个字在哪一页,再翻到那一页去看,指针不也是这样,指针不就是内存的地址,要对一个内存进行操作,我们先拿到指针,然后去那块内存去操作。所谓的指针、字典、索引、目录等等术语,不同的背景可能称呼不同,但我们要理解他们的本质。如果使用(5,3)这种表示方法,原来需要用6个字节表示,现在只需要记录5和3即可。那么,5和3怎么记录呢?一种方法自然还是可以用Unicode,那么就相当于节省了2个字节,但是有两个问题,第一个问题是解压缩的时候怎么知道是正常的5和3这两个字符,还是这只是一个特殊标记呢?所以前面还得加一个标志来区分一下,到底接下来的Unicode码是指普通字符,还是指距离和长度,如果是普通Unicode,则直接查Unicode码表,如果是距离和长度,则往前面移动一段距离,拷贝即可。第二个问题,还是压缩程度不行,这么一弄,感觉压缩不了多少,如果重复字符比较长那倒是比较划算,因为反正“距离+长度”就够了,但比如这个例子,如果5和3前面加一个特殊字节,岂不是又是3个字节,那还不如不压缩。咋办呢?能不能对(5,3)这种整数进行再次压缩?这里就利用了我们前面说的一个基本原则:出现的少的整数多编一些比特,出现的多的整数少编一些比特。那么,比如3、4、5、6、7、8、9这些距离谁出现得多?谁出现的少呢?谁知道?

    压缩之前当然不知道,不过扫描一遍不就知道了?比如,后面那个重复的字符串“容易。”按照前面的规则可以表示为(7,3),即离前面重复的字符串距离为7,长度为3。(7,3)指着前面跟自己一样那个字符串。那么,为什么不指着第一个“容易。”要指着第二个“容易。”呢?如果指着第一个,那就不是(7,3)了,就是(12,3)了。当然,表示为(12,3)也可以解压缩,但是有一个问题,就是12这个值比7大,大了又怎么了?我们在生活中会发现一些普遍规律,重复现象往往具有局部性。比如,你跟一个人说话,你说了一句话以后,往往很快会重复一遍,但是你不会隔了5个小时又重复这句话,这种特点在文件里面也存在着,到处都是这种例子,比如你在编程的时候,你定义了一个变量int nCount,这个nCount一般你很快就会用到,不会离得很远。我们前面所说的距离代表了你隔了多久再说这句话,这个距离一般不大,既然如此,应该以离当前字符串距离最近的那个作为记录的依据(也就是指向离自己最近那个重复字符串),这样的话,所有的标记都是一些短距离,比如都是3、4、5、6、7而不会是3、5、78、965等等,如果大多数都是一些短距离,那么这些短距离就可以用短一些的比特表示,长一些的距离不太常见,则用一些长一些的比特表示。这样, 总体的表示长度就会减少。好了,我们前面得到了(5,3)、(7、3)这种记录重复的表示,距离有两种:5、7;长度只有1种:3。咋编码?越短越好。

    既然表示的比特越短越好,3表示为0、5表示为10、7表示为11,行不行?这样(5,3),(7,3)就只需要表示为100、110,这样岂不是很短?貌似可以,貌似很高效。

    但解压缩遇到10这两个比特的时候,怎么知道10表示5呢?这种表示方法是一个映射表,称为码表。我们设计的上面这个例子的码表如下:

    3-->0

    5-->10

    7-->11

    这个码表也得传过去或者记录在压缩文件里才行啊,否则无法解压缩,但会不会记录了码表以后整体空间又变大了,会不会起不到压缩的作用?而且一个码表怎么记录?码表记录下来也是一堆数据,是不是也需要编码?码表是否可以继续压缩?那岂不是又需要新的码表?压缩会不会是一个永无止境的过程?作为一个入门级的同学,大概想到这儿就不容易想下去了。

     

    3、ZIP中的LZ编码思想

    上面我们说的重复字符串用指针标记记录下来,这种方法就是LZ这两个人提出来的,理解起来比较简单。后面分析(5,3)这种指针标记应该怎么编码的时候,就涉及到一种非常广泛的编码方式,Huffman编码,Huffman大致和香农是一个时代的人,这种编码方式是他在MIT读书的时候提出来的。接下来,我们来看看ZIP是怎么做的。

    以上面的例子,一个很简单的示意图如下:

    可以看出,ZIP中使用的LZ77算法和前面分析的类似,当然,如果仔细对比的话,ZIP中使用的算法和LZ提出来的LZ77算法其实还是有差异的,不过我建议不用仔细去扣里面的差异,思想基本是相同的,我们后面会简要分析一下两者的差异。LZ77算法一般称为“滑动窗口压缩”,我们前面说过,该算法的核心是在前面的历史数据中寻找重复字符串,但如果要压缩的文件有100MB,是不是从文件头开始找?不是,这里就涉及前面提过的一个规律,重复现象是具有局部性的,它的基本假设是,如果一个字符串要重复,那么也是在附近重复,远的地方就不用找了,因此设置了一个滑动窗口,ZIP中设置的滑动窗口是32KB,那么就是往前面32KB的数据中去找,这个32KB随着编码不断进行而往前滑动。当然,理论上讲,把滑动窗口设置得很大,那样就有更大的概率找到重复的字符串,压缩率不就更高了?初看起来如此,找的范围越大,重复概率越大,不过仔细想想,可能会有问题,一方面,找的范围越大,计算量会增大,不顾一切地增大滑动窗口,甚至不设置滑动窗口,那样的软件可能不可用,你想想,现在这种方式,我们在压缩一个大文件的时候,速度都已经很慢了,如果增大滑动窗口,速度就更慢,从工程实现角度来说,设置滑动窗口是必须的;另一方面,找的范围越大,距离越远,出现的距离很多,也不利于对距离进行进一步压缩吧,我们前面说过,距离和长度最好出现的值越少越好,那样更好压缩,如果出现的很多,如何记录距离和长度可能也存在问题。不过,我相信滑动窗口设置得越大,最终的结果应该越好一些,不过应该不会起到特别大的作用,比如压缩率提高了5%,但计算量增加了10倍,这显然有些得不偿失。

    在第一个图中,“容易。”是一个重复字符串,距离distance=5,字符串长度length=3。当对这三个字符压缩完毕后,接下来滑动窗口向前移动3个字符,要压缩的是“我...”这个字符串,但这个串在滑动窗口内没找到,所以无法使用distance+length的方式记录。这种结果称为literal。literal的中文含义是原义的意思,表示没有使用distance+length的方式记录的那些普通字符。literal是不是就用原始的编码方式,比如Unicode方式表示?ZIP里不是这么做的,ZIP把literal认为也是一个数,尽管不能用distance+length表示,但不代表不可以继续压缩。另外,如果“我”出现在了滑动窗口内,是不是就可以用distance+length的方式表示?也不是,因为一个字出现重复,不值得用这种方式表示,两个字呢?distance+length就是两个整数,看起来也不一定值得,ZIP中确实认为2个字节如果在滑动窗口内找到重复,也不管,只有3个字节以上的重复字符串,才会用distance+length表示,重复字符串的长度越长越好,因为不管多长,都用distance+length表示就行了。

    这样的话,一段字符串最终就可以表示成literal、distance+length这两种形式了。LZ系列算法的作用到此为止,下面,Phil Katz考虑使用Huffman对上面的这些LZ压缩后的结果进行二次压缩。个人认为接下来的过程才是ZIP的核心,所以我们要熟悉一下Huffman编码。

     

    4、ZIP中的Huffman编码思想

    上面LZ压缩结果有三类(literal、distance、length),我们拿出distance一类来举例。distance代表重复字符串离前一个一模一样的字符串之间的距离,是一个大于0的整数。如何对一个整数进行编码呢?一种方法是直接用固定长度表示,比如采用计算机里面描述一个4字节整数那样去记录,这也是可以的,主要问题当然是浪费存储空间,在ZIP中,distance这个数表示的是重复字符串之间的距离,显然,一般而言,越小的距离,出现的数量可能越多,而越大的距离,出现的数量可能越少,那么,按照我们前面所说的原则,小的值就用较少比特表示,大的值就用较多比特表示,在我们这个场景里,distance当然也不会无限大,比如不会超过滑动窗口的最大长度,假如对一个文件进行LZ压缩后,得到的distance值为:

    3、6、4、3、4、3、4、3、5

    这个例子里,3出现了4次,4出现了3次,5出现了1次,6出现了1次。当然,不同的文件得到的结果不同,这里只是举一个典型的例子,因为只有4种值,所以我们没有必要对其它整数编码。只需要对这4个整数进行编码即可。

    那么,怎么设计一个算法,符合3的编码长度最短?6的编码长度最长这种直观上可行的原则(我们并没有说这是理论上最优的方式)呢?

    看起来似乎很难想出来。我们先来简化一下,用固定长度表示。这里有4个整数,只要使用2个比特表示即可。于是这样表示就很简单:

    00-->3; 01-->4; 10-->5;  11-->6。

    00、01这种编码结果称为码字,码字的平均长度是2。上面这个对应关系即为码表,在压缩时,需要将码表放在最前面,后面的数字就用码字表示,解码时,先把码表记录在内存里,比如用一个哈希表记录下来,解压缩时,遇到00,就解码为3等等。

    因为出现了9个数,所以全部码字总长度为18个比特。(我们暂时不考虑记录码表到底要占多少空间)

    想要编码结果更短,因为3出现的最多,所以考虑把3的码字缩短点,比如3是不是可以用1个比特表示,这样才算缩短吧,因为0和1只是二进制的一个标志,所以用0还是1没有本质区别,那么,我们暂定把3用比特0表示。那么,4、5、6还能用0开头的码字表示呢?

    这样会存在问题,因为4、5、6的编码结果如果以0开头,那么,在解压缩的时候,遇到比特0,就不知道是表示3还是表示4、5、6了,就无法解码,当然,似乎理论上也不是不可以,比如可以往后解解看,比如假定0表示3的条件下往后解,如果无效则说明这个假设不对,但这种方式很容易出现两个字符串的编码结果是一样的,这个谁来保证?所以,4、5、6都得以1开头才行,那么,按照这个原则,4用1个比特也不行,因为5、6要么以0开头,要么以1开头,就无法编码了,所以我们将4的码字增加至2个比特,比如10,于是我们得到了部分码表:

    0-->3;10-->4。

    按照这个道理,5、6既不能以0开头,也不能以10开头了,因为同样存在无法解码的问题,所以5应该以11开头,就定为11行不行呢?也不行,因为6就不知道怎么编码了,6也不能以0开头,也不能以10、11开头,那就无法表示了,所以,迫不得已,我们必须把5扩展一位,比如110,那么,6显然就可以用111表示了,反正也没有其他数了。于是我们得到了最终的码表:

    0-->3;10-->4;110-->5;111-->6。

    看起来,编码结果只能是这样了,我们来算一下,码字的总长度减少了没有,原来的9个数是3、6、4、3、4、3、4、3、5,分别对应的码字是:

    0、111、10、0、10、0、10、0、110

    算一下,总共16个比特,果然比前面那种方式变短了。我们在前面的设计过程中,是按照这些值出现次数由高到底的顺序去找码字的,比如先确定3,再确定4、5、6等等。按照一个码字不能是另一个码字的前缀这一规则,逐步获得所有的码字。这种编码规则有一个专用术语,称为前缀码。Huffman编码基本上就是这么做的,把出现频率排个序,然后逐个去找,这个逐个去找的过程,就引入了二叉树。不过Huffman的算法一般是从频率由低到高排序,从树的下面依次往上合并,不过本质上没区别,理解思想即可。上面的结果可以用一颗二叉树表示为下图:

    这棵树也称为码树,其实就是码表的一种形式化描述,每个节点(除了叶子节点)都会分出两个分支,左分支代表比特0,右边分支代表1,从根节点到叶子节点的一个比特序列就是码字。因为只有叶子节点可以是码字,所以这样也符合一个码字不能是另一个码字的前缀这一原则。说到这里,可以说一下另一个话题,就是一个映射表map在内存中怎么存储,没有相关经验的可以跳过,map实现的是key-->value这样的一个表,map的存储一般有哈希表和树形存储两类,树形存储就可以采用上面这棵树,树的中间节点并没有什么含义,叶子节点的值表示value,从根节点到叶子节点上分支的值就是key,这样比较适合存储那些key由多个不等长字符组成的场合,比如key如果是字符串,那么把二叉树的分支扩展很多,成为多叉树,每个分支就是a,b,c,d这种字符,这棵树也就是Trie树,是一种很好使的数据结构。利用树的遍历算法,就实现了一个有序Map。

    好了,我们理解了Huffman编码的思想,我们来看看distance的实际情况。ZIP中滑动窗口大小固定为32KB,也就是说,distance的值范围是1-32768。那么,通过上面的方式,统计频率后,就得到32768个码字,按照上面这种方式可以构建出来。于是我们会遇到一个最大的问题,那就是这棵树太大了,怎么记录呢?

    好了,个人认为到了ZIP的核心了,那就是码树应该怎么缩小,以及码树怎么记录的问题。

     

    5、ZIP中Huffman码树的记录方式

    分析上面的例子,看看这个码表:

    0-->3;10-->4;110-->5;111-->6。

    我们之前提过,0和1就是二进制的一个标志,互换一下其实根本不影响编码长度,所以,下面的码表其实是一样的:

    1-->3;00-->4;010-->5;011-->6。

    1-->3;01-->4;000-->5;001-->6。

    0-->3;11-->4;100-->5;101-->6。

    。。。。。

    这些都可以,而且编码长度完全一样,只是码字不同而已。

    对比一下第一个和第二个例子,对应的码树是这个样子:

    也就是说,我们把码树的任意节点的左右分支旋转(0、1互换),也可以称为树的左右互换,其实不影响编码长度,也就是说,这些码表其实都是一样好的,使用哪个都可以。

    这个规律暗示了什么信息呢?暗示了码表可以怎么记录呢?Phil Katz当年在牢里也深深地思考了这一问题。

    为了体会Phil Katz当时的心情,我们有必要盯着这两棵树思考几分钟:怎么把一颗树用最少的比特记录下来?

    Phil Katz当时思考的逻辑我猜是这样的,既然这些树的压缩程度都一样,那干脆使用最特殊的那棵树,反正压缩程度都一样,只要ZIP规定了这棵树的特殊性,那么我记录的信息就可以最少,这种特殊化的思想在后面还会看到。不同的树当然有不同的特点,比如数据结构里面常见的平衡树也是一类特殊的树,他选的树就是左边那棵,这棵树有一个特点,越靠左边越浅,越往右边越深,是这些树中最不平衡的树。ZIP里的压缩算法称为Deflate算法,这棵树也称为Deflate树,对应的解压缩算法称为Inflate,Deflate的大致意思是把轮胎放气了,意为压缩;Inflate是给轮胎打气的意思,意为解压。那么,Deflate树的特殊性又带来什么了?

    揭晓答案吧,Phil Katz认为换来换去只有码字长度不变,如果规定了一类特殊的树,那么就只需要记录码字长度即可。比如,一个有效的码表是0-->3;10-->4;110-->5;111-->6。但只需要记录这个对应关系即可:

    3  4  5  6

    1  2  3  3

    也就是说,把1、2、3、3记录下来,解压一边照着左边那棵树的形状构造一颗树,然后只需要1、2、3、3这个信息自然就知道是0、10、110、111。这就是Phil Katz想出来的ZIP最核心的一点:这棵码树用码字长度序列记录下来即可。

    当然,只把1、2、3、3这个序列记录下来还不行,比如不知道111对应5还是对应6?

    所以,构造出树来只是知道了有哪些码字了,但是这些码字到底对应哪些整数还是不知道。

    Phil Katz于是又出现了一个想法:记录1、2、3、3还是记录1、3、2、3,或者3、3、2、1,其实都能构造出这棵树来,那么,为什么不按照一个特殊的顺序记录呢?这个顺序就是整数的大小顺序,比如上面的3、4、5、6是整数大小顺序排列的,那么,记录的顺序就是1、2、3、3。而不是2、3、3、1。

    好了,根据1、2、3、3这个信息构造出了码字,这些码字对应的整数一个比一个大,假如我们知道编码前的整数就是3、4、5、6这四个数,那就能对应起来了,不过到底是哪四个还是不知道啊?这个整数可以表示距离啊,距离不知道怎么去解码LZ?

    Phil Katz又想了,既然distance的范围是1-32768,那么就按照这个顺序记录。上面的例子1和2没有,那就记录长度0。所以记录下来的码字长度序列为:

    0、0、1、2、3、3、0、0、0、0、0、。。。。。。。。。。。。

    这样就知道构造出来的码字对应哪个整数了吧,但因为distance可能的值很多(32768个),但实际出现的往往不多,中间会出现很多0(也就是根本就没出现这个距离),不过这个问题倒是可以对连续的0做个特殊标记,这样是不是就行了呢?还有什么问题?

    我们还是要站在时代的高度来看待这个问题。我们明白,每个distance肯定对应唯一一个码字,使用Huffman编码可以得到所有码字,但是因为distance可能非常多,虽然一般不会有32768这么多,但对一个大些的文件进行LZ编码,distance上千还是很正常的,所以这棵树很大,计算量、消耗的内存都容易超越了那个时代的硬件条件,那么怎么办呢?这里再次体现了Phil Katz对Huffman编码掌握的深度,他把distance划分成多个区间,每个区间当做一个整数来看,这个整数称为Distance Code。当一个distance落到某个区间,则相当于是出现了那个Code,多个distance对应于一个Distance Code,Distance虽然很多,但Distance Code可以划分得很少,只要我们对Code进行Huffman编码,得到Code的编码后,Distance Code再根据一定规则扩展出来。那么,划分多少个区间?怎么划分区间呢?我们分析过,越小的距离,出现的越多;越大的距离,出现的越少,所以这种区间划分不是等间隔的,而是越来越稀疏的,类似于下面的划分:

    1、2、3、4这四个特殊distance不划分,或者说1个Distance就是1个区间;5,6作为一个区间;7、8作为一个区间等等,基本上,区间的大小都是1、2、4、8、16、32这么递增的,越往后,涵盖的距离越多。为什么这么分呢?首先自然是距离越小出现频率越高,所以距离值小的时候,划分密一些,这样相当于一个放大镜,可以对小的距离进行更精细地编码,使得其编码长度与其出现次数尽量匹配;对于距离较大那些,因为出现频率低,所以可以适当放宽一些。另一个原因是,只要知道这个区间Code的码字,那么对于这个区间里面的所有distance,后面追加相应的多个比特即可,比如,17-24这个区间的Huffman码字是110,因为17-24这个区间有8个整数,于是按照下面的规则即可获得其distance对应的码字:

    17-->110 000

    18-->110 001

    19-->110 010

    20-->110 011

    21-->110 100

    22-->110 101

    23-->110 110

    24-->110 111

    这样计算复杂度和内存消耗是不是很小了,因为需要进行Huffman编码的整数一下字变少了,这棵树不会多大,计算起来时间和空间复杂度降低,扩展起来也比较简单。当然,从理论上来说,这样的编码方式实际上将编码过程分为了两级,并不是理论上最优的,把所有distance当作一个大空间去编码才可能得到最优结果,不过还是那句话,工程实现的限制,在压缩软件实现上,我们不能用压缩率作为衡量一个算法优劣的唯一指标,其实耗费的时间和空间同样是指标,所以需要看综合指标。很多其他软件也一样,扩展性、时间空间复杂度、稳定性、移植性、维护的方便性等等是工程上很重要的东西。我没有看过RAR是如何压缩的,有可能是在类似的地方进行了改进,如果如此,那也是站在巨人的肩膀上,而且硬件条件不同,进行一些改进也并不奇怪。

    具体来说,Phil Katz把distance划分为30个区间,如下图:

    这个图是我从David Salomon的《Data Compression The Complete Reference》这本书(第四版)中拷贝出来的,下面的有些图也是,如果需要对数据压缩进行全面的了解,这本书几乎是最全的了,强烈推荐。

    当然,你要问为什么是30个区间,我也没分析过,也许是复杂度和压缩率经过试验之后的一种折中吧。

    其中,左边的Code表示区间的编号,是0-29,共30个区间,这只是个编号,没有特别的含义,但Huffman就是对0-29这30个Code进行编码的,得到区间的码字;

    bits表示distance的码字需要在Code的码字基础上扩展几位,比如0就表示不扩展,最大的13表示要扩展13位,因此,最大的区间包含的distance数量为8192个。

    Distance一列则表示这个区间涵盖的distance范围。

    理解了码树如何有效记录,以及如何缩小码树的过程,我觉得就理解了ZIP的精髓。

     

    6、ZIP中literal和length的压缩方式

    说完了distance,LZ编码结果还有两类:literal和length。这两类也利用了类似于distance的方式进行压缩。

    前面分析过,literal表示未匹配的字符,我们前面之所以拿汉字来举例,完全是为了便于理解,ZIP之所以是通用压缩,它实际上是针对字节作为基本字符来编码的,所以一个literal至多有256种可能。

    length表示重复字符串长度,length=1当然不会出现,因为一个字符不值得用distance+length去记录,重复字符串当然越长越好,Phil Katz(下面还是简称PK了,拷贝太麻烦)认为,length=2也不值得用这种方式记录,还是太短了,所以PK把length最小值认为是3,必须3个以上字符的字符串出现重复才用distance+length记录。那么,最大的length是多少呢?理论上当然可以很长很长,比如一个文件就是连续的0,这个重复字符串长度其实接近于这个文件的实际长度。但是PK把length的范围做了限制,限定length的个数跟literal一样,也只有256个,因为PK认为,一个重复字符串达到了256个已经很长了,概率非常小;另外,其实哪怕超过了256,我还是认为是一段256再加上另外一段,增加一个distance+length就行了嘛,并不影响结果。而且这样做,我想同样也考虑了硬件条件吧。

    初看有点奇怪的在于,将literal和length二者合二为一,什么意思呢?就是对这两种整数(literal本质上是一个字节)共用一个Huffman码表,一会儿解释为什么。PK对Huffman的理解我觉得达到了炉火纯青的地步,前面已经看到,后面还会看到。他认为Huffman编码的输入反正说白了就是一个集合的元素就行,无论这个元素是啥,所以多个集合看做一个集合当作Huffman编码的输入没啥问题。literal用整数0-255表示,256是一个结束标志,解码以后结果是256表示解码结束;从257开始表示length,所以257这个数表示length=3,258这个数表示length=4等等,但PK也不是一直这么一一对应,和distance一样,也是把length(总共256个值)划分为29个区间,其结果如下图:

    其中的含义和distance类似,不再赘述,所以literal/length这个Huffman编码的输入元素一共285个,其中256表示解码结束标志。为什么要把二者合二为一呢?因为当解码器接收到一个比特流的时候,首先可以按照literal/length这个码表来解码,如果解出来是0-255,就表示未匹配字符,如果是256,那自然就结束,如果是257-285之间,则表示length,把后面扩展比特加上形成length后,后面的比特流肯定就表示distance,因此,实际上通过一个Huffman码表,对各类情况进行了统一,而不是通过加一个什么标志来区分到底是literal还是重复字符串。

    好了,理解了上面的过程,就理解了ZIP压缩的第二步,第一步是LZ编码,第二步是对LZ编码后结果(literal、distance、length)进行的再编码,因为literal/length是一个码表,我称其为Huffman码表1,distance那个码表称为Huffman码表2。前面我们已经分析了,Huffman码树用一个码字长度序列表示,称为CL(Code Length),记录两个码表的码字长度序列分别记为CL1、CL2。码树记录下来,对literal/length的编码比特流称为LIT比特流;对distance的编码比特流称为DIST比特流。

    按照上面的方法,LZ的编码结果就变成四块:CL1、CL2、LIT比特流、DIST比特流。CL1、CL2是码字长度的序列,这个序列说白了就是一堆正整数,因此,PK继续深挖,认为这个序列还应该继续压缩,也就是说,对码表进行压缩。

     

    7、ZIP中对CL进行再次压缩的方法

    这里仍然沿用Huffman的想法,因为CL也是一堆整数,那么当然可以再次应用Huffman编码。不过在这之前,PK对CL序列进行了一点处理。这个处理也是很精巧的。

    CL序列表示一系列整数对应的码字长度,对于literal/length来说,总共有0-285这么多符号,所以这个序列长度为286,每个符号都有一个码字长度,当然,这里面可能会出现大段连续的0,因为某些字符或长度不存在,尤其是对英文文本编码的时候,非ASCII字符就根本不会出现,length较大的值出现概率也很小,所以出现大段的0是很正常的;对于distance也类似,也可能出现大段的0。PK于是先进行了一下游程编码。在说什么是游程编码之前,我们谈谈PK对CL序列的认识。

    literal/length的编码符号总共286个(回忆:256个Literal+1个结束标志+29个length区间),distance的编码符号总共30个(回忆:30个区间),所以这颗码树不会特别深,Huffman编码后的码字长度不会特别长,PK认为最长不会超过15,也就是树的深度不会超过15,这个是否是理论证明我还没有分析,有兴趣的同学可以分析一下。因此,CL1和CL2这两个序列的任意整数值的范围是0-15。0表示某个整数没有出现(比如literal=0x12, length Code=8, distance Code=15等等)。

    什么叫游程呢?就是一段完全相同的数的序列。什么叫游程编码呢?说起来原理更简单,就是对一段连续相同的数,记录这个数一次,紧接着记录出现了多少个即可。David的书中举了这个例子,比如CL序列如下:

    4, 4, 4, 4, 4, 3, 3, 3, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2
    那么,游程编码的结果为:

    4, 16, 01(二进制), 3, 3, 3, 6, 16, 11(二进制), 16, 00(二进制), 17,011(二进制), 2, 16, 00(二进制)
    这是什么意思呢?因为CL的范围是0-15,PK认为重复出现2次太短就不用游程编码了,所以游程长度从3开始。用16这个特殊的数表示重复出现3、4、5、6个这样一个游程,分别后面跟着00、01、10、11表示(实际存储的时候需要低比特优先存储,需要把比特倒序来存,博文的一些例子有时候会忽略这点,实际写程序的时候一定要注意,否则会得到错误结果)。于是4,4,4,4,4,这段游程记录为4,16,01,也就是说,4这个数,后面还会连续出现了4次。6,16,11,16,00表示6后面还连续跟着6个6,再跟着3个6;因为连续的0出现的可能很多,所以用17、18这两个特殊的数专门表示0游程,17后面跟着3个比特分别记录长度为3-10(总共8种可能)的游程;18后面跟着7个比特表示11-138(总共128种可能)的游程。17,011(二进制)表示连续出现6个0;18,0111110(二进制)表示连续出现62个0。总之记住,0-15是CL可能出现的值,16表示除了0以外的其它游程;17、18表示0游程。因为二进制实际上也是个整数,所以上面的序列用整数表示为:

    4, 16, 1, 3, 3, 3, 6, 16, 3, 16, 0, 17, 3, 2, 16, 0

    我们又看到了一串整数,这串整数的值的范围是0-18。这个序列称为SQ(Sequence的意思)。因为有两个CL1、CL2,所以对应的有两个SQ1、SQ2。

    针对SQ1、SQ2,PK用了第三个Huffman码表来对这两个序列进行编码。通过统计各个整数(0-18范围内)的出现次数,按照相同的思路,对SQ1和SQ2进行了Huffman编码,得到的码流记为SQ1 bits和SQ2 bits。同时,这里又需要记录第三个码表,称为Huffman码表3。同理,这个码表也用相同的方法记录,也等效为一个码长序列,称为CCL,因为至多有0-18个,PK认为树的深度至多为7,于是CCL的范围是0-7。

    当得到了CCL序列后,PK决定不再折腾,对这个序列用普通的3比特定长编码记录下来即可,即000代表0,111代表7。但实际上还有一点小折腾,就是最后这个序列如果全部记录,那就需要19*3=57个比特,PK认为CL序列里面CL范围为0-15,特殊的几个值是16、17、18,如果把CCL序列位置置换一下,把16、17、18这些放前面,那么这个CCL序列就很可能最后面跟着一串0(因为CL=14,15这些很可能没有),所以最后还引入了一个置换,其示意图如下,分别表示置换前的CCL序列和置换后的CCL。可以看出,16、17、18对应的CCL被放到了前面,这样如果尾部出现了一些0,就只需要记录CCL长度即可,后面的0不记录。可以继续节省一些比特,不过这个例子尾部置换后只有1个0:

    不过粗看起来,这个置换效果并不好,我一开始接触这个置换的时候,我觉得应该按照16、17、18、0、1、2、3、。。。这样的顺序来存储,如果按照我理解的,那么置换后的结果如下:

    2、4、0、4、5、5、1、5、0、6、0、0、0、0、0、0、0、0、0

    这样后面的一大串0直接截断,比PK的方法更短。但PK却按照上面的顺序。我总是认为,我觉得牛人可能出错了的时候,往往是我自己错了,所以我又仔细想了一下,上面的顺序特点比较明显,直观上看,PK认为CL为0和中间的值出现得比较多(放在了前面),但CL比较小的和比较大的出现得比较少(1、15、2、14这些放在了后面,你看,后面交叉着放),在文件比较小的时候,这种方法效果不算好,上面就是一个典型的例子,但文件比较大了以后,CL1、CL2码树比较大,码字长度普遍比较长,大部分很可能接近于中间值,那么这个时候PK的方法可能就体现出优势了。不得不说,对一个算法或者数据结构的优化程度,简直完全取决于程序员对那个东西细节的理解的深度。当我仔细研究了ZIP压缩算法的过程之后,我对PK这种深夜埋头冥思苦想的大牛佩服得五体投地。

    到此为止,ZIP压缩算法的结果已经完毕。这个算法命名为Deflate算法。总结一下其编码流程为:

     

    8、Deflate压缩数据格式

    ZIP的格式实际上就是Deflate压缩码流外面套了一层文件相关的信息,这里先介绍Deflate压缩码流格式。其格式为:

    Header:3个比特,第一个比特如果是1,表示此部分为最后一个压缩数据块;否则表示这是.ZIP文件的某个中间压缩数据块,但后面还有其他数据块。这是ZIP中使用分块压缩的标志之一;第2、3比特表示3个选择:压缩数据中没有使用Huffman、使用静态Huffman、使用动态Huffman,这是对LZ77编码后的literal/length/distance进行进一步编码的标志。我们前面分析的都是动态Huffman,其实Deflate也支持静态Huffman编码,静态Huffman编码原理更为简单,无需记录码表(因为PK自己定义了一个固定的码表),但压缩率不高,所以大多数情况下都是动态Huffman。

    HLIT:5比特,记录literal/length码树中码长序列(CL1)个数的一个变量。后面CL1个数等于HLIT+257(因为至少有0-255总共256个literal,还有一个256表示解码结束,但length的个数不定)。

    HDIST:5比特,记录distance码树中码长序列(CL2)个数的一个变量。后面CL2个数等于HDIST+1。哪怕没有1个重复字符串,distance都为0也是一个CL。

    HCLEN:4比特,记录Huffman码表3中码长序列(CCL)个数的一个变量。后面CCL个数等于HCLEN+4。PK认为CCL个数不会低于4个,即使对于整个文件只有1个字符的情况。

    接下来是3比特编码的CCL,一共HCLEN+4个,用以构造Huffman码表3;

    接下来是对CL1(码长)序列经过游程编码(SQ1:缩短的整数序列)后,并对SQ1继续用Huffman编码后的比特流。包含HLIT+257个CL1,其解码码表为Huffman码表3,用以构造Huffman码表1;

    接下来是对CL2(码长)序列经过游程编码(SQ2:缩短的整数序列)后,并对SQ2继续用Huffman编码后的比特流。包含HDIST+1个CL2,其解码码表为Huffman码表3,用于构造Huffman码表2;

    总之,上面的数据都是为了构造LZ解码需要的2个Huffman码表。

    接下来才是经过Huffman编码的压缩数据,解码码表为Huffman码表1和码表2。
    最后是数据块结束标志,即literal/length这个码表输入符号位256的编码比特。
    对倒数第1、2内容块进行解码时,首先利用Huffman码表1进行解码,如果解码所得整数位于0-255之间,表示literal未匹配字符,接下来仍然利用Huffman码表1解码;如果位于257-285之间,表示length匹配长度,之后需要利用Huffman码表2进行解码得到distance偏移距离;如果等于256,表示数据块解码结束。

     

    9、ZIP文件格式解析

     上面各节对ZIP的原理进行了分析,这一节我们来看一个实际的例子,为了更好地描述,我们尽量把这个例子举得简单一些。下面是我随便从一本书拷贝出来的一段较短的待压缩的英文文本数据:

    As mentioned above,there are many kinds of wireless systems other than cellular.

    这段英文文本长度为80字节。经过ZIP压缩后,其内容如下:

    可以看到,第1、2字节就是PK。看着怎么比原文还长,这怎么叫压缩?实际上,这里面大部分内容是ZIP的文件标记开销,真正压缩的内容(也就是我们前面提到的Deflate数据,划线部分都是ZIP文件开销)其实肯定要比原文短(否则ZIP不会启用压缩),我们这个例子是个短文本,但对于更长的文本而言,那ZIP文件总体长度肯定是要短于原始文本的。上面的这个ZIP文件,可以看到好几个以PK开头的区域,也就是不同颜色的划线区域,这些其实都是ZIP文件本身的开销。

    所以,我们首先来看一看ZIP的格式,其格式定义为:

    [local file header 1]
    [file data 1]
    [data descriptor 1]
    ..........
    [local file header n]
    [file data n]
    [data descriptor n]
    [archive decryption header] 
    [archive extra data record] 
    [central directory]
    [zip64 end of central directory record]
    [zip64 end of central directory locator] 
    [end of central directory record]
    local file header+file data+data descriptor这是一段ZIP压缩数据,在一个ZIP文件里,至少有一段,至多那就不好说了,假如你要压缩的文件一共有10个,那这个地方至少会有10段,ZIP对每个文件进行了独立压缩,RAR在此进行了改进,将多个文件联合起来进行压缩,提高了压缩率。local file header的格式如下:

    可见,起始的4个字节就是0x50(P)、0x4B(K)、0x03、0x04,因为是低字节优先,所以Signature=0x03044B50.接下来的内容按照上面的格式解析,十分简单,这个区域在上面ZIP数据的那个图里面是红色划线区域,之后则是压缩后的Deflate数据。在文件的尾部,还有ZIP尾部数据,上面这个例子包含了central directory和end of central directory record,一般这两部分也是必须的。central directory以0x50、0x4B、0x01、0x02开头;end of central directory record以0x50、0x4B、0x05、0x06开头,其含义比较简单,分别对应于上面ZIP数据那个图的蓝色和绿色部分,下面是两者的格式:

    end of central directory record格式:

    这几张图是我从网上找的,写得比较清晰。对于其中的含义,解释起来也比较简单,我分析的结果如下:注意ZIP采用的低字节优先,在一个字节里面低位优先,需要反过来看。

    Local File Header: (38B,304b)
    00001010110100101100000000100000 (signature)
    0000000000010100 (version:20)
    0000000000000000 (generalBitFlag)
    0000000000001000 (compressionMethod:8)
    0100110110001110 (lastModTime:19854)
    0100010100100101 (lastModDate:17701)
    01010100101011010100001100111100 (CRC32)
    00000000000000000000000001001000 (compressedSize:72)
    00000000000000000000000001010000 (uncompressedSize:80)
    0000000000001000 (filenameLength:8)
    0000000000000000 (extraFieldLength:0)
    0010101010100110110011100010111001110100001011100001111000101110 (fileName:Test.txt)
     (extraField)


    Central File Header: (54B,432b)
    00001010110100101000000001000000 (signature)
    0000000000010100 (versionMadeBy:20)
    0000000000010100 (versionNeeded:20)
    0000000000000000 (generalBitFlag)
    0000000000001000 (compressionMethod:8)
    0100110110001110 (lastModTime:19854)
    0100010100100101 (lastModDate:17701)
    01010100101011010100001100111100 (CRC32)
    00000000000000000000000001001000 (compressedSize:72)
    00000000000000000000000001010000 (uncompressedSize:80)
    0000000000001000 (filenameLength:8)
    0000000000000000 (extraFieldLength:0)
    0000000000000000 (fileCommenLength:0)
    0000000000000000 (diskNumberStart)
    0000000000000001 (internalFileAttr)
    10000001100000000000000000100000 (externalFileAttr)
    00000000000000000000000000000000 (relativeOffsetLocalHeader)
    0010101010100110110011100010111001110100001011100001111000101110 (fileName:Test.txt)
     (extraField)
     (fileComment)


    end of Central Directory Record: (22B,176b)
    00001010110100101010000001100000 (signature)
    0000000000000000 (numberOfThisDisk:0)
    0000000000000000 (numberDiskCentralDirectory:0)
    0000000000000001 (EntriesCentralDirectDisk:1)
    0000000000000001 (EntriesCentralDirect:1)
    00000000000000000000000000110110 (sizeCentralDirectory:54)
    00000000000000000000000001101110 (offsetStartCentralDirectory:110)
    0000000000000000 (fileCommentLength:0)
     (fileComment)

    Local File Header Length:304
    Central File Header Length:432
    End Central Directory Record Length:176

    可见,开销总的长度为38+54+22=114字节,整个文件长度为186字节,因此Deflate压缩数据长度为72字节(576比特)。尽管这里看起来只是从80字节压缩到72字节,那是因为这是一段短文本,重复字符串出现较少,但如果文本较长,那压缩率就会增加,这里只是举个例子。

    下面对其中的关键部分,也就是Deflate压缩数据进行解析。

     

    10,Deflate解码过程实例分析

    我们按照ZIP格式把Deflate压缩数据(72字节)提取出来,如下(每行8字节):

    1010100001010011100010111011000000000001000001000011000010100010
    1000101110101010011110110000000001100011101110000011100010100101
    0101001111001100000010001101001010010010000101101010101100001101
    1011110100011111100011101111111001110010011101110110011100010101
    0010110100010100101100110001100100000100110111101101111000011101
    0010001001100110111001000010011001101010101000110110000001110101
    0100011010010011100010110111001000111101101001011100101010010111
    0111000011111000011110000011010111001011011111111100100010001001
    1010001100001110000010101010111101101010100101111101011111100000

    Deflate格式除了上面的介绍,也可以参考RFC1951,解析如下:

    Header:101, 第一个比特是1,表示此部分为最后一个压缩数据块;后面的两个比特01表示采用动态哈夫曼、静态哈夫曼、或者没有编码的标志,01表示采用动态Huffman;在RFC1951里面是这么说明的:

    00 - no compression

    01 - compressed with fixed Huffman codes

    10 - compressed with dynamic Huffman codes

    11 - reserved (error)

    注意,这里需要按照低比特在先的方式去看,否则会误以为是静态Huffman。

    接下来:
    HLIT:01000,记录literal/length码树中码长序列个数的一个变量,表示HLIT=2(低位在前),说明后面存在HLIT + 257=259个CL1,CL1即0-258被编码后的长度,其中0-255表示Literal,256表示无效符号,257、258分别表示Length=3、4(length从3开始)。因此,这里实际上只出现了两种重复字符串的长度,即3和4。回顾这个图可以更清楚:

    继续:
    HDIST:01010,记录distance码树中码长序列个数的一个变量,表示HDIST=10,说明后面存在HDIST+1=11个CL2,CL2即Distance Code=0-10被编码的长度。

    继续:

    HCLEN:0111,记录Huffman码树3中码长序列个数的一个变量,表示HCLEN=14(1110二进制),即说明紧接着跟着HCLEN+4=18个CCL,前面已经分析过,CCL记录了一个Huffman码表,这个码表可以用一个码长序列表示,根据这个码长序列可以得到码表。于是接下来我们把后面的18*3=54个比特拷贝出来,上面的码流目前解析为下面的结果:

    101(Header) 01000(HLIT) 01010(HDIST) 0111(HCLEN) 
    000 101 110 110 000 000 000 010 000 010 000 110 000 101 000 101 000 101 (CCL)
    110101010011110110000000001100011101110000011100010100101
    0101001111001100000010001101001010010010000101101010101100001101
    1011110100011111100011101111111001110010011101110110011100010101
    0010110100010100101100110001100100000100110111101101111000011101
    0010001001100110111001000010011001101010101000110110000001110101
    0100011010010011100010110111001000111101101001011100101010010111
    0111000011111000011110000011010111001011011111111100100010001001
    1010001100001110000010101010111101101010100101111101011111100000

    标准的CCL长度为19(回忆一下:CCL范围为0-18,按照整数大小排序记录各自的码字长度),因此最后一个补0。得到序列:

    000 101 110 110 000 000 000 010 000 010 000 110 000 101 000 101 000 101 000

    其长度分别为(低位在前):
    0、5、3、3、0、0、0、2、0、2、0、3、0、5、0、5、0、5、0
    前面已经分析过,这个CCL序列实际上是经过一次置换操作得到的,需要进行相反的置换,置换后为:

    3、5、5、5、3、2、2、0、0、0、0、0、0、0、0、0、0、5、3
    这个就是对应于0-18的码字长度序列。
    根据Deflate树的构造方式,得到下面的码表(Huffman码表3):

    00      <-->   5
    01      <-->   6
    100     <-->  0
    101     <-->  4
    110     <-->  18
    11100   <-->1
    11101   <-->2
    11110   <-->3
    11111   <-->17

    接下来就是CL1序列,按照前面的指示,一共有259个,分别对应于literal/length:0-258对应的码字长度序列,我们队跟着CCL后面的比特按照上面获得的码表进行逐步解码,在解码之前,实际上并不知道CL1的比特流长度有多少,需要根据259这个数字来判定,解完了259个整数,表明解析CL1完毕:

    101(Header) 01000(HLIT) 01010(HDIST) 0111(HCLEN) 
    000 101 110 110 000 000 000 010 000 010 000 110 000 101 000 101 000 101 (CCL)

    110(18)1010100(7比特,记录连续的11-138个0,此处一共0010101b=21,即记录21+11=32个0)

    11110(3)110(18)0000000(7比特,记录连续的11-138个0,此处为全0,即记录0+11=11个0)

    01(6)100(0)01(6)110(18)1110000(7比特,记录连续的11-138个0,此处为111b=7,即记录7+11=18个0)

    01(6)110(18)0010100(7比特,记录连续的11-138个0,此处为10100b=20,即记录20+11=31个0)

    101(4)01(6)01(6)00(5)11110(3)01(6)100(0)00(5)00(5)100(0)01(6)101(4)

    00(5)101(4)00(5)100(0)100(0)00(5)101(4)101(4)01(6)01(6)01(6)100(0)

    00(5)110(18)1101111(7比特,记录连续的11-138个0,此处为1111011b=123,即记录123+11=134个0)

    统计一下,上面已经解了32+11+18+31+134+30=256个数了,因为总共259个,还差三个:

    01(6)00(5)01(6)

    好了,CL1比特流解析完毕了,得到的CL1码长序列为:

    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0 0 0 0 0 
    0 0 0 0 6 0 6 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 6 6 5 3 6 0 5 5 0 6 4 5 4 5 0 0 5 4 4 6 6 6 
    0 5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 5 6

    总共259个,每行40个。根据这个序列,同样按照Deflate树构造方法,得到literal/length码表(Huffman码表1)为:

    000     --> (System.Char)(看前面的CL1序列,空格对应的ASCII为0x20=32,码字长度3,即上面序列中第一个3)
    001     -->e(System.Char)
    0100    -->a(System.Char)
    0101    -->l(System.Char)
    0110    -->n(System.Char)
    0111    -->s(System.Char)
    1000    -->t(System.Char)
    10010   -->d(System.Char)
    10011   -->h(System.Char)
    10100   -->i(System.Char)
    10101   -->m(System.Char)
    10110   -->o(System.Char)
    10111   -->r(System.Char)
    11000   -->y(System.Char)
    11001   -->3(System.Int32)(看前面的CL1序列,对应257,码字长度5)
    110100  -->,(System.Char)
    110101  -->.(System.Char)
    110110  -->A(System.Char)
    110111  -->b(System.Char)
    111000  -->c(System.Char)
    111001  -->f(System.Char)
    111010  -->k(System.Char)
    111011  -->u(System.Char)
    111100  -->v(System.Char)
    111101  -->w(System.Char)
    111110  -->-1(System.Int32)(看前面的CL1序列,对应256,码字长度6)
    111111  -->4(System.Int32)(看前面的CL1序列,对应258,码字长度6)

    可以看出,码表里存在两个重复字符串长度3和4,当解码结果为-1(上面进行了处理,即256),或者说遇到111110的时候,表示Deflate码流结束。

    按照同样的道理,对CL2序列进行解析,前面已经知道HDIST=10,即有11个CL2整数序列:

    11111(17)000(3比特,记录连续的3-10个0,此处为0,即记录3个0)

    11101(2)11111(17)100(3比特,记录连续的3-10个0,此处为001b=1,即记录4个0)

    11100(1)100(0)11101(2)

    已经结束,总共11个。

    于是CL2序列为:

    0 0 0 2 0 0 0 0 1 0 2

    分别记录的是distance码为0-10的码字长度,根据下面的对应关系,需要进行扩展:

    比如,第1个码长2记录的是Code=3的长度,即Distance=4对应的码字为:

    10      -->4(System.Int32)

    第1个码长1记录的是Code=8的长度(码字为0,扩展三位000-111),即Distance=17-24对应的码字为(注意,低比特优先):

    0 000    -->17(System.Int32)
    0 100    -->18(System.Int32)
    0 010    -->19(System.Int32)
    0 110    -->20(System.Int32)
    0 001    -->21(System.Int32)
    0 101    -->22(System.Int32)
    0 011    -->23(System.Int32)
    0 111    -->24(System.Int32)

    注意,扩展的时候还是低比特优先。

    最后1个码长2记录的是Code=10的长度(其实是码字:11,扩展四位0000-1111),即Distance=33-48对应的码字为:

    11 0000  -->33(System.Int32)
    11 1000  -->34(System.Int32)
    11 0100  -->35(System.Int32)
    11 1100  -->36(System.Int32)
    11 0010  -->37(System.Int32)
    11 1010  -->38(System.Int32)
    11 0110  -->39(System.Int32)
    11 1110  -->40(System.Int32)
    11 0001  -->41(System.Int32)
    11 1001  -->42(System.Int32)
    11 0101  -->43(System.Int32)
    11 1101  -->44(System.Int32)
    11 0011  -->45(System.Int32)
    11 1011  -->46(System.Int32)
    11 0111  -->47(System.Int32)
    11 1111  -->48(System.Int32)

    至此为止,Huffman码表1、Huffman码表2已经还原出来,接下来是对LZ压缩所得到的literal、distance、length进行解码,目前剩余的比特流如下,先按照Huffman码表1解码,如果解码结果是长度(>256),则接下来按照Huffman码表2解码,逐步解码即可:

    [As ]:110110(A)0111(s)000(空格)

    [mentioned ]:10101(m)001(e)0110(n)1000(t)10100(i)10110(o)0110(n)001(e)10010(d)000(空格)

    [above,]:0100(a)110111(b)10110(o)111100(v)001(e)110100(,)

    [there ]:1000(t)10011(h)001(e)10111(r)001(e)000(空格)

    [are ]:0100(a)11001(长度3,表示下一个需要用Huffman解码)10(Distance=4,即重复字符串为re空格)

    [many ]:10101(m)0100(a)0110(n)11000(y)000(空格)

    [kinds ]:111010(k)10100(i)0110(n)10010(d)0111(s)000(空格)

    [of ]:10110(o)111001(f)000(空格)

    [wireless ]:111101(w)10100(i)10111(r)001(e)0101(l)001(e)0111(s)0111(s)000(空格)

    [systems o]:0111(s)11000(y)0111(s)1000(t)001(e)10101(m)11001(长度指示=3,接下来根据distance解码)0110(Distance=20,即重复字符串为s o)

    [ther ]:111111(长度指示=4,接下来根据distance解码)111001(Distance=42,即重复字符串为ther)000(空格)

    [than ]:1000(t)10011(h)0100(a)0110(n)000(空格)

    [cellular.]:111000(c)001(e)0101(l)0101(l)111011(u)0101(l)0100(a)10111(r)110101(.)

    [256,结束标志]111110(结束标志)0000(字节补齐的0)

    于是解压缩结果为:

    As mentioned above,there are many kinds of wireless systems other than cellular.

    再来回顾我们的解码过程:

    译码过程:
    1、根据HCLEN得到截尾信息,并参照固定置换表,根据CCL比特流得到CCL整数序列;
    2、根据CCL整数序列构造出等价于CCL的二级Huffman码表3;
    3、根据二级Huffman码表3对CL1、CL2比特流进行解码,得到SQ1整数序列,SQ2整数序列;
    4、根据SQ1整数序列,SQ2整数序列,利用游程编码规则得到等价的CL1整数序列、CL2整数序列;
    5、根据CL1整数序列、CL2整数序列分别构造两个一级Huffman码表:literal/length码表、distance码表;
    6、根据两个一级Huffman码表对后面的LZ压缩数据进行解码得到literal/length/distance流;
    7、根据literal/length/distance流按照LZ规则进行解码。

    Deflate码流长度总共为72字节=576比特,其中:

    3比特Header;

    5比特HLIT;

    5比特HDIST;

    4比特HCLEN;

    54比特CCL序列码流;

    133比特CL1序列码流;

    34比特CL2序列码流;

    338比特LZ压缩后的literal/length/distance码流。

    11、ZIP的其它说明

    上面各个环节已经详细分析了ZIP压缩的过程以及解码流程,通过对一个实例的解压缩过程分析,可以彻底地掌握ZIP压缩和解压缩的原理和过程。还有一些情况需要说明:

    (1)上面的算法复杂度主要在于压缩一端,因为需要统计literal/length/distance,创建动态Huffman码表,相反解压只需要还原码表后,逐比特解析即可,这也是压缩软件的一个典型特点,解压速度远快于压缩速度。

    (2)上面我们分析了动态Huffman,对于LZ压缩后的literal/length/distance,也可以采用静态Huffman编码,这主要取决于ZIP在压缩中看哪种方式更节省空间,静态Huffman编码不需要记录码表,因为这个码表是固定的,在RFC1951里面也有说明。对于literal/length码表来说,需要对0-285进行编码,其码表为:

    对于Distance来说,需要对Code=0-29的数进行编码,则直接采用5比特表示。Distance和动态Huffman一样,在此基础上进行扩展。

    (3)ZIP中使用的LZ77算法是一种改进的LZ77。主要区别有两点:

    1)标准LZ77在找到重复字符串时输出三元组(length, distance, 下一个未匹配的字符)(有兴趣可以关注LZ77那篇论文);Deflate在找到重复字符串时仅输出双元组(length, distance)。
    2)标准LZ77使用”贪婪“的方式解析,寻找的都是最长匹配字符串。Deflate中不完全如此。David Salomon的书里给了一个例子:

    对于上面这个例子,标准LZ77在滑动窗口中查找最长匹配字符串,找到的是"the"与前面的there的前三个字符匹配,这种贪婪解析方式逻辑简单,但编码效率不一定最高。Deflate则不急于输出,跳过t继续往后查看,发现"th ne"这5个字符存在重复字符串,因此,Deflate算法会选择将t作为未匹配字符输出,而对后面的匹配字符串用(length, distance)编码输出。显然,这样就提高了压缩效率,因为标准的LZ77找到的重复字符串长度为3,而Deflate找到的是5。换句话说,Deflate算法并不是简单的寻找最长匹配后输出,而是会权衡几种可行的编码方式,用其中最高效的方式输出。

     

    12、总结

    本篇博文对ZIP中使用的压缩算法进行了详细分析,从一个简单地例子出发,一步步地分析了PK设计Deflate算法的思路。最后,通过一个实际例子,分析了其解压缩流程。总的来看,ZIP的核心在于如何对LZ压缩后的literal、length、distance进行Huffman编码,以及如何以最小空间记录Huffman码表。整个过程充满了对数据结构尤其是树的深入优化利用。按照上面的分析,如果要对ZIP进行进一步改进,可以考虑的地方也有不少,典型的有:

    (1)扩大LZ编码的滑动窗口的大小;

    (2)将Huffman编码改进为算术编码等压缩率更高的方法,毕竟,Huffman的码字长度必须为整数,这就从理论上限制了它的压缩率只能接近于理论极限,但难以达到。我记得在JPEG图像编码领域,以前的JPEG采用了DCT变换编码+Huffman的方式,现在JPEG2000将其改为小波变换+算数编码,所以数据压缩也可以尝试类似的思路;

    (3)将多个文件进行合并压缩,ZIP中,不同的文件压缩过程没有关系,独立进行,如果将它们合并起来一起进行压缩,压缩率可以得到进一步提高。

     

    描述分析有误的地方,敬请指正。针对数据压缩相关的话题,后续会对HBase列压缩等等进行分析,看看ZIP这种文件压缩和HBase这种数据库数据压缩的区别和联系。

    展开全文
  •  注意:如果要对子目录进行迭代压缩压缩后的文件不能放在被压缩目录的子目录中,否则会异常,用脚趾想想也应该知道怎么回事.... zip.ExtractZip(@"/Storage Card/foo.zip", @"/Storage Card/压缩后存放的...
  • 算法,压缩,解压缩算法,压缩,解压缩算法,压缩,解压缩算法,压缩,解压缩算法,压缩,解压缩文件压缩压缩算法大全C的文件压缩压缩算法大全C的文件压缩压缩算法大全C的
  • 都要通过网络拷贝,发送到reduce阶段,这一过程中,涉及到大量的网络IO,如果数据能够进行压缩,那么数据的发送量就会少得多,那么如何配置hadoop的文件压缩呢,以及hadoop当中的文件压缩支持哪些压缩算法呢?...
  • 都要通过网络拷贝,发送到reduce阶段,这一过程中,涉及到大量的网络IO,如果数据能够进行压缩,那么数据的发送量就会少得多,那么如何配置hadoop的文件压缩呢,以及hadoop当中的文件压缩支持哪些压缩算法呢?...
  • 基于huffman压缩算法文件压缩项目 今天我们来看看一个小项目.
  • 压缩算法

    2020-07-28 08:50:10
    此外,我们把相机拍完的照片保存到计算机上的时候,也会使用压缩算法进行文件压缩文件压缩的格式一般是JPEG。 那么什么是压缩算法呢?压缩算法又是怎么定义的呢?在认识算法之前我们需要先了解一下文件是如何存储...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 6,619
精华内容 2,647
关键字:

文件压缩算法