ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC哈夫曼压缩器源码解析:从建树到位流写入的完整实现

MFC哈夫曼压缩器源码解析:从建树到位流写入的完整实现

简介:这份资源是一个基于哈夫曼编码实现的文件压缩与解压工具,面向学习数据结构、算法课程设计或C++桌面开发的学生与开发者,帮助理解哈夫曼树构造、编码生成及二进制文件读写的完整流程。压缩包共53个文件,约109.76MB,包含5个cpp源文件与7个h头文件承载核心压缩解压逻辑,rc、aps、ico、bmp等资源文件配合MFC搭建可视化界面,sln、vcxproj、filters构成VS工程结构,另有pdb、obj、tlog等编译调试产物。已有1102人学习下载,说明该案例在算法实践与MFC入门中具有一定参考价值。读者可从中获得可直接编译运行的完整工程,观察哈夫曼编码如何压缩文件体积、如何还原原始数据,并借鉴MFC对话框与控件交互的写法,适合作为课程设计、毕业设计或自学练手的参考素材。

1. 从一份 Huffman.zip 说起:MFC 界面下的哈夫曼压缩器到底能干什么

如果你手头正好有一份Huffman.zip,解压后看到一堆.cpp、.h和.rc文件,别急着怀疑它是不是“上古遗留物”。这其实是一个用 C++ 配合 MFC 写的哈夫曼编码压缩/解压工具,带图形界面,能直接选文件、点按钮完成压缩和解压。它解决的不是“我要替代 WinRAR”这种需求,而是让你在一个可视化的窗口里,亲眼看到哈夫曼树怎么建、编码表怎么生成、二进制流怎么写出。对于正在学数据结构、准备课程设计,或者想找一个能跑起来的 MFC 小项目练手的人来说,这份源码的性价比很高。它不依赖第三方压缩库,核心算法全部手写,压缩率虽然比不上 ZIP,但胜在逻辑透明,改起来不玄学。接下来我会按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份资源拆开讲清楚。

2. 工程结构与技术选型:MFC 对话框 + 纯 C++ 算法内核

2.1 目录里都有什么

解压Huffman.zip后,常见的目录结构大致如下(不同版本可能略有差异,但核心文件不会少):

Huffman/ ├── Huffman.sln # VS 解决方案文件 ├── Huffman.vcxproj # 项目文件 ├── Huffman.vcxproj.filters ├── res/ # 资源文件夹 │ ├── Huffman.rc2 │ └── Huffman.ico ├── HuffmanDlg.cpp # 主对话框实现 ├── HuffmanDlg.h ├── Huffman.cpp # 应用入口 ├── Huffman.h ├── stdafx.cpp # 预编译头 ├── stdafx.h ├── targetver.h ├── HuffmanCore.cpp # 哈夫曼核心算法 ├── HuffmanCore.h ├── resource.h # 资源 ID 定义 └── Huffman.rc # 对话框布局资源

这个结构是典型的 MFC 对话框工程。HuffmanDlg负责界面交互,HuffmanCore才是真正干活的模块。把算法和界面分开写,好处是以后想换 Qt 或者加命令行模式,核心代码不用大改。

2.2 技术栈与依赖

  • 语言:C++(C++11 足够,部分老版本可能用 C++98 写法)
  • 框架:MFC(Microsoft Foundation Classes),基于对话框模式
  • 开发环境:Visual Studio(2015/2017/2019/2022 均可,需安装“使用 C++ 的桌面开发”工作负载)
  • 字符集:项目可能使用多字节字符集或 Unicode,打开工程后先看属性页里的“字符集”设置
  • 第三方库:无。所有压缩逻辑自己实现,不依赖 zlib、7z 等

注意:如果你的 VS 没有安装 MFC 组件,打开项目会提示找不到afxwin.h。在 Visual Studio Installer 里勾选“MFC 和 ATL 支持”即可。

2.3 核心数据结构长什么样

哈夫曼算法的核心是树节点和编码表。在HuffmanCore.h里,通常会看到类似这样的定义:

// 哈夫曼树节点 struct HuffNode { unsigned char data; // 叶子节点存储的字节值 unsigned int weight; // 权值(出现频率) HuffNode* left; // 左孩子 HuffNode* right; // 右孩子 HuffNode* parent; // 父节点,方便回溯编码 HuffNode() : data(0), weight(0), left(nullptr), right(nullptr), parent(nullptr) {} }; // 编码表项:每个字节对应的哈夫曼编码 struct CodeEntry { std::vector<bool> bits; // 用 bool 向量存 0/1,方便逐位操作 unsigned int length; // 编码长度 };

用std::vector<bool>存编码虽然有点“奢侈”,但写起来直观,调试时能直接打印每一位。如果追求极致内存,可以改成unsigned char加位掩码,但代码可读性会下降。对于课程设计或小工具来说,现在的写法更友好。

2.4 压缩流程的四个阶段

整个压缩过程可以拆成四步:

  1. 统计频率:读一遍源文件,用unsigned int freq[256]记录每个字节出现次数。
  2. 构建哈夫曼树:把所有频率非零的字节建成叶子节点,放进优先队列,每次取两个最小权值合并,直到只剩一个根节点。
  3. 生成编码表:从叶子往上回溯到根,左分支记 0,右分支记 1,反转后得到每个字节的哈夫曼编码。
  4. 写入压缩文件:先写文件头(原始文件大小、频率表或树结构),再把每个字节替换成对应的位流,每 8 位打包成一个字节写入。

解压则是反过来:读文件头重建哈夫曼树,然后逐位遍历树,走到叶子就输出一个字节,直到还原出原始大小的数据。

3. 跑起来:从打开解决方案到完成第一次压缩

3.1 环境准备与项目打开

先确认你的 Visual Studio 安装了 MFC 组件。打开 VS Installer,点“修改”,在“单个组件”里搜索“MFC”,勾选最新版本即可。然后双击Huffman.sln,VS 会自动加载项目。如果提示“重定向项目”,选“确定”就行,一般不影响编译。

打开后先看解决方案配置:Debug 和 Release 都有,平台是 Win32 或 x64。建议先用 Debug 跑通,方便看输出信息。

3.2 编译时可能遇到的第一个坑

直接按 F7 编译,有时候会报fatal error C1083: 无法打开包括文件: "afxwin.h"。这不是代码问题,而是 MFC 没装或者项目属性里的“使用 MFC”设置不对。右键项目 → 属性 → 配置属性 → 常规 → “使用 MFC”应设为“在共享 DLL 中使用 MFC”或“在静态库中使用 MFC”。如果这个选项是灰色的,说明 MFC 组件确实没装。

另一个常见错误是error MSB8041: 此项目需要 MFC 库。同样,装组件就能解决。

3.3 界面操作与文件选择

编译成功后运行,会弹出一个对话框。上面通常有:

  • 一个“选择文件”按钮,点击后弹出CFileDialog
  • 一个显示文件路径的编辑框
  • “压缩”和“解压”两个按钮
  • 一个进度条或状态文本

选择文件后,程序会读取文件内容并统计频率。如果文件很大(比如几百 MB),统计阶段会卡一下,因为它是单线程逐字节读的。对于学习用途,建议先用几 KB 到几 MB 的文本文件测试。

3.4 压缩按钮背后的代码链路

点击“压缩”按钮时,MFC 会调用OnBnClickedCompress()之类的消息处理函数。典型实现如下:

void CHuffmanDlg::OnBnClickedCompress() { // 1. 获取源文件路径 CString srcPath; GetDlgItemText(IDC_EDIT_SRC, srcPath); if (srcPath.IsEmpty()) { MessageBox(_T("请先选择文件"), _T("提示"), MB_ICONWARNING); return; } // 2. 读取文件到内存(小文件直接读,大文件建议分块) std::ifstream ifs(srcPath, std::ios::binary); if (!ifs) { MessageBox(_T("文件打开失败"), _T("错误"), MB_ICONERROR); return; } std::vector<unsigned char> data( (std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>() ); ifs.close(); // 3. 调用核心压缩函数 HuffmanCore core; std::vector<unsigned char> compressed; if (!core.Compress(data, compressed)) { MessageBox(_T("压缩失败"), _T("错误"), MB_ICONERROR); return; } // 4. 写入目标文件(默认在原文件名后加 .huf) CString dstPath = srcPath + _T(".huf"); std::ofstream ofs(dstPath, std::ios::binary); ofs.write(reinterpret_cast<const char*>(compressed.data()), compressed.size()); ofs.close(); // 5. 显示压缩率 CString msg; msg.Format(_T("压缩完成!原始大小:%zu 字节,压缩后:%zu 字节"), data.size(), compressed.size()); MessageBox(msg, _T("结果"), MB_ICONINFORMATION); }

这段代码的逻辑很直白:读文件 → 调核心 → 写文件 → 弹结果。参数方面,srcPath来自编辑框,dstPath默认加.huf后缀。如果你想改成弹保存对话框,把第 4 步换成CFileDialog的DoModal()即可。

3.5 解压流程与验证

解压按钮的处理函数类似,只是调用core.Decompress()。解压时需要先读文件头里的原始大小和频率表,重建哈夫曼树,然后逐位解码。验证方法很简单:压缩一个文本文件,再解压,用fc命令或文本比较工具对比原文件和解压后的文件,内容一致就说明算法正确。

提示:如果解压出来的文件乱码或大小不对,先检查文件头写入和读取的字节序是否一致。常见错误是写入时用了sizeof(int),读取时却按unsigned short读。

4. 核心算法拆解:建树、编码、位流写入

4.1 频率统计与优先队列建树

统计频率的代码没什么好说的,一个循环搞定。建树才是哈夫曼的精华。常见做法是用std::priority_queue,自定义比较器让权值小的排在前面:

// 比较器:权值小的优先级高 struct Compare { bool operator()(HuffNode* a, HuffNode* b) { return a->weight > b->weight; // 注意是大于,因为 priority_queue 默认大顶堆 } }; HuffNode* BuildTree(const unsigned int freq[256]) { std::priority_queue<HuffNode*, std::vector<HuffNode*>, Compare> pq; // 为每个出现的字节创建叶子节点 for (int i = 0; i < 256; ++i) { if (freq[i] > 0) { HuffNode* leaf = new HuffNode(); leaf->data = static_cast<unsigned char>(i); leaf->weight = freq[i]; pq.push(leaf); } } // 特殊情况:文件只有一种字节 if (pq.size() == 1) { HuffNode* only = pq.top(); pq.pop(); HuffNode* root = new HuffNode(); root->weight = only->weight; root->left = only; only->parent = root; return root; } // 合并最小的两个节点 while (pq.size() > 1) { HuffNode* left = pq.top(); pq.pop(); HuffNode* right = pq.top(); pq.pop(); HuffNode* parent = new HuffNode(); parent->weight = left->weight + right->weight; parent->left = left; parent->right = right; left->parent = parent; right->parent = parent; pq.push(parent); } return pq.top(); }

这里有个容易翻车的点:如果文件里所有字节都相同,优先队列里只有一个节点,直接取出来当根节点会导致编码长度为 0。所以上面加了一个特殊处理,给它造一个父节点,强制产生一位编码。这个细节很多网上抄来抄去的代码都没写,结果压缩空文件或单字符文件时直接崩。

4.2 递归生成编码表

树建好后,从根往下遍历,左走加 0,右走加 1,到叶子就把路径存进编码表:

void GenerateCodes(HuffNode* root, std::vector<bool>& current, std::map<unsigned char, CodeEntry>& codeTable) { if (!root) return; // 叶子节点:保存编码 if (!root->left && !root->right) { CodeEntry entry; entry.bits = current; entry.length = static_cast<unsigned int>(current.size()); codeTable[root->data] = entry; return; } // 左分支加 0 current.push_back(false); GenerateCodes(root->left, current, codeTable); current.pop_back(); // 右分支加 1 current.push_back(true); GenerateCodes(root->right, current, codeTable); current.pop_back(); }

用递归写最简洁,但树很深时可能爆栈。对于 256 个叶子节点的哈夫曼树,深度最多 255,递归完全扛得住。如果你非要改成迭代,可以用栈模拟,但代码会复杂不少,没必要。

4.3 位流写入与文件头设计

压缩文件不能只存编码后的位流,还得让解压端知道怎么重建树。常见做法是在文件头写入频率表(256 个unsigned int),解压端读出来后用同样的建树逻辑重建。文件头格式如下:

字段类型说明
原始文件大小unsigned int用于解压时判断何时停止
频率表unsigned int[256]每个字节的出现次数,0 表示未出现
压缩数据位流每 8 位打包成一个字节,末尾补 0

写入位流的代码需要一个小缓冲区:

bool WriteBits(std::ofstream& ofs, const std::vector<bool>& bits, unsigned char& buffer, int& bitCount) { for (bool bit : bits) { buffer = (buffer << 1) | (bit ? 1 : 0); ++bitCount; if (bitCount == 8) { ofs.put(static_cast<char>(buffer)); buffer = 0; bitCount = 0; } } return true; } // 收尾:把不足 8 位的剩余位补 0 写入 void FlushBits(std::ofstream& ofs, unsigned char& buffer, int& bitCount) { if (bitCount > 0) { buffer <<= (8 - bitCount); ofs.put(static_cast<char>(buffer)); buffer = 0; bitCount = 0; } }

参数buffer和bitCount需要在压缩主循环外维护,每处理完一个字节的编码就调用一次WriteBits。最后别忘了FlushBits,否则最后几个位会丢,解压出来文件末尾少几个字节。

4.4 解压时的逐位遍历

解压端先读文件头和频率表,重建哈夫曼树,然后从根开始,每读一位就向左或向右走,走到叶子就输出字节并回到根:

bool Decompress(const std::vector<unsigned char>& compressed, std::vector<unsigned char>& output) { // 1. 读取文件头 unsigned int originalSize = *reinterpret_cast<const unsigned int*>(&compressed[0]); unsigned int freq[256]; memcpy(freq, &compressed[4], sizeof(freq)); // 2. 重建哈夫曼树 HuffNode* root = BuildTree(freq); // 3. 逐位解码 HuffNode* current = root; unsigned int decoded = 0; size_t pos = 4 + sizeof(freq); // 跳过文件头 while (decoded < originalSize && pos < compressed.size()) { unsigned char byte = compressed[pos++]; for (int i = 7; i >= 0 && decoded < originalSize; --i) { bool bit = (byte >> i) & 1; current = bit ? current->right : current->left; if (!current->left && !current->right) { output.push_back(current->data); ++decoded; current = root; // 回到根,准备下一个字节 } } } // 4. 释放树内存(略) return decoded == originalSize; }

注意originalSize的判断必须放在内层循环里,否则最后补的 0 位会被误判成有效数据,多输出几个字节。

5. 避坑与排查:那些让你怀疑人生的编译和运行错误

5.1 现象:编译报错“无法打开源文件 afxwin.h”

原因:Visual Studio 没有安装 MFC 组件,或者项目属性里“使用 MFC”被设成了“不使用”。

解决:打开 VS Installer → 修改 → 单个组件 → 搜索“MFC” → 勾选对应版本 → 安装。然后在项目属性 → 常规 → 使用 MFC 里选“在共享 DLL 中使用 MFC”。

5.2 现象:压缩后的文件比原文件还大

原因:哈夫曼编码对已经压缩过的文件(如 jpg、png、zip)效果很差,因为字节分布接近均匀,编码长度接近 8 位,再加上文件头 1KB 多的频率表,自然变大。

解决:这是算法特性,不是 bug。测试时用纯文本文件(.txt、.csv、.log)才能看到压缩效果。如果非要压缩二进制文件,可以在压缩前判断熵值,熵太高就直接存储不压缩。

5.3 现象:解压时程序崩溃,提示访问冲突

原因:文件头读取时没有检查文件大小,或者频率表读取越界。比如压缩文件被截断,compressed[4]之后的数据不够 1024 字节。

解决:在读文件头之前先判断compressed.size() >= 4 + 256 * sizeof(unsigned int),不满足就直接返回错误。另外,reinterpret_cast读unsigned int在跨平台时可能有对齐问题,稳妥做法是用memcpy逐字节拷贝。

5.4 现象:解压出来的文件末尾多出几个乱码字节

原因:压缩时最后不足 8 位的补了 0,解压时没有用originalSize控制停止,把补的 0 也解出了字符。

解决:在解压循环里每输出一个字节就判断decoded < originalSize,达到原始大小立即停止,忽略剩余位。

5.5 现象:Release 模式下压缩结果和 Debug 不一样

原因:代码里有未初始化变量,或者浮点运算在优化后行为不同。哈夫曼算法本身是确定性的,出现差异通常是buffer或bitCount没初始化。

解决:在压缩函数开头把buffer = 0; bitCount = 0;写死。另外检查std::vector<bool>的遍历有没有依赖未定义行为。

6. 二次开发与改造:从能跑到好用

6.1 把算法内核抽成独立静态库

现在HuffmanCore和 MFC 对话框在同一个工程里,想复用到其他项目得连界面一起拷。更干净的做法是新建一个“静态库”项目,把HuffmanCore.cpp/.h移进去,编译出.lib,然后在 MFC 工程里链接。这样以后写命令行版、Qt 版、甚至 Python 扩展都能直接调。

改造步骤:

  1. 在解决方案里右键 → 添加 → 新建项目 → 静态库。
  2. 把核心文件移过去,去掉对 MFC 头文件的依赖(HuffmanCore里不应该出现CString)。
  3. MFC 工程里添加引用,或者手动在链接器 → 输入 → 附加依赖项里加上.lib。

6.2 加一个“压缩率实时显示”的进度条

现在的界面点完压缩才弹结果,大文件时用户不知道卡了多久。可以在Compress函数里加一个回调,每处理 10% 的数据就更新一次进度条。MFC 里用PostMessage发自定义消息给对话框,避免跨线程直接操作控件。

// 在核心类里定义回调 typedef void (*ProgressCallback)(int percent, void* userData); bool Compress(const std::vector<unsigned char>& input, std::vector<unsigned char>& output, ProgressCallback cb = nullptr, void* userData = nullptr) { // ... 统计频率 ... if (cb) cb(10, userData); // ... 建树 ... if (cb) cb(30, userData); // ... 编码写入 ... for (size_t i = 0; i < input.size(); ++i) { // 写入当前字节的编码 if (cb && i % (input.size() / 10 + 1) == 0) { cb(30 + static_cast<int>(60.0 * i / input.size()), userData); } } if (cb) cb(100, userData); return true; }

对话框里实现回调函数,把百分比转成进度条位置。注意回调里不要直接调SetPos,用PostMessage更安全。

6.3 支持文件夹批量压缩

现在的逻辑一次只能选一个文件。想压缩整个文件夹,可以遍历目录,对每个文件单独压缩,最后打包成一个自定义格式(比如先写文件数量,再写每个文件的路径和压缩数据)。这个改造量不小,但核心压缩函数不用动,只是外层加一层遍历和打包逻辑。

我一般会先写一个CompressFolder函数,用CFileFind递归找文件,把每个文件的压缩结果追加到同一个输出流里,每个文件前写一个头部记录原始路径和大小。解压时按顺序读头部,还原目录结构。

6.4 验证改造是否正确的三个方法

改完代码最怕引入新 bug。我习惯用这三个方法验证:

  1. 单元测试:对核心算法写几个测试用例,比如空文件、单字节文件、全相同字节文件、随机二进制文件,压缩再解压,对比原始数据。
  2. 边界测试:文件大小刚好是 8 位的整数倍、差 1 位、差 7 位,看补位逻辑是否正确。
  3. 对比测试:用同一份文件,分别跑改造前和改造后的程序,对比压缩后的字节流是否完全一致。如果一致,说明算法没被改坏。

提示:改造前先用 Git 打一个 tag,改完发现不对可以随时回滚。没有版本控制的话,至少把原始Huffman.zip备份一份。

6.5 一个容易忽略的细节:内存泄漏

BuildTree里new出来的节点,在解压完成后必须全部delete。MFC 程序虽然退出时系统会回收,但如果你把核心代码抽成库给长期运行的服务用,泄漏就会累积。写一个FreeTree(HuffNode* root)递归释放:

void FreeTree(HuffNode* root) { if (!root) return; FreeTree(root->left); FreeTree(root->right); delete root; }

在Compress和Decompress的出口处都调一次。别嫌麻烦,我见过太多课程设计因为没释放内存,跑几百次后直接卡死。

从那以后我每次改完压缩逻辑,都会强制走一遍“空文件 → 单字节 → 全相同 → 随机二进制 → 大文本”这五个用例,确认解压结果和原始文件二进制一致才提交。希望这份拆解能帮你把Huffman.zip跑起来,并且改出自己想要的样子。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进