ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

哈夫曼编码压缩实战:C语言实现、位流读写与实验报告

哈夫曼编码压缩实战:C语言实现、位流读写与实验报告

简介:这份数据结构课程设计资料围绕“用哈夫曼编码实现文件压缩”展开,面向正在完成课程设计、需要理解文件压缩原理并落地C语言实现的学生。内容从文件概念、线性链表插入删除、Huffman树构造与存储、二叉树遍历等实验目标切入,给出压缩程序的概要设计与核心数据结构定义,包括Huffman树节点、编码表及动态数组存储方式,并配有约14个功能函数和菜单函数的组织思路,可帮助读者梳理从统计ASCII字符频率、建树、生成编码到写入压缩文件的完整流程。压缩包共2个文件,含1份doc实验报告和1份cpp源代码,整体约433KB,便于对照报告阅读代码、按模块调试与复现实验。目前已有4708人学习,适合用作课程设计参考、代码拆解和答辩准备,也能为后续理解哈夫曼编码与文件压缩的一般原理提供较完整的学习样本。

1. 一份 4KB 文本压到 62%,一份 4KB 图片却涨了 300 字节

先给个反直觉的实测结论:哈夫曼编码从来不是“万能压缩”。一份 4KB 的纯英文日志,跑完压到原大小的 62%;换一张同样 4KB 的 JPEG 再跑,输出反而比原文件多出 300 多字节。程序没写崩,是哈夫曼的适用边界本来就长这样——它吃的是符号分布的不均匀度,而 JPEG 的字节分布已经接近均匀。

这套「代码 + 报告」的组合,代码部分是用 C 语言从零实现的哈夫曼压缩/解压命令行工具,报告部分是配套的实验数据、压缩率对照表和误差分析。它解决的事情很具体:不依赖任何第三方压缩库,把频率统计、哈夫曼树构建、位流读写、文件头设计这四块串成一条能跑通的数据结构实战链路,并且把每一步的收益量化出来。适合正在做数据结构课程设计、需要一个能编译能复现的参考实现的人,也适合想弄明白位运算和二进制文件读写到底怎么落地的人。

2. 原理先立住:前缀码、WPL 与一次纸面推演

动手写 C 之前,有三件事必须先在纸上想清楚,否则代码写完只是“能跑”,报告里一个数都解释不了。这一章把前缀码的约束、贪心选择的最优性、以及压缩率的上界算给你看,最后用一段 Python 把理论压缩率先量出来,避免写完 C 才发现压缩率还不如不压。

2.1 前缀码:变长编码不能随便编

定长编码给 256 个字节各分配 8 位,谁都不吃亏,谁也没占便宜。变长编码想省空间,就必须让高频字节拿短码、低频字节拿长码——但一旦允许长度不等,就冒出一个硬约束:任何一 个编码都不能是另一个编码的前缀。

举个会翻车的例子。假设给 A 编0,给 B 编01,给 C 编1。现在收到比特串01,你没法判断它是 A 后面跟了个1,还是 B 本身。解码器只能靠“猜”,而压缩算法最不能容忍的就是歧义。前缀性质的作用就是让解码器沿着树一路往下走,走到叶子就确定是一个完整符号,不需要任何回溯。

这条约束直接决定了数据结构选型:编码表必须能表达成一棵二叉树,左枝是 0、右枝是 1,所有符号都挂在叶子上,内部节点不承载任何符号。这条性质不是可选项,它是后面所有代码的地基。

提示:判断一棵编码树是否合法,只需要检查“所有符号是否都在叶子上”。有符号落在内部节点上,前缀性质当场就破了。

2.2 WPL 与贪心:哈夫曼树为什么是最优的

给每个叶子挂一个权重(该符号在文件里出现的次数),整棵树的代价定义为带权路径长度:

WPL = Σ (第 i 个叶子的权重 × 第 i 个叶子的深度)

WPL 除以文件总字节数,就是平均码长,单位是 bit/符号。压缩后的大小基本就等于总字节数 × 平均码长 / 8。所以“压缩得好不好”这个问题,等价于“WPL 能不能更小”。

哈夫曼的贪心策略是:每次从当前森林里挑两个权重最小的树合并成一棵新树,新树权重等于两者之和,重复到只剩一棵。这个策略的最优性可以用交换论证说明——权重最小的两个符号,一定存在一棵最优树让它们互为兄弟且深度最大。既然总存在这样的最优树,每次合并最小的两个就不会错过最优解。

对写代码的人来说,这段证明的实际价值在于:贪心策略的正确性依赖“每次选的是当前最小的两个”,不是“全局最小的两个”。合并出来的新节点要重新扔回候选池参与后续比较,这一点如果漏掉,压缩率会明显变差,而且不会有任何报错。

2.3 三段式流水线:统计、建树、编码

整个压缩器可以拆成三个互不耦合的阶段,每个阶段都有明确的输入输出,这也是报告里画流程图的天然分界:

阶段输入输出复杂度
频率统计原始字节流256 项频表O(n),n 为文件字节数
建树 + 生成码表频表每个符号的码字与码长O(k²),k 为不同符号数,k ≤ 256
编码输出原始字节流 + 码表文件头 + 位流O(n × 平均码长)

第二阶段的 O(k²) 看着吓人,但 k 最大只有 256,实际比较次数在 13 万量级,耗时不到 1 毫秒,完全构不成瓶颈。真正吃时间的是第一和第三阶段,它们都是对文件长度线性的,所以压缩一个 100MB 文件和压缩一个 4KB 文件,耗时差异主要来自 I/O 而不是算法。

解压是另一条流水线:读文件头 → 用频表重建同一棵树 → 按位从根往下走 → 走到叶子输出一个字节。这里有个容易忽略的点:解压端必须能完整还原出和压缩端一模一样的一棵树,树的形状只要差一个分支,后面所有比特都会解错。所以文件头里存的信息必须足够重建树,这一点在第 3 章的文件头设计里会展开。

2.4 先用 Python 把压缩率算出来再动手

写 C 之前先用 Python 跑一遍估算,能省掉大量“编译 → 跑 → 发现压缩率是 105% → 改”的循环。下面这段脚本做的事很简单:读文件、统计频率、建哈夫曼树、算理论压缩后大小,并把文件头开销也算进去。

import heapq, math, sys from collections import Counter def huffman_code_lengths(freqs): """返回 {符号: 码长},用列表模拟节点,避免引入额外依赖""" if len(freqs) == 1: return {list(freqs)[0]: 1} # 单符号退化,强制给 1 位 heap = [[w, [ch, 0]] for ch, w in freqs.items()] heapq.heapify(heap) while len(heap) > 1: lo = heapq.heappop(heap) hi = heapq.heappop(heap) for pair in lo[1:]: pair[1] += 1 # 左枝深度 +1 for pair in hi[1:]: pair[1] += 1 # 右枝深度 +1 heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:]) root = heapq.heappop(heap) return {ch: depth for ch, depth in root[1:]} def estimate(path): data = open(path, 'rb').read() if not data: return None freqs = Counter(data) lens = huffman_code_lengths(freqs) total_bits = sum(freqs[ch] * lens[ch] for ch in freqs) payload = (total_bits + 7) // 8 # 位流字节数,向上取整 header = 16 + 5 * len(freqs) # magic+orig_size+sym_count+符号表 entropy = -sum((c/len(data)) * math.log2(c/len(data)) for c in freqs.values()) return { 'orig': len(data), 'payload': payload, 'header': header, 'total': payload + header, 'entropy': entropy, 'avg_len': total_bits / len(data), 'ratio': (payload + header) / len(data), } for p in sys.argv[1:]: r = estimate(p) print(f"{p}: 原始 {r['orig']}B, 熵 {r['entropy']:.2f} bit, " f"平均码长 {r['avg_len']:.2f} bit, 输出 {r['total']}B, " f"压缩率 {r['ratio']*100:.1f}%")

这段代码有几个参数值得盯住。payload是纯位流字节数,header是固定 16 字节加每个不同符号 5 字节的符号表,两者相加才是磁盘上真实的文件大小——很多人算压缩率时只算 payload,结果实测比自己预估的大一截,翻车点就在这里。entropy是信息熵,它给出了平均码长的理论下界:哈夫曼的平均码长一定 ≥ 熵,且最多比熵大 1 位,这条不等式是报告里解释“为什么压缩率卡在这个数”的最好武器。

拿一份 4KB 英文日志跑一遍,典型结果是熵约 4.3 bit、平均码长约 4.5 bit、符号数约 70 个,于是 payload ≈ 2300 字节、header = 366 字节、总输出约 2670 字节,压缩率 65%。如果换个 4KB 但只有 20 种符号的配置文件,header 只剩 116 字节,压缩率能到 55% 左右。这组数字先记住,第 5 章的报告表格就是在这个基础上填的。

3. C 语言落地:六个函数把压缩链路串起来

这一章直接上代码。整个压缩器拆成六个部分,每个部分都单独可测:节点结构、频表统计、建树、码表生成、位流读写、文件头与主流程。写完一段就编译一段,别攒到最后一起调——二进制文件出问题时,调试器能给你的信息非常有限。

3.1 数据结构选型:静态数组比指针树更省心

树的存储有两种常见写法,一种是用malloc建节点、用指针把左右孩子串起来;另一种是开一个定长数组,用下标当“指针”。这个场景我一般会选后者,理由有三条:节点总数有明确上界(2×256−1 = 511),不需要动态扩容;数组可以整体内存清零初始化,不会漏掉parent字段;最关键的,序列化和调试时可以直接按下标打印整棵树,而指针树打出来全是地址。

#include <stdio.h> #include <stdlib.h> #include <string.h> #define NSYM 256 #define MAXNODE (2 * NSYM - 1) /* 静态数组版哈夫曼节点:-1 表示该字段不存在 */ typedef struct { unsigned long long freq; /* 用 64 位,避免大文件计数溢出 */ int parent; /* 父节点下标,根为 -1 */ int left; /* 左孩子下标,叶子为 -1 */ int right; /* 右孩子下标,叶子为 -1 */ int ch; /* 叶子存放符号值 0~255,内部节点为 -1 */ } HNode; /* 每个符号的码字:最长可能到 255 位,用 32 字节位数组存 */ typedef struct { unsigned char bits[32]; unsigned char len; } HCode;

freq用unsigned long long而不是unsigned int不是保守,是必须的:一个 5GB 的文件里某个字节出现次数可能超过 42 亿,32 位会直接回绕成一个小数,建出来的树完全是错的,而且不会报错。bits[32]是 256 位,覆盖了最坏情况——256 个符号里如果频率呈指数衰减分布(比如 1, 1, 2, 4, 8, ...),最深叶子深度能到 255,码长超过 32 位,用unsigned int存码字在这类输入上会截断。

注意:如果只是想跑通常规文本,码长几乎不会超过 20 位,用unsigned int也没事;但只要测试集里出现极端分布的文件,这个假设就会破。写报告时把这条边界写进“局限性”,答辩时是个加分项。

3.2 频率统计:一次遍历搞定 256 项频表

统计阶段没什么花活,一次fread分块读、逐字节累加即可。分块大小取 64KB 是个经验值:太小系统调用次数多,太大栈上放不下要改用malloc。这里用固定缓冲区在栈上分配,简单直接。

/* 统计文件字节频率,返回读到的总字节数,失败返回 0 */ static unsigned long long count_freq(const char *path, unsigned long long *freq) { FILE *fp = fopen(path, "rb"); /* 必须是 rb,不能是 r */ unsigned char buf[65536]; size_t n; unsigned long long total = 0; if (!fp) { perror("open"); return 0; } memset(freq, 0, sizeof(unsigned long long) * NSYM); while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { for (size_t i = 0; i < n; i++) freq[buf[i]]++; /* 直接以字节值为下标 */ total += n; } fclose(fp); return total; }

fopen的第二个参数写"rb"是硬性要求。在 Windows 上以文本模式"r"打开,运行库会把0x0D 0x0A这两个字节自动合并成一个0x0A,读到的字节数和文件实际大小对不上,频表从一开始就是错的——后面所有的压缩率、解压长度全跟着错,而且现象非常隐蔽:小文件可能刚好没碰上0x0D,跑一次不报错,换个大文件就崩。这条坑在第 4 章还会展开。

统计完成后,freq[i] == 0的符号表示文件里压根没出现,建树时要跳过它们,否则会给不存在的符号分配码字,白白拉长平均码长。

3.3 建树与码表生成:贪心合并 + 父指针回溯

建树用最朴素的线性扫描找两个最小:每轮扫一遍所有parent == -1的节点。256 个叶子最多 511 个节点,总比较次数在 13 万级别,实测耗时不到 1 毫秒。上小根堆当然更快,但代码量翻倍、调试成本上升,在这个规模下不划算。

/* 在未合并节点中找频率最小的两个,返回 0 表示不足两个 */ static int pick_two_min(const HNode *t, int n, int *a, int *b) { int m1 = -1, m2 = -1; for (int i = 0; i < n; i++) { if (t[i].parent != -1) continue; /* 已合并,跳过 */ if (m1 == -1 || t[i].freq < t[m1].freq) { m2 = m1; m1 = i; } else if (m2 == -1 || t[i].freq < t[m2].freq) { m2 = i; } } *a = m1; *b = m2; return (m1 != -1 && m2 != -1); } /* 建树,返回根节点下标;nleaf 传出叶子数 */ static int build_tree(HNode *t, const unsigned long long *freq, int *nleaf) { int n = 0; for (int i = 0; i < NSYM; i++) { /* 只给出现过的符号建叶子 */ if (freq[i] == 0) continue; t[n].freq = freq[i]; t[n].parent = -1; t[n].left = -1; t[n].right = -1; t[n].ch = i; n++; } *nleaf = n; if (n == 0) return -1; /* 空文件 */ if (n == 1) return 0; /* 单符号,特判见第 4 章 */ while (1) { int alive = 0; for (int i = 0; i < n; i++) if (t[i].parent == -1) alive++; if (alive < 2) break; int a, b; pick_two_min(t, n, &a, &b); t[n].freq = t[a].freq + t[b].freq; t[n].parent = -1; t[n].left = a; t[n].right = b; t[n].ch = -1; t[a].parent = n; t[b].parent = n; n++; } return n - 1; /* 最后创建的就是根 */ }

码表生成用父指针往上回溯,把路径上的 0/1 先倒着记下来再反转。相比递归 DFS,迭代写法不用担心中间递归深度的边界,而且每个叶子的代价就是它自己的码长,总代价等于整棵树的 WPL,正好和 2.2 节的理论对上。

/* 为每个叶子生成码字,叶子下标 i 对应符号 t[i].ch */ static void build_codes(const HNode *t, int nleaf, HCode *code) { for (int i = 0; i < nleaf; i++) { unsigned char tmp[NSYM]; int len = 0, cur = i, p; while ((p = t[cur].parent) != -1) { /* 一路爬到根 */ tmp[len++] = (t[p].left == cur) ? 0 : 1; /* 是左孩子记 0 */ cur = p; } for (int k = 0; k < len; k++) code[i].bits[k] = tmp[len - 1 - k]; /* 反转成正序 */ code[i].len = (unsigned char)len; } }

注意code数组是按叶子下标索引的,不是按符号值索引。所以主流程里还需要一张leaf_of[NSYM]反查表,把字节值映射到叶子下标,否则编码时会读到别人的码字——这种错误的表现是解压出来的内容“大部分对、个别字节错”,极难排查。

3.4 位流读写:把 0/1 拼成字节

压缩器最容易写错的地方就在这一层。码字是变长的,最短 1 位最长 255 位,而文件是按字节写的,必须有一个缓冲区把散落的位攒够 8 位再落盘。写入端维护一个 8 位缓冲和已填充位数,凑满就fputc;读取端对称,缓冲用完就fgetc再读一个。

typedef struct { FILE *fp; unsigned char buf; int nbits; /* 当前缓冲已填的位数 0~7 */ unsigned long long total_bits; } BitWriter; static void bw_init(BitWriter *bw, FILE *fp) { bw->fp = fp; bw->buf = 0; bw->nbits = 0; bw->total_bits = 0; } static void bw_putbit(BitWriter *bw, int bit) { bw->buf = (unsigned char)((bw->buf << 1) | (bit & 1)); bw->nbits++; bw->total_bits++; if (bw->nbits == 8) { /* 攒满一字节,落盘 */ fputc(bw->buf, bw->fp); bw->buf = 0; bw->nbits = 0; } } /* 收尾:不足 8 位的左移补齐,这几位是填充位,解压时必须靠 orig_size 丢弃 */ static void bw_flush(BitWriter *bw) { if (bw->nbits > 0) { bw->buf = (unsigned char)(bw->buf << (8 - bw->nbits)); fputc(bw->buf, bw->fp); bw->buf = 0; bw->nbits = 0; } } typedef struct { FILE *fp; unsigned char buf; int nbits; /* 当前缓冲剩余可读位数 0~8 */ } BitReader; static int br_getbit(BitReader *br) { if (br->nbits == 0) { /* 缓冲空,读新字节 */ int c = fgetc(br->fp); if (c == EOF) return -1; br->buf = (unsigned char)c; br->nbits = 8; } br->nbits--; return (br->buf >> br->nbits) & 1; /* 从高位往低位取 */ }

bw_flush里的左移补齐是必须的,但补齐的这几位在解压端会被当成真实数据读进来——所以文件头里必须存原始文件长度,解压时数够这么多个字节就停,多读出来的填充位直接丢掉。这一条如果漏掉,解压出来的文件末尾会多出 1~7 个字节的垃圾数据。

提示:total_bits这个字段不只是统计用。写报告时可以拿它和“原始字节数 × 8”对比,直接算出比特级压缩率和字节级压缩率的差值,解释填充浪费了多少。

3.5 文件头设计:解压端重建树的全部依据

解压端没有原始文件,唯一的信息来源就是文件头。所以文件头里必须同时包含:魔数(用来判断文件是不是本程序生成的)、原始字节数(用来截断填充位)、以及每个非零频率符号的符号值和频率(用来重建完全相同的树)。

偏移(字节)长度字段说明
04magic固定写入"HUF1",小端无关
48orig_size原始文件字节数,用unsigned long long
124sym_count频率非零的符号个数,1 ≤ sym_count ≤ 256
165 × sym_count符号表每项为 1 字节符号 + 4 字节频率

写文件头时有个小陷阱:不要直接把结构体fwrite到文件里。C 编译器会在结构体字段之间插入填充字节(unsigned long long通常要求 8 字节对齐),不同编译器、不同平台插的填充数可能不一样,读端按同样结构体读就会错位。稳妥做法是逐字段写:

static int write_header(FILE *fp, unsigned long long orig_size, const unsigned long long *freq, int sym_count) { unsigned char magic[4] = {'H', 'U', 'F', '1'}; if (fwrite(magic, 1, 4, fp) != 4) return 0; if (fwrite(&orig_size, 8, 1, fp) != 1) return 0; /* 假定同平台同字节序 */ if (fwrite(&sym_count, 4, 1, fp) != 1) return 0; for (int i = 0; i < NSYM; i++) { if (freq[i] == 0) continue; unsigned char ch = (unsigned char)i; unsigned int f = (unsigned int)freq[i]; /* 单符号频率单存 32 位足够 */ if (fwrite(&ch, 1, 1, fp) != 1) return 0; if (fwrite(&f, 4, 1, fp) != 1) return 0; } return 1; }

符号表里频率压到 32 位是有取舍的:一个文件里单个符号出现超过 42 亿次的情况几乎见不到,但如果真要支持,就得改成 8 字节,头部开销从 5 字节/符号涨到 9 字节/符号。报告里可以把这两版的头部开销做成一张对照表,很能说明“设计取舍”这件事。

3.6 主流程与命令行接口

把上面几块拼起来,压缩主流程是:统计 → 建树 → 生成码表 → 写文件头 → 逐字节编码写位流 → flush。解压主流程是:读文件头 → 用符号表重建树 → 从根开始按位下降 → 命中叶子就输出并回到根 → 数够orig_size个字节就停。

/* 解压核心循环:从根出发,按位下降,命中叶子输出并回根 */ static int decompress(FILE *in, FILE *out, const HNode *t, int root, unsigned long long orig_size) { BitReader br = { in, 0, 0 }; unsigned long long written = 0; int node = root; while (written < orig_size) { /* 用 orig_size 截断填充位 */ int bit = br_getbit(&br); if (bit < 0) return 0; /* 位流提前结束,文件损坏 */ node = bit ? t[node].right : t[node].left; if (node < 0) return 0; if (t[node].left == -1 && t[node].right == -1) { fputc(t[node].ch, out); written++; node = root; /* 回到根,准备解下一个符号 */ } } return 1; }

编译和跑通的最小验证链路:

gcc -O2 -Wall -Wextra -o huff huff.c ./huff -c input.txt output.huf # 压缩 ./huff -d output.huf restored.txt # 解压 cmp input.txt restored.txt && echo "字节级一致" ls -l input.txt output.huf # 看压缩率

cmp这一步千万别省。很多人只对比文件大小或者肉眼看内容,而哈夫曼的错误往往是“个别比特错位导致特定字符变成别的字符”,肉眼看小文件根本发现不了,只有字节级比对才能兜住。如果cmp报错但文件长度一致,基本可以锁定在位流读写或者码表索引上了。

4. 避坑与排查:五个让结果悄悄错位的细节

下面这五条都是我在真实调试里踩过的,共同特征是不崩溃、不报错,只是结果不对。二进制格式的问题最怕这个,所以每条都按“现象 → 原因 → 解决”写清楚。

4.1 位流层的两个坑

坑一:末尾填充位被当成数据解码。

现象:解压出来的文件比原文件多出 1~7 个字节,内容是一串乱码字符,但前面的内容完全正确。文件越小越容易复现,大文件反而不明显。

原因:bw_flush会把最后一个不满 8 位的缓冲左移补齐再写出去,这几位是纯粹的填充。解压端如果只判断“位流读完为止”,就会把填充位也当作真实码字继续往下走,走到某个叶子就吐出一个字符。

解决:文件头里必须写死orig_size,解压循环用written < orig_size作为终止条件,而不是fgetc返回 EOF。这条改完之后,末尾的垃圾数据立刻消失。另外orig_size建议用 8 字节存,4 字节在 4GB 以上的文件上会直接截断。

坑二:文件里只有一种字节时,树退化成单个节点。

现象:压缩一个内容全是'a'的测试文件,程序卡死或者解压出来是空的,甚至段错误。

原因:频表里只有一个非零项,build_tree里n == 1直接返回了 0,此时树只有一个节点,left和right都是 -1。解压循环中node永远是根,left == -1 && right == -1的判断让它在第一次就命中叶子,但码表那边len是 0,编码时一个比特都没写出去——位流是空的,解压端读第一个 bit 就 EOF。

解决:特判nleaf == 1,给这个符号分配固定 1 位的码字(比如0),编码时每个符号写一个0位,解压时每读一位就输出一个该符号。这个特判是压缩格式里非常典型的边界处理,报告里单独列一段讲它,比写十个常规测试用例更有说服力。

4.2 类型与内存层的两个坑

坑三:用char或unsigned int存频率,大文件计数回绕。

现象:压一个几 GB 的视频文件,压缩率异常地好(甚至低于 20%),解压出来全是错,且错的位置没有规律。

原因:char是有符号的,频率超过 127 就变成负数,参与建树比较时负数永远最小,树形完全错乱。unsigned int好些,但超过 42 亿次照样回绕。

解决:频率字段统一用unsigned long long,并且在写文件头的符号表时明确做一次窄化转换(unsigned int),转换前判断是否溢出。如果确实要支持超大文件,符号表里也存 8 字节频率,头部开销从 5 字节/符号涨到 9 字节/符号,这个取舍在报告里可以算一笔账。

坑四:直接把结构体fwrite到文件,跨编译器读不回来。

现象:同一台机器上压缩和解压都正常,把.huf文件拷到另一台机器(或者换一个编译器重新编译解压端)就解不出来,读出来的orig_size是个天文数字。

原因:结构体成员之间有编译器插入的填充字节,fwrite(&header, sizeof(header), 1, fp)会把填充一起写进去。读端如果结构体定义的对齐要求不同,字段偏移就对不上,读到的全是错位数据。

解决:文件头一律逐字段写、逐字段读,不要对整个结构体做fwrite。如果确实想省事,可以在结构体上加__attribute__((packed)),但那会引入非对齐访问,在部分架构上直接触发总线错误,得不偿失。

4.3 文件层的坑

坑五:文本模式打开二进制文件,换行被悄悄改写。

现象:在 Windows 上压缩,解压出来的文件用十六进制看,所有0x0D 0x0A都变成了0x0A,文件长度比原文件短。

原因:fopen(path, "w")是文本模式,写入时运行库会把\n自动扩展成\r\n;fopen(path, "r")读取时又会把\r\n折叠成\n。压缩后的位流里出现0x0A的概率是 1/256,一旦出现就被改写,位流整体错位。

解决:所有与二进制文件相关的fopen一律用"rb"/"wb"。这个改动只有两个字符,但它决定了程序能不能在 Windows 上跑出正确结果。Linux 上两种模式行为一致,所以很多人在 Linux 上开发时完全感知不到,一换平台就全崩。

注意:如果测试集里恰好没有触发换行的字节,这个 bug 可能潜伏很久。写完代码后拿一个二进制文件(比如压缩包本身)跑一遍cmp,能一次性把这类问题暴露出来。

5. 报告怎么写:把实验数据变成可复现的验证链路

代码能跑只是及格线,报告的价值在于把“压缩率 62%”这个孤零零的数字,变成一组别人能照着复现、能解释因果的实验。这一章讲测试集怎么挑、指标怎么定、表格怎么填,以及答辩时最容易被追问的四个点。

5.1 测试集要覆盖四类文件,缺一类结论就不成立

只拿一个 txt 文件跑出压缩率就写结论,是报告里最常见的硬伤。哈夫曼的压缩效果高度依赖符号分布,测试集必须让分布从“极度不均匀”到“接近均匀”都覆盖到,否则结论没有边界。

文件类型典型大小符号种类预期压缩率选它的理由
纯英文文本100KB 左右60~8055%~65%字母频率分布悬殊,哈夫曼的主场
源代码 / 配置文件50KB 左右40~7050%~60%空格和缩进占比高,分布更集中
位图(BMP 未压缩)500KB 左右200+80%~95%像素值分布较散,收益有限但为正
已压缩文件(jpg/zip)任意256≥ 100%字节接近均匀,压缩后反而变大

第四类是整份报告里最有价值的一组数据。它证明的不是“程序有 bug”,而是“哈夫曼对高熵数据无效”这条理论边界——2.4 节算出的熵如果接近 8 bit,平均码长就只能贴着 8 bit,加上文件头开销,压缩率必然超过 100%。把这条写进报告,比多跑十个文本文件的样例更能说明你真的理解了算法。

5.2 指标定义要写死,别留模糊空间

压缩率这个词在不同人嘴里含义不同,报告里必须给公式,最好连单位都标清楚:

  • 位流压缩率=ceil(总比特数 / 8) / 原始字节数,反映编码本身的效率
  • 整体压缩率=(位流字节数 + 头部字节数) / 原始字节数,反映真实落盘效果
  • 空间节省率=1 − 整体压缩率,报告里说“省了 38%”时用的就是它
  • 平均码长=总比特数 / 原始字节数,单位 bit/符号,用来和熵对照
  • 头部开销占比=头部字节数 / 整体输出字节数,小文件上这个数很吓人

头部开销占比这一项特别值得单列。一个 4KB 的文件,头部 366 字节,占比 13.7%;同样格式压 1MB 的文件,头部还是 366 字节,占比降到 0.03%。这解释了为什么“哈夫曼压小文件效果差”——不是编码不行,是固定成本摊不薄。

5.3 三组对照实验,把结论钉死

实验一:同类文件不同大小。取同一份日志的 4KB / 64KB / 1MB / 8MB 四个切片,记录整体压缩率和头部占比。预期是压缩率随文件增大而下降并趋于稳定,头部占比单调下降。这条曲线能直观说明固定开销的影响。

实验二:不同文件类型同大小。取四类文件各截取 100KB,记录熵、平均码长、位流压缩率、整体压缩率。预期是熵越低、平均码长越短、压缩率越低,三者严格正相关。如果出现熵低但压缩率反而高的个例,说明码长分布被极端符号拖长了,值得单独分析。

实验三:与定长编码对照。把同一批文件的压缩结果和“原始大小”放在一起,算净节省字节数。这一组是为了回答“到底值不值得压”这个朴素问题——对已压缩文件,净节省是负数,这个负号就是结论。

每组实验都要跑往返校验:压缩后解压,用cmp或直接算校验和比对。校验失败的数据一律不能进表格。报告里可以写一句“所有样本均通过字节级往返校验”,这一句的分量比十个漂亮的压缩率数字都重。

5.4 答辩最容易被追问的四个点

第一个问题通常是“为什么不用堆”。标准答法是:不同符号数 k ≤ 256,线性扫描找两个最小的总比较次数约 13 万次,实测耗时低于 1ms,占整体耗时不到 0.1%;换堆带来的代码复杂度和调试成本不划算。回答时把“k 有上界”这个前提说清楚,比空谈复杂度更有说服力。

第二个问题是“文件头为什么不存码表,存频表不是更占空间”。答案是:存码表要存每个符号的码字(最长 255 位)加码长,平均下来每个符号远不止 5 字节;存频表是 5 字节/符号,而且解压端用频率重建树的结果和压缩端严格一致,不会因为码表传输损坏而出错。这里的核心是冗余信息最小化。

第三个问题是“压缩率能不能再高点”。这条要老实回答边界:在单符号静态哈夫曼的框架内,压缩率的上限由熵决定,能优化的只有文件头开销(用规范哈夫曼把码长表压到 256 字节)和分块自适应(对不同区块用不同的树)。把这两个方向说出来,等于给报告留了个自然的延伸。

第四个问题是“大文件怎么办”。答案分两层:内存上,全程流式处理,只保留 256 项频表和 511 个节点,与文件大小无关;类型上,频率用 64 位,原始长度用 64 位,理论上限是 2⁶⁴ 字节。这里要主动说明白己测过多大的文件,别把“理论支持”说成“实测支持”。

6. 进阶:规范哈夫曼把文件头再压掉四分之三

前面那版实现的文件头是 5 字节/符号,英文文本约 70 种符号,头部 366 字节。这个开销在 4KB 文件上占了 9%,是压缩率上不去的主要原因之一。想再挤几个点,第一条路就是规范哈夫曼(Canonical Huffman)。

6.1 只传码长表,头部从 366 字节降到 256 字节

规范哈夫曼的核心观察是:只要所有符号的码长确定,码字本身可以按一套固定规则重新算出来,不需要传输。规则有三条:码字按码长升序排列;同码长的符号按符号值升序排列;第一个长度为 L 的码字,等于前一个长度的最后一个码字加一后左移一位。

/* 按规范哈夫曼规则,从码长表反推码字 */ static void canonical_codes(const unsigned char *len, int n, unsigned int *code_out) { int bl_count[NSYM] = {0}; unsigned int next_code[NSYM] = {0}; unsigned int code = 0; for (int i = 0; i < n; i++) bl_count[len[i]]++; bl_count[0] = 0; for (int bits = 1; bits < NSYM; bits++) { /* 每个长度的起始码字 */ code = (code + bl_count[bits - 1]) << 1; next_code[bits] = code; } for (int i = 0; i < n; i++) { if (len[i] == 0) continue; /* 该符号未出现 */ code_out[i] = next_code[len[i]]++; } }

头部就只需要存 256 个字节的码长(每个码长最多 255,一个字节放得下),加上 16 字节固定字段,总共 272 字节。相比原来的 366 字节省了 94 字节,在 4KB 文件上把压缩率从 65% 拉到 62.7% 左右。这个提升不算大,但它带来一个更重要的好处:码长表是定长的,解压端读取逻辑变得极其简单,不用循环解析变长的符号表。

代价也有:码长表要按符号值顺序存满 256 项,哪怕文件里只有 3 种符号也要占 272 字节。所以规范哈夫曼对“符号种类多”的文件更友好,对“符号种类极少”的小文件反而不如变长符号表。这个取舍可以直接做成一张对照表放进报告。

6.2 分块自适应:什么时候值得多存几张表

单棵树是对整个文件全局最优,但如果文件前后两半的符号分布差异很大(比如前半是英文、后半是二进制数据),全局树对两边都不是最优。分块自适应的做法是把文件切成固定大小的块(常见 64KB 或 256KB),每块独立统计、独立建树、独立存头部。

收益和成本都很明确:块越大,头部摊销得越好但适应性越差;块越小,适应性越好但头部开销暴涨。按 272 字节头部、64KB 块大小算,头部占比是 0.4%,基本可以接受;如果块缩到 4KB,头部占比直接飙到 6.6%,反而亏了。

我一般会先用一个脚本扫一遍文件,按 64KB 分块分别算熵,看块间熵的方差。方差小于 0.3 bit 就没必要分块,全局树足够;方差大于 1 bit 才值得上分块。这个判断步骤很便宜,但能避免盲目上分块导致压缩率不升反降。

6.3 收尾的一句实话

这套代码我前后改过三版,第一版跑通就交了,第二版被cmp打回来——解压出来末尾多了 5 个字节,查了两小时才发现是填充位没截断。从那以后我每次写完压缩逻辑,都强制走一遍“压缩 → 解压 →cmp→ 再看大小”的四步,一步都不跳。

这套「代码 + 报告」的组合就是照着这条链路整理出来的:代码部分是能编译、能往返校验的完整 C 实现,报告部分是上面这几组实验的数据模板和边界分析。想直接拿去对照自己的实现或者补课设报告,按第 3 章的编译命令跑一遍就知道能不能用。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表