*如何找到最长匹配?
前面我们主要分析数组head[]的使用,现在我们看prev[]数组,该数组不仅可以用来解决冲突,还主要用于最长匹配的查找过程。
还是先来分析插入的过程。前面我们讲到插入过程就是用head[ins_h]来记录当前字符串(由三个连续字符构成)的出现位置,而ins_h就是当前字符串的哈希值,head[]数组的索引。可是如果准备将当前字符串的出现位置插到某个head[ins_h]的时候,head[ins_h]不为空怎么办?head[ins_h]不为空就是说此时head[ins_h]已经记录了一个位置,如果强行用当前字符串出现的位置更新head[ins_h],那后面查匹配串的时候就有可能找不到最优的匹配。例如,有如下字符串
“mnoabczxyuvwabc123456abczxydefgh”,
假设第二个“abc”字符串在插入字典的时候,把第一个“abc”字符串的位置覆盖掉了,那么当第三个“abc”字符串查找匹配的时候,就只能找到第二个“abc”字符串的位置,匹配长度也就只有3而已。可见,这种野蛮粗暴的直接覆盖方式对于压缩来讲几乎没有什么优势,必须有一种方法能够既存储当前的字符串出现位置,又能够存储之前的字符串出现位置。数组prev[]就是用来做这件事的。
注意(题外话):有人可能会想到当处理第三个“abc”字符串的时候,第二个“abc”字符串不是已经被长度距离对儿替换了么,那第三个“abc”字符串与第二个“abc”字符串的距离是不是就是8(因为第二个“abc”字符串被长度距离对儿替换,该字符串用了三个字节,而长度距离对儿只用了两个字节,所以第三个“abc”距离这个长度距离对儿的距离就是8B)?不是这样的,仍然是9B,第三个“abc”与第二个“abc”的距离仍然是字符串与字符串的距离而不是字符串与长度距离对儿的距离。长度距离对儿说白了是压缩的输出结果,而不是参与压缩处理过程的“原料”!前面我们讲过,压缩始终拿窗口中的数据来分析处理,将压缩结果放到另外一个地方输出,LZ77过程始终是拿窗口中的数据来分析处理的,窗口中的数据是不会被改动的,除非将WSIZE2的数据覆盖到WSIZE1,而长度距离对儿不过是LZ77的输出的结果罢了。还拿上面那个字符串举例,把上面那个字符串记为original_str,这个字符串在窗口中而且不会被改动;将第二个“abc”字符串用长度距离对儿替换后,整个字符串就是“mnoabczxyuvw(3,9)123456abczxydefgh”,将这个字符串记为output_str1,这个串是个LZ77的输出结果,不在窗口中;当我们准备分析第三个“abc”字符串的时候,我们是要对着在窗口中的原始字符串original_str来分析的,而不是对着output_str1来分析!!!所以,在original_str中,第三个“abc”字符串距离第一个“abc”字符串的距离为18B,距离第二个“abc”字符串的距离为9B。这个概念一定要深入理解,会影响到后面对源码的理解!!!
设ins_h为当前字符串的哈希值,strstart为当前字符串的位置。在插入字典的时候,如果发现head[ins_h]不为空,说明之前有过该字符串(当然也可能是冲突),那么就用prev[strstart]来记录之前的位置,对应伪代码“prev[strstart] = head[ins_h]”,然后再用当前位置更新head[ins_h],对应伪代码“head[ins_h] = strstart”。结合我们前面讲的内容,将字符串插入字典的过程到现在才算完整。
我们现在来分析一下prev[]数组的使用原理。prev[]数组共有WSIZE个元素,正好覆盖了一个查找缓冲区的最大范围。数组索引strstart是当前字符串的位置(即先行缓冲区第一个字符在window中的位置,这个概念贯穿本系列文章始终),而数组元素prev[strstart]是前一个字符串的位置。从这两句伪代码中,我们可以看出一种“链式”的关系,
prev[strstart] =head[ins_h];
head[ins_h] =strstart;
压缩是逐字节进行的,先行缓冲区是逐字节减小的。strstart从0开始,始终是先行缓冲区的第一个字节(先行缓冲区从1开始,别与window搞混了)。当它是0的时候,它就是window中的第0个字符。随着压缩的逐字节推进,strstart是在逐渐增大的,所以能够保证每次对prev[strstart]赋值的时候不会把旧的内容覆盖掉,每次赋值之前,prev[strstart]一定都是全新的!!!概念1,每次为prev[strstart]赋值之前,prev[strstart]一定都是全新的。
当前字符串的哈希值计算结束之后,先判断head[ins_h]是否为空,如果为空,说明没匹配,作罢;如果不为空,说明有匹配,那就用一个变量将head[ins_h]记录下来,我们将这个变量记为match_head,然后再将当前字符串插入到字典中,插入过程就是上面那两句伪代码(提示,head[ins_h]会被当前值更新)。概念2,如果有匹配,先将匹配位置head[ins_h]记录下来,再将当前字符串插入字典。这样就形成了一种“链式”关系,这种链式关系通过prev[]数组的索引和数组元素值将同一哈希值但是位置不同的字符串链在了一起。将上面那两句伪代码多执行几次,就很容易看到这条链,以下图为例,
上面这个例子将匹配链原理明明白白的展示出来,可以看出,prev[x]既是匹配串的位置,也是找到上一个匹配串位置的prev[]数组的索引!概念3,prev[x]既是匹配串的位置,也是找到上一个匹配串位置的prev[]数组的索引。匹配链的最大作用在于我们后面要讲的查找最长匹配。
该说一个细节了。前面多次提到在查匹配串的时候会判断head[x]或prev[x]是否为空,那怎么样算是空?其实从上面的图中就可以看出,为0的时候就是空,但是这里有几个细节要注意一下。压缩正式开始之前的初始化过程会把head[]数组全部初始化为0,但是不会初始化prev[]数组。prev[]数组的初始化会动态进行,源码中的注释是这样说的:“prev will beinitialized on the fly”。head[x]和prev[x]使用0作为“空”,即没有匹配串的标志,他俩的值的本身的含义是字符串的位置,但是window中的首个字符串,也就是由window的第0个字符组成的字符串,位置就是0,这就产生歧义了。源码解决这个歧义的方法非常简单粗暴,就是干脆不让window中的首个字符串参与匹配过程,源码注释是这样说的:“To simplify the code,we prevent matches with the string of window index0.”(尽管不让其参与匹配过程,但这个字符串还是要往字典中插的,插入动作还是要执行的,只不过因为插入的值是0,所以匹配的时候会认为这是个空位置而已)。这样就保证了匹配过程中的head[x]和prev[x]如果为0,就只表示没有匹配串。
查找缓冲区最多有32KB,数组prev[]共有32KB个元素,而该数组索引的实际意义又是字符串的位置,所以我们能将该数组与查找缓冲区联系起来。我们知道,原始数据是放到window中的,window有两个WSIZE大;先行缓冲区的第一个字节strstart(就是先行缓冲区首个字符,window中的首个字符我会说window的第0个字节,两个说法不同,不要搞混)随着压缩的逐字节进行而在window中不断推进,势必要大于32K(strstart的范围是闭区间[0, 2*WSIZE - MIN_LOOKAHEAD],前面提到过一点),而strstart的位置其实就是当前字符串的位置。还没看出问题吗?prev[]只有32K个元素,其索引就是字符串的位置,而strstart如果大于32K,那还怎么把字符串插到字典中?prev[strstart]不就数组越界了么?!为了解决这个问题,压缩算法内部对prev[]的索引使用了一个非常巧妙而又简单易懂的处理方法:让strstart与WMASK按位与运算的结果作prev[]的索引,而不是直接用strstart作prev[]的索引,其中,WMASK的值是十进制的32768,即,prev[strstart & WMASK]。这样就保证了在strstart的取值范围内,prev[]的索引与strstart都是完全对应的。例如,strstart = 300,那么strstart & WMASK = 300;strstart = 32968 = 32K + 200,那么strstart & WMASK = 200。这么看来,prev[]数组可以做到在strstart超过32K的时候,始终保证匹配链的长度在32K的范围内,用新的字符串位置值替换掉同一索引下旧的字符串位置值(旧的位置与当前字符串的位置之间的距离有可能已经超过32K,就算不超过也是所有匹配串中最远的),如下图所示,
不觉得有什么问题吗?有没有发现当strstart大于32K的时候,匹配链有可能出现死循环?当使用新的位置值覆盖prev[x_min]的时候,这个被覆盖的prev[x_min]有可能是0,即“再往前没有匹配串”。如果没被覆盖,搜索匹配链时如果搜到这个节点,就不会继续再搜了;但是现在被覆盖了,也就是说表示“再往前没有匹配串”这个语义的节点不存在了,那搜索怎么停止?如果不停止一定就是死循环。压缩对于该问题的处理方法是设置一个最大链长度,记为max_chain_length,每次沿着匹配链查找的时候,最多找max_chain_length个节点。这样不仅能够解决这种潜在的死循环问题,还可以通过设置max_chain_length为不同的值来加快压缩速度。max_chain_length值越小,搜索的匹配节点越少,压缩速度越快,但是压缩率可能会相对低一些。
到现在为止,有关往字典中“插入”字符串的原理就算分析完善了。下面我们来看LZ77的重点:如何找到最长匹配。前面我们提到了一个变量,match_head,现在要用到;之前一直说的哈希冲突问题,也会在这里说明。
在选择最长匹配的过程中,不得不提到一种算法思想:贪心算法。贪心算法是一种算法思想而不是某种具体的算法实现,个人认为其精髓在于局部最优解的获取。压缩这里也用到了这种思想,所谓最长匹配,只是局部的一种最长匹配而已,但是从贪心算法可知,这种局部最优是接近全局最优的。
对于最长匹配问题,压缩同时使用了两种处理方式,这两种方式共同作用下的结果就是得到局部最长匹配,我称这两种方式分别为:懒惰匹配和longest_match。不要直译后者,会被误导的。后者其实是源码中的一个函数,但只完成了查找最长匹配的一部分,只有结合了懒惰匹配,才是完整的查找最长匹配的过程。懒惰匹配针对不同字符串找最长匹配,而longest_match找当前字符串的最长匹配,这两种方式的维度不同。
先来分析懒惰匹配。根据前文,我们已知strstart表示先行缓冲区第一个字节,而且是当前字符串的起始字符。那么(strstart + 1)和(strstart + 2)就是后续字符串的起始字符。分别以这三个字符为起始字符,可以得到三个不同的字符串(每个字符串都由连续的三个字符组成)。利用前文提到过的匹配链查找方法,我们假设这三个字符串都可以找到自己的匹配串,但是(strstart + 3)没有匹配串。所谓懒惰匹配就是,虽然当前字符串找到了最适合自己的那个匹配串,但是并不急于让长度距离对儿将其替换,而是用两个变量分别将匹配长度和匹配位置记录下来,这两个变量就是prev_length和prev_match,前者记录匹配长度,后者记录匹配位置。然后让strstart往先行缓冲区方向挪动一个字符,即让查找缓冲区吞噬先行缓冲区一个字节,而先行缓冲区减少一个字节,对应伪代码
strstart++;
现在strstart到了新的位置,当前串更新。此时再找最适合当前串的匹配串。我们假设找到了最适合当前串的匹配串,得到了匹配位置以及匹配长度,此时就是懒惰匹配大显身手的时候了。用当前匹配串的匹配长度和prev_length做比较,如果前者大于后者,则用前者将后者更新,同时用当前的匹配位置更新prev_match,而(当前strstart – 1)位置处的那个字符就作为一个未匹配字符来处理,当prev_length和prev_match完成更新后,strstart再往先行缓冲区的方向挪动一个字符并继续进行同样的处理,这种处理方式就是懒惰匹配;如果前者小于后者,即上次的匹配长度(prev_length)大于这次的,那么懒惰匹配停止,并用上次的匹配长度和匹配位置组成长度距离对儿二元组完成替换。懒惰匹配停止之后,将prev_length和prev_match初始化,将strstart挪到新的位置(被替换字符串之外),准备开始新一轮的懒惰匹配过程。注意,被替换字符串中每一个字符所组成的字符串仍然要插入字典。例如,有如下字符串,
“1abc23bcdefghijklm456abcdefghijklmnopq”,
假设现在strstart是21,即第二个“abc”中a的位置(这里要从0开始)。当前字符串即第二个“abc”,其匹配串位置为1,匹配长度为3,所以prev_match = 1,prev_length = 3,但是现在并不替换,而是让strstart往先行缓冲区的方向挪动一个字节,即strstart++;现在strstart是第二个“abc”中b的位置,即22,并且找到了匹配串,其匹配串位置为6,匹配长度为12,该长度大于prev_length,所以将prev_length和prev_match用当前值更新,对应伪代码是“prev_length = 12, prev_match =6;”,将位置21处的那个a作为一个未匹配字符来处理,此时仍然不替换,而是继续往下进行,让strstart往先行缓冲区的方向挪动一个字节,即strstart++;现在strstart是第二个“abc”中c的位置,即23,并且找到了匹配串,其匹配串位置为7,匹配长度为11,由于该匹配长度小于prev_length,所以懒惰匹配停止,开始替换。被替换字符串为“bcdefghijklm”,匹配长度为12,匹配距离为(22 – 6),替换结果为,
“1abc23bcdefghijklm456a(12, 16)nopq”,
其中,“a(12, 16)”中的a作为一个未匹配字符来处理。此时流程还未结束,strstart仍然要在被替换字符串“bcdefghijklm”中逐字节处理,从而将每个单独串都插入到字典中,这些单独串例如“bcd”、“cde”、“def”、“efg”、“fgh”,一直到字符串“mno”,都要被插入字典;此时完成了一个替换,prev_length和prev_match此时的值不能再用于后续的懒惰匹配,所以要将prev_length和prev_match从新初始化,初始化的值我们会在后面的源码分析中见到。提示,当先行缓冲区的尺寸小于3时(放到此例中就是strstart到了o的位置),该先行缓冲区的所有字符都作为未匹配字符处理。懒惰匹配原理就是这样。
现在来分析longest_match过程。简单来说,longest_match就是遍历匹配链的过程。当strstart移动到一个新位置,并且在字典中发现存在匹配串时,这个遍历过程就开始了。前面我们提到一个变量,match_head,这个变量记录的就是距离strstart最近的那个匹配串的位置,通过这个变量可以遍历整个匹配链,匹配链中每一个节点既是匹配串位置又是找到上一个匹配串的索引。匹配过程就是沿着匹配位置和当前位置,逐字节的判断是否相等,直到找到不相等的那个字符,然后记录匹配长度;通过遍历整个匹配链,找到当前字符串在其对应的匹配链中的那个最长匹配。因为找到匹配串的位置仍然要逐字节查看是否相等,这样就解决了哈希冲突的问题,就算哈希冲突,也能够通过这种方式发现字符串不匹配,如果不匹配,那么再接着遍历匹配节点即可。例如,有如下字符串,
“1abc290abcdef3abcdefghijklm456abcdefghijklmnopq”,
为了方便说明,我们将其中的主要字符串前面加上带圈字符(在word初稿里我用带圈字符,但是csdn不支持带圈字符,所以这里使用斜体替代),如下,
“11abc2902abcdef33abcdefghij5klm4564abcdefghijklmnopq”,
简单起见,忽略其中更细节的匹配过程,比如字符串“def”等的匹配,我们只看“abc”这几个字符串。前面已经多次强调,压缩的处理过程始终是针对原始数据而不是中途的压缩结果的,所以就算2中的“abc”被替换成了长度距离对儿,但是 3 和 4 的压缩过程仍然使用原始字符串来分析。当strstart到了4的a时,开始遍历匹配链。假设字符串 5 和“abc”的哈希值相同,那么此时通过哈希值找到的第一个匹配就是 5,但是从a和k开始逐字节比较,发现根本不同,所以5这个“匹配”放弃,继续遍历哈希链,到了 3 ;新的匹配是 3,从这两个字符串的首个字符a开始,逐个比较,发现一口气可以比到字符m,记录这个长度,继续往前遍历匹配链;后续匹配串分别是2 和 1,但是匹配长度都不如 3,所以就拿 3 作为当前strstart的最适合匹配串。
这里要注意几个问题:
i. 匹配节点的遍历不是无限进行的,有一个最大匹配链节点个数限制,这个我们前面已经提到过;
ii. 匹配串的匹配长度也不是无限的,一个匹配串的最大匹配长度为MAX_MATCH,源码中该值为258。为什么是258,会在后面的哈夫曼编码相关章节中分析;
iii. 匹配距离不是无限的,匹配距离不能超过MAX_DIST,源码中该值为(WSIZE-MIN_LOOKAHEAD)。其实这个才是查找缓冲区真正个尺寸。
最长匹配串的获得由longest_match和懒惰匹配共同完成。前者负责找到当前strstart的最长匹配,后者负责在连续几个strstart的最长匹配中找到匹配串长度最长的那个作为最终的、真正的最长匹配。二者维度不同,前者只针对一个strstart;后者针对不同但是连续的strstart,而且这些strstart都已经找到自己专属的那个最长匹配。