手搓Lzw压缩算法
压缩算法 JavaScript手搓LZW压缩算法
0.前言
在写完上一篇《手搓编辑器》后,一直在想手搓系列能写啥,考虑过手搓操作系统、手搓数据库,但发现有点不自量力,根本不是一两篇文章能讲明白的,于是转去寻找有没有一些短小精悍、又带点难度的东西。
在偶然的一天,看了眼前端项目线上构建产物,忽然想到代码压缩时候用的是什么压缩算法呢?发现手搓压缩算法这个选题有点搞头。
那就从经典的LZW压缩算法开始。
1.LZW压缩算法
LZW压缩算法的基本原理:提取源文件中的不同字符,基于这些字符创建一个字典,然后用字典中的字符的索引来替代源文件中的相应字符,从而减少原始数据大小。
看到这里,第一感觉那岂不是得先处理一遍源文件以获得这个所谓的字典。但其实并不是,这也是LZW算法第一个精妙的点,算法运行的时候,会根据已读取字符来逐步构建字典,这样一来,当后面再读取到相同的字符串时,即可以用字典中的索引来代替。这也使得压缩后的结果是自解释的,即字典是不会被写进压缩文件的。
LZW压缩算法是的解压过程其实和压缩过程很像,也是读取压缩文件,构建字典,还原文件。但由于压缩文件不包含字典,所以在解压过程中会遇到一种特殊情况:如果一个索引第一次出现,该如何确定它对应的字符串? 这个问题的解法是LZW压缩算法中第二个精妙的点,解决方案很简单,但理解这个解法有点绕,需要反复揣摩。此处暂且不表,后面来攻克它。
压缩过程
不引入过多概念,给定一个待压缩字符串ababcababac
,用自然语言来描述一下LZW压缩算法的压缩过程。
先尝试理解一下字典是如何构建的。
生成一个初始态字典,为待压缩字符串中所有的单字符添加索引:
const dictionary = {
a: 0,
b: 1,
c: 2,
}
- 读取到第1个字符a
- 发现在字典中,继续;
- 读取到第2个字符b
- 和上一步的字符a构成字符串ab;
- 发现ab不在字典中,添加映射
ab: 3
;
- 读取到第3个字符a
- 和上一步的字符b构成字符串ba;
- 发现ba不在字典中,添加映射
ba: 4
;
- 读取到第4个字符b
- 和上一步的字符a构成字符串ab;
- 发现ab在字典中,继续;
- 读取到第5个字符c
- 和上一步的字符串ab构成字符串abc;
- 发现abc不在字典中,添加映射
abc: 5
;
- 读取到第6个字符a
- 和上一步的字符c构成字符串ca;
- 发现ca不在字典中,添加映射
ca: 6
;
- 读取到第7个字符b
- 和上一步的字符a构成字符串ab;
- 发现ab在字典中,继续;
- 读取到第8个字符a
- 和上一步的字符串ab构成字符串aba;
- 发现aba不在字典中,添加映射
aba: 7
;
- 读取到第9个字符b
- 和上一步的字符a构成字符串ab;
- 发现ab在字典中,继续;
- 读取到第10个字符a
- 和上一步的字符串ab构成字符串aba;
- 发现aba在字典中,继续
- 读取到第11个字符c
- 和上一步的字符串aba构成字符串abac
- 发现abac不在字典中,添加映射
abac: 8
- 字典构建结束
先来回顾后面的压缩是如何利用前面添加的映射的:
- 映射
ab: 3
是在第2步添加的,然后在第4步和第7步中就发现ab已在字典中存在,所以可以进行压缩; - 映射
aba: 7
是在第8步添加的,然后在第10步中就发现aba已在字典中存在,所以可以进行压缩。
再关注下压缩过程中提到“继续”的地方,会发现一个特别之处,那就是“继续”的时候,表明可以尝试在前一个字符串的基础上继续添加一个字符,从而组成更长的字符串。当无法构成更长已知字符串时,则从当前字符重新开始增长,像滑动窗口一样。
再来看一个值得思考的地方,注意到ababac
子串,给之间加上空格以示区分ab aba c
,并标记为第一段、第二段、第三段。对照压缩过程的第8步,添加映射aba: 7
,这里的字符串aba其实是第一段的ab和第二段的第一个字符a组成的。添加到字典后,紧跟着第9步、第10步就继续读取字符b、字符a,组成了字符串aba并发现存在于字典中。
什么样的结构能出现上面描述的场景?一定是一个类似于ab aba
的形式,因为两段一定有相同的前缀,并且后一段一定是比前一段多了一个字符,这个字符还必须是前一段的第一个字符。
随便举几个反例,如果是ab abc
,得到aba的映射后,下一步无法立即使用,用到的abc的映射是第5步得到的;如果是ab bba
,得到abb的映射后,下一步的字符串是bb,也无法立即使用。
后一段一定是比前一段多了一个字符,这个字符还必须是前一段的第一个字符这个结论牢记,这是解压里最难的部分。
压缩代码
function lzwCompress(uncompressed) {
const dictionary = new Map();
let dictSize = 256;
// 生成初始态字典
for (let i = 0; i < 256; i++) {
dictionary.set(String.fromCharCode(i), i);
}
// 待压缩字符串处理成字符流
const data = (uncompressed + "").split("");
const result = [];
// 想象一个滑动窗口
let w = "";
// 遍历字符流
for (let c of data) {
// 滑动窗口延伸,不断寻找更长的字符串
const wc = w + c;
if (dictionary.has(wc)) {
w = wc;
} else {
result.push(dictionary.get(w));
// 找不到的时候,向字典中添加新的映射
dictionary.set(wc, dictSize++);
// 滑动窗口收缩
w = String(c);
}
}
// 处理最后一个字符
if (w !== "") {
result.push(dictionary.get(w));
}
return result;
}
算法代码相比于上面的过程分析,多了个输出压缩结果的步骤。这也很好理解,还用ab aba c
来举例子,第一段的ab在字典中,但此时还不能输出ab对应的映射值3,因为不确定后面是否还能组成更长的字符串。只有当第一段的ab加上字符a组成字符串aba的时候,才可以确定目前为止字典中没有比ab更长的字符串映射存在,才可以把ab对应的映射3添加到压缩结果中。
所以正好在else分支中干了三件事,第一,添加前一段的映射值到压缩结果中;第二,添加新的映射;第三,收缩滑动窗口,重新开始增长。
解压过程
给定同样的初始态字典:
const dictionary = {
0: 'a',
1: 'b',
2: 'c',
}
由于键是连续增长的数字,所以可以用数组来代替,初始态字典表示为:
const dictionary = ['a', 'b', 'c']
给定压缩结果:0 1 3 2 3 7 2
。
读取到第1个记号0,一定在字典中,输出a;
- 读取到第2个记号1
- 发现在字典中,输出b,添加映射
3: ab
;
- 发现在字典中,输出b,添加映射
- 读取到第3个记号3
- 发现在字典中,输出ab,添加映射
4: ba
;
- 发现在字典中,输出ab,添加映射
- 读取到第4个记号2
- 发现在字典中,输出c,添加映射
5: abc
;
- 发现在字典中,输出c,添加映射
- 读取到第5个记号3
- 发现在字典中,输出ab,添加映射
6: ca
;
- 发现在字典中,输出ab,添加映射
- 读取到第6个记号7
- 发现不在字典中,输出aba,添加映射
7: aba
;
- 发现不在字典中,输出aba,添加映射
- 读取到第7个记号2
- 发现在字典中,输出c,添加映射
8: abac
- 发现在字典中,输出c,添加映射
解压过程也很好理解,对着压缩过程反过来想。比如解压的第三步,如何得到5: abc
的映射的?从压缩过程第5步可以知道,字符串abc是由上一步的字符串ab和当前的字符c组成。那解压时候也类似,上一步的结果,加上当前的结果,构成了一个新的值,并写入字典。
有一点要注意,加上当前结果的时候,加的是当前结果的第一个字符。举例来看,压缩过程第3步读取字符a并和上一步的字符b构成字符串ba,添加映射ba: 4
,同时输出压缩结果3。所以在解压过程的第2步读到记号3,对应到字符串ab,此时要取第一个字符a和上一步的字符b组成字符串ba并写入4: ba
。
到第5步之前,还都好理解,按照上面说的规则一步一步解压即可。但第5步中,既输出了aba,又添加了映射7: aba
,这和之前的步骤完全不同,为什么会出现这种情况?就是因为记号7不在字典中,所以一定是在压缩的时候,上一步刚写入aba: 7
的映射,下一步立即就用上了。回想压缩过程中是怎么处理这种情况的,那个重要的结论:后一段一定是比前一段多了一个字符,这个字符还必须是前一段的第一个字符就派上用场了,直接按照这个结论写代码就好了。
解压代码
对照着解压过程,直接翻译代码。
function lzwDecompress(compressed) {
// 使用数组来创建字典
const dictionary = [];
let dictSize = 256;
// 生成初始态字典
for (let i = 0; i < 256; i++) {
dictionary[i] = String.fromCharCode(i);
}
let w = String.fromCharCode(compressed[0]);
const result = [w];
for (let i = 1; i < compressed.length; i++) {
let k = compressed[i];
let entry;
if (dictionary[k]) {
// 常规情况,记号在字典中
entry = dictionary[k];
} else {
// 处理字典中找不到记号的情况
// 根据结论:“后一段一定是比前一段多了一个字符,这个字符还必须是前一段的第一个字符”来处理
entry = w + w.charAt(0);
}
result.push(entry);
// 添加新的映射
dictionary[dictSize++] = w + entry.charAt(0);
w = entry;
}
return result.join("");
}
2.总结
至此,LZW压缩算法的压缩和解压过程都已经分析完了,但给到的代码只能作为研究和了解算法思想来使用,距离完整应用还有很大的差距。例如,在压缩和解压初始化时,都只生成了256大小的字典,如果超出256要怎么处理,这些都是必须要考虑的。但相信理解了LZW压缩算法的核心后,剩下的都不是太大的问题。