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
,
A
),以前没见过,不认识,好,现在
用
6
代表(
A
,
B
),
下次就认识了。因为有不认识的了,所以把前缀
A
放入到输出流中,只保留后缀
B
,让它变成前缀。
第三步,取下一个字符,是
A
,现在的字符串是(
B
,
A
),还是不认得,用一个新标号来表示他,
7
=(
B
,
A
)。把前缀
B
放入到输出流,
A
变成前缀。
第四步,取下一个字符,是
B
,那么取道的是(
A
,
B
),哈,这次认得了,不就是老
6
么。好,把字符串规约到
6
,以
6
来作为前缀。
第五步,取下一个,是
A
,即为(
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
的后缀就是6
代表的字符串的第一个字符
。现在我们把
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
和第二个字符
B
。
前缀=输入流[输入序号];
输入序号++;
从这里开始,我们开始压缩过程,直到把数据处理玩:
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编码,等等。