第一章 轻松一下:数据压缩简史
算起来,数据压缩的起源要比计机的起源早得多,有兴趣的读者可以翻阅一下任何一本成语辞典,查查诸如“二桃三士”、“萧规曹随”之类的短语涵盖了多少信息内容。
认真一点:数据压缩技术在计算机技术的萌芽时期就已经被提上了议事日程,有关信息如何被高效存储和传递的话题不断被军事科学家、数学家、电子学家讨论来、讨论去。终于,随着信息论的产生和发展,数据压缩也由热门话题演变成了真正的技术。
通用无损数据压缩
科学家在研究中发现,大多数信息的表达都存在着一定的冗余度,通过采用一定的模型和编码方法,可以降低这种冗余度。贝尔实验室的 Claude Shannon 和 MIT 的 R.M.Fano 几乎同时提出了最早的对符号进行有效编码从而实现数据压缩的 Shannon-Fano 编码方法。
D.A.Huffman 于 1952 年第一次发表了他的论文“最小冗余度代码的构造方法”(A Method for the Construction of Minimum Redundancy Codes)。从此,数据压缩开始在商业程序中实现并被应用在许多技术领域。UNIX 系统上一个不太为现代人熟知的压缩程序 COMPACT 就是 Huffman 0 阶自适应编码的具体实现。80 年代初,Huffman 编码又在 CP/M 和 DOS 系统中实现,其代表程序叫 SQ。在数据压缩领域,Huffman 的这一论文事实上开创了数据压缩技术一个值得回忆的时代,60 年代、70 年代乃至 80 年代的早期,数据压缩领域几乎一直被 Huffman 编码及其分支所垄断。如果不是后面将要提到的那两个以色列人,也许我们今天还要在 Huffman 编码的 0 和 1 的组合中流连忘返。
让我们沿着 Huffman 的轨迹再向后跳跃几年,80 年代,数学家们不满足于 Huffman 编码中的某些致命弱点,他们从新的角度入手,遵循 Huffman 编码的主导思想,设计出另一种更为精确,更能接近信息论中“熵”极限的编码方法——算术编码。凭借算术编码的精妙设计和卓越表现,人们终于可以向着数据压 缩的极限前进了。可以证明,算术编码得到的压缩效果可以最大地减小信息的冗余度,用最少量的符号精确表达原始信息内容。当然,算术编码同时也给程序员和计 算机带来了新的挑战:要实现和运行算术编码,需要更为艰苦的编程劳动和更加快速的计算机系统。也就是说,在同样的计算机系统上,算术编码虽然可以得到最好 的压缩效果,但却要消耗也许几十倍的计算时间。这就是为什么算术编码不能在我们日常使用的压缩工具中实现的主要原因。
那么,能不能既在压缩效果上超越 Huffman,又不增加程序对系统资源和时间的需求呢?我们必须感谢下面将要介绍的两个以色列人。
直到 1977 年,数据压缩的研究工作主要集中于熵、字符和单词频率以及统计模型等方面,研究者们一直在绞尽脑汁为使用 Huffman 编码的程序找出更快、更好的改进方法。1977 年以后,一切都改变了。
1977 年,以色列人 Jacob Ziv 和 Abraham Lempel 发表了论文“顺序数据压缩的一个通用算法”(A Universal Alogrithem for Sequential Data Compression)。
1978 年,他们发表了该论文的续篇“通过可变比率编码的独立序列的压缩”(Compression of Individual Sequences via Variable-Rate Coding)。
所有的一切都改变了,在这两篇论文中提出的两个压缩技术被称为 LZ77 和 LZ78 (不知为什么,作者名字的首字母被倒置了)。简单地说,这两种压缩方法的思路完全不同于从 Shannon 到 Huffman 到算术压缩的传统思路,倒是和本章开头所举的成语辞典的例子颇为相似,因此,人们将基于这一思路的编码方法称作“字典”式编码。字典式编码不但在压缩效果 上大大超过了 Huffman,而且,对于好的实现,其压缩和解压缩的速度也异常惊人。
1984 年,Terry Welch 发表了名为“高性能数据压缩技术”(A Technique for High-Performance Data Compression)的论文,描述了他在 Sperry Research Center(现在是 Unisys 的一部分)的研究成果。他实现了 LZ78 算法的一个变种 —— LZW。LZW 继承了 LZ77 和 LZ78 压缩效果好、速度快的优点,而且在算法描述上更容易被人们接受(有的研究者认为是由于 Welch 的论文比 Ziv 和 Lempel 的更容易理解),实现也比较简单。不久,UNIX 上出现了使用 LZW 算法的 Compress 程序,该程序性能优良,并有高水平的文档,很快成为了 UNIX 世界的压缩程序标准。紧随其后的是 MS-DOS 环境下的 ARC 程序( System Enhancement Associates, 1985 ),还有象 PKWare、PKARC 等仿制品。LZ78 和 LZW 一时间统治了 UNIX 和 DOS 两大平台
80 年代中期以后,人们对 LZ77 进行了改进,随之诞生了一批我们今天还在大量使用的压缩程序。Haruyasu Yoshizaki(Yoshi) 的 LHarc 和 Robert Jung 的 ARJ 是其中两个著名的例子。LZ77 得以和 LZ78、LZW 一起垄断当今的通用数据压缩领域。
目前,基于字典方式的压缩已经有了一个被广泛认可的标准,从古老的 PKZip 到现在的 WinZip,特别是随着 Internet 上文件传输的流行,Z[wiki]IP[/wiki] 格式成为了事实上的标准,没有哪一种通用的文件压缩、归档系统敢于不支持 ZIP 格式。
多媒体信息的压缩
今天的程序员们和设计师们往往乐此不疲地为计算机更换更大的硬盘,增加更多的内存,其主要目的是为了存放和处理越来越多的声音、图像和视频数 据。对声音、图像、视频等多媒体信息的压缩有两条思路,要么采用成熟的通用数据压缩技术进行压缩,要么根据媒体信息的特性设计新的压缩方法。事实上,人们 在两条道路上都作了卓有成效的探索。
还记得 GIF 格式吗?GIF 可以把原始图形文件以非常小数据量存储,可以在同一个文件中存储多幅图像从而实现动画效果。知道 GIF 中的图像使用什么方法压缩的吗?LZW!原来如此啊。GIF 大概是使用通用压缩技术压缩图像信息的最成功的例子,当然,GIF 文件中除了经过 LZW 压缩的像素信息以外,还保存有图像的各种属性信息以及图像所使用的调色板信息等。GIF 精确地保留了原始图像的每一个像素信息,是无损图像压缩的代表。
根据媒体特性量身定制的压缩方法中,行程编码(RLE: Run-Length Encoding)是最为简单、最容易被想到的一种。大多数计算机中产生的图像(和现实世界的图像例如照片不同)都具有着大面积重复的颜色块,为什么非要 用无数个完全相同的颜色值来表示某块图像呢?我们完全可以用一个颜色值加一个重复次数来表示这一块图像,冗余度由此减小了,这就是 RLE 方法的基本思路。显然,它不适于用来压缩照片、声音等很少连续重复信息的数据。RLE 方法最有代表性的实现有 PCX 和 Targa 图形格式。
如果分别考察的话,只有黑白两种颜色的二值图像以及只有 256 级灰度变化的图像具有一些独特的地方,可以被压缩算法加以利用。我们知道,传真图像是一种典型的二值图像。国际电报电话咨询委员会( CCITT )为此建立了一系列的压缩标准,专门用于压缩传递二值图像(可以是无损的或有损的)。对于灰度图像,除了著名的 JPEG 标准以外,后文将要介绍的一种叫 FELICS 的算法可以实现效果非常好的无损压缩。
70 年代末 80 年代初,人们逐渐意识到,对到多数灰度或是彩色图像乃至声音文件,没有必要忠实地保留其所有信息,在允许一定的精度损失的情况下,可以实现更为有效的压缩 方法。到 80 年代末,许多人已经在这一领域取得了不小的收获,设计出了一批在压缩效果上让人惊讶不已的声音和图像压缩算法。在此基础上,国际标准化组织( ISO )和 CCITT 联合组成了两个委员会。委员会的名字我们大概都已经非常熟悉了:静态图像联合专家小组( JPEG )和动态图像联合专家小组( MPEG )。JPEG 的压缩目标是静止图像(灰度的和彩色的),MPEG 的目标则是声音和视频。但他们的基本思路是完全一样的,即保留媒体信息中最有规律、最能体现信息主要特征的数据,而略去其他不重要的数据。他们都取得了令 人赞叹的成就。
你刚看完 VCD 吗?那么你刚刚享用过他们为我们带来的乐趣了。知道普通 VCD 每一帧有多少彩色像素吗?知道每秒钟播放多少帧吗?知道的话,算一算一部 100 分钟的电影不压缩的话需要多少空间。每张光盘的容量是 640M,那么,不压缩的电影需要多少张光盘来存放呢?你该知道 JPEG 或是 MPEG 的厉害了吧。
最后,必须简单地提到与图像压缩领域相关的电子出版印刷领域中的一种叫做 PostScript 的东西。PostScript是作为电子印刷业的标准页面描述语言被设计出来的,它起源于 1976 年的 Evans & Sutherland 计算机公司,当时的名字是 Design System。1978 年,John Warnock 和 Martin Newel 将其演变为 JAM 语言。1982 年,John Warnock 和 Chuck Geschke 创建了著名的 Adobe System 公司,并第三次设计和实现了这个语言,并将其称为 PostScript。
PostScript 的主要思路是存储和传输预先定义的命令来“画”出图像,而不是存储和传输图像的每一个像素,这特别适用于在激光打印机上的输出。采用类似“从(10, 10)到(100, 100)画一条红色直线”或是“在(50,50)以 40 为半径画一个蓝色的圆”之类的命令存储图像显然比直接存储每个像素节省了不少地方。所以,从压缩技术的角度来看,PostScript 也可以算是压缩方法的一种。根据类似的原理,Windows 中的 WMF 格式、HP 的 HPGL 语言、AutoCAD 中使用的 DXF 格式等等,都可以对某种特定的图像进行有效的压缩。
什么是熵
数据压缩不仅起源于 40 年代由 Claude Shannon 首创的信息论,而且其基本原理即信息究竟能被压缩到多小,至今依然遵循信息论中的一条定理,这条定理借用了热力学中的名词“熵”( Entropy )来表示一条信息中真正需要编码的信息量:
考虑用 0 和 1 组成的二进制数码为含有 n 个符号的某条信息编码,假设符号 Fn 在整条信息中重复出现的概率为 Pn,则该符号的熵也即表示该符号所需的位数位为:
En = - log2( Pn )
整条信息的熵也即表示整条信息所需的位数为:E = ∑En
举个例子,对下面这条只出现了 a b c 三个字符的字符串:aabbaccbaa
字符串长度为 10,字符 a b c 分别出现了 5 3 2 次,则 a b c 在信息中出现的概率分别为 0.5 0.3 0.2,他们的熵分别为:
Ea = -log2(0.5) = 1
Eb = -log2(0.3) = 1.737
Ec = -log2(0.2) = 2.322
整条信息的熵也即表达整个字符串需要的位数为:
E = Ea * 5 + Eb * 3 + Ec * 2 = 14.855 位
回想一下如果用计算机中常用的 ASCII 编码,表示上面的字符串我们需要整整 80 位呢!现在知道信息为什么能被压缩而不丢失原有的信息内容了吧。简单地讲,用较少的位数表示较频繁出现的符号,这就是数据压缩的基本准则。
细心的读者马上会想到,我们该怎样用 0 1 这样的二进制数码表示零点几个二进制位呢?确实很困难,但不是没有办法。一旦我们找到了准确表示零点几个二进制位的方法,我们就有权利向无损压缩的极限挑战了。不要着急,看到第四章就明白了。
模型
从上面的描述,我们明白,要压缩一条信息,首先要分析清楚信息中每个符号出现的概率。不同的压缩程序通过不同的方法确定符号的出现概率,对符号 的概率计算得越准确,也就越容易得到好的压缩效果。在压缩程序中,用来处理输入信息,计算符号的概率并决定输出哪个或哪些代码的模块叫做模型。
难道对信息中字符的出现概率这么难以估计以至于有各种不同的压缩模型吗?对上面的字符串我们不是很容易就知道每个字符的概率了吗?是的是的,不 过上面的字符串仅有 10 个字符长呀,那只是例子而已。考虑我们现实中要压缩的文件,大多数可是有几十 K 甚至几百 K 长,几 M 字节的文件不是也屡见不鲜吗?
是的,我们可以预先扫描文件中的所有字符,统计出每个字符出现的概率,这种方法在压缩术语里叫做“静态统计模型”。但是,不同的文件中,字符有 不同的分布概率,我们要么先花上大量的时间统计我们要压缩的所有文件中的字符概率,要么为每一个单独的文件保存一份概率表以备解压缩时需要。糟糕的是,不 但扫描文件要消耗大量时间,而且保存一份概率表也使压缩后的文件增大了不少。所以,在实际应用中,“静态统计模型”应用的很少。
真正的压缩程序中使用的大多是一种叫“自适应模型”的东西。自适应模型可以说是一台具有学习功能的自动机。他在信息被输入之前对信息内容一无所 知并假定每个字符的出现概率均等,随着字符不断被输入和编码,他统计并纪录已经出现过的字符的概率并将这些概率应用于对后续字符的编码。也就是说,自适应 模型在压缩开始时压缩效果并不理想,但随着压缩的进行,他会越来越接近字符概率的准确值,并达到理想的压缩效果。自适应模型还可以适应输入信息中字符分布 的突然变化,可以适应不同的文件中的字符分布而不需要保存概率表。
上面提到的模型可以统称为“统计模型”,因为他们都是基于对每个字符出现次数的统计得到字符概率的。另一大类模型叫做“字典模型”。实际上,当 我们在生活中提到“工行”这个词的时候,我们都知道其意思是指“中国工商银行”,类似的例子还有不少,但共同的前提是我们心中都有一本约定俗成的缩写字 典。字典模型也是如此,他并不直接计算字符出现的概率,而是使用一本字典,随着输入信息的读入,模型找出输入信息在字典中匹配的最长的字符串,然后输出该 字符串在字典中的索引信息。匹配越长,压缩效果越好。事实上,字典模型本质上仍然是基于对字符概率的计算的,只不过,字典模型使用整个字符串的匹配代替了 对某一字符重复次数的统计。可以证明,字典模型得到的压缩效果仍然无法突破熵的极限。
当然,对通用的压缩程序来说,保存一本大字典所需的空间仍然是无法让人忍受的,况且,任何一本预先定义的字典都无法适应不同文件中数据的变化情 况。对了,字典模型也有相应的“自适应”方案。我们可以随着信息的不断输入,从已经输入的信息中建立合适的字典,并不断更新这本字典,以适应数据的不断变 化。
让我们从另一个角度理解一下自适应模型。Cluade Shannon 曾试图通过一个“聚会游戏”(party game)来测定英语的真实信息容量。他每次向听众公布一条被他隐藏起一个字符的消息,让听众来猜下一个字符是什么,一次猜一个,直到猜对为止。然 后,Shannon 使用猜测次数来确定整个信息的熵。在这个实验中,一种根据前面出现过的字符估计下一个字符概率的模型就存在于听众的头脑中,比计算机中使用的自适应模型更 为高级的是,听众除了根据字符出现过的次数外,还可以根据他们对语言的经验进行猜测。
编码
通过模型,我们已经确定了对某一个符号该用多少位二进制数进行编码。现在的问题是,如何设计一种编码方案,使其尽量精确地用模型计算出来的位数表示某个符号。
最先被考虑的问题是,如果对 a 用 3 个二进制位就可以表示,而对 b 用 4 个二进制位就可以表示,那么,在解码时,面对一连串的二进制流,我怎么知道哪三个位是 a,哪四个位是 b 呢?所以,必须设计出一种编码方式,使得解码程序可以方便地分离每个字符的编码部分。于是有了一种叫“前缀编码”的技术。该技术的主导思想是,任何一个字 符的编码,都不是另一个字符编码的前缀。反过来说就是,任何一个字符的编码,都不是由另一个字符的编码加上若干位 0 或 1 组成。看一下前缀编码的一个最简单的例子:
符号 编码
A 0
B 10
C 110
D 1110
E 11110
有了上面的码表,你一定可以轻松地从下面这串二进制流中分辨出真正的信息内容了:
1110010101110110111100010 - DABBDCEAAB
下一个问题是:象上面这样的前缀编码只能表示整数位的符号,对几点几位的符号只能用近似的整数位输出,那么怎样输出小数位数呢?科学家们用算术编码解决了这个问题,我们将在第四章对算术编码作详细的讨论。
总结一下
不同的模型使用不同的方法计算字符的出现概率,由此概率可以得出字符的熵;然后使用不同的编码方法,尽量接近我们期望得到的熵值。所以,压缩效 果的好坏一方面取决于模型能否准确地得到字符概率,另一方面也取决于编码方法能否准确地用期望的位数输出字符代码。换句话说,压缩 = 模型 + 编码。如下图所示:
--------- 符号 --------- 概率 -------- 代码 ----------
| 输入 |-------->| 模型 |-------->| 编码 |-------->| 输出 |
--------- --------- -------- ----------
资源
我们已经知道,编写压缩程序往往不能对数据的整个字节进行处理,而是要按照二进制位来读写和处理数据,操作二进制位的函数也就成为了压缩程序中使用最为普遍的工具函数。我们在此提供两组函数集,使用它们可以有效的进行文件或内存中的二进制位操作。它们共有六个文件:
bitio.h - 用于文件中二进制位操作的函数说明。
bitio.cpp - 用于文件中二进制位操作的函数实现。
errhand.h 和 errhand.cpp - bitio.cpp 中使用的错误处理函数。
wm_bitio.h - 用于内存中二进制位操作的函数说明。
wm_bitio.cpp - 用于内存中二进制位操作的函数实现。
它们被共同包装在文件 bitio.zip 中。
第三章 奇妙的二叉树:Huffman的贡献
提起 Huffman 这个名字,程序员们至少会联想到二叉树和二进制编码。的确,我们总以 Huffman 编码来概括 D.A.Huffman 个人对计算机领域特别是数据压缩领域的杰出贡献。我们知道,压缩 = 模型 + 编码,作为一种压缩方法,我们必须全面考虑其模型和编码两个模块的功效;但同时,模型和编码两个模块又相互具有独立性。举例来说,一个使用 Huffman 编码方法的程序,完全可以采用不同的模型来统计字符在信息中出现的概率。因此,我们这一章将首先围绕 Huffman 先生最为重要的贡献 —— Huffman 编码展开讨论,随后,我们再具体介绍可以和 Huffman 联合使用的概率模型。
为什么是二叉树?
为什么压缩领域中的编码方法总和二叉树联系在一起呢?原因非常简单,回忆一下我们介绍过的“前缀编码”:为了使用不固定的码长表示单个字符,编 码必须符合“前缀编码”的要求,即较短的编码决不能是较长编码的前缀。要构造符合这一要求的二进制编码体系,二叉树是最理想的选择。考察下面这棵二叉树:
根(root)
0 | 1
+------+------+
0 | 1 0 | 1
+-----+-----+ +----+----+
| | | |
a | d e
0 | 1
+-----+-----+
| |
b c
要编码的字符总是出现在树叶上,假定从根向树叶行走的过程中,左转为0,右转为1,则一个字符的编码就是从根走到该字符所在树叶的路径。正因为字符只能出现在树叶上,任何一个字符的路径都不会是另一字符路径的前缀路径,符合要求的前缀编码也就构造成功了:
a - 00 b - 010 c - 011 d - 10 e - 11
Shannon-Fano 编码
进入 Huffman 先生构造的神奇二叉树之前,我们先来看一下它的前身,由 Claude Shannon 和 R.M.Fano 两人提出的 Shannon-Fano 编码。
讨论之前,我们假定要编码字符的出现概率已经由某一模型统计出来,例如,对下面这串出现了五种字符的信息( 40 个字符长 ):
cabcedeacacdeddaaabaababaaabbacdebaceada
五种字符的出现次数分别:a - 16,b - 7,c - 6,d - 6,e - 5。
Shannon-Fano 编码的核心仍然是构造二叉树,构造的方式非常简单:
1) 将给定符号按照其频率从大到小排序。对上面的例子,应该得到:
a - 16
b - 7
c - 6
d - 6
e - 5
2) 将序列分成上下两部分,使得上部频率总和尽可能接近下部频率总和。我们有:
a - 16
b - 7
-----------------
c - 6
d - 6
e - 5
3) 我们把第二步中划分出的上部作为二叉树的左子树,记 0,下部作为二叉树的右子树,记 1。
4) 分别对左右子树重复 2 3 两步,直到所有的符号都成为二叉树的树叶为止。现在我们有如下的二叉树:
根(root)
0 | 1
+------+------+
0 | 1 0 | 1
+-----+-----+ +---+----+
| | | |
a b c |
0 | 1
+-----+-----+
| |
d e
于是我们得到了此信息的编码表:
a - 00 b - 01 c - 10 d - 110 e - 111
可以将例子中的信息编码为:
cabcedeacacdeddaaabaababaaabbacdebaceada
10 00 01 10 111 110 111 00 10 00 10 ......
码长共 91 位。考虑用 ASCII 码表示上述信息需要 8 * 40 = 240 位,我们确实实现了数据压缩。
Huffman 编码
Huffman 编码构造二叉树的方法和 Shannon-Fano 正好相反,不是自上而下,而是从树叶到树根生成二叉树。现在,我们仍然使用上面的例子来学习 Huffman 编码方法。
1) 将各个符号及其出现频率分别作为不同的小二叉树(目前每棵树只有根节点)。
a(16) b(7) c(6) d(6) e(5)
2) 在 1 中得到的树林里找出频率值最小的两棵树,将他们分别作为左、右子树连成一棵大一些的二叉树,该二叉树的频率值为两棵子树频率值之和。对上面的例子,我们得到一个新的树林:
| (11)
a(16) b(7) c(6) +---+---+
| |
d e
3) 对上面得到的树林重复 2 的做法,直到所有符号都连入树中为止。这一步完成后,我们有这样的二叉树:
根(root)
0 | 1
+------+----------------+
| 0 | 1
| +---------+-----------+
| 0 | 1 0 | 1
a +-------+------+ +-------+-------+
| | | |
b c d e
由此,我们可以建立和 Shannon-Fano 编码略微不同的编码表:
a - 0 b - 100 c - 101 d - 110 e - 111
对例子中信息的编码为:
cabcedeacacdeddaaabaababaaabbacdebaceada
101 0 100 101 111 110 111 0 101 0 101 ......
码长共 88 位。这比使用 Shannon-Fano 编码要更短一点。
让我们回顾一下熵的知识,使用我们在第二章学到的计算方法,上面的例子中,每个字符的熵为:
Ea = - log2(16 / 40) = 1.322
Eb = - log2( 7 / 40) = 2.515
Ec = - log2( 6 / 40) = 2.737
Ed = - log2( 6 / 40) = 2.737
Ee = - log2( 5 / 40) = 3.000
信息的熵为:
E = Ea * 16 + Eb * 7 + Ec * 6 + Ed * 6 + Ee * 5 = 86.601
也就是说,表示该条信息最少需要 86.601 位。我们看到,Shannon-Fano 编码和 Huffman 编码都已经比较接近该信息的熵值了。同时,我们也看出,无论是 Shannon-Fano 还是 Huffman,都只能用近似的整数位来表示单个符号,而不是理想的小数位。我们可以将它们做一个对比:
符号 理想位数 S-F 编码 Huffman 编码
( 熵 ) 需要位数 需要位数
----------------------------------------------------
a 1.322 2 1
b 2.515 2 3
c 2.737 2 3
d 2.737 3 3
e 3.000 3 3
----------------------------------------------------
总 计 86。601 91 88
这就是象 Huffman 这样的整数位编码方式无法达到最理想的压缩效果的原因。
为 Huffman 编码选择模型(附范式 Huffman 编码)
最简单,最容易被 Huffman 编码利用的模型是“静态统计模型”,也就是说在编码前统计要编码的信息中所有字符的出现频率,让后根据统计出的信息建立编码树,进行编码。这种模型的缺点 是显而易见的:首先,对数据量较大的信息,静态统计要消耗大量的时间;其次,必须保存统计出的结果以便解码时构造相同的编码树,或者直接保存编码树本身, 而且,对于每次静态统计,都有不同的结果,必须分别予以保存,这要消耗大量的空间(这意味着压缩效率的下降);再次,事实上,即使不将编码树计算在内,对 通常含有 0 - 255 字符集的计算机文件来说,静态统计模型统计出的频率是字符在整个文件中的出现频率,往往反映不出字符在文件中不同局部出现频率的变化情况,使用这一频率进 行压缩,大多数情况下得不到太好压缩效果,文件有时甚至在压缩后反而增大了。所以,“静态统计模型”一般仅作为复杂算法的某一部分出现,在信息的某一局部 完成压缩功能。我们很难将其用于独立的压缩系统。
有一种有效的“静态统计模型”的替代方案,如果我们要压缩的所有信息具有某些共同的特性,也即在分布上存在着共同的特征,比如我们要压缩的是普 通的英文文本,那么,字母 a 或者字母 e 的出现频率应当是大致稳定的。使用语言学家事先已经建立好的字母频率表来进行压缩和解压缩,不但不用保存多份统计信息,而且一般说来对该类文件有着较好的 压缩效果。这种方案除了适应性不太强以外,偶尔还会有一些尴尬的时候。读一遍下面这段话:
If Youth,throughout all history, had had a champion to stand up for it; to show a doubting world that a child can think;and, possibly, do it practically; you wouldn't constantly run across folks today who claim that "a child don't know anything." - Gadsby by E.V.Wright, 1939.
发现什么问题了吗?哦,整段话中竟没有出现一次英文中出现频率最高的字母 e !真让人惊讶,但没有办法,事先拟定的频率分布总有意外的时候。
对英文或中文文本,有一种比较实用的静态模型:不是把字符而是把英文单词或中文词语作为统计频率和编码的单位进行压缩。也就是说,每次编码的不 再是 a b c 这样的单个符号,而是 the look flower 这样的单词。这种压缩方式可以达到相当不错的压缩效果,并被广泛地用于全文检索系统。
对基于词的编码方式,需要解决几个技术难点。首先是分词的问题,英文单词可以由词间空格分隔,但中文怎么办呢?其实,有很多中文分词算法可以解 决这个问题,本书就不再详细介绍了。王笨笨就曾开发过一个不错的分词模块,但希望通过收取一定报酬的方式提供该模块,如有需要,请和王笨笨 E-Mail 联系。一旦我们将词语分离出来,我们就可以对每个词进行频率统计,然后建立 Huffman 编码树,输出编码时,一个编码将代替一个词语。但要注意,英文和汉语的单词数量都在几万到十几万左右,也就是说,我们的 Huffman 编码树将拥有十几万个叶子节点,这对于一棵树来说太大太大了,系统将无力承担所需要的资源,这怎么办呢?我们可以暂时抛开树结构,采用另一种构造 Huffman 编码的方式——范式 Huffman 编码。
范式 Huffman 编码(Canonical Huffman Code)的基本思路是:并非只有使用二叉树建立的前缀编码才是 Huffman 编码,只要符合(1)是前缀编码(2)某一字符编码长度和使用二叉树建立的该字符的编码长度相同这两个条件的编码都可以叫做 Huffman 编码。考虑对下面六个单词的编码:
符号 出现次数 传统 Huffman 编码 范式 Huffman 编码
------------------------------------------------------------
单词1 10 000 000
单词2 11 001 001
单词3 12 100 010
单词4 13 101 011
单词5 22 01 10
单词6 23 11 11
注意到范式 Huffman 编码的独特之处了吗?你无法使用二叉树来建立这组编码,但这组编码确实能起到和 Huffman 编码相同的作用。而且,范式 Huffman 编码具有一个明显的特点:当我们把要编码的符号按照其频率从小到大排列时,如果把范式 Huffman 编码本身作为单词的话,也呈现出从小到大的字典顺序。
构造范式 Huffman 编码的方法大致是:
1) 统计每个要编码符号的频率。
2) 根据这些频率信息求出该符号在传统 Huffman 编码树中的深度(也就是表示该符号所需要的位数 - 编码长度)。因为我们关心的仅仅是该符号在树中的深度,我们完全没有必要构造二叉树,仅用一个数组就可以模拟二叉树的创建过程并得到符号的深度,具体方法 这里就不详述了。
3) 分别统计从最大编码长度 maxlength 到 1 的每个长度对应了多少个符号。根据这一信息从 maxlength 个 0 开始以递增顺序为每个符号分配编码。例如,编码长度为 5 的符号有 4 个,长度为 3 的有 1 个,长度为 2 的有 3 个,则分配的编码依次为: 00000 00001 00010 00011 001 01 10 11
4) 编码输出压缩信息,并保存按照频率顺序排列的符号表,然后保存每组同样长度编码中的最前一个编码以及该组中的编码个数。
现在完全可以不依赖任何树结构进行高速解压缩了。而且在整个压缩、解压缩过程中需要的空间比传统 Huffman 编码少得多。
最后要提到的是,Huffman 编码可以采用自适应模型,根据已经编码的符号频率决定下一个符号的编码。这时,我们无需为解压缩预先保存任何信息,整个编码是在压缩和解压缩过程中动态创 建的,而且自适应编码由于其符号频率是根据信息内容的变化动态得到的,更符合符号的局部分布规律,因此在压缩效果上比静态模型好许多。但是,采用自适应模 型必须考虑编码表的动态特性,即编码表必须可以随时更新以适应符号频率的变化。对于 Huffman 编码来说,我们很难建立能够随时更新的二叉树,使用范式 Huffman 编码是个不错的选择,但依然存在不少技术上的难题。幸好,如果愿意的话,我们可以暂时不考虑自适应模型的 Huffman 编码,因为对于自适应模型我们还有许多更好的选择,下面几章将要谈到的算术编码、字典编码等更为适合采用自适应模型,我们将在其中深入探讨自适应模型的各 种实现方法。
我们在上一章中已经明白,Huffman 编码使用整数个二进制位对符号进行编码,这种方法在许多情况下无法得到最优的压缩效果。假设某个字符的出现概率为 80%,该字符事实上只需要 -log2(0.8) = 0.322 位编码,但 Huffman 编码一定会为其分配一位 0 或一位 1 的编码。可以想象,整个信息的 80% 在压缩后都几乎相当于理想长度的 3 倍左右,压缩效果可想而知。
难道真的能只输出 0.322 个 0 或 0.322 个 1 吗?是用剪刀把计算机存储器中的二进制位剪开吗?计算机真有这样的特异功能吗?慢着慢着,我们不要被表面现象所迷惑,其实,在这一问题上,我们只要换一换 脑筋,从另一个角度……哎呀,还是想不通,怎么能是半个呢?好了,不用费心了,数学家们也不过是在十几年前才想到了算术编码这种神奇的方法,还是让我们虚 心地研究一下他们究竟是从哪个角度找到突破口的吧。
输出:一个小数
更神奇的事情发生了,算术编码对整条信息(无论信息有多么长),其输出仅仅是一个数,而且是一个介于 0 和 1 之间的二进制小数。例如算术编码对某条信息的输出为 1010001111,那么它表示小数 0.1010001111,也即十进制数 0.64。
咦?怎么一会儿是表示半个二进制位,一会儿又是输出一个小数,算术编码怎么这么古怪呀?不要着急,我们借助下面一个简单的例子来阐释算术编码的基本原理。为了表示上的清晰,我们暂时使用十进制表示算法中出现的小数,这丝毫不会影响算法的可行性。
考虑某条信息中可能出现的字符仅有 a b c 三种,我们要压缩保存的信息为 bccb。
在没有开始压缩进程之前,假设我们对 a b c 三者在信息中的出现概率一无所知(我们采用的是自适应模型),没办法,我们暂时认为三者的出现概率相等,也就是都为 1/3,我们将 0 - 1 区间按照概率的比例分配给三个字符,即 a 从 0.0000 到 0.3333,b 从 0.3333 到 0.6667,c 从 0.6667 到 1.0000。用图形表示就是:
+-- 1.0000
|
pc = 1/3 |
|
+-- 0.6667
|
Pb = 1/3 |
|
+-- 0.3333
|
Pa = 1/3 |
|
+-- 0.0000
现在我们拿到第一个字符 b,让我们把目光投向 b 对应的区间 0.3333 - 0.6667。这时由于多了字符 b,三个字符的概率分布变成:Pa = 1/4,Pb = 2/4,Pc = 1/4。好,让我们按照新的概率分布比例划分 0.3333 - 0.6667 这一区间,划分的结果可以用图形表示为:
+-- 0.6667
Pc = 1/4 |
+-- 0.5834
|
|
Pb = 2/4 |
|
|
+-- 0.4167
Pa = 1/4 |
+-- 0.3333
接着我们拿到字符 c,我们现在要关注上一步中得到的 c 的区间 0.5834 - 0.6667。新添了 c 以后,三个字符的概率分布变成 Pa = 1/5,Pb = 2/5,Pc = 2/5。我们用这个概率分布划分区间 0.5834 - 0.6667:
+-- 0.6667
|
Pc = 2/5 |
|
+-- 0.6334
|
Pb = 2/5 |
|
+-- 0.6001
Pa = 1/5 |
+-- 0.5834
现在输入下一个字符 c,三个字符的概率分布为:Pa = 1/6,Pb = 2/6,Pc = 3/6。我们来划分 c 的区间 0.6334 - 0.6667:
+-- 0.6667
|
|
Pc = 3/6 |
|
|
+-- 0.6501
|
Pb = 2/6 |
|
+-- 0.6390
Pa = 1/6 |
+-- 0.6334
输入最后一个字符 b,因为是最后一个字符,不用再做进一步的划分了,上一步中得到的 b 的区间为 0.6390 - 0.6501,好,让我们在这个区间内随便选择一个容易变成二进制的数,例如 0.64,将它变成二进制 0.1010001111,去掉前面没有太多意义的 0 和小数点,我们可以输出 1010001111,这就是信息被压缩后的结果,我们完成了一次最简单的算术压缩过程。
怎么样,不算很难吧?可如何解压缩呢?那就更简单了。解压缩之前我们仍然假定三个字符的概率相等,并得出上面的第一幅分布图。解压缩时我们面对 的是二进制流 1010001111,我们先在前面加上 0 和小数点把它变成小数 0.1010001111,也就是十进制 0.64。这时我们发现 0.64 在分布图中落入字符 b 的区间内,我们立即输出字符 b,并得出三个字符新的概率分布。类似压缩时采用的方法,我们按照新的概率分布划分字符 b 的区间。在新的划分中,我们发现 0.64 落入了字符 c 的区间,我们可以输出字符 c。同理,我们可以继续输出所有的字符,完成全部解压缩过程(注意,为了叙述方便,我们暂时回避了如何判断解压缩结束的问题,实际应用中,这个问题并不难 解决)。
现在把教程抛开,仔细回想一下,直到你理解了算术压缩的基本原理,并产生了许多新的问题为止。
真的能接近极限吗?
现在你一定明白了一些东西,也一定有了不少新问题,没有关系,让我们一个一个解决。
首先,我们曾反复强调,算术压缩可以表示小数个二进制位,并由此可以接近无损压缩的熵极限,怎么从上面的描述中没有看出来呢?
算术编码实际上采用了化零为整的思想来表示小数个二进制位,我们确实无法精确表示单个小数位字符,但我们可以将许多字符集中起来表示,仅仅允许在最后一位有些许的误差。
结合上面的简单例子考虑,我们每输入一个符号,都对概率的分布表做一下调整,并将要输出的小数限定在某个越来越小的区间范围内。对输出区间的限 定是问题的关键所在,例如,我们输入第一个字符 b 时,输出区间被限定在 0.3333 - 0.6667 之间,我们无法决定输出值得第一位是 3、4、5 还是 6,也就是说,b 的编码长度小于一个十进制位(注意我们用十进制讲解,和二进制不完全相同),那么我们暂时不决定输出信息的任何位,继续输入下面的字符。直到输入了第三个 字符 c 以后,我们的输出区间被限定在 0.6334 - 0.6667 之间,我们终于知道输出小数的第一位(十进制)是 6,但仍然无法知道第二位是多少,也即前三个字符的编码长度在 1 和 2 之间。等到我们输入了所有字符之后,我们的输出区间为 0.6390 - 0.6501,我们始终没有得到关于第二位的确切信息,现在我们明白,输出所有的 4 个字符,我们只需要 1 点几个十进制位,我们唯一的选择是输出 2 个十进制位 0.64。这样,我们在误差不超过 1 个十进制位的情况下相当精确地输出了所有信息,很好地接近了熵值(需要注明的是,为了更好地和下面的课程接轨,上面的例子采用的是 0 阶自适应模型,其结果和真正的熵值还有一定的差距)。
小数有多长?
你一定已经想到,如果信息内容特别丰富,我们要输出的小数将会很长很长,我们该如何在内存中表示如此长的小数呢?
其实,没有任何必要在内存中存储要输出的整个小数。我们从上面的例子可以知道,在编码的进行中,我们会不断地得到有关要输出小数的各种信息。具 体地讲,当我们将区间限定在 0.6390 - 0.6501 之间时,我们已经知道要输出的小数第一位(十进制)一定是 6,那么我们完全可以将 6 从内存中拿掉,接着在区间 0.390 - 0.501 之间继续我们的压缩进程。内存中始终不会有非常长的小数存在。使用二进制时也是一样的,我们会随着压缩的进行不断决定下一个要输出的二进制位是 0 还是 1,然后输出该位并减小内存中小数的长度。
静态模型如何实现?
我们知道上面的简单例子采用的是自适应模型,那么如何实现静态模型呢?其实很简单。对信息 bccb 我们统计出其中只有两个字符,概率分布为 Pb = 0.5,Pc = 0.5。我们在压缩过程中不必再更新此概率分布,每次对区间的划分都依照此分布即可,对上例也就是每次都平分区间。这样,我们的压缩过程可以简单表示为:
输出区间的下限 输出区间的上限
--------------------------------------------------
压缩前 0.0 1.0
输入 b 0.0 0.5
输入 c 0.25 0.5
输入 c 0.375 0.5
输入 b 0.375 0.4375
我们看出,最后的输出区间在 0.375 - 0.4375 之间,甚至连一个十进制位都没有确定,也就是说,整个信息根本用不了一个十进制位。如果我们改用二进制来表示上述过程的话,我们会发现我们可以非常接近该 信息的熵值(有的读者可能已经算出来了,该信息的熵值为 4 个二进制位)。
为什么用自适应模型?
既然我们使用静态模型可以很好地接近熵值,为什么还要采用自适应模型呢?
要知道,静态模型无法适应信息的多样性,例如,我们上面得出的概率分布没法在所有待压缩信息上使用,为了能正确解压缩,我们必须再消耗一定的空 间保存静态模型统计出的概率分布,保存模型所用的空间将使我们重新远离熵值。其次,静态模型需要在压缩前对信息内字符的分布进行统计,这一统计过程将消耗 大量的时间,使得本来就比较慢的算术编码压缩更加缓慢。
另外还有最重要的一点,对较长的信息,静态模型统计出的符号概率是该符号在整个信息中的出现概率,而自适应模型可以统计出某个符号在某一局部的 出现概率或某个符号相对于某一上下文的出现概率,换句话说,自适应模型得到的概率分布将有利于对信息的压缩(可以说结合上下文的自适应模型的信息熵建立在 更高的概率层次上,其总熵值更小),好的基于上下文的自适应模型得到的压缩结果将远远超过静态模型。
自适应模型的阶
我们通常用“阶”(order)这一术语区分不同的自适应模型。本章开头的例子中采用的是 0 阶自适应模型,也就是说,该例子中统计的是符号在已输入信息中的出现概率,没有考虑任何上下文信息。
如果我们将模型变成统计符号在某个特定符号后的出现概率,那么,模型就成为了 1 阶上下文自适应模型。举例来说,我们要对一篇英文文本进行编码,我们已经编码了 10000 个英文字符,刚刚编码的字符是 t,下一个要编码的字符是 h。我们在前面的编码过程中已经统计出前 10000 个字符中出现了 113 次字母 t,其中有 47 个 t 后面跟着字母 h。我们得出字符 h 在字符 t 后的出现频率是 47/113,我们使用这一频率对字符 h 进行编码,需要 -log2(47/113) = 1.266 位。
对比 0 阶自适应模型,如果前 10000 个字符中 h 的出现次数为 82 次,则字符 h 的概率是 82/10000,我们用此概率对 h 进行编码,需要 -log2(82/10000) = 6.930 位。考虑上下文因素的优势显而易见。
我们还可以进一步扩大这一优势,例如要编码字符 h 的前两个字符是 gt,而在已经编码的文本中 gt 后面出现 h 的概率是 80%,那么我们只需要 0.322 位就可以编码输出字符 h。此时,我们使用的模型叫做 2 阶上下文自适应模型。
最理想的情况是采用 3 阶自适应模型。此时,如果结合算术编码,对信息的压缩效果将达到惊人的程度。采用更高阶的模型需要消耗的系统空间和时间至少在目前还无法让人接受,使用算术压缩的应用程序大多数采用 2 阶或 3 阶的自适应模型。
转义码的作用
使用自适应模型的算术编码算法必须考虑如何为从未出现过的上下文编码。例如,在 1 阶上下文模型中,需要统计出现概率的上下文可能有 256 * 256 = 65536 种,因为 0 - 255 的所有字符都有可能出现在 0 - 255 个字符中任何一个之后。当我们面对一个从未出现过的上下文时(比如刚编码过字符 b,要编码字符 d,而在此之前,d 从未出现在 b 的后面),该怎样确定字符的概率呢?
比较简单的办法是在压缩开始之前,为所有可能的上下文分配计数为 1 的出现次数,如果在压缩中碰到从未出现的 bd 组合,我们认为 d 出现在 b 之后的次数为 1,并可由此得到概率进行正确的编码。使用这种方法的问题是,在压缩开始之前,在某上下文中的字符已经具有了一个比较小的频率。例如对 1 阶上下文模型,压缩前,任意字符的频率都被人为地设定为 1/65536,按照这个频率,压缩开始时每个字符要用 16 位编码,只有随着压缩的进行,出现较频繁的字符在频率分布图上占据了较大的空间后,压缩效果才会逐渐好起来。对于 2 阶或 3 阶上下文模型,情况就更糟糕,我们要为几乎从不出现的大多数上下文浪费大量的空间。
我们通过引入“转义码”来解决这一问题。“转义码”是混在压缩数据流中的特殊的记号,用于通知解压缩程序下一个上下文在此之前从未出现过,需要使用低阶的上下文进行编码。
举例来讲,在 3 阶上下文模型中,我们刚编码过 ght,下一个要编码的字符是 a,而在此之前,ght 后面从未出现过字符 a,这时,压缩程序输出转义码,然后检查 2 阶的上下文表,看在此之前 ht 后面出现 a 的次数;如果 ht 后面曾经出现过 a,那么就使用 2 阶上下文表中的概率为 a 编码,否则再输出转义码,检查 1 阶上下文表;如果仍未能查到,则输出转义码,转入最低的 0 阶上下文表,看以前是否出现过字符 a;如果以前根本没有出现过 a,那么我们转到一个特殊的“转义”上下文表,该表内包含 0 - 255 所有符号,每个符号的计数都为 1,并且永远不会被更新,任何在高阶上下文中没有出现的符号都可以退到这里按照 1/256 的频率进行编码。
“转义码”的引入使我们摆脱了从未出现过的上下文的困扰,可以使模型根据输入数据的变化快速调整到最佳位置,并迅速减少对高概率符号编码所需要的位数。
存储空间问题
在算术编码高阶上下文模型的实现中,对内存的需求量是一个十分棘手的问题。因为我们必须保持对已出现的上下文的计数,而高阶上下文模型中可能出现的上下文种类又是如此之多,数据结构的设计将直接影响到算法实现的成功与否。
在 1 阶上下文模型中,使用数组来进行出现次数的统计是可行的,但对于 2 阶或 3 阶上下文模型,数组大小将依照指数规律增长,现有计算机的内存满足不了我们的要求。
比较聪明的办法是采用树结构存储所有出现过的上下文。利用高阶上下文总是建立在低阶上下文的基础上这一规律,我们将 0 阶上下文表存储在数组中,每个数组元素包含了指向相应的 1 阶上下文表的指针,1 阶上下文表中又包含了指向 2 阶上下文表的指针……由此构成整个上下文树。树中只有出现过的上下文才拥有已分配的节点,没有出现过的上下文不必占用内存空间。在每个上下文表中,也无需 保存所有 256 个字符的计数,只有在该上下文后面出现过的字符才拥有计数值。由此,我们可以最大限度地减少空间消耗。
资源
关于算术压缩具体的设计和实现请参考下面给出的示例程序。
程序 Arith-N 由 League for Programming Freedom 的 Mark Nelson 提供,由王笨笨在 Visual C++ 5.0 环境下编译、调试通过。
Arith-N 包含 Visual C++ 工程 ArithN.dsp 和 ArithNExpand.dsp,分别对应了压缩和解压缩程序 an.exe 与 ane.exe。
Arith-N 是可以在命令行指定阶数的 N 阶上下文自适应算术编码通用压缩、解压缩程序,由于是用作教程示例,为清晰起见,在某些地方并没有刻意进行效率上的优化。
所有源程序包装在文件 Arith-N.zip 中。
第五章 聪明的以色列人(上):LZ77
全新的思路
我们在第三和第四章中讨论的压缩模型都是基于对信息中单个字符出现频率的统计而设计的,直到 70 年代末期,这种思路在数据压缩领域一直占据着统治地位。在我们今天看来,这种情形在某种程度上显得有些可笑,但事情就是这样,一旦某项技术在某一领域形成 了惯例,人们就很难创造出在思路上与其大相径庭的哪怕是更简单更实用的技术来。
我们敬佩那两个在数据压缩领域做出了杰出贡献的以色列人,因为正是他们打破了 Huffman 编码一统天下的格局,带给了我们既高效又简便的“字典模型”。至今,几乎我们日常使用的所有通用压缩工具,象 ARJ,PKZip,WinZip,LHArc,RAR,GZip,ACE,ZOO,TurboZip,Compress,JAR……甚至许多硬件如网络 设备中内置的压缩算法,无一例外,都可以最终归结为这两个以色列人的杰出贡献。
说起来,字典模型的思路相当简单,我们日常生活中就经常在使用这种压缩思想。我们常常跟人说“奥运会”、“IBM”、 “[wiki]TCP[/wiki]”之类的词汇,说者和听者都明白它们指的是“奥林匹克运动会”、“国际商业机器公司”和“传输控制[wiki]协议 [/wiki]”,这实际就是信息的压缩。我们之所以可以顺利使用这种压缩方式而不产生语义上的误解,是因为在说者和听者的心中都有一个事先定义好的缩略 语字典,我们在对信息进行压缩(说)和解压缩(听)的过程中都对字典进行了查询操作。字典压缩模型正是基于这一思路设计实现的。
最简单的情况是,我们拥有一本预先定义好的字典。例如,我们要对一篇中文文章进行压缩,我们手中已经有一本《现代汉语词典》。那么,我们扫描要 压缩的文章,并对其中的句子进行分词操作,对每一个独立的词语,我们在《现代汉语词典》查找它的出现位置,如果找到,我们就输出页码和该词在该页中的序 号,如果没有找到,我们就输出一个新词。这就是静态字典模型的基本算法了。
你一定可以发现,静态字典模型并不是好的选择。首先,静态模型的适应性不强,我们必须为每类不同的信息建立不同的字典;其次,对静态模型,我们 必须维护信息量并不算小的字典,这一额外的信息量影响了最终的压缩效果。所以,几乎所有通用的字典模型都使用了自适应的方式,也就是说,将已经编码过的信 息作为字典,如果要编码的字符串曾经出现过,就输出该字符串的出现位置及长度,否则输出新的字符串。根据这一思路,你能从下面这幅图中读出其中包含的原始 信息吗?
啊,对了,是“吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮”。现在你该大致明白自适应字典模型的梗概了吧。好了,下面就让我们来深入学习字典模型的第一类实现——LZ77 算法。
滑动的窗口
LZ77 算法在某种意义上又可以称为“滑动窗口压缩”,这是由于该算法将一个虚拟的,可以跟随压缩进程滑动的窗口作为术语字典,要压缩的字符串如果在该窗口中出 现,则输出其出现位置和长度。使用固定大小窗口进行术语匹配,而不是在所有已经编码的信息中匹配,是因为匹配算法的时间消耗往往很多,必须限制字典的大小 才能保证算法的效率;随着压缩的进程滑动字典窗口,使其中总包含最近编码过的信息,是因为对大多数信息而言,要编码的字符串往往在最近的上下文中更容易找 到匹配串。
参照下图,让我们熟悉一下 LZ77 算法的基本流程。
1、从当前压缩位置开始,考察未编码的数据,并试图在滑动窗口中找出最长的匹配字符串,如果找到,则进行步骤 2,否则进行步骤 3。
2、输出三元符号组 ( off, len, c )。其中 off 为窗口中匹配字符串相对窗口边界的偏移,len 为可匹配的长度,c 为下一个字符。然后将窗口向后滑动 len + 1 个字符,继续步骤 1。
3、输出三元符号组 ( 0, 0, c )。其中 c 为下一个字符。然后将窗口向后滑动 len + 1 个字符,继续步骤 1。
我们结合实例来说明。假设窗口的大小为 10 个字符,我们刚编码过的 10 个字符是:abcdbbccaa,即将编码的字符为:abaeaaabaee
我们首先发现,可以和要编码字符匹配的最长串为 ab ( off = 0, len = 2 ), ab 的下一个字符为 a,我们输出三元组:( 0, 2, a )
现在窗口向后滑动 3 个字符,窗口中的内容为:dbbccaaaba
下一个字符 e 在窗口中没有匹配,我们输出三元组:( 0, 0, e )
窗口向后滑动 1 个字符,其中内容变为:bbccaaabae
我们马上发现,要编码的 aaabae 在窗口中存在( off = 4, len = 6 ),其后的字符为 e,我们可以输出:( 4, 6, e )
这样,我们将可以匹配的字符串都变成了指向窗口内的指针,并由此完成了对上述数据的压缩。
解压缩的过程十分简单,只要我们向压缩时那样维护好滑动的窗口,随着三元组的不断输入,我们在窗口中找到相应的匹配串,缀上后继字符 c 输出(如果 off 和 len 都为 0 则只输出后继字符 c )即可还原出原始数据。
当然,真正实现 LZ77 算法时还有许多复杂的问题需要解决,下面我们就来对可能碰到的问题逐一加以探讨。
编码方法
我们必须精心设计三元组中每个分量的表示方法,才能达到较好的压缩效果。一般来讲,编码的设计要根据待编码的数值的分布情况而定。对于三元组的 第一个分量——窗口内的偏移,通常的经验是,偏移接近窗口尾部的情况要多于接近窗口头部的情况,这是因为字符串在与其接近的位置较容易找到匹配串,但对于 普通的窗口大小(例如 4096 字节)来说,偏移值基本还是均匀分布的,我们完全可以用固定的位数来表示它。
编码 off 需要的位数 bitnum = upper_bound( log2( MAX_WND_SIZE ))
由此,如果窗口大小为 4096,用 12 位就可以对偏移编码。如果窗口大小为 2048,用 11 位就可以了。复杂一点的程序考虑到在压缩开始时,窗口大小并没有达到 MAX_WND_SIZE,而是随着压缩的进行增长,因此可以根据窗口的当前大小动态计算所需要的位数,这样可以略微节省一点空间。
对于第二个分量——字符串长度,我们必须考虑到,它在大多数时候不会太大,少数情况下才会发生大字符串的匹配。显然可以使用一种变长的编码方式 来表示该长度值。在前面我们已经知道,要输出变长的编码,该编码必须满足前缀编码的条件。其实 Huffman 编码也可以在此处使用,但却不是最好的选择。适用于此处的好的编码方案很多,我在这里介绍其中两种应用非常广泛的编码。
第一种叫 Golomb 编码。假设对正整数 x 进行 Golomb 编码,选择参数 m,令
b = 2m
q = INT((x - 1)/b)
r = x - qb - 1
则 x 可以被编码为两部分,第一部分是由 q 个 1 加 1 个 0 组成,第二部分为 m 位二进制数,其值为 r。我们将 m = 0, 1, 2, 3 时的 Golomb 编码表列出:
值 x m = 0 m = 1 m = 2 m = 3
-------------------------------------------------------------
1 0 0 0 0 00 0 000
2 10 0 1 0 01 0 001
3 110 10 0 0 10 0 010
4 1110 10 1 0 11 0 011
5 11110 110 0 10 00 0 100
6 111110 110 1 10 01 0 101
7 1111110 1110 0 10 10 0 110
8 11111110 1110 1 10 11 0 111
9 111111110 11110 0 110 00 10 000
从表中我们可以看出,Golomb 编码不但符合前缀编码的规律,而且可以用较少的位表示较小的 x 值,而用较长的位表示较大的 x 值。这样,如果 x 的取值倾向于比较小的数值时,Golomb 编码就可以有效地节省空间。当然,根据 x 的分布规律不同,我们可以选取不同的 m 值以达到最好的压缩效果。
对我们上面讨论的三元组 len 值,我们可以采用 Golomb 方式编码。上面的讨论中 len 可能取 0,我们只需用 len + 1 的 Golomb 编码即可。至于参数 m 的选择,一般经验是取 3 或 4 即可。
可以考虑的另一种变长前缀编码叫做 γ 编码。它也分作前后两个部分,假设对 x 编码,令 q = int( log2x ),则编码的前一部分是 q 个 1 加一个 0,后一部分是 q 位长的二进制数,其值等于 x - 2q 。γ编码表如下:
值 x γ编码
---------------------
1 0
2 10 0
3 10 1
4 110 00
5 110 01
6 110 10
7 110 11
8 1110 000
9 1110 001
其实,如果对 off 值考虑其倾向于窗口后部的规律,我们也可以采用变长的编码方法。但这种方式对窗口较小的情况改善并不明显,有时压缩效果还不如固定长编码。
对三元组的最后一个分量——字符 c,因为其分布并无规律可循,我们只能老老实实地用 8 个二进制位对其编码。
根据上面的叙述,相信你一定也能写出高效的编码和解码程序了。
另一种输出方式
LZ77 的原始算法采用三元组输出每一个匹配串及其后续字符,即使没有匹配,我们仍然需要输出一个 len = 0 的三元组来表示单个字符。试验表明,这种方式对于某些特殊情况(例如同一字符不断重复的情形)有着较好的适应能力。但对于一般数据,我们还可以设计出另外 一种更为有效的输出方式:将匹配串和不能匹配的单个字符分别编码、分别输出,输出匹配串时不同时输出后续字符。
我们将每一个输出分成匹配串和单个字符两种类型,并首先输出一个二进制位对其加以区分。例如,输出 0 表示下面是一个匹配串,输出 1 表示下面是一个单个字符。
之后,如果要输出的是单个字符,我们直接输出该字符的字节值,这要用 8 个二进制位。也就是说,我们输出一个单个的字符共需要 9 个二进制位。
如果要输出的是匹配串,我们按照前面的方法依次输出 off 和 len。对 off,我们可以输出定长编码,也可以输出变长前缀码,对 len 我们输出变长前缀码。有时候我们可以对匹配长度加以限制,例如,我们可以限制最少匹配 3 个字符。因为,对于 2 个字符的匹配串,我们使用匹配串的方式输出并不一定比我们直接输出 2 个单个字符(需要 18 位)节省空间(是否节省取决于我们采用何种编码输出 off 和 len)。
这种输出方式的优点是输出单个字符的时候比较节省空间。另外,因为不强求每次都外带一个后续字符,可以适应一些较长匹配的情况。
如何查找匹配串
在滑动窗口中查找最长的匹配串,大概是 LZ77 算法中的核心问题。容易知道,LZ77 算法中空间和时间的消耗集中于对匹配串的查找算法。每次滑动窗口之后,都要进行下一个匹配串的查找,如果查找算法的时间效率在 O(n2) 或者更高,总的算法时间效率就将达到 O(n3),这是我们无法容忍的。正常的顺序匹配算法显然无法满足我们的要求。事实上,我们有以下几种可选的方案。
1、限制可匹配字符串的最大长度(例如 20 个字节),将窗口中每一个 20 字节长的串抽取出来,按照大小顺序组织成二叉有序树。在这样的二叉有序树中进行字符串的查找,其效率是很高的。树中每一个节点大小是 20(key) + 4(off) + 4(left child) + 4(right child) = 32。树中共有 MAX_WND_SIZE - 19 个节点,假如窗口大小为 4096 字节,树的大小大约是 130k 字节。空间消耗也不算多。这种方法对匹配串长度的限制虽然影响了压缩程序对一些特殊数据(又很长的匹配串)的压缩效果,但就平均性能而言,压缩效果还是不 错的。
2、将窗口中每个长度为 3 (视情况也可取 2 或 4)的字符串建立索引,先在此索引中匹配,之后对得出的每个可匹配位置进行顺序查找,直到找到最长匹配字符串。因为长度为 3 的字符串可以有 2563 种情况,我们不可能用静态数组存储该索引结构。使用 Hash 表是一个明智的选择。我们可以仅用 MAX_WND_SIZE - 1 的数组存储每个索引点,Hash 函数的参数当然是字符串本身的 3 个字符值了,Hash 函数算法及 Hash 之后的散列函数很容易设计。每个索引点之后是该字符串出现的所有位置,我们可以使用单链表来存储每一个位置。值得注意的是,对一些特殊情况比如 aaaaaa...之类的连续字串,字符串 aaa 有很多连续出现位置,但我们无需对其中的每一个位置都进行匹配,只要对最左边和最右边的位置操作就可以了。解决的办法是在链表节点中纪录相同字符连续出现 的长度,对连续的出现位置不再建立新的节点。这种方法可以匹配任意长度的字符串,压缩效果要好一些,但缺点是查找耗时多于第一种方法。
3、使用字符树( trie )来对窗口内的字符串建立索引,因为字符的取值范围是 0 - 255,字符树本身的层次不可能太多,3 - 4 层之下就应该换用其他的数据结构例如 Hash 表等。这种方法可以作为第二种方法的改进算法出现,可以提高查找速度,但空间的消耗较多。
如果对窗口中的数据进行索引,就必然带来一个索引位置表示的问题,即我们在索引结构中该往偏移项中存储什么数据:首先,窗口是不断向后滑动的, 我们每次将窗口向后滑动一个位置,索引结构就要作相应的更新,我们必须删除那些已经移动出窗口的数据,并增加新的索引信息。其次,窗口不断向后滑动的事实 使我们无法用相对窗口左边界的偏移来表示索引位置,因为随着窗口的滑动,每个被索引的字符串相对窗口左边界的位置都在改变,我们无法承担更新所有索引位置 的时间消耗。
解决这一问题的办法是,使用一种可以环形滚动的偏移系统来建立索引,而输出匹配字符串时再将环形偏移还原为相对窗口左边界的真正偏移。让我们用图形来说明,窗口刚刚达到最大时,环形偏移和原始偏移系统相同:
偏移: 0 1 2 3 4 ...... Max
|--------------------------------------------------------------|
环形偏移: 0 1 2 3 4 ...... Max
窗口向后滑动一个字节后,滑出窗口左端的环形偏移 0 被补到了窗口右端:
偏移: 0 1 2 3 4 ...... Max
|--------------------------------------------------------------|
环形偏移: 1 2 3 4 5 ...... Max 0
窗口再滑动 3 个子节后,偏移系统的情况是:
偏移: 0 1 2 3 4 ...... Max
|--------------------------------------------------------------|
环形偏移: 4 5 6 7 8...... Max 0 1 2 3 依此类推。
我们在索引结构中保存环形偏移,但在查找到匹配字符串后,输出的匹配位置 off 必须是原始偏移(相对窗口左边),这样才可以保证解码程序的顺利执行。我们用下面的代码将环形偏移还原为原始偏移:
// 由环形 off 得到真正的off(相对于窗口左边)
// 其中 nLeftOff 为当前与窗口左边对应的环形偏移值
int GetRealOff(int off)
{
if (off >= nLeftOff)
return off - nLeftOff;
else
return (_MAX_WINDOW_SIZE - (nLeftOff - off));
}
这样,解码程序无需考虑环形偏移系统就可以顺利高速解码了。
资源
结合上面的讨论,典型的 LZ77 算法应当不难实现,我们本章给出的源码是一个较为特殊的实现。
示例程序 lz77.exe 使用对匹配串和单个字符分类输出的模型,输出匹配串时,off 采用定长编码,len 采用γ编码。索引结构采用 2 字节长字符串的索引,使用 256 * 256 大小的静态数组存储索引点,每个索引点指向一个位置链表。链表节点考虑了对 aaaaa... 之类的重复串的优化。
示例程序的独特之处在于使用了 64k 大小的固定长度窗口,窗口不做滑动(因此不需要环形偏移系统,也节省了删除索引点的时间)。压缩函数每次只对最多 64k 长的数据进行压缩,主函数将原始文件分成 64k 大小的块逐个压缩存储。使用这种方法首先可以增大匹配的概率,字符串可以在 64k 空间内任意寻找最大匹配串,以此提高压缩效率。其次,这种方法有利于实现解压缩的同步。也就是说,利用这种方法分块压缩的数据,很容易从原始文件中间的任 何一个位置开始解压缩,这尤其适用于全文检索系统中全文信息的保存和随机读取。
结合上述示例程序,王笨笨开发了可压缩多个文件并可同步(随机)解压缩的文件级接口,但此接口并非自由代码(free code)。如果需要可以和王笨笨联系。
示例程序 lz77 的所有源文件被包装在文件 lz77.zip 中,由王笨笨编写,在 Visual C++ 5.0 环境下编译通过。其使用方法是:
压缩: lz77 c 源文件名 压缩文件名
解压缩: lz77 d 压缩文件名 源文件名
第六章 聪明的以色列人(下):LZ78 和 LZW
LZ78 的算法描述:
for (;;)
{
current_match = 1;
current_length = 0;
memset(test_string, '/0', MAX_STRING);
for (;;)
{
test_string[current_length ++] = getc(input);
new_match = find_match(test_string);
if (new_match) == -1)
break;
current_match = new_match;
}
output_code(current_match);
output_char(test_string[current_letgth - 1];
add_string_to_dictionary(test_string);
}
LZ78 示例:
输入正文: "DAD DADA DADDY DADO..."
输出短语 输出字符 编码后的串
0 'D' "D"
0 'A' "A"
1 ' ' "D"
1 'A' "DA"
4 ' ' "DA "
4 'D' "DAD"
1 'Y' "DY"
0 ' ' " "
6 'O' "DADO"
此时字典的情况:
0 ""
1 "D"
2 "A"
3 "D "
4 "DA"
5 "DA "
6 "DAD"
7 "DY"
8 " "
9 "DADO"
第六章 聪明的以色列人(下):LZ78 和 LZW
LZ78 的算法描述:
for (;;)
{
current_match = 1;
current_length = 0;
memset(test_string, '/0', MAX_STRING);
for (;;)
{
test_string[current_length ++] = getc(input);
new_match = find_match(test_string);
if (new_match) == -1)
break;
current_match = new_match;
}
output_code(current_match);
output_char(test_string[current_letgth - 1];
add_string_to_dictionary(test_string);
}
LZ78 示例:
输入正文: "DAD DADA DADDY DADO..."
输出短语 输出字符 编码后的串
0 'D' "D"
0 'A' "A"
1 ' ' "D"
1 'A' "DA"
4 ' ' "DA "
4 'D' "DAD"
1 'Y' "DY"
0 ' ' " "
6 'O' "DADO"
此时字典的情况:
0 ""
1 "D"
2 "A"
3 "D "
4 "DA"
5 "DA "
6 "DAD"
7 "DY"
8 " "
9 "DADO"