数据压缩.docx
《数据压缩.docx》由会员分享,可在线阅读,更多相关《数据压缩.docx(20页珍藏版)》请在冰豆网上搜索。
数据压缩
第四章向极限挑战:
算术编码
第三章第五章
我们在上一章中已经明白,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。
咦?
怎么一会儿是表示半个二进制位,一会儿又是输出一个小数,算术编码怎么这么古怪呀?
不要着急,我们借助下面一个简单的例子来阐释算术编码的基本原理。
为了表示上的清晰,我们暂时使用十进制表示算法中出现的小数,这丝毫不会影响算法的可行性。
考虑某条信息中可能出现的字符仅有abc三种,我们要压缩保存的信息为bccb。
在没有开始压缩进程之前,假设我们对abc三者在信息中的出现概率一无所知(我们采用的是自适应模型),没办法,我们暂时认为三者的出现概率相等,也就是都为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.01.0
输入b0.00.5
输入c0.250.5
输入c0.3750.5
输入b0.3750.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由LeagueforProgrammingFreedom的MarkNelson提供,由王笨笨在VisualC++5.0环境下编译、调试通过。
Arith-N包含VisualC++工程ArithN.dsp和ArithNExpand.dsp,分别对应了压缩和解压缩程序an.exe与ane.exe。
Arith-N是可以在命令行指定阶数的N阶上下文自适应算术编码通用压缩、解压缩程序,由于是用作教程示例,为清晰起见,在某些地方并没有刻意进行效率上的优化。
第五章聪明的以色列人(上):
LZ77
第四章第六章
全新的思路
我们在第三和第四章中讨论的压缩模型都是基于对信息中单个字符出现频率的统计而设计的,直到70年代末期,这种思路在数据压缩领域一直占据着统治地位。
在我们今天看来,这种情形在某种程度上显得有些可笑,但事情就是这样,一旦某项技术在某一领域形成了惯例,人们就很难创造出在思路上与其大相径庭的哪怕是更简单更实用的技术来。
我们敬佩那两个在数据压缩领域做出了杰出贡献的以色列人,因为正是他们打破了Huffman编码一统天下的格局,带给了我们既高效又简便的“字典模型”。
至今,几乎我们日常使用的所有通用压缩工具,象ARJ,PKZip,WinZip,LHArc,RAR,GZip,ACE,ZOO,TurboZip,Compress,JAR……甚至许多硬件如网络设备中内置的压缩算法,无一例外,都可以最终归结为这两个以色列人的杰出贡献。
说起来,字典模型的思路相当简单,我们日常生活中就经常在使用这种压缩思想。
我们常常跟人说“奥运会”、“IBM”、“TCP”之类的词汇,说者和听者都明白它们指的是“奥林匹克运动会”、“国际商业机器公司”和“传输控制协议”,这实际就是信息的压缩。
我们之所以可以顺利使用这种压缩方式而不产生语义上的误解,是因为在说者和听者的心中都有一个事先定义好的缩略语字典,我们在对信息进行压缩(说)和解压缩(听)的过程中都对字典进行了查询操作。
字典压缩模型正是基于这一思路设计实现的。
最简单的情况是,我们拥有一本预先定义好的字典。
例如,我们要对一篇中文文章进行压缩,我们手中已经有一本《现代汉语词典》。
那么,我们扫描要压缩的文章,并对其中的句子进行分词操作,对每一个独立的词语,我们在《现代汉语词典》查找它的出现位置,如果找到,我们就输出页码和该词在该页中的序号,如果没有找到,我们就输出一个新词。
这就是静态字典模型的基本算法了。
你一定可以发现,静态字典模型并不是好的选择。
首先,静态模型的适应性不强,我们必须为每类不同的信息建立不同的字典;其次,对静态模型,我们必须维护信息量并不算小的字典,这一额外的信息量影响了最终的压缩效果。
所以,几乎所有通用的字典模型都使用了自适应的方式,也就是说,将已经编码过的信息作为字典,如果要编码的字符串曾经出现过,就输出该字符串的出现位置及长度,否则输出新的字符串。
根据这一思路,你能从下面这幅图中读出其中包含的原始信息吗?
啊,对了,是“吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮”。
现在你该大致明白自适应字典模型的梗概了吧。
好了,下面就让我们来深入学习字典模型的第一类实现——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编码表列出:
值xm=0m=1m=2m=3
-------------------------------------------------------------
10000000000
210010010001
31101000100010
411101010110011
511110110010000100
6111110110110010101
711111101110010100110
8111111101110110110111
91111111101111001100010000
从表中我们可以看出,Golomb编码不但符合前缀编码的规律,而且可以用较少的位表示较小的x值,而用较长的位表示较大的x值。
这样,如果x的取值倾向于比较