LZW 压缩算法

LZW压缩算法介绍

转自: http://www.360doc.com/content/11/0217/14/2150347_93804935.shtml
LZW是啥意思?懒子王!一听这名就知道这算法不是一般的懒子,要不怎么也称王呢。

  懒子王压缩算法是一种新颖的压缩方法,由Lemple-Ziv-Welch 三人共同创造,用他们的名字命名。它采用了一种先进的字典压缩,将每个第一次出现的串放在一个字典中,用一个数字来表示串,压缩文件只存储数字,不存贮串,从而使图象文件的压缩效率得到较大的提高。懒子的是,压缩完了之后这个字典就可以给扔了,解压时会重建起这个字典。

  在懒子王算法中,有这么几个概念:

1.字符:不一定是指ASCII字符,就是一个8位二进制数,0--255,unsigned char或是uint8或是BYTE型能表示的。

2.串:一个字符的序列,没有C语言中用'\0'封尾的那种要求。

3.字典:里面存放串,每一个串对应的编码都与字典中的位置形成一一对应。

4.根:字典产生时就带有的东西,比如带有的字符叫根字符,可以分别是0--255,根字符和空前缀组成根串,根串的编码称为根编码。

  一个串被表示成(前缀,后缀)格式,前缀可以是一个字符,也可以是一个串的编码,为统一形式,一个字符用对应的根编码表示,所以,前缀是一个编码。后缀就是一个字符,没有别的形式。

  还有一点,在字典中有两个特殊的条目,一个是CLEAR,一个是END,比如字典的根编码是0--255,则CLEAR = 256,END = 257。

  现在我们来以一个具体的例子说明这个算法是怎么个懒子法的,假设这个字典的根字符是A,B,C,D,4个,加上CLEAR和END一共6个,占用000--101,现在编码长度是3位。输入流里面的字符序列是

ABABABABBBABABAA

  第一步,取第一个字符,是A,A已经在我们的字典中了(根字符),也就是说,我们已经(认识)它了,就把它的编码作前缀,成(A,)。

  下一步,取第二个字符,现在的取到的串为(A,B)。以前没见过,不认识,好,现在把(A,B)编码为6,下次再碰到就认识了。把A放入到输出流中,让后缀B作前缀(实际是根字符B的编码,为了方便才写B)。

  第三步,读下一个字符,是A,现在串是(B,A),还是不认识,用一个新编码来表示它,令7=(B,A)。把前缀B放入到输出流,后缀A变前缀。

  第四步,取下一个字符,是B,现在串是(A,B),嘿,这次认识了,不就是老6嘛,好了,让6作前缀去。

  第五步,取下一个,是A,现在串是(6,A),又不认识了,令8=(6,A)。等等,有情况!刚才新建的字典条目6,7已经把3位的编码给占满了,这可咋整呀?懒子王是一定有办法解决的,现在懒子王算法的第二点懒子之处就体现出来了,变长编码。刚才编码用3位表示,现在不行了,就用4位表示,现在能表示8了,其它照常,输出前缀6,后缀A变前缀。

  就这样,输入流中的数据变成了AB68……放到输出流中,怎么样,变短了吧,嘿嘿。

  现在问题又来了,字典里3位不够了用4位来表示可以,因为咱这字典里有地方嘛,可是要是咱建个12位的字典,已经有4096个条目,存满了,再来新串咋整呀,别忘了咱最开始说懒子王的时候说它是怎么懒子的了?字典用完就扔了,解压时能重建,对吧,想想啊,一个字典已经建满了,解压的时候也可以把这个字典重建到满,对吧,如果现在这个输入流结束了,再来个输入流,会出现什么情况呢?压缩时新建个字典,解压时再重建这个字典,对吧,那解决方案就来了,旧字典字典不要了,当作上一个输入流结束下一个输入流开始,从只有根串的字典开始再建一个,怎么样,懒子吧,但这招它就是好使。

  总结一下,懒子王压缩算法的基本流程大致如下:

1.从输入流中读入一个字符,作为当前串的后缀。

2.如果当前串在字典中,就用当前串的编码作前缀,转到第1步。

3.如果当前串不在字典中,就把当前串放到字典中,并把前缀放到输出流,后缀变前缀,转到第1步。

4.当输入流读完了后,串中应该还剩一个前缀,把它放到输出流,结束。

  解压算法实际就是查字典的过程,也是对串进行处理,以上面的例子为例,输入流是AB68……

  第一步,读第一个编码,是A,在字典中,就作为后缀,现在串是(,A),串也在字典中,不作其它处理,把编码A作为前缀,并放入输出流(实际上是把前缀对应的最原始串放入输出流)。

  第二步,读下一个编码,是B,也在字典中,作为后缀,现在串是(A,B),不在字典中,就把它加入到字典中,令6=(A,B),把当前编码B作前缀,并放入输出流。

  第三步,读下一个编码,是6,在字典中,作为后缀……?看看开头的定义,后缀是一个字符,可不能是编码呀,把6展开,6=AB,我们把6的第一个字符,即A,作为后缀,现在串是(B,A),不在字典中,令7=(B,A),把当前编码6(不是它是第一个字符A)作为前缀,并把前缀6代表的最原始的串(字符的序列)放入输出流(为方便,说成把6放入输出流)。

  第四步,读下一个编码,是…………8??是呀,咋了,上面写着呢,现在输入流中的二进制数据是1000……,读3位读到的应该是4呀,怎么会成8了呢,注意到字典建到7了,3位已经满了,接下来再建字典就应该是4位的了,所以现在读输入流时改成一次读4位,这就是8了,8这个编码不在字典中,建一个吧,令8=(6,8),这怎么能行呢,别说后缀不能是编码了,就算能是编码,怎么能用自己来定义自己呢,那咋整呀,咱回头想想这个8是咋来的啊,它的前缀是6,所以8=(AB……),可见8的第一个字符就是6的第一个字符A,好了,令8=(6,A),然后把8作前缀,并放入输出流。

现在输出流中的字符是A B AB ABA ……,与原文件一致。

总结一个算法流程:

1.在输入流中读一个字符。

2.如果当前编码在字典中,则把当前编码的第一个字符作为当前串的后缀,如果当前串不在字典中,就把它加入到字典中,然后把当前编码作为串的前缀,转到第4步。

3.如果当前编码不在字典中,就把前缀的第一个字符作为后缀,把串加入到字典中,用当前串的编码作前缀,转到第4步。

4.把前缀放到输出流,转到第1步。

  到现在为止,这个算法已经很懒子了,但是要称王,恐怕还没有绝对的把握。

  压缩率已经通过变长编码来提高了,现在再做一件懒子事吧,来说说怎么提高算法的速度。

  要说速度主要还是要从字典和字典条目的结构说起,如果字典条目的结构就是一个(前缀,后缀),且字典是顺序结构来存储这些条目的,那么在查字典时就在一个一个的比对,非常浪费时间,所以我们有必要想一些改进方法来让算法更懒子。

空间换时间的方法:

  这个算法在那份《改进的字典压缩LZW算法》中提出过。建立一个数组,大小是字典大小*根字符数量*字典大小战胜的字节数。比如建的字典大小是4096,根字符是256个,那这个数组就可以定义为uint8 dict[4096][256],大小就是4096*256*2=2M。字典先初始化成全0,在清空字典后也要设成全0再重建。具体算法如下:

开始:
后缀=读入的字符;
编码=数组[前缀][后缀];
if (编码==0){  //串(前缀,后缀)还没有出现过,不在字典中
              数组[前缀][后缀]=当前字典大小;
              当前字典大小++;
              输出流+=前缀;
              前缀=后缀;

else {                         //串在字典中

              前缀=编码;

goto 开始;


  可以看出,这个算法每一步只需一次查找,速度当然是非常快了,只不过这个速度是靠内存的大量消耗的代价来取得的。虽然2M的内存在今天好像算不上什么,但是想一想这个算法可是在1978年提出,1984年实现的,那个时候2M的内存简直就是天文数字,而那时的巨型机速度也比不上现在的PC机,所以那时是不可能用以上的两种算法来实现的,无论是第二种算法速度上的开销还是上面这种算法内存上的开销,都是那时不可能承受的,因此,他们一定是采用了别的算法,兼顾了速度和内存的开销。

一种兼顾时间和空间的方法:

  首先,定义一个结构体。
Struct Code{              //结构     编码{
       Word suffix;        //                          本编码的后缀;
       Word FirstSon;    //                          第一个子编码;
       Word NextBrother;            //            下一个兄弟编码;
}                                              //     }
说明一下。第一个子编码就是第一个以这个编码为前缀的编码,下一个兄弟编码是指下一个与这个编码前缀相同的编码。建立一个编码结构数组,如果最大字长12位的话,数组长度就是4096个。建立好了以后就把它全部清零。
  那么每一次读取一个新的字符后的操作为:
1.读取这个编码的FirstSon。如果FirstSon为空,则表示还没有以这个编码为前缀的编码,就可以在当前的最后一个编码后建立一个新的编码,将FirstSon设置为这个新编码,将新编码的suffix设置为这个刚读取的字符,将当前的前缀放入输出流,后缀变成前缀。读入下一个字符
2.如果FirstSon不为空,就跳到这个编码上,比对这个编码的后缀和当前的后缀,如果相同,则表明找到了,把这个编码作为当前字符串的前缀,读下一个字符。
3.如果这个编码的后缀不等于当前后缀,那么就跳到他的NextBrother标记,比对两个后缀,直到找到或者NextBrother为空。
4.如果NextBrother为空了,表示还没有表示这个串的编码,那么在当前的最后一个编码后建立一个新编码,将当前编码的NextBrother设置成新编码,将新编码的后缀设置成当前后缀,将当前前缀放到输出流,后缀变前缀。读入下一个字符。
  按照这种算法,每次需要查找的次数大大减少,最好的可能一次就找到,最坏的可能是255次,但是这种可能实在太小,我想应该在10次以内吧。而且这个算法的内存使用很少,一个标记只占6个字节,比第二种算法多2个。实际上如果是以8位来处理,标记最大长度12位来算的话,一个标记结构所需要的位数为 8+12+12=32位,正好4个字节,所以可以用一个byte 存后缀,一个word的低12位存 NextBrother , 高四位存 FirstSon 的高4位,再用一个byte存FistSon的低字节。因为每次FirstSon只用一次,所以处理麻烦些无所谓。这样算来,字典所用的空间就是4K*4=16K
  也许你觉得没必要在内存空间的使用上如此计较。当然,如果是在PC机上,这个几十K的空间,确实没必要这么计较。实际上这种单纯的LZW算法已经很少单独在PC机上使用了。不过在很多的嵌入系统中,需要存储很多的记录数据,这种记录的数据往往重复的内容很多,如果压缩后,体积会大大减小。而嵌入式系统的存储空间又是很有限的,它的内存大小和CPU的速度又不能支持复杂的、比较吃内存的压缩算法,这时这种简单而又消耗小的算法就有用武之地了。大家以后如果遇到这种情况,可以考虑使用这种算法。

我的散列表方法:

  建立一个大小为4096的散列表,先用散列函数求出256个根字符的散列值(在散列表中的位置),字典就初始化完成了,然后每来一个新串,就用散列函数计算出它的散列值,把它加到字典中的相应位置。

  关于字符串的散列函数,有挺多经典的,不过我用的是最简单的,就是取模,具体是怎么取的一会再说。

  再说说散列表的另一个重要的事,冲突处理:一般有三种方法:线性再散列,拉链,外散列,但我用的是暴雪的一种方法,同时使用三个不同的散列函数来确定串,这样重复的概率可以降低到1/2^23级。看星际和魔兽里有什么.mpq文件吧,那就是用这种方法做的数据包。我是把前缀和后缀拼起来,分别取它的低、中、高K位,K=编码长度。前缀12位,后缀8位,拼起来20位,三个散列函数最少能取27位,谁要说冲突,你可以跟他赌国足拿世界杯,虽然你不能赢,但也不会输。什么内散列法,外散列法的,其实在这种情况下都没有顺延法好用。

  以我目前的水平来推测,现在算法已经懒子到可以称王的程度了,也许会有其它的牛人,会想到让这个算法更懒子的方案,欢迎分享讨论。懒子王的口号是:没有最懒子,只有更懒子!

 

参考文献:http://blog.csdn.net/whycadi/archive/2006/05/29/760576.aspx






LZW for GIF 算法原理和实现
转自: http://blog.csdn.net/whycadi/article/details/760576
LZW for GIF 算法原理和实现
Why 2004-4-6
       废话少说。先说LZW for GIF 的原理。
       LZW是一个字典式压缩算法,他在压缩原始数据时,对每一个新出现的原始数据串赋一个数值作为标号,那么下次又出现了这个串后,就可以用这个值来代替了。比如
 
原始数据:  ABCCAABCDDAACCDB      ,
ABCD可以用0~3的数来表示。那么注意这个字符串中出现了好几个重复的字串:
AB CCA ABCDDAACCDB
那么就可以用 4来代表 AB,5来代表 CC 等等,原来的字符串就变为
压缩后的数据: 45 A 4 CDDAA5DB ,
变短了一点点。实际上上面这个字符串号可以进一步的压缩,等会再谈。
 
为了区别代表串的值和原来的单个的数据值,需要使它们的数值域不重合,比如说原来的数我们是以8位为单位来处理的(就算实际上不是8位的,我们也可以看作是8位的,反正是一个0101的数据流),那么就认为原始的数的范围是0~255,压缩程序生成的标号的范围就不能为0~255,可以从256开始,但是这样一来就超过了8位的表示范围了,所以 LZW 算法必须要扩展数据的位数,至少要扩一位,这样看起来不是反而是增加了数据流的体积了吗?不过如果能用一个数据代表一个原始数据串,那么还是划得来的。从这个原理也可以看出LZW算法的适用范围, 那就是原始数据串最好是有大量的子串多次重复出现,重复的越多,压缩效果越好。反之则越差,可能真的不减反增了
LZW算法在处理数据时,随着新的串的不断发现,标号的值也就不断增加,增加到一定的程度,出与查找效率和标号集(也就是字典)所需存储空间的考虑,就不能再让它增加了,那么怎么办呢?干脆就从头开始,在这里做一个标记( 清除标志 CLEAR),表示从这里我 重新开始构造字典字典了,以前的所有标记作废,开始使用新的标记。这个标号集的大小多少比较合适呢?据说理论上是越大压缩率越高(我个人感觉太大了也不见得就好),不过处理的开销也呈指数增长, 一般都是根据处理速度和内存空间选定一个大小,GIF规范规定的是12位,超过12位的表达范围就推倒重来,并且GIF为了提高压缩率,采用的是变长的字长。比如说原始数据是8位,那么一开始,先加上一位再说,开始的字长就成了9位,然后开始加标号,当标号加到512时,也就是超过9为所能表达的最大数据时,也就意味着后面的标号要用10位字长才能表示了,那么从这里开始,后面的字长就是10位了。依此类推,到了2^12也就是4096时,在这里插一个清除标志,从后面开始,从9位再来。
GIF规定的清除标志 CLEAR 的数值是原始数据字长表示的最大值加 1,如果原始数据字长是8清除标志就是256,如果原始数据字长为4那么就是16。另外GIF还规定了一个 结束标志 END ,它的值是清除标志 CLEAR 再加 1。由于GIF规定的位数有1位(单色图),4位(16色)和8位(256色),而1位的情况下如果只扩展1位,只能表示4种状态,那么加上一个清除标志和结束标志就用完了,所以1位的情况下就必须扩充到3位。其它两种情况初始的字长就为5位和9位。
 
好了,现在开始谈谈LZW的具体算法。前面已经说了,LZW是基于字典的压缩方法,那么这个字典是怎么来的呢?难道先编一本“大百科字典”,随压缩包免费奉送?这显然是不可能的。LZW算法的优点就是可以 动态生成字典,并且这个字典的信息已经包含在压缩后的数据流中了,不必再另外储存字典信息了。下面以一个压缩过程为例来说明一下。这个例子是Bob Montgomery给出的,非常的经典,我这里充当一个翻译的工作,并稍微加一点我的解释。
比如有一个字符串,是由A、B、C、D四个字符构成的,那么就可以用0 1 2 3 来表示,两位就够了。
A B A B A B A B B B A B A B A A C D A C D A D C A B A A A B A B .....
首先要扩充一位,变成3位,定义 Clear=4,End=5。那么以后的标号就从6开始。
第一步,取第一个字符,是A,A已经在我们的定义中了,也就是说,我们已经(认识)他了,就不做处理了。
下一步,取第二个字符,现在的取到的字符串为 A B ,注意,这里引入一个 前缀( prefix 后缀( suffix 的概念,一个字符串可以用一个前缀加一个后缀来表示即 (前缀,后缀)前缀是一个标号,可以是原始的字符,也可以是一个代表字符串的标号,后缀则是一个字符。那么这里取到了( A B ),以前没见过,不认识,好,现在 6 代表( A B ), 下次就认识了。因为有不认识的了,所以把前缀  A  放入到输出流中,只保留后缀  B  ,让它变成前缀。
第三步,取下一个字符,是  A ,现在的字符串是( B A ),还是不认得,用一个新标号来表示他, 7 =( B A )。把前缀  B  放入到输出流, 变成前缀。
第四步,取下一个字符,是  B  ,那么取道的是( A B ),哈,这次认得了,不就是老 6 么。好,把字符串规约到  6 ,以 6 来作为前缀。
第五步,取下一个,是 ,即为( 6 A ),又不认得了,就令 8 =( 6 A ),把前缀  6 放到 输出流。现在的前缀变成  A 了。 注意 ,到这里标号已经超过 3 位能够表示的最大范围了,所以接下来必须要 扩展数据位 ,那么接下来的数据就是以 4 位字长来表示了。
接下来的流程不一一讲述了,如图所示
A B A B A B A B B B A B A B A A C D A C D A D C A B A A A B A B ....输入流.
////---/-----///---/-------/// // / ///---/---// ///-----/---/---/
6 7   8     9 10 11      12 13 14 15 16 17 18 19 20   21 22 23     标号
    6    8      10    9                14 16        8    13 7       前缀
 
 
 
 
 
 
Color    Code      Prefix    Suffix    String    Output 位数
A        0                             -
B        1                             -
C        2                             -
D        3                             -
Clear     4                             -
End       5                             -
A /                A         A                   First color is a special case.
B / /   6         A         B         AB        A                3
A | /   7         B         A         BA        B                3
B |
A / |   8         6         A         ABA       6                3
B    |
A    |
B / /   9         8         B         ABAB      8                4
B / |   10        B         B         BB        B                4
B    |
A | /   11        10        A         BBA       10              
B |
A |
B |
A / /   12        9         A         ABABA     9
A / /   13        A         A         AA        A
C / /   14        A         C         AC        A
D / /   15        C         D         CD        C
A / |   16        D         A         DA        D                4
C    |
D | /   17        14        D         ACD       14              5
A |
D / /   18        16        D         DAD       16
C / /   19        D         C         DC        D
A / |   20        C         A         CA        C
B    |
A    |
A | /   21        8         A         ABAA      8
A |
B / |   22        13        B         AAB       13
A    |
B    /   23        7         B         BAB       7
输出数据流为: A B 6 8 B 10 9 A A C D 14 16 D C 8 ....
 
LZW的解压
LZW 的解压过程就是一个查字典的过程。那么这个字典是从哪里来的呢?这就是 LZW 的最大优点之一,那的字典信息是完全包含在压缩后的数据中的,解压程序可以动态的从压缩过的数据中构造出来,因此解压过程也非常类似于压缩过程。
以上面的例子压缩结果为例,它的输出就是我们的输入,为:
待解压的数据流为: A B 6 8 B 10 9 A A C D 14 16 D C 8 ....
解压的过程是以一对一对数据来处理的。首先我们要知道原始数据的位数,这一点是要在处理压缩数据以前就知道的。在 GIF 文件中,可以从。我们来一步步分析这个过程:
第一步,取第一个和第二个数据,是( A B ),不认得,令 6 =( A B ),把前缀 A 放入输出流中,后缀 B 变成前缀;
第二步,取第三个数据,现在变为( B 6 )。不认得, 那么令 7 =( B 6 )吗? …… NO 请先回过头去看压缩过程,我们定义一个新的标志,前缀可以是一个代表子串的标号,但是后缀都是一个单个的数据的,所以这里我们也不能让后缀是一个字串标号。那么后缀是什么呢?让我们把 6 展开, 6 A B ,那么这里原来的字串就是 B A B ……,现在知道了把,其实 7 =( B A ), 那么7的后缀就是代表的字符串的第一个字符 。现在我们把 B 放入输出流。
第三步,取第四个数据。第四个数据是……………????怎么,不就是 8 吗。那是,明摆着写着呢。如果我不这样写,写成 0001100001011001 …………你还认得是 8 吗?由于 GIF 是变位长的,所以我们一定要清楚在这里到地取几位。先回过头来看上一步,由于上一步我们已经把标号排到 7 了, 7 3 位的最大数值,所以从这一步以后,就应该把 字长加一位 ,所以这里我们要取 4 位,就是 1000 ,也就是 8 了。好了,现在得到的是( 6 8 )。不认得,那么令 8 =( 6 ,……………………………………… 8 ??)。 8 我们还不知道呢,怎么能用它来定义自己呢?回顾一下上一步我们定义 7 的时候,取的后缀是 6 的第一个字符,那么这里我 8 的后缀也应该是 8 的第一个字符,我们知道 8 的前缀是 6 ,那么 8 的第一个字符也就是 6 的第一个字符。也就是  A 。所以 8 =( 6 A ),现在把 6 代表的字符串 AB 放入输出流,让前缀变为 8
第四步,取第五个数,现在是( 8 B ),让 9 =( 8 B ),把 8 6A AB A 放入输出流,前缀为 B
第五步,取第六个,( B 10 ),同第三步,令 10 =( B 10 的第一个字符)=( B B ),把 B 放入输出流
………………………
现在的输出流是
1            2            3            4            5      ………
A           B      AB         AB A        B  ……
和原来的数据比较一下:
A     B   A B       A B A  B B B A B A B A A C D A C D
完全一样。
就这样,完成了解压缩的过程。
 
算法的实现
        根据前面的介绍的原理,就可以设计程序的了。其实看起来,好像是很简单的,压缩就是一个一个的读,已经认识的就用代表它的标记来代替,不认识的就定义一个新标记来代表它,同时把前缀放到输出流中;解压过程就是一对一对的读,每次都可以定义一个新标记,同时把前缀展开,放入输出流。有什么难的?
的确,看起来很简单,实际上编起来也不难,然而,却有一个很重要的问题需要好好的考虑,那就是, 怎么样才能知道当前的字符串是已经遇见过的?如果是遇见过的,它的标号是多少呢? 显而易见,必须把已经遇见过并且标了好的字符串以某种方式储存起来,每次读入一个新的字符,就要去查找储存起来的数据。由于每次读一个字符都要查找储存的数据,所以花在查找上的时间就成为了压缩过程最主要的开销,因此以什么方式储存这些数据并用什么方式来查找就决定了这个算法的效率。以下就说说几种储存方式的区别。
1. 最直观的方法
最直观的方法,当然是把每个标号所代表的字符串都存起来。建立一个字符串数组,比如上面的例子, 6 AB 7 BA 8 ABA 9 ABAB 等等等等,那么就建立一个字符串数组,令
S[6]=”AB”;
S[7]=”BA”;
S[8]=”ABA”;
S[9]=”ABAB”;
………………
看到这里可能大家心里都在摇头,这样存实在是太蠢了。一来所耗的空间太大,二来根本没提供任何信息帮助查询,要检查一个字符串是否匹配,就要一个一个的比对,想一想如果以 12 位最大字长来算,就有 4000 左右的标号,每一个字符串又可长可短,如果按最大可能来分配…………算了,我都不想算了。如果进行一次查找,以最坏的可能的话,可能会进行上万次比对。所以这种存法,虽然直观,但完全没有使用价值。
2. 最节省内存的算法
请注意,其实每个字符串都是由一个前缀和一个后缀组成的,从我的这篇文章中大家可以很轻易的注意到这一点,因为我在前面的说明中反复的强调了这一点,但是我在学习过程中所看的资料对这个都没有做明确的说明,一笔带过了,所以我总结出这个原理的重点和优化算法的关键实在是花了不少的功夫。
         注意到了这一点,我们就可以对一个标号只存一个前缀和一个后缀,以最大字长 12 位为例,我们可以建立这样一个结构数组,结构为:
typedef struct  标号{
               word        前缀;
               word       后缀;
;
结构数组为:
标号 标号组[ 4096 ];
 
其实用不着 4096 个,因为前面的 2^n 2 n 是原始数据字长)个是不用记的,那些值是原始数据和清除标记还有结束标记。只不过这样可以使标记的数值正好可以等于它的下标,不用在换算了,如果一定要省掉前面那些也无所谓,只不过需要转换一下数值和下标而已。(做个简单的加减法,不过每次都要做,开销还是有点大)
好,我们开始,如上例,我们先把这块内存清空,并定义一些变量:
memset( 标号组, 0 sizeof( 标号组 ))       // 一共是 16K
int    当前最大标号= 6
word  前缀,后缀;
byte  输入流[ x ];
byte  输出流[最大长度];
int  输入序号= 0 ,输出序号= 0
然后,我们读入第一个字符  A 和第二个字符
前缀=输入流[输入序号];
输入序号++;
从这里开始,我们开始压缩过程,直到把数据处理玩:
int I=6;
for( 输入序号  ;  输入序号 <X ;  输入序号 ++){
               后缀=输入流[输入序号];
              // 查找当前串在表中的位置
              bool found=false;
              while ( I< 当前最大标号  ) {
                     if (  前缀  !=  标号组[ I ]。前缀 I ++; continue;
                     if(  后缀  !=  标号组[ I ]。后缀  )  I ++; continue;
                     // 找到了,就是这个串
                     found=true;
                      前缀= I       // 把当前串规约到标号
                     I ++;
                     break;
              
              if ( ! found ) {                     // 没找到,把这个串加到标号组中
                      标号组[当前最大标号]。前缀=前缀;
                      标号组[当前最大标号]。后缀=后缀;
                      当前最大标号++;
                      输出流[输出序号]=前缀;
                      输出序号++;
                      前缀=后缀;
                     if  当前最大标号 > 4095 {           // 已经超过了最大的长度
                             当前最大标号= 6
                             输出流[输出序号]= 清除标志;
                             输出序号++;
                     
                     I=6;
              
输出流[输出序号]=前缀;
 
后面的处理输出串的就省略了。
这个算法的原理就是,查找标号组,找到了,就把串归约到标号,把标号作为前缀,再读入下一个字符,因为如果这时有匹配的串,那么它的标号也肯定比现在的前缀大,所以可以从前缀+ 1 的地方开始搜索。如果搜索到当前最大的标号还没有匹配的话,那么就表明没有匹配的标号。就要增加一个新的标号,放到标号组的最后,并把前缀放到输出流中,后缀变成前缀。
可以看出这个算法是非常的节省内存的,它只需要为每个标号分配 4 个字节,但是它的效率却也不高,因为他每输出一个标号到输出流,实际上都要把整个表号组挨个搜索一遍,那么越到后面,开销就越大。所以这个方法也不好。
        那么怎么样才能把搜索速度加快呢?下面的这个方法速度绝对快,但是它的内存开销也非常大,是典型的以空间换时间方法。
3. 以空间换时间的算法
这个算法就是在那份《改进的字典压缩LZW编码向君》(包含在我发上的GIF文档中)中提出的算法。他的程序是用Pasic写的,我是看都没看(看不懂),不过从他的说明中,我明白了他的做法。
我们来看看LZW的主要时间开销,就是读入一个新的字符后,怎么样才能把当前的字符串规约到一个标号(无论是已知的还是未知的),这个过程需要查找表号组,那么为了减少时间开销,最直接的办法就是减少查找的次数。我们读入一个新的字符后,得到的字符串为 (前缀,新字符),其中前缀是上一次已经归约好了的,是已知的,新字符也是已知的,怎样才能根据这两个数值来一次找到它所属的标号呢?
如果有一个二维数组 数组[ x ][ y ],我们知道了一个元素的 x y 以后,可以很快的算出他在数组中的位置,那么,在这里,我们也可以建立一个数组,他的大小=标号最大数量*字符集数量*标号字节数。以字符为 8 位,表号长度为 12 位来计算,这个数组的大小就是  4096 256 2 2M 。建立了这个数组后,首先是要把它的内容全部清 0 。并且每次达到最大字长,从头再来后都要全部清 0 ,而不像上一种算法,只需要改变当前最大标号值,对表号组中的数可以不清除。这个算法具体的做法是:
开始:
后缀=输入流[输入当前位置++];
标号=标号数组[前缀][后缀];
if  (标号== 0 ){    //这种 (前缀,后缀)还没有出现过
               表号组[前缀][后缀]=当前最大标号;
               当前最大标号++;
               输出流[输出当前位置++]=前缀;
               前缀=后缀;
else                            //这个组合已出现过了,
               前缀=标号;
goto  开始;
可以看出,这个算法每一步只需一次查找,速度当然是非常快了,只不过这个速度是靠内存的大量消耗的代价来取得的。虽然2M的内存在今天好像算不上什么,但是想一想这个LZ78算法可是在1978年提出,1984年实现的,那个时候2M的内存简直就是天文数字,而那时的巨型机速度也比不上现在的PC机,所以那时是不可能用以上的两种算法来实现的,无论是第二种算法速度上的开销还是上面这种算法内存上的开销,都是那时不可能承受的,因此,他们一定是采用了别的算法,兼顾了速度和内存的开销。
4. 一个我没看懂的方法
就是在我提供的那个 GIF VCL 控件中作者使用的算法了。只不过我这人最不喜欢看代码了,而且文件里一行注释都没有,格式也很乱(可能高手都这样吧),所以我怎么也看不明白。因为我没看懂,所以也就不多说了,不过我在看程序的时候,看到他用了这样一个名称:  Hash_Table ,想必是使用了哈希表。大家可以去回顾一下哈希表的内容,作者可能是构造了一个哈希表来解决这个空间与时间的矛盾。
5. 我的方法
在看过前面说的第三种方法后,由于那个源代码我实在看不懂,所以我决定还是自己想。经过一天的思考,想出了一个算法。这个算法每读一个字符所需查找的次数可以降到很少,而所需的内存空间也只比第二种方法大一点点。
首先,定义一个结构体。
Struct Mark{              // 结构       标记{
       Word suffix;        //                           本标记的后缀;
       Word FirstSon;    //                           第一个子标记;
       Word NextBrother;            //             下一个兄弟标记;
                                              //     }
说明一下。第一个子标记就是第一个以这个标记为前缀的标记,下一个兄弟标记是指下一个与这个标记前缀相同的标记。建立一个标记结构数组,如果最大字长 12 位的话,数组长度就是 4096 个。建立好了以后就把它全部清零。
那么每一次读取一个新的字符后的操作为:
1.                  读取这个标记的 FirstSon 。如果 FirstSon 为空,则表示还没有以这个标记为前缀的标记,就可以在当前的最后一个标记后建立一个新的标记,将 FirstSon 设置为这个新标记,将新标记的 suffix 设置为这个刚读取的字符,将当前的前缀放入输出流,后缀变成前缀。读入下一个字符
2.                  如果 FirstSon 不为空,就跳到这个标记上,比对这个标记的后缀和当前的后缀,如果相同,则表明找到了,把当前字符串的前缀规约到这个标记,读下一个字符。
3.                  如果这个标记的后缀不等于当前后缀,那么就跳到他的 NextBrother 标记,比对两个后缀,直到找到或者 NextBrother 为空。
4.                  如果 NextBrother 为空了,表示还没有表示这个组合的标记,那么在当前的最后一个标记后建立一个新标记,将当前标记的 NextBrother 设置成新标记,将新标记的后缀设置成当前后缀,将当前前缀放到输出流,后缀等于前缀。读入下一个字符
按照这种算法,每次需要查找的次数大大减少,最好的可能一次就找到,最坏的可能是 255 次,但是这种可能实在太小,我想应该在 10 次以内吧。而且这个算法的内存使用很少,一个标记只占 6 个字节,比第二种算法多 2 个。实际上如果是以 8 位来处理,标记最大长度 12 位来算的话,一个标记结构所需要的位数为  8 12 12 32 位,正好 4 个字节,所以可以用一个 byte  存后缀,一个 word 的低 12 位存 NextBrother ,  高四位存  FirstSon  的高 4 位,再用一个 byte FistSon 的低字节。因为每次 FirstSon 只用一次,所以处理麻烦些无所谓。这样算来,字典所用的空间就是 4K 4 16K
        也许你觉得没必要在内存空间的使用上如此计较。当然,如果是在 PC 机上,这个几十 K 的空间,确实没必要这么计较。实际上这种单纯的 LZW 算法已经很少单独在 PC 机上使用了。不过在很多的嵌入系统中,需要存储很多的记录数据,这种记录的数据往往重复的内容很多,如果压缩后,体积会大大减小。而嵌入式系统的存储空间又是很有限的,它的内存大小和 CPU 的速度又不能支持复杂的、比较吃内存的压缩算法,这时这种简单而又消耗小的算法就有用武之地了。大家以后如果遇到这种情况,可以考虑使用这种算法。
 
再说说解压算法。
实际上解压算法要简单得多,因为他每次都是按照读入的标记来查找的,所以采用(前缀。后缀)的格式存储成一个结构数组就可以了,可以根据标记直接找到它的位置,再根据它的前缀依次往前找,每次把后缀压入一个栈,到头后弹出到输出流就可以了。具体算法就不详细说了。
 
最后再随便说说怎么样增强压缩比。在 GIF 规范中采用的是变字长的标记,所以最后对输出流好要进行一次处理。但是 GIF 对标记的字长并没有按照统计规律来进行优化,所以可以在得到标记的输出后,统计各标记的出现频率,对出现最频繁的标记采用最短的字长。具体的编码方法很多,比如  Huffman 编码,Golomb编码,等等。

你可能感兴趣的:(算法,struct,嵌入式,存储,扩展,byte)