资讯详情 MFC哈夫曼压缩器源码解析:从建树到位流写入的完整实现
📅 2026/10/23 16:04:41
简介这份资源是一个基于哈夫曼编码实现的文件压缩与解压工具面向学习数据结构、算法课程设计或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 技术栈与依赖语言CC11 足够部分老版本可能用 C98 写法框架MFCMicrosoft Foundation Classes基于对话框模式开发环境Visual Studio2015/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::vectorbool bits; // 用 bool 向量存 0/1方便逐位操作 unsigned int length; // 编码长度 };用std::vectorbool存编码虽然有点“奢侈”但写起来直观调试时能直接打印每一位。如果追求极致内存可以改成unsigned char加位掩码但代码可读性会下降。对于课程设计或小工具来说现在的写法更友好。2.4 压缩流程的四个阶段整个压缩过程可以拆成四步统计频率读一遍源文件用unsigned int freq[256]记录每个字节出现次数。构建哈夫曼树把所有频率非零的字节建成叶子节点放进优先队列每次取两个最小权值合并直到只剩一个根节点。生成编码表从叶子往上回溯到根左分支记 0右分支记 1反转后得到每个字节的哈夫曼编码。写入压缩文件先写文件头原始文件大小、频率表或树结构再把每个字节替换成对应的位流每 8 位打包成一个字节写入。解压则是反过来读文件头重建哈夫曼树然后逐位遍历树走到叶子就输出一个字节直到还原出原始大小的数据。3. 跑起来从打开解决方案到完成第一次压缩3.1 环境准备与项目打开先确认你的 Visual Studio 安装了 MFC 组件。打开 VS Installer点“修改”在“单个组件”里搜索“MFC”勾选最新版本即可。然后双击Huffman.slnVS 会自动加载项目。如果提示“重定向项目”选“确定”就行一般不影响编译。打开后先看解决方案配置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::vectorunsigned char data( (std::istreambuf_iteratorchar(ifs)), std::istreambuf_iteratorchar() ); ifs.close(); // 3. 调用核心压缩函数 HuffmanCore core; std::vectorunsigned 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_castconst 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_queueHuffNode*, std::vectorHuffNode*, Compare pq; // 为每个出现的字节创建叶子节点 for (int i 0; i 256; i) { if (freq[i] 0) { HuffNode* leaf new HuffNode(); leaf-data static_castunsigned 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::vectorbool current, std::mapunsigned char, CodeEntry codeTable) { if (!root) return; // 叶子节点保存编码 if (!root-left !root-right) { CodeEntry entry; entry.bits current; entry.length static_castunsigned 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::vectorbool bits, unsigned char buffer, int bitCount) { for (bool bit : bits) { buffer (buffer 1) | (bit ? 1 : 0); bitCount; if (bitCount 8) { ofs.put(static_castchar(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_castchar(buffer)); buffer 0; bitCount 0; } }参数buffer和bitCount需要在压缩主循环外维护每处理完一个字节的编码就调用一次WriteBits。最后别忘了FlushBits否则最后几个位会丢解压出来文件末尾少几个字节。4.4 解压时的逐位遍历解压端先读文件头和频率表重建哈夫曼树然后从根开始每读一位就向左或向右走走到叶子就输出字节并回到根bool Decompress(const std::vectorunsigned char compressed, std::vectorunsigned char output) { // 1. 读取文件头 unsigned int originalSize *reinterpret_castconst 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::vectorbool的遍历有没有依赖未定义行为。6. 二次开发与改造从能跑到好用6.1 把算法内核抽成独立静态库现在HuffmanCore和 MFC 对话框在同一个工程里想复用到其他项目得连界面一起拷。更干净的做法是新建一个“静态库”项目把HuffmanCore.cpp/.h移进去编译出.lib然后在 MFC 工程里链接。这样以后写命令行版、Qt 版、甚至 Python 扩展都能直接调。改造步骤在解决方案里右键 → 添加 → 新建项目 → 静态库。把核心文件移过去去掉对 MFC 头文件的依赖HuffmanCore里不应该出现CString。MFC 工程里添加引用或者手动在链接器 → 输入 → 附加依赖项里加上.lib。6.2 加一个“压缩率实时显示”的进度条现在的界面点完压缩才弹结果大文件时用户不知道卡了多久。可以在Compress函数里加一个回调每处理 10% 的数据就更新一次进度条。MFC 里用PostMessage发自定义消息给对话框避免跨线程直接操作控件。// 在核心类里定义回调 typedef void (*ProgressCallback)(int percent, void* userData); bool Compress(const std::vectorunsigned char input, std::vectorunsigned 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_castint(60.0 * i / input.size()), userData); } } if (cb) cb(100, userData); return true; }对话框里实现回调函数把百分比转成进度条位置。注意回调里不要直接调SetPos用PostMessage更安全。6.3 支持文件夹批量压缩现在的逻辑一次只能选一个文件。想压缩整个文件夹可以遍历目录对每个文件单独压缩最后打包成一个自定义格式比如先写文件数量再写每个文件的路径和压缩数据。这个改造量不小但核心压缩函数不用动只是外层加一层遍历和打包逻辑。我一般会先写一个CompressFolder函数用CFileFind递归找文件把每个文件的压缩结果追加到同一个输出流里每个文件前写一个头部记录原始路径和大小。解压时按顺序读头部还原目录结构。6.4 验证改造是否正确的三个方法改完代码最怕引入新 bug。我习惯用这三个方法验证单元测试对核心算法写几个测试用例比如空文件、单字节文件、全相同字节文件、随机二进制文件压缩再解压对比原始数据。边界测试文件大小刚好是 8 位的整数倍、差 1 位、差 7 位看补位逻辑是否正确。对比测试用同一份文件分别跑改造前和改造后的程序对比压缩后的字节流是否完全一致。如果一致说明算法没被改坏。提示改造前先用 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跑起来并且改出自己想要的样子。本文还有配套的精品资源点击获取