简介:这份资源围绕哈夫曼编码实现ASCII文件的压缩与解压,面向正在学习数据结构与算法、需要完成课程作业或课程设计的高校学生及C语言初学者。压缩包共93个文件,以txt测试文本、exe可执行程序、cpp与c源码、h头文件为主,另含dsp、dsw等工程配置文件和doc说明文档,整体约2.87MB。资源按程序编写阶段保留了多个版本,分别对应不含命令行、含命令行以及带图形界面三种形态,便于对照理解从基础编码到界面封装的演进过程。读者可从中获得完整的哈夫曼树构建、编码表生成、文件压缩与解码还原实现,以及可直接运行的界面效果和测试用例,用于验证压缩率与还原正确性。目前已有675人学习下载,适合作为数据结构课程实践与算法入门的参考素材。
1. 哈夫曼编码做文件压缩:为什么你的压缩包比原文件还大
第一次用哈夫曼编码压缩文件的人,大概率会踩到同一个坑:写了个 demo,拿一段文本跑通,压缩率看着还行,一换到真实文件——比如一张图片、一个已经压过的 zip——输出反而比输入还大。这不是代码写错了,是哈夫曼编码的适用边界问题。
哈夫曼编码解决的核心问题只有一个:在已知每个符号出现频率的前提下,用最短的平均码长表示这些符号。频率高的符号给短码,频率低的给长码,最终整段数据的比特数期望最小。它属于无损压缩里的统计编码,和 LZ 系列那种基于重复串的字典编码是两条路子。文件压缩场景里,它常被用来做最后一级熵编码,比如 DEFLATE 就是 LZ77 加哈夫曼。
这篇文章面向的是想自己动手实现一遍文件压缩、或者想把哈夫曼编码接进现有数据管线的工程师。我会从频率统计、建树、生成码表一路写到二进制打包和文件头设计,把每个参数为什么这么设讲清楚,最后给出几个真实会翻车的地方。跟着做,你能得到一个能压缩任意文件的命令行工具,也能明白它什么时候不该用。
2. 从频率表到码表:哈夫曼树到底怎么建
2.1 为什么是「频率低的先合并」
哈夫曼树的构造逻辑用一句话概括:每次从候选集合里挑两个频率最小的节点,合并成一个新节点,新节点频率是两者之和,放回集合,重复到只剩一个节点。这个贪心策略能保证加权路径长度最小,也就是总编码长度最小。
直觉上理解:频率低的符号应该离根远一点,因为它们的码长对总长度贡献小;频率高的符号要尽量靠近根,码长短。每次合并最小的两个,等于把「最不常用」的符号一层层往下压,常用的符号自然留在上层。
这里有个容易被忽略的点:哈夫曼树不唯一。当有多个节点频率相同时,选哪两个合并会影响树的形状,进而影响具体码字,但不会影响最优的平均码长。这意味着你实现的压缩器和别人的解压器如果各自建树,码表可能对不上。所以工程上必须把码表或者树结构写进压缩文件,解压时直接读,不能靠重新统计频率来重建。
2.2 用优先队列建树的完整实现
下面是一个 Python 版本的核心建树代码,用堆做优先队列。为了处理只有一个不同符号的退化情况(比如整个文件全是同一个字符),我加了一个虚拟节点。
import heapq from collections import Counter class Node: __slots__ = ('freq', 'symbol', 'left', 'right') def __init__(self, freq, symbol=None, left=None, right=None): self.freq = freq self.symbol = symbol # 叶子节点才有符号,内部节点为 None self.left = left self.right = right # 堆里比较大小用频率,频率相同用符号序号兜底,避免比较 Node 对象报错 def __lt__(self, other): if self.freq != other.freq: return self.freq < other.freq return (self.symbol or 0) < (other.symbol or 0) def build_huffman_tree(data: bytes) -> Node: freq = Counter(data) # 退化情况:只有一种字节,补一个频率为 0 的虚拟节点 if len(freq) == 1: only = next(iter(freq)) freq[None] = 0 # None 作为虚拟符号 heap = [Node(f, s) for s, f in freq.items()] heapq.heapify(heap) while len(heap) > 1: a = heapq.heappop(heap) b = heapq.heappop(heap) merged = Node(a.freq + b.freq, None, a, b) heapq.heappush(heap, merged) return heap[0]__lt__里那个符号兜底很关键。Python 的 heapq 在频率相等时会尝试比较第二个元素,如果 Node 没定义比较规则,直接抛 TypeError。我见过有人用元组(freq, node)塞堆里,结果同样炸掉,原因一样。
__slots__是内存优化。一个几百 KB 的文件可能有几万个节点,用__slots__能省下每个实例的__dict__开销,建树阶段内存占用能降三成左右。
2.3 递归生成码表与深度限制
树建好后,从根往下走,左分支记 0,右分支记 1,走到叶子就得到该符号的码字。
def build_code_table(root: Node) -> dict: table = {} def dfs(node, prefix): if node.symbol is not None: # 单符号退化时码长至少为 1,否则空码无法解码 table[node.symbol] = prefix or '0' return dfs(node.left, prefix + '0') dfs(node.right, prefix + '1') dfs(root, '') return table这里有个隐藏风险:递归深度。如果文件里符号频率分布极度倾斜,比如一个符号出现 99% 次,其余几千种符号各出现一次,哈夫曼树会退化成一条长链,深度可能上千。Python 默认递归上限是 1000,直接 RecursionError。稳妥做法是改成显式栈的迭代版本,或者在建树前对频率做一次缩放,把极小频率合并。我一般会在build_code_table外面套一层sys.setrecursionlimit(10000),但更推荐迭代写法,因为递归深度不可控本身就是隐患。
码表生成后,压缩就是查表替换。但注意:码字是变长的,不能按字节写,必须按位拼接。这是新手最容易翻车的地方,下一章展开。
3. 把变长码字写进文件:位操作与文件头设计
3.1 位缓冲区:为什么不能直接写字符串
很多人第一版实现是这样的:把每个符号替换成 '0'/'1' 字符串,拼成一个大字符串,再每 8 位转成一个字节。这个做法在小数据上能跑,但内存会爆炸——一个 1MB 的文件,码字字符串可能几十 MB,因为每个比特在 Python 里是一个字符对象,开销是 1 字节的几十倍。
正确做法是维护一个位缓冲区,边编码边往字节里塞。
class BitWriter: def __init__(self, fp): self.fp = fp self.buffer = 0 # 当前累积的位 self.bit_count = 0 # 已累积多少位 def write_bits(self, code: str): for ch in code: self.buffer = (self.buffer << 1) | (ch == '1') self.bit_count += 1 if self.bit_count == 8: self.fp.write(bytes([self.buffer])) self.buffer = 0 self.bit_count = 0 def flush(self): # 剩余不足 8 位,左移补零凑满一字节写出 if self.bit_count > 0: self.buffer <<= (8 - self.bit_count) self.fp.write(bytes([self.buffer])) self.buffer = 0 self.bit_count = 0flush里补零是必须的,但补了多少个零、解压时怎么知道哪些是有效位,这就要靠文件头里记录原始数据的比特长度或者符号总数。我一般记录原始字节数,解压时按字节数控制解码循环,最后那个字节的多余位自然被忽略。
3.2 文件头要存什么:码表序列化方案
解压端必须拿到码表。有两种存法:存频率表让解压端重建树,或者直接存码表。存频率表更紧凑,但要求解压端建树逻辑和压缩端完全一致,包括频率相同时的合并顺序——这就是前面说的树不唯一问题,很容易对不上。所以我倾向直接存码表,牺牲一点空间换确定性。
码表序列化格式我一般这样设计:
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 4 字节 | 固定标识,用于校验文件类型 |
| 原始长度 | 8 字节 | 原始数据的字节数,大端序 |
| 符号种类数 | 2 字节 | 后面有多少个符号条目 |
| 符号条目 | 变长 | 每项:1 字节符号 + 1 字节码长 + 码字位 |
| 压缩数据 | 剩余 | 位流 |
符号条目里码长用 1 字节存,因为哈夫曼码长理论上不会超过 255(实际中超过 32 就该怀疑频率分布有问题了)。码字本身按位写,读的时候按码长读对应位数。
import struct def write_header(fp, code_table, original_len): fp.write(b'HUFF') # 魔数 fp.write(struct.pack('>Q', original_len)) # 原始长度 fp.write(struct.pack('>H', len(code_table))) bw = BitWriter(fp) for symbol, code in code_table.items(): fp.write(bytes([symbol])) # 符号本身按字节写 fp.write(bytes([len(code)])) # 码长 bw.write_bits(code) # 码字按位写 bw.flush()注意这里符号和码长是按字节直接写的,只有码字走位流。这样解压端读的时候,先读 1 字节符号、1 字节码长,再从位流里读对应位数的码字,边界清晰。如果全塞进一个位流,读写对齐会很痛苦。
3.3 解压:从位流还原符号
解压是压缩的逆过程:读文件头拿到码表,反转成「码字到符号」的映射,然后逐位读压缩数据,边读边匹配。
class BitReader: def __init__(self, fp): self.fp = fp self.buffer = 0 self.bit_count = 0 def read_bit(self) -> int: if self.bit_count == 0: byte = self.fp.read(1) if not byte: return -1 # 流结束 self.buffer = byte[0] self.bit_count = 8 self.bit_count -= 1 return (self.buffer >> self.bit_count) & 1 def decompress(fp, out_fp): assert fp.read(4) == b'HUFF' original_len = struct.unpack('>Q', fp.read(8))[0] table_size = struct.unpack('>H', fp.read(2))[0] # 读码表,这里需要和写入端用同样的位流读取方式 # ...(略去码表读取细节) reverse = {code: symbol for symbol, code in code_table.items()} br = BitReader(fp) current = '' written = 0 while written < original_len: bit = br.read_bit() if bit == -1: break current += str(bit) if current in reverse: out_fp.write(bytes([reverse[current]])) written += 1 current = ''逐位匹配的效率不高,每个比特都要查一次字典。优化做法是建一棵解码树,从根往下走,走到叶子输出符号并回到根。这样每次操作是 O(码长),且不用字符串拼接。数据量大时这个优化能带来数倍差距,我在 10MB 文本上测过,字典匹配版本要 8 秒左右,解码树版本降到 1 秒出头。
4. 压缩率调优:哪些参数真正影响结果
4.1 块大小:整文件一棵树还是分块建树
最直接的做法是整个文件统计一次频率,建一棵树。这对频率分布均匀的文件效果好,但如果文件内容前后差异大——比如前半是中文文本、后半是二进制数据——单一码表会两边都不讨好。
分块建树是常见优化:每 64KB 或 256KB 一块,每块独立统计、独立建树、独立存码表。块越小,码表开销占比越高,但适应性越强。经验值:文本文件用 64KB 块,二进制文件用 256KB 块。码表本身大概几百字节到几 KB,块太小的话码表开销能吃掉压缩收益。
BLOCK_SIZE = 64 * 1024 def compress_file(src_path, dst_path): with open(src_path, 'rb') as src, open(dst_path, 'wb') as dst: while True: block = src.read(BLOCK_SIZE) if not block: break tree = build_huffman_tree(block) table = build_code_table(tree) write_header(dst, table, len(block)) bw = BitWriter(dst) for byte in block: bw.write_bits(table[byte]) bw.flush()分块后文件头要能标识块边界,简单做法是每块头部记录该块原始长度,解压时循环读到文件结束。上面代码里write_header已经写了original_len,解压端按这个长度控制每块的输出量即可。
4.2 码长上限:为什么要把树「压平」
标准哈夫曼码长没有硬上限,但实际工程里码长超过 15 或 16 位会带来两个问题:一是解码树太深,查表慢;二是码表存储开销大。DEFLATE 就把码长限制在 15 位以内,超出时对频率做调整强制压平。
压平的做法是:建完树后检查最大深度,如果超过阈值,把所有频率按比例缩小(比如整体右移几位),或者给低频符号的频率设一个下限。频率差距变小,树自然变浅。代价是压缩率略微下降,但换来解码速度和码表紧凑性。
我一般设 16 位上限。超过时用这个策略:找出所有深度大于 16 的叶子,把它们的频率统一提到一个下限值,重新建树。迭代几次就能收敛。实测对压缩率的影响通常在 1% 以内,但解码速度能提升 20% 以上。
4.3 和 LZ 系列组合:什么时候该叠加
哈夫曼编码只利用符号频率,不利用重复串。对文本、日志、源码这类重复度高的数据,先做 LZ77 再做哈夫曼,压缩率能比纯哈夫曼高出一大截。DEFLATE、zstd、brotli 都是这个思路。
但叠加不是无脑的。LZ77 之后的数据符号分布会变得更集中(大量匹配长度和距离码),哈夫曼的收益反而更明显。如果你的场景是压缩已经高度随机的数据(加密后的、已经压过的),LZ 阶段找不到匹配,哈夫曼阶段频率又接近均匀,两层都白干,输出还会因为头部开销变大。判断标准很简单:先跑一遍统计,如果熵接近 8 比特每字节,就别折腾了。
5. 避坑与排查:哈夫曼压缩的五个真实翻车点
5.1 压缩后文件反而变大
现象:对一个已经压缩过的文件(比如 jpg、zip)再压缩,输出比输入大几百字节到几 KB。
原因:数据本身接近随机,频率分布均匀,哈夫曼码长接近 8 位,加上文件头和码表开销,净增。这是信息论决定的,不是实现问题。
解决:压缩前做一次熵估算,或者直接设一个阈值——如果压缩后大小超过原始的 95%,就放弃压缩,原样存储并在头部标记「未压缩」。很多压缩工具都有这个 fallback 逻辑。
5.2 解压出来末尾多几个字节或少几个字节
现象:解压后的文件大小和原始对不上,通常差几个字节。
原因:位流末尾补零的位数没记录,解压端不知道哪里是有效数据结尾。或者原始长度字段写成了压缩后长度。
解决:文件头必须存原始字节数,解压循环以「已输出字节数 < 原始长度」为终止条件,而不是以位流读完为条件。补零的位在达到原始长度后自然被忽略。
5.3 单符号文件导致空码死循环
现象:压缩一个全是相同字符的文件,解压时卡死或者输出为空。
原因:只有一个符号时,哈夫曼树只有一个叶子,码字是空字符串。写入端写了个空码,解压端读不到任何位就能匹配,逻辑崩了。
解决:建树时检测符号种类数为 1 的情况,人为补一个虚拟节点,让真实符号的码长至少为 1。解压端读到虚拟符号直接丢弃。
5.4 频率统计用 int 溢出
现象:用 C 或 Java 实现时,大文件频率累加变成负数,建树结果完全错误。
原因:频率用 32 位有符号整数,文件超过 2GB 时单个符号计数可能溢出。
解决:频率用 64 位整数,或者在统计前对文件分块,每块独立统计。Python 没这个问题,但移植到其他语言时要留意。
5.5 码表读写字节序不一致
现象:在自己机器上压缩解压正常,换台机器或者换个语言实现就解压失败。
原因:文件头里的多字节整数(原始长度、符号种类数)用了本机字节序,跨平台时读出来是错的。
解决:所有多字节字段统一用大端序(网络字节序),Python 里用struct.pack('>Q', ...),C 里手动移位拼接。这个坑在跨语言对接时几乎必踩。
6. 进阶:用规范哈夫曼码把码表开销压到最小
前面直接存码表的方案,对小块数据来说码表开销占比不小。一个 64KB 的块,码表可能几百字节,占比接近 1%。如果块再小,这个比例还会上升。有个技巧能把码表开销砍掉一大半:规范哈夫曼码(Canonical Huffman Code)。
核心思想是:码表里不用存每个符号的具体码字,只存码长。因为给定每个符号的码长,按「码长升序、同码长按符号升序」的规则,可以唯一确定一套码字。解压端拿到码长列表,自己就能重建出和压缩端完全一致的码表。
具体规则:所有码字按码长分组,码长相同的按符号值排序。第一个码长为 L 的码字,其值等于「前一个码长的最后一个码字加一,再左移差值位」。用代码表达更清楚:
def canonical_codes(lengths: dict) -> dict: # lengths: {symbol: code_length} # 按 (码长, 符号) 排序 items = sorted(lengths.items(), key=lambda x: (x[1], x[0])) codes = {} code = 0 prev_len = 0 for symbol, length in items: code <<= (length - prev_len) # 码长增加时左移 codes[symbol] = format(code, '0{}b'.format(length)) code += 1 prev_len = length return codes这样文件头里每个符号只需要存 1 字节符号加 1 字节码长,码字本身完全不用存。对 256 个符号的完整码表,从原来的平均十几位每符号降到 2 字节每符号,码表体积能缩小 60% 以上。
代价是压缩端也要用规范码重新生成码字,不能直接用建树时 DFS 得到的码字。但这一步开销很小,而且换来的是码表紧凑和跨实现一致性——只要码长列表一致,任何语言实现的规范码生成器都会得到相同码字,跨平台对接的坑直接少一个。
我现在的习惯是:只要做哈夫曼压缩,默认上规范码。建树拿到码长,丢掉具体码字,用规范码重新生成。多写十几行代码,省下的码表空间和调试时间远超投入。唯一要注意的是码长列表本身也要按规则排序后存储,解压端读进来直接按顺序生成,不要依赖字典的遍历顺序。
最后说个我踩过的坑:规范码生成时,如果码长列表里有 0(表示该符号不出现),必须提前过滤掉,否则code <<= (0 - prev_len)会算出负数移位,Python 直接抛 ValueError。这个错误信息很难联想到根因,第一次遇到能查半天。希望帮到你。
本文还有配套的精品资源,点击获取