简介:这份文档面向数据结构课程设计学习者,围绕Huffman编码实现文件压缩与解压缩,适合需要完成C语言课程设计、理解哈夫曼树构建与编解码流程的学生参考。压缩包内共1个docx文件,约1.41MB,内容为完整的课程设计报告,涵盖课程设计要求、总体设计、详细数据结构与算法设计、程序测试、结论及源程序附录,并给出HTNode、HuffmanTree、Huffmancode等结构定义和init、creattree、select、HuffmanCoding、zip、unzip等函数说明。文档还涉及字符频率统计、优先队列选最小节点、按位读写、编码表存储、压缩比输出与解压还原等关键实现思路,可帮助读者梳理从统计、建树、编码到解码的完整链路。目前已有1061人学习下载,适合用作课程设计撰写与代码实现的对照材料。
1. 哈夫曼压缩:为什么压缩率能到 33% 却也可能超过 100%
很多人第一次看到压缩比数据都会觉得奇怪:同一个程序压缩 txt 能压到 33%,压 docx 却变成 106%,压 jpg 是 117%。压缩率还有超过 100% 的?这不是压缩失败了吗?其实这恰恰是哈夫曼编码最诚实的地方——它压的是「符号出现频率的不均匀性」,而不是「文件」本身。一份纯文本里空格、字母 e、标点反复出现,频率极度倾斜,哈夫曼树就给它短码,压得狠;而 jpg、docx 内部已经做过熵编码,字节分布接近均匀,哈夫曼再压一遍只能给每个字节接近 8 位,还要额外塞编码表和填充损耗,于是「负压缩」。搞懂这个分界线在哪、为什么划在那,你就真正理解了哈夫曼编码的底层机制,也理解了为什么它是数据结构课里最值得动手实现一遍的算法之一:贪心、最优前缀码、位级 IO、树结构序列化,四件事一次性串起来。
2. 两遍扫描:为什么压缩必须把文件读两次
2.1 第一遍扫描统计频率,为什么不能边读边编码
任何变长前缀码都有一个绕不开的先后顺序依赖:你必须先知道全局频率,才能算出每个符号该分配多长的码字。所以哈夫曼压缩的程序骨架一定是两遍扫描——第一遍只读不写,统计 0~255 每个字节出现的次数;第二遍才根据频率表建树、生成编码、重新读文件写入。有人会想省掉一遍,改成边读边估算频率,那样得到的码表不是最优的,压缩率会明显变差,而且解压端还得用同样的估算逻辑重算,复杂度不降反升。
第一遍扫描还有一个容易被忽略的细节:文件长度。频率要用「出现次数 / 总字节数」表示,源码里先把长度记下来,遍历完再统一除。这里有个真实的坑——如果直接对每个字节做count/total的浮点除法,没问题,但如果用整数除法就会全部变成 0,整个树建不起来。
// 第一遍扫描:只统计,不建树 int init(FILE *&fp, HuffmanTree HT) { int i, j = 1, n; double total = 0; unsigned char ch; fseek(fp, 0L, SEEK_SET); // 回到文件头,保证统计完整 fseek(fp, 0L, SEEK_CUR); // 切换读写指针状态(读写混用时必要) while (1) { fread(&ch, 1, 1, fp); // 每次读一个字节,天然覆盖 0~255 if (feof(fp)) break; // 读到文件末尾退出 charnum[ch]++; // charnum 是长度 257 的全局计数数组 total++; // 累计总字节数 } for (i = 0; i <= 256; i++) { if (charnum[i] == 0) continue; // 没出现过的符号不进树 HT[j].lchild = 0; HT[j].parent = 0; HT[j].rchild = 0; HT[j].weight = charnum[i] / total; // 频率 = 次数 / 总长度 HT[j].ch = (unsigned char)i; j++; } n = j - 1; // 实际出现的符号种类数 return n; }charnum[257]这个数组大小不是随便取的:0~255 是全部字节取值,多开一格是为了防止unsigned char在某些平台上被提升成 int 后越界访问。total用 double 存,是因为后面的weight就是 double,整数除法会直接把所有频率抹成 0,后面建树时所有权值相等,树退化成链,编码长度失控。返回值n是「实际出现的符号种类数」,这个数必须写进压缩文件头,否则解压端不知道该重建多少个叶子。
2.2 第二遍扫描:编码表怎么建、码字往哪存
第二遍扫描做两件事:查表把每个字节翻译成 01 串,再把 01 串按位拼进输出缓冲。查表的关键是Huffmancode结构——每个符号对应一个char *code字符串。生成方式是自底向上:从叶子出发沿parent指针爬到根,往左走记 '0',往右走记 '1',因为是从下往上爬,得到的串是反的,所以要从临时缓冲的尾部往前填。
void HuffmanCoding(HuffmanTree HT, Huffmancode HC, int n) { int i; char *lcode = (char *)malloc(n * sizeof(char)); // 最长码不会超过 n-1 lcode[n - 1] = '\0'; for (i = 1; i <= n; i++) { int c = i; // 当前节点 int f = HT[i].parent; // 父节点 int start = n - 1; while (f != 0) { // 一直爬到根(根的 parent 为 0) if (HT[f].lchild == c) lcode[--start] = '0'; // 是左孩子,记 0,向前填 else lcode[--start] = '1'; // 是右孩子,记 1 c = f; f = HT[c].parent; } HC[i].ch = HT[i].ch; HC[i].code = (char *)malloc((n - start) * sizeof(char)); strcpy(HC[i].code, &lcode[start]); // 只拷贝有效部分 } free(lcode); }start从n-1往前递减,是因为树高最多 n-1,倒着填天然避免反转字符串。malloc(n - start)精确分配码字长度,多一个字节给\0,这里n - start已经包含了这一位,所以strcpy拷进去正好。别小看这个精确分配,如果统一按n分配,几百个符号就是几百份冗余,虽然能跑,但课程设计报告里「数据结构描述」这一项扣分往往就扣在这种地方。
3. 哈夫曼树:贪心为什么能得到最优前缀码
3.1 建树过程:每次挑两个最小的,凭什么最优
建树的核心循环是「从当前森林里挑两个权值最小的根,合并成新根,新根权值为两者之和」。这条贪心规则之所以能得到最优前缀码,本质是交换论证:假设最优树里权值最小的两个叶子不在最深层,把它们和最深层的一对兄弟交换,总代价(加权路径长度)不会变大,因为最小权值放得越深,乘的路径长度越小,总和越小。反复交换就得到一棵「最小两个叶子是最深兄弟」的树,于是可以先把它们合并当作一个整体子问题,递归下去贪心成立。
落到代码上,难点不在理解而在「挑最小」的实现。源码用的是线性扫描:每次select调两次min,min从头扫到尾找 parent 为 0 且权值最小的节点。
// 在 1..i 中找 parent==0 且权值最小的节点索引 int min(HuffmanTree HT, int i) { int min, j = 1; double minw; while (HT[j].parent != 0) j++; // 先找到一个候选起点 min = j; minw = HT[j].weight; for (; j <= i; j++) { if (HT[j].weight < minw && HT[j].parent == 0) { min = j; minw = HT[j].weight; } } HT[min].parent = 1; // 标记已选,防止第二次 select 重复选中 return min; } void select(HuffmanTree HT, int i, int &min1, int &min2) { min1 = min(HT, i); // 第一次选最小,同时打标记 min2 = min(HT, i); // 第二次选次小,自动跳过已标记 }注意HT[min].parent = 1这一行——它不是在建树,而是在打「已被挑选」的临时标记,用一个非零的假 parent 把这个节点从下一次挑选里排除掉。等真正合并时,creattree里的HT[min1].parent = i会把它覆盖成真父节点。这个技巧很常见,但要小心:如果临时标记写成 1,而节点 1 恰好是一个真实存在的叶子,while (HT[j].parent != 0)这种循环就可能出问题。更稳妥的写法是用一个独立 visited 数组,或者用一个大于所有真实索引的哨兵值。课程设计里这种「能跑但脆」的代码非常典型,报告里能指出这一点就是加分项。
建树本身很短:
void creattree(HuffmanTree &HT, int n) { int i, min1, min2; int m = 2 * n - 1; // n 个叶子的哈夫曼树共 2n-1 个节点 for (i = n + 1; i <= m; i++) { // 初始化内部节点 HT[i].parent = HT[i].lchild = HT[i].rchild = 0; HT[i].weight = 0; } for (i = n + 1; i <= m; i++) { select(HT, i - 1, min1, min2); // 从前 i-1 个节点中挑两个最小 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; } }2n-1这个节点总数来自二叉树的边数公式:n 个叶子、n-1 个内部节点。数组开2*N-1,N 取 257,最大正好 513。循环条件i <= i-1里那个i-1是关键——新生成的内部节点还没参与挑选,只在前i-1个已存在的节点里选,这个边界写错就会挑到权值为 0 的空槽,树结构直接崩。
时间复杂度上,线性扫描选最小的代价是 O(n²),n 最大 257,实际可忽略。但如果把符号扩展到更多(比如按字节对编码),就该换成小顶堆,每次取堆顶 O(log n),总建树 O(n log n)。常见实现思路是维护一个优先队列,把频率当 key,这正是哈夫曼编码在工业级压缩器里被采用时的标准做法。
3.2 编码的唯一性与前缀性质
哈夫曼码是「前缀码」:没有任何一个码字是另一个码字的前缀。这个性质直接决定了压缩文件能不解码表边界地顺序解压——从根往下走,一碰到叶子就确定一个符号,不必回溯。它的代价是不能像定长编码那样按固定位数切分,所以压缩时必须知道「结尾有多少位是补的零」。
源码里的处理是:最后一个不满 16 位的块,补零凑满,然后把补的零的个数(16 - flag)用 4 个字节写在文件末尾,解压时先fseek(fp1, -4, SEEK_END)读这个数,再回退到开头。这是哈夫曼实现里最容易翻车的一处边界,下面单独说。
4. 位级 IO:short int 打包和尾部填充怎么不出错
4.1 为什么用 short int 中转,15 位转 2 字节到底压了多少
C 的fwrite最小单位是字节,而哈夫曼码是逐位产生的,所以必须自己攒够位再刷。源码的做法是:用一个int sixteen[200]数组顺序存 0/1,攒够 16 位就按sum += pow(2.0, 15-j) * sixteen[j]算成一个整数,存进short int写出去。报告原文写的「15 个 0/1 转化为一个数据存入 short int,将 15 字节转化为 2 字节」其实表述有误:是把 16 个二进制位打包进一个 2 字节的 short,即 16 bit → 16 bit,压缩来自码字本身短,而不是这一层打包。
这里真正压的是这个:一个原文字节 8 位,经过哈夫曼编码后平均可能是 4.5 位,所以整体体积下降。打包这一步只是把「位流」变成「字节流」,不做额外压缩。理解这一点很重要,否则会误以为「15 字节变 2 字节」是主要压缩来源——那是把 15 个 0/1 字符(在内存里是一个字节存一个)压成 short,属于内存表示优化,不是算法压缩。
// 攒够 16 位就打包写出 if (flag < 16) { for (j = 0; j < len; j++) sixteen[k++] = HC[i].code[j] - '0'; } else { for (j = 0; j < len; j++) sixteen[k++] = HC[i].code[j] - '0'; for (j = 0; j < 16; j++) sum += pow(2.0, 15 - j) * sixteen[j]; // 高位在左 // 更新缓冲,把剩下的位移到前面 for (j = 0; j < 16; j++) sixteen[j] = 0; for (j = 16; j < k; j++) sixteen[j - 16] = sixteen[j]; flag = k - 16; k = flag; *fp = sum; fwrite(fp, sizeof(short int), 1, fp2); // 一次写 2 字节 }pow(2.0, 15 - j)的指数从 15 递减到 0,说明第 0 位是最高位,这和后面解压端(1 << 15) & num[i]再左移是严格对应的,两边错一位就全乱。用short int而不是int是为了让文件里每个打包块正好 2 字节,sizeof(short int)在主流平台上稳定为 2,但严格说 C 标准只保证至少 16 位,跨平台可移植性要靠<stdint.h>的uint16_t,这是这段代码的一个设计瑕疵,报告里可以主动提。
4.2 尾部补零:解压端怎么知道哪里该停
压缩的最后一块如果不满 16 位,后面补 0 凑满再写。补了几个 0 必须记下来,否则解压时会把填充的 0 当成有效编码继续走树,轻则解出多余字符,重则走到空节点。源码的做法是把16 - flag写成 4 字节追加在文件最后,解压时先定位到末尾读它。
解压的整体流程反过来:读文件头的符号种类数,逐个读 (char, double) 重建频率,再建同一棵树(哈夫曼树对同一组权值是确定的,前提是挑最小的规则一致),然后开始逐位解码。
// 解压:从压缩文件末尾读出填充位数 int dy; fseek(fp1, -4, SEEK_END); fread(&dy, 4, 1, fp1); // dy = 补零个数 rewind(fp1); // 重建树所需的数据块长度 = 总长 - 4(填充数) - 4(符号种类数) long Lsize2; fseek(fp1, 0, SEEK_END); Lsize2 = ftell(fp1); rewind(fp1); fread(&typen, sizeof(int), 1, fp1); Lsize2 -= 8; for (i = 1; i <= typen; i++) { fread(&(HT[i].ch), sizeof(unsigned char), 1, fp1); fread(&(HT[i].weight), sizeof(double), 1, fp1); HT[i].parent = HT[i].lchild = HT[i].rchild = 0; Lsize2 -= 9; // 每个符号占 1 + 8 = 9 字节 } Lsize2 /= 2; // 剩余字节数 / 2 = short 个数解码循环里while (i < k - dy)的k - dy就是有效位边界,dy是补零数。这个减法看着简单,但它是整个解压正确性的最后一道闸:直接写while (i < k)会在结尾多吐出几个错字符,而且只有文件长度不是 16 的整数倍时才暴露,测试时很容易漏。
5. 避坑:这类实现最容易翻车的五个地方
现象一:解压出来的文件末尾多几个乱码字符。原因:填充位数dy算错,或者解压循环没减掉它。源码里dy = 16 - flag,如果最后一块flag恰好为 0(正好 16 位装满),dy会算成 16,但实际没补零,多减了 16 位有效数据。解决:用dy = (16 - flag) % 16,或者判断flag == 0时不写填充字段。
现象二:压缩后体积几乎没变,甚至更大。原因:压缩文件头固定要存「符号种类数(4 字节)+ 每符号 1 字节字符 + 8 字节 double 频率」,满 256 种符号就是 4 + 256×9 = 2308 字节的固定开销。压缩小文件(几百字节)时,这个头比正文还大。解决:把权重从 double 改成整型计数(4 字节即可),或者对编码表本身再做一次简单编码;文件太小时直接放弃压缩走存储模式。
现象三:频率全是 0,建树时死循环或树退化成链。原因:charnum[i] / total用了整数除法,total声明成 int,哈希后全是 0。源码里total是 double,但charnum若是 int 数组,charnum[i]能强转,问题不大;更隐蔽的是忘记把charnum清零(全局变量默认清零,但若是局部数组就必须 memset)。解决:权重统一用 double 或 long,除法前确认 total 非零。
现象四:压缩再解压,中文或二进制文件内容对不上。原因:unsigned char在fgetc返回值里可能是 EOF(-1) 和 0xFF 混淆。fgetc返回 int,直接赋给unsigned char会截断,用while (!feof(fp1))做循环条件还会多读一次(feof 要读完才置位)。解决:循环条件用fread的返回值判断,while (fread(&ch,1,1,fp) == 1),彻底规避 EOF 歧义。
现象五:跨平台或换编译器后,压缩出来的文件自己都解不开。原因:double的二进制表示、字节序、short宽度都可能不同,而文件格式直接fwrite了内存布局。解决:文件头写明魔数和版本,数值用固定宽度整数(uint32_t)并统一小端序手动序列化,不要直接把结构体内存写文件。
6. 设计取舍:这份课设实现真正值得带走的东西
站在「能跑」和「正确」之间,这份实现做了不少典型取舍,而这些取舍恰恰是理解哈夫曼工程化的入口。
第一是选最小节点的实现。线性扫描 O(n²) 写法简单、可读性强,在 n≤257 时性能毫无压力;但一旦把符号从「单字节」扩展到「字节对」或「词典序」,n 会到几万,就必须换小顶堆。这里的分界线是「符号表规模」,不是数据量大小——这是很多人第一次做 LZ 系压缩时最容易误判的地方。
第二是文件格式的健壮性。当前格式是「符号种类数 + 逐个 (char, double) + 位流 + 填充位数」,紧凑但不自描述:没有魔数、没有版本、依赖宿主平台的 double 表示。工业级格式(比如 DEFLATE 的块头)都会带校验和与块边界信息,目的是单点损坏不扩散。课程设计里能意识到这一点,把权重降精度、加个魔数,就比只会跑示例文件高一个层次。
第三是压缩收益的边界。源码的测试数据很说明问题:txt 33%、exe 62%、docx 106%、jpg 117%。决定压缩率的从来不是算法多聪明,而是输入的熵——符号分布越倾斜,哈夫曼的短码收益越大;熵已经接近 8 位的文件,再套一层只会被编码表的固定开销拖累。所以任何压缩器都有「不适合压缩」的输入类型,这不是缺陷,是信息论的下界在起作用。
如果要把这份实现继续往前推,我会建议三个方向:用真正的位缓冲区(一个uint32_t累加,满 8 位刷一字节)替换当前的 short 打包,顺手解决跨平台;把编码表存成「规范化哈夫曼码长序列」,解压端用码长重建树,能把头部开销压到符号数十几个字节;再就是给压缩前加一个「抽样估计熵」的开关,熵太高就直接存储模式,避免负压缩。
这几个方向我当年做类似课设时是一个都没做到——写完能跑就交,后来真正踩过跨平台 double 不一致、feof 多读一字节、填充位数算错这三个坑,才回头明白这份「简陋」实现里每个看起来随意的决定,背后都对应一个真实的工程约束。从那以后我每次碰位级 IO 的文件格式,都强制先把「头部字段、位流边界、尾部填充」三件事在白纸上画清楚再写代码。希望这份拆解能帮你少走一遍我走过的弯路,也建议你拿到源码后,先别急着跑通示例,而是拿一个小文件手动推演一遍位流,那个过程比读十遍算法描述都管用。
本文还有配套的精品资源,点击获取