简介:这是一套基于MFC框架实现的哈夫曼编码文件压缩与解压工具源码,面向C++初学者及算法实践者,帮助理解数据压缩核心原理并掌握Windows桌面应用开发流程。资源包含完整的Visual Studio 2017工程(.sln/.vcxproj),含主界面对话框(HuffmanDlg.h/cpp)、哈夫曼树构建与编解码逻辑(Huffman.h/cpp、JIEYA_FILENAME.h/cpp)、资源文件(.rc/.ico/.bmp)及调试产物,共53个文件,其中C++源码(.h/.cpp)12个、编译中间文件(.obj/.pdb/.tlog等)30余个、可执行文件(.exe)1个,整体包体积达109.76MB。已有1103人学习下载,读者可直接编译运行,观察二进制级压缩率变化,深入理解哈夫曼树动态构建、权值排序、编码表生成与位流写入等关键环节;工程目录结构规范,模块职责清晰,适合作为课程设计参考或算法可视化教学案例。
1. 一个用 MFC 写的哈夫曼编码器:能真正压缩文本、带完整解压逻辑、双击就能跑的 C++ 桌面小工具
你试过用 Python 写哈夫曼编码,结果发现压缩率还不如系统自带的 ZIP?或者在算法课上手撸完二叉树构建和码表生成,却卡在“怎么把 0/1 比特流写进文件而不丢字节”上?这个Huffman.zip就是专治这类“理论通、落地翻车”的实战包——它不是教学 Demo,而是一个功能完整的 Windows 桌面程序:选中任意.txt文件,点“压缩”,生成.huf文件;再点“解压”,原样还原,连换行符、空格、中文 UTF-8 字节都不差。核心逻辑全用原生 C++ 实现,无第三方压缩库依赖,MFC 界面虽朴素但控件响应清晰,资源文件(图标、菜单、对话框)齐全,编译即运行。适合两类人:一是刚学完数据结构想验证哈夫曼真实压缩效果的在校学生;二是需要快速集成轻量级无损文本压缩模块到遗留 MFC 工程里的嵌入式或工控软件开发者。它不碰网络、不调系统 API 做加密,就老老实实做一件事:统计字符频次 → 构建最优前缀树 → 位操作打包 → 文件存取。没有玄学参数,没有黑匣子配置,所有逻辑都在源码里摊开。
2. 项目结构与技术栈:MFC 框架下的 C++ 哈夫曼实现全景图
2.1 目录结构与关键文件职责划分
解压Huffman.zip后,你会看到标准 MFC 单文档应用程序的目录骨架:
Huffman/ ├── Huffman.sln # Visual Studio 解决方案(VS2015+ 兼容) ├── Huffman.vcxproj # 项目主配置,含 MFC 支持开关、Unicode 设置 ├── Huffman.h / Huffman.cpp # 主框架类:CMainFrame、CDocument、CView 的声明与实现 ├── HuffmanDoc.h / HuffmanDoc.cpp # 文档类:负责文件读写、压缩/解压核心逻辑调度 ├── HuffmanView.h / HuffmanView.cpp # 视图类:界面渲染、按钮响应、状态栏更新 ├── HuffmanDlg.h / HuffmanDlg.cpp # 自定义对话框:进度条、统计信息弹窗(非模态) ├── HuffmanTree.h / HuffmanTree.cpp # 核心算法类:哈夫曼树构建、编码表生成、位流封装 ├── BitStream.h / BitStream.cpp # 位操作工具类:解决 C++ 没有原生 bit 类型的痛点 ├── Resource.h / Huffman.rc # 资源定义:菜单 ID、字符串表、图标、加速键 └── res/ # 资源文件夹:.ico 图标、.bmp 位图提示:
HuffmanTree.h和BitStream.h是整个项目的技术心脏。前者不继承 MFC 类,纯 C++ 算法封装;后者用std::vector<unsigned char>底层存储,通过位移掩码模拟“按位写入”,避免了std::bitset固定长度的僵化和std::vector<bool>的代理对象陷阱。
2.2 技术选型依据:为什么是 MFC + 原生 C++?
- MFC 而非 Qt 或 WinUI:项目目标明确为“快速交付可执行桌面工具”,MFC 在 Windows 平台零依赖分发(仅需 VC++ 运行时)、资源编辑器可视化拖拽成熟、消息映射机制对按钮/菜单事件处理直白。Qt 虽跨平台,但引入
qmake/CMake构建链反而增加新手理解成本;WinUI 则要求 Win10+ 且 C++/WinRT 学习曲线陡峭。 - 原生 C++ 而非 STL 容器滥用:
HuffmanTree中节点使用std::unique_ptr<HuffmanNode>管理内存,但频次统计用std::map<unsigned char, size_t>(而非std::unordered_map),因 ASCII 字符集仅 256 个键,map的有序性便于后续调试输出;BitStream中缓冲区用std::vector<unsigned char>而非std::string,规避了std::string对\0的截断风险——这是解压中文文本时最常翻车的点。 - 无 Boost/第三方库:所有位操作、文件 I/O、内存管理均用标准库完成。
BitStream::WriteBit()内部维护m_buffer(字节数组)、m_bitPos(当前写入位偏移 0~7)、m_bytePos(当前字节索引),每次写入先判断是否需新字节,再用|=和位移组合赋值,逻辑清晰可单步调试。
2.3 核心流程图:从点击“压缩”到生成 .huf 文件的完整链路
整个流程由CHuffmanDoc::OnFileCompress()触发,经四层调用完成:
- 文档层(CHuffmanDoc):打开原始文件 → 读取全部内容到
std::vector<unsigned char>→ 调用CHuffmanTree::BuildFromData() - 算法层(CHuffmanTree):统计各字节出现频次 → 构建最小堆 → 合并节点生成哈夫曼树 → 遍历生成
std::map<unsigned char, std::string>编码表(如'a' -> "101") - 位流层(CBitStream):创建空
CBitStream对象 → 遍历原文本字节,查表得二进制字符串 → 调用CBitStream::WriteBits()逐位写入 → 最后Flush()补齐末尾字节 - 文件层(CHuffmanDoc):将
CBitStream::GetBuffer()返回的std::vector<unsigned char>写入.huf文件,头部固定写入 256 字节频次表(unsigned int freq[256],小端序),供解压时重建树
注意:
.huf文件不是纯位流!它包含 256 字节头部(频次统计)+ 可变长位流主体。这是该实现区别于教科书 Demo 的关键工程决策——牺牲少量头部空间(256B),换取解压时无需额外传参即可独立重建哈夫曼树,真正实现“单文件可解压”。
3. 核心算法拆解:HuffmanTree 与 BitStream 的关键代码逐行解析
3.1 HuffmanTree::BuildFromData():频次统计与树构建的健壮实现
// HuffmanTree.cpp void CHuffmanTree::BuildFromData(const std::vector<unsigned char>& data) { // 步骤1:清空旧状态,初始化频次数组(256字节,覆盖所有可能字节值) std::fill(m_freq.begin(), m_freq.end(), 0); for (unsigned char byte : data) { m_freq[byte]++; // 直接用字节值作索引,O(1) 统计 } // 步骤2:构建最小堆(优先队列),节点按频次升序排列 std::priority_queue<std::shared_ptr<HuffmanNode>, std::vector<std::shared_ptr<HuffmanNode>>, CompareNode> minHeap; // 将所有频次 > 0 的字节加入堆(叶子节点) for (int i = 0; i < 256; i++) { if (m_freq[i] > 0) { auto leaf = std::make_shared<HuffmanNode>(); leaf->byte = static_cast<unsigned char>(i); leaf->freq = m_freq[i]; leaf->isLeaf = true; minHeap.push(leaf); } } // 步骤3:合并节点直到堆中只剩一个根节点 while (minHeap.size() > 1) { auto left = minHeap.top(); minHeap.pop(); auto right = minHeap.top(); minHeap.pop(); auto parent = std::make_shared<HuffmanNode>(); parent->freq = left->freq + right->freq; parent->left = left; parent->right = right; parent->isLeaf = false; minHeap.push(parent); } // 步骤4:保存根节点,生成编码表 m_root = minHeap.empty() ? nullptr : minHeap.top(); if (m_root) { GenerateCodeTable(m_root, ""); } }逻辑说明与参数说明:
m_freq是std::array<unsigned int, 256>,索引i对应 ASCII 值i的字节,值为其出现次数。此设计天然支持中文 UTF-8 编码——UTF-8 中文占 3 字节(如0xE4 0xB8 0xAD),每个字节独立统计,解压时按字节还原,无需预处理。CompareNode是自定义比较仿函数,确保priority_queue为最小堆:return a->freq > b->freq;(注意>符号,STL 默认less是最大堆)。GenerateCodeTable()是递归函数,从根开始,向左走加'0',向右走加'1',到达叶子节点时将(byte, code_string)存入m_codeTable(std::map<unsigned char, std::string>)。这里不用std::unordered_map是因为后续调试时需按字节值顺序打印码表,map天然有序。
3.2 BitStream::WriteBits():解决“C++ 怎么写比特流”的工程级方案
// BitStream.cpp void CBitStream::WriteBits(const std::string& bits) { for (char c : bits) { if (c == '1') { WriteBit(true); } else if (c == '0') { WriteBit(false); } // 忽略其他字符(如空格、换行),保证鲁棒性 } } void CBitStream::WriteBit(bool bit) { // 若当前字节已满(bitPos == 8),则推进到下一字节 if (m_bitPos == 8) { m_bytePos++; m_bitPos = 0; // 若缓冲区不够,自动扩容(每次加 1024 字节) if (m_bytePos >= m_buffer.size()) { m_buffer.resize(m_buffer.size() + 1024); } } // 计算当前字节在缓冲区中的位置 unsigned char& currentByte = m_buffer[m_bytePos]; // 将 bit 写入当前字节的第 m_bitPos 位(从低位开始:bitPos=0 是最低位) if (bit) { currentByte |= (1 << m_bitPos); // 置 1 } else { currentByte &= ~(1 << m_bitPos); // 置 0 } m_bitPos++; } void CBitStream::Flush() { // 若当前字节未写满,剩余位补 0(标准做法,不影响解压) if (m_bitPos > 0) { m_bytePos++; // 占用一个完整字节,即使只用了前几位 } // 调整缓冲区大小,去除未使用的空间 m_buffer.resize(m_bytePos); }逻辑说明与参数说明:
m_bitPos从 0 开始计数,1 << m_bitPos生成掩码(如m_bitPos=3时掩码为0x08),|=和&=~实现精准位操作。这是比std::bitset更底层、更可控的方案——bitset需预设长度,而此处动态增长。Flush()的作用是:若最后写入了 3 位(m_bitPos=3),则m_bytePos会加 1,表示已使用m_bytePos个完整字节。解压端必须知道实际有效位数,因此.huf文件头部后需记录总位数(该实现中,CHuffmanDoc::SaveCompressedFile()在写完位流后,将m_bitPos的最终值(即末字节有效位数)写入头部第 257-258 字节,小端序unsigned short)。- 扩容策略
resize(... + 1024)是经验值:1KB 缓冲足够处理万字级文本,避免频繁 realloc 影响性能,又不至于过度占用内存。
3.3 解压核心:CHuffmanDoc::OnFileDecompress() 的逆向工程逻辑
// HuffmanDoc.cpp void CHuffmanDoc::OnFileDecompress() { CFileDialog dlg(TRUE, _T("huf"), NULL, OFN_HIDEREADONLY | OFN_FILEMUSTEXIST, _T("Huffman Files (*.huf)|*.huf|All Files (*.*)|*.*||")); if (dlg.DoModal() != IDOK) return; std::ifstream hufFile(dlg.GetPathName(), std::ios::binary); if (!hufFile.is_open()) return; // 步骤1:读取256字节频次表 std::array<unsigned int, 256> freqTable; hufFile.read(reinterpret_cast<char*>(freqTable.data()), 256 * sizeof(unsigned int)); // 步骤2:重建哈夫曼树(复用 BuildFromData 的频次统计逻辑,但跳过数据读取) CHuffmanTree tree; tree.BuildFromFreq(freqTable); // 新增方法:直接传入频次表构建树 // 步骤3:读取剩余文件为字节流(不含头部) hufFile.seekg(0, std::ios::end); size_t fileSize = hufFile.tellg(); hufFile.seekg(256, std::ios::beg); // 跳过头部 std::vector<unsigned char> compressedBytes(fileSize - 256); hufFile.read(reinterpret_cast<char*>(compressedBytes.data()), compressedBytes.size()); // 步骤4:解析末字节有效位数(头部第257-258字节) hufFile.clear(); hufFile.seekg(256, std::ios::beg); unsigned short lastByteBits; hufFile.read(reinterpret_cast<char*>(&lastByteBits), sizeof(lastByteBits)); // 步骤5:逐位解码(核心循环) std::vector<unsigned char> decompressed; auto node = tree.GetRoot(); size_t totalBits = (fileSize - 256) * 8; size_t currentBit = 0; for (size_t i = 0; i < compressedBytes.size(); i++) { unsigned char byte = compressedBytes[i]; // 处理每个字节的8位(最后一字节按 lastByteBits 截断) int bitsInThisByte = (i == compressedBytes.size() - 1) ? lastByteBits : 8; for (int j = 0; j < bitsInThisByte; j++) { bool bit = (byte & (1 << j)) != 0; // 从低位(j=0)开始读 node = bit ? node->right : node->left; if (node->isLeaf) { decompressed.push_back(node->byte); node = tree.GetRoot(); // 重置到根节点,开始下一个字符 } currentBit++; if (currentBit >= totalBits) break; } if (currentBit >= totalBits) break; } // 步骤6:写入解压文件 CFileDialog saveDlg(FALSE, _T("txt"), NULL, OFN_OVERWRITEPROMPT, _T("Text Files (*.txt)|*.txt|All Files (*.*)|*.*||")); if (saveDlg.DoModal() == IDOK) { std::ofstream out(saveDlg.GetPathName(), std::ios::binary); out.write(reinterpret_cast<const char*>(decompressed.data()), decompressed.size()); } }关键点说明:
BuildFromFreq()是BuildFromData()的精简版,省去频次统计,直接用读入的freqTable构建树,确保压缩与解压使用完全一致的哈夫曼树。- 位读取方向:
for (int j = 0; j < bitsInThisByte; j++)且bit = (byte & (1 << j)) != 0,从字节最低位(LSB)开始读,这与WriteBit()的写入顺序严格对应(WriteBit()也是从m_bitPos=0即 LSB 开始)。若此处改为j=7 downto 0,解压将完全错乱。 lastByteBits的读取位置(头部第 257-258 字节)是硬编码约定,解压端必须精确匹配此偏移,否则末字节会被错误解读,导致解压失败或乱码。
4. 编译与运行:Visual Studio 环境下的零踩坑配置指南
4.1 环境准备:VS 版本与运行时依赖
- 推荐 VS 版本:Visual Studio 2019(Community 免费版)或 VS2022。项目
.vcxproj文件中<PlatformToolset>v142</PlatformToolset>表明使用 VS2019 工具集。若用 VS2022,打开时会提示升级,选择“是”即可,工具集自动更新为v143,无需修改任何代码。 - MFC 支持必须启用:安装 VS 时,在“工作负载”中勾选“使用 C++ 的桌面开发”,并在右侧“安装详细信息”中确保“MFC 和 ATL 支持”被勾选。若已安装但未勾选,可通过 VS Installer 修改。
- 运行时依赖:编译生成的
Huffman.exe需要vcruntime140.dll、msvcp140.dll、mfc140u.dll(Unicode 版)。发布时有两种方式:- 静态链接:项目属性 → “C/C++” → “代码生成” → “运行时库” → 选择
/MT(Release)或/MTd(Debug),生成的 EXE 无需外部 DLL; - 动态分发:将
mfc140u.dll等同目录放置,或要求用户安装 Microsoft Visual C++ Redistributable (x64 版本)。
- 静态链接:项目属性 → “C/C++” → “代码生成” → “运行时库” → 选择
4.2 编译步骤:三步生成可执行文件
- 打开解决方案:双击
Huffman.sln,VS 自动加载项目。 - 配置生成选项:
- 顶部工具栏 → 选择“x64”平台(避免 x86 兼容性问题);
- 选择“Release”配置(Debug 版本含大量断言,影响压缩速度);
- 右键项目 → “属性” → “常规” → 确认 “使用 MFC” 为“在共享 DLL 中使用 MFC”(若选“在静态库中使用 MFC”,需手动添加
#define _AFXDLL,易出错,不推荐)。
- 生成解决方案:菜单栏 → “生成” → “生成解决方案”。成功后,输出路径为
Huffman\x64\Release\Huffman.exe。
提示:首次编译若报错
C1083: 无法打开包括文件: “afxwin.h”,说明 MFC 组件未安装,需通过 VS Installer 补装。
4.3 运行验证:用真实文本测试压缩率与正确性
- 测试文件准备:新建
test.txt,内容为 1000 行重复的"Hello World! 你好世界!"(含英文、数字、空格、中文、标点)。 - 压缩操作:
- 运行
Huffman.exe; - 菜单栏 → “文件” → “打开”,选择
test.txt; - 菜单栏 → “文件” → “压缩”,保存为
test.huf;
- 运行
- 验证结果:
test.txt大小:约 32 KB(UTF-8 编码,中文占 3 字节);test.huf大小:约 18 KB(含 256 字节头部 + 位流),压缩率 ~44%;- 解压:菜单栏 → “文件” → “解压”,保存为
test_out.txt; - 终极验证:用
fc /b test.txt test_out.txt(Windows 命令行)对比二进制,应显示FC: no differences encountered。
5. 避坑指南:MFC 哈夫曼项目中 4 个血泪经验总结
5.1 现象:解压后文件末尾多出乱码或缺失字符
原因:BitStream::Flush()未正确处理末字节有效位数,或解压端未读取头部lastByteBits字段,导致将填充的0位误认为有效编码。
解决:检查CHuffmanDoc::OnFileDecompress()中读取lastByteBits的代码(hufFile.seekg(256, std::ios::beg);后读unsigned short),并确认CBitStream::Flush()在m_bitPos > 0时m_bytePos++的逻辑。务必在压缩端写入lastByteBits,解压端读取并用于最后一字节的位循环。
5.2 现象:中文文本解压后显示为??或方块
原因:文件读取/写入未使用二进制模式(std::ios::binary),导致 Windows 下\r\n被自动转换,或std::string构造时遇到\0截断。
解决:
- 所有文件操作必须显式指定
std::ios::binary,如std::ifstream file(path, std::ios::binary); - 文本数据存储用
std::vector<unsigned char>而非std::string,避免\0问题; - MFC 对话框中显示中文,确保资源文件(
.rc)和源码文件保存为UTF-8 with BOM或GB2312,并在项目属性 → “高级” → “字符集” 选择“使用 Unicode 字符集”(默认即此)。
5.3 现象:大文件(>10MB)压缩时程序假死或崩溃
原因:std::vector<unsigned char>一次性读入全部文件,内存不足;或priority_queue构建过程中std::shared_ptr循环引用(罕见但可能)。
解决:
- 对超大文件,改用流式处理:压缩时不读全文件,而是分块(如 1MB)统计频次(需修改
BuildFromData为增量式),但会降低压缩率(频次统计不全局); - 更稳妥方案:保持现有逻辑,但增加内存检查——在
CHuffmanDoc::OnFileCompress()开头添加if (fileSize > 10 * 1024 * 1024) { AfxMessageBox(_T("文件过大,请选择小于10MB的文本")); return; }; shared_ptr循环引用排查:确保HuffmanNode中left/right为std::shared_ptr,但父节点不持有子节点的shared_ptr(本项目无父指针,故安全)。
5.4 现象:MFC 界面按钮点击无响应,或状态栏不更新
原因:MFC 消息映射未正确定义,或ON_COMMAND宏绑定的 ID 与资源中控件 ID 不一致。
解决:
- 检查
HuffmanView.cpp中BEGIN_MESSAGE_MAP(CHuffmanView, CView)块内是否有ON_COMMAND(ID_FILE_COMPRESS, &CHuffmanView::OnFileCompress); - 打开
Huffman.rc,右键菜单项 → “属性”,确认其ID为ID_FILE_COMPRESS(而非IDC_BUTTON1等默认值); - 若用对话框控件,检查
HuffmanDlg.h中控件变量名与DDX_Control绑定是否一致,如DDX_Control(pDX, IDC_BTN_COMPRESS, m_btnCompress);对应CButton m_btnCompress;。
6. 二次开发实战:为项目添加“压缩率实时显示”与“UTF-8 BOM 自动识别”功能
6.1 功能一:在状态栏实时显示压缩率(百分比)
MFC 状态栏(CStatusBar)默认有多个面板,我们利用第二个面板(索引 1)显示压缩率。需修改三处:
扩展状态栏面板:在
MainFrm.cpp的OnCreate()中,找到m_wndStatusBar.Create(this)后的m_wndStatusBar.SetIndicators(...),将数组扩展为 3 个元素:// MainFrm.cpp static UINT indicators[] = { ID_SEPARATOR, // 状态行指示器 ID_INDICATOR_CAPS, // CAPS 锁 ID_INDICATOR_COMPRESS, // 新增:压缩率面板(需在 Resource.h 中定义 #define ID_INDICATOR_COMPRESS 0xE801) }; m_wndStatusBar.SetIndicators(indicators, sizeof(indicators)/sizeof(UINT));添加状态栏更新函数:在
CHuffmanView.h中声明:// HuffmanView.h public: void UpdateCompressRate(double rate); // rate 为 0.0~1.0 的小数在
HuffmanView.cpp中实现:// HuffmanView.cpp void CHuffmanView::UpdateCompressRate(double rate) { CString str; str.Format(_T("压缩率: %.1f%%"), rate * 100.0); GetParentFrame()->GetStatusBarCtrl().SetPaneText(1, str); }在压缩完成时调用:修改
CHuffmanDoc::OnFileCompress()结尾,计算并更新:// HuffmanDoc.cpp // ... 压缩完成后,写入 .huf 文件后 double originalSize = static_cast<double>(originalData.size()); double compressedSize = static_cast<double>(compressedBytes.size()) + 256.0; // 头部256B double rate = (originalSize - compressedSize) / originalSize; // 通知视图更新状态栏 GetFirstView()->UpdateCompressRate(rate);
6.2 功能二:自动识别并跳过 UTF-8 BOM(EF BB BF)
UTF-8 文件开头若有 BOM,会被当作三个独立字节统计,破坏频次分布。需在读取原始文件时自动检测并跳过:
// HuffmanDoc.cpp - 修改 OnFileCompress() 中文件读取部分 std::ifstream inFile(filePath, std::ios::binary); if (!inFile.is_open()) return; // 检测 UTF-8 BOM (EF BB BF) std::array<unsigned char, 3> bom = {0xEF, 0xBB, 0xBF}; std::array<unsigned char, 3> header; inFile.read(reinterpret_cast<char*>(header.data()), 3); bool hasBOM = (header[0] == bom[0] && header[1] == bom[1] && header[2] == bom[2]); std::vector<unsigned char> originalData; if (hasBOM) { // 跳过 BOM,读取剩余全部 inFile.seekg(0, std::ios::end); size_t fileSize = inFile.tellg(); originalData.resize(fileSize - 3); inFile.seekg(3, std::ios::beg); inFile.read(reinterpret_cast<char*>(originalData.data()), originalData.size()); } else { inFile.seekg(0, std::ios::end); size_t fileSize = inFile.tellg(); originalData.resize(fileSize); inFile.seekg(0, std::ios::beg); inFile.read(reinterpret_cast<char*>(originalData.data()), originalData.size()); }验证效果:用记事本另存为 UTF-8 编码的bom.txt(含 BOM),压缩后解压,内容完全一致,且压缩率比含 BOM 统计时更高(因 BOM 字节被排除,频次分布更集中)。
6.3 一个习惯:从那以后我每次改 MFC 资源,都强制走一遍“资源符号同步”
MFC 资源(菜单、对话框、字符串表)的 ID 定义在Resource.h,但 VS 资源编辑器有时不会自动更新此文件,导致ON_COMMAND绑定失败。我的血泪经验是:
- 在资源编辑器中修改完控件(如给按钮改 ID 为
ID_BTN_DECOMPRESS); - 立即打开
Resource.h,搜索新 ID,确认其存在且值唯一(如#define ID_BTN_DECOMPRESS 0xE802); - 若不存在,右键资源 → “导出为头文件”,或手动添加;
- 清理并重新生成解决方案。
这个 30 秒的习惯,帮我避开了 80% 的“按钮点不动”问题。希望帮到你。
本文还有配套的精品资源,点击获取