简介:一套基于 C++ 和 MFC 编写的 Huffman 压缩解压完整工程,面向正在学习数据结构、算法以及 Windows 应用开发的人群。项目以文件为处理对象,先统计字符频率,再利用优先级队列构建最优二叉树,随后为每个叶子节点生成唯一二进制编码;在 MFC 界面中,通过 CFileDialog 完成文件选取,使用 CFile 类进行读取、写入和位流操作,最终实现文本文件的压缩与还原。压缩包共包含 46 个文件,除了核心的 h/cpp 源码、vcxproj/sln 工程配置外,还提供可运行的 exe 程序、调试符号和编译过程生成的临时文件,总体积约 40.81MB,便于直接打开工程查看运行效果。目前已有 206 人浏览学习,完整代码可帮助读者厘清压缩表构建、编码写入、解码还原等实现细节,同时熟悉 MFC 对话框、文件选择器及文件读写接口;在此基础上也能进一步扩展 LZ77、LZW 等压缩算法。
1. MFC环境下用C++实现Huffman压缩解压:这个源码包为什么值得拆一遍
接触过数据压缩开发的人应该都听过霍夫曼编码:给每个字节按出现频率分配变长码,高频用短码、低频用长码,最后输出一串二进制位流。理论好懂,可真要把这套算法放进一个MFC对话框程序,让用户选文件、按按钮、看进度条,中间要处理的细节比想象中多得多。我拿到这份C++MFC的Huffman压缩解压源码后,第一件事就是把它完整拆开重跑了一遍。这篇文章围绕这份资源解决三个问题:它到底能干什么、核心代码在哪里改、调试时最容易在哪个环节翻车,适合正在做课程设计,或者想把压缩模块塞进Windows桌面客户端的朋友。
2. Huffman压缩核心算法:从频率统计走到二进制位流的三个关键点
2.1 频率统计:先把文件从头到尾读一遍,让256个计数器说话
霍夫曼压缩的底层逻辑很直接:出现越多的字节,分配的编码越短。为了让编码最短,必须先统计每个字节在文件里出现的次数。常见做法是把一张长度256的无符号数组当计数器:字节值就是下标,fread按块读入,逐字节累加。我在这份源码里见过有人直接把整文件一次性塞进vector的做法,统计思路没问题,但文件稍大内存就吃紧,所以我习惯把缓冲区固定在8192字节,循环读入,统计完再决定要不要继续压缩。
unsigned long long freqTable[256] = {0}; unsigned char buf[8192]; size_t n; FILE* fp = fopen(srcPath, "rb"); if (!fp) return; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { for (size_t i = 0; i < n; i++) { freqTable[buf[i]]++; } }这段代码把源文件按8KB一块读进缓冲区,取块内每个字节的数值作为下标,累加频率。freqTable用64位无符号整型,即使文件达到几个GB,单个字节出现次数也不会溢出。参数层面有三个可调点:缓冲区越大、磁盘IO次数越少,但内存占用同步上升;下标0到255对应无符号字节,如果拿char当下标,负数会直接越界崩溃;fopen第二个参数必须是"rb",少了b,Windows会在读到0x1A时误判为EOF,导致统计结果缺一段。这一步只是第一遍读文件,真正编码时还要再读一遍,所以完整实现里要保留文件路径,等码表生成后再重新打开。
2.2 构建霍夫曼树:优先队列默认是大顶堆,这个坑最阴
统计完频率后,要把频率组合成一棵树。原理是每次从集合里取出两个权重最小的节点,合成一个新节点再放回去,一直重复到只剩一个根节点。这个“取最小”用优先队列实现最直接,但C++的priority_queue默认是大顶堆,必须显式改成小顶堆,否则每次取出来的是权重最大的节点,建出来的树完全走形,压缩率甚至不如直接存原文。
实际工程里我一般不用裸指针建树,而是用一个节点池vector存所有节点,优先队列只存节点下标。这样后续把树写进压缩文件、解压时重建树都方便:
struct HuffNode { int left, right; int ch; unsigned long long weight; bool isLeaf; }; static vector<HuffNode> g_pool; struct NodeIndexCmp { bool operator()(int a, int b) const { const HuffNode& na = g_pool[a]; const HuffNode& nb = g_pool[b]; if (na.weight != nb.weight) return na.weight > nb.weight; // 小顶堆:权重小的先出 return na.ch > nb.ch; // 权重相同时按ch稳定排序 } }; priority_queue<int, vector<int>, NodeIndexCmp> q; for (int i = 0; i < 256; i++) { if (freqTable[i] == 0) continue; HuffNode nd; nd.left = nd.right = -1; nd.ch = i; nd.weight = freqTable[i]; nd.isLeaf = true; g_pool.push_back(nd); q.push((int)g_pool.size() - 1); } while (q.size() > 1) { int a = q.top(); q.pop(); int b = q.top(); q.pop(); HuffNode parent; parent.left = a; parent.right = b; parent.ch = -1; parent.weight = g_pool[a].weight + g_pool[b].weight; parent.isLeaf = false; g_pool.push_back(parent); q.push((int)g_pool.size() - 1); } int root = q.top();第8行比较器是关键,权重小的节点优先级高;权重相同时再比ch,这样相同输入在不同机器上会生成完全一样的树,压缩文件可复现,回归测试也方便。如果省掉权重相同的比较逻辑,某些边界文件下节点出队顺序不稳定,压缩率没影响,但文件头会变,解压端只要按文件头重建树就没问题,吃亏的是调试时发现两次压缩出的二进制不一样,容易误判程序有bug。
2.3 编码表生成:回溯遍历树,vector 是省空间的天然选择
树建好后,编码规则是左子树记0、右子树记1,从根走到叶子,路径就是该字节的霍夫曼码。我一般用递归遍历写码表,同时对prefix引用做回退,保证兄弟节点路径互不污染:
vector<vector<bool>> g_codeTable(256); void BuildCodeFromPool(int node, vector<bool>& prefix) { if (node < 0 || node >= (int)g_pool.size()) return; const HuffNode& cur = g_pool[node]; if (cur.isLeaf) { g_codeTable[cur.ch] = prefix; return; } prefix.push_back(false); BuildCodeFromPool(cur.left, prefix); prefix.pop_back(); prefix.push_back(true); BuildCodeFromPool(cur.right, prefix); prefix.pop_back(); } vector<bool> prefix; BuildCodeFromPool(root, prefix);递归深度理论上可能达到节点总数,对绝大多数普通文件都没问题,但极端频率分布下栈会压到两百多层,开发时我见过在这个函数里爆栈的情况。稳妥做法是改成显式栈遍历,逻辑完全等价,只是少了栈溢出风险。码表用vector 存储,每个bit只有一位,比用vector 存0/1省32倍内存;不过注意vector 有特殊实现,取元素时得到的是代理引用,赋值和拷贝要按值处理,不能拿它的引用到处传。
2.4 二进制位流写入与文件头:补位数的秘密全在这里
编码表生成后,压缩写入是最容易出错的环节。一个字节的编码可能只有1位,也可能有十几位,而磁盘最小单位是字节,位流必须积累到8位才落盘。残存的非整字节位必须补齐,同时文件头要记录刚才补了多少位:
class BitWriter { public: explicit BitWriter(FILE* fp) : out_(fp), cur_(0), bits_(0) {} void write(bool bit) { cur_ = (cur_ << 1) | (bit ? 1 : 0); if (++bits_ == 8) { fwrite(&cur_, 1, 1, out_); cur_ = 0; bits_ = 0; } } int flush() { if (bits_ == 0) return 0; int pad = 8 - bits_; cur_ <<= pad; fwrite(&cur_, 1, 1, out_); cur_ = 0; bits_ = 0; return pad; } private: FILE* out_; unsigned char cur_; int bits_; };write每接收一个bool就左移,bits_到8立刻写出并清零;flush把剩余不足8位的部分左移到字节顶,返回补丁位数pad。这个pad是文件头里最重要的一字节,很多实现写死了“补零到8位”,却忘了把补位数存下来,解压时把多余位也当编码消费,末尾就多出几个乱码。文件头至少要有下面几块设计:
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 4字节 | 例如"HFZC",防止用户拿错文件导致解压崩溃 |
| 原文件名长度 + 原文件名 | 4字节 + 变长 | 解压时自动重建原文件 |
| 原文件大小 | 8字节 | 用64位记录,解压到该字节数就停 |
| 补位数pad | 1字节 | 范围0到7,配合BitWriter的flush结果 |
| 树信息 | 变长 | 先序遍历树节点,或直接保存256条编码表 |
树信息两种存法我都见过:保存完整编码表实现最简单,固定256条就行,但每个文件要多出1到2KB头部,对几百字节的小文件影响明显;先序遍历树节点更紧凑,解压节点时要多写一点构建逻辑。这份源码包如果足够完整,应该能看到多文件头解析和解压端重建树的过程,没有的话反而是改造的好切入口。
3. 把算法接进MFC对话框:控件、线程与中文路径的落地顺序
3.1 对话框布局:压缩、解压两个按钮加一个进度条就够用
在VS环境里新建MFC对话框工程,资源视图里拖一个“压缩”按钮、一个“解压”按钮、两个编辑框和一个进度条。按钮ID我用IDC_BTN_COMPRESS、IDC_BTN_DECOMPRESS,编辑框用IDC_EDIT_SRC、IDC_EDIT_DST,进度条用IDC_PROGRESS。按钮点击事件里做三件事:弹文件选择框、把路径写进编辑框、启动压缩线程。压缩输出路径我习惯直接拼一个后缀:
void CMyDlg::OnBnClickedBtnCompress() { CFileDialog dlg(TRUE, NULL, NULL, OFN_FILEMUSTEXIST | OFN_HIDEREADONLY, _T("所有文件|*.*||"), this); if (dlg.DoModal() != IDOK) return; CString srcPath = dlg.GetPathName(); CString dstPath = srcPath + _T(".hufz"); SetDlgItemText(IDC_EDIT_SRC, srcPath); SetDlgItemText(IDC_EDIT_DST, dstPath); m_srcPath = srcPath; m_dstPath = dstPath; AfxBeginThread(CompressThread, this); }CFileDialog第一个参数TRUE表示打开文件对话框,OFN_FILEMUSTEXIST强制文件必须存在,避免把空路径塞进压缩函数后黑匣子式报错。后缀.hufz是我随手起的,和文件头里的魔数对应起来就行。注意这里不要直接写CString给fopen,MFC在Unicode字符集下CString是宽字符串,传给fopen会编译报错或者运行截断,中文路径尤其严重。
3.2 压缩线程与进度回传:不要在UI线程里做全文件循环
压缩耗时不短,几十MB的文件可能要跑几秒甚至更久。如果直接在按钮消息处理函数里执行整套压缩循环,界面会一直转圈,Windows消息队列被堵死,系统甚至会弹“程序未响应”。所以这里一定要把压缩丢进独立线程:
UINT CompressThread(LPVOID pParam) { CMyDlg* pDlg = reinterpret_cast<CMyDlg*>(pParam); BOOL ok = pDlg->DoCompress(pDlg->m_srcPath, pDlg->m_dstPath); ::PostMessage(pDlg->GetSafeHwnd(), WM_APP_COMPRESS_FINISHED, ok, 0); return 0; }线程里执行实际的DoCompress函数,结束后通过PostMessage把结果状态发回窗口。MFC规定控件访问要在创建它的线程里做,所以子线程不能直接调进度条SetPos,正确姿势是发消息到主窗口,再由主窗口更新控件:
const int WM_APP_PROGRESS = WM_APP + 1; // DoCompress内部,每处理完一块数据: int percent = (int)(doneBytes * 100 / totalBytes); ::PostMessage(m_hWnd, WM_APP_PROGRESS, percent, 0); // 主窗口消息映射: LRESULT CMyDlg::OnProgress(WPARAM wp, LPARAM) { m_Progress.SetPos((int)wp); return 0; }主窗口类里用ON_MESSAGE(WM_APP_PROGRESS, OnProgress)把消息映射到处理函数。percent计算时先乘100再除totalBytes,避免直接把比例转整数丢失精度。totalBytes用64位,文件超过2GB也不会溢出。
3.3 中文路径与Unicode字符集:fopen换成_wfopen
MFC工程如果使用Unicode字符集,CString内部就是wchar_t字符串,而标准fopen接受的是char路径。直接把CString强转const char*,中文路径会变成一串乱码,解压时甚至创建不出文件。两个方案:一种是把项目字符集改成多字节,但新工程默认Unicode,改回去不划算;另一种是核心文件读取全部换成宽字符版函数:
FILE* fpSrc = _wfopen((LPCTSTR)m_srcPath, L"rb"); FILE* fpDst = _wfopen((LPCTSTR)m_dstPath, L"wb");_wfopen是fopen的宽字符版本,路径参数接受wchar_t数组。MFC下LPCTSTR在Unicode工程里就是const wchar_t*,直接传即可。注意文件头里保存的源文件名也要统一:我在压缩端用WideCharToMultiByte把宽文件名转成UTF-8字节写入文件头,解压端用MultiByteToWideChar转回CString,这样无论路径里带中文、日文还是特殊符号都能正确还原。
3.4 文件打开方式:坚持二进制模式,CArchive不碰
MFC自带CArchive可以对CFile做序列化,但它是为文档视图框架设计的,追求的是对象序列化,不是裸字节流压缩。霍夫曼压缩要的是逐字节读入、逐位写出,CArchive中间那一层转换完全多余。我在这类源码包里看到过用CFile直接做的,也能跑,但CFile的Read/Write在字节层面没有比fread/fwrite省事,反而多一层异常处理和缓冲管理。所以我一般把压缩核心保持成纯C函数,用fopen系列操作,界面层只负责把CString路径转给函数:
BOOL CMyDlg::DoCompress(const CString& src, const CString& dst) { FILE* fpSrc = _wfopen((LPCTSTR)src, L"rb"); if (!fpSrc) return FALSE; FILE* fpDst = _wfopen((LPCTSTR)dst, L"wb"); if (!fpDst) { fclose(fpSrc); return FALSE; } // 统计频率 -> 建树 -> 生成码表 -> 写文件头和编码数据 // 压缩完成后返回压缩前后文件大小,供测试观察 fclose(fpSrc); fclose(fpDst); return TRUE; }DoCompress把整条流程串起来后,按钮事件、线程入口和后期自动化测试都能复用同一个入口,这是MFC程序里很实用的一层封装。解压按钮同样道理,调DoDecompress之前,先从输入文件读4字节魔数比对,魔数不对直接弹提示:
char magic[4]; fread(magic, 1, 4, fpIn); if (memcmp(magic, "HFZC", 4) != 0) { AfxMessageBox(_T("文件头不对,可能不是本工具的压缩产物")); }这样即使别人硬塞一个图片文件过来,也不会让后面的解压代码跑进野指针。
4. 避坑排查:Huffman压缩在MFC里最常翻车的五个位置
4.1 压缩产物比原文件还大,是不是程序写错了
现象:对一个几百字节的txt文件点压缩,生成的.hufz文件反而比源文件大几十字节。
原因:文件头要记录魔数、文件名、原大小、补位信息和树数据,至少要占几十字节;源文件本来就小,霍夫曼减少的那点字节根本盖不住文件头开销。还有一种情况是压缩函数里用了文本模式打开,把字节0x0A扩展成0x0D0A,内容越压越大。
解决:在压缩入口先收集完频率和总字节数后做一个快速判断,估算编码数据段长度,如果压缩后的数据段加上文件头仍然接近原文件大小,就直接提示“文件过小或数据较随机,压缩收益不明显”,不再写压缩包。我还在代码里加了一段日志,压缩完成后用原始文件大小除以输出文件大小,把压缩率打印到调试输出窗口,眼见为实。
4.2 解压出来的文件末尾多出几个字节
现象:txt文件解压后能打开,但末尾多出一段黑块或无字空行;逐字节对比原文件,发现尾部多了1到7位垃圾。
原因:写最后一个字节时做了补零,但文件头里没有记录补位数,解压端把补的零位也当成有效码字去查表,查出一个多余字节写进输出;或者补位数记录了,但解压循环没有用原始文件大小做终止条件。
解决:解压侧维护一个计数器,每还原一个符号就加一,达到文件头里记录的rawSize就立刻停止,不再读位流。
unsigned long long restored = 0; while (restored < rawSize) { int sym = NextSymbolFromTree(root); if (sym < 0) break; // 数据读完了还没达到rawSize,说明压缩文件损坏 outBuf[restored] = (unsigned char)sym; restored++; } fwrite(outBuf, 1, restored, fpOut);这里只写出restored个字节,天然把补齐位挡在外面。NextSymbolFromTree每走一步消费一个bit,内部要判断bit buffer是否已空、文件是否读完,读完还没凑够符号就返回负值,上层应该提示文件损坏,而不是继续输出。
4.3 中文文件名解压后变乱码或创建失败
现象:选用中文路径的源文件压缩一切正常,解压时弹“无法创建文件”,路径显示成一串问号。
原因:MFC工程用Unicode字符集,CString是wchar_t,文本处理函数里用的却是char路径,文件名包含中文时被截断成乱码。代码里如果再用了MultiByteToWideChar却指定了错误的代码页,同样会出现乱码文件名。
解决:压缩端保存文件名用WideCharToMultiByte(CP_UTF8)转成UTF-8写入文件头,解压端用MultiByteToWideChar(CP_UTF8)还原成宽字符串,再用_wfopen创建文件。UTF-8能覆盖全量Unicode字符,比本地ANSI代码页跨机器更稳。资源里若看到直接用char保存中文路径的版本,这个位置是必改项。
4.4 大文件压缩界面假死或内存暴涨
现象:对100MB以上的文件执行压缩,界面瞬间卡死,任务管理器里内存占用直线上升。
原因:压缩函数一次性用fread把整个文件读进内存统计,再保存一份编码缓冲,甚至把输出也放进内存再一次性写盘,100MB源文件在32位进程里直接撑爆地址空间;MFC又是在UI线程里跑压缩循环,窗口自然假死。
解决:统计阶段保持分块读,编码阶段也分块读入、逐位写出。进度条每一块更新一次,线程独立跑,主界面始终能响应。代码层面把fread的缓冲大小和进度通知间隔做成宏定义,压缩核心不碰MFC控件,就不会被跨线程访问拖累。
4.5 对BMP、EXE等二进制文件压缩后解压文件损坏
现象:BMP图片压缩后体积没小多少,解压出来打不开;EXE压缩后解压运行时直接报错。
原因:凡是把压缩输出文件用"wt"或"rt"文本模式打开的,Windows下0x0A会被自动写成0x0D0A,读的时候又反向转换一次,霍夫曼编码里的位流被打乱,还原字节全部错位。这是没搞清楚文本模式和二进制模式导致的。
解决:源文件和目标文件统一用"rb"和"wb"打开,压缩过程不做任何字符转换。检查项目里所有fopen和_wfopen,模式字符串带t的一律改b。这个坑在Windows平台特别隐蔽,因为普通文本文件压缩测试时可能碰巧通过,一换BMP就现原形。
5. 验证与调参:把压缩率对比和逐字节校验变成固定习惯
5.1 解压“成功”不等于解压正确,逐字节对比才是硬标准
很多MFC压缩程序测试时只看到解压按钮没报错就认为通过,其实文件内容早就不对了。我拿到这份源码后写了个对比函数,把原文件和解压结果逐块读出来比较,任何一位不同都能立刻定位:
int CompareFile(const wchar_t* a, const wchar_t* b) { FILE* fa = _wfopen(a, L"rb"); FILE* fb = _wfopen(b, L"rb"); if (!fa || !fb) return -1; unsigned char ca[8192], cb[8192]; while (true) { size_t na = fread(ca, 1, sizeof(ca), fa); size_t nb = fread(cb, 1, sizeof(cb), fb); if (na != nb) { fclose(fa); fclose(fb); return 1; // 大小不一致 } if (na == 0) break; if (memcmp(ca, cb, na) != 0) { fclose(fa); fclose(fb); return 2; // 内容不一致 } } fclose(fa); fclose(fb); return 0; }对比函数返回0表示完全一致,1表示长度不同,2表示某一块内容不同。我在调试过程中把它绑在解压线程结束后的消息处理里,每次自动跑一遍,从根源上杜绝“看起来成功、实际损坏”的假象。
5.2 压缩率观察表与可调参数
压缩率不是越压越小就越好,还要看文件类型。我跑了几类常见文件后做了一个观察表,同一份源码不用改参数也能复现类似结果:
| 源文件类型 | 压缩后与原始大小比例 | 备注 |
|---|---|---|
| 纯英文文本 | 40%到55% | 字母频率差异明显,霍夫曼码长短分明 |
| 中文UTF-8文本 | 60%到75% | 字节分布较散,高频集中在范围段 |
| BMP位图 | 95%到105% | 像素字节分布均匀,文件头开销吃掉收益 |
| EXE可执行文件 | 85%到98% | 指令分布较随机,压缩空间有限 |
如果发现自己的压缩率偏离这个范围,先检查是不是文本模式打开文件,再看文件头是不是把编码表全量写入了。想继续提高压缩率又不想换算法,可以在压缩前对源数据做一次RLE预处理,或者把两个字节合并成一个符号重新统计频率。文件头那段编码表改成先序遍历树结构,能把几百字节的固定开销降下去,对1KB上下的小文件效果尤其明显。
从那以后,我每次拿到Huffman相关源码,都会强制走一遍固定流程:压缩完成立刻对比原文件和解压文件,记录压缩率,再决定要不要改参数。这套流程让我把Huffman从“能用”变成了“可控”,少踩了很多玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取