简介:这份面向数据结构课程设计与C语言学习者的哈夫曼编码文件压缩资料,围绕ASCII字符频率统计、哈夫曼树构造、编码表生成与压缩文件写入展开,帮助读者理解文件压缩的一般原理并完成课程设计。压缩包仅2个文件,包含1份cpp源码与1份doc报告,整体约433KB:源码给出哈夫曼树、编码表等结构定义及压缩流程和菜单函数,报告可对照实验目的、概要设计与实现思路。已有4708人学习,适合需要参考完整代码框架、梳理链表与二叉树遍历算法、撰写实验报告或排查编码写入问题的学生。读者可从中获得可运行的C语言实现、数据结构设计样例和课程设计报告模板,便于按步骤复现并理解字符频率到压缩输出的完整链路。
1. 从一份课设压缩器说起:哈夫曼编码为什么值得亲手写一遍
把一份 300MB 的日志文件压到 180MB,不引第三方库,纯 C 标准库两百多行就能做到——这就是用哈夫曼编码实现文件压缩最直接的吸引力。它属于数据结构课里少见的「能跑出真实产物」的题目:一边是堆、二叉树、贪心这些课本概念,一边是 fopen/fread、位运算、文件头设计这些工程细节,两头必须同时立住,程序才真的能还原出一个字节不差的文件。一个字节都不许错,这就是它比链表、排序那些题目更值钱的地方。这套「代码 + 报告」的资源,适合三类人:数据结构课设周要交作业的学生、想补一遍 C 语言位操作和最小堆实现的自学者、以及想找个最小可运行样例去改造成压缩工具雏形的开发者。下面按「这东西是什么 → 怎么跑起来 → 核心代码怎么写的 → 会踩哪些坑 → 怎么改」的顺序拆。
2. 项目全景与工程结构:频次表、最小堆、码表三件套怎么拼起来
哈夫曼压缩的原理一句话能说完:统计每个字节出现的次数,用次数当权重建一棵二叉树,出现多的字节走短码、出现少的走长码,再把原来每个 8 位的字节换成不等长的比特串。难的是后半句——不等长比特串怎么落进「按字节寻址」的文件里。整个工程结构其实就是围绕这个问题组织起来的:统计模块产出一张 256 项的频次表,建树模块把频次表变成树,码表模块把树变成每个字节对应的比特串,位读写模块负责比特与字节之间的搬运,压缩/解压模块负责串起流程并管理文件头。这五块拆开都很小,难的是接口对齐。
2.1 一份压缩器的功能边界
先把这个项目的功能边界说清楚,这决定了后面代码能写多简单。它实现的是单文件、单趟、静态哈夫曼:整个文件用同一棵码树,不切块、不做自适应;只处理普通文件,不打包目录;输出格式自定义,不兼容 zip/gzip。压缩端做三件事——扫一遍统计频次、把频次表(而不是码表)写进文件头、第二遍扫描按码表输出比特流。解压端反过来——读文件头重建同一棵树、按比特流从根往下走、走到叶子就吐一个字节,直到吐出原始字节数为止。
这里有个设计取舍值得单独讲:文件头里存频次还是存码表?存码表看上去更直接,但每个字节要额外存「码长 + 码值」,最坏情况下开销比存频次还大;存频次则只多一次建树,而建树对 256 个符号来说开销可以忽略。我一般会选存频次,解压时用完全相同的建树代码把树重建出来,两条路径共用一份buildTree,一致性天然有保障,也少了一整类「码表写错导致解码错位」的坑。
配套的实验报告在这套资源里不是摆设。一份能过审的报告通常包含:需求分析与功能边界、数据结构设计(频次表 / 堆 / 树节点的字段说明)、算法流程图与关键伪码、测试用例与压缩比数据表、时间与空间复杂度分析。写报告时最容易空的地方是「测试」一节,建议直接把你跑出来的文件大小、压缩耗时、md5 比对结果贴成表格,比写十行「经过测试程序运行正确」有说服力得多。
2.2 目录结构与编译方式
源码按职责拆成几个 .c,头文件统一放 include,Makefile 管编译。这个结构不算讲究,但对课设规模刚好——再拆细就是过度设计,全塞进一个 main.c 又会让人读不下去。
hzip/ ├── Makefile ├── README.md ├── include/ │ ├── huff.h # 结构体定义与建树/码表接口 │ └── bitio.h # 位读写器接口 ├── src/ │ ├── main.c # 命令行入口:-c 压缩 / -d 解压 │ ├── freq.c # 字节频次统计 │ ├── hufftree.c # 最小堆、建树、码表生成 │ ├── bitio.c # 比特级读写缓冲 │ ├── compress.c # 压缩主流程 + 文件头写入 │ ├── decompress.c # 解压主流程 + 文件头解析 │ └── util.c # 递归释放、错误提示 ├── tests/ │ ├── run_diff.sh # 压缩→解压→比对 的自测脚本 │ └── samples/ # 文本、图片、随机二进制样本 └── docs/ └── report.docx # 配套实验报告编译只需要一行,-Iinclude指定头文件路径,src/*.c一把捞进来。加上-Wall -Wextra是有必要的,位运算那部分少写一个括号,编译器不提醒你就得靠调试器找半天。
gcc -std=c99 -O2 -Wall -Wextra -Iinclude src/*.c -o hzipMakefile 里写清楚CC、CFLAGS、TARGET三个变量加一条$(TARGET): $(OBJS)规则就够用,别抄那种几十行的通用模板,改起来反而费劲。生成的hzip是单个可执行文件,没有任何运行时依赖,拷到别的机器上直接能跑——这一点在交作业现场很重要,评审机器上不一定装了 cmake。
2.3 三个核心数据结构
工程里真正需要设计的结构体只有三个,其余的(BitWriter、BitReader)都是围绕它们服务的。先把这三个定下来,后面所有代码都是它们的搬运工。
/* include/huff.h */ #define MAX_SYMBOLS 256 #define MAX_CODE_BYTES 32 /* 256 个符号最坏码长 255 位,32 字节够放 */ typedef struct HuffNode { unsigned char ch; /* 叶子才有意义:0~255 */ unsigned int freq; /* 权重,也是堆的排序键 */ struct HuffNode *left; struct HuffNode *right; } HuffNode; typedef struct { HuffNode *data[MAX_SYMBOLS + 1]; /* 下标从 1 开始,data[0] 空置 */ int size; } MinHeap; typedef struct { unsigned char bits[MAX_CODE_BYTES]; int len; /* 有效位数,0 表示该字节不出现 */ } Code;MinHeap的data开 257 而不是 256,是因为二叉堆用 1 号下标做根时,父子关系是i/2和2i、2i+1,不用做边界判断,代码短一截。Code用字节数组而不是unsigned long long存比特串,是因为码长理论上能到 255 位,用 64 位整数会在极端分布下静默截断——这类溢出编译器不会报错,只会让你在解压时看到一屏乱码。
HuffNode里ch只在叶子节点有意义,内部节点的ch保持 0 就行,不要拿它做任何判断。判断叶子统一用!node->left && !node->right,这是后面解码逻辑的基石。
3. 核心链路拆解:一个字节从读入到变成比特流的五步
这一章按数据流动的顺序走一遍:读文件 → 统频次 → 建树 → 生成码表 → 写比特流。每一步给一段能直接抄进项目的代码,并说明这段代码里哪些参数不能乱改。
3.1 第一步:字节频次统计
统计的本质就是开一个 256 长的计数数组,把文件按块读进来,每个字节给对应下标加一。用fgetc逐字节读在功能上没问题,但每读一个字节就进一次库函数,大文件上会明显拖慢;改成 64KB 块读,速度差出好几倍。
/* src/freq.c */ int countFreq(FILE *in, unsigned int freq[MAX_SYMBOLS], unsigned long long *total) { unsigned char buf[65536]; size_t n; memset(freq, 0, sizeof(unsigned int) * MAX_SYMBOLS); *total = 0; while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { for (size_t i = 0; i < n; i++) { freq[buf[i]]++; /* buf[i] 是 unsigned char,取值 0~255 */ } *total += n; } return ferror(in) ? -1 : 0; }buf声明成unsigned char而不是char是关键:char在多数平台上是有符号的,遇到 0x80 以上的字节会变成负数,拿它当数组下标就是越界写,程序在压缩图片时必崩。total用unsigned long long是为了避开 32 位机上 2GB 文件的溢出问题,虽然课设样本到不了这个量级,但成本只是一个类型名多敲几个字符。
调用方记得在读完之后rewind(in),因为紧接着要第二遍扫描写比特流。这一步忘了rewind,压缩出来的文件会缺前半截内容,而且是那种「大小看着正常、解压全是乱码」的错法,很费时间排查。
3.2 第二步:最小堆与哈夫曼树构建
建树是贪心:每次从森林里取两个权重最小的节点,合并成一个新节点塞回去,重复到只剩一个。要高效地反复取最小,就得用最小堆。下面两个函数是堆的全部实现,上浮用于插入,下沉用于弹出。
/* src/hufftree.c —— 上浮插入 */ static void heapPush(MinHeap *h, HuffNode *node) { int i = ++h->size; while (i > 1 && h->data[i / 2]->freq > node->freq) { h->data[i] = h->data[i / 2]; i /= 2; } h->data[i] = node; } /* 下沉弹出 */ static HuffNode *heapPop(MinHeap *h) { HuffNode *top = h->data[1]; HuffNode *last = h->data[h->size--]; int i = 1; while (i * 2 <= h->size) { int c = i * 2; /* 左孩子 */ if (c + 1 <= h->size && h->data[c + 1]->freq < h->data[c]->freq) c++; /* 右孩子更小则取右 */ if (h->data[c]->freq >= last->freq) break; h->data[i] = h->data[c]; i = c; } h->data[i] = last; return top; }heapPush里先++h->size再判断,等于把新元素先「假设」放在末尾,然后一路和父节点比大小往上挪。heapPop把最后一个元素拎出来当替补,从根往下沉,遇到两个孩子都比它大就停。这两个函数都没有做容量检查——data开 257、参与建树的节点总数最多 2×256-1=511 个但同一时刻堆里最多 256 个,所以 257 是安全的;如果你后续要改成块级压缩、每块独立建树,记得重新算这个上界。
建树本身只有十几行,但有一个必须处理的边界:文件里只有一种字节。这时频次表里只有一个非零项,堆 size 为 1,直接返回那个叶子节点,不需要合并。这个分支如果漏了,heapPop会从空堆里弹出垃圾指针。
HuffNode *buildTree(unsigned int freq[MAX_SYMBOLS], int *leafCount) { MinHeap h; h.size = 0; for (int i = 0; i < MAX_SYMBOLS; i++) { if (freq[i] == 0) continue; HuffNode *n = calloc(1, sizeof(HuffNode)); if (!n) return NULL; n->ch = (unsigned char)i; n->freq = freq[i]; heapPush(&h, n); } *leafCount = h.size; if (h.size == 0) return NULL; /* 空文件 */ while (h.size > 1) { HuffNode *a = heapPop(&h); HuffNode *b = heapPop(&h); HuffNode *p = calloc(1, sizeof(HuffNode)); if (!p) return NULL; p->freq = a->freq + b->freq; p->left = a; p->right = b; heapPush(&h, p); } return heapPop(&h); }calloc而不是malloc是有意的:内部节点的ch和左右指针必须初始为 0,漏了初始化就可能把野指针当子树访问。leafCount通过出参带回,解压时要用它判断单叶特例。这棵树的叶子数最多 256,节点总数最多 511,内存占用可以忽略,真正占空间的是后面的码表。
3.3 第三步:码表生成与位写入缓冲
从树到码表是一次深度优先遍历:往左走记 0,往右走记 1,走到叶子就把路径上的比特粘成一条码。因为一条码可能超过 64 位,所以用字节数组按位写入。
static void genCodeRec(HuffNode *node, unsigned char *buf, int depth, Code table[MAX_SYMBOLS]) { if (!node) return; if (!node->left && !node->right) { /* 叶子 */ table[node->ch].len = depth > 0 ? depth : 1; memcpy(table[node->ch].bits, buf, (depth + 7) / 8); return; } buf[depth / 8] &= ~(1 << (7 - depth % 8)); /* 左分支写 0 */ genCodeRec(node->left, buf, depth + 1, table); buf[depth / 8] |= (1 << (7 - depth % 8)); /* 右分支写 1 */ genCodeRec(node->right, buf, depth + 1, table); }注意depth为 0 时说明整棵树只有一个叶子,此时强行把码长记成 1,配合后面的解压特判,可以让单字节文件也能正常往返。buf由调用方清零后传入,函数内部只改动当前路径上的位,回溯时靠下一层的写入覆盖,不用清零——这是这份代码里最容易被误读的地方,第一次读建议拿个小文件在纸上画一遍。
有了码表,下一步是把比特流真正写进文件。文件系统的最小写入单位是字节,所以需要一个攒够 8 位再落盘的缓冲。
/* include/bitio.h */ typedef struct { FILE *fp; unsigned char buf; /* 待写出的字节,低位对齐 */ int bitcnt; /* 已填入的位数 0~7 */ } BitWriter; static inline void bw_init(BitWriter *bw, FILE *fp) { bw->fp = fp; bw->buf = 0; bw->bitcnt = 0; } static inline void bw_putBit(BitWriter *bw, int bit) { bw->buf = (unsigned char)((bw->buf << 1) | (bit & 1)); if (++bw->bitcnt == 8) { fputc(bw->buf, bw->fp); bw->buf = 0; bw->bitcnt = 0; } } static inline void bw_flush(BitWriter *bw) { if (bw->bitcnt > 0) { bw->buf <<= (8 - bw->bitcnt); /* 剩余位左移到高位,低位补 0 */ fputc(bw->buf, bw->fp); bw->buf = 0; bw->bitcnt = 0; } }bw_flush是整个压缩流程的收尾动作,压缩结束时必须调用一次,否则最后不满 8 位的那几个比特留在内存里没写出去。补 0 到整字节会让解压端多看到几个无效位,这正是后面要记录原始文件大小的原因。bitcnt用int而不是bool,就是为了能直接判断> 0而不需要额外的「是否有残留」标志。
3.4 第四步:压缩主流程与文件头
文件头定义了输出格式,是整个项目里唯一一处「自己定规矩」的地方。我定成四段:4 字节魔数、8 字节原始大小、2 字节有效表项数、若干 5 字节表项(1 字节符号值 + 4 字节频次)。
/* src/compress.c */ static int writeHeader(FILE *out, unsigned long long origSize, unsigned int freq[MAX_SYMBOLS]) { int n = 0; for (int i = 0; i < MAX_SYMBOLS; i++) if (freq[i]) n++; fwrite("HZP1", 1, 4, out); /* 魔数 + 格式版本 */ fwrite(&origSize, sizeof(origSize), 1, out); fwrite(&n, sizeof(unsigned short), 1, out); for (int i = 0; i < MAX_SYMBOLS; i++) { if (!freq[i]) continue; unsigned char b = (unsigned char)i; fwrite(&b, 1, 1, out); fwrite(&freq[i], sizeof(unsigned int), 1, out); } return ferror(out) ? -1 : 0; }魔数的第四个字符当版本号用,将来改格式直接换成HZP2,解压端不认识就报错退出,别在字段含义上偷偷改。origSize是解压循环的终止条件——比特流末尾会补 0,不靠原始大小截断就会多吐出几个字节。表项数用unsigned short存,最大 256,够用且省空间。
主流程把前面几块串起来,注意扫描了两遍文件:第一遍统计,第二遍写比特流,中间rewind一次。
int compressFile(const char *inPath, const char *outPath) { FILE *in = fopen(inPath, "rb"); FILE *out = fopen(outPath, "wb"); if (!in || !out) return -1; unsigned int freq[MAX_SYMBOLS]; unsigned long long total = 0; if (countFreq(in, freq, &total) < 0) return -1; int leafCount = 0; HuffNode *root = buildTree(freq, &leafCount); if (!root && total > 0) return -1; Code table[MAX_SYMBOLS]; memset(table, 0, sizeof(table)); unsigned char path[MAX_CODE_BYTES] = {0}; genCodeRec(root, path, 0, table); writeHeader(out, total, freq); BitWriter bw; bw_init(&bw, out); rewind(in); unsigned char chunk[65536]; size_t n; while ((n = fread(chunk, 1, sizeof(chunk), in)) > 0) { for (size_t i = 0; i < n; i++) { Code *c = &table[chunk[i]]; for (int k = 0; k < c->len; k++) { bw_putBit(&bw, (c->bits[k / 8] >> (7 - k % 8)) & 1); } } } bw_flush(&bw); freeTree(root); fclose(in); fclose(out); return ferror(in) || ferror(out) ? -1 : 0; }两个细节:fopen必须带b,不加b在 Windows 上会被 CRT 做换行转换;freeTree放在fclose之前,不要指望进程退出时系统帮你回收,报告里写「无内存泄漏」是要拿工具证明的。
3.5 第五步:解压反查
解压不需要码表,只要用文件头里的频次重建同一棵树,然后从根开始按比特走:读到 0 走左,读到 1 走右,碰到叶子就输出一个字节并回到根。终止条件用原始大小,不用「比特流读完」。
/* src/decompress.c */ static int decodeBody(FILE *in, FILE *out, HuffNode *root, unsigned long long origSize, int leafCount) { if (leafCount == 1) { /* 全文件只有一种字节 */ for (unsigned long long i = 0; i < origSize; i++) fputc(root->ch, out); return 0; } BitReader br; br_init(&br, in); HuffNode *p = root; unsigned long long written = 0; while (written < origSize) { int bit = br_getBit(&br); if (bit < 0) return -1; /* 流被截断 */ p = (bit == 0) ? p->left : p->right; if (!p) return -1; /* 树与流不匹配 */ if (!p->left && !p->right) { fputc(p->ch, out); written++; p = root; } } return 0; }BitReader与BitWriter对称:内部维持一个字节和剩余位数,位数耗完就fgetc再取一个。br_getBit返回 -1 表示文件提前结束,这个返回值必须检查,否则会拿 -1 去索引分支,触发空指针。leafCount == 1的分支是硬编码特例:这种情况下码表里那条码长为 1 的路径根本不会被写出(因为压缩端只有一个叶子,写码时会因为depth==0走特殊分支),与其在压缩端造一个假根节点,不如在解压端显式处理,逻辑更直白。
从这里也能看出为什么文件头存频次而不存码表:解压端调用的buildTree和压缩端是同一份代码,只要频次相同,树的形状必然相同,左右孩子的顺序也相同,天然对齐。
4. 跑通与验收:编译命令、比对脚本和实测压缩比
代码能编译通过离「项目跑通」还差一段距离。这一段要做三件事:编出可执行文件并跑一次成功的压缩解压,用工具证明结果和原文件逐字节一致,以及看压缩比数据判断程序是不是真的在做该做的事。
4.1 编译、运行与第一次压缩
从干净目录开始,确保没有残留的目标文件干扰。-O2打开优化会明显加快大文件处理速度,-g只在调试阶段加,最终交付版本可以去掉。
# 编译 gcc -std=c99 -O2 -Wall -Wextra -Iinclude src/*.c -o hzip # 压缩 ./hzip -c tests/samples/sample.txt /tmp/sample.hz # 解压 ./hzip -d /tmp/sample.hz /tmp/sample.out.txt # 逐字节比对 cmp tests/samples/sample.txt /tmp/sample.out.txt && echo "字节一致"cmp没有任何输出就代表两个文件一模一样,有差异会打印第一个不同字节的偏移量——这个偏移量本身也是线索:如果差异出现在文件开头几百字节内,多半是文件头解析错了;如果出现在末尾,多半是bw_flush或原始大小截断的问题。
main.c里的参数解析保持最简:argv[1]是-c或-d,后面跟输入输出路径,参数个数不对就打印用法并返回非 0。不要在这个项目里引入 getopt,四个参数的手写判断比库函数更好读,也少一个平台差异点。
第一次跑建议直接用tests/samples/里的纯文本样本,几百 KB 就够。跑通之后再拿图片、压缩包、可执行文件试——那些文件会暴露压缩比上的问题,但不会暴露正确性问题,顺序别搞反。
4.2 正确性验收:cmp 与 md5 对拍
单次比对通过不代表程序对所有输入都正确。要证明「对任意文件都能往返」,得靠批量对拍。写个几行的 shell 脚本,把样本目录跑一遍,任何一个失败就停下来。
#!/bin/bash # tests/run_diff.sh —— 压缩→解压→比对,任一失败立即退出 set -e BIN=./hzip TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT for f in tests/samples/*; do [ -f "$f" ] || continue name=$(basename "$f") "$BIN" -c "$f" "$TMP/$name.hz" "$BIN" -d "$TMP/$name.hz" "$TMP/$name.out" if ! cmp -s "$f" "$TMP/$name.out"; then echo "FAIL: $name" exit 1 fi printf '%-32s %8s -> %8s\n' "$name" \ "$(stat -c%s "$f")" "$(stat -c%s "$TMP/$name.hz")" done echo "ALL PASS"set -e让任何一条命令失败就中断,trap保证退出时清掉临时目录,cmp -s的-s是静默模式只返回状态码。脚本顺手把原始大小和压缩后大小打出来,等于免费得到一份压缩比数据,写报告时直接抄。
对拍样本要覆盖五类,缺一类就可能漏掉一类 bug:纯文本(重复度高)、UTF-8 中文(多字节字符)、已压缩的 PNG/JPG(近乎随机)、全零二进制(单字节特例)、空文件(0 字节边界)。空文件那条尤其容易漏,很多实现会在这里崩在buildTree返回 NULL 上。
4.3 压缩比实测与「什么时候别用哈夫曼」
下面这组数字来自一次在本机跑的实测,量级可以参考,具体数字会随样本和实现细节浮动。判断标准不是「压得越小越好」,而是「压缩后的字节数是不是真的比原始小」。
| 样本类型 | 原始大小 | 压缩后 | 比值 | 说明 |
|---|---|---|---|---|
| 纯英文源码 | 512 KB | 约 250 KB | ~49% | 空格和常见字母占比高 |
| UTF-8 中文文本 | 1 MB | 约 620 KB | ~60% | 汉字三字节一组,重复模式多 |
| 已压缩图片 | 512 KB | 512 KB+ | ~100% | 熵已接近上限,加文件头反而变大 |
| 全零二进制 | 1 MB | 约 1.3 KB | ~0.1% | 只有一种字节,走单叶特例 |
| 随机字节 | 256 KB | 256 KB+ | ~100.3% | 头开销 200~800 字节 |
规律很清楚:哈夫曼吃的是「字节分布不均」这碗饭。文本类文件里空格、字母e、换行出现极多,它们的码长短到 3~5 位,平均码长能压到 4~5 位,理论极限就在 50%~60% 附近。已经用 DEFLATE 压过的 PNG、以及加密后的数据,字节分布接近均匀,无论怎么编码平均码长都是 8 位,压缩后必然比原文件大——大出来的部分就是文件头。
这直接引出一条工程上的必要处理:加收益判定。压缩完成后比较输出大小和输入大小,若输出 ≥ 输入,就把输出改写成「魔数 + 原始内容」的存储格式(或者干脆让调用方保留原文件)。没有这一步的压缩器,用户拿它压 zip 包会得到一个更大的文件,然后以为程序坏了。
另一个数据是耗时。1MB 文本在-O2下大约几十毫秒,其中建树和生成码表的开销是常数级(最多 256 个符号),耗时几乎全在第二遍逐比特输出上。有位运算和循环,但没有复杂度问题,不需要优化。
4.4 用 valgrind 和 gdb 定位内存与越界问题
树节点全靠calloc,码表在栈上,位缓冲里没有动态内存——这套设计的泄漏面很小,但「小」不等于「没有」。跑一遍 valgrind,重点看definitely lost和Invalid write两类报告。
# 内存泄漏与越界检查 valgrind --leak-check=full --error-exitcode=1 \ ./hzip -c tests/samples/sample.txt /tmp/s.hzdefinitely lost非 0 基本就是freeTree漏了某个分支,或者calloc之后某条错误返回路径直接return了没释放。Invalid write of size 1则优先怀疑两个地方:Code.bits数组开小了(码长超过 256 位的情况在理论上存在,虽然实际文件很难构造出来),以及堆数组下标越界。
崩溃类的现场用 gdb 看调用栈比printf快得多:
gcc -std=c99 -g -O0 -Iinclude src/*.c -o hzip_dbg gdb -q ./hzip_dbg (gdb) run -d /tmp/s.hz /tmp/s.out (gdb) bt编译时务必用-O0 -g,-O2会把局部变量优化掉,gdb 里print出来全是<optimized out>。栈顶落在decodeBody一般是比特流截断没检查返回值;落在heapPop一般是堆为空还去弹,说明频次表读坏了。
5. 避坑与排查:六个能让课设现场翻车的问题
下面六条都是这套代码在真实调试中会遇到的,按「现象 → 原因 → 解决」写。每一条我都建议你手动复现一次,比看十遍强。
5.1 现象:解压文件尾部多出几个乱字节
现象很稳定:解压出来的文件比原文件长 1~2 个字节,多出来的内容看着像随机数据,cmp报在文件末尾。原因有两个可能,第一个是压缩结束时没有调用bw_flush,最后不满 8 位的那几个比特根本没写出去——但这种情况表现出来是文件变短、解压时br_getBit返回 -1,不是变长。变长的真正原因是解压循环没有用origSize截断,而是一直读到比特流结束,末尾补的那些 0 位被当成合法码继续往下走,多解出几个字节。
解决办法是把while (written < origSize)作为唯一的循环条件,br_getBit返回 -1 只作为异常处理。原始大小存在文件头的 8 字节字段里,解压时先读出来,别用「读到 EOF 为止」这种偷懒写法。
5.2 现象:Windows 下编译,解压后 md5 对不上
同一份代码在 Linux 上跑得好好的,换到 Windows 用 MinGW 编译,解压出来的文本文件 md5 变了,图片直接打不开。血泪经验:问题出在fopen的模式字符串上。用"r"/"w"打开文件时,C 运行时库默认走文本模式,读的时候把\r\n合并成\n,写的时候把\n转换成\r\n。压缩端按\r\n统计频次,解压端写出去变成\r\r\n,字节数直接变了。
解决办法是所有fopen一律写"rb"/"wb",一个都不能漏。检查方法很简单:grep -n 'fopen' src/*.c,逐行看第二个参数有没有b。这件事在 Linux 上永远测不出来,所以最好一开始就养成习惯。
5.3 现象:单字节文件把程序送进死循环
拿一个 1000 字节全是'a'的文件测试,压缩很快,解压卡死,CPU 占满但不出结果也不退出。原因是这种情况下建出来的树只有一个叶子节点,genCodeRec里depth == 0,如果不做特判,码长会被记成 0;压缩端写出 0 个比特,解压端却在等比特,两边对不上,while循环里written永远不增加。
解决办法就是 3.3 和 3.5 里那两处特判:压缩端把单叶的码长强制记成 1,解压端在leafCount == 1时直接按原始大小输出root->ch,完全不进比特循环。这个边界值一定要单独建一个测试样本,别指望对拍脚本碰巧覆盖到。
5.4 现象:压缩到一半段错误,栈顶在 heapPop
跑大文件(几 MB)时随机崩,gdb 的bt显示在heapPop,print h->size出来是个负数。原因是堆数组的容量算错了。MinHeap.data只有 257 个槽位,如果buildTree里对每个非零频次都heapPush一次,同时堆里最多 256 个元素,看起来没问题。但一旦改成「块级压缩、每块独立建树」,块大小和并发状态一变,这个上界就不成立了。
解决办法有两个层面:一是明确data容量的推导依据并写成注释,别让后来人猜;二是在heapPush顶部加一句assert(h->size < MAX_SYMBOLS),Debug版本开-DNDEBUG之外的构建就能第一时间抓到越界,而不是等到内存被踩坏之后在别处崩。
5.5 现象:小文件越压越大
压一个 500 字节的配置文件,输出 1.2KB。原因不是算法错了,是文件头太重。表项每项 5 字节,如果这 500 字节里出现了 200 种不同的字节值,光表就要 1000 字节,加上 14 字节固定头,比原始数据还大两倍。这不是 bug,是格式设计的固有开销。
解决办法是加收益判定,把「压缩」和「存储」做成两种输出模式,魔数第四位区分。压缩端先写到一个临时缓冲区或者临时文件,写完比较大小,不划算就把原文件内容原样拷过去、只改文件头标记。收益判定的阈值设为「输出 < 输入」即可,不必抠那十几个字节。
5.6 现象:valgrind 报 definitely lost,几 KB 起步
程序功能全对,但 valgrind 报告有几 KB 内存没释放,条数正好等于树的内部节点数。原因是freeTree只释放了叶子或者只释放了根。哈夫曼树是递归结构,必须后序遍历释放:先递归左右子树,再释放当前节点。
解决办法是写一个十行的递归函数,两个递归调用放在free之前。注意递归深度最大 255(极端偏斜的树),栈上完全撑得住,不需要改成迭代。释放完之后把根指针置 NULL,避免freeTree被调用两次导致 double free——这个错误 glibc 会直接 abort,比内存泄漏还难查。
6. 二次开发改造:把课设改成能日常用的小工具
课设版本跑通之后,往「能用」方向走大概有三步,每一步都对应一个具体改动点。第一步是加收益判定和原始存储模式,这解决了小文件和已压缩文件的负收益问题,改动集中在compressFile末尾的十几行,加个if (outSize >= total)分支,把魔数换成HZP0再把原文件字节拷过去。第二步是加块级自适应:把文件切成 256KB 的块,每块独立统计频次、独立建树、独立写头,好处是并行化和局部最优,代价是块头开销变大,块大小这个参数要在压缩比和开销之间做权衡——我一般从 256KB 起试,文本文件上比全局单树能多压 3~5 个百分点。第三步是把main.c的参数解析换成 getopt_long,支持-o指定输出、-q静默、-t只测不写,这些是命令行工具的标配。
改造过程中最容易出问题的不是新功能,是格式兼容。每次改文件格式,魔数第四位必须跟着变,HZP1→HZP2,解压端遇到不认识的版本直接报错退出,别试图向下兼容。我吃过这个亏:为了「支持旧格式」在解压端加了一堆分支判断,结果新格式的字段顺序和旧格式只差一个字节,测试时两边数据刚好一样没暴露,交上去之后用真实文件一跑全乱。
验证方法用一张对照表比拍脑袋可靠:
| 改造项 | 验证方式 | 通过标准 |
|---|---|---|
| 收益判定 | 压一个已压缩的 PNG | 输出大小 ≤ 输入,且解压后字节一致 |
| 块级压缩 | 1MB 文本对拍 | cmp无差异,压缩比比单树版本小 |
| 参数解析 | 缺参数、错参数、多参数 | 打印用法并返回非 0,不崩溃 |
| 格式版本 | 用旧版本解新文件 | 明确报「不支持的格式版本」 |
改完一项就跑一次tests/run_diff.sh,全绿再动下一项。这脚本看着土,但它是我唯一信得过的「后悔药」——手动点几下鼠标验证过的代码,十个里有三个在第二天改别的地方时被悄悄改坏。从那以后我每次动compress.c或decompress.c,不管改动多小,都强制先跑一遍对拍脚本再往下写,宁可多花三十秒,也不要在报告交上去的前一晚对着 md5 不一致的文件干瞪眼。希望帮到你。
本文还有配套的精品资源,点击获取