☰
C语言哈夫曼编码文件压缩:频次统计、最小堆建树与位流写入
2026/10/31 12:32:45 网站建设 项目流程

简介:面向数据结构课程设计与C语言算法练习,这份资料围绕“用哈夫曼编码实现文件压缩”提供源码与配套实验报告,适合正在完成课程设计、希望理解哈夫曼树构造及文件压缩原理的学生参考。压缩包共2个文件,主要为1个cpp源码和1个doc报告,整体约433KB;源码涉及Huffman树与编码表的结构定义、字符频率统计、编码生成与写入等环节,报告则覆盖实验题目、目的、环境、内容及概要设计,便于对照梳理14个功能函数和菜单组织方式。已有4708人学习下载,说明该题目在课程实践中较受关注。读者可借此理解从ASCII字符频率统计、哈夫曼树建立、编码表生成到压缩文件输出的流程,并结合报告中的结构定义、线性链表操作和二叉树遍历思路进行算法复盘与实现排错。

1. 一份几十兆的日志文件,怎么用哈夫曼编码压下去

一份几十兆的日志文件直接发出去既占空间,也看不出压缩算法到底省在哪。用哈夫曼编码实现文件压缩,把字节频次统计、最小堆建树、编码表生成和位流写入串成完整链路,再配一份能讲清设计取舍的代码+报告,是数据结构课设里少见的“能跑又能写”的组合。这份资源用C语言实现,适合刚学完树和优先队列、想把哈夫曼树从纸面落到磁盘文件的学生,也适合想补二进制I/O基本功的开发者。它不追求通用压缩率,而是把每个字节怎么变成变长编码、文件头怎么存频率表、解压怎么按原始长度截断填充位讲透,踩坑点都留在代码和报告里。

2. 工程骨架与压缩文件格式:从字节频次表到HUF1文件头

2.1 模块划分与目录结构

工程分三层:位I/O层只管把0/1写进字节缓冲区,哈夫曼核心层负责频率统计、最小堆、建树和编码表,命令行入口只解析c/d两个动作。这样拆的好处是,报告里的“数据结构设计”和“算法流程”能一一对应到文件,二次开发时也不容易把位操作和树操作搅在一起。常见做法是把位缓冲单独放一对.c/.h,哈夫曼核心放另一对,main.c保持薄,只做参数校验和调用。

文件职责关键接口
bitio.c/h位缓冲读写,负责最后字节填充bwWriteBit、bwFlush、brReadBit
huffman.c/h频次统计、最小堆、建树、编码表、压缩/解压countFreq、buildTree、makeCodes、compressFile、decompressFile
main.c解析c/d和输入输出路径main
tests/空文件、单字节、重复字节、随机二进制样例run_tests.sh
report.md需求分析、数据结构、流程、测试记录报告正文

接口设计上,压缩和解压都只暴露文件路径,不把FILE*交给上层,避免调用方忘记fclose。频率表用uint64_t freq[256]在内存里统计,写文件头时转成uint32_t,因为单个文件的字节频次超过42亿的情况很少见,1KB的头比8KB的头更容易接受。报告里可以把这个取舍写清楚:存频率表不存整棵树,解压端用同一套建树函数重建,代码复用高,代价是文件头固定偏大。

main.c的参数不用做得太复杂,./huffman c input.bin output.huf和./huffman d output.huf restored.bin足够覆盖课设演示。参数个数不对时打印用法并返回非零,别用exit(0)把错误吞掉,不然后面写测试脚本时判断失败很别扭。

2.2 文件头字段与内存数据结构

压缩文件不是一上来就写位流,前面要放一个固定头:魔数、原始长度、256个字节的频次表。解压端先读头,校验魔数,拿到原始长度和频次表,再调用同一个buildTree重建哈夫曼树。原始长度必须存,因为最后一个字节会用0补齐到8位,解压时不能读到文件尾就停,只能按原始字节数截断。

字段长度说明
magic4字节固定HUF1,用来识别文件格式
origSize8字节原始文件字节数,小端存储
freq[256]1024字节每个字节值的出现次数,每项4字节小端
位流变长按编码表逐位写入,最后不足8位补0

总头长度是1036字节。这个数字要写进报告,因为它解释了一个现象:几百字节的小文件压完反而变大。不是算法错了,是头开销固定存在。想优化可以把频次表改成变长编码,或者对小文件直接存原文件,但课设阶段先把这个坑讲明白,比强行优化更有价值。

#define MAGIC "HUF1" #define MAX_NODES 511 typedef struct { unsigned char ch; /* 叶子对应的字节值,内部节点填0 */ uint64_t freq; /* 频次或子树频次之和 */ int left; /* 左孩子下标,-1表示无 */ int right; /* 右孩子下标,-1表示无 */ } HNode; typedef struct { char magic[4]; /* "HUF1" */ uint64_t origSize; /* 原始文件长度 */ uint32_t freq[256]; /* 频次表,写文件时逐项小端输出 */ } HuffHeader;

逻辑上,HNode用数组下标代替指针,建堆时只移动int索引,不用搬动整个节点,报告里画树也方便标号。left/right为-1表示叶子,解压时靠这个判断是否走到字符。参数上,freq在压缩端是统计结果,在解压端是从文件头读回来的频次表;origSize只用于解压循环次数,不参与建树。

注意:不要直接fwrite(&header, sizeof(header), 1, fp)。结构体可能因对齐插入填充字节,换编译器后头长度和字段偏移会变。逐字段写magic、origSize、freq,读的时候也逐字段读,文件格式才稳定。

2.3 构建、运行与验收命令

编译用C11,开警告,先把隐式声明和符号问题清掉。命令不复杂,但每一步都要能在报告里复现:

gcc -std=c11 -O2 -Wall -Wextra -o huffman main.c huffman.c bitio.c ./huffman c tests/empty.bin tests/empty.huf ./huffman d tests/empty.huf tests/empty.out cmp tests/empty.bin tests/empty.out && echo "empty ok" ./huffman c tests/one.bin tests/one.huf ./huffman d tests/one.huf tests/one.out cmp tests/one.bin tests/one.out && echo "one ok" ./huffman c tests/random.bin tests/random.huf ./huffman d tests/random.huf tests/random.out cmp tests/random.bin tests/random.out && echo "random ok"

c表示压缩,后面两个参数是输入和输出;d表示解压,顺序一样。cmp在Linux/macOS下逐字节比较,Windows下用fc /b。我一般会先拿空文件、单字节文件、全相同字节文件、随机二进制四组跑一遍,再上文本日志。空文件验证头写入和origSize=0分支,单字节验证树退化成叶子,全相同验证编码长度只有1,随机二进制验证位流没有按文本模式被改写。

报告里的测试记录不要只贴“压缩成功”,要写清楚输入大小、输出大小、压缩率、是否逐字节一致。压缩率按(1 - outSize / inSize) * 100%算,小文件出现负值就如实写“头开销导致膨胀”,这比硬凑一个好看的数字更可信。验收时顺便看一眼输出文件头的前4个字节是不是HUF1,用od -An -tx1 -N4 output.huf即可,报告里放这条命令的输出截图,读者能快速确认文件格式没写歪。

3. 核心压缩链路:频率统计、最小堆建树与编码表生成

3.1 频率统计与零频字节处理

频率统计是整条链路的入口,读文件必须用rb,按块读比fgetc快,也避免文本模式把\r\n转换掉。统计结果里频次为0的字节不建叶子,只处理出现过的字节,这样最小堆的规模最多256,建树循环最多255次。原始文件长度单独用uint64_t累计,后面写文件头和解压循环都要用。

int countFreq(const char *path, uint64_t *freq, uint64_t *fileSize) { FILE *fp = fopen(path, "rb"); if (!fp) return -1; memset(freq, 0, 256 * sizeof(uint64_t)); unsigned char buf[8192]; size_t n; uint64_t total = 0; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { for (size_t i = 0; i < n; i++) { freq[(unsigned char)buf[i]]++; /* 按无符号字节计数 */ } total += (uint64_t)n; } if (ferror(fp)) { fclose(fp); return -2; } fclose(fp); *fileSize = total; return 0; }

这段代码的逻辑是分块扫描整个文件,freq输出256项频次,fileSize输出原始字节数。参数path是输入路径,freq由调用方提供数组,fileSize不能传空指针。返回0表示成功,-1表示打开失败,-2表示读过程中出错。缓冲区取8KB是折中,太大占栈,太小系统调用多;如果文件特别大,total用uint64_t避免32位溢出。

零频字节不建叶子,这一点要在报告里说明。有人把256个字节全塞进堆,频次为0的节点会把树拉得很深,编码表浪费,压缩率也难看。正确做法是先扫一遍频次,只把freq[i] > 0的字节生成叶子节点。空文件则一个叶子都没有,压缩端只写文件头,正文不写,解压端读到origSize=0直接创建空文件。

3.2 最小堆的插入与弹出

建哈夫曼树需要每次取两个频次最小的节点,最小堆是最直接的选择。堆里存的是节点数组下标,比较函数先比freq,频次相同再比ch,保证同样输入每次建出来的树一致,报告里的编码表才能复现。合并出来的内部节点ch填0,left/right指向两个子节点下标,频次相加后重新入堆。

static HNode nodes[MAX_NODES]; static int nodeCount = 0; static int heap[256]; static int heapSize = 0; static int lessNode(int a, int b) { if (nodes[a].freq != nodes[b].freq) return nodes[a].freq < nodes[b].freq; return nodes[a].ch < nodes[b].ch; /* 频次相同按字节值稳定排序 */ } static void heapPush(int idx) { int i = heapSize++; heap[i] = idx; while (i > 0) { int p = (i - 1) / 2; if (!lessNode(heap[i], heap[p])) break; int tmp = heap[i]; heap[i] = heap[p]; heap[p] = tmp; i = p; } } static int heapPop(void) { int top = heap[0]; heap[0] = heap[--heapSize]; int i = 0; while (1) { int l = 2 * i + 1; int r = l + 1; int small = i; if (l < heapSize && lessNode(heap[l], heap[small])) small = l; if (r < heapSize && lessNode(heap[r], heap[small])) small = r; if (small == i) break; int tmp = heap[i]; heap[i] = heap[small]; heap[small] = tmp; i = small; } return top; }

逻辑上,heapPush把新节点下标放到堆尾再上浮,heapPop把堆顶取出后用堆尾覆盖再下沉。参数idx是nodes数组里的节点下标,不是频次本身。heapSize由建树函数清零,堆容量256够用,因为最多256个叶子。复杂度上,插入和弹出都是O(log σ),σ是不同字节数,最大256;整体建树是O(σ log σ),对文件压缩来说可以忽略,真正的开销在逐位写。

频次相同按ch排序不是必须,但少了这个规则,同样的文件在不同运行里可能得到不同编码表,报告里的截图和测试数据会对不上。血泪经验是,调试阶段一旦发现两次压缩输出不一致,先看比较函数是不是只比了频次。

3.3 建树与递归生成编码表

建树循环每次弹两个最小节点,生成一个内部节点再压回去,直到堆里只剩一个根。只有一个不同字节时,根直接是叶子,编码长度按1处理,约定编码为0。生成编码表用递归,往左走追0,往右走追1,走到叶子就把路径拷进code[ch],长度记到codeLen[ch]。

static char code[256][256]; static int codeLen[256]; static void dfsCode(int root, char *buf, int depth) { HNode *n = &nodes[root]; if (n->left == -1 && n->right == -1) { if (depth == 0) { code[n->ch][0] = '0'; /* 单字符文件强制1位编码 */ codeLen[n->ch] = 1; } else { memcpy(code[n->ch], buf, (size_t)depth); codeLen[n->ch] = depth; } return; } buf[depth] = '0'; dfsCode(n->left, buf, depth + 1); buf[depth] = '1'; dfsCode(n->right, buf, depth + 1); } int buildTree(const uint64_t freq[256]) { nodeCount = 0; heapSize = 0; for (int i = 0; i < 256; i++) { if (freq[i] == 0) continue; nodes[nodeCount] = (HNode){(unsigned char)i, freq[i], -1, -1}; heapPush(nodeCount); nodeCount++; } if (nodeCount == 0) return -1; /* 空文件 */ if (nodeCount == 1) return 0; /* 只有一种字节 */ while (heapSize > 1) { int a = heapPop(); int b = heapPop(); nodes[nodeCount] = (HNode){0, nodes[a].freq + nodes[b].freq, a, b}; heapPush(nodeCount); nodeCount++; } return heapPop(); }

buildTree返回根节点下标,-1表示空文件,0表示单字符树。参数freq是256项频次表,内部节点最多255个,加上叶子最多511个,MAX_NODES取511刚好。dfsCode的buf是路径缓冲区,深度最大255,code[256][256]放得下。递归深度受不同字节数限制,不会像普通二叉树那样退化到文件长度,所以栈空间不用担心。

报告里可以把建树过程画成一张小表:先列频次,再列每次合并的两个最小节点和新节点频次,最后画树。编码表也列出来,旁边写“出现次数多的字节编码短,出现次数少的编码长”,这比空讲原理有说服力。复杂度写O(n + σ log σ),n是文件字节数,σ是不同字节数;空间主要是频次表、节点数组和编码表,都是常数级。

4. 位流写入与解压验证:把编码真正压进磁盘

4.1 位缓冲区与写码函数

哈夫曼编码是变长位串,不能直接fputc字符串,否则每个0/1都占一个字节,压缩变成膨胀。位缓冲区的做法是维护一个unsigned char buf和bitCount,每写一位就左移并入,攒满8位再写文件。最后不足8位时左移补0,调用bwFlush落盘。解压端按位读,读满8位再从文件取下一个字节。

typedef struct { FILE *fp; unsigned char buf; int bitCount; } BitWriter; static void bwWriteBit(BitWriter *bw, int bit) { bw->buf = (unsigned char)((bw->buf << 1) | (bit & 1)); bw->bitCount++; if (bw->bitCount == 8) { fputc(bw->buf, bw->fp); bw->buf = 0; bw->bitCount = 0; } } static void bwFlush(BitWriter *bw) { if (bw->bitCount > 0) { bw->buf <<= (8 - bw->bitCount); /* 低位补0,解压按原长度截断 */ fputc(bw->buf, bw->fp); bw->buf = 0; bw->bitCount = 0; } } static void bwWriteCode(BitWriter *bw, const char *code, int len) { for (int i = 0; i < len; i++) { bwWriteBit(bw, code[i] - '0'); } }

bwWriteBit的bit只取最低位,传0或1都行。bwFlush必须在压缩主流程结束前调用,否则最后不满8位的缓冲区不会落盘,解压时少一段。bwWriteCode按编码表从高位到低位写,和解压端走树的方向保持一致。参数bw由调用方初始化,fp必须是wb模式打开的文件。

注意:最后字节补0是正常行为,解压端不能读到文件尾就停,必须按文件头里的origSize循环。否则补出来的0会被当成有效位,解压文件多出几个字节。

4.2 写入文件头与压缩主流程

压缩主流程先统计频次,再建树、生成编码表,然后打开输入输出文件,逐字段写文件头,最后重新读输入文件写位流。这里会读两遍输入:第一遍统计,第二遍压缩。课设文件通常不大,两遍读换来代码简单;如果追求一遍读,可以把整个文件读进内存,但大文件会吃内存,报告里要写清楚取舍。

static void writeU32LE(FILE *fp, uint32_t v) { unsigned char b[4] = { (unsigned char)(v & 0xff), (unsigned char)((v >> 8) & 0xff), (unsigned char)((v >> 16) & 0xff), (unsigned char)((v >> 24) & 0xff) }; fwrite(b, 1, 4, fp); } static void writeU64LE(FILE *fp, uint64_t v) { for (int i = 0; i < 8; i++) { unsigned char b = (unsigned char)((v >> (8 * i)) & 0xff); fwrite(&b, 1, 1, fp); } } int compressFile(const char *inPath, const char *outPath) { uint64_t freq[256], size; if (countFreq(inPath, freq, &size) != 0) return -1; int root = buildTree(freq); if (size > 0 && root < 0) return -2; memset(codeLen, 0, sizeof(codeLen)); if (size > 0) { char buf[256]; dfsCode(root, buf, 0); } FILE *fi = fopen(inPath, "rb"); FILE *fo = fopen(outPath, "wb"); if (!fi || !fo) { if (fi) fclose(fi); if (fo) fclose(fo); return -3; } fwrite(MAGIC, 1, 4, fo); writeU64LE(fo, size); for (int i = 0; i < 256; i++) writeU32LE(fo, (uint32_t)freq[i]); if (size > 0) { BitWriter bw = {fo, 0, 0}; int ch; while ((ch = fgetc(fi)) != EOF) { bwWriteCode(&bw, code[ch], codeLen[ch]); } bwFlush(&bw); } fclose(fi); fclose(fo); return 0; }

逻辑上,writeU32LE和writeU64LE把整数按小端逐字节写,避免结构体对齐和本机字节序影响文件格式。compressFile先统计再压缩,空文件只写头,size>0才生成编码表和位流。参数inPath/outPath是输入输出路径,返回值0成功,负数是不同阶段的错误码。fwrite的返回值在正式代码里应该检查,课设演示可以先简化,但报告里最好提一句“生产代码要处理写失败”。

小文件压缩后变大是常见现象,头固定1036字节,输入只有500字节时输出可能1500多字节。报告里不要回避,直接算一笔账:头开销1036字节,位流节省的空间要超过这个数才划算。随机二进制数据的频次接近均匀,哈夫曼编码平均长度接近8位,压缩率接近0甚至为负,这也是正常结果。

4.3 解压重建树与验证

解压端先读HUF1魔数,再读原始长度和256项频次表,用同一套buildTree重建树。然后按origSize循环:每个字符从根开始,读一位走左或右,走到叶子输出字节并回到根。单字符树根就是叶子,特殊处理成“读一位、输出根字符”,保证位流位置消耗正确。

static int readBit(BitReader *br) { if (br->bitCount == 0) { int c = fgetc(br->fp); if (c == EOF) return -1; br->buf = (unsigned char)c; br->bitCount = 8; } int bit = (br->buf >> 7) & 1; br->buf <<= 1; br->bitCount--; return bit; } int decompressFile(const char *inPath, const char *outPath) { FILE *fi = fopen(inPath, "rb"); if (!fi) return -1; char magic[4]; if (fread(magic, 1, 4, fi) != 4 || memcmp(magic, MAGIC, 4) != 0) { fclose(fi); return -2; } uint64_t size = readU64LE(fi); uint64_t freq[256]; for (int i = 0; i < 256; i++) { freq[i] = readU32LE(fi); } int root = buildTree(freq); FILE *fo = fopen(outPath, "wb"); if (!fo) { fclose(fi); return -3; } if (size == 0) { fclose(fi); fclose(fo); return 0; } if (root < 0) { fclose(fi); fclose(fo); return -4; } BitReader br = {fi, 0, 0}; if (nodes[root].left == -1 && nodes[root].right == -1) { for (uint64_t i = 0; i < size; i++) { if (readBit(&br) < 0) break; /* 单字符树也要消耗位 */ fputc(nodes[root].ch, fo); } } else { for (uint64_t i = 0; i < size; i++) { int node = root; while (nodes[node].left != -1 || nodes[node].right != -1) { int bit = readBit(&br); if (bit < 0) { fclose(fi); fclose(fo); return -5; } node = bit ? nodes[node].right : nodes[node].left; } fputc(nodes[node].ch, fo); } } fclose(fi); fclose(fo); return 0; }

readBit从br->buf最高位取位,取完左移,bitCount减到0再读下一个字节。decompressFile里的size控制输出次数,多一个少一个都会在cmp里暴露。参数inPath是压缩文件,outPath是还原文件,返回负数表示魔数错、建树失败或位流提前结束。单字符分支里的readBit不能省,省了虽然输出内容对,但位流指针没动,后面如果追加校验字段就会错位。

验证不要只看“能解压”,要逐字节比较。测试表可以这样写进报告:

用例输入特征预期
空文件0字节输出0字节,头魔数正确
单字节只有A压缩头1036+1字节左右,解压还原A
全相同10KB的0x00压缩率很高,编码长度1
文本日志混合ASCII压缩率为正,cmp一致
随机二进制均匀分布压缩率接近0或为负,cmp一致

报告里的截图放ls -l看大小、cmp无输出、sha256sum两边一致。压缩率低不丢人,能解释清楚头开销和频次分布才是课设要的东西。

5. 避坑与排查:哈夫曼压缩最常见的五个翻车点

5.1 空文件与单字符树退化

现象:空文件压缩后解压崩溃,或者单字节文件解压出乱码、死循环。原因:空文件没有叶子,buildTree返回-1,如果压缩端还去生成编码表就会访问空节点;单字符文件建树后根就是叶子,dfsCode深度为0,编码表里没有有效位串,解压端按普通树走会卡在left/right都是-1的循环外,但位流没消耗。解决:压缩端在size==0时只写头不写正文,解压端读到origSize=0直接创建空文件返回;单字符树强制编码为0、长度1,解压端单独分支,每输出一个字符读一位。报告里把这两个用例单列,别混在“正常文件”里一笔带过。

5.2 填充位与原始长度:解压多出字节

现象:解压出来的文件比原文件多几个字节,内容前面都对,尾部多出0x00或乱码。原因:压缩最后不满8位时左移补0,解压端如果循环到fgetc返回EOF才停,会把补出来的0也当成有效编码,走树走到某个叶子就多输出字符。解决:文件头必须存origSize,解压循环严格按原始字节数走,不读文件尾。压缩端在bwFlush后不要再写位,文件头写完再开位流,顺序别反。排查时用cmp -l original restored | head看第一个差异位置,如果差异在文件尾,基本就是填充位没截断。

5.3 文件头对齐与字节序:换台机器就读不了

现象:本机压缩本机解压正常,换一台机器或换编译器后读头失败,魔数对但频次表全乱。原因:直接fwrite整个结构体,编译器在char magic[4]和uint64_t origSize之间插了填充字节,uint32_t又按本机字节序写,文件布局不固定。解决:逐字段写,整数统一小端,读的时候逐字节拼。结构体只用来在内存里组织数据,不直接落盘。排查时用od -An -tx1 -N16 file.huf看前16字节,前4字节应该是48 55 46 31,后面8字节是原始长度的小端表示。报告里写一句“文件格式与内存结构解耦”,这是能加分的工程意识。

5.4 文本模式、缓冲区与内存:压缩包损坏或内存飙升

现象一:Windows下压缩后的文件比预期大,解压还原失败,尤其文本文件里带0x1A时提前结束。原因:fopen用了"r"/"w",文本模式把\r\n转成\n,遇到0x1A当EOF。解决:所有文件一律"rb"/"wb",频次统计、压缩、解压都不例外。现象二:压缩文件最后少一截,解压提前报错。原因:bwFlush没调用,或者调用前fclose了输出文件。解决:压缩主流程结束前显式bwFlush,再fclose,顺序别写反。现象三:大文件压缩时内存飙高。原因:图省事把整个文件读进malloc的缓冲区再逐字节编码。解决:频率统计分块读,压缩阶段重新打开文件流式读,内存只保留频次表、节点数组和编码表。排查时看/usr/bin/time -v的峰值内存,如果随文件大小线性涨,就是缓存了全文件。

6. 二次开发:把固定哈夫曼改成可配置的分块压缩

固定哈夫曼的改造空间不在树本身,而在“频率表怎么来”和“文件头怎么组织”。最顺手的一步是加命令行参数:-b 524288按512KB分块,每块独立统计频次、独立建树、独立写头,解压端按块循环。分块后压缩率通常略降,但内存上限固定,处理大文件更稳。第二歩在文件头加CRC32,解压完算一遍原文件校验和,和头里的值比对,能挡住位流截断和填充位错误。第三歩把压缩率、耗时打印到stderr,报告里的性能表就有数据来源。

改造点改动位置验证方法
分块压缩compressFile外层加块循环,头里加blockSize大文件峰值内存不随文件线性涨
CRC32校验文件头加4字节,解压后重算故意改一位,解压应报校验失败
命令行参数main.c解析-b、-v-b 1024能跑通小文件
统计输出压缩结束打印原大小/压缩后大小/耗时报告表格有可复现数据

改造完先跑三组回归:空文件、单字符、随机二进制。分块后还要加一组“块边界文件”,大小刚好等于块大小加1字节,检查最后一块的origSize有没有写错。我一般会把cmp和sha256sum写进脚本,每次改完先跑脚本再看压缩率,避免手工点一遍漏用例。从那以后我每次动位流代码,都强制先跑空文件和单字符这两组,再谈压缩率——这两个用例不过,后面的数字都是假的。希望帮到你。

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

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

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

立即咨询