简介:一份数据结构课程设计配套报告,聚焦哈夫曼树编译码器的完整实现,面向计算机专业学生、课设答辩者及需要掌握无损压缩算法的自学者。报告基于VC++ 6.0开发环境,完整覆盖初始化、编码、译码、打印代码文件、打印哈夫曼树及退出六大功能模块,从需求分析、总体设计到详细数据结构均有系统说明,重点讲解频率统计、最小堆构建最优二叉树、左右分支生成二进制编码、译码还原等算法流程。资源包共1个doc文件,大小为2.76MB,内容为华北科技学院课程设计说明书,包含系统框图、总流程图、函数列表及主要函数介绍,辅助理解编码器的整体架构与实现细节,可直接用于课程设计报告撰写或实验对照参考。目前已有810人学习下载,适合用于课设参考、考前复习或自学哈夫曼编码原理,也可作为教师布置同类实训的案例素材。
1. 哈夫曼编译码器:课程设计里最容易被低估的一个“简单题”
拿到《数据结构课程设计哈夫曼树编译码器报告》这个题目,很多人第一反应是:哈夫曼树不就是最小堆加递归遍历吗?真动手才发现,建树半小时能写完,后面的坑全在文件格式、比特对齐和解码还原上。这门课设真正考察的,不是会不会建树,而是能否把一个算法变成能处理真实文件的编译码工具。
编译码器的本质,是基于哈夫曼编码的压缩与解压工具:统计字节频率、构建哈夫曼树、生成前缀编码、输出压缩比特流;解码端再从压缩流中重建同一棵树,还原原始内容。适合正在准备课程设计,或想彻底搞懂前缀编码的人。
不少人在这个题目上翻车:建树漂亮,压缩出来的文件自己却解不开。这篇笔记只讲一件事——怎么做到能跑、能解、能答辩。
2. 从字符频率到一棵二叉树:把哈夫曼树建对、建稳
2.1 为什么频率高的字节必须走短路:定长编码与最优前缀码
先明确一个前提:哈夫曼编码不是对“字符”做文章,而是对“字节”做文章。文件在磁盘上是一串字节,取值 0 到 255。拿一本英文小说举例,空格和字母 e 出现几十万次,而某些特殊符号只出现几次。如果所有字节都用固定 8 位存储,大量空间都耗在了“不常出现的字节”上。
变长编码的基本思想,是让高频字节用短码、低频字节用长码。但这里有一个硬约束:任意一个字节的编码,不能是另一个字节编码的前缀。否则解码端读到一长串比特流时,没办法判断当前比特位到底属于哪个字节。满足这个约束的编码叫前缀码,哈夫曼树天然就是前缀码——所有字节都放在叶子节点上,从根到叶子的路径不会穿过另一个叶子,编码之间不存在互相包含的关系。
对比一下三种常见方案:
| 方案 | 编码规则 | 压缩率 | 问题 |
|---|---|---|---|
| ASCII 定长编码 | 每个字节固定 8 位 | 0% | 没有利用频率差异 |
| 等长分组编码 | 每 4 位一组,覆盖 16 种字节 | 视字符集而定 | 通用性差,分组长度难选 |
| 哈夫曼变长编码 | 按频率分配码长 | 文本通常 40%~60% | 需要保存频率表,有头部开销 |
举一个具体的例子:假设文件里只有五种字节,频率分别是 5、4、3、2、1,总共 15 个字节。ASCII 编码需要 15×8=120 位。用哈夫曼编码,一种常见的编码分配是让频率 5 和 4 的字节各占 2 位,频率 3 的占 2 位,频率 2 和 1 的占 3 位,总长度大约 33 到 34 位,取决于频率相同节点之间的合并顺序。这就是“最优前缀码”的价值:平均码长向高频字节倾斜,整体存储量显著下降。所以建树时唯一要做对的事,就是让频率高的字节在树上的路径更短。
2.2 节点结构与优先队列选型:为什么必须用最小堆
建树过程本身不复杂,但选错数据结构会让代码又慢又难写。一个常见错误做法是把节点放进数组,每次线性扫描找最小的两个。对于字符集最多 256 个叶子节点来说,线性扫描在功能上没问题,但课程设计考察的正是“用哈夫曼树体会优先队列的贪心过程”,所以主流做法是用最小堆。
C++ 标准库的priority_queue默认是最大堆,直接 push 节点取出来的是最大值,必须自定义比较器。这里有一个容易绕晕的点:priority_queue的比较器返回true表示“优先级低”,所以想让频率小的节点先出队,比较器里反而要写成“大于”。我一般会写一个结构体,包装节点的 vector 引用,直接在比较器里读数组里的频率值。
#include <queue> #include <vector> using namespace std; struct HuffNode { unsigned char ch; // 叶子节点保存的字节 long long freq; // 权重,用 long long 防止大文件溢出 int left, right; // 左右孩子下标,-1 表示叶子 }; struct NodeCompare { const vector<HuffNode>& nodes; // 引用所有节点,比较时直接读频率 bool operator()(int a, int b) const { if (nodes[a].freq != nodes[b].freq) return nodes[a].freq > nodes[b].freq; // 频率小的优先级更高 return a > b; // 频率相同按下标,保证结果可复现 } };逻辑说明:比较器里拿到的 a、b 不是节点对象,而是节点在 vector 里的下标。堆里只存 int,不复制节点对象,开销小。比较器返回true表示 a 的优先级低于 b,所以频率大反而返回true,这样堆顶永远是频率最小的节点。加一个按下标比较的次级条件,是为了避免频率相同的节点在堆里顺序不稳定——否则同样的输入文件每次压缩出的编码不一样,报告里的数据分析前后对不上,会很被动。
这个设计的另一个好处是避开了指针。节点全部放在同一个 vector 里,下标访问不涉及指针重定位;动态扩容时下标依然有效,而指针方案在 vector 扩容后全部失效。我见过不少同学用new HuffNode加裸指针建树,早期测试没问题,换大数据时莫名崩溃,多半就是指针悬挂。用下标方案可以从根上规避这一类问题。
2.3 左0右1还是左1右0:编码方向与递归边界
树建好之后,从根出发做深度优先遍历,每向左边走一步追加字符'0',向右边走一步追加'1',走到叶子时,路径上的字符串就是该叶子字节的编码。左 0 右 1 或左 1 右 0 都可以,编码端和解码端保持一致就行。真正要注意的是递归边界。
第一个边界是空树。文件为空时,没有任何字节出现,节点数组为空,建树函数应该返回一个特殊值(比如 -1),调用方不能继续往下写数据区。第二个边界是单节点树。整个文件只有一种字节时,树上只有一个叶子,它同时也是根。DFS 到达这个叶子时,累计的编码字符串是空的,空编码写不进文件。处理办法是单独判断:字符种类为 1 时,编码固定写成单个比特'0';解码端检测到种类数为 1,不走树,直接循环输出原始文件大小个该字节。
第三个边界是关于树高的。最多 256 个叶子节点,最坏情况下哈夫曼树退化成一条链,高度可能到 255,递归深度完全在安全范围内。但递归函数里不要传大对象,我一般传string编码字符串的引用或传值,每层追加一个字符,这个开销可以忽略。编码表用unordered_map<unsigned char, string>存储,一个字节的键对应一个编码字符串,后面写文件时直接查表。
3. 编译码器整体设计:文件格式决定你能不能解回来
3.1 压缩文件里到底存什么:头部、频率表、原始长度、有效比特数
很多人的第一版实现只把编码比特流写进文件,解码时才发现根本还原不出一棵一样的树。编译码器要能在编码和解码两个独立进程之间工作,必须把重建树所需的信息持久化。常见做法是存频率表而不是存编码表:编码端统计每个字节出现次数,解码端用同样的建树函数重建一棵完全相同的哈夫曼树。
我一般把压缩文件设计成三块:头部、频率表、数据区。头部固定字段包括魔数、原始文件大小、有效字节种类数、最后一个字节的有效比特数。魔数用来快速校验文件是不是本工具压缩的,我用两个字节HZ。原始文件大小是解码循环的终止条件,必须用long long,因为文件可能超过 4GB。最后一个字节的有效比特数解决尾部比特填充问题:数据区按字节写,最后不足 8 位的部分补 0,但补出来的 0 不是有效编码,解码端必须知道真正有效几位。
| 字段 | 字节数 | 说明 |
|---|---|---|
| 魔数 | 2 | HZ,快速识别压缩文件格式 |
| 原始文件大小 | 8 | long long,解码循环的终止条件 |
| 有效字节种类数 | 2 | unsigned short,最大 256 |
| 末字节有效比特数 | 1 | 1~8,去掉数据区尾部的填充位 |
| 频率表 | 种类数 × 9 | 每项 1 字节字节值 + 8 字节频率 |
频率表每项不要用 4 字节,用 8 字节。因为一个文件里某个字节可能出现的次数超过 21 亿,4 字节的int会溢出。课程设计的测试文件通常不大,但代码里养成用long long的习惯,能避免只在特定大文件上出现的隐蔽 bug。这个选择写进报告里,也是一个能体现设计意识的细节。
另一个常见设计决策是:不要存编码表,只存频率表。存编码表看起来省去了解码端建树的步骤,但编码表是以“字节值 + 编码字符串”的形式存在文件里,字符串长度不一,解析麻烦;更重要的是,编码表本身没有和原始频率形成校验关系,文件一旦被改动,解码结果完全不可控。存频率表的话,解码端重建的树与编码端完全一致,树本身就是协议,不会出现两棵树的形态偏差。
3.2 编码端主流程:统计、建树、生成编码表、写文件
编码端逻辑分四步,顺序不能乱。第一步是二进制方式打开原文件,逐字节统计频率。注意是二进制方式,文本方式在 Windows 平台上会对0x0D 0x0A换行做自动转换,统计出的频率表和实际字节流对不上,压缩和解压就会不一致。统计数组用long long freq[256],下标就是字节值。
第二步是建树并生成编码表。建树函数从频率表出发,把每个出现过的字节变成叶子节点,压入最小堆,然后反复弹出两个最小节点合并成一个父节点,父节点权重等于两个孩子之和,再压回堆,直到堆里只剩一个节点。生成编码表用 DFS,从左孩子走追加'0',从右孩子走追加'1',到叶子时把编码字符串记录进哈希表。
第三步是写头部和频率表。头部字段按 3.1 的布局逐一写入,频率表按“字节值 + 频率”逐项写。这里的顺序要和解析端完全一致,先写哪些字段、每个字段几字节,都要在报告里明确定义。
第四步是重新读原文件,逐字节查编码表,把编码字符串按位写入文件。为什么不是直接用第一步读进内存的数据?如果文件不大,一次性读入内存当然可以复用;但为了演示更通用的做法,我一般让编码端读两遍:第一遍统计频率,第二遍写数据。这样不会因为大文件同时占住原始数据和压缩数据两份内存。写比特时用一个unsigned char当缓冲,每收到一个比特就把缓冲左移一位并写入最低位,凑满 8 位就落盘一次。如果直接把'0'和'1'的 ASCII 码写进文件,压缩结果反而会变成原来的 8 倍,这是新手最容易犯的低级错误。
数据区写完后,缓冲里可能还有不足 8 位的残量,把它左移补 0 后写入文件,并把有效比特数记进头部。如果最后恰好凑满了一个完整字节,有效比特数就是 8。这个字段不能省,否则解码端无法判断末尾的 0 是有效数据还是填充。
3.3 解码端主流程:读头、重建树、沿着树游走还原
解码端的核心不是逐条查编码表,而是沿着树走。从根节点开始,每读一个比特决定走向左孩子还是右孩子:比特 0 走左,比特 1 走右。每到达一个叶子节点,就把该节点保存的字节写进输出文件,然后指针回到根节点,继续读下一个比特。
这样设计的好处是天然支持任意长度的变长编码。不需要比对编码前缀,只要编码端和解码端重建的是同一棵树,走到叶子的时刻一定对应一个完整的编码。解码循环的次数用头部保存的原始文件大小控制,每输出一个字节计数一次,计数到 0 就停止。不要读到压缩文件的物理末尾才停,否则尾部填充的 0 会被还原成多余的字节。
按位读取需要一个辅助函数。fgetc一次读出一个字节,但哈夫曼编码是逐位消费的,所以解码端要维护一个字节缓冲和一个已消费位数计数器,每消费一位就移动一位,缓冲耗尽再fgetc下一个字节。这个过程和编码端的按位写入是镜像操作。
单字符文件的特殊分支要在解码主流程里单独处理:读取头部后,如果有效字节种类数为 1,不建树,直接从频率表里取出唯一的字节值,循环输出原始文件大小次。如果漏掉这个分支,解码时会走进空编码的分支,轻则无限循环,重则越界访问。
4. 核心代码实现:能直接跑通的最小完整版本
4.1 建树:优先队列 + 下标节点的完整代码
前面定义了节点结构和比较器,这里把建树函数完整写出来。这个函数同时被编码端和解码端调用,它接收一个长度为 256 的频率数组,返回根节点在节点数组中的下标。解码端从头部读出频率表后,走同一段逻辑,保证两边树结构一致。
int buildTreeFromFreq(vector<long long>& freq, vector<HuffNode>& nodes) { nodes.clear(); for (int v = 0; v < 256; ++v) { if (freq[v] == 0) continue; HuffNode nd; nd.ch = (unsigned char)v; nd.freq = freq[v]; nd.left = nd.right = -1; nodes.push_back(nd); } if (nodes.empty()) return -1; // 空文件,调用方单独处理 if (nodes.size() == 1) return 0; // 单节点树,根就是唯一的叶子 priority_queue<int, vector<int>, NodeCompare> pq(NodeCompare{nodes}); for (int i = 0; i < (int)nodes.size(); ++i) pq.push(i); while (pq.size() > 1) { int a = pq.top(); pq.pop(); // 最小 int b = pq.top(); pq.pop(); // 次小 HuffNode parent; parent.freq = nodes[a].freq + nodes[b].freq; parent.left = a; parent.right = b; parent.ch = 0; nodes.push_back(parent); pq.push((int)nodes.size() - 1); } return pq.top(); }参数说明:freq是下标 0~255 的频率数组,函数内部按照频率非零的字节创建叶子节点。返回 -1 表示没有任何有效数据,返回 0 表示单节点树,这两种情况都需要调用方做特殊分支。返回的其他值就是根节点的下标。注意这里把内部节点和叶子节点放进同一个vector,整个树的生命周期由vector管理,不需要手动释放,也不会出现指针悬挂。
为什么不把被合并的两个节点从堆里删掉再挂到父节点上?其实已经“删”了——它们通过 pop 离开堆,然后被写进父节点的 left 和 right,相当于挂到了新父节点下面。后续 DFS 从根往下走,只需要 left 和 right 就能访问整棵树,不需要 parent 指针。这个设计让节点结构最小化,也省掉了向上回溯的维护成本。
4.2 生成编码表与按位写文件:编码端的两个核心函数
生成编码表用 DFS,编码字符串通过值传递累积,到叶子时写入哈希表。这里有一个边界处理:单节点树到达叶子时,编码字符串是空的,我在这种情况下强制赋成"0",保证编码端至少能写出一个比特。
void dfsCodes(const vector<HuffNode>& nodes, int cur, string code, unordered_map<unsigned char, string>& table) { if (nodes[cur].left == -1 && nodes[cur].right == -1) { table[nodes[cur].ch] = code.empty() ? string(1, '0') : code; return; } dfsCodes(nodes, nodes[cur].left, code + "0", table); dfsCodes(nodes, nodes[cur].right, code + "1", table); }逻辑说明:叶子节点的特征是 left 和 right 同时为 -1。到达叶子时,把当前累积的 0/1 字符串写入编码表。哈希表的键是unsigned char,直接用字节值做键,写数据时查表快。代码里string(1, '0')生成一个长度为 1 的字符串,这是为了处理单节点树的特殊情况。注意这里牺牲了一点纯粹性:单节点树编码端写一个 0,解码端走特殊分支,两边的行为必须一一对应。
按位写文件的辅助函数如下。编码端对源文件每个字节查表,得到类似"0100"的编码字符串,然后逐字符调用writeBit:
void writeBit(FILE* out, int bit, unsigned char& buf, int& bits) { buf = (buf << 1) | (bit & 1); // 新位进入最低位 if (++bits == 8) { // 凑满 8 位,落盘 fputc((int)buf, out); buf = 0; bits = 0; } }参数说明:buf是累积缓冲,bits是当前缓冲里的有效位数。每次写入前缓冲左移一位,把最低位空出来,再并入当前比特。当bits增加到 8 时,缓冲刚好是一个完整字节,fputc落盘并复位。调用方在写完整个数据区后必须检查bits:如果不是 0,说明最后不足 8 位,补 0 后写出,并把有效位数记录到头部。如果最后恰好bits为 0,说明数据正好对齐 8 位,此时末字节有效比特数应记为 8,不能记为 0。这个细节编码端和解码端必须统一口径。
4.3 按位读文件与解码循环:还原原始内容的最后一步
解码端对应readBit,逻辑是writeBit的镜像:维护一个字节缓冲和一个已消费位数计数器,每消费一位移动一位,缓冲耗尽时读入下一字节。
int readBit(FILE* in, unsigned char& buf, int& bits) { if (bits == 0) { // 缓冲耗尽,读入下一字节 int c = fgetc(in); if (c == EOF) return -1; // 数据意外截断 buf = (unsigned char)c; bits = 8; } int bit = (buf >> 7) & 1; // 取最高位 buf = (buf << 1); // 消费该位 bits--; return bit; }逻辑说明:bits表示当前缓冲里还没被消费的位数。首次调用时bits为 0,触发fgetc把缓冲填满 8 位。每次取最高位,然后把整个缓冲左移一位,相当于把消费过的位移出。返回 -1 表示文件意外结束,调用方应当立即报错而不是继续解码,否则会输出一段错乱的数据。
解码主循环按“原文件字节数”控制,而不是按压缩数据字节数控制。每输出一个字节,计数器减一,归零即停止。
bool decodeFile(FILE* in, FILE* out, long long originalSize, const vector<HuffNode>& nodes, int root) { unsigned char buf = 0; int bits = 0; int cur = root; for (long long n = 0; n < originalSize; ++n) { if (nodes.size() == 1) { // 单节点树,直接输出唯一字节 fputc(nodes[root].ch, out); continue; } while (nodes[cur].left != -1) { int bit = readBit(in, buf, bits); if (bit < 0) return false; // 数据截断 cur = (bit == 0) ? nodes[cur].left : nodes[cur].right; } fputc(nodes[cur].ch, out); cur = root; // 回到根,开始下一个字节 } return true; }参数说明:nodes是解码端重建的树,root是根节点下标,originalSize来自头部。内层循环从根出发,沿着比特一路向下走,遇到叶子就输出并回到根。单节点树的特殊分支放在循环内,好处是主流程只有一条循环,不容易漏分支。这里用nodes.size() == 1判断单节点树,而不是检测root的左右孩子,更直观也更安全。到这一步,一个能处理任意文件、能压能解的最小完整版本就成立了。
5. 避坑指南:哈夫曼编译码器最容易翻车的 5 个细节
5.1 现象:解压后的文件尾部多出几个乱码字符
这是哈夫曼课程设计里出现频率最高的问题。压缩文件的数据区按字节写,最后一个编码很可能不足 8 位,编码端在缓冲后面补了 0 凑成完整字节;解码端不知道哪里是有效数据的结束点,把填充的 0 也当成有效编码继续沿树走,结果多还原出几个完全无关的字节。
原因就是头部没有记录“末字节有效比特数”,或者解码循环的终止条件用的是压缩文件大小而不是原始文件大小。解决方法是双保险:头部显式保存有效比特数,取值 1~8;同时解码循环用原始文件大小控制输出次数,输出到数量就停。我还会在解码循环内检查readBit的返回值,如果压缩文件被意外截断,立刻报错退出,不要静默补输出。
5.2 现象:小文件一切正常,大文件压缩出来解不开
某个字节出现次数超过约 21 亿时,int频率会溢出成负数,优先队列里出现负权重,整棵树直接建错。这个问题在课程设计的测试数据里一般不出现,但一旦源文件是几个 GB 的二进制数据,“小文件正常、大文件翻车”的排查过程会非常折磨人。另一个相关的问题是写头部频率表时用int强传,溢出发生在落盘那一瞬间。
解决方法是统一用long long存频率,头部每项频率固定 8 字节。不要为了省几个字节去精打细算,64 位权重换来的是对所有文件大小的兼容。老师问起来,你可以直接回答“频率可能超过 int 上限,所以用 64 位整数存储”,这是一个很容易拿分的答辩点。
5.3 现象:压缩包在自己机器上能解,拷到别的机器上就失败
一种常见写法是只把“字节值 + 编码字符串”写进文件,解码端不重建树,而是拿编码表逐条比对。这个方案在编码表格式固定、且字节种类很少时偶尔能跑通;一旦源文件是中文文本或二进制数据,编码表项数增加,字符串前缀匹配的效率和正确性都会出问题。更隐蔽的是,如果编码表里的编码顺序或者长度字段写错一个位置,换台机器解析就直接错位。
解决方法是放弃存编码表,改成在文件头存频率表。解码端复用buildTreeFromFreq重建同一棵树,再沿树游走。这样“树”成为两端唯一的协议,树由频率表唯一确定,不存在编码表格式不一致的问题。报告里可以专门写一段设计决策,解释为什么存频率表比存编码表更可靠。
5.4 现象:程序正常跑完,退出时崩溃或双重释放
用new动态创建节点、用指针left/right构建树,然后在析构时删除根节点,子节点全部变成悬垂指针;或者编码端和解码端各自建了一棵树,析构函数写得不对称。哈夫曼树是一棵任意形态的二叉树,不是完全二叉树,递归释放必须严格按照“先释放左右子树,再释放自己”的顺序来,顺序一错就是崩溃。
解决方法是改用 vector 下标方案。所有节点放在同一个动态数组里,程序结束时随 vector 析构统一释放,不存在手动 delete 的时机问题。如果出于演示目的坚持用指针,务必写一个递归的deleteTree(node*),先递归删除左右孩子,再删除自身,同时保证一个节点不会同时被两个父节点引用。我在帮同学排查时,这类崩溃经常在答辩演示的大文件上爆发,越早换成下标方案越省心。
5.5 现象:中文文本解压后乱码,图片压缩后对不上原文件
本质原因是把输入当“文本”处理了。用fscanf、gets、fgets读文件,会在换行符或某些字节上做自动转换;用char数组存字节,又没考虑有符号数的问题。哈夫曼编码的对象是字节流,不是字符流。中文在 UTF-8 下是 2 到 4 个字节,图片、音频的字节分布根本没有“字符”概念,任何按文本方式读文件的代码都会翻车。
解决方法是文件一律用"rb"/"wb"二进制方式打开,读取和写入统一走unsigned char,频率统计遍历全部 256 个字节值,而不是先划分“字符种类”。做到这三点,这个编码器就可以压缩任意文件,不只是 .txt。我通常拿一张大图片和一个视频做验证,能完整还原才算通过。
6. 验证与进阶:从“跑通”到“敢答辩”的加分操作
6.1 压缩率与一致性验证:用这三组数字说话
跑通之后先验证正确性,再谈压缩率。正确性的唯一标准是逐字节对比:解码输出文件和原始文件用二进制方式逐个字节比对,任何一个字节不同都算失败。在 Linux 下直接使用cmp命令,Windows 下可以写一个几十行的对比函数。第二步记录压缩率,计算公式是压缩文件大小除以原始文件大小。文本文件通常能压到 50% 到 70%,随机数据几乎压不动,甚至因为头部开销超过 100%。
| 测试文件 | 原始大小 | 压缩后大小 | 压缩率 |
|---|---|---|---|
| 英文文本 | 100 KB | 约 55 KB | 约 55% |
| 中文文本 | 100 KB | 约 65 KB | 约 65% |
| 随机二进制 | 100 KB | 约 101 KB | 约 101% |
上表只是示意,实际数值以你的测试结果为准。随机数据压不动是正常的,说明哈夫曼压缩的是统计冗余,而不是魔法。把这三组数据写进报告,比一大段原理更能证明你真的跑过完整流程。
6.2 边界测试用例:空文件、单字符文件、全相同字符
空文件是最容易被忽略的用例:频率表全为 0,节点数组为空,buildTreeFromFreq返回 -1。编码端应该直接写出一个“原始大小为 0”的头,解码端遇到原始大小为 0 时直接生成空文件,不能去建树。单字符文件如“全是空格”,字符种类只有 1,走单节点树分支。全相同字符的极端情况是压缩后大小接近头部大小,数据区只有一两个比特。这三个边界用例必须进报告的测试部分,答辩时老师几乎必问。
6.3 两个值得做的进阶方向
第一是动态哈夫曼编码:不再需要先统计全文件频率再生成树,而是边读文件边调整树结构,好处是压缩流可以直接从源文件流式生成,不用保存完整频率表,适合网络传输场景。第二是分块哈夫曼:把大文件切成 64KB 的块,每块独立统计建树,对代码、日志这类局部性强的文件,压缩率能进一步提升。两个方向都在现有代码上改动不大,却能明显拉开完成度。
我现在的习惯是拿到任何编码类题目,第一件事永远是先定文件格式,把“编码端写什么、解码端读什么”画清楚,再动手写树。格式定稳了,树写错了最多是压缩率难看;格式没定,树再漂亮也解不回来。希望帮到你。
本文还有配套的精品资源,点击获取