缩小,减少和爆炸:遗留ZIP压缩方法

2021-03-15 03:46:11

去年我写了一篇关于zip文件的长篇文章,涵盖了历史记录,详细描述了缩小的压缩方法,并显示了一个简单的zip程序的实现。我对这个主题着迷:数据压缩的魔力,以及这种真正无处不在的文件格式如何工作。

然而,ZIP文件支持许多不同的压缩方法,但虽然令人放气是今天最常用的,但在引入ZIP文件格式之后,它不会添加到几年后。虽然今天早期的方法不相关,但所涉及的技术仍然有趣和相关。例如,使用LZW压缩的第一个方法,其中普及的字典压缩,由于专利问题而获得的臭名昭着,并且仍然广泛用于GIF文件。从历史的角度来看,较旧的方法允许我们追踪PKZIP根部的进化到我们今天使用的放气方法。

本文介绍并实现缩小,减少和屏蔽压缩方法。前一篇文章不需要阅读,但为读者提供有用的背景,而无需以前的ZIP文件的知识。所有代码都在hwzip-2.0.zip中提供。

非常感谢Ange Albertini,Mark Nelson,Jason Summers,Rui Ueyma和Nico WeberWho为本文的草案提供了宝贵的反馈。

菲尔卡特斯通过创建自己版本的那个流行的acc程序,从而始于那个流行的acc程序,他称为pkarc。与原始计划的法律纠纷'" arc战争",katz创建了自己的文件格式:zip文件。 (有关更多历史,请参阅上一篇文章。)

弧使用称为LZW的压缩算法。由于KATZ在他的PKARC程序中实现了各种改进,因此在创建新的文件格式时使用该体验是自然的。原始的PKZIP压缩方法是katz称为收缩的LZW变体。它首先在Beta版本,PKzip 0.80中发布,然后在1989年初在PKzip 0.90中公开发布。

与所有ZIP压缩方法一样,PKZIP附带的应用笔记中描述了缩小。然而,描述非常简短,并假设与LZW熟悉熟悉。

LZW在1984年纸上介绍了Terry Welch,称为高性能数据压缩技术。它在1978年亚伯拉罕LEMPEL和JACOB ZIV的算法上建立了一个算法,因此名称:LEMPEL-ZIV-WELCH(LZW)压缩。

与LEMPEL和ZIV' S纸(LZ78)在专业的科学期刊上发表,并在理论结果上专注于实用的压缩算法,Welch' S paper在IEEE' SCOBER是一种广泛读取的杂志,并以非常便宜的方式引入了一种实用且有效的压缩方法。

事实上,该算法在韦尔奇&#39中如此简单且很好地解释了读者们诱惑坐下来实现它 - 这正是斯宾塞W.托马斯,然后是犹他大学助理教授&#39 ;计算机图形组(带茶壶的人)。托马斯称他的程序压缩,并与Net.Sources Uenet Post与世界分享。

在LZW之前,常见的压缩计划全部基于霍夫曼编码的变体。托马斯' s usenet post与Unix紧凑型和包装程序进行比较,并在PC世界中挤压在霍夫曼编码有时被称为"挤压"这些程序通过将字节转换为霍夫曼' s算法分配的可变长度代码来压缩数据:公共字节获得更短的代码,产生了更小的数据总体表示。但是,LZW在更高的级别上工作:而不是在单个字节上运行,它将代码分配给字节序列,这可能导致重复发生的序列的更大压缩。

Compress很快成为UNIX上的数据压缩的事实上的程序,到了POSIX标准中的指出。 LZW也用于许多其他程序,硬件和文件格式 - 最值得注意的是GIF图像文件格式,这可能是今天最常用的。

(Compress创建带有.z扩展的文件,许多人假设与zip文件有关。但是,压缩了几年的zip文件格式。相反,扩展可能由pack启发,它使用小写.z为其输出文件。包的起源并不完全清楚,但是史蒂夫·扎克在兰德写的一个早期版本。如果这是第一个版本,也许z是针对zucker。)

虽然Welch' S纸做了一个很好的解释LZW,但它遗漏了一个重要的细节:该算法获得专利。几年后,在拥有专利的公司造成的软件社区中引起了主要争议,实现了专利的公司,实现了如何广泛使用该算法并开始提取其许可费用。这对GIF文件尤其有问题。虽然专利现在有谢闻地过期,但算法臭名昭着。

LZW是一种基于字典的压缩方法:将源数据解析为在字典中发生的子字符串,并且算法的结果是指代所述词典条目的代码序列。将新条目添加到字典中,因为输入已处理。

字典表示为一组代码和它们的相应字符串。只要该字符串存在于字符串中。当用字典中不再存在下一个字节的当前字符串不再存在,输出与当前字符串相对应的代码,将添加新字符串(当前字符串+下一个字节)添加到字典为稍后使用,该过程继续下一个字节作为当前字符串。在结束时,输出代码序列是压缩的结果。

例如,如果我们将输入限制到英文字母表,则初始字典可以如下所示:

八个字符的输入已被压缩到七个输出代码:12,15,14,4,28,5,18。

initialize_dictionary(); current_code = get_input_byte(); while(more_input_available()){current_byte = get_input_byte(); new_string =字典[current_code] + clust_byte; if(new_string在字典中存在){current_code = get_code_for(new_string); } else {输出(current_code);字典。添加(new_string); current_code = clust_byte;输出(current_code);

LZW解压缩还开始于由所有单字节字符串组成的字典。通过查找和输出来自字典的相应字符串来处理输入代码。棘手的部分正在更新字典。当压缩机输出代码时,该代码代表"当前字符串",然后压缩机添加那个"当前字符串"加上字典的下一个字节。解压缩器无法直接观察到字典的更新,因为它只看到输出代码。但是,它可以推断更新,因为它知道当它收到一个新代码时,由上一个代码加上的字符串添加到字典中,并将其添加到字段中,并且"以下字节"是当前代码表示的字符串的第一个字节。

注意每个添加的字符串是先前输出的字符串,以及下一个字符串的第一个字节。

然而,这里有一个障碍。因为解压缩器始终在压缩机后面添加到字典中的一步,所以它可能在解压缩器添加到字典之前使用代码。

关于这个例子的特别是&#39的特别是代码31(" ana")在处理第三个&#34时添加到词典中;一个",然后立即得到输出,这意味着解压缩器不会有机会添加它:

这被称为LZW" kwkwk问题"如果k是一个字节,wa字符串字节,并且kw已经存在于字典中,则压缩字符串kwkwk将导致kwk的代码添加到字典中,然后立即使用,然后立即使用,这意味着解压缩器将在之前看到代码它存在于它的字典中。 (在该示例中,K是" A"和W是" n")

幸运的是,这是在Decompressor' S字典中可以使用代码的唯一情况。它可以作为特殊情况处理。当解压缩器接收到作为下一个代码的输入代码是将添加到字典中的下一个代码时,它知道它' s" kwkwk"情况,并且代码对应于先前输出的字符串(kW),其第一个字节(k)扩展。

initialize_dictionary(); current_code = get_input_code();输出(Current_code); while(more_input_available()){previous_code = current_code; current_code = get_input_code(); if(current_code == next_unused_code()){/ *处理kwkwk案例。 * /字典[current_code] =字典[previous_code] +字典[previous_code] [0];输出(字典[current_code]); } else {输出(字典[current_code]);字典。添加(字典[previous_code] +字典[current_code] [0]); }}

LZW的一部分' S appeal是它' s相对容易实施。以下侧重于PKZIP' S LZW变体的实际实施。

由于算法如何工作,添加到字典中的字符串始终由字典中的前缀字符串组成,用单个字节扩展。这意味着通过仅存储前缀和扩展字节的代码,可以以内存高效的方式表示字典条目。

我们可以将字典视为树结构(Trie的形式)。例如,上面的伦敦人示例中的字典形成了这棵树:

树中的每个节点对应于代码,并且表示从根到从根到节点的步行时形成的字符串,沿方式连接字节。例如,右下方的节点表示字符串"一个" (代码31)。

对于压缩,字典表示需要支持快速查找字符串(前缀+扩展字节),或者如果存在,则将字符串插入' t存在。为了适应这一点,我们将存储压缩机'哈希表中的字典树节点,前缀代码和扩展字节作用为键,代码为值。而不是在字典中插入初始ontete字符串,我们将根据隐式显示它们。代码可用于Shrink.c。

#define max_code_size 13#定义hash_bits(max_code_size + 1)/ *的负载系数为0.5。 * / #define hashtab_size(1u<< hash_bits)/ *哈希表,其中键(前缀_code,ext_byte)对,并且值*是相应的代码。如果prefix_code为Invalid_code,则表示哈希*表槽为空。 * / typedef struct hashtab_t hashtab_t; struct hashtab_t {uint16_t prefix_code; uint8_t ext_byte; uint16_t代码;};静态void hashtab_init(hashtab_t *表){size_t i; for(i = 0; i< hashtab_size; i ++){表[i]。 prefix_code = Invalid_code; }}

要在字典中查找字符串,我们散列前缀代码和扩展字节,然后扫描散列表线性从散列确定的位置开始:

静态UINT32_T哈希(UINT16_T代码,UINT8_T字节){静态CONST uint32_t hash_mul = 2654435761u; / * Knuth'乘法哈希。 * / return(((((((((((uint32_t)byte<< 16)|代码)* hash_mul)>> (32 - hash_bits);} / *如果表中存在于表中,或者返回对应于前缀代码和扩展字节的代码。 * /静态uint16_t hashtab_find(const hashtab_t * table,uint16_t prefix_code,uint8_t ext_byte){size_t i = hash(prefix_code,ext_byte);断言(prefix_code!= Invalid_code);虽然(true){/ *扫描,直到我们找到键或空插槽。 * /断言(i< hashtab_size); if(表[i]。prefix_code == prefix_code&表[i]。ext_byte == ext_byte){return表[i]。代码 ; }如果(表[i]。prefix_code == Invalid_code){返回Invalid_code; } i =(i + 1)%hashtab_size;断言(i!=哈希(prefix_code,ext_byte)); }}

静态void hashtab_insert(hashtab_t *表,uint16_t prefix_code,uint8_t ext_byte,uint16_t代码){size_t i =哈希(prefix_code,ext_byte);断言(prefix_code!= Invalid_code);断言(代码!= invalid_code);断言(hashtab_find(表,prefix_code,ext_byte)== Invalid_code);虽然(true){/ *扫描,直到我们找到一个空插槽。 * /断言(i< hashtab_size); if(表[i]。prefix_code == Invalid_code){中断; } i =(i + 1)%hashtab_size;断言(i!=哈希(prefix_code,ext_byte)); }断言(i< hashtab_size);表[i]。代码=代码;表[i]。 prefix_code = prefix_code;表[i]。 ext_byte = ext_byte; serrert(hashtab_find(表,prefix_code,ext_byte)==代码);}

对于解压缩,字典表示需要支持查找或使用特定代码插入字符串。这意味着我们可以将树节点保留在代码索引的数组中。

树表示的一个问题是,当检索与某个代码对应的字符串时,我们查找代码的节点,从节点到root,并以错误的顺序遇到字符串:它' s中的字符串撤销。

Welch' s纸张建议使用堆栈以正确的顺序输出代码的字符串:在从节点到root的步行时,按下堆栈上的字节,然后通过弹出堆栈,然后在堆栈中输出它们#39; s空。

堆栈方法虽然它通常用于实践,但有点烦人,因为它有效地需要两次复制数据:首先从字典到堆栈,然后从堆栈到输出目的地。

如果在树步行之前已知字符串的长度,则字节可以直接写入输出缓冲器。例如,如果我们知道代码31的字符串("一个")长度,树步行(e)中的初始字节可以直接输出到第三个输出位置,下一个字节(n)到第二种等。存储字典中的字符串长度需要更多内存,但在插入字符串时,它们很容易添加:新字符串的长度是其前缀加1的长度。

进一步拍摄这项技术(谢谢Nico!),观察到当解压缩器向字典添加一个新字符串时,该字符串已经存在于输出缓冲区中:通过拍摄先前输出的字符串并附加第一个字符来形成新字符串当前输出字符串。因此,如果先前输出的字符串是n个字节长而不是在索引i上输出,则新字符串是输出缓冲区中的n + 1字节I处的位置I.如果我们跟踪每个字符串' s位置在输出缓冲区中的位置,我们可以直接复制它,不要'它根本不得不走路。事实上,我们甚至需要存放树;存储每个字符串' s长度和输出缓冲位置足够。 (对于缩小,我们仍然需要将树存储为处理下面描述的部分清算。)该方案需要所有以前的输出都可提供,因此不适合流传输减压,但应尽可能更快。从某种意义上说,这使得内存LZW减压更类似于下面描述的LZ77。

#define max_code((1u< max_code_size) - 1)#define Invalid_code uint16_max typedef strictetab_t codetab_t; struct codetab_t {uint16_t prefix_code; / * Invalid_code表示条目无效。 * / uint8_t ext_byte; uint16_t len; size_t last_dst_pos;};静态void codetab_init(codetab_t * codetab){size_t i; / *文字字节的代码。设置Phony Prefix_code,以便它们'重新有效。 * / for(i = 0; i< = uint8_max; i ++){codetab [i]。 prefix_code =(uint16_t)i; codetab [i]。 ext_byte =(uint8_t)i; codetab [i]。 len = 1; for(; i< = max_code; i ++){codetab [i]。 prefix_code = Invalid_code; }}

实现LZW压缩时的重要考虑因素是代码用于代码的比特,代码大小。

在压缩8位字节时,我们需要使用至少9位代码,除了我们希望在字典中的初始单字节字符串外放置任何内容。使用更多代码的位允许更大的字典,这可能适合压缩,但是较大的字典不一定支付每个输出代码的增加的大小。

例如,如果我们在上面的伦敦人示例中使用9位代码,七个输出代码将占用63位。如果我们使用10位代码,字典可以容纳更多字符串,但输出需要70位,这可能比未压缩大小大。

虽然LZW论文指出,使用12位代码是常见的,但大多数实现实际上使用可变长度代码。 Spencer Thomas' s原始压缩程序以9位代码开头,可以长达16位。 GIF文件最初在3到9位之间使用,根据图像中的颜色数量,并且可以将代码大小增长为12位。这个想法是避免较大的代码大小的开销,直到字典增长潜在地利用它。

在压缩和GIF的情况下,压缩机使用初始代码大小启动发射码,然后在将字典中插入' t符合当前代码大小时的代码,增加大小。例如,如果初始代码大小为9位,则压缩机将增加一旦将代码512(其需要10位)插入字典中就会增加大小。解压缩器必须始终使用与压缩机相同的代码大小,并以相同的方式维护大小:从相同的初始大小开始并在添加超过当前代码大小的限制之外的条目时增加它。以这种方式,代码大小隐式地在压缩机和解压缩器之间保持同步,类似于字典。

虽然在压缩机和解压缩器之间隐式地同步代码大小,但它具有优雅的缺点,即它在需要发射较大尺寸的任何代码之前可能长时间增加代码大小。仅仅因为代码被添加到字典中并不是那么意味着它将很快或永远使用。相反,一些LZW压缩机使用特殊代码来发信号代码大小明确增加到解压缩器。这样,他们可以在发出需要更大尺寸的代码之前增加代码大小。

pkzip' s shrink方法使用9到13位之间的代码,并且信号代码大小通过发射控件代码(256)然后是一个来显式增加。

该代码首先发出至少显着,并且我们将重新使用先前文章中描述的比特流实现,该方法在比特流中可用。 为了读取和编写LZW代码,我们使用辅助函数在内部处理代码大小调整: #define min_code_size 9#define max_code_size 13#define max_code((1u< max_code_size)#define invalid_code uint16_max#define control_code 256#define inc_code_size 1 / *将代码写入输出比特流,增加代码 必要的。 成功返回true。 * /静态BOOL WRITE_CODE(OSTREAM_T * OS,UINT16_T代码,size_t * code_size){assert(代码< = max_code); 而(代码>( ......