资讯详情

哈夫曼编译码器完整课设:从建树到编码译码的C语言实现

📅 2026/10/18 1:29:26 | 华诺云谱 👁 阅读
哈夫曼编译码器完整课设:从建树到编码译码的C语言实现
简介一份面向数据结构课程设计的哈夫曼编译码器完整报告文档适合计算机相关专业本科生的课设参考也适合需要理解哈夫曼树原理与C实现的读者。报告基于VC 6.0环境系统阐述哈夫曼编码原理、需求分析、总体设计与详细实现覆盖初始化、编码、译码、打印代码文件、打印哈夫曼树、退出程序六大功能模块。同时给出字符频率统计、最小堆建树、编码生成及译码还原的完整设计思路并配有函数列表与主要函数说明可帮助读者快速搭建同类课设框架。压缩包内共1个doc文档整体约2.76MB已有810人学习下载。文档包含华北科技学院课程设计说明书的完整目录结构从需求分析、总体设计到主要函数介绍层层展开适合对照写作或直接作为课程设计报告模板。1. 哈夫曼编译码器一个能跑通的完整课设不只是一棵二叉树数据结构课程设计里最常出现的题目之一就是哈夫曼编译码器标准玩法是读入一批字符和权值构造最优二叉树再做编码和译码。这份课程设计报告把整条链路做成了一个带菜单的完整工具初始化、编码、译码、打印编码、打印哈夫曼树表、退出六个功能全部落地连文件读写路径都提前规划好了不是那种只讲原理的代码片段。它适合作三类人的参考资料一是正在做哈夫曼课设、想知道完整工程怎么组织的同学二是复习数据结构、想对照代码理解最优二叉树原理的开发者三是想拿现成 C 语言小程序做二次开发、研究文件流和菜单架构的熟手。读懂它哈夫曼树从建树、编码到译码的完整闭环就基本通了。2. 总体设计六个功能模块加一张五域静态链表2.1 先看整体五个文件把数据流转串起来这个编译码器不是单机跑一遍、内存里算完就结束的程序所有中间结果都落盘到 D 盘根目录一共五个文件各管一段。报告里对文件职责写得比较清楚我整理成一张表。文件路径存放内容产生时机D:\ToBeTran.txt待编码的源字符串执行编码E时写入D:\Code.txt源字符串编码后的二进制串编码E时写入D:\Decode.txt译码还原出的原始文本译码D时写入D:\HuffmanTree.txt编译码表与哈夫曼树表初始化I与打印树表T时写入D:\CodePrint.txt每行 50 个字符的编码副本打印编码P时写入整个数据流可以描述成一条单向管道用户从终端输入字符串原样存到 ToBeTran.txt编码函数读这个文件逐字符查编译码表拼出 01 串写回 Code.txt译码再从 Code.txt 读编码按哈夫曼树还原成明文到 Decode.txt。HuffmanTree.txt 是中间契约文件编码和译码都依赖它重建出来编译码表。这种文件约定有一个好处每个功能都可以单独调试。比如编码结果不对不需要重启程序直接打开 Code.txt 看输出跟 HuffmanTree.txt 里的编译码表比对就能定位问题。课设阶段用文本文件而不是二进制文件牺牲一点空间换来的是肉眼可见的排障体验。2.2 节点结构五个域数组模拟链式报告里说哈夫曼树采用链式存储方便修改替换实际代码实现却是用数组下标模拟双亲和左右孩子指针相当于静态三叉链表。每个节点五个域ch 存字符weight 存权值parent、lchild、rchild 分别存双亲、左孩子、右孩子的数组下标。结构体定义大概是这样的typedef struct { char ch; /* 叶子节点存储的字符 */ unsigned int weight; /* 权值即使用频度 */ int parent; /* 双亲节点下标0 表示无双亲 */ int lchild; /* 左孩子下标 */ int rchild; /* 右孩子下标 */ } HTNode, *HuffmanTree; typedef char **HuffmanCode; /* 编译码表HC[i] 指向第 i 个字符的编码串 */这里有个容易被忽略的设计细节数组下标从 1 开始计数HT[0] 空着不用。因为 parent 字段用 0 表示“还没有被合并”如果下标也从 0 开始那就没法区分“下标为 0 的节点”和“没有双亲的节点”。叶子节点有 n 个合并过程中每次新增一个内部节点最终节点总数是 2n - 1所以树数组申请 (2n) 个元素就够了下标范围 1 到 2n-1。编译码表用二级指针实现本质是一个长度为 n1 的字符指针数组。每个叶子节点编码时从叶子向上回溯走到根节点为止路径上左边记 0、右边记 1最后把逆序结果复制到 HC[i] 里。这个二级指针结构在后续编码和译码时非常顺手编码时按下标取 HC[j] 拼接译码时拿 HC[j] 跟当前编码串前缀做匹配。2.3 菜单组织死循环加双写判断兼容大小写主函数没用什么黑科技一个 while 死循环包住菜单打印和功能分支用户每执行完一个功能自动跳回主菜单只有输入 q 或 Q 才退出。值得学习的是它兼容了大小写i/I、e/E、d/D、p/P、t/T、q/Q 都能识别避免用户开着大写锁直接翻车。int main() { char choice; while (1) { HCVision(); /* 打印操作菜单 */ choice getchar(); getchar(); /* 吸收回车符 */ switch (choice) { case I: case i: Init(); break; case E: case e: Encoding(); break; case D: case d: Decoding(); break; case P: case p: CodePrint(); break; case T: case t: HuffmanTreePrint(); break; case Q: case q: return 0; default: printf(输入错误请重新输入\n); } } return 0; }菜单处理除了主循环还有一个容易被忽略的点getchar() 读完功能字符后必须再调用一次 getchar() 把缓冲区的回车吃掉否则第 4 章的初始化函数一进来就撞上残留的换行符导致字符集录入错位。如果不用 C 的 getchar 而改用 scanf 读功能号同样要处理这个换行残留问题常见做法是 scanf(%c%*c) 用星号抑制符吃掉回车。从主循环的分支设计能看出报告作者对用户操作顺序的考虑每个功能不假设前置状态编码函数如果没有编译码表会主动报错并给出处理选项而不是直接崩溃。这种防御式写法从数据结构课设角度看已经合格后面第 5 章会专门展开这些坑。3. 核心模块拆解从建树到译码的完整链路3.1 初始化两轮扫描选最小n-1 次合并建树初始化是整个系统的地基主要做三件事录入字符和权值、调用建树函数构造哈夫曼树、把编译码表写入 HuffmanTree.txt。建树前要先实现一个 Select 函数它的任务是从当前所有还没有双亲的节点里选出权值最小的两个。我见过不少写法是单循环一次扫出两个最小值但那种写法在权值相等时容易出 bug这里用两轮扫描更稳。void Select(HuffmanTree HT, int n, int *s1, int *s2) { int i; unsigned int min1 0xFFFFFFFF, min2 0xFFFFFFFF; *s1 0; *s2 0; /* 第一轮找最小 */ for (i 1; i n; i) { if (HT[i].parent 0 HT[i].weight min1) { min1 HT[i].weight; *s1 i; } } /* 第二轮找次小排除 s1 本身 */ for (i 1; i n; i) { if (i ! *s1 HT[i].parent 0 HT[i].weight min2) { min2 HT[i].weight; *s2 i; } } }Select 函数的关键逻辑在两个循环的排除条件上。第一轮找最小值把下标存在 s1第二轮用 i ! *s1 确保不会把同一个节点选两次只用 HT[i].parent 0 筛选还未被合并的节点。这样设计后即使存在多个相同权值的叶子也能稳定选出两个不同下标的节点。参数 n 不是总节点数而是当前待选区域的边界——因为建树是动态的每合并一次有效节点数就加一新生成的内部节点也参与下一轮选择。有了 Select建树就顺理成章了void HuffmanCoding(HuffmanTree *HT, HuffmanCode *HC, int *w, int n) { int m 2 * n - 1; int i, s1, s2, start, c, f; char *cd; *HT (HuffmanTree)malloc((m 1) * sizeof(HTNode)); for (i 1; i n; i) { (*HT)[i].ch 0; (*HT)[i].weight w[i]; (*HT)[i].parent 0; (*HT)[i].lchild 0; (*HT)[i].rchild 0; } for (i n 1; i m; i) { (*HT)[i].ch 0; (*HT)[i].weight 0; (*HT)[i].parent 0; (*HT)[i].lchild 0; (*HT)[i].rchild 0; } /* 反复合并两个最小节点生成 n-1 个内部节点 */ for (i n 1; i m; i) { Select(*HT, i - 1, s1, s2); (*HT)[s1].parent i; (*HT)[s2].parent i; (*HT)[i].lchild s1; (*HT)[i].rchild s2; (*HT)[i].weight (*HT)[s1].weight (*HT)[s2].weight; } /* 从每个叶子回溯到根倒序拼出编码 */ *HC (HuffmanCode)malloc((n 1) * sizeof(char *)); cd (char *)malloc(n * sizeof(char)); cd[n - 1] \0; for (i 1; i n; i) { start n - 1; for (c i, f (*HT)[i].parent; f ! 0; c f, f (*HT)[f].parent) { if ((*HT)[f].lchild c) cd[--start] 0; else cd[--start] 1; } (*HC)[i] (char *)malloc((n - start) * sizeof(char)); strcpy((*HC)[i], cd[start]); } free(cd); }建树部分的核心是第二个循环。i 从 n1 走到 2n-1每次调用 Select 找出两个权值最小的节点把它们的 parent 指向新建的内部节点 i再把 i 的左右孩子分别指向 s1 和 s2权值相加。这个过程重复 n-1 次最终得到一棵带权路径长度最小的二叉树。编码部分是另一个容易看懵的细节。不从根向下走而是从叶子向上走cd 数组从末尾开始向前填充每向上一层在 start 位置写入 0 或 1回溯结束后从 start 到末尾就是该字符的完整编码。因为这个方向是从叶子到根路径上是反的所以必须倒着填最后复制时从 start 取到结尾即可。参数 w 是权值数组下标从 1 开始与 Init 函数中用户输入的字符顺序一一对应。3.2 编码逐字符查表拼 01 串未知字符给三个出路编码函数负责把待压缩的源文本变成 01 串。报告里的实现顺序是先把用户输入的字符串写到 ToBeTran.txt再读回来逐字符处理。这样即使程序中途崩溃源字符串也已经在磁盘上留了底不至于全部丢失。void Encoding() { FILE *fpIn, *fpOut, *fpCode; char str[1024]; int i, j; fpIn fopen(D:\\ToBeTran.txt, w); printf(请输入需要编码的字符串\n); gets(str); fprintf(fpIn, %s, str); fclose(fpIn); fpIn fopen(D:\\ToBeTran.txt, r); fpCode fopen(D:\\Code.txt, w); while ((i fgetc(fpIn)) ! EOF) { /* 在字符表里找到对应叶子下标 j */ for (j 1; j n; j) { if (HT[j].ch i) { fprintf(fpCode, %s, HC[j]); /* 查表拼编码 */ break; } } if (j n) { printf(错误编译码表中不存在字符 %c\n, i); /* 提供三个选项重新输入 / 返回主菜单 / 退出 */ } } fclose(fpIn); fclose(fpCode); }编码本身的逻辑不复杂读一个字符在 HT 数组的前 n 个叶子节点里线性查 ch 字段命中就把 HC[j] 拼到 Code.txt 尾部。这里要注意的是可变长编码的优势出现频率高的字符编码短比如 e 可能只有两位而 z 可能有五六位整体压缩率来自这种长短搭配。未知字符处理是这段代码最容易出错的地方。如果用户输入的字符串里有大写字母而初始化时编译码表只录入了小写字母和空格循环里的 for 会扫完 n 个叶子都找不到匹配。报告的解法是报错后给出三条路重新输入字符串、返回主菜单、直接退出程序。这个分支看似简单实际上把程序从“死板报错退出”提升到了“可恢复错误”的级别课程设计里能做到这一层的不多。还有一点值得注意gets 函数在 VC6 里用得很顺手但它不检查缓冲区边界输入超过 1023 字符会越界。课设场景下输入句子不会太长能跑通即可如果是二次开发拿去做正式工具建议换成 fgets 并指定长度。3.3 译码前缀匹配与顺序表查找的组合译码是编码的逆过程核心思路是利用哈夫曼编码的前缀特性——没有任何一个编码是另一个编码的前缀所以从编码串开头按位匹配命中一个就输出字符然后继续匹配剩下的编码。报告里的实现是把 Code.txt 的 01 串整体读入 h 数组然后外层循环控制消费进度内层循环遍历编译码表比对。void Decoding() { FILE *fpCode, *fpDecode; char h[10240]; /* 从 Code.txt 读出的加密编码串 */ int pos 0; /* 当前消费到的编码位置 */ int i, len; fpCode fopen(D:\\Code.txt, r); fpDecode fopen(D:\\Decode.txt, w); if (fpCode NULL) { printf(错误Code.txt 不存在请先执行编码功能\n); return; } fscanf(fpCode, %s, h); fclose(fpCode); while (h[pos] ! \0) { for (i 1; i n; i) { len strlen(HC[i]); if (strncmp(h pos, HC[i], len) 0) { fputc(HT[i].ch, fpDecode); /* 命中写入一个字符 */ pos len; /* 消费掉这段编码 */ break; } } if (i n) { printf(错误编码串与编译码表不匹配译码终止\n); break; } } fclose(fpDecode); }译码循环的核心是 strncmp 配合 h pos 做前缀匹配。内层 for 从编译码表第 1 个字符开始试拿 HC[i] 的长度去切编码串当前位置的前缀匹配成功就输出 HT[i].ch 并前进 len 位。由于哈夫曼编码是前缀码任何合法的编码串在这个循环里都能唯一匹配不会出现二义性。这里有个参数细节值得专门说strncmp 比较的是前 len 个字符而 len 是当前字符编码的实际长度不是全表最长长度。这样做避免了拿短编码去切分长编码串导致误匹配的问题。比如 e 的编码是 01另一个字符的编码是 0101如果用整串长度去对齐就永远对不上了。程序的健壮性体现在两处如果 Code.txt 不存在先判断文件指针再操作避免空指针解引用如果内层循环跑完都没匹配说明编码文件已经被改坏给出提示后终止。这种优雅降级的处理方式让译码不只是编码的机械逆过程。3.4 打印模块50 字符换行和树表输出打印编码函数 CodePrint 负责把 Code.txt 里的 01 串按每行 50 个字符重新排版输出到终端的同时写入 CodePrint.txt。代码思路很朴素打开源文件和目标文件逐个字符复制每写满 50 个就输出一个换行符。void CodePrint() { FILE *fpCode, *fpPrint; char ch; int cnt 0; fpCode fopen(D:\\Code.txt, r); fpPrint fopen(D:\\CodePrint.txt, w); if (fpCode NULL) { printf(错误Code.txt 不存在请先执行编码\n); return; } while ((ch fgetc(fpCode)) ! EOF) { fputc(ch, fpPrint); putchar(ch); cnt; if (cnt 50) { fputc(\n, fpPrint); putchar(\n); cnt 0; } } fclose(fpCode); fclose(fpPrint); }这段代码的逻辑很简单真正要留意的是它只负责排版不负责改内容。它从 Code.txt 读什么就写什么唯一增加的是换行符译码函数如果要重新使用这段编码直接读 Code.txt 而不是 CodePrint.txt否则会把分布在各行行尾的换行符也当成编码的一部分。打印哈夫曼树表函数 HuffmanTreePrint 则完全是另一个用途它把整棵树的节点信息以表格形式输出包含节点编号、字符、权值、双亲、左孩子、右孩子六列。初始化时只是把编译码表写入 HuffmanTree.txt这里的 T 功能才把完整的树结构也补进去方便人工检查建树过程是否正确。4. 编译运行与测试VC 6.0 里从建工程到全流程验证4.1 工程搭建空控制台工程和源码粘贴报告标注的开发环境是 VC 6.0这个版本跑哈夫曼课设足够。新建工程的步骤是标准操作打开 VC6选 File 菜单 NewProjects 标签页里选 Win32 Console Application工程名随便起一个比如 Huffman路径建议别带中文和空格然后一直 Next 到 Finish。建完后在 FileView 里找到 Source Files右键 Add Files to Folder新建一个 C Source File 把附录源码整个粘进去。编译时最常遇到的是 C 和 C 的语法差异。VC6 会把 .cpp 文件当 C 编译malloc 返回值需要显式强转报告源码里如果写的是 C 风格 malloc 没强转会报 cannot convert 之类的错误。遇到这种问题把每个 malloc 前面加上对应的类型强转即可比如 (HuffmanTree)malloc(...)。另外如果源码里用了 gets 函数VC6 会给出 C4996 警告这只是安全警告不影响链接课程设计里可以忽略。还有一类问题是编译选项导致的。工程默认可能是 Unicode 字符集而源文件里全是窄字符操作多字节和 Unicode 混用会出链接错误。在 Project Settings 里把字符集改成 Not Set 或多字节字符集基本就能干净编译通过。这些都处理完后 F7 编译0 error 0 warning 才算真正进入调试阶段。4.2 全流程验证六步闭环跑通编译通过只是起点跑通整个闭环才是验证哈夫曼树是否正确的方式。我的建议是准备一组小规模测试数据比如 8 个字符空格对应权值 7、a 权值 3、e 权值 4、i 权值 2、o 权值 2、u 权值 1、y 权值 1、句点权值 1。初始化时依次录入注意字符间用空格隔开权值用数字输入。操作顺序是先按 I 初始化终端录入字符集后打开 D:\HuffmanTree.txt 确认编译码表生成正确再按 E 编码输入完整句子 You are the only one I am still in love with. 注意这个句子包含大写 Y 和 I 以及句点如果编译码表没有这些字符编码函数会立刻报错。要么把编译码表补全要么测试时把句子改成全小写加句点的版本。编码完成后立刻按 D 译码再打开 D:\Decode.txt 和原始的 ToBeTran.txt 做对比两端内容应该逐字符一致。接下来按 P 打印编码检查终端输出是否按 50 字符换行。最后按 T 打印哈夫曼树表对照 HuffmanTree.txt 确认每个节点的双亲和孩子下标关系正确。全部走通后按 Q 退出。这里最容易翻车的就是字符集覆盖不完整测试句子里的字符必须在编译码表里全部存在。我建议初始化时把测试句子里出现的所有字符都纳入字符集包括大写字母、空格和标点宁可多录几个不常用的字符也不要让编码函数走到未知字符分支。4.3 异常分支验证不按顺序操作时的报错行为课设验收时老师通常会故意不按顺序操作比如上来就按 E 编码或者不初始化直接按 D 译码。报告对这种情况有预设处理未初始化就编码会提示编译码表可能为空并提供重新输入、返回主菜单、退出三个选项未初始化就译码或打印编码文件不存在时直接提示错误并返回主菜单。测试时要专门验证这几条异常路径这比正常路径更能体现程序的完整度。打开程序直接输入 e观察是否出现友好提示而不是程序崩溃确认 Code.txt 不存在时输入 d观察文件指针判断是否生效没有初始化直接输入 t看会不会把空的树表打印出来。这些测试记录最后要写进报告的调试与测试章节作为系统健壮性的证据。需要注意的一个隐藏陷阱如果我前面建议用 fscanf 读编码串那么汉字或者特殊符号会被 fscanf 截断因为它以空白字符作为分隔符。若测试句子包含中文编码函数录入到编译码表没问题但译码时 fscanf 读不回完整串。课设场景下还是老老实实用全英文和标点测试等改造阶段再考虑二进制读文件。5. 避坑记录哈夫曼课设里最常见的几个翻车点5.1 文件路径与读写侧第一个翻车点集中在 fopen 上。很多同学直接照抄 D:\ 开头的路径结果自己电脑 D 盘根目录没有写权限或者目录根本不存在fopen 返回 NULL后续 fputc 全部打在空指针上程序直接闪退。解决方法是每次 fopen 后立即判断返回值为空就打印错误并返回主菜单同时把路径常量抽出来统一改成相对路径比如 Code.txt 直接放工程目录下这样交报告跑演示时不容易被环境差异绊倒。第二个坑是文件内容格式的隐式依赖。译码函数用 fscanf(%s, h) 读编码串如果 Code.txt 里既有编码又有换行和空格fscanf 会在首个空白处停住导致只读进前半段编码。编码函数写 Code.txt 时如果用了 fprintf 且格式串里带了 \n就要确认译码时能完整读回。课设阶段建议统一约定Code.txt 里只有纯 01 串不插入任何不可见字符。5.2 输入输出侧第三个翻车点几乎每个写 C 课设的同学都踩过scanf 和 getchar 混用导致字符集录入错位。用户输入完字符个数 n 后按回车缓冲区的换行符如果不处理下一个读取字符的语句会直接吃到 \n第一个录入的字符就变成了一个看不见的换行。我一般在 scanf(%d, n) 后面紧跟一句 getchar() 或者 scanf(%*c)把所有输入缓冲区的回车清掉再继续读字符这个习惯从那以后一直保留。第四个坑是字符集与实际输入不匹配。编码函数在编译码表里查不到字符时的报错分支设计得没问题但不少同学测试时输入了编译码表外的字符程序提示错误后又不知道错误是来自输入字符串还是编译码表。报告里给出的判断思路很好用先检查输入字符串是否越界再检查编译码表是否为空或漏录字符。我在调试时会故意输入一个编译码表确定没有的字符比如 验证报错分支真的能触发。5.3 状态流转与流程侧第五个翻车点是不按顺序操作导致的连锁错误。程序虽然做了防御处理但有些状态是隐藏的比如执行完编码后编码函数把 HT 和 HC 都留在全局变量里此时执行打印哈夫曼树没问题但如果重新初始化了一组新字符旧的 HT 被覆盖而 Code.txt 还是上一次编码的结果这时再译码就会乱套。解决思路是在每次初始化时清空或重建相关文件并设计一个简单的状态标志位记录“当前是否已经初始化”在所有功能入口处检查这个标志。还有一个容易被忽略的细节每行 50 个字符的打印规则只影响 CodePrint不影响译码。有些同学看到 CodePrint.txt 里换行了就以为 Code.txt 也换行了译码时把 \n 当成编码的一部分结果永远匹配不上。记住一个原则Code.txt 是唯一合法编码源CodePrint.txt 只是给人看的排版副本两边内容一致但格式不同。6. 二次开发把课程设计升级成可用的压缩小工具这个编译码器最明显的短板是权值靠手工录入不是从文本里统计出来的。真正的压缩工具应该先读一遍源文件统计每个字符出现次数作为权值自动建树再编码。改造方向不复杂把 Init 函数里手工输入字符和权值的逻辑换成扫描 ToBeTran.txt用数组做频度统计然后过滤掉出现次数为 0 的字符剩下的进哈夫曼树。第二个值得动手的改造是二进制输出。当前编码结果以字符形式存成 01 串一个比特占一个字节压缩效果完全没体现出来。把 01 串每 8 位合成一个字节写入文件体积直接缩小到原来的八分之一这才是哈夫曼压缩该有的样子unsigned char byte 0; int bitCnt 0; for (i 0; code[i]; i) { byte (byte 1) | (code[i] - 0); bitCnt; if (bitCnt 8) { fwrite(byte, 1, 1, fpBin); byte 0; bitCnt 0; } } if (bitCnt 0) { byte (8 - bitCnt); /* 末尾不足 8 位补零 */ fwrite(byte, 1, 1, fpBin); }这段代码把字符串形式的编码按位移进一个字节满 8 位就落盘一次最后不足 8 位时向左移位补零。改造完后译码端要配套处理最后一个字节的补零问题方案是在二进制文件头部记录源文本长度或者最后一个有效编码的位数。第三个改造方向是命令行参数化。现在的菜单交互适合演示但自动化验证时每跑一次都要手动输入好几个字符和权值效率太低。参照常规压缩工具的接口设计成 hfm -c source.txt 压缩、hfm -d source.hfm 解压用不到一百行代码就能把 init、encoding、decoding 串成一条命令链。我自己的习惯是每次改完都强制走一遍“初始化 → 编码 → 译码 → 字节比对”的闭环确认还原结果和源文件一字不差并且随机换几组不同长度的文本跑看有没有边界情况漏掉。哈夫曼这类课设只有亲手把异常分支也测过一遍才算真正做完而不是只跑通演示路径。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑