前几年我帮朋友做一批历史对话日志的压缩归档,日志是纯文本,去掉空白字符之后单文件接近700MB。一开始我想的都是统计编码——字符频率这么集中,霍夫曼压一遍应该差不多了吧。试下来确实能到55%左右,但总觉得哪里不对劲,因为真正占空间的其实是那些一遍又一遍重复出现的错误堆栈、日志模板、时间戳前缀。霍夫曼只看单个符号出现的概率,完全看不到“序列”层面的重复。后来换用LZ系列编码的经典实现,同样的数据一口气压到了约20%。从那次之后我养成了一个习惯:评估任何压缩方案之前,先问一句数据里到底有多少“重复结构”。LZ系列编码,正是信息论通向工程压缩时最关键的桥梁之一。
1. 从统计编码到字典压缩:LZ系列在信息论坐标里的位置
香农的信息论给了压缩一个理论下限:一个信源的熵决定了无损压缩平均每符号至少需要多少比特。霍夫曼编码、算术编码这类统计编码,都是在“已知或估计符号概率分布”的前提下向这个下限逼近。问题是,现实中的数据往往不是按单符号独立出现的,大量信息隐藏在连续片段的结构里。典型如一段C语言代码,if (x > 0)可能出现了几百次,统计编码会把i、f、空格等符号的频率算得很好,但它不知道“if (x > 0)这个整体”才是真正值得压缩的单元。
上个世纪70年代末,Jacob Ziv和Abraham Lempel提出了完全不同的思路:与其去猜符号概率,不如直接在原始数据里找已经出现过的重复片段,然后把“重复的位置和长度”记录下来。这就是LZ系列编码的起点。它最大的特点是通用性,压缩器不需要事先知道任何概率模型,纯粹靠数据自身的自相似性就能完成压缩。理论上甚至可以证明,对于平稳遍历信源,LZ算法的渐近压缩率会逼近熵率,这让它和统计编码一样有着扎实的理论背书。
LZ系列主要分两支:LZ77和LZ78。LZ77后来衍生出DEFLATE、LZMA、LZ4、Zstandard这些随处可见的算法;LZ78衍生出LZW,曾经是GIF和TIFF的标配。不管是哪一支,核心思想都可以浓缩成一句话:用“历史引用”代替“重新描述”。就像写文章时不把同一段话抄两遍,而是写“见上文第几段”,解码端顺着引用把它恢复出来。
| 维度 | 统计编码(霍夫曼/算术编码) | LZ77 | LZ78/LZW |
|---|---|---|---|
| 依赖模型 | 符号概率分布 | 无,自适应 | 无,自适应 |
| 找冗余的基本单位 | 单符号 | 任意长度的重复片段 | 任意长度的重复短语 |
| 一次扫描 | 需要两遍或概率估计 | 一遍 | 一遍 |
| 适合场景 | 符号分布集中的数据 | 文本、日志、结构化数据 | 文本、图像索引流 |
| 代表算法 | Huffman、Range Coder | DEFLATE、LZMA、Zstd | LZW、GIF |
信息论里经常讲“熵编码”和“字典编码”是两条路线。我在实际工程里明显感觉到,统计编码解决的是“符号分布不平均”的问题,而LZ系列解决的是“符号序列重复出现”的问题。大多数真实文件两种冗余都有,所以才会有DEFLATE这种“先用LZ77扫掉序列重复,再用霍夫曼处理符号频率”的组合拳。理解了这个坐标位置,后面看LZ77、LZ78的细节就不容易迷路。
2. LZ77核心机制拆解:滑动窗口与三元组输出
LZ77的思想非常直观:当前要编码的位置之前,已经编码过的历史数据就是一本“不断滚动的字典”。把最近一段历史放进一个滑动窗口,然后在窗口里寻找与待编码区开头最长的匹配子串。一旦找到,就输出一个三元组——(匹配起点相对当前位置的距离distance,匹配长度length,匹配后的下一个字符next)。
手动走一遍例子最清楚。假设要压缩字符串abacababac,压缩器的窗口大小设为8,当前已经处理了前4个字符,情况是这样:
| 步骤 | 窗口(已编码历史) | 待编码区 | 最长匹配 | 输出 |
|---|---|---|---|---|
| 1 | abac | ababac | 窗口开头的aba匹配,长度3,距离4 | (4, 3, 'b') |
| 2 | acabab | ac | 窗口开头的ac匹配,长度2,距离6 | (6, 2, EOF) |
第一步里,待编码区的a b a b a c和窗口里的a b a c对齐,逐位比较能找到最长匹配aba,长度3。注意窗口里第4个字符是c,而待编码区第4个字符是b,所以匹配在3处截止。这个匹配起点离当前待编码位置往回数4个字符,所以distance就是4。输出三元组后,再读入下一个字面量b,解码端流程也很简单:看到(4, 3, 'b')就从当前位置往前4个字符开始复制3个字符,得到aba,再补上b,当前输出变成abacab,正好和原始串对齐。
这里有个很多初学者会忽略的细节:LZ77允许匹配的length大于distance。什么意思呢?比如字符串是abcabcabc,窗口里刚编码完abcabc,待编码区是abc,看起来匹配长度是3。但如果继续处理更长的重复,比如abababab,一旦匹配串跨过了自身复制的起点,解码端就必须逐字节复制,而不是一次性memcpy一整块源数据。因为源块和目标块可能在内存里重叠。写解码器的时候,我见过有人直接把源内存块整段拷贝,结果输出变成一堆乱码,就是因为没处理这种“自重叠复制”的情况。很多文档不会提这个,但实际实现里很容易翻车。
LZ77真正适合什么数据?回想一下我压那份700MB日志的场景,错误堆栈通常几百字节完全一致地重复出现,距离可能隔着几十KB甚至几MB,只要窗口够大,都能找到匹配。它不太适合的是短字符串、随机性强的数据。随机数据里很难出现跨长度的重复片段,LZ77不仅压不动,还会因为输出三元组的开销反而把数据变大。所以做压缩选型时,第一个问题永远是:这份数据里有没有足够多、足够长的重复结构?没有的话,LZ77再经典也救不了你。
3. LZ78到LZW:动态字典的演进与解码器的隐蔽分支
LZ77的滑动窗口有一个天然短板:匹配只能发生在窗口范围内,窗口一旦滑过去,之前的短语就彻底忘了。LZ78换了个思路——不搞滑动窗口,而是维护一个全局的、持续增长的短语字典。每遇到一个新的“前缀+字符”组合,就给这个组合分配一个新的索引号。编码器输出的是字典索引和下一个字符的组合,比如(index, next_char),之后索引就能代表整段短语。
1984年,Terry Welch在LZ78基础上改出了LZW算法。LZW做了一件看起来很“激进”的事:输出里不再带next_char,而是只输出纯索引。字典初始化时已经包含了全部单字符,所以编码器从头开始累积前缀,每发现prefix + current不在字典里,就输出prefix的索引,然后把prefix + current登记为新索引,接着把current当作新前缀继续。这样做的好处是输出流非常紧凑,每个符号都对应一个定长码字。
用一个具体例子说明。要压缩ABABABA,LZW编码过程如下:
| 步骤 | 读入字符 | 当前前缀 | 前缀+字符在字典中? | 输出索引 | 新增字典项 |
|---|---|---|---|---|---|
| 1 | A | A | 是(初始化字典含A、B) | - | - |
| 2 | B | AB | 否,输出A的索引1 | 1 | 3=AB |
| 3 | A | BA | 否,输出B的索引2 | 2 | 4=BA |
| 4 | B | AB | 是 | - | - |
| 5 | A | ABA | 否,输出AB的索引3 | 3 | 5=ABA |
| 6 | B | AB | 是 | - | - |
| 7 | A | ABA | 是 | - | - |
| 8 | 结束 | - - | - | 输出ABA的索引5 | - |
最终输出序列:1 2 3 5。解码端拿到1 2 3 5,正常情况下逐个查字典、边还原边建新索引,过程是顺畅的。但有一个非常隐蔽的情况——当读到某个码字时,它还没有出现在字典里。这就是编码过程中“字典刚登记完索引,下一个码字紧接着就引用它”造成的。解码器怎么应对?看上面这个例子的第6步到第8步:AB之后读入A生成了新索引5=ABA,紧接着编码器就输出了5。这时候解码端读到码字5,发现字典里还没有5,但它知道上一个输出是AB,于是特殊构造:上一个输出 + 上一个输出的首字符,也就是AB + A = ABA,输出ABA之后再把5登记进字典。这个特殊分支在某些老教材里叫超前一码引用,我第一次实现LZW的时候在这上面空转了很久,还以为是字典初始化顺序写错了。
这里放一段简化的LZW解码核心逻辑,能很直观看清这个分支:
dictionary = {i: chr(i) for i in range(256)} prev_code = None for code in code_stream: if code in dictionary: entry = dictionary[code] else: # 关键分支:码字指向尚未定义的字典项 entry = dictionary[prev_code] + dictionary[prev_code][0] output(entry) if prev_code is not None: dictionary[len(dictionary)] = dictionary[prev_code] + entry[0] prev_code = code工程上,LZW还要面对两个很现实的问题。一是码字宽度。字典索引越来越多,定长码位从9位涨到10位、11位、12位,压缩器要约定什么时候扩位,否则解不出来。二是字典写满之后怎么办。常见策略是“冻结”——不再新增索引,只输出已有索引;或者“清空”——重置字典重新积累。GIF图像里就专门设计了一个Clear Code标记,配合处理动态变化的图像内容。我处理过一批老GIF文件,它们压缩率一直上不去就是因为字典频繁清空,这是动态字典模型在短周期变化数据上的本能缺陷,不是算法写错了。
4. DEFLATE:教材LZ77最快落地的工程形态
如果你打开一个.gz文件或一张.png图片,看到的压缩数据绝大多数是DEFLATE格式。DEFLATE本质上是LZ77的工程化变体,它的整体流程分成两层:第一层用LZ77把重复片段转成长度-距离对,第二层再把这一串符号做霍夫曼编码。这种“先字典编码,再熵编码”的组合,是工业界对LZ77最成功的改造之一。
为什么实际实现没有直接套用教科书上的(distance, length, next_char)三元组?因为在DEFLATE的框架里,如果每次匹配都强制带上一个next_char,符号流的种类会变得很杂,不利于后续霍夫曼编码。DEFLATE的做法是把字面量(literal)、匹配长度(length)、匹配距离(distance)统一成一张大的符号表来处理:字面量和长度共用同一个符号空间,用256作为块结束标记,距离单独走一张距离码表。这样输出流就是一个干净的自描述符号序列,再交给霍夫曼编码时结构非常规整。
DEFLATE的LZ77有几个关键参数,都是教材不会细讲但工程实现绕不开的:
- 最小匹配长度是3字节。为什么不是2?因为输出一个匹配对本身有成本,需要占用长度码、距离码,还可能在霍夫曼树上占位置。如果匹配太短,省下的字节数不足以抵消编码开销,反而越压越大。3是长期实践压出来的经验值。
- 最大匹配长度是258字节。这个数看着奇怪,其实和DEFLATE的长度符号设计有关。长度符号3-258被映射到257-285这29个符号上,配合若干额外的扩展比特位,最长正好258。超出这个长度的连续重复会被拆成多个匹配对输出。
- 滑动窗口最大32768字节。这就是为什么PNG在压缩大图时,远处重复的颜色块对压缩率帮助不大——它们已经滑出窗口了。
DEFLATE还会对每个block选择不同的霍夫曼策略:不压缩、固定霍夫曼树、动态霍夫曼树。动态树需要先把树的描述存到块头,对于小数据块反而不划算。这也是为什么一个几字节的文件用gzip压完,体积反而比原文大——它连动态树都要花空间存。很多人看到这个现象会怀疑压缩实现有问题,其实不是,这是格式设计的正常表现。
实际使用DEFLATE最方便的方式还是走zlib。下面是两个最常见的用法,一个命令行一个Python,都能直接看到压缩效果:
gzip -k file.txtimport zlib data = open("file.txt", "rb").read() compressed = zlib.compress(data, 6) # level=6 是默认档位 print(len(data), len(compressed))我经常用zlib的level参数去试不同数据,得到的结论是:对于日志、代码这类重复度高的文本,level从1调到9,压缩率通常能提升几个百分点,但耗时可能差出5到10倍。如果你的场景是海量日志实时落盘,真没必要上最高档,默认6在很多情况下已经是性价比最好的位置。
5. 参数、内存与速度:LZ工程优化必须想清楚的三笔账
很多年前我做一个嵌入式设备的数据同步模块,设备内存只有几十MB,每次同步要压缩几百MB的文本数据。当时我天真地以为LZ77就是背一个窗口在那找匹配,参数随便调调就行。真做起来才发现,工程优化根本不是“压缩率越高越好”,而是压缩率、速度、内存三笔账一起算。这一节的参数取舍,全部来自我和团队在这些账目上的真实记录。
窗口大小是第一笔账。LZ77的核心搜索空间就是窗口。窗口开得越大,能发现的重复距离就越远,压缩率自然上限更高。但代价有两块:内存占用线性增长,哈希链搜索也变慢。DEFLATE把窗口定死在32KB,就是考虑过当时内存和CPU的平衡点。后来的LZMA允许把窗口开到几MB甚至几十MB,压缩率大幅提升,代价是压缩端内存和耗时都上去了。我在日志归档场景里测过,当重复片段距离分布在几百KB时,窗口32KB完全不够看,换LZMA的4MB窗口能多压20%左右;但如果是几KB的小文件,大窗口只是白占内存。选窗口大小的判断标准,应该是先统计一下数据里重复片段平均距离大概在什么量级。
哈希链是第二笔账。纯粹的LZ77字符串匹配,要到窗口里做最长前缀搜索,靠朴素比较根本扛不住。工程实现几乎都用哈希表加速:把每3字节算出一个哈希值,相同哈希的位置串成一条链,搜索时只需要在这条链上比较候选位置。链越长,找到更远更好匹配的机会越大,但比较次数也跟着涨。zlib的level参数本质上就是在控制这个hash chain的长度上限。level 1时链很短,快速扫过;level 9时链很长,压缩率拉满,速度掉得厉害。压缩等级并不是线性地“越高越慢”,它是压缩率曲线的渐近区,堆再多的链收益也非常有限。
还有一个工程细节叫lazy matching,很多新手根本没听过,但它在DEFLATE里影响很大。普通贪婪匹配是当前位置找到最长匹配就直接输出;lazy matching会多看一个位置,比较一下“当前匹配”和“下一个位置能找到的匹配”哪个更长,如果下一个更长,就先输出当前字符,把置位让给下一轮。代价是额外多做一次匹配搜索,换来的是更接近全局最优的分割。zlib在较高level下默认开启这个机制,这也是为什么同样数据在不同level下输出结果差异明显。
内存占用是第三笔账。LZ77压缩器除了窗口,还要保存哈希表、匹配链、待处理缓冲区。zlib里有个memLevel参数,控制哈希表的大小。哈希表小了,哈希碰撞增多,搜索质量下降;哈希表大了,内存占用上升。嵌入式环境下内存吃紧,我一般会把memLevel调到8以下,配合小窗口,牺牲一点压缩率换稳定运行。经验值是这样的:文本日志、JSON这类高度结构化的数据,中等参数就有很好效果;二进制固件、代码段,重复距离往往很近,窗口不用太大;已经压缩过的数据(比如JPEG里再套一层压缩)就完全没必要上LZ,只会白白增加延迟。
| 场景 | 推荐窗口 | 推荐level | 备注 |
|---|---|---|---|
| 实时网络日志压缩 | 32KB-64KB | 1-3 | 速度优先,低延迟 |
| 离线归档 | 4MB以上 | 9 | 压缩率优先,接受慢 |
| 嵌入式设备 | 16KB-32KB | 6以下 | 内存受限,平衡为主 |
| PNG图片内部 | 32KB固定 | 6左右 | 格式固定,无法改窗口 |
这套参数组合我一直在用,效果比较稳定。真正的优化顺序应该是:先确定数据特征,再确定环境限制,最后才谈算法级别。参数只是实现这些策略的工具,不是数字越大越好。
6. LZ的测不准地带:局限、改良与它留给后人的问题
LZ系列再能打,也不是万能的。最典型的一类失败案例:对随机数、加密数据、已经压缩过的数据做LZ压缩。随机数据没有重复结构,加密数据把重复模式彻底打散,已经压过的数据几乎接近熵极限,LZ在这些数据上要么压缩率趋近于1,要么因为输出索引开销反而变大。我见过有系统对每个HTTP响应不分青红皂白都做一层gzip,结果响应体本身已经是Brotli压过的,再压一遍体积基本不变,CPU白烧。合理的做法是在压缩链路上加一步快速判断——采样前几KB算一个经验熵,如果看起来太“高熵”,直接跳过压缩或改用纯存储模式。
理论家们关心的是LZ的渐近最优性:Ziv和Lempel在1978年的论文里证明了,在一定条件下LZ78对平稳遍历信源可以达到熵率。这个结论给了无数工程实现底气:我们不是在用一个没有理论支撑的野路子,而是在用数学上渐进最优的通用方案。不过“渐近”两个字意味着要处理非常长的数据流才能逼近理论值,这也解释了为什么小文件用LZ经常占不到便宜。
后来者站在LZ的肩膀上做了大量改良。LZMA延续了LZ77的匹配思想,但匹配之后不再用定长表示距离和长度,而是用区间编码加上位字段压缩,复杂度和压缩率双双提升。Brotli在LZ77外面套了上下文建模,对网页文本尤其有效。Zstandard则专门优化了解码速度和参数灵活性,成为新一代日志压缩和网络传输的热门选择。甚至LZ4这类只追求极致速度的算法,也还是LZ77的变体——它宁愿牺牲压缩率,也要让解压跑到几GB每秒。
信息论这门学科给人的第一印象通常是数学推导和概率公式,但我做了多年工程之后回头看,LZ系列才是那个把理论真正“焊”进现实的关键一环。它没有要求你知道信源的熵是多少,也不要求你预先建立一个精确的模型,它只是相信一点:真实世界产生的数据一定带着大量重复和规律,只要把这些规律找出来,压缩就自然发生了。而工程上的乐趣,恰好在于弄明白什么样的数据在什么样的参数下重复得最明显。我自己现在判断一个压缩需求,永远先问三件事:数据里重复结构多不多、压缩耗时预算有多少、解码端内存压力大不大。LZ系列在这三件事之间留出的调优空间,才是它真正让人着迷的地方。