简介:面向数据结构与算法综合实验的高校学生,依托武汉理工大学教学项目,这份基于Visual Studio 2017的C++完整工程聚焦二叉树与赫夫曼图片压缩,旨在通过实际编码演练掌握最优二叉树构建和无损压缩原理。压缩包共含36个文件,涵盖头文件和实现代码(h/cpp)、Visual Studio工程与解决方案(vcxproj/sln)、编译过程中生成的obj/pdb/ipch等中间产物,以及可直接运行的exe程序,整个压缩包约29.27MB。目前已有1583人参与学习下载。源码完整实现了图片读取、像素频率统计、赫夫曼树构造、编码字典生成、数据压缩编码以及解码还原的全部流程,配套工程可在VS2017中直接打开运行和断点调试。通过阅读和复现代码,读者能直观看到二叉树在压缩算法中的具体角色,理解高频短码、低频长码的编码策略,同时获得一份结构规范的课程设计参考,对提升编程调试能力与算法应用水平有明显帮助。
1. 用二叉树压缩图片:这不是理论题,是真能跑的课设
A同学拿到“数据结构与算法综合实验—二叉树与赫夫曼图片压缩”这个题目时,第一反应是“二叉树还能干这个?”其实这正是赫夫曼编码的经典落地场景:把图片像素看成一个个字节值,统计每个字节值出现的频率,用赫夫曼树生成不等长编码,让高频字节用短编码、低频字节用长编码,重新写成比特流之后,文件体积就下来了。这个项目不算大,但二叉树遍历、优先队列、位运算、文件格式解析全都会碰到,适合正在找课程设计参考、或者想把数据结构学活的人。拿到代码之后编译就能看到一张 BMP 被压小、还能解压还原,接下来我按“设计—核心代码—跑通—避坑—改造”的顺序把它拆开讲。
2. 实验拆解与技术选型:BMP 文件怎么变成比特流
课程设计最忌讳拿到题目就写代码。A同学先按“输入—处理—输出”把需求拆成了四个问题:读入的图片是什么格式、压缩后的文件里存什么、怎么保证能还原、用什么数据结构组织频率信息。这四个问题决定了整个项目的形态,也决定了后面每一行代码的归属。
2.1 实验要求决定了项目的整体形态
“赫夫曼图片压缩”在数据结构课设里属于综合应用型题目,它要求的不只是建一棵树,而是把树算法真正用于一个实际文件。通常一个合格的实现至少要覆盖六个环节:读取图片文件、统计每个字节值出现的次数、用频率建赫夫曼树、生成每个字节对应的编码、把编码按位写入压缩文件、再从压缩文件解压还原。
很多同学把压缩和解压分开写,压缩一边建树一边输出,解压时又重新算一遍,结果两边树不一致。项目里常见的做法是把“赫夫曼树构建”做成一个独立模块,建树只做一次,之后把编码表存进压缩文件,解压时根据编码表重建树。这样压缩器和解压器之间只靠一个格式约定通信,代码边界非常干净。
从程序形态上看,这是典型的命令行工具:main 负责接收参数,调用压缩或解压接口,打印统计信息。没有界面反而好测试,因为每一步都能用命令行参数复现,出问题时也容易定位。A同学在实验报告里写“本程序由四个模块组成”,其实就是把压缩、解压、赫夫曼树、位读写这四件事分开。
2.2 为什么用 BMP 做实验对象
选 BMP 不是因为它小巧,而是因为它“朴素”。BMP 是未压缩的位图格式,像素数据直接排列在文件头部之后,没有 DCT 变换、没有颜色子采样这些 JPEG 的复杂概念。对数据结构实验来说,处理 BMP 只需要解析两个文件头,然后把剩下的字节交给赫夫曼模块即可,难度刚好卡在“能写”和“有得讲”之间。
24 位 BMP 的基础结构很固定:前 14 个字节是 BITMAPFILEHEADER,其中偏移 10 到 13 记录像素数据偏移量;再往后 40 个字节是 BITMAPINFOHEADER,其中偏移 34 到 37 记录像素数据大小。像素数据从 bfOffBits 处开始,通常是 54。常见的坑在这里已经埋了一个:BMP 每一行像素的字节数必须补齐到 4 的倍数,如果图片宽度不是 4 的倍数,行尾会多出 0 到 3 个填充字节。
A同学第一次读取时直接按宽乘高乘 3 去读,结果图片边缘出现斜向偏移。原因就是没有考虑行对齐。后面会在避坑章节详细说这个,这里只需要记住:BMP 的像素区是一个“带行填充的二维数组”,不是连续的一维数组。
2.3 目录结构:四个模块怎么划分
拿到项目源码后,先看目录结构能快速建立全局认识。一个常见的工程化组织方式是这样的:
|-- main.c # 入口,命令解析:-c 压缩,-d 解压 |-- huffman.h # 赫夫曼树与编码表接口 |-- huffman.c # 建树、生成编码、释放树 |-- bitio.h # 位读写接口 |-- bitio.c # 按位读写文件,解决字节补齐 |-- bmp_io.h # BMP 解析接口 |-- bmp_io.c # 读取 BMP 文件头、像素数据 |-- compress.c # 压缩主流程 |-- decompress.c # 解压主流程 `-- test/ |-- sample.bmp # 测试图片 |-- sample.huf # 压缩产物 `-- restore.bmp # 解压还原图这样的划分把“树算法”和“文件格式”解耦了:huffman.c 只关心频率数组和字节值,完全不知道 BMP 的存在;bmp_io.c 只负责从 BMP 文件里把像素字节抠出来;bitio.c 只处理比特流的写入和读出。main.c 里做的事情只有调度。
这一层设计值得学:如果把所有逻辑都堆在 main.c,看起来代码很多,但改一个地方会牵动全局;按数据流拆成四个模块后,A同学在做二次开发时只改了 bmp_io.c 就支持了灰度 BMP,huffman 模块一行没动。
2.4 自定义压缩格式:给程序一个“可逆”的边界
压缩文件不是把比特流一写了之,还必须让解压器知道原始信息。项目里通常自定义一个 .huf 格式,结构如下:
| 偏移 | 字段 | 大小 | 作用 |
|---|---|---|---|
| 0-13 | BMP 文件头 | 14 字节 | 解压时原样写回 |
| 14-53 | BMP 信息头 | 40 字节 | 解压时原样写回 |
| 54 | 最后字节有效位数 | 1 字节 | 避免位补齐造成多余数据 |
| 55-58 | 原始像素字节数 | 4 字节 | 控制解压终止条件 |
| 59-60 | 编码表条目数 | 2 字节 | 最多 256 条 |
| 61 起 | 编码表 | N 字节 | 每条含字节值、编码长度、编码值 |
| 尾部 | 压缩比特流 | 不定长 | 像素数据按赫夫曼编码后的结果 |
这个格式就是压缩器与解压器之间的“接口契约”。A同学第一次没有保存“最后字节有效位数”,解压出来的图尾巴上多了一片雪花;第二次没有保存原始像素字节数,碰到编码表刚好能让树走到更深处时,程序会多解压出几个字节。这两个字段缺一不可。
3. 核心模块拆解:从统计频率到赫夫曼编码表
赫夫曼编码的核心思想不复杂:频率越高的字节,编码越短;频率越低,编码越长。但代码落地时有三个细节必须处理好:森林怎么组织、节点怎么比较、编码表怎么生成。这一章把这三个问题逐个过一遍。
3.1 结构定义:叶子节点和最小堆
每个字节值对应一个叶子节点,内部节点只有左右孩子和频率。这里最容易翻车的是把 ch 定义成 char。C 语言里 char 有没有符号由编译器决定,字节值 0x80 以上的值会被当成负数,频率统计就直接错乱了。项目里统一用 unsigned char 来存字节值。
typedef struct HufNode { unsigned char ch; // 叶子节点对应的字节值 0~255 unsigned long freq; // 出现次数,必须用 unsigned long struct HufNode *left; struct HufNode *right; } HufNode;freq 用 unsigned long 而不是 int,是因为一张几兆像素的图片里,某个字节值出现次数可能超过 21 亿,int 会溢出变成负数,后面建树时的加法就全错了。这个改动看似不起眼,实际是 A同学用一张 4K 测试图跑出来的血泪经验。
建树时需要频繁取出当前频率最小的两个节点,最合适的结构是二叉堆。因为字节值最多 256 种,堆数组长度直接定为 256,不需要动态扩容:
typedef struct { HufNode *data[256]; int size; } MinHeap;堆节点比较的逻辑里,我一般会在频率相同时做一次“握手”,避免两个相同频率节点的顺序飘忽不定。这里先只比较频率,等到避坑章节再讲稳定性的问题。
int nodeLess(HufNode *a, HufNode *b) { return a->freq < b->freq; }3.2 建树:每次合并频率最小的两个节点
建树的循环很直观:把森林里最小的两个节点取出来,合并成一个内部节点,放回堆里,直到堆里只剩一个节点,那就是赫夫曼树的根。
HufNode* buildHuffmanTree(unsigned long freq[]) { MinHeap h; initHeap(&h); // 每个出现过的字节值生成一个叶子节点 for (int i = 0; i < 256; i++) { if (freq[i] > 0) { HufNode *node = malloc(sizeof(HufNode)); node->ch = (unsigned char)i; node->freq = freq[i]; node->left = node->right = NULL; heapPush(&h, node); } } // 反复合并最小的两棵树 while (h.size > 1) { HufNode *a = heapPop(&h); HufNode *b = heapPop(&h); HufNode *parent = malloc(sizeof(HufNode)); parent->freq = a->freq + b->freq; parent->left = a; parent->right = b; heapPush(&h, parent); } return heapPop(&h); }这段代码的输入 freq 是长度 256 的频率数组,下标就是字节值,值就是出现次数。每次 heapPop 取出当前最小频率节点,parent 的左右子树顺序决定了编码是左 0 还是右 1。这里虽然随意,但压缩和解压必须使用同一套顺序规则,否则会出现同一个字节在压缩时走左子树、解压时走右子树的情况。
堆操作的时间复杂度是 O(log n),n 最大 256,所以整体建树是 O(256 log 256),可以看成常数操作。真正耗时的是后面的文件遍历和位写入。
3.3 生成编码表:DFS 向左记 0,向右记 1
树建好之后,从根节点做一次深度优先遍历,每向下一层把编码左移一位,叶子节点就把当前编码存进数组。赫夫曼编码是前缀编码,因此不可能出现一个编码是另一个编码的前缀,这是 DFS 遍历天然保证的。
void buildCodeTable(HufNode *root, unsigned int code, int depth, unsigned int codes[], int lens[]) { if (root == NULL) { return; } // 叶子节点:记录编码值和编码长度 if (root->left == NULL && root->right == NULL) { codes[root->ch] = code; lens[root->ch] = depth; return; } // 向左是 0,向右是 1 buildCodeTable(root->left, code << 1, depth + 1, codes, lens); buildCodeTable(root->right, (code << 1) | 1, depth + 1, codes, lens); }调用时 code 和 depth 都从 0 开始。codes 数组用字节值做下标,lens 数组记录对应编码的位数。为什么不直接用字符串存编码?如果用 char* 存每个字节的编码,256 个字符串累加起来不仅占内存,写文件时还要额外处理字符串长度。用 unsigned int 加 lens 的方式,压缩时只需按位段取出 code 的二进制位,写文件非常顺。
这里还有一个边界要注意:赫夫曼树可能退化成深度超过 32 的链表吗?理论上如果 256 个频率呈斐波那契比例分布,深度会接近 256,unsigned int 的 32 位就不够存。实践中普通图片不会出现这种极端分布,但代码里可以加一个 depth 上限检查,超过 31 位就报错或改用数组存编码。A同学后来做通用文件压缩时补了这个检查,属于很有价值的防御性编程。
3.4 手动模拟一遍:理解树与编码的关系
为了验证逻辑,A同学用一行极简数据做过推演。假设像素字节流里只有 a、b、c 三个值,频率分别是 4、2、1。合并 b 和 c,得到频率 3 的内部节点;再合并 a 和这个内部节点,根节点频率 7。左子树是 a,右子树是内部节点。这样编码表是 a=0,b=10,c=11。
压缩前每个字节 8 位,7 个字节要 56 位。压缩后编码总长度是 4×1 + 2×2 + 1×2 = 10 位,只有原来的五分之一左右。这个例子直观说明了一件事:赫夫曼压缩的收益来自频率分布的不均匀,如果每个字节值频率都差不多,编码平均长度会接近 8,再加上编码表开销,压缩后反而可能更大。
4. 压缩与解压:从像素流到比特流的完整链路
有了赫夫曼树和编码表,剩下的问题是怎样把编码写进文件,以及怎样从文件里读出来。文件的最小单位是字节,而赫夫曼编码是长度不一的比特串,所以这一章必须解决“按位读写”的问题。
4.1 位写入:让每个 0/1 真正占一个 bit
按位写入的思路很简单:维护一个字节缓冲区和当前已填充的位数。每次写一个 bit,把它放到缓冲区的低位,当缓冲区凑满 8 位,就作为一个字节写入文件。
typedef struct { FILE *fp; unsigned char buf; int bitCnt; } BitWriter; void initBitWriter(BitWriter *bw, FILE *fp) { bw->fp = fp; bw->buf = 0; bw->bitCnt = 0; } void writeBit(BitWriter *bw, int bit) { // 先左移,再把当前 bit 放进最低位 bw->buf = (bw->buf << 1) | (bit & 1); bw->bitCnt++; if (bw->bitCnt == 8) { fputc(bw->buf, bw->fp); bw->buf = 0; bw->bitCnt = 0; } } void flushBitWriter(BitWriter *bw) { // 尾部不足 8 位时,左移补 0 再写入 if (bw->bitCnt > 0) { bw->buf <<= (8 - bw->bitCnt); fputc(bw->buf, bw->fp); bw->buf = 0; bw->bitCnt = 0; } }flush 函数是压缩流程的收尾动作。最后一次写入时,缓冲区往往不满 8 位,如果不补齐,文件就会少一个字节;补齐又会在尾部引入多余的 0,解压时如果不加控制,这些 0 会被当成像素数据读出来。所以 .huf 头部里 “最后字节有效位数” 这一项就是为了在解压时丢弃补齐位。
压缩像素字节时,从 codes[index] 里按 lens[index] 逐位取出编码,调用 writeBit 写入。注意读取顺序要和生成顺序一致:生成编码时高位是根节点方向,写入时也应当从最高位开始写,否则解压时按位走树的顺序就反了。这里建议用一个 for 循环从 len-1 递减到 0。
4.2 压缩主流程:统计、建树、写头、编码
压缩函数把所有步骤串起来。先是读取 BMP,统计像素区的频率,然后建树、生成编码表,再打开输出文件写 .huf 头,最后把像素数据逐个字节编码写入。
void compressImage(const char *bmpPath, const char *hufPath) { unsigned char *pixels = NULL; long pixelSize = 0; unsigned long freq[256] = {0}; // 1. 解析 BMP,得到像素字节流和大小 pixels = readBmpPixels(bmpPath, &pixelSize); // 2. 统计频率 for (long i = 0; i < pixelSize; i++) { freq[pixels[i]]++; } // 3. 建树 + 生成编码表 HufNode *root = buildHuffmanTree(freq); unsigned int codes[256] = {0}; int lens[256] = {0}; buildCodeTable(root, 0, 0, codes, lens); // 4. 写 .huf 头与编码表 FILE *out = fopen(hufPath, "wb"); writeBmpHeader(bmpPath, out); fwrite(&lastByteBits, 1, 1, out); fwrite(&pixelSize, sizeof(long), 1, out); writeCodeTable(out, codes, lens); // 5. 像素数据按赫夫曼编码写为比特流 BitWriter bw; initBitWriter(&bw, out); for (long i = 0; i < pixelSize; i++) { writeBits(&bw, codes[pixels[i]], lens[pixels[i]]); } flushBitWriter(&bw); fclose(out); free(pixels); freeHuffmanTree(root); }readBmpPixels 返回的 pixelSize 是整个像素区的有效数据长度。注意 BMP 行尾的填充字节也会被统计和压缩,虽然颜色信息不包含它们,但它们也是文件的一部分,解压时原样写回才能保证图片尺寸不变。
这一步最容易出问题的点在于写编码表。编码表不能只写 codes,因为 codes 里的二进制高位可能没有意义,必须同时写入 lens。项目里每条编码表记录包含三个字段:原始字节值(1 字节)、编码长度(1 字节)、编码值(4 字节)。读表时按编码长度把编码值从最高位开始逐位插入到重建树中。
4.3 解压还原:顺着赫夫曼树把比特流走回字节
解压过程是压缩的逆过程:读出头信息,重建赫夫曼树,然后逐位读取压缩数据,从根节点开始,遇到 0 走左子树、遇到 1 走右子树,走到叶子就把叶子节点的字节值输出,然后回到根节点,读下一个编码。
void decompressImage(const char *hufPath, const char *outPath) { FILE *in = fopen(hufPath, "rb"); writeBmpHeaderToFile(in, outPath); // 原样拷贝 BMP 头 unsigned char lastBits = fgetc(in); long pixelSize = 0; fread(&pixelSize, sizeof(long), 1, in); HufNode *root = newHufNode(0, 0); rebuildTreeFromCodeTable(in, root); // 按编码表重建树 BitReader br; initBitReader(&br, in); FILE *out = fopen(outPath, "ab"); long written = 0; HufNode *p = root; while (written < pixelSize) { int bit = readBit(&br); p = bit ? p->right : p->left; if (p->left == NULL && p->right == NULL) { fputc(p->ch, out); written++; p = root; } } fclose(out); fclose(in); freeHuffmanTree(root); }解压终止条件不是“文件读完了”,而是“已经写出了 pixelSize 个字节”。这是处理尾部补齐的关键。lastBits 记录最后一个字节里真正有用的位数,但在上面这段循环里我们没有显式使用它,因为 written 到达 pixelSize 后循环直接退出,多余的补齐位根本没有机会被读取。lastBits 的作用更多是用于校验和解压后做一次完整性检查,例如对比还原文件与原始文件的大小。
int readBit(BitReader *br) { if (br->bitCnt == 0) { br->buf = (unsigned char)fgetc(br->fp); br->bitCnt = 8; } int bit = (br->buf >> 7) & 1; br->buf <<= 1; br->bitCnt--; return bit; }readBit 从缓冲区的最高位取数据,每次取完左移一位。这与 writeBit 从最低位写入的方向是对称的,压缩和解压只要有一侧顺序不一致,还原出来的图片就会错位甚至完全花屏。A同学第一次实现时压缩从左往右取编码位,解压从右往左重建树,结果整张图全是噪点,后来把两侧统一成“从最高位开始”才恢复正常。
4.4 跑起来看什么:压缩比与正确性
程序写完先别急着压大图,找一张几十 KB 的小 BMP 做冒烟测试。命令行输出大致会长这样:
> ./hufcmp -c test/sample.bmp test/sample.huf BMP size : 162000 bytes Compressed size : 108542 bytes Ratio : 67.0% Encoding table : 34 entries拿到这个输出要会看三个指标。第一是压缩比是否明显小于 100%,如果接近或超过 100%,说明图片自身频率分布太平滑或图片太小,应当检查统计和建树逻辑。第二是编码表条目数是否合理,24 位 BMP 的颜色字节值通常不会占满 256 个,条目太少或太多都可能有问题。第三是解压后图片必须和原始图片逐字节一致,可以写一个小命令对比两张 BMP 的文件大小,再用内存比较校验像素区是否完全相同。
如果解压后的图与原图不完全一致,优先检查编码表写入和读取是否使用了相同的字段顺序、位写入与位读取的字节顺序是否对称。这两个问题占了课设排错的大头,下一章会专门讲。
5. 避坑指南:赫夫曼图片压缩最常见的五个翻车现场
这个课设的代码量不大,但几乎所有同学都会在同样的地方踩坑。下面五条是从 A同学自己实现和帮别人调试的经历里总结出来的,每一条都按“现象—原因—解决”写清楚。
5.1 压缩后文件不降反升
现象:压了一张小尺寸 BMP,生成的 .huf 比原图还大,压缩比超过 100%。明明赫夫曼编码是压缩算法,怎么会变大。
原因:赫夫曼的收益来自频率分布不均匀。如果图片本身很小、颜色值接近均匀分布,每个字节平均编码长度接近 8 位,几乎没省下空间。同时 .huf 还要额外保存 BMP 头、编码表、若干元信息,这些开销在小文件上一对比就非常刺眼。
解决:传统做法是在压缩完成后比较压缩流大小与原始像素大小,如果平均编码长度大于等于 8,说明压缩无收益,就在 .huf 头部写一个“未压缩”标记,后续像素数据原样存放。解压时看到这个标记,直接拷贝到输出文件即可。也可以在压缩前先对频率数组算一遍平均码长,预判是否值得压缩。
5.2 解压出来的图片花屏,边缘出现斜向偏移
现象:程序能跑通,但还原出来的 BMP 颜色错乱,尤其是图片底部和右侧有明显“斜切”痕迹,逐像素对比时出现大量值对不上。
原因:24 位 BMP 的每一行像素字节数必须补齐到 4 的倍数。假设图片宽度是 33 像素,每行像素是 99 字节,但每行实际存储是 100 字节,行尾多 1 个填充字节。如果按“宽×高×3”去读像素区,后面的行会整体向前偏移一个字节,颜色当然全乱。
解决:统计和压缩时按 BMP 实际的 pixelSize 读取,也就是图片大小减去 bfOffBits;解压还原时原样保留行填充。计算行字节数用公式:paddedBytes = ((width * 24 + 31) / 32) * 4。A同学在 bmp_io.c 里用一个 readBmpPixels 函数封装了这个逻辑,压缩模块完全不需要知道行填充的存在。
5.3 解压出来的图片尾部多出雪花点,或比原来多几个字节
现象:主体图正确,但图片最底部或数据末尾多出一些随机颜色的小点,对比文件大小也比原图多出几个字节。
原因:位写入最后不足 8 位时通过左移补零,这些补零在解压时如果被当作赫夫曼编码读取,就会走到额外的叶子节点,输出多余的字节。
解决:在压缩文件头部保存“最后一个字节有效位数”以及“原始像素字节数”两个字段。解压循环以原始像素字节数为终止条件,一旦输出数量达到 pixelSize,立即停止,不再读取剩余比特。补零存不影响文件正确性,只要解压器不消费它们就行。
5.4 频率相同的节点顺序不稳定,编码表两边对不上
现象:压缩正常,解压时程序在某些图片上报错,或者还原出来是花屏;换一台电脑、换一个编译器,同样的输入却产生不同结果。
原因:建树时如果两个节点的频率相同,堆排序不保证它们的相对顺序。压缩时把编码表写进文件,当时用的树和解压时重建的树不是同一棵时,虽然结构类似,但某些相同频率节点的左右位置可能互换,导致编码不匹配。
解决:最稳的方案是压缩时把编码表完整写入 .huf,解压时根据编码表重建树,而不是重新统计频率再建树。同时在节点比较函数里增加“频率相同时,叶子节点按字节值排序”的规则,让同样输入的运行结果永远一致。A同学把它叫做“赫夫曼树的后悔药”,看似多写一个重建函数,实际减少了大量玄学问题。
5.5 用 int 统计频率和存编码,大图上直接溢出
现象:压一张几十兆的大图时,统计出来的频率变成负数,建树结果明显错误;某些字节的编码值输出时符号位发癫,位流错乱。
原因:int 最大只能表示 21 亿多,一张未压缩的大图里高频字节很容易超过这个数。编码值用 int 保存时,如果深度达到 31 位以上,左移会移进符号位,变成负数,随后按位写入时掩码操作也会跟着出错。
解决:频率数组用 unsigned long,编码值用 unsigned int,并且重点记住“只在叶子节点记录编码,内部节点不记录”。同时在生成编码表时增加深度检查,超过 32 位就改用数组形式逐位保存编码。普通图片几乎不会触发,但这道防线让自己写通用压缩器时少了一次大翻车。
6. 二次开发:从课程设计到能写进简历的作品
一个课设要变成拿得出手的作品,关键不是加界面,而是看它能不能脱离原始题目限制。这里给两条最实际的改造路径。
6.1 把压缩对象从 BMP 换成任意文件
BMP 只是赫夫曼编码的一个载体,把 bmp_io.c 换成通用文件读取后,整个程序就变成了“通用无损压缩器”。改造点非常集中:压缩时不再拷贝 BMP 头,而是在 .huf 头部记录原始文件长度、文件扩展名;统计频率时直接对整个文件字节流扫描;解压时按记录的文件扩展名还原输出。
// 压缩头部:从“拷贝 BMP 头”改成“记录原始长度与文件名” unsigned long origSize = getFileSize(inputPath); fwrite(&origSize, sizeof(unsigned long), 1, out); fwrite(ext, 1, strlen(ext) + 1, out);这个改动让赫夫曼模块、位读写模块完全复用,只动了入口处的文件元信息处理。做完之后可以压文本、压 JSON、压日志文件,这些场景下频率差异大,压缩效果往往比图片更直观。再配合压缩比对比,就能很好地展示“数据分布与压缩率的关系”这个主题。
6.2 加上“不压缩标记”和压缩率统计
小文件压完反而变大是硬伤,所以在文件头里加一个标志字节:0x00 表示采用赫夫曼压缩,0x01 表示未压缩原始存储。压缩完成后比较原始大小与压缩流大小,决定最终采用哪种方式。解压时先读标志,走两个不同的还原分支。
| 场景 | 原行为 | 改进后 |
|---|---|---|
| 大图、频率分布集中 | 压缩率高,正常写编码流 | 保持不变 |
| 小图、频率分布均匀 | 压缩后文件变大 | 自动回退为原样存储 |
| 日志/文本文件 | 压缩率较高 | 保持赫夫曼压缩 |
| 已压缩的 ZIP/JPEG | 二次压缩效果差 | 自动回退,避免劣化 |
这个改进能明显提升程序的“智商”,也是答辩时很自然的加分点:你没有只实现算法,而是考虑到了算法适用的边界。A同学把这个项目改成通用压缩器之后,又补了一个简单的测试脚本,每次改动后自动压一批不同类型的文件,对比原文件和解压文件的字节数。从那以后我每次做压缩类改动,都会强制把“统计、压缩、解压、比对”四步完整走一遍,确认中间产物可复现再提交代码。这种验证习惯比代码本身更值钱,希望帮到你。
本文还有配套的精品资源,点击获取