☰
Huffman文件压缩课设实战:频率统计、最小堆建树与按位读写
2026/10/15 10:23:00 网站建设 项目流程

简介:这是一份面向计算机/软件专业学生的数据结构课程设计报告,主题是基于Huffman编码实现文件压缩与解压缩,适合正在做Huffman树、贪心算法及文件位级读写相关课题的读者参考。报告围绕压缩与解压缩两个C语言程序展开,涵盖总体设计、HTNode与HuffmanTree结构体定义、字符频率统计、Huffman树构建、编码表生成、按位读写压缩、编码表恢复与解码还原等完整流程,并附关键函数说明、测试分析与源程序附录。资源共1个docx文档,压缩包约1.41MB,作为课程设计报告可直接用于撰写文档、梳理算法思路和检查代码结构。报告中还整理出压缩前后文件大小、压缩比等验证数据,便于对照调试;已有1060人学习,对需要快速理解Huffman编码工程化实现或完成类似课设任务的同学具有较高参考价值。

1. Huffman文件压缩:不是背一棵树就能交差的课设

基于Huffman编码的文件压缩与解压缩,是数据结构课程设计里出镜率最高的一类题目。很多同学以为这门课设的重点是建树,毕竟考试只考到“高频字符用短码,低频字符用长码”。实际上,真正把一个文件压缩再解压还原,难点全在树之外:码表怎么落盘、二进制怎么按位写、最后一个字节的填充怎么处理、解压时怎么判断停止。只要一处没想清楚,压缩包要么比原文件还大,要么解压出来是坏的。这篇笔记按我实际完成这类课设的路径,把代码、参数和踩过的坑分开讲清楚。适合正在准备Huffman课设,或想自己写一个最小可用文件压缩器的开发者。

2. 频率统计与Huffman树构建:最小堆是绕不开的功臣

2.1 为什么Huffman编码要先统计整个文件

Huffman编码属于静态无损压缩,它在编码前先扫描一遍整个文件,统计每个字节出现的次数,用出现次数近似概率。高频字节分配短码字,低频字节分配长码字,最终让平均码长低于8 bit。这里有不少人会有一个误区:觉得只要把出现次数最多的几个字符分配短码就行,其余随便配。其实Huffman树通过反复合并最小频率节点的方式,保证整棵树的带权路径长度最小,也就是说,在你选定的频率表下,得到的码表是最优前缀码。

这个方案有一个关键前提:码表信息必须跟着压缩数据一起走,否则解压端无法还原。常见做法是压缩时扫描一遍文件得到频率表,再把频率表写进压缩包头部;解压端读头部重建同一棵Huffman树,再解码后续数据。统计阶段读的是二进制字节,不是文本行,所以文件要用"rb"方式打开,避免平台对换行符做翻译。

2.2 频率统计:fgetc逐字节读入,256个计数桶

先看频率统计的代码。这里接收fgetc的返回值用int,而不是char,这是第一个容易翻车的地方。

#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_CHAR 256 // 统计频率并返回原文件字节数,失败返回0 unsigned long long countFrequency(const char *path, unsigned long *freq) { FILE *fp = fopen(path, "rb"); if (!fp) { perror("打开输入文件失败"); return 0; } unsigned long long totalBytes = 0; int byte; while ((byte = fgetc(fp)) != EOF) { freq[(unsigned char)byte]++; // 转成无符号再当下标 totalBytes++; } fclose(fp); return totalBytes; }

fgetc返回的是0到255之间的字节值,或者EOF(-1)。如果直接把返回值存进char再当数组下标,在char有符号的平台上会得到负数,数组越界访问是必然的。我习惯写成freq[(unsigned char)byte]++,把负数挡在EOF判断之后。

totalBytes要写进文件头,解压端靠它判断什么时候停止解码,所以必须统计准确。还有一种边界情况:文件是空的,totalBytes为0,循环一次都不进。后面解压时应该直接创建一个空文件返回,不能继续读压缩数据。

2.3 最小堆:建树时的优先级容器

Huffman树的构建过程是反复取当前频率最小的两个节点合并,再放回去。课程里的简化做法是先把所有节点按频率排序,然后顺着取,但合并产生的父节点频率可能低于后面还未处理的节点,直接顺序取并不能保证每次都拿到最小值。用最小堆维护“当前未合并节点集合”才是可靠做法,每次取堆顶复杂度O(log n),n最多256,毫无压力。

typedef struct HuffNode { unsigned char ch; // 叶子节点存储的字节值 unsigned long freq; // 出现频率 struct HuffNode *left; struct HuffNode *right; } HuffNode; typedef struct { HuffNode **data; // 节点指针数组 int size; int capacity; } MinHeap; MinHeap *createHeap(int capacity) { MinHeap *h = malloc(sizeof(MinHeap)); h->data = malloc(sizeof(HuffNode *) * capacity); h->size = 0; h->capacity = capacity; return h; } void heapPush(MinHeap *h, HuffNode *node) { int i = h->size++; h->data[i] = node; // 从下往上调整,父节点频率大于子节点就交换 while (i > 0) { int parent = (i - 1) / 2; if (h->data[parent]->freq <= h->data[i]->freq) break; HuffNode *tmp = h->data[parent]; h->data[parent] = h->data[i]; h->data[i] = tmp; i = parent; } } HuffNode *heapPop(MinHeap *h) { if (h->size == 0) return NULL; HuffNode *top = h->data[0]; h->data[0] = h->data[--h->size]; // 从上往下调整,跟两个子节点中较小的那个交换 int i = 0; while (1) { int left = 2 * i + 1; int right = 2 * i + 2; int smallest = i; if (left < h->size && h->data[left]->freq < h->data[smallest]->freq) smallest = left; if (right < h->size && h->data[right]->freq < h->data[smallest]->freq) smallest = right; if (smallest == i) break; HuffNode *tmp = h->data[i]; h->data[i] = h->data[smallest]; h->data[smallest] = tmp; i = smallest; } return top; }

堆里存的是节点指针而不是节点值,这样 realloc 扩容时节点本身不会被搬走,左右子树引用不会失效。比较逻辑只读freq字段,不需要管字符本身谁大谁小,因为Huffman树本来就不要求唯一。

2.4 构建Huffman树与码表:递归DFS生成前缀码

树的构建逻辑很固定:把出现过的字符包成叶子节点入堆,然后循环弹出两个最小节点,合成父节点再入堆,直到堆里只剩一个节点,那就是根。

HuffNode *buildHuffmanTree(unsigned long *freq) { MinHeap *heap = createHeap(MAX_CHAR); for (int i = 0; i < MAX_CHAR; i++) { if (freq[i] == 0) continue; HuffNode *node = malloc(sizeof(HuffNode)); node->ch = (unsigned char)i; node->freq = freq[i]; node->left = NULL; node->right = NULL; heapPush(heap, node); } // 只有一个有效字符时,根就是它自己,不能进合并循环 while (heap->size > 1) { HuffNode *left = heapPop(heap); HuffNode *right = heapPop(heap); HuffNode *parent = malloc(sizeof(HuffNode)); parent->ch = 0; parent->freq = left->freq + right->freq; parent->left = left; parent->right = right; heapPush(heap, parent); } HuffNode *root = heapPop(heap); free(heap->data); free(heap); return root; }

一个容易被忽略的边界:如果原文件只有一种字符(例如全是字母A),堆里只有一个节点,while循环不会执行,返回的根节点就是那个叶子。这会导致后面编码长度为0,压缩流程需要单独处理,第5章会展开。另一个边界是空文件,堆为空,buildHuffmanTree返回NULL,压缩端在统计完频率后就要判断有效字符数,为0则直接写特殊头部,不进入建树逻辑。

码表生成用深度优先遍历。约定左孩子编码追加'0',右孩子追加'1':

// 递归生成编码表,codeTable 长度为 256 void generateCodes(HuffNode *node, char *code, int depth, char **codeTable) { if (!node) return; if (!node->left && !node->right) { codeTable[node->ch] = malloc(depth + 1); memcpy(codeTable[node->ch], code, depth); codeTable[node->ch][depth] = '\0'; return; } if (node->left) { code[depth] = '0'; generateCodes(node->left, code, depth + 1, codeTable); } if (node->right) { code[depth] = '1'; generateCodes(node->right, code, depth + 1, codeTable); } }

code数组在递归时复用同一块内存,每深入一层追加一个字符,到叶子时把当前路径拷贝出来。Huffman树的深度理论上不会超过不同字符个数,但256个字符时深度可以达到255,所以code数组我习惯开512长度,避免深度极端时越界。到这里,我们已经有了完整的codeTable,codeTable[ch]是一个类似"0101"的字符串,接下来要解决怎么把编码真正写进文件。

3. 压缩端落盘:文件头设计、按位写入与主流程编排

3.1 文件头字段:解压时重建树的所有信息

压缩包不能只存编码流。解压端需要知道:这是不是合法的Huffman压缩文件、原文件多大、每个字节出现多少次、最后一个字节补了几个0。这些信息都写在文件头,我一般按下面的顺序组织:

字段长度说明
魔数4字节固定为HBUF,用于文件类型校验
原始文件大小8字节无符号整数,小端序写入
有效字符数2字节频率表中出现的不同字节个数
频率表count组 × 9字节每组:1字节字符 + 8字节频率
填充位数1字节最后一个字节补了多少个0

这里有一个反直觉的设计:频率表存的是“字符出现次数”,而不是“字符的编码”。因为编码是从频率推导出来的,解压端只要拿到完全相同的频率表,重建的树和码表就和压缩端一致。直接存码表也可以,比如A:010,但每个字符编码长度不固定,还要额外存长度,文件头更臃肿。存频率表简单,重建树用的是同一个函数。

跨平台问题在文件头里格外明显。如果定义struct然后整个fwrite,struct的字段对齐、long在不同平台的宽度都会破坏兼容性。我实际使用中写一组固定字节序的辅助函数:

// 固定单字节写 void writeU8(FILE *fp, unsigned char val) { fputc(val, fp); } // 固定8字节,小端序写入 void writeU64(FILE *fp, unsigned long long val) { for (int i = 0; i < 8; i++) { fputc((val >> (8 * i)) & 0xFF, fp); } } // 固定2字节,小端序写入 void writeU16(FILE *fp, unsigned short val) { fputc(val & 0xFF, fp); fputc((val >> 8) & 0xFF, fp); }

fputc一次写一个字节,左移和按位与把小端序显式算出来,不依赖平台字节序和整数宽度。读端对应写readU8、readU64、readU16,拼回去的顺序要和写入相反。这套函数虽然琐碎,但写完以后压缩包在Windows和Linux之间互相拷贝都能解压。

3.2 BitWriter:把01字符流变成二进制bit

codeTable里存的是'0'和'1'组成的字符串。如果直接fputs写出去,每个0和1都会占一个字节,压缩文件反而膨胀8倍。正确做法是自己实现一个bit级写入器:bit攒满8个再落一个字节。

typedef struct { FILE *fp; unsigned char buffer; // 当前正在攒的字节 int bitCount; // buffer里已有的bit数 } BitWriter; void bitWrite(BitWriter *bw, int bit) { // 先到的bit放在字节的最高位 bw->buffer |= (bit & 1) << (7 - bw->bitCount); bw->bitCount++; if (bw->bitCount == 8) { fputc(bw->buffer, bw->fp); bw->buffer = 0; bw->bitCount = 0; } } void bitFlush(BitWriter *bw) { if (bw->bitCount > 0) { // 剩余不足8bit,用0补满后写出去 fputc(bw->buffer, bw->fp); bw->buffer = 0; bw->bitCount = 0; } }

这个实现是从最高位开始填的大端方式,和大多数教材描述一致,用十六进制编辑器看压缩包时也能直接对上。如果你习惯低比特在前,也可以反过来,但压缩和解压的读取顺序必须一致。bitCount从0开始,每写一个bit加1,满了自然落盘。文件结束时调用bitFlush,把最后一个不满8位的字节补0写出去。

补0这个动作就产生了padBits。比如最后一个字节只用了5位,补了3个0,padBits就是3。这个值必须记在文件头里,否则解压端不知道最后几位是有效数据还是填充。

3.3 压缩主循环:两遍扫描文件,尾部字段回填

压缩主流程需要两遍读原文件:第一遍统计频率,第二遍真正编码输出。因为频率表必须先知道才能构建编码,不能一边读一边写。两遍扫描是静态Huffman的标准做法。

int compressFile(const char *inPath, const char *outPath) { unsigned long freq[MAX_CHAR] = {0}; unsigned long long totalBytes = countFrequency(inPath, freq); if (totalBytes == 0) { // 空文件:只写魔数和原长为0,解压端直接生成空文件 FILE *out = fopen(outPath, "wb"); fwrite("HBUF", 1, 4, out); writeU64(out, 0); fclose(out); return 0; } unsigned short charCount = 0; for (int i = 0; i < MAX_CHAR; i++) { if (freq[i] > 0) charCount++; } HuffNode *root = buildHuffmanTree(freq); char *codeTable[MAX_CHAR] = {0}; char code[512]; generateCodes(root, code, 0, codeTable); FILE *out = fopen(outPath, "wb"); fwrite("HBUF", 1, 4, out); writeU64(out, totalBytes); writeU16(out, charCount); for (int i = 0; i < MAX_CHAR; i++) { if (freq[i] > 0) { writeU8(out, (unsigned char)i); writeU64(out, (unsigned long long)freq[i]); } } // 先写0占位,压缩完成后回填 writeU8(out, 0); long padPos = ftell(out) - 1; // 记录padBits字段位置 FILE *in = fopen(inPath, "rb"); BitWriter bw = { out, 0, 0 }; int byte; int totalBits = 0; while ((byte = fgetc(in)) != EOF) { char *enc = codeTable[(unsigned char)byte]; for (int j = 0; enc[j]; j++) { bitWrite(&bw, enc[j] - '0'); totalBits++; } } bitFlush(&bw); // 回填padBits:totalBits恰好是8的倍数时为0 int padBits = (8 - (totalBits % 8)) % 8; fseek(out, padPos, SEEK_SET); writeU8(out, (unsigned char)padBits); fclose(in); fclose(out); return 0; }

为什么要先占位后回填?因为padBits只有编码到最后才知道,而它在文件头里的位置是固定的。写文件头时不占这个位置,解压端按固定偏移读就会错位。用ftell记住位置,编码完再fseek回去覆盖即可。

注意:padBits的计算是(8 - totalBits % 8) % 8,不能写成8 - totalBits % 8。当totalBits恰好是8的倍数时,前者为0,后者为8,8会成为一个非法值写入文件头。

这段主流程里有一个性能隐患:每个bit调用一次bitWrite,每次都要判断是否满8字节。小文件无所谓,压几百MB的文件会明显变慢。课程设计阶段不需要优化,但如果你要处理大文件,可以把BitWriter改成先攒到一个本地缓冲区,满4KB再fwrite一次,速度能提升不少。

3.4 压缩率预期:什么文件值得压,什么文件别压

Huffman压缩不是万能的。文本、日志、JSON这些ASCII密集文件,压缩率通常在50%到70%之间;已压缩过的文件(JPEG、PNG、zip)再压基本压不动,甚至因为文件头开销反而变大;完全随机的数据会膨胀。测试样本最好覆盖这三种类型,否则很容易得出“Huffman无用”的片面结论。

4. 解压端还原:读文件头、重建树、逐位解码

4.1 文件头校验:第一道防线

解压的第一步是读取文件头并校验。很多人一上来直接读频率表然后解码,魔数都不看,结果把任意一个文件拖进去都能解出乱码。我习惯在解压函数开头做严格校验:

int decompressFile(const char *inPath, const char *outPath) { FILE *in = fopen(inPath, "rb"); if (!in) return -1; char magic[4]; if (fread(magic, 1, 4, in) != 4 || memcmp(magic, "HBUF", 4) != 0) { fprintf(stderr, "文件头校验失败:不是合法的Huffman压缩包\n"); fclose(in); return -1; } unsigned long long origSize = readU64(in); if (origSize == 0) { FILE *out = fopen(outPath, "wb"); fclose(out); fclose(in); return 0; } unsigned short charCount = readU16(in); if (charCount == 0 || charCount > 256) { fprintf(stderr, "频率表长度非法\n"); fclose(in); return -1; } unsigned long freq[MAX_CHAR] = {0}; for (int i = 0; i < charCount; i++) { unsigned char ch = (unsigned char)fgetc(in); unsigned long long f = readU64(in); freq[ch] = (unsigned long)f; } int padBits = fgetc(in); if (padBits < 0 || padBits > 7) { fprintf(stderr, "填充位数非法\n"); fclose(in); return -1; } // 后面接解码主循环... }

这里几个校验逻辑值得注意。charCount超过256直接拒绝,因为频率表不可能超过256项;padBits超过7也拒绝,因为一个字节最多补7个0。这两个判断在压缩包被截断或意外破坏时,能防止程序无限循环或解出超大文件。fgetc返回EOF时padBits会是-1,这个判断也顺带覆盖了文件截断的情况。

4.2 重建树与内存管理

读到的频率表填进freq数组后,重建树直接调用压缩端那个buildHuffmanTree。我经常看到有人压缩端写一份建树代码,解压端又写一份,两边的左右子树约定稍有不一致,解出来就全乱。其实这个函数的输入输出完全对称,直接复用就行。

这里有个容易被忽略的内存问题:压缩结束时树的节点还占着内存。如果程序一次只处理一个文件,进程退出就释放了;但如果你把压缩和解压功能做进一个带菜单的程序里,连续操作多个文件就会泄漏。我习惯在每次建树用完以后写一个freeTree(root)递归释放全部节点,避免被评审老师问到内存泄漏问题。

4.3 解码主循环与终止条件

解码端也要有一个bit级读取器,与BitWriter对称:

typedef struct { FILE *fp; int buffer; // 当前字节 int bitCount; // 当前字节剩余未读bit数 } BitReader; // 返回0或1,读到文件末尾返回-1 int bitRead(BitReader *br) { if (br->bitCount == 0) { int b = fgetc(br->fp); if (b == EOF) return -1; br->buffer = b; br->bitCount = 8; } int bit = (br->buffer >> (br->bitCount - 1)) & 1; br->bitCount--; return bit; }

这个读取器从当前字节的最高位开始取,因为写入时也是从最高位开始。bitCount表示buffer里还有多少有效bit,为0时就去读下一个字节。注意buffer是int而不是unsigned char,这样fgetc返回的EOF才能被正确处理,否则读到0xFF会被当成-1导致提前终止。

解码主循环的核心是:从根出发,每读一个bit就往左或右孩子走一步,遇到叶子就输出字符,然后回到根继续。

HuffNode *root = buildHuffmanTree(freq); if (!root) { fprintf(stderr, "频率表为空\n"); fclose(in); return -1; } FILE *out = fopen(outPath, "wb"); BitReader br = { in, 0, 0 }; unsigned long long written = 0; HuffNode *cur = root; while (written < origSize) { int bit = bitRead(&br); if (bit < 0) { fprintf(stderr, "压缩数据提前结束,文件可能已损坏\n"); fclose(out); remove(outPath); // 删掉不完整的输出文件 return -1; } cur = (bit == 0) ? cur->left : cur->right; if (!cur) { fprintf(stderr, "编码路径不存在,文件可能已损坏\n"); fclose(out); remove(outPath); return -1; } if (!cur->left && !cur->right) { fputc(cur->ch, out); written++; cur = root; } } fclose(out); fclose(in); return 0; }

终止条件用origSize,而不是EOF。因为最后一个字节有填充位,如果以EOF为终止条件,可能把填充的0也解出来;如果恰好读到EOF还有几个有效bit没处理,又会少字节。用原始字节数做计数器,写够就停,填充位的存在就被自然绕过了。

这段代码里值得记住的习惯是:遇到bit小于0或cur为NULL时,不能直接return,要先关掉输出文件,再用remove删掉半成品。我在测试中多次遇到不删坏文件的情况,结果脚本里cmp比对时,旧文件被误当成解压结果,严重误导排查方向。删除不完整输出文件的成本很低,但能避免一连串后续问题。

5. 常见问题与避坑:压缩包膨胀、末尾乱码、结构体对齐这些坑我都替你踩过

5.1 压缩后文件反而变大:小文件和无规律数据不适合Huffman

现象:压缩一个不到100字节的文本文件,或者压缩一个PNG图片,输出文件比输入文件还大。

原因有两层。第一层是固有开销:文件头固定要写魔数4字节、原大小8字节、有效字符数2字节、填充位数1字节,再加上频率表,起步就是几十字节,小文件根本摊不平。第二层是数据本身的信息熵:已经压缩过的数据里字符分布接近均匀,Huffman给每个字符分配的码长几乎都是8位左右,占不到便宜。

解决:压缩前先判断文件大小和有效字符数。常见的做法是设一个阈值,比如小于4KB的文件直接“原样存储”,不建树不编码。文件头里用一个模式字节区分“Huffman压缩模式”和“存储模式”,这个模式字节放在魔数后面,解压端读到存储模式后直接把剩余数据原样拷贝到输出文件。不做这个处理,演示时很容易被“压缩后变大”弄得尴尬。

5.2 解压后末尾多字节或少字节:终止条件写错了

现象:文本文件解压后最后一行少了换行符,或者二进制文件解压后尾部多出几个字节,用cmp比对时差异集中在文件末尾。

原因:压缩时最后一个字节不足8位,用0补满,解压时如果把这些填充位也解出来,就会多出字符;反过来,如果提前用EOF判断结束,有几个有效bit还没处理就停了,就会少字节。

解决:用origSize做终止条件,而不是EOF。同时压缩端在文件头里记录padBits,解压循环在写满origSize个字节后自然退出,根本不触碰填充位。如果解压结束后发现还有未读数据,可以忽略或打印警告。这个坑我实际调了很久,最后才意识到问题根本不在树的构建,而在循环边界。

5.3 换个平台就乱码:直接写结构体的坑

现象:压缩包在自己机器上解压正常,拷贝到另一台电脑上要么报“文件头校验失败”,要么解出一堆乱码。

原因:直接把struct整体fwrite进文件,这里有字段对齐和padding,不同编译器下结构体大小可能不一样。更隐蔽的是long的宽度,64位Linux下是8字节,Windows下是4字节,频率表读出来完全是错的。这类问题看起来像玄学,其实是字节序和类型宽度没有固定导致的。

解决:所有整数都用固定宽度类型,序列化时逐字段读写。我在第3章给的writeU64、writeU16就是干这个用的。魔数用原始字节数组比较,整数全部用无符号类型并显式按小端序读写。代码虽然啰嗦一些,但换来的是压缩包跨平台通用。如果课程设计需要在两个环境分别演示,这个细节能堵住一大半问题。

5.4 单字符文件把程序卡死

现象:压缩一个全部是字母A的文件,程序要么卡住,要么压缩包巨大,解压出来的内容也不对。

原因:统计后有效字符数是1,buildHuffmanTree直接返回一个叶子节点作为根。generateCodes给这个叶子生成的编码是空字符串。压缩时遍历每个字节,enc[j]第一次取值就跳过,结果是0个bit被写入,但文件头固定开销还在。解压端拿到的树没有内部节点,解码时从根出发走一步就碰到空指针。

解决:把有效字符数等于1的文件当特例处理。最简单的是走“存储模式”,直接把原文件内容复制到输出文件,并在文件头标记模式。空文件也要走特例:只写魔数和origSize=0,解压端直接生成空文件。这两个特例不改的话,会有相当多的正常运行场景崩溃,尤其是处理纯文本模板这类文件时。

5.5 损坏的压缩包让解压直接崩溃

现象:压缩包在拷贝过程中被截断,或者被其他程序改坏,解压时程序crash,或者解压出巨大的垃圾文件。

原因:解码循环里cur = (bit == 0) ? cur->left : cur->right,如果树本身是单节点,左右孩子都是NULL;或者频率表被破坏,树的结构不对,走到空指针后还访问cur->ch,段错误就出现了。另一种情况是charCount被污染成超大值,读取频率表时把文件指针带飞。

解决:在每次走向左右孩子之后判断cur是否为NULL,是就报错退出并删除半成品文件。魔数、charCount、padBits的合法性校验都要做。压缩包被截断时bitRead迟早返回-1,也要在循环里处理。这些防御代码看起来只是几行if,但演示现场一个损坏文件导致的crash,会让整个演示翻车。

6. 验证压缩器:用一个10行脚本把边界用例扫一遍

写完压缩和解压,第一件事不是拿文本文件试,而是建一个测试目录,放上空文件、单字节文件、全同字符文件、随机字节文件、真实文本文件。每个文件都跑一遍compress、decompress,再用cmp比对原文件和解压结果。

#!/bin/bash # 测试样本放在 tests/ 目录下,命名 input0、input1 ... for f in tests/input*; do ./compress "$f" "$f.huf" || echo "$(basename "$f"): COMPRESS FAIL" ./decompress "$f.huf" "$f.out" || echo "$(basename "$f"): DECOMPRESS FAIL" if cmp -s "$f" "$f.out"; then echo "$(basename "$f"): PASS" else echo "$(basename "$f"): FAIL" cmp -l "$f" "$f.out" | head -5 ls -l "$f" "$f.huf" | awk '{print $5, $9}' fi done

cmp -s是静默比较,退出码为0表示一致;不一致时用cmp -l打印前几个差异位置,能直接看出错误在文件头还是编码流中。测试样本建议至少准备5个:空文件、单字节文件(内容0xFF)、全A的长文件、随机字节文件、一段真实日志文本。随机文件用head -c 100000 /dev/urandom > tests/input3生成即可。

正确性优先于压缩率。PASS的前提下再看压缩比:全A文件如果走了存储模式特判,输出约等于原文件大小;随机文件大概率膨胀,属于预期行为。这个脚本顺带还会暴露出压缩端崩溃后.huf缺失的问题,比单独手点文件高效得多。

如果还想进阶,可以给程序加一个命令行参数:-m store走存储模式、-m huffman走Huffman模式,测试脚本里同时覆盖两种模式。更进一步的方向是先用LZ77做一次字典预压缩,再交给Huffman处理文本冗余,或者把文件按64KB分块、每块独立建树,用空间换更高的压缩灵活度。我当时花了整晚排查“解压末尾多字符”,最后发现是循环边界多处理了一个填充bit,并不是树的问题。从那以后我养成的习惯是:先想清楚终止条件写在哪,再动笔写循环。希望这篇笔记能帮你少走这一晚的弯路。

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

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

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

立即咨询