☰
Huffman编码文件压缩:两遍扫描、位流打包与压缩比解析
2026/10/28 3:26:23 网站建设 项目流程

简介:这份数据结构课程设计文档围绕基于Huffman编码的文件压缩与解压缩展开,面向正在完成课程设计、准备验收或复习最优二叉树应用的学生。内容以C语言压缩程序和解压程序为主线,覆盖字符频率统计、Huffman树构建、编码表生成、压缩文件存储、按位解码还原、命令行参数处理及文件大小与压缩比输出,并给出结构体定义、主要函数说明、详细算法设计、程序测试报告、结论和源程序附录,可帮助读者对照梳理从统计、建树到编码与解压的完整流程,也可作为课程报告撰写与代码实现的参考。压缩包内共1个docx文件,大小约1.41MB,章节完整,查阅和整理都较方便。已有1061人学习下载,适合借鉴报告组织方式、模块划分、函数设计与测试思路,深入理解Huffman编码在文件压缩场景中的实现要点。

1. 从一份课程设计报告说起:Huffman 编码文件压缩到底能压什么

带压缩功能的工具用多了,容易默认“压缩”就等于变小。把这份数据结构课程设计里的 Huffman 编码文件压缩与解压缩程序跑起来,结论会反着来:同样一套代码,txt 能压到 33.02%,JPG 却涨到 117.76%。它适合正在做课程设计验收、想弄懂 C 语言按位读写、准备拿 Huffman 树做二次开发的人;不适合期待“一键压缩所有格式”的人。报告里最有价值的不是压缩比数字,而是两遍扫描、建树、编码表落盘、short 打包这一段完整链路。

2. 目录与数据结构拆解:报告附录里的 HTNode、Huffmancode 和 2*N-1

2.1 报告目录和源码附录怎么对上

这份资源的组织方式很典型:前半部分是课程设计报告,后半部分是源程序附录。报告目录按“课程设计内容与要求、总体设计、详细数据结构设计、详细算法设计、程序测试报告、结论、源程序附录”展开,评分点也写得直白:运行验收 4 分、T(n) 分析 2 分、代码 2 分、测试 2 分、数据结构描述 2 分、算法描述 2 分、代码注释 2 分、排版 2 分。拿这份资源复现时,不要一上来就从附录第一行读到最后一行,先按评分点倒推需要看什么。

报告位置对应源码/产出复现时先看什么
总体设计压缩、解压主流程文件怎么打开,菜单怎么分流
详细数据结构HTNode、HuffmanTree、Huffmancode数组下标从 1 开始,0 号位不用
详细算法设计建树、选最小节点、编码select/min和HuffmanCoding
程序测试报告压缩比表txt、docx、JPG 的差异
源程序附录init/zip/unzip位流打包和尾部补零

实际跑的时候,先准备一个几十字节的小文本,比如只有aaBBaaaaccBBB这类重复字符的内容。菜单选压缩,输入源文件名和目标文件名;再选解压,把刚生成的文件还原成另一个名字。小文件跑通,再换几千字节的源码文件,最后才拿图片和 PDF 做对照。这个顺序能快速定位问题:小文件就错,多半是结构体初始化或位打包边界;只有大文件错,先看内存和fread/fwrite返回值。

报告里的“总体设计”有一个容易忽略的点:压缩程序和解压程序都要求命令行参数输入源文件名、目标文件名。附录源码又给了一个菜单式main,把压缩、解压、退出做成循环选择。这两种入口不冲突,验收时用菜单更方便演示,工程化时把zip、unzip抽成两个命令行子命令更合适。读代码时盯住zip(char *file1, char *file2)和unzip(char *file1, char *file2, HuffmanTree HT)这两个函数,其他都是外围。

2.2 HTNode 和 HuffmanTree 的数组下标

核心结构体在报告里写得很短,但每个字段都决定后面的位流格式:

#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; // 形如 "101" 的编码串,动态分配 } Huffmancode[257]; double charnum[N + 1]; // 全局字符计数,init 里累加

N取 257,不是 256。原因很实际:字节符号是 0 到 255,但 Huffman 树数组习惯从下标 1 开始存叶子,0 号位空出来当哨兵,2*N-1给的是2*257-1=513个节点空间,足够容纳 256 个叶子和 255 个内部节点。HuffmanTree是数组类型,传参时会退化成指针,函数里仍然按HT[1]、HT[n]访问。ch用unsigned char,避免 128 以上的字节被当成负数。

weight在报告里用double存频率,写压缩文件时也按 8 字节double写。这个选择对课程设计很省事:init里直接算charnum[i]/nnum,建树时比较浮点数;解压时把double原样读回来,重建树不需要额外换算。代价是每个符号的表头要占 9 字节,256 种符号最多 2304 字节,小文件会被表头拖累。工程化时更常见的是存整数频次,甚至只存码长和码字;课程设计阶段保留double没问题,但要知道压缩比为什么会被拉高。

全局charnum[N+1]是一个隐藏坑。它在init里累加,如果同一个进程连续压缩两个文件而没有清零,第二次统计会把第一次的计数带进来,字符种类数和权重全错。常见做法是在init开头memset(charnum, 0, sizeof(charnum));,或者把计数数组改成局部变量再传参。报告附录的原始版本没有显式清零,单次菜单操作不容易暴露,连续压缩两次就会翻车。

2.3 Huffmancode 编码表和全局统计变量

Huffmancode存的是“字符到 01 串”的映射。它不存码长,靠strlen(HC[i].code)现算;code是malloc出来的,最后要释放。报告里的编码函数从叶子往根回溯,遇到左孩子补0,右孩子补1,因为是从后往前填,所以用start指针从缓冲区尾部向前移动。

typedef struct { unsigned char ch; char *code; } Huffmancode[257]; /* 典型调用关系 */ HuffmanTree HT; Huffmancode HC; int n = init(fp1, HT); // 统计符号种类和频率 creattree(HT, n); // 建 Huffman 树 HuffmanCoding(HT, HC, n);// 生成每个叶子的 01 编码

这里要区分两份“表”:内存里的HC是压缩时查表用的,压缩文件里并不直接写HC[i].code,而是写HT[i].ch和HT[i].weight。解压端读回字符和权重,再调用同一个creattree重建树,然后按位走树。这样做的好处是表头紧凑,缺点是压缩端和解压端必须使用完全一致的建树规则;一旦select的选最小节点顺序变了,相同权重会生成不同形态的 Huffman 树,解压就会乱码。

Huffmancode数组开到 257,和HTNode的 257 个叶子位置对齐。编码表的下标1..n与HT[1..n]一一对应,HC[i].ch = HT[i].ch。如果文件只有一种字节,n=1,建树循环不执行,编码函数会生成空串,压缩阶段写不出任何有效位,解压阶段也无法靠空串还原字符。这个边界在课程设计测试里经常被漏掉,后面排查章节会单独讲。

3. 压缩主链路:两遍扫描、建树和编码表如何落到 C 代码

3.1 init 统计频率:第一遍扫描文件

第一遍扫描只做一件事:按字节读文件,统计每个字节出现次数,再把出现过的字节填进HT[1..n]。报告里的要求是“符号种类数以及各符号出现概率”,概率用出现次数/文件字节长度。下面这段在原代码基础上补了清零和空文件保护,逻辑不变:

int init(FILE *fp, HuffmanTree HT) { unsigned char ch; double total = 0; int n = 0; memset(charnum, 0, sizeof(charnum)); // 连续压缩时必须清 rewind(fp); while (fread(&ch, 1, 1, fp) == 1) { charnum[ch]++; total++; } for (int i = 0; i <= 255; i++) { if (charnum[i] == 0) continue; n++; HT[n].ch = (unsigned char)i; HT[n].weight = charnum[i] / total; // 空文件不会进循环 HT[n].parent = 0; HT[n].lchild = 0; HT[n].rchild = 0; } return n; }

参数fp是以rb打开的源文件指针,函数内部先rewind回到开头,再逐字节读。charnum是全局计数数组,下标就是字节值 0 到 255;total是文件总字节数,用来把计数转成频率。返回值n是实际出现的符号种类数,后续建树只处理HT[1..n]。

原报告附录用while(1){ fread(&ch,1,1,fp); if(feof(fp)) break; ... },这种写法在读到文件尾时会把最后一次fread的结果也带进循环,常见表现是多统计一个字节。用fread(...) == 1做循环条件更稳。另一个细节是空文件:total=0,循环不进入,返回n=0,建树和编码都不能继续。课程设计可以打印“空文件不压缩”并退出,工程代码则写一个只含文件头的空压缩文件。

3.2 creattree/select/min:建树时怎么选两个最小节点

建树是 Huffman 的核心。报告里的creattree先初始化n+1到2n-1的内部节点,然后反复调用select找parent==0且权值最小的两个节点,把它们的parent指向新节点,新节点权值为两者之和。原附录的min函数找到最小节点后会把parent临时置 1,再让第二次min跳过它;这种写法能跑,但可读性差,也容易在改造时踩坑。下面给一个等价但更直白的版本:

void select_two(HuffmanTree HT, int range, int *min1, int *min2) { int first = -1, second = -1; for (int i = 1; i <= range; i++) { if (HT[i].parent != 0) continue; // 已在树上的不再选 if (first == -1 || HT[i].weight < HT[first].weight) { second = first; first = i; } else if (second == -1 || HT[i].weight < HT[second].weight) { second = i; } } *min1 = first; *min2 = second; } void creattree(HuffmanTree HT, int n) { if (n <= 1) return; // 单字符/空文件特殊处理 int m = 2 * n - 1; for (int i = n + 1; i <= m; i++) { HT[i].parent = 0; HT[i].lchild = 0; HT[i].rchild = 0; HT[i].weight = 0; } for (int i = n + 1; i <= m; i++) { int min1, min2; select_two(HT, i - 1, &min1, &min2); 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_two的range是当前已存在的节点范围。第一轮是n,第二轮是n+1,一直到2n-2。first和second记录两个最小下标,遇到更小的就替换。creattree里m=2*n-1,新节点从n+1开始,最后HT[m]是根。左右孩子的顺序会影响编码串,但不影响前缀码的正确性;压缩端和解压端只要用同一个函数、同一份权重表,树形就一致。

复杂度是O(n^2),因为每建一个内部节点都线性扫描一次。n最大 256,实际建树最多 255 轮,扫描范围也不到 513,课程设计完全够用。报告评分里有 T(n) 分析,可以写:init扫描文件O(L),creattree为O(n^2),HuffmanCoding为O(n * 平均码长),压缩主循环按总位数计O(B),解压按位走树O(B);总时间由文件字节数和总编码位数主导,建树部分在有 256 种符号上限时可以视作常数级。这个分析比空写“时间复杂度 O(n)”更容易过验收。

3.3 HuffmanCoding:从叶子回溯生成 01 串

编码函数从每个叶子往上找父节点,直到根。左孩子记0,右孩子记1。因为是从叶子往根走,得到的是反序编码,所以用buf从后往前填,最后strcpy到HC[i].code:

void HuffmanCoding(HuffmanTree HT, Huffmancode HC, int n) { char *buf = (char *)malloc(n * sizeof(char)); if (!buf) exit(1); for (int i = 1; i <= n; i++) { int start = n - 1; buf[start] = '\0'; int c = i; int f = HT[i].parent; while (f != 0) { if (HT[f].lchild == c) buf[--start] = '0'; else buf[--start] = '1'; c = f; f = HT[c].parent; } HC[i].ch = HT[i].ch; HC[i].code = (char *)malloc(n - start); strcpy(HC[i].code, &buf[start]); } free(buf); }

buf的长度取n,因为 256 个叶子的 Huffman 树最深可能到 255 层,加上结尾\0正好需要n个字节。start从n-1开始,每走一层向前移动一格,最后&buf[start]就是正序编码串。HC[i].code按n-start分配,拷完由调用方负责释放。

这里要特别注意n=1:start=n-1=0,buf[0]='\0',while不进入,HC[1].code是空串。压缩时每个字符写 0 位,压缩文件里没有可解码的位流,解压端无法知道原文件有多少个字符。报告里的测试数据没有覆盖单字符文件,验收时如果老师拿一个只有aaaa的文件来试,就会暴露。常见补法是:压缩文件头额外写原文件字节数,单字符文件直接写“符号 + 个数”,或者给单字符约定一个固定码0并在文件头保存长度。课程设计阶段至少要在zip入口判断n<=1并给出提示,不要让它静默生成坏文件。

3.4 zip 主链路:先写表,再写位流

zip的骨架分三段:打开文件、统计并建树编码、写压缩文件。报告附录的顺序是先用init得到n,再creattree、HuffmanCoding,然后rewind(fp1)第二遍扫描源文件,边查编码表边写目标文件。压缩文件开头先写n,再写每个叶子的ch和weight,后面才是压缩位流。这个顺序决定了unzip的读取方式:先读n,再读n组表项,重建树,最后读位流。

fp1 = fopen(file1, "rb"); fp2 = fopen(file2, "wb"); if (!fp1 || !fp2) { perror("fopen"); exit(1); } int n = init(fp1, HT); creattree(HT, n); HuffmanCoding(HT, HC, n); fwrite(&n, sizeof(int), 1, fp2); // 符号种类数 for (int i = 1; i <= n; i++) { fwrite(&HT[i].ch, sizeof(unsigned char), 1, fp2); fwrite(&HT[i].weight, sizeof(double), 1, fp2); } rewind(fp1); // 第二遍扫描,准备写位流

file1是源文件,file2是压缩目标文件,二者都以二进制模式打开。n用int写 4 字节,ch写 1 字节,weight写 8 字节,所以表头大小是4 + 9n。第二遍扫描前必须rewind(fp1),否则文件指针在末尾,读不到任何字节。写表时不要写parent/lchild/rchild,这些是内存里的建树结果,解压端会用权重重新算出来;写进去反而让格式变复杂。

压缩位流的逐位写入放在下一章展开。这里先记住一个原则:编码表只负责“字符到 01 串”的映射,真正落盘的是连续的位。每读一个源字节,就在HC里找到对应编码,把每一位追加到 16 位缓冲区;缓冲区满 16 位就写一个short。文件末尾不足 16 位时补零,并记录补了几个零。这个“补零位数”是解压端截掉尾部垃圾位的唯一依据,写错就会在还原文件末尾多出几个字节或少几个字节。

4. 位流与压缩文件格式:short 打包、补零位数和压缩比验收

4.1 压缩文件的三段结构

把报告里的写入顺序整理成格式表,复现时按这张表核对偏移,比盲读代码快得多:

字段类型大小说明
symbol_countint4 字节实际出现的字节种类数n
symbolsunsigned char + double9n字节每个符号的字符和频率权重
bit_streamunsigned short[]2k字节压缩位流,高位在前
pad_bitsint4 字节末尾补零位数,取值 0 到 15

总长度是4 + 9n + 2k + 4。解压端先fseek(fp1, -4, SEEK_END)读pad_bits,再rewind读symbol_count,然后读n组表项。剩下的字节减去表头和尾部 4 字节,再除以 2,就是short的个数。报告附录里的Lsize2就是按这个思路算的:先减 8(开头的n4 字节 + 末尾的dy4 字节),每读一组表项再减 9,最后除以 2。

提示:格式表里的pad_bits是补零位数,不是补零字节数。取值只应在 0 到 15 之间。如果读出来是 16 或负数,先查写端dy的计算,不要继续解压。

4.2 short 打包:每 16 位写一次,末尾补零

报告正文提过“将 15 个 0/1 转化为一个数据存入 short int”,附录代码实际按 16 位处理,并用short int的 2 字节写文件。这里不纠结 15 还是 16,直接按附录的 16 位实现讲,因为文件格式、dy和读端移位都是按 16 位配套的。下面用unsigned short承接这 16 位,写文件仍是 2 字节,避免有符号左移带来的符号位干扰:

static void bw_put_bit(FILE *out, int bit, unsigned short *acc, int *cnt) { *acc = (unsigned short)((*acc << 1) | (bit & 1)); (*cnt)++; if (*cnt == 16) { fwrite(acc, sizeof(unsigned short), 1, out); *acc = 0; *cnt = 0; } } /* 压缩主循环里的位写入 */ unsigned short acc = 0; int cnt = 0; int c; while ((c = fgetc(fp1)) != EOF) { int idx = map[(unsigned char)c]; // map[字节] = HC 下标 const char *code = HC[idx].code; for (int k = 0; code[k] != '\0'; k++) { bw_put_bit(fp2, code[k] - '0', &acc, &cnt); } } int dy; if (cnt > 0) { acc = (unsigned short)(acc << (16 - cnt)); // 低位补 0 fwrite(&acc, sizeof(unsigned short), 1, fp2); dy = 16 - cnt; } else { dy = 0; // 刚好满 16 位,没有补零 } fwrite(&dy, sizeof(int), 1, fp2);

bw_put_bit每次把新位放到acc的最低位,cnt累计到 16 就写一个unsigned short,然后清零。map是 256 项的查表数组,map[字节]直接给出HC下标,避免每读一个字节都循环1..n找编码。dy只在最后有余位时等于16-cnt;如果cnt==0,说明最后一个字节正好凑满 16 位,补零位数必须是 0。原附录写的是dy = 16 - flag,当flag==0时会得到 16,解压端会多丢 16 位,表现就是还原文件末尾少一截。这个边界在后面排查章节会展开。

4.3 解压:把 short 拆回二进制,再按树走

解压端不需要编码表,只需要权重表重建树。读表后creattree(HT, n),根节点是2n-1(n=1时是 1)。然后把压缩位流按unsigned short读进内存,逐位从高位到低位取,0 走左孩子,1 走右孩子,走到叶子就输出字符并回到根:

int n; fread(&n, sizeof(int), 1, fp1); for (int i = 1; i <= n; 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; } creattree(HT, n); int root = (n == 1) ? 1 : 2 * n - 1; long total = 0; fseek(fp1, 0, SEEK_END); total = ftell(fp1); long short_count = (total - 4 - 4 - 9L * n) / 2; unsigned short *words = (unsigned short *)malloc(short_count * sizeof(unsigned short)); fread(words, sizeof(unsigned short), short_count, fp1); long total_bits = short_count * 16 - dy; int node = root; for (long i = 0; i < total_bits; i++) { int bit = (words[i / 16] >> (15 - i % 16)) & 1; node = bit ? HT[node].rchild : HT[node].lchild; if (node == 0) { /* 损坏文件,报错退出 */ } if (HT[node].lchild == 0 && HT[node].rchild == 0) { fputc(HT[node].ch, fp2); node = root; } }

short_count由文件总长减去头部n、尾部dy和9n表项后除以 2 得到。total_bits再减去dy,把末尾补的零位排除掉。words[i/16]定位第几个short,15 - i%16定位这个short里的第几位,因为写的时候是高位先出。走到叶子就写一个字节,然后把node重置到根,继续读下一位。

这段代码有两个必须加的检查:short_count不能为负,fread(words, ...)的返回值要等于short_count;否则损坏文件会让malloc出巨大内存或读出越界。课程设计验收时文件都是自己压的,不容易坏;二次开发接手外部文件,必须把魔数、长度和校验加进头里。

4.4 测试表里的压缩比怎么读

报告给出的测试结果很值得保留:

文件类型压缩比读法
txt33.02%文本重复多,Huffman 编码平均码长短,明显变小
docx106.11%已是压缩容器,字节分布接近均匀,表头还增加开销
ppt101.94%同上,压缩后略大
pdf98.47%视内容而定,接近持平
JPG117.76%图像已压缩,字节频率均匀,Huffman 很难再压
mp499.99%基本持平
exe62.77%二进制里仍有重复模式,能压一部分

这里的压缩比按“压缩后大小 / 原文件大小 × 100%”理解。小于 100% 是变小,大于 100% 是变大。报告结论写“内容重复字符较多的文件,压缩比更小,压缩效果越好”,这句话是对的,但验收时要补一句:Huffman 按字节频率编码,遇到已经压缩过的格式,字节分布接近均匀,平均码长接近 8 位,加上4+9n的表头和末尾补位,变大是正常现象。拿 JPG 的 117.76% 去证明程序写错了,会冤枉代码。更合理的验收组合是:纯文本、C 源码、日志文件、全同字节文件、256 种字节都出现的小二进制文件。

5. 常见问题排查:尾部丢字节、压缩比 117% 和重建树不一致

5.1 尾部少字节:dy 在刚好满 16 位时被写成 16

现象:解压出来的文件和原文件前面一致,末尾少几个字节,或者最后一段变成乱码;压缩比看起来正常,unzip也不报错。

原因:写端最后一段位流刚好凑满 16 位时,cnt==0,原代码用dy=16-flag得到 16。解压端按total_bits = short_count*16 - dy计算,会多减 16 位,正好把最后一组有效数据丢掉。另一个变体是最后不足 16 位时没有写残余short,只写了dy,解压端同样少数据。

解决:把dy的语义固定成“末尾补零位数”,只在cnt>0时写残余并令dy=16-cnt,否则dy=0。解压端用total_bits = short_count*16 - dy,并且断言0 <= dy <= 15。测试时专门造一个编码后总位数刚好是 16 的倍数的文件,比如反复调整小文本内容,直到压缩位流长度是 2 字节的整数倍,再解压比对cmp。

5.2 压缩比大于 100%:不是算法失效,是输入不适合

现象:docx、JPG 压缩后变成 106%、117%,文件反而更大;换 txt 又能压到 33%。

原因:Huffman 按字节出现频率分配变长码。已经压缩过的格式,字节分布接近均匀,平均码长接近 8 位,压缩空间很小;压缩文件还要写4+9n的表头和末尾补位,小文件尤其明显。n越大,表头越重,256 种符号时表头固定 2308 字节。

解决:验收时按文件类型选样本,不要拿 JPG 当主要指标;在程序里打印“原大小、压缩后大小、压缩比、符号种类数”,让结果可解释。工程化时可以在压缩前按扩展名或采样估算,对已经压缩的格式直接跳过,或者加一个“压缩后更大就保留原文件”的策略。课程设计报告里把 JPG 117.76% 写进测试表不是扣分点,能解释清楚反而是加分点。

5.3 空文件和单字符文件:建树循环不进入,编码为空

现象:压缩一个 0 字节文件直接崩溃;压缩只有aaaa的文件,生成的压缩文件解压后为空,或者解压程序死循环。

原因:init对空文件返回n=0,creattree的m=2n-1变成 -1,后续访问越界。单字符文件n=1,creattree直接返回,HuffmanCoding生成空串,压缩位

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

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

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

立即咨询