简介:哈夫曼编译码器课程设计报告面向数据结构课程设计阶段的学生,围绕哈夫曼树构建、编码与译码的无损压缩实验展开,开发环境标注为 VC++ 6.0。报告以初始化、编码、译码、打印代码文件、打印哈夫曼树和退出程序六个功能为主线,串联字符频率统计、优先队列建树、0/1 编码生成、编码文件存储与原始文本还原等环节。资源包共 1 个 doc 文件,大小约 2.76MB,内容包含需求分析、系统框图与总流程图、详细设计、函数列表、主要函数介绍及实现步骤,目录层级较完整,便于按章节查阅。已有 811 人学习,适合需要完成同类课设、撰写实验报告或复现哈夫曼编译码程序的学生参考,可从中获取模块划分、数据结构组织、核心函数说明和报告排版思路。
1. 哈夫曼编译码器:压缩率的账其实记在频率分布上,不在数据上
很多人第一次写哈夫曼编译码器,心里默认它是个“压缩程序”——输入一段文本,吐出一段更短的文本,压缩率是个固定属性。真跑起来就会翻车:同一份代码,换一段输入,压缩率能从 45% 掉到负的。原因不复杂,编码表是从字符频率分布长出来的,收益只跟分布的偏斜程度有关,跟文本长短几乎无关。一万字的文章和二十个字的短语,用的还是同一棵哈夫曼树。分布越接近均匀,收益越趋近于零。
第二件容易被忽略的事:变长编码能成立,全靠“任一编码都不是另一编码的前缀”这条约束。有了它,译码时不需要任何分隔符,逐位往下走、走到叶子就切出一个字符。这份课程设计报告里的哈夫曼编译码器,就是把这两件事拆成构造、编码、译码、打印四个动作,再配一条 I→E→D 的状态链。搞懂它,等于搞懂了所有前缀码压缩工具的骨架。
2. 构造哈夫曼树:为什么每次挑两个最小值就能得到最优前缀码
哈夫曼 1952 年给出的那个贪心策略,形式上是“反复取权值最小的两个节点合并”,但它能成为最优解,靠的是一条更朴素的直觉:频次最低的字符,理应沉到最深的位置,因为它们出现的次数少,付长码的代价最小。每次合并都把两个最“轻”的子树捆在一起往下压一层,等价于让代价最大的组合承担最深的深度。这个贪心选择性质可以严格证明,但工程上更值得关心的是它怎么落地。
2.1 五个域与链式节点:parent 指针是编码的第一现场
报告里给的节点结构是五个域:ch、weight、parent、lchild、rchild。注意它没有存“编码”本身,也没有存“深度”。编码是事后从叶子往根爬出来的,parent指针就是那条回溯路径。这个设计选择决定了后面几个函数的写法——没有parent,编码就得从根往下递归并一路带着前缀串,空间和代码量都要翻倍。
typedef struct { char ch; /* 叶子存字符;内部节点此域空闲 */ int weight; /* 权值:叶子是频次,内部节点是两孩子之和 */ int parent; /* 双亲下标,0 表示尚无双亲(未被合并) */ int lchild; /* 左孩子下标,0 表示无 */ int rchild; /* 右孩子下标,0 表示无 */ } HTNode, *HuffmanTree;这里有个容易被忽视的细节:parent == 0被复用成了“这个节点还没被合并”的标记。Select()就是靠这个标记筛掉已经被吃掉的节点。用 0 当下标哨兵,前提是数组从 1 开始计数、0 号单元永远空着——这是国内数据结构教材沿袭下来的惯例,好处是不用额外维护一个visited数组,坏处是任何一次越界写 0 号单元都会静默破坏整棵树的语义。我一般会在这里加一句注释提醒自己:HT[0]是神圣不可侵犯的。
存储上还有一个报告里说得很清楚的分工:哈夫曼树用链式(其实是带下标的静态链表,malloc一块2n-1大小的连续数组),而编码表用顺序结构。前者需要频繁改parent,后者只需要按下标随机读,这个组合在n不大的课设场景里是最省事的。
2.2 Select() 的双扫描:n-1 次合并里那些被浪费的比较
构造过程本身只有十几行,重心全在Select()。标准写法是两趟线性扫描:第一趟找最小,第二趟找次小。n个叶子要合并n-1次,第i次合并前要扫描的区间长度是n + i - 1,总比较次数是O(n²)量级。
/* 在 HT[1..end] 中挑出 parent==0 且权值最小的两个节点,下标带回 s1、s2 */ void Select(HuffmanTree HT, int end, int *s1, int *s2) { int i, min1 = 0x7FFFFFFF, min2 = 0x7FFFFFFF; *s1 = *s2 = 0; for (i = 1; i <= end; i++) { /* 第一趟:全局最小 */ if (HT[i].parent == 0 && HT[i].weight < min1) { min1 = HT[i].weight; *s1 = i; } } for (i = 1; i <= end; i++) { /* 第二趟:排除 s1 后的次小 */ if (HT[i].parent == 0 && HT[i].weight < min2 && i != *s1) { min2 = HT[i].weight; *s2 = i; } } }参数end是“当前已存在的节点上界”,第一次调用时end = n,之后每次合并完加一;s1、s2是指针出参,因为 C 没有多返回值。两趟扫描看着笨,但它有个隐藏优点:权值相等时的行为是确定的。第一趟用严格小于<,相同权值时记录的是下标最大的那个;第二趟排除s1后自然拿到另一个。这个细节在调试时很关键——如果你把<写成<=,s1会变成下标最大者,虽然结果仍然合法,但打印出来的树形会和教材答案对不上,对课设验收是致命的。
把Select()换成小顶堆,复杂度能压到O(n log n)。但在n ≈ 几十的课设规模下,O(n²)的常数因子优势反而更明显,堆的sift_down写错一个边界就全盘崩。这是个典型的“理论最优不等于工程最优”的场景,后面第 6 章还会回到这个取舍。
2.3 从叶子回溯到根:编码为什么必须“倒着写”
HuffmanCoding()的主体分两段。第一段建树,第二段生成编码表。第二段是新手最容易写错的地方:从叶子出发往上爬,每爬一步只能知道“我是父节点的左孩子还是右孩子”,但爬的顺序是从低位到高位,拼出来的串是反的。教材的标准解法是开一个长度n的临时缓冲区,从尾部往前填,填完把&cd[start]整段拷走。
void HuffmanCoding(HuffmanTree *HT, char ***HC, int *w, int n) { int m = 2 * n - 1, i, s1, s2, start, c, f; char *cd; *HT = (HuffmanTree)malloc((m + 1) * sizeof(HTNode)); /* 0 号单元不用 */ for (i = 1; i <= n; ++i) { /* 叶子初始化 */ (*HT)[i].ch = 0; (*HT)[i].weight = w[i]; (*HT)[i].parent = (*HT)[i].lchild = (*HT)[i].rchild = 0; } for (; i <= m; ++i) { /* 内部节点清零 */ (*HT)[i].weight = 0; (*HT)[i].parent = (*HT)[i].lchild = (*HT)[i].rchild = 0; } for (i = n + 1; i <= m; ++i) { /* n-1 次合并 */ Select(*HT, i - 1, &s1, &s2); (*HT)[s1].parent = (*HT)[s2].parent = i; (*HT)[i].lchild = s1; (*HT)[i].rchild = s2; (*HT)[i].weight = (*HT)[s1].weight + (*HT)[s2].weight; } *HC = (char **)malloc((n + 1) * sizeof(char *)); cd = (char *)malloc(n * sizeof(char)); /* 最长码 n-1 位 + '\0' */ cd[n - 1] = '\0'; for (i = 1; i <= n; ++i) { start = n - 1; c = i; f = (*HT)[i].parent; while (f != 0) { /* 自底向上回溯 */ if ((*HT)[f].lchild == c) cd[--start] = '0'; else cd[--start] = '1'; c = f; f = (*HT)[f].parent; } (*HC)[i] = (char *)malloc((n - start) * sizeof(char)); strcpy((*HC)[i], &cd[start]); } free(cd); }关键参数:m = 2n - 1是整棵树的总节点数,这个式子来自“n个叶子 +n-1个内部节点”;i <= m的循环恰好执行n-1次,也就是恰好合并n-1次,合并完只剩一个根,它的parent天然是 0。缓冲区长度取n而不是n-1,是因为要留一个字节放'\0'。cd只分配一次、反复覆写,这个复用是安全的,因为每次strcpy都从start位置开始拷贝,start之前的脏数据不会被读进去。
如果不想倒着写,另一条路是建完树后从根做一次 DFS,边往下走边累加前缀。两种写法等价,但 DFS 版需要显式栈或递归,而且必须搞清楚“叶子靠左还是靠右”,代码量更大。倒填缓冲区的写法更“脏”但更短,这也是它成为教材模板的原因。
3. 编码与译码的不对称:前缀码怎么保证不需要分隔符
编码和译码在哈夫曼这棵树上是两个相反方向的行走。编码从叶子往根爬,译码从根往叶子探。方向相反带来一个有趣的后果:编码天然是“整字符粒度”的,一次查表搞定一个字符;译码天然是“逐位粒度”的,走一步看一步,直到撞上叶子。理解了这个不对称,才能看懂报告里两段风格完全不同的代码。
3.1 编码方向:自底向上拼接,逆序输出的字符串
Encoding()的逻辑朴素到几乎没什么可讲:读入一串原文,逐个字符去编码表里查,查到就拼接,查不到就报错。真正需要留意的是它跟文件系统的交互顺序。
/* 对 s 中的每个字符查表拼接,结果写 Code.txt,原文写 ToBeTran.txt */ int Encoding(HuffmanTree HT, char **HC, int n, const char *s) { FILE *fpSrc = fopen("D:\\ToBeTran.txt", "w"); FILE *fpOut = fopen("D:\\Code.txt", "w"); int i, k; if (!fpSrc || !fpOut) return -1; fputs(s, fpSrc); /* 先留原文,便于比对 */ for (i = 0; s[i] != '\0'; ++i) { for (k = 1; k <= n; ++k) { /* 顺序表查找字符 */ if (HT[k].ch == s[i]) { fputs(HC[k], fpOut); break; } } if (k > n) { /* 表里没这个字符 */ fclose(fpSrc); fclose(fpOut); return i; /* 返回出错位置,调用方据此提示 */ } } fclose(fpSrc); fclose(fpOut); return -1; /* -1 表示全部成功 */ }fpSrc和fpOut各司其职:前者保存原文快照,后者只装纯 0/1 位流。这个“原文备份”的设计不是多余的,它是后面译码结果正确性的唯一验收标准——译码完把Decode.txt和ToBeTran.txt做一次字符串比对,全等就说明整条链路没坏。返回值用出错下标而非-1表示成功,这种“反直觉的约定”在国内教材里很常见,读代码时务必先看清注释。
这里还有一条不成文的规矩:Code.txt里绝对不能有换行、空格、任何分隔符。整个文件必须是一根连续的位流,否则译码阶段逐位下探的指针会被这些杂字符带偏。所有“好看”的格式化,都要留到打印那一步做。
3.2 译码方向:自顶向下逐位下探,走到叶子就吐字符
报告里的译码用的是另一条路:把整个码流读进数组,然后按每个字符的编码长度切片,拿切片去和编码表做顺序比较。它能跑通,本质上是因为前缀码保证“没有任何一个码是另一个码的前缀”,所以第一个匹配上的必然是唯一的正确解。但这个写法有个隐性的性能与健壮性问题。
/* 报告原版思路:顺序表切片比较 */ int Decoding(char **HC, HuffmanTree HT, int n, const char *code, FILE *out) { int i = 0, k; while (code[i] != '\0') { for (k = 1; k <= n; ++k) { int L = (int)strlen(HC[k]); if (strncmp(code + i, HC[k], L) == 0) { /* 前缀码 => 首个命中即正确 */ fputc(HT[k].ch, out); i += L; break; } } if (k > n) return i; /* 没有码能匹配,位流损坏 */ } return -1; }每次切一个字符,最坏要跟n个编码逐个比,单次比较又要L次字符比较,总代价约O(码流长度 × 平均码长 × n)。n = 26时这个开销在课设里完全感知不到,但如果换成几百个符号的字典,就会明显变慢。
真正优雅的写法是顺着树往下走,一步一位,走到叶子就回根:
/* 逐位下探版:从根出发,遇 0 走左、遇 1 走右,撞叶子即输出并回根 */ int DecodeWalk(HuffmanTree HT, int root, const char *code, FILE *out) { int p = root; while (*code) { p = (*code == '0') ? HT[p].lchild : HT[p].rchild; if (p == 0) return -1; /* 走进了空指针,位流非法 */ if (HT[p].lchild == 0 && HT[p].rchild == 0) { fputc(HT[p].ch, out); p = root; /* 重置回根,准备下一个字符 */ } ++code; } return (p == root) ? -1 : -2; /* 停在半路,说明码流被截断 */ }这一版的复杂度是线性的:每个位只走一步,跟n无关。更重要的是它会主动发现两类错误:p == 0说明位流里出现了树上不存在的路径,多半是文件被污染;p != root说明码流末尾缺了半截,说明上一个写文件的函数没写完就退了。这两种情况在顺序表版里都不会显式报错——它只是匹配不上,然后返回一个位置,调用方很容易把它当成“正常结束”。这是我更倾向逐位版的原因,不是因为快,是因为它把错误暴露出来了。
3.3 顺序表查找版译码的隐患:为什么它也能跑通
有人会问:既然前缀码保证了唯一可译,顺序表版为什么不能算正确?答案是“能跑通,但脆”。它依赖一个外部条件:编码表里每个编码的字符串必须严格是 0/1 组成,长度必须与树深一致。一旦某个环节写文件时多写了一个换行,strncmp就永远对不上,而它会安静地返回错误位置,让调用方自己去猜。
还有一个更隐蔽的问题:如果编码表里存在重复的编码串(正常情况下不该有,但如果初始化时两个字符的权值都为 0,或Select()出了 bug 让两个孩子指向同一个节点,就可能出现),顺序表版会永远匹配上第一个,导致译码输出一堆重复字符。逐位下探版则会老老实实走到某个叶子,输出那个叶子实际对应的字符——错误同样存在,但它至少不会因为表顺序而改变结果。
所以在我的实现习惯里,译码一律用逐位下探,顺序表版只作为“用来验证树建得对不对”的辅助手段:用编码版生成码流,用下探版译回去,两边结果一致才说明树和表是自洽的。
4. 状态管理与文件落盘:I→E→D 这条隐式依赖链是怎么坏掉的
报告里最有教学价值的部分,其实是它的调试章节。里面列了一串“未初始化就直接按 E / 按 D / 按 P / 按 T”的截图,每次都会弹错误提示再回主菜单。这些截图暴露了这份课设真正的架构:它不是六个独立功能,而是一条带前置条件的状态链。
4.1 六个功能键其实是一个状态机
六个按键里,I是唯一的起点。它做两件事:从终端读入字符集大小n、n个字符、n个权值;建树并把编译码表和树表写进HuffmanTree.txt。没有I,后面的E无从查表,D无树可下探,P没有Code.txt可读,T没有树可打印。所以那四次报错不是冗余的容错代码,而是状态机在拒绝非法迁移。
/* 主循环骨架:用一个标志位表达"树是否就绪" */ int main(void) { HuffmanTree HT = NULL; char **HC = NULL; int n = 0, ready = 0; /* ready: 0=未建树, 1=已建树 */ char cmd; Menu(); while ((cmd = getchar()) != 'Q' && cmd != 'q') { while (getchar() != '\n') ; /* 吃掉行尾换行,防止污染下一次读入 */ switch (cmd) { case 'I': case 'i': ready = Init(&HT, &HC, &n); /* 成功返回 1 */ break; case 'E': case 'e': if (!ready) { puts("请先执行 I 初始化"); break; } Encoding(HT, HC, n, ReadLine()); break; case 'D': case 'd': if (!ready) { puts("请先执行 I 初始化"); break; } Decoding(HC, HT, n, ReadAll("D:\\Code.txt"), stdout); break; /* P、T 同理 */ } Menu(); } return 0; }ready这个标志位是整个程序的中枢。没有它,HT就是个野指针,一进Encoding()就会在HT[k].ch上炸掉。报告里那些错误提示,本质上就是在ready == 0时提前返回。这行while (getchar() != '\n') ;也别小看,它负责把scanf留下的换行符清掉,否则下一次getchar()会立刻读到一个'\n',菜单瞬间闪一下又回来,看起来像“程序没反应”。
4.2 内存态落盘:HuffmanTree.txt 是后悔药
报告要求把编译码表和哈夫曼树表都落到D:\HuffmanTree.txt,这个设计的价值远超“作业要求”。树是纯内存结构,程序一退就没了。把它序列化成文本,意味着下次启动可以不重新输入频率,直接从文件恢复。这相当于给自己留了后悔药——改错一个权值、想对比两棵不同频率的树,只要文件还在,随时能重来。
代价是序列化格式必须稳定。表的每一行对应一个下标,字段顺序固定为下标 字符 权值 parent lchild rchild。读回来的时候按同样顺序解析,任何一个字段少一位都会让整张表错位。我一般的做法是写文件时用fprintf(fp, "%d %c %d %d %d %d\n", ...),字符域用%c而不是%s,因为叶子字符可能是空格——空格用%s读会直接吞掉,这是第 5 章要专门说的一条坑。
4.3 每行 50 字符的打印:格式美观与数据可逆的冲突
CodePrint()做的事很单纯:从Code.txt读位流,每 50 个字符换一行,同时输出到终端和CodePrint.txt。需求里特意写明“每行 50 个代码”,是为了让人眼能扫。但这跟第 3 章那条“Code.txt不能有换行”的铁律构成了一个精巧的对照:Code.txt是给机器读的,CodePrint.txt是给人看的,两者内容相同、格式不同,绝不能混用。
/* 从 Code.txt 读纯位流,每 50 字符换行,同时写终端和 CodePrint.txt */ void CodePrint(void) { FILE *in = fopen("D:\\Code.txt", "r"); FILE *out = fopen("D:\\CodePrint.txt", "w"); int c, cnt = 0; if (!in || !out) { puts("文件打开失败"); return; } while ((c = fgetc(in)) != EOF) { if (c == '\n' || c == '\r') continue; /* 关键:跳过残留换行,保持位流纯净 */ fputc(c, stdout); fputc(c, out); if (++cnt % 50 == 0) { /* 每 50 个换一行 */ fputc('\n', stdout); fputc('\n', out); } } fclose(in); fclose(out); }cnt是累计计数器,% 50 == 0在第一次满足时恰好是第 50 个字符之后,不会出现“第一行只有 49 个”的错位。那句continue是防御性的:万一手工编辑过Code.txt,末尾多了个换行,fgetc会把它读进来并计入cnt,导致后面所有行的断点整体偏移一位。跳过它,打印结果就永远对齐。
5. 避坑与排查:五条从终端报错里爬出来的记录
课设报告里那几张报错截图,每一张背后都是一次真实的翻车。我把它们连同自己写同类程序时踩过的坑归拢成五条,按“现象 → 原因 → 解决”记下来。这部分的血泪经验,比前面任何一节都更值得逐字看。
5.1 编码里多出的换行:译码时莫名其妙多出乱码
现象:输入一句英文做编码,Code.txt里看着全是 0 和 1,但一译码就在开头或结尾多出几个奇怪的字符。
原因:scanf("%s", buf)读字符串时遇到空格就停,把剩下的内容留在输入缓冲区里;下一次循环读功能键时,getchar()先读到的就是那个空格或者换行。这个杂字符再被Encoding()当成原文的一部分写进Code.txt,位流就脏了。
解决:输入一律用fgets(buf, sizeof(buf), stdin)整行读,读完立刻把末尾的'\n'换成'\0';或者在每次读功能键之前,用while (getchar() != '\n') ;清空缓冲区。前者更稳,因为它能保留原文里的空格——哈夫曼编码完全能处理空格字符,没必要在输入阶段就把它切掉。
5.2 空格的吞掉:字符集里的空格没进树
现象:初始化时输入字符集,里面明明有空格,但打印出来的编译码表里没有它,编码时一遇到空格就报“字符不在表中”。
原因:scanf("%c", &ch)前面如果刚读过数字,缓冲区里残留的'\n'会被当成字符读走,于是表里多了一个“换行字符”,真正的空格反而没读到。这个 bug 极隐蔽,因为表的行数是对的,只是内容错位。
解决:读字符之前先清一次缓冲区,或者在格式串里显式跳过空白:scanf(" %c", &ch),前面那个空格会让scanf跳过所有空白字符——但注意,这同时也跳过了你想读的空格本身。真正要做的是先读n和权值,把缓冲区清干净,再用getchar()逐个读字符,包括空格。建表阶段把ch用%c写入、用%c读回,全程不经过%s。
5.3 Code.txt 既读又写:读到的是上次的残骸
现象:连续做两次编码,第二次译出来的结果里混进了第一次的内容。
原因:fopen("D:\\Code.txt", "w")会截断文件,看起来没问题;但如果某次Encoding()在中途因为“字符不在表中”提前return,fclose被跳过,文件句柄泄漏,下一次fopen的写入位置和缓冲区状态就不可预期。更常见的情况是译码函数直接读Code.txt,而编码函数还没把缓冲区 flush 到磁盘。
解决:所有fopen之后立刻判空,所有提前return之前先fclose。编码和译码不要在同一个函数调用里串联,中间必有文件关闭。调试时可以在Encoding()末尾加一句fflush(fpOut)再fclose,确保落盘。至于D:\根目录的写权限,在部分受限环境里会直接失败,fopen返回NULL——这就是为什么判空不能省。
5.4 单字符集 n=1 与权值为 0:一棵只有根的树
现象:字符集大小输入 1,程序建完树后打印出来的编码表是空的,编码任何字符都失败。
原因:m = 2n - 1 = 1,合并循环for (i = n + 1; i <= m; ++i)一次都不执行,整棵树只有一个节点,它既是根也是叶子。此时编码长度应该是 0 位还是 1 位,教材没统一规定。如果按“根没有parent,回溯立刻结束”处理,得到的编码是空串,写进文件什么都读不到。
解决:n == 1单独处理,规定该字符的编码为单字符"0"(或"1"),译码时遇到任何位都输出这个字符。另一种情况是某个字符权值为 0:它仍然应该进树,只是会沉到最深。但如果所有字符权值都是 0,Select()的两趟扫描会因为min1、min2初始值相同而挑中同一批节点,建出来的树结构退化——输入阶段就该拦住“权值全为零”的情况并提示重输。
5.5 打印文件与原文件混淆:把 CodePrint.txt 当 Code.txt 用
现象:译码时读CodePrint.txt,结果大量字符对不上,或者干脆一个都译不出来。
原因:CodePrint.txt每 50 个字符插一个换行,是给人看的格式;Code.txt是纯位流,没有换行。两个文件内容“看起来一样”,实际字节流不同。报告里的文件清单把这两者分列得很清楚,但写代码时手一滑把路径写串,是极高频的失误。
解决:路径字符串统一用宏定义,不要在函数里手写"D:\\Code.txt"。注意 C 字符串里的反斜杠要写成\\,写成单个\时\C是非法转义,编译器可能只给个警告,运行时路径就变成D:Code.txt(当前目录下的相对路径),文件被悄悄建到别处去了。路径宏加一次,所有函数共用,改路径只改一处。
6. 设计取舍:一棵两百行能写完的树,值不值得做得更“正确”
写到这里可以回头看一眼这份课程设计的整体形状。它用静态链表存树,用O(n²)的线性扫描选最小值,用顺序表查找做译码,用文本文件做持久化。按现代工程标准,这四条里至少三条会被判“不够好”:堆能到O(n log n),逐位下探能把译码压到线性,二进制序列化比文本表省一半空间。但把这四条全改掉,代码量会翻三倍,而n只有几十的时候,运行时间差异在毫秒级都体现不出来。
这是我认为这份报告最诚实的地方:它没有假装自己在做压缩软件,它是在用最小的机制把“频率 → 树 → 码 → 原文”这条闭环跑通。Select()的两趟扫描是笨的,但它把“相等权值怎么办”这个边角问题暴露在一个二十行的函数里,而不是藏在堆的sift_down里。顺序表版译码是慢的,但它让“前缀码唯一可译”这条性质变得肉眼可见——你能亲手验证第一个匹配上的就是对的。
真要往下深挖,我建议拿 WPL 做一次交叉验证。对建好的树,遍历所有叶子,把weight × 编码长度累加,得到的值必须等于所有内部节点权值之和。这两个数从不同路径算出来,一个是自底向上按叶子算,一个是自顶向下按合并过程算,它们相等,说明树的结构没有任何一处被写坏。
/* 校验:叶子加权路径长 == 所有内部节点权值之和 */ int CheckWPL(HuffmanTree HT, char **HC, int n, int m) { int leafSum = 0, innerSum = 0, i; for (i = 1; i <= n; ++i) leafSum += HT[i].weight * (int)strlen(HC[i]); for (i = n + 1; i <= m; ++i) innerSum += HT[i].weight; return (leafSum == innerSum) ? 1 : 0; /* 1 表示树自洽 */ }这个校验不依赖任何外部数据,纯靠树自身的两个视角做对账。从那以后我每次写完建树逻辑,都会先把CheckWPL()挂上去跑一遍,通过了才去碰编码和文件读写——因为在树上出的错,会以“译码结果多几个怪字符”这种毫不相干的形式在下游冒出来,排查成本翻好几倍。先对账,再往下走,能省掉大量无头苍蝇式的调试。
这份课设报告除了源码,还保留了那份逐步调试的记录——哪一步没初始化就会报什么错、编码表存成什么格式、每行 50 字符的对齐是怎么处理的。想照着源码把这条闭环自己推一遍的,资源就在下面,取回去对着敲,比读任何一版教材伪代码都来得实在。希望帮到你。
本文还有配套的精品资源,点击获取