☰
哈夫曼编解码系统C语言实现:从建树到文件落盘的完整实验报告
2026/10/22 2:42:31 网站建设 项目流程

简介:这份《数据结构(C语言版)实验报告(哈夫曼树)》面向计算机专业学生与数据结构初学者,聚焦哈夫曼编码与译码系统的完整实现,帮助读者理解变长前缀编码、最优二叉树构建及数据压缩原理。资源为1个PDF文件,压缩包约917KB,内容涵盖需求分析、概要设计、详细设计与测试数据,完整呈现初始化、编码、译码、打印代码文件、打印哈夫曼树五大功能模块,并给出HTNode结构体、HuffmanCode指针数组及initHuffmanTree、HuffmanCoding、Select、encoding、decode、dispHT等核心函数的实现思路。读者可据此掌握优先队列建树、DFS/BFS遍历、文件读写与菜单式交互设计,并借助教科书例6-2及字符频度统计完成编码译码验证。目前已有196人学习,适合作为课程实验参考与算法练习范本。

1. 从一份实验报告说起:哈夫曼编解码系统到底能跑出什么

很多人第一次接触哈夫曼树,都是在《数据结构与算法》的实验课上。题目看起来不复杂:给一组字符和频度,建树、编码、译码、打印。但真到动手写代码,问题就来了——文件读写时机不对、编码表对不上、译码时树遍历走岔了,随便一个环节都能让程序输出一堆乱码。这份《数据结构(C语言版)实验报告(哈夫曼树).pdf》就是围绕这个经典实验展开的完整记录,包含需求分析、概要设计、详细设计、调试分析和可直接编译运行的C语言源码。它解决的不是“哈夫曼树是什么”这种教科书问题,而是“怎么用C语言把一套编解码系统真正跑通”的工程问题。适合正在做数据结构实验的学生、需要快速复现哈夫曼编解码的开发者,以及想拿一份能直接编译的参考实现来对照自己代码的人。下面我从这份资源的结构出发,把建树、编码、译码、文件落盘和调试排错几个环节拆开讲,每一步都落到可执行的代码和参数上。

2. 哈夫曼树的数据结构与建树逻辑:从HTNode到Select函数

2.1 为什么用数组存树而不是指针

这份实验报告里,哈夫曼树没有用常见的二叉链表指针来组织,而是用了一个静态数组加下标索引的方案。核心结构体定义如下:

typedef struct { unsigned int weight; unsigned int parent, lchild, rchild; } HTNode, *HuffmanTree;

每个节点存四个字段:权值、父节点下标、左孩子下标、右孩子下标。n个叶子节点加n-1个内部节点,总共2n-1个节点,全部放在一个连续数组里,下标从1开始用。这种写法在实验报告里很常见,原因是它省去了指针操作,建树过程中频繁查找最小权值节点时,直接遍历数组比递归遍历指针树更直观,也更适合C语言初学者调试。

我一般会提醒一点:数组方案有个隐含约束——节点一旦分配了下标就不能再移动。所以建树前必须把数组大小算准,m = 2*n - 1,少一个都会越界。实验报告里HuffmanCoding函数开头就做了这个判断:

int HuffmanCoding(int *w) { if (n <= 1) return 1; int m = 2 * n - 1; HT = (HuffmanTree)malloc((m + 1) * sizeof(HTNode)); // ... 初始化前n个叶子节点 for (int i = 1; i <= n; ++i) { HT[i].weight = w[i]; HT[i].parent = HT[i].lchild = HT[i].rchild = 0; } // ... 初始化n+1到m的内部节点为空 for (; i <= m; ++i) HT[i].weight = HT[i].parent = HT[i].lchild = HT[i].rchild = 0; }

这里malloc用了(m+1)而不是m,因为下标从1开始,第0个位置空着不用。参数w是外部传入的权值数组,同样从下标1开始有效。这个细节如果搞错,后面Select函数遍历时就会读到未初始化的垃圾值。

2.2 Select函数的选最小逻辑与常见翻车点

建树的核心循环是每次从当前所有parent == 0的节点中选出权值最小的两个,合并成新节点。实验报告里的Select函数实现如下:

void Select(int j, int &s1, int &s2) { // 先找到第一个parent为0的节点作为s1 for (int h = 1; h <= j; h++) if (HT[h].parent == 0) { s1 = h; break; } h++; // 再找第二个parent为0的节点作为s2 for (; h <= j; h++) if (HT[h].parent == 0) { s2 = h; break; } // 保证s1权值小于等于s2 int m; if (HT[s1].weight > HT[s2].weight) { m = s1; s1 = s2; s2 = m; } // 在剩余节点中继续找更小的 h++; for (; h <= j; h++) if (HT[s1].weight > HT[h].weight && HT[h].parent == 0) s1 = h; for (m = 1; m <= j; m++) if (HT[s2].weight > HT[m].weight && m != s1 && HT[m].parent == 0) s2 = m; }

这个函数的参数j是当前已分配节点的最大下标,s1和s2是引用传出的两个最小权值节点下标。逻辑分三步:先各找一个候选,再交换保证s1更小,最后全局扫描更新。看起来没问题,但有一个血泪经验:如果两个节点权值相同,这个实现选出来的s1和s2顺序可能和预期不一致,导致建出的树形状不同。树形状不同不影响编码正确性,但会影响打印出来的树结构和编码长度分布。如果你在对照教科书例6-2的结果,发现编码对不上,先检查是不是相同权值节点的选取顺序有差异。

另一个常见翻车点是Select的循环边界。j传的是i-1,也就是当前已经存在的节点数。如果写成j传i,就会把还没赋值的空节点也纳入比较,选出来的s1或s2可能是权值为0的无效节点,建出来的树直接废掉。

3. 编码与译码的完整链路:从文件读取到CodeFile落盘

3.1 编码:字符到01串的映射与文件写入

编码模块的核心思路是:先通过HuffmanCoding建好树并生成每个字符的编码表HC,然后读入待编码文本,逐字符查表拼接01串,写入CodeFile.txt。实验报告里编码表生成的关键片段:

HC = (HuffmanCode)malloc((n + 1) * sizeof(char*)); char *cd = (char*)malloc(n * sizeof(char)); cd[n - 1] = '\0'; for (i = 1; i <= n; ++i) { int start = n - 1; for (int 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);

这段代码从每个叶子节点往上回溯到根,左分支记0、右分支记1,因为是从下往上走,所以编码是倒着填进cd数组的,最后用strcpy从start位置拷贝出来。参数n是字符集大小,cd数组长度取n是因为哈夫曼树最深不会超过n层。这里有个容易忽略的点:cd[n-1]先置'\0',保证strcpy能正确截断。

编码写入文件的逻辑在openfileEnco和inputEnco里,核心循环是:

outstuf.open("CodeFile.txt", ios::out); for (i = 1; i < m; i++) { for (int j = 1; j <= n; j++) if (file[i] == ch[j]) { cout << HC[j]; outstuf << HC[j]; break; } if (j == (n + 1)) { cout << endl << "此处编码出现错误,含有不可编码的字符!" << endl; break; } } outstuf.close();

外层遍历输入文本的每个字符,内层在字符集ch里线性查找匹配项,找到就输出对应编码。如果内层循环走完都没找到,说明输入里含有字符集之外的字符,直接报错退出。这个线性查找在字符集小的时候没问题,26个字母最多比26次,但如果字符集上百,建议改成哈希表或直接用字符ASCII值做下标映射。

3.2 译码:从01串回到原文的树遍历

译码是编码的逆过程:读入01串,从根节点出发,遇0走左孩子、遇1走右孩子,走到叶子节点就输出对应字符,然后回到根节点继续。实验报告里openfileDeco的核心逻辑:

int m = 2 * n - 1; // 根节点下标 for (i = 1; password[i] != '$';) { char record[20]; for (int j = 0, q = i; password[q] != '$'; j++, q++) { if (password[q] == '0') { record[j] = '0'; m = HT[m].lchild; } else { record[j] = '1'; m = HT[m].rchild; } if (HT[m].rchild == 0) { record[j + 1] = '\0'; break; } } if (HT[m].rchild != 0) { cout << endl << "译码在此处出现错误!此处不可译码!" << endl; break; } for (int p = 1; p <= n; p++) { if (!strcmp(record, HC[p])) { outstuf << ch[p]; cout << ch[p]; break; } } i = i + strlen(record); m = 2 * n - 1; // 回到根节点 }

这里有几个参数需要说清楚。m初始化为2*n-1,就是根节点在数组里的下标。record数组暂存当前字符的01编码,长度20对于一般字符集够用,但如果字符集很大、树很深,需要调大。password数组存的是从CodeFile.txt读入的01串,末尾手动补了一个'$'作为结束标记。每次译出一个字符后,i跳过已消费的编码长度,m重置回根节点。

注意一个细节:译码时判断叶子节点的条件是HT[m].rchild == 0,而不是HT[m].lchild == 0 && HT[m].rchild == 0。因为哈夫曼树是严格二叉树,不存在只有左孩子或只有右孩子的节点,所以判断一个孩子是否为空就够了。但如果你自己实现时用了不同的建树逻辑,这个条件需要相应调整。

3.3 文件落盘:hfmTree、CodeFile、TextFile的读写时机

实验报告里涉及多个文件:data.txt存字符集和权值,hfmTree.txt存建树用的字符和权值,code.txt存字符到编码的映射表,CodeFile.txt存编码结果,TextFile.txt存译码结果,TreePrint.txt存树形打印结果。文件多不是问题,问题在于打开和关闭的时机。

调试分析里明确记录了一个坑:“明明建立了文件却发现未存入数据,多次调试发现是打开文件的操作放在了循环体内部导致数据无法写入。”这个坑的典型表现是:程序跑完不报错,但打开输出文件一看是空的,或者只有最后一条记录。原因是每次循环都open一次文件,把之前写的内容覆盖了。正确做法是在循环外open,循环内只写,循环结束后close。

另一个容易忽略的是outstuf这个全局ofstream对象。实验报告里多处直接调用outstuf.open(...)和outstuf.close(),如果某次忘记close就再次open,行为是未定义的。我一般会在每次open之前先判断是否已经打开,或者干脆用局部ofstream对象,让析构函数自动关闭。

4. 避坑与排查:哈夫曼编解码实验里最容易翻车的五个点

4.1 编码结果对不上教科书答案

现象:用教科书例6-2的数据建树,输出的编码和书上不一致,但译码能还原原文。原因:哈夫曼树不是唯一的。当存在相同权值的节点时,不同的选取顺序会建出不同形状的树,编码长度分布可能相同,但具体01串不同。实验报告里的Select函数在权值相同时按数组下标顺序选取,如果你的实现用了不同的比较逻辑,结果就会不同。解决:如果题目要求必须匹配教科书答案,需要严格按照教科书给出的选取规则(通常是“权值小的优先,权值相同时下标小的优先”)修改Select函数。如果只要求编码正确且译码可还原,不必强求一致。

4.2 译码时提示“此处不可译码”

现象:译码过程中断,输出“译码在此处出现错误!此处不可译码!”。原因:通常有两种可能。一是CodeFile.txt里的01串不完整,比如编码时程序异常退出导致文件被截断;二是译码时树遍历走错了分支,走到了一个非叶子节点但01串已经结束。实验报告里判断条件是HT[m].rchild != 0,意味着当前节点还有孩子,不是叶子,但已经没有更多01位可读了。解决:先检查CodeFile.txt的内容是否完整,可以在编码后手动打开文件确认01串长度和预期一致。如果文件没问题,检查译码循环里m的重置逻辑——每次译出一个字符后必须把m重置回根节点下标2*n-1,漏掉这一步会导致下一轮从错误位置开始遍历。

4.3 手动输入编码时程序提前结束

现象:选择手动输入编码,输入一段文本后按回车,程序没有输出编码结果就回到了菜单。原因:实验报告里手动输入编码用cin >> file[i]逐字符读取,cin默认跳过空白字符(空格、制表符、换行)。如果输入文本里包含空格,空格会被跳过,导致字符丢失。更严重的是,如果输入的第一个字符就是空格,cin会一直等到读到非空白字符才继续,看起来像程序卡住。解决:如果需要支持空格,改用cin.get(file[i])逐字符读取,或者在输入前用cin >> noskipws关闭跳过空白。实验报告里用#代表空格,说明作者也意识到了这个问题,用替换字符的方式绕过去了。

4.4 建树后打印树形结构输出乱码

现象:选择“输出哈夫曼树”,终端显示一堆数字和横线,但结构完全看不懂。原因:dispHT函数用递归加缩进的方式打印树,缩进层级由level参数控制。实验报告里调用方式是dispHT(HT + 2*n - 1, 1),从根节点开始,层级为1。如果传入的根节点下标不对,或者level初始值不对,打印出来的缩进就全乱了。另外,dispHT里同时往终端和outstuf写数据,如果outstuf没有正确打开,终端输出正常但文件是空的。解决:确认调用时根节点下标是2*n-1,level从1开始。如果只想在终端看,可以先注释掉outstuf相关的行,排除文件流的影响。

4.5 程序运行一次后再次建树崩溃

现象:第一次建树、编码、译码都正常,回到菜单再次选择建树,程序直接崩溃或输出异常。原因:全局指针HT、HC、ch在第一次建树时已经分配了内存,第二次建树时没有释放旧内存就直接覆盖,造成内存泄漏。更严重的是,如果第二次的字符集大小n和第一次不同,HT数组的实际大小和代码里用的n不匹配,后续访问越界。解决:在initHuffmanTree或HuffmanCoding开头加一段释放逻辑:

if (HT) { free(HT); HT = NULL; } if (HC) { for (int i = 1; i <= n; i++) free(HC[i]); free(HC); HC = NULL; } if (ch) { free(ch); ch = NULL; }

注意释放HC时需要先释放每个HC[i]指向的编码字符串,再释放HC数组本身。这个顺序反了会导致部分内存泄漏。

5. 进阶技巧:用凹入表打印树形结构并验证编码长度

5.1 凹入表打印的实现细节

实验报告里的dispHT函数用递归方式打印树,核心思路是:先递归打印右子树(层级+1),再打印当前节点(根据层级缩进),最后递归打印左子树(层级+1)。这样打印出来的效果是根节点在最左边,右子树在上、左子树在下,整体呈横向的树形结构。

void dispHT(HuffmanTree nodeRoot, int level) { if (HT == NULL) return; if (nodeRoot->rchild) { dispHT(HT + nodeRoot->rchild, level + 1); } for (int i = 0; i < level; i++) { if (i < level - 1) { cout << " "; outstuf << " "; } else { cout << "-------"; outstuf << "-------"; } } cout << nodeRoot->weight << endl; outstuf << nodeRoot->weight << endl; if (nodeRoot->lchild != 0) { dispHT(HT + nodeRoot->lchild, level + 1); } }

参数nodeRoot是当前节点指针,实际传的是HT + 下标。level控制缩进量,每层缩进4个空格,最后一层用-------连接权值。这个打印方式的好处是不需要计算树的宽度,递归深度就是树的高度,对于哈夫曼树来说高度不会超过n,不会栈溢出。

5.2 用编码长度验证树的最优性

哈夫曼树的一个关键性质是:所有字符的编码长度加权和最小,也就是Σ(weight[i] * codeLength[i])最小。建完树后,可以用这个公式验证一下自己的实现是否正确。具体做法是:遍历每个叶子节点,从该节点往上走到根,记录路径长度作为编码长度,然后计算加权和。

int totalCost = 0; for (int i = 1; i <= n; i++) { int len = 0; int c = i; while (HT[c].parent != 0) { len++; c = HT[c].parent; } totalCost += HT[i].weight * len; } cout << "加权路径总长度: " << totalCost << endl;

这个值可以和手动计算的预期值对比。如果比预期值大,说明建树过程中选最小节点的逻辑有问题,可能选错了节点。如果和预期一致,说明树是最优的,编码结果可信。

5.3 一个我常用的调试习惯

每次建完树后,我会先不急着编码,而是做两件事:第一,用dispHT打印树形结构,肉眼检查每个内部节点的权值是否等于其左右孩子权值之和;第二,计算加权路径总长度,和手动计算的理论值对比。这两个检查都通过后,再进行编码和译码。这样做的原因是,建树是整条链路的基础,树错了后面全错,而编码和译码的错误往往比较隐蔽,不如建树错误容易定位。

从那以后我每次做哈夫曼相关实验,都强制走一遍“打印树形 + 验证加权路径长度”的流程,省去了大量回头排查的时间。希望帮到你。

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

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

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

立即咨询