☰
基于Huffman编码的C语言文件压缩与解压缩课设实战
2026/10/28 3:25:18 网站建设 项目流程

简介:这份文档面向数据结构课程设计学习者,围绕基于Huffman编码的文件压缩与解压缩项目,给出可参考的课程设计报告与实现思路。正文覆盖总体设计、详细数据结构与算法设计、主要函数说明、程序测试报告、结论及源程序附录,串联字符频率统计、Huffman树构建、编码表存储、按位读写、压缩比输出与解压还原等环节,并对HTNode、Huffmancode结构体、命令行输入输出流程和代码注释规范作了说明。包内共1个docx文件,约1.41MB,以报告文档为主,便于查阅、打印和整理。已有1061人学习,适合需要完成同类课设、理解Huffman编码应用或补充C语言文件处理实践的学习者参考。

1. 从一个 117% 的压缩比说起:这套 Huffman 课设真正要交付的东西

一份测试记录里躺着这样一行数据:JPG 文件压缩后是原文件的 117.76%,Mp4 是 99.99%,而纯文本 txt 只有 33.02%。只看「压缩」两个字,前两行像是翻车,实际上这是 Huffman 编码在真实文件上的正常表现,也是数据结构课程设计里最值得讲清楚的一件事。这套基于 Huffman 编码的文件压缩与解压缩课设,要交付的不是一个能打赢 zip 的工具,而是两个能用命令行跑起来的 C 程序:一个读源文件吐出压缩文件,一个读压缩文件还原出与源文件逐字节一致的目标文件,外加一份能把数据结构、算法、T(n) 和测试数据都讲圆的设计报告。适合正在做数据结构课设、需要一个能直接照着搭骨架的人。

2. 需求拆解与技术选型:字节符号表 + 两遍扫描 + 位流输出

2.1 把任务书上的四句话翻译成可勾选的验收项

任务书上的文字往往写得很客气,落到验收现场就变成一条条能不能当场跑出来的动作。我习惯先把原始要求逐条翻译成功能点,再倒推需要哪些函数和哪些文件格式字段,这样报告里的「总体设计」一节基本就是翻译表扩写,不会写着写着跑偏。

原始要求翻译成功能点现场验收动作
完成两个 C 语言程序压缩端与解压端各自独立编译,不共享可执行文件只保留解压程序源码,也能单独构建成功
命令行参数输入源文件与目标文件入口判断 argc,取 argv[1]、argv[2];源文件不存在则退出,目标文件不存在则创建故意传一个不存在的源文件名,看是否打印提示并退出
压缩时两遍扫描源文件第一遍只统计符号种类与频次,第二遍才编码输出在第一遍结束后打印符号种类数,与手算结果比对
符号表与 Huffman 树存进压缩文件压缩文件头部自带符号种类数与每组的字符、权重,解压端不依赖源文件压完把源文件删掉,再解压,看是否能还原

第二行和第四行是很多方案里最容易含糊过去的地方。命令行参数这块,常见做法是用argc != 3直接拒绝,打印一行用法提示,返回非 0 退出码;「目标文件不存在则创建」不需要额外写creat,fopen(..., "wb")本身就会创建,写"wb"而不是"w"这件事后面还会单独说。

第四行决定了整个压缩文件的格式设计方向。把码表写进压缩文件,解压端才能独立工作;如果不写,解压时必须回头读原文件重建树,那就不叫解压了,叫「对照还原」。这一条也顺带解释了为什么结构体里用int存左右孩子下标而不是指针——下标是可以序列化的,指针不行。

2.2 符号集取 0 到 255,而不是可见字符

题目里给了一个很小的例子:19 字节的文件aaBBaaaaccBBB,符号种类数是 5,五个符号分别是 97、66、99、13、10,频率分别是 6/19、5/19、2/19、3/19、3/19。13 和 10 这一对值得多看两眼,它们就是回车和换行,在文本文件里几乎必然出现。如果符号集只取可见 ASCII(32 到 126),这两个符号就会被漏掉,统计总数对不上文件长度,编码表里查不到它们,压缩到那一字节时直接崩或者写出垃圾。

所以符号集老老实实取unsigned char的 0 到 255,覆盖所有可能的字节值。这里有个血泪教训:如果用char声明字符变量,在多数编译器上它是有符号的,读到 0x80 以上的字节会变成负数,拿它当下标去访问charnum[ch]就直接越界,而且越界得毫无声息,可能跑十次才崩一次。全程用unsigned char,只在fgetc的返回值上留一个int中转。

数组长度取N = 257而不是 256,是因为这套实现里所有树节点下标从 1 开始。0 号下标空着不用,parent == 0就成了「这个节点还没有父节点」的天然标记,lchild == 0 && rchild == 0就成了「这是叶子」的判断依据。省掉一个额外的布尔数组,代价是浪费一个HTNode的空间,这笔账很划算。

#define N 257 /* 0 号下标不用:parent==0 表示尚未合并,lchild==rchild==0 表示叶子 */ unsigned char buf[256]; /* 实际用得上的只有 0..255 */

统计频次的那一趟,用一个double charnum[257]或者unsigned long count[257]全局数组就够,每读一字节charnum[ch]++,同时累加总长度。总长度也可以直接用fseek(fp, 0, SEEK_END)加ftell拿到,比边读边数少一次累加,但两种都要rewind回到文件开头才能开始第二遍。

2.3 两遍扫描与一次读全的取舍

题目明确建议两遍扫描,第一遍统计,第二遍编码。也有人图省事,fread把整个文件读进内存,统计和编码都在内存里做,最后一次性写盘。两种都能跑出正确结果,区别在于报告里怎么写 T(n) 和空间复杂度。

两遍扫描的代价是 2n 次字节读,空间是常数级:一个 257 项的计数数组加一棵最多 513 个节点的树。一次读全的代价是 n 次读加 O(n) 的堆内存,对几十 MB 的文件还能接受,对上百 MB 的文件就要小心了。我一般会按题目原意走两遍扫描,因为fseek、ftell、rewind这一套文件定位操作本身就是这个课设想让人练熟的东西,答辩时被问到「你怎么知道文件多大」也答得干脆。

建树部分的复杂度更值得写进报告。每合并一次要在剩下的节点里找权值最小的两个,线性扫描是 O(k),一共合并 k-1 次,整体 O(k²)。k 最大 256,k² 大约六万五千次比较,实际耗时可以忽略。用最小堆可以把这一段压到 O(k log k),但代码量差不多翻倍,报告页码有限,收益不明显。写报告的时候把这个权衡写出来,比单纯贴一句「时间复杂度 O(n log n)」要有说服力得多。

整个压缩流程的 T(n) 可以这样拆:第一遍扫描 O(n),建树 O(k²),求码表 O(k·L)(L 是最长码长,不超过 k-1),第二遍扫描 O(n·L̄)(L̄ 是平均码长)。因为 k ≤ 256 是常数上界,整体可以写成 O(n),但报告里把 k 保留着写,阅卷的人一眼就能看出你是真推过而不是抄的。

3. 数据结构与压缩文件格式:树、码表、位缓冲区三件套的落盘约定

3.1 HuffmanTree 与 Huffmancode 的结构定义与下标约定

树和码表的结构定义直接决定了后面所有函数的签名。

#define N 257 typedef struct Node { unsigned char ch; /* 叶子代表的字节值 0-255;内部节点此字段无意义 */ double weight; /* 频次,或归一化后的概率 */ int parent, lchild, rchild; /* 存下标不存指针,0 表示空 */ } HTNode, HuffmanTree[2 * N - 1]; typedef struct { unsigned char ch; /* 该编码对应的字节 */ char *code; /* 变长 01 串,需 malloc,结尾 '\0' */ } Huffmancode[N];

2 * N - 1这个长度不是拍脑袋来的。n 个叶子的严格二叉树必有 n-1 个内部节点,总共 2n-1 个,下标 1 到 2n-1。前 n 个位置放叶子,后 n-1 个位置放合并出来的内部节点,这样内部节点的下标永远大于它两个孩子的下标,select在1..i-1范围里找最小值时不会误选到还没生成的节点。

用下标不用指针,有三个实际好处。写入文件时可以直接序列化;解压端重建树时下标关系天然保持;调试时打印HT[i].parent得到的是一个 3 位以内的整数,比打印一个地址直观太多。weight用double是为了跟题目给的概率口径一致(6/19 这种),如果改成unsigned long存原始计数,比较时不会有浮点误差,文件里每个符号也省 4 字节。两种都行,关键是压缩端和解压端必须一致,报告里也要前后一致。

Huffmancode里的code是指针,意味着每个符号的码字要单独malloc。这一步别忘了配对释放,压完一个文件如果不释放,连续压十个文件就漏十份内存;虽然课设通常只压一个文件就退出,但报告里「代码规范」那一栏是会给分的。

3.2 压缩文件的二进制布局

压缩文件不是一个黑匣子,它的每一段都要能说清楚。这套方案的实际布局是这样:

偏移长度内容写入方式
04 字节int n,符号种类数fwrite(&n, sizeof(int), 1, fp)
4n × 9 字节n 组:1 字节字符 + 8 字节权重循环fwrite两次
4 + 9n2m 字节m 个short位流字整块或逐个fwrite
文件末尾4 字节int dy,最后一字补零的个数fwrite(&dy, sizeof(int), 1, fp)

解压端的读取顺序必须和写入严格对齐:先fseek(fp, -4, SEEK_END)读末尾的dy,rewind回去读n,再读 n 组字符与权重,剩下(文件长度 - 8 - 9n) / 2就是位流字的个数 m。这个推导要写进报告的详细设计里,因为它解释了为什么解压端不需要知道原始文件多大。

注意:把double直接二进制写盘,跨平台会遇到端序和sizeof(double)的差异。同一台机器上压、同一台机器上解没有问题,这也是课程设计的默认假设,但报告里最好补一句「码表以二进制形式存储,要求压缩端与解压端平台一致」,这句话能挡掉答辩时一半的追问。

还有一种更省事的变体:权重不存double,存unsigned int计数,每个符号从 9 字节降到 5 字节,码表开销直接砍掉将近一半。对 19 字节的样例文件来说,头部的 49 字节码表比数据本身还大,压缩比会难看到没法看;对小文件来说这是必然的,报告里要主动说明「压缩比在小文件上不具参考性」,别等老师来问。

3.3 位缓冲区:15 位装进 short,还是 16 位装进 unsigned short

C 语言没有位类型的数组,写文件最小单位是字节。所以必须自己攒一个缓冲区,把变长的 01 串按位填进去,填满一个字就写盘。原文报告里写的是「short int 有一位符号位,故将 15 个 0/1 转化为一个数据存入 short int」,代码里却是按pow(2.0, 15 - j)从 j=0 算到 15,实际上是 16 位。两处口径不一致,答辩时很容易被揪住。

我一般会统一成 16 位加unsigned short,理由很简单:无符号类型没有符号位干扰,移位不会产生实现定义的行为,容量还多一位。

unsigned short bucket = 0; /* 位桶,攒满 16 位就写盘 */ int filled = 0; /* 当前已填位数 0..15 */ /* 往桶里塞一个比特:先左移腾位置,再把新比特放到最低位 */ static void push_bit(unsigned short *bucket, int *filled, int bit, FILE *fp) { *bucket = (unsigned short)((*bucket << 1) | (bit & 1)); if (++(*filled) == 16) { fwrite(bucket, sizeof(unsigned short), 1, fp); *bucket = 0; *filled = 0; } }

bit & 1是防御性写法,保证传进来的即使是'0' - '0'之外的脏值也只影响最低位。filled用一个指针传进去,是因为压缩主循环要跨很多次调用共享它。最后收尾时必须处理不满 16 位的情况:把桶左移16 - filled位,让有效比特对齐到高位,低位补零,然后把补零个数写进文件末尾那 4 字节。

if (filled > 0) { bucket = (unsigned short)(bucket << (16 - filled)); /* 有效位左对齐 */ fwrite(&bucket, sizeof(unsigned short), 1, fp2); int pad = 16 - filled; /* 补了几个零 */ fwrite(&pad, sizeof(int), 1, fp2); } else { int pad = 0; fwrite(&pad, sizeof(int), 1, fp2); /* 末尾一定要写,解压端靠它定位 */ }

pad这个字段看着不起眼,却是解压端最容易出错的地方。有效总位数 = m × 16 - pad,多解一位就会在还原文件末尾多出一个字符。所以这里有个原则:不管有没有补零,末尾这 4 字节必须写,写 0 也要写,让解压端的读取路径只有一条,不要出现「某些文件有尾字段、某些没有」的分支。

4. 四段核心算法落地:建树、求码、压缩、解压的代码与参数说明

4.1 建树与 select:找最小两个节点的那点讲究

建树的过程本身就是 Huffman 算法的四条步骤:初始 n 棵单节点树、取最小两棵合并、删旧添新、重复到只剩一棵。

void creattree(HuffmanTree HT, int n) { int m = 2 * n - 1; /* 先把内部节点区间的字段清零,避免上一轮残留的 parent 干扰 select */ for (int i = n + 1; i <= m; i++) { HT[i].parent = HT[i].lchild = HT[i].rchild = 0; HT[i].weight = 0; } for (int i = n + 1; i <= m; i++) { int min1, min2; select(HT, i - 1, min1, min2); /* 在 1..i-1 中找两个 parent==0 且权值最小的 */ HT[min1].parent = i; HT[min2].parent = i; HT[i].lchild = min1; HT[i].rchild = min2; HT[i].weight = HT[min1].weight + HT[min2].weight; } }

select的写法有个小技巧值得讲:连续调用两次「找最小」,第一次找到的节点必须被打上标记,否则第二次还会找到它自己。原文的做法是在min()里把选中节点的parent临时置成 1,这样第二轮扫描时它就不满足parent == 0的条件了。

/* 在 1..i 范围内找 parent==0 且权值最小的节点,找到后把它的 parent 置 1 当墓碑 */ int min(HuffmanTree HT, int i) { int j = 1, best = 0; double bestw = 0; while (j <= i && HT[j].parent != 0) j++; /* 跳过已有父节点的 */ if (j > i) return 0; /* 没有可用节点 */ best = j; bestw = HT[j].weight; for (; j <= i; j++) { if (HT[j].parent == 0 && HT[j].weight < bestw) { best = j; bestw = HT[j].weight; } } HT[best].parent = 1; /* 墓碑标记,防止第二次 select 重复选中 */ return best; }

这个技巧的好处是省一个visited数组,坏处是把parent字段当成了两种语义用:0 表示未合并,1 表示临时占用,大于 1 才是真正的父节点下标。所以creattree结束之后,第 1 到 n 号叶子节点的parent一定大于等于 n+1,不会有残留的 1。但如果同一份HuffmanTree要在进程里复用(比如连续压多个文件),下一轮进来之前必须把 1 到 2n-1 的parent全部清回 0,包括叶子区。这一点后面避坑章还会提。

select只要写两行:

void select(HuffmanTree HT, int i, int *min1, int *min2) { *min1 = min(HT, i); *min2 = min(HT, i); }

i这个参数的含义是「当前已存在的节点上界」。第一次合并时 i = n,之后每合并一次 i 加一,所以在creattree里传的是i - 1。

还有一个细节:weight是double,两个不同符号的频次完全相等是可能的,比如样例文件里 13 和 10 的频次都是 3。浮点相等时谁先谁后会影响树的形状,但不会影响编码的正确性——前缀无歧义这个性质不依赖于具体的合并顺序。如果报告里要求「同一输入必须得到同一输出」(比如测试用例要做文件哈希比对),那就得补一个平局规则,让下标小的优先,或者干脆把weight改成整数计数,从根上避开浮点比较。

4.2 自底向上回溯求码表

树建好之后,每个叶子的编码就是「从根走到它」的路径,左 0 右 1。但从叶子往上走比从根往下走更方便,因为每个节点只存了parent,没存「我是父节点的左孩子还是右孩子」——不过这个信息可以现推:HT[f].lchild == c就说明是左边。

void HuffmanCoding(HuffmanTree HT, Huffmancode HC, int n) { char *lcode = (char *)malloc(n * sizeof(char)); /* 最长码长不超过 n-1 */ lcode[n - 1] = '\0'; for (int i = 1; i <= n; i++) { int start = n - 1; int c = i, f = HT[i].parent; while (f != 0) { lcode[--start] = (HT[f].lchild == c) ? '0' : '1'; /* 倒着填 */ c = f; f = HT[c].parent; } HC[i].ch = HT[i].ch; HC[i].code = (char *)malloc(n - start); /* 含结尾 '\0' */ strcpy(HC[i].code, &lcode[start]); } free(lcode); }

从n - 1这个位置往前倒填是关键。如果正着填,每次都要先知道码长,或者填完再memmove挪一次,代码更啰嗦。倒填之后start停在真实起点的位置,&lcode[start]直接就是一个完整的 C 字符串,strcpy一步到位。malloc的长度n - start里已经含了'\0'的位置,因为lcode的长度是 n,从start到n-1有n - start个字节,其中最后一个位置是被初始化的'\0'。

这段代码有一个隐含假设:n个叶子的树最长路径不超过 n-1。这是对的,因为每次合并至少让路径上的节点数加一,而合并总次数是 n-1。所以给lcode分配 n 字节足够,不需要按2n分。

free(lcode)之后,HC[i].code仍然指向各自malloc的堆块,这些块要么在压缩结束后统一释放,要么跟着进程退出一起回收。课设场景下泄漏一次问题不大,但既然写了malloc,配对写一个free循环也就三行的事。

4.3 压缩主循环:查表、拼位、写盘

有了码表,第二遍扫描就变成「读一字节、查出码串、逐位塞进位桶」。

rewind(fp1); int idx[256]; /* 从字节值直接映射到 HC 下标,O(1) 查表 */ memset(idx, 0, sizeof(idx)); for (int i = 1; i <= n; i++) idx[HC[i].ch] = i; int a; while ((a = fgetc(fp1)) != EOF) { /* 用返回值判断结束,不要用 feof */ int i = idx[(unsigned char)a]; if (i == 0) continue; /* 理论上不会发生,防御性跳过 */ for (const char *p = HC[i].code; *p != '\0'; p++) { push_bit(&bucket, &filled, *p - '0', fp2); } }

idx这个数组是性能上的小优化,也是代码可读性上的分水岭。原文用的是一个for (i = 1; i <= n; i++) if (HC[i].ch == a)的线性查找,n 最大 256,三百多字节的文件感受不到差别。但写成idx之后,主循环里少一层嵌套,答辩时讲起来也清楚:这就是一个 256 项的直接寻址表,本质上是把「符号集有限」这个前提用足了。

fgetc的返回值必须用int接收,这一点不能省。fgetc返回的取值域是 0 到 255 加上 EOF(-1),如果用char接,读到字节 0xFF 时会被截成 -1,跟 EOF 撞车,循环就提前结束了。这个 bug 在纯文本文件上永远不出现,一换成 jpg 或者压缩包就露馅,属于典型的「测试用例选得太温柔」型翻车。

参数说明:push_bit的四个参数分别是位桶指针、已填位数指针、待写入比特、目标文件指针。之所以用指针传位桶,是因为 C 语言没有引用,而位桶的状态必须跨调用保留。

4.4 解压:从根节点往下走树

解压端拿到的是「码表 + 位流 + 补零个数」,要做的正好反过来:按位读,0 往左走,1 往右走,走到叶子就吐一个字节,然后回根重新开始。

int root = 2 * n - 1; long total_bits = (long)m * 16 - pad; /* 有效比特总数 */ int f = root; for (long b = 0; b < total_bits; b++) { int word = bits[b >> 4]; /* 第几个 16 位字 */ int bit = (word >> (15 - (b & 15))) & 1; /* 字内第几位,从高位开始 */ f = bit ? HT[f].rchild : HT[f].lchild; if (HT[f].lchild == 0 && HT[f].rchild == 0) { /* 走到叶子 */ fputc(HT[f].ch, fp2); f = root; /* 回根,准备解下一个符号 */ } }

这段和原文的写法有一处明显差别:原文是先把所有short展开成一个int数组decode[k * 16],再在展开后的数组上循环。展开的好处是下标计算简单,坏处是内存占用翻 32 倍(int四字节、short两字节,再乘以 16 倍展开系数),一个 1 MB 的压缩文件要额外吃 32 MB 内存。按位即时取不需要额外数组,代价是每次多两次移位一次与运算。

循环的终止条件必须是「有效位数用完」,不能写成「走到叶子就结束」。因为最后一个符号的码字可能正好在补零区之前结束,如果按叶子判断,补的那些零会在树上走出一些奇怪的路径,运气好走到某个叶子多吐一个字符,运气不好走到内部节点然后数组越界。原文用的是while (i < k - dy),其中dy是补零个数,思路一致,只是变量命名不够直白。

fputc每写一个字节就刷一次缓冲区,对 1 MB 的文件来说系统调用次数太多。想快一点可以用一个 4 KB 的输出缓冲数组,攒满再fwrite,但课设规模下没必要,保持代码短比跑得快重要。

5. 避坑与排查:最容易让程序在验收现场翻车的五个点

先澄清一个不算 bug 的现象:压缩比超过 100%。测试数据里 JPG 是 117.76%,Mp4 是 99.99%,docx 是 106.11%。原因不复杂——这些格式本身已经做过熵编码或者本身就是压缩容器,字节分布接近均匀,Huffman 给每个符号的平均码长接近 8 位,压不动;再叠上 9n 字节的码表开销和 8 字节的头尾字段,净增就这么来了。文本文件能压到 33.02%,是因为字符分布极不均匀,空格、换行、常用字母的码长只有三四位。报告里把这张表原样放上去,再补一句「压缩效果取决于符号分布的偏斜程度」,比强行解释成「程序有问题」强。

下面五条是真正会让程序在验收现场跑不出结果的。

5.1 用 feof 判断文件结束,最后一个字节被处理两次

现象:压缩出来的文件比预期多几个码字,解压还原后末尾多出半个字符;或者程序在最后一步突然崩溃。

原因:feof(fp)不是「文件到头了」的预测,而是「上一次读操作撞到文件尾」的事后标记。写成while (!feof(fp1)) { a = fgetc(fp1); ... },最后一次循环里fgetc返回 EOF,feof此时才置位,但循环体已经拿着a = -1执行了一轮,把-1转成unsigned char就是 255,凭空多统计了一个符号。

解决:直接用读函数的返回值做循环条件。fgetc返回int,与EOF比较;fread返回实际读到的元素个数,与期望个数比较。feof只适合在读完之后判断「是正常结束还是出错」,不适合当循环条件。

5.2 fgetc 的返回值塞进 char,遇到 0xFF 就提前收工

现象:txt 文件压得好好的,一换成 jpg、png 或者 exe,压缩过程在中途无声无息地结束,还原出来的文件只有原文件的一小段。

原因:char在多数编译器上是有符号的,把fgetc返回的 255 存进char就变成 -1,和EOF相等,循环条件直接判负退出。图片和可执行文件里 0xFF 出现的概率很高,文本文件里几乎为零,所以这个 bug 有极强的欺骗性。

解决:接收变量声明成int,比较完EOF之后立刻转成unsigned char再用。同理,fwrite写单个字节时用fputc((unsigned char)c, fp)或者fwrite(&c, 1, 1, fp),别让符号位掺和进来。

5.3 用 "w" 或 "r" 打开文件,换行符被悄悄改写

现象:压缩再解压之后,文件字节数变了,用十六进制对比发现每一处0D 0A变成了0A,或者反过来多出了0D。

原因:文本模式在部分平台上会做换行翻译。写的时候\n变成\r\n,读的时候反过来。对于只处理文本的程序这是便利,对于按字节压缩的程序这是灾难:压缩时统计到的 10 和 13 的频次,跟解压时实际要写的字节对不上,还原文件自然不一致。

解决:所有fopen一律带b,"rb"、"wb"。题目给的那个 19 字节样例里正好同时有 13 和 10,拿它当第一个测试用例,这个坑一测就现形。

5.4 select 里的 parent 标记没清干净,第二次建树直接歪掉

现象:单次压缩解压完全正常,改成循环处理多个文件后,第二个文件的压缩比明显异常,或者解压直接得到乱码。

原因:min()里把选中节点的parent置 1 当墓碑用,这个 1 是临时状态。如果HuffmanTree是全局变量或者循环外声明的,第二次进creattree时,上一轮留下的parent值还在,select会认为这些节点已经有父节点了,跳过它们,结果要么选错节点,要么在while里死循环找不到可用的。

解决:每次建树前把1..2n-1的parent、lchild、rchild全部清零,不要只清内部节点区间。更稳妥的做法是把HuffmanTree定义在压缩函数内部当局部变量,靠作用域强制隔离,代价是栈上多占几 KB——513 个节点每个 24 字节左右,也就 12 KB,撑得住。

5.5 空文件和单符号文件没有特判,直接除零或者死循环

现象:压一个空文件,程序在小数点后第几位崩掉;压一个内容全是同一个字符的文件,解压出来是空的,或者程序卡住不动。

原因:空文件第一遍扫描结束,n = 0,2*n-1是负数,建树那层循环一次不进,后面算概率时除以总长度 0,浮点异常。单符号文件n = 1,树只有一个节点,它既是根又是叶子,HuffmanCoding里while (f != 0)一次不进,码串是空串,压缩时一个比特都写不进去,解压时root指向的那个节点既没有左孩子也没有右孩子,按位走第一步就落到空指针上。

解决:入口处两个特判。n == 0时压缩文件只写头部(长度为 0)就返回,解压端读到n == 0直接创建一个空目标文件。n == 1时给这唯一的符号定义一个位固定为'0'(或者干脆在位流里存字符重复次数),解压端读到n == 1就走专用分支,不建树、不走树,直接按位数值写字符。

提示:这两个用例在验收表上一定要列出来,而且要放在前面。老师看到你主动考虑退化情况,对「测试」那一栏的评分帮助比多压十个 txt 文件大得多。

6. 验收与答辩:用一张测试表和一份 T(n) 分析把分数拿稳

验收现场的时间通常只有五到十分钟,能不能过,很大程度上取决于你打开电脑之前有没有把测试用例排好序。我一般会准备这样一张表,按复杂度递增排,第一条就是能背下来的:

序号输入期望结果判据
119 字节的 aaBBaaaaccBBB还原后与原文件逐字节相同符号种类数 5,符号为 97、66、99、13、10
2空文件(0 字节)生成合法压缩文件,解压得到 0 字节文件程序不崩溃,退出码为 0
3单字符重复 1 MB 的文件还原一致压缩比接近 0,验证 n=1 分支
4一个 txt、一个 docx、一个 jpg全部还原一致压缩比分别为小于 100%、可能大于 100%
5不存在的源文件名打印提示并退出退出码非 0,不产生目标文件

第一条是老师给的例子,符号种类数 5 这个数字能直接手算,你先跑它,等于告诉对方「我知道正确结果长什么样」。第四条里 jpg 压完变大这件事要主动说,别等被问。第五条是命令行参数处理的分,很多人只写正常路径,一传错文件名就段错误。

T(n) 分析别写成一串大 O 符号了事。按「扫描 O(n) → 建树 O(k²) → 求码 O(k·L) → 编码 O(n·L̄) → 解压 O(m·16)」分段列,每一段说明 n、k、L 各是什么,k 是符号种类数、上界 256,L 是最长码长、上界 k-1。最后给一句结论:由于 k 是常数,整体随文件长度线性增长。这句话比空写一个 O(n) 有内容得多。

答辩最常被问的三个问题,答案都在前面的章节里:为什么压缩文件要存码表(因为解压端不能依赖源文件);为什么用下标不用指针(因为要序列化,且跨进程地址无意义);压缩比为什么有的大于 100%(因为源文件已被压缩过,符号分布均匀,叠加码表开销)。把这三条各准备两句话,剩下就是现场演示别手抖。

我第一版做这个课设的时候,测试只用了自己随手写的一个 txt,验收时老师从桌面拖了一张照片进来,程序跑了三秒就停,还原出来的文件只有前半截。那天回去之后我把 0xFF 那个坑记了很多年:从那以后我每次写文件处理相关的代码,第一件事就是拿一个全 0xFF 的二进制文件跑一遍,不管需求里有没有提。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询