
简介这份面向数据结构课程设计与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头文件统一放 includeMakefile 管编译。这个结构不算讲究但对课设规模刚好——再拆细就是过度设计全塞进一个 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 -stdc99 -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、2i1不用做边界判断代码短一截。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-1511 个但同一时刻堆里最多 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 的路径根本不会被写出因为压缩端只有一个叶子写码时会因为depth0走特殊分支与其在压缩端造一个假根节点不如在解压端显式处理逻辑更直白。从这里也能看出为什么文件头存频次而不存码表解压端调用的buildTree和压缩端是同一份代码只要频次相同树的形状必然相同左右孩子的顺序也相同天然对齐。4. 跑通与验收编译命令、比对脚本和实测压缩比代码能编译通过离「项目跑通」还差一段距离。这一段要做三件事编出可执行文件并跑一次成功的压缩解压用工具证明结果和原文件逐字节一致以及看压缩比数据判断程序是不是真的在做该做的事。4.1 编译、运行与第一次压缩从干净目录开始确保没有残留的目标文件干扰。-O2打开优化会明显加快大文件处理速度-g只在调试阶段加最终交付版本可以去掉。# 编译 gcc -stdc99 -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 PASSset -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 KB512 KB~100%熵已接近上限加文件头反而变大全零二进制1 MB约 1.3 KB~0.1%只有一种字节走单叶特例随机字节256 KB256 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-checkfull --error-exitcode1 \ ./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 -stdc99 -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显示在heapPopprint 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 不一致的文件干瞪眼。希望帮到你。本文还有配套的精品资源点击获取