简介:这份资源是一个基于哈夫曼编码实现的文件压缩与解压工具,面向学习数据结构、算法课程设计或C++桌面开发的学生与开发者,帮助理解哈夫曼树构造、编码生成及二进制文件读写的完整流程。压缩包共53个文件,约109.76MB,包含cpp与h源码、MFC对话框与资源文件、sln与vcxproj工程配置、exe可执行程序及调试中间文件,覆盖从编码实现到可视化界面的完整工程结构。已有1102人学习下载,说明其在课程实践与自学场景中具有一定参考价值。读者可从中获取哈夫曼压缩解压的核心算法实现、MFC界面搭建方式以及可直接运行的工程文件,便于对照调试、二次修改或作为课程设计模板使用,快速掌握压缩软件从算法到界面的落地思路。
1. 从一份 Huffman.zip 说起:MFC 里手搓压缩工具的完整落地路径
如果你手头正躺着一份Huffman.zip,里面是一个基于 MFC 的哈夫曼编码压缩/解压小工具,那你大概率面临两个选择:要么把它当成课程作业交完就删,要么把它拆开、跑通、改到能真正处理任意二进制文件。我选后者。这个资源的核心价值不在于“哈夫曼编码”本身——那套贪心建树、前缀码分配的流程,任何一本数据结构教材都讲得比我清楚——而在于它把算法塞进了一个带界面的 Windows 桌面程序里,涉及文件流读写、位操作、内存管理、MFC 对话框数据交换这一整条链路。换句话说,它是一份“算法怎么变成能双击运行的 exe”的完整样本。适合谁?适合已经懂哈夫曼原理、但一动手就卡在“压缩完的文件怎么存树”“解压时怎么知道读几位”这类工程问题上的 C++ 学习者。下面我按“先跑通、再拆解、后避坑”的顺序,把这份资源从头到尾过一遍。
2. 先让 Huffman.zip 跑起来:环境、编译与第一次压缩
2.1 确认你的编译环境能扛住 MFC
MFC 不是标准 C++ 的一部分,它是微软的类库,所以第一步不是打开代码,而是确认工具链。Visual Studio 2019 或 2022 的“使用 C++ 的桌面开发”工作负载里,默认勾选“MFC 和 ATL 支持”才会带上afxwin.h这些头文件。如果你装的是 Community 版,安装时漏勾了,打开项目会直接报Cannot open include file: 'afxwin.h'。补救办法是打开 Visual Studio Installer,点“修改”,在“单个组件”里搜“MFC”,把对应版本的“适用于最新 v143 生成工具的 C++ MFC”勾上。
另一个常见问题是字符集。老一点的 MFC 项目默认用多字节字符集,新版本 VS 默认 Unicode。如果编译时报const char*和LPCTSTR类型不匹配,别急着改代码,先右键项目 → 属性 → 配置属性 → 高级 → 字符集,切成“使用多字节字符集”。这一步能消掉大半的编译错误。
2.2 从零建一个能跑的 MFC 对话框工程
如果Huffman.zip里的工程文件因为 VS 版本差异打不开,我一般会新建一个 MFC 对话框程序,把核心的.cpp和.h文件拖进去。新建流程:文件 → 新建 → 项目 → MFC 应用 → 应用程序类型选“基于对话框”。建好后,把资源里的Huffman.cpp、Huffman.h、FileOperate.cpp这类文件复制到项目目录,在解决方案资源管理器里右键“头文件”和“源文件”分别添加现有项。
接下来是界面控件。压缩工具通常需要这几个元素:一个“选择文件”按钮、一个显示路径的编辑框、一个“压缩”按钮、一个“解压”按钮、一个显示压缩率或状态的静态文本。在对话框资源视图里拖控件,然后给编辑框绑定CString变量,给按钮添加BN_CLICKED事件处理函数。绑定变量用“添加变量向导”,选“值”类别,变量类型CString,变量名比如m_filePath。
2.3 第一次压缩:代码入口与参数含义
核心压缩逻辑通常长这样,我把它简化成可读的版本:
// 压缩入口:读取源文件,统计频率,建树,生成编码表,写压缩文件 void CHuffmanDlg::OnBnClickedBtnCompress() { CFileDialog dlg(TRUE, NULL, NULL, OFN_HIDEREADONLY, _T("所有文件 (*.*)|*.*||")); if (dlg.DoModal() != IDOK) return; CString srcPath = dlg.GetPathName(); CString dstPath = srcPath + _T(".huf"); // 压缩后加 .huf 后缀 // 1. 读源文件到内存 std::ifstream in(srcPath, std::ios::binary); if (!in) { AfxMessageBox(_T("源文件打开失败")); return; } std::vector<unsigned char> data((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); in.close(); // 2. 统计字节频率(0~255) unsigned long freq[256] = {0}; for (unsigned char c : data) freq[c]++; // 3. 建哈夫曼树并生成编码表 HuffmanTree tree; tree.Build(freq); std::string code[256]; tree.GenerateCodes(code); // 4. 写压缩文件:先写频率表(用于重建树),再写位流 std::ofstream out(dstPath, std::ios::binary); out.write(reinterpret_cast<char*>(freq), sizeof(freq)); BitWriter bw(out); for (unsigned char c : data) bw.WriteBits(code[c]); bw.Flush(); out.close(); CString msg; msg.Format(_T("压缩完成,输出:%s"), dstPath); AfxMessageBox(msg); }这段代码里几个参数值得盯住。freq[256]是频率表,用unsigned long而不是int,因为大文件里某个字节出现次数可能超过 21 亿——虽然实际很少见,但用无符号长整型是稳妥做法。sizeof(freq)是 1024 字节,直接写进压缩文件头部,解压时先读这 1024 字节就能重建出完全相同的哈夫曼树,不需要在文件里存树结构。BitWriter是我习惯封装的一个小类,内部维护一个unsigned char缓冲和位数计数,攒够 8 位就写一个字节出去。最后Flush()把不足 8 位的尾巴补零写出,同时要记录有效位数,否则解压时会多解出几个垃圾字节。
提示:压缩文件头部除了频率表,通常还要写一个 4 字节的“原始文件长度”,解压时用来判断何时停止,避免尾部补零被误解析。
3. 拆开哈夫曼树:建树、编码表与位流读写的工程实现
3.1 优先队列建树:为什么用priority_queue而不是手写堆
哈夫曼建树的经典做法是每次取两个频率最小的节点合并。教材里常让你手写一个最小堆,但在 C++ 工程里,std::priority_queue配合自定义比较器就够了,代码量少一半,出错概率也低。节点结构体里存freq、ch(叶子节点才有意义)、左右孩子指针。比较器按freq升序,这样top()就是最小频率节点。
struct Node { unsigned long freq; int ch; // 叶子节点存字节值,内部节点存 -1 Node *left, *right; Node(unsigned long f, int c = -1) : freq(f), ch(c), left(nullptr), right(nullptr) {} }; struct Cmp { bool operator()(Node* a, Node* b) { return a->freq > b->freq; } // 小顶堆 }; void HuffmanTree::Build(unsigned long freq[256]) { std::priority_queue<Node*, std::vector<Node*>, Cmp> pq; for (int i = 0; i < 256; i++) { if (freq[i] > 0) pq.push(new Node(freq[i], i)); } // 边界:文件只有一种字节,需要补一个虚拟节点 if (pq.size() == 1) pq.push(new Node(0, -1)); while (pq.size() > 1) { Node* a = pq.top(); pq.pop(); Node* b = pq.top(); pq.pop(); Node* parent = new Node(a->freq + b->freq); parent->left = a; parent->right = b; pq.push(parent); } root = pq.top(); }这里有个容易翻车的边界:如果整个文件只包含一种字节(比如全是'a'),频率表里只有一个非零项,优先队列里只有一个节点,循环不执行,树根就是那个叶子。此时编码表里这个字节的编码是空字符串,写位流时一位都不写,解压时无法还原。所以我在建树前加了一句判断:如果只有一个有效节点,补一个频率为 0 的虚拟节点,让树至少有两层。虚拟节点对应的字节值设成 -1,生成编码表时跳过它。
3.2 递归生成编码表:从根到叶的路径就是码字
建完树后,从根节点出发,向左走记'0',向右走记'1',走到叶子就把累积的字符串存进code[ch]。递归实现最直观:
void HuffmanTree::GenerateCodes(std::string code[256]) { std::string path; DFS(root, path, code); } void HuffmanTree::DFS(Node* node, std::string& path, std::string code[256]) { if (!node) return; if (node->ch != -1) { // 叶子 code[node->ch] = path.empty() ? "0" : path; // 单节点树兜底 return; } path.push_back('0'); DFS(node->left, path, code); path.pop_back(); path.push_back('1'); DFS(node->right, path, code); path.pop_back(); }path用引用传递,避免每次递归都拷贝字符串。push_back和pop_back成对出现,保证回溯正确。注意code[node->ch] = path这一句,如果树只有一层(前面补了虚拟节点的情况),真实叶子的路径长度是 1,不会是空串,所以解压时至少能读到一个位。
3.3 位流写入与读取:压缩率的关键在这几十行
位流写入的核心是“攒够 8 位写一个字节”。我一般这样封装:
class BitWriter { std::ofstream& out; unsigned char buffer; int bitCount; public: BitWriter(std::ofstream& os) : out(os), buffer(0), bitCount(0) {} void WriteBits(const std::string& bits) { for (char b : bits) { buffer = (buffer << 1) | (b - '0'); bitCount++; if (bitCount == 8) { out.put(buffer); buffer = 0; bitCount = 0; } } } void Flush() { if (bitCount > 0) { buffer <<= (8 - bitCount); // 左移补齐到 8 位 out.put(buffer); } } };buffer << 1把已有位左移,腾出最低位给新位。bitCount到 8 就写盘并清零。Flush里buffer <<= (8 - bitCount)是把剩余位对齐到高位,低位补零。这里有个细节:解压时怎么知道最后几个零是补的还是真实的?常见做法是在压缩文件头部额外写一个字节,记录最后一个字节里有效位的数量(1 到 8)。解压时读到文件末尾,根据这个数把补零位丢掉。
对应的BitReader逻辑相反:从文件读一个字节,逐位取出,取完再读下一个。解压时先读 1024 字节频率表重建树,再读原始文件长度,然后从根节点开始,每读一位就向左或向右走,走到叶子输出对应字节,同时回到根节点,直到输出字节数等于原始长度。
4. 避坑与排查:MFC 哈夫曼工具最容易翻车的五个地方
4.1 压缩后文件比原文件还大
现象:对一个几十字节的文本文件压缩,生成的.huf反而多了几百字节。原因:频率表固定占 1024 字节,加上文件头,小文件的“固定开销”远超压缩收益。解决:这是哈夫曼编码的固有特性,不是 bug。如果确实要处理小文件,可以在压缩前判断文件大小,小于某个阈值(比如 2KB)就不压缩,直接原样拷贝并加个标记位。或者在写频率表时只写非零项,用“字节值 + 频率”的变长格式,但解压端要同步改。
4.2 解压出来的文件末尾多出几个乱码字节
现象:解压后的文件比原始文件长,尾部多出几个无意义字符。原因:压缩时最后一个字节补了零,解压时没有根据“有效位数”截断,把补零位也当成了编码。解决:在压缩文件头里写一个字节记录最后一个字节的有效位数,解压时读到文件末尾先算出总有效位数,超过原始长度的输出一律丢弃。更稳妥的做法是直接依赖“原始文件长度”字段,输出够数就停。
4.3 中文路径导致文件打开失败
现象:源文件放在桌面或“下载”文件夹,路径含中文,std::ifstream打开失败。原因:std::ifstream在 Windows 上默认用窄字符 API,遇到非 ASCII 路径会乱码。解决:MFC 里用CFile代替std::ifstream,或者用_wfopen配合宽字符路径。如果坚持用标准库,可以把CString转成std::wstring,再用std::ifstream的宽字符重载(C++17 起部分编译器支持)。我一般直接在 MFC 里用CFile读写,省去编码转换的麻烦。
4.4 内存泄漏:建树后没释放节点
现象:反复压缩大文件,任务管理器里进程内存持续上涨。原因:new Node(...)创建的树节点在压缩完成后没有delete,每次压缩都泄漏一批。解决:给HuffmanTree加一个Destroy(Node* node)递归释放函数,在析构函数里调用。或者用std::unique_ptr<Node>管理左右孩子,但树结构用智能指针稍显啰嗦。我习惯在Build之前先调一次Destroy(root),确保旧树被清掉。
4.5 解压时树重建失败:频率表读写字节序不一致
现象:压缩和解压在同一台机器上正常,换一台机器解压就崩溃或输出乱码。原因:unsigned long在不同平台可能是 4 字节也可能是 8 字节,直接write(reinterpret_cast<char*>(freq), sizeof(freq))写出的字节数不一致。解决:不要直接写unsigned long数组。把频率值统一转成uint32_t再写,读的时候也按uint32_t读,然后赋给unsigned long。这样跨平台和跨编译器都稳。
注意:如果你在 VS 里调试时发现解压结果不对,先别怀疑算法,用十六进制编辑器打开压缩文件,看头部 1024 字节的频率表是否和源文件统计一致。这一步能快速定位是压缩端写错还是解压端读错。
5. 进阶:把压缩率再压一压,以及一个验证习惯
5.1 用规范哈夫曼树减少码长方差
普通哈夫曼树在频率相近时,码长分配可能不太均匀。规范哈夫曼(Canonical Huffman)的思路是:只传每个码长上有多少个码字,不传整棵树。解压端按固定规则重建编码表。这样做的好处是文件头更小,而且解码时可以用查表法加速。实现上,先统计codeLength[256],然后按码长从小到大、同码长按字节值升序分配码字。具体规则是:第一个码字全零,同码长下一个码字加一,换到更长码长时左移一位。这样压缩文件头从 1024 字节降到最多 256 字节左右。
5.2 分块压缩:让大文件也能边压边写
一次性把整个文件读进内存,对几百 MB 的文件不友好。可以改成按块处理:每块比如 1MB,独立统计频率、独立建树、独立写头。解压时按块还原再拼接。代价是每块都有固定开销,压缩率略降,但内存占用可控。块大小设成 64KB 到 1MB 之间比较平衡,太小则开销占比高,太大则失去分块意义。
5.3 验证压缩正确性的三个检查点
我每次改完压缩逻辑,都会走一遍这三个检查,缺一不可。第一,压缩再解压后,用fc /b 原文件 解压文件做二进制比对,输出“FC: 找不到差异”才算过。第二,构造边界文件:空文件、单字节文件、全相同字节文件、256 种字节各出现一次的文件,分别跑一遍。第三,用随机二进制文件(比如从系统目录拷一个.dll)测试,确保非文本数据也能正确还原。
:: Windows 下二进制比对,确认解压结果与源文件完全一致 fc /b original.bin original.bin.huf.decompressed如果输出“FC: 找不到差异”,说明压缩解压链路无损。如果有差异,用certutil -hashfile 文件名 SHA256分别算两个文件的哈希,快速确认是否真的不一致。
5.4 一个让我少熬两小时的习惯
早些年我调这个工具时,解压总在文件末尾多出几个字节,查了一晚上以为是位操作写反了,最后发现是压缩时Flush之后没有记录有效位数,解压端把补零位也解了。从那以后,我每次写位流相关的代码,都强制在文件头里加两个字段:原始文件长度(4 字节)和最后一个字节的有效位数(1 字节)。这两个字段加起来 5 个字节,换来的是解压端逻辑清晰、不用猜。后来再遇到类似问题,我第一反应就是去看文件头里有没有这两个值,而不是去翻位运算的代码。希望这个习惯也能帮你省下几个小时的调试时间。
本文还有配套的精品资源,点击获取