LZ77算法其实就是一个字符串匹配的算法。就是要顺序扫描待压缩文件的每一字节,查找当前这一字节开头的串(以下称“当前串”,当前串起始地址确定,但长度不确定)是否在已扫描过的文本中出现过,以及出现的位置。
为了快速查找当前串的匹配串,需要一张哈希表来记录之前出现的字符串的位置。但是这张哈希表可能非常之庞大。因为,单是从某一字节开始的串就有长度从1,2,3,……的一系列串, 要是待压缩文件又很大,处理到最后可能哈希表的规模会爆炸性增长。
所以,必须做一些限制。
一个限制是哈希表只记录固定长度(比如3字节长)的串的位置,不过这个限制其实并不影响更长串的匹配。因为通过哈希表至少已经能找到一些可能匹配的串的起始位置了,然后再做逐字节的匹配,直到不能匹配为止,匹配的长度越长越好。
另一个限制是,只匹配当前字符串之前W个字节范围内的串,这段随着扫描向前移动的W字节范围,就是所谓的滑动窗口。由于匹配只在滑动窗口范围内进行,所以压缩效果会略受影响。
第一个限制中的“固定长度”,也叫做“最小匹配长度”。如果滑动窗口大小已经确定下来,那么这个最小匹配长度也就能计算出来。
因为匹配只在滑动窗口中进行,匹配距离最大也就是W;既然是压缩,用(距离,长度)代替当前串后应该比原来占的空间少才行,直观上想象,匹配长度越长,压缩的效果就越好,反之,匹配长度越小,压缩效果就越差,当匹配长度小于某个值时,完全起不到压缩作用。如果匹配长度用L表示,并且假设(距离,长度)在上下文中能自说明,即不需要用额外的描述符标明这是一个(距离,长度)的开始,那么应该满足logW + logL<L,(即:表示距离的比特数+表示长度的比特数 < 当前串的比特数)。如果W取32K,即滑动窗口的大小是32K长度,则不等式应满足15 + logL < L,可以算出L应大于20,用字节为单位L至少应该是3字节。实际上gzip也是这样取值的,滑动窗口大小32K,最小匹配长度3字节,而匹配长度最多就可以8比特表示(15 + 8 < 3*8)。
需要特别指出的是,因为最小匹配长度是3,gzip用(实际的匹配长度 - 3)来记录匹配长度,这样能表示的实际匹配长度范围就扩大到[3, 255+3];同样的,距离用(实际的距离 - 1)来记录。可见gzip的精打细算。
滑动窗口只是一个概念,只要我们保证匹配的距离不超过W长度,那么逻辑上就有了一个长度为W的滑动窗口了。所以在实现上,只要尽可能保证在顺序扫描文本时,当前字节之前的W个字节还保存在内存中可供匹配就可以了。在Gzip中,用2W(64K)大小的缓存来实现滑动窗口(以下我们就称这个缓存为滑动窗口缓存,相对滑动窗口的偏移就是相对这个缓存起始地址的偏移)。Gzip一开始从待压缩文件中读64K数据放到这个缓存中,然后顺序扫描,用LZ77算法压缩。当差不多处理完这64K数据后,就会刷新滑动窗口缓存,把前32K数据用后32K数据覆盖掉,然后再从待压缩文件读取数据补充到缓存中来。
哈希表是用来存放键值对{三字节长度串,该串相对滑动窗口的偏移量}的。这个哈希表的数据结构异常的简单,就是一个整型数组head[],外加用来处理哈希冲突的“链表”:另一个整型数组prev[]。下面举个例子说明它们的含义。比如有head[h] = n,表示一个哈希值为h的3字节串相对滑动窗口的偏移是n,如果同时又有prev[n]=m,表示相对滑动窗口偏移n的3字节串与偏移量是m的3字节串的哈希值相同,且后者距当前串更远,这就是prev(ious)的含义。假如此时计算出当前串的哈希值正好是h,那么我们就逐字节比较window+head[h]和strstart(window是滑动窗口缓存起始地址,strstart是当前串地址),看到底匹配多长,然后再尝试匹配window+prev[prev[n]]和strstart,再继续尝试匹配window+prev[prev[prev[n]]]和strstart。。。最后选匹配长度最长的。
在扫描的过程中,一方面要尝试匹配当前串,另一方面要把每个字节起始的3字节串插入到哈希表中,以供后来者匹配。当刷新滑动窗口缓存时,同时要维护好这个哈希表,把所有偏移量剪掉32K,不足32K的清零,相当于把过旧的信息从哈希表中删除。