资讯动态

MFC环境下C++实现Huffman压缩解压:源码解析与避坑指南

发布时间:2026/10/15 14:18:09 来源:尧图企业网站定制
简介一套基于 C 和 MFC 编写的 Huffman 压缩解压完整工程面向正在学习数据结构、算法以及 Windows 应用开发的人群。项目以文件为处理对象先统计字符频率再利用优先级队列构建最优二叉树随后为每个叶子节点生成唯一二进制编码在 MFC 界面中通过 CFileDialog 完成文件选取使用 CFile 类进行读取、写入和位流操作最终实现文本文件的压缩与还原。压缩包共包含 46 个文件除了核心的 h/cpp 源码、vcxproj/sln 工程配置外还提供可运行的 exe 程序、调试符号和编译过程生成的临时文件总体积约 40.81MB便于直接打开工程查看运行效果。目前已有 206 人浏览学习完整代码可帮助读者厘清压缩表构建、编码写入、解码还原等实现细节同时熟悉 MFC 对话框、文件选择器及文件读写接口在此基础上也能进一步扩展 LZ77、LZW 等压缩算法。1. MFC环境下用C实现Huffman压缩解压这个源码包为什么值得拆一遍接触过数据压缩开发的人应该都听过霍夫曼编码给每个字节按出现频率分配变长码高频用短码、低频用长码最后输出一串二进制位流。理论好懂可真要把这套算法放进一个MFC对话框程序让用户选文件、按按钮、看进度条中间要处理的细节比想象中多得多。我拿到这份CMFC的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少了bWindows会在读到0x1A时误判为EOF导致统计结果缺一段。这一步只是第一遍读文件真正编码时还要再读一遍所以完整实现里要保留文件路径等码表生成后再重新打开。2.2 构建霍夫曼树优先队列默认是大顶堆这个坑最阴统计完频率后要把频率组合成一棵树。原理是每次从集合里取出两个权重最小的节点合成一个新节点再放回去一直重复到只剩一个根节点。这个“取最小”用优先队列实现最直接但C的priority_queue默认是大顶堆必须显式改成小顶堆否则每次取出来的是权重最大的节点建出来的树完全走形压缩率甚至不如直接存原文。实际工程里我一般不用裸指针建树而是用一个节点池vector存所有节点优先队列只存节点下标。这样后续把树写进压缩文件、解压时重建树都方便struct HuffNode { int left, right; int ch; unsigned long long weight; bool isLeaf; }; static vectorHuffNode 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_queueint, vectorint, 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引用做回退保证兄弟节点路径互不污染vectorvectorbool g_codeTable(256); void BuildCodeFromPool(int node, vectorbool 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(); } vectorbool 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位记录解压到该字节数就停补位数pad1字节范围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给fopenMFC在Unicode字符集下CString是宽字符串传给fopen会编译报错或者运行截断中文路径尤其严重。3.2 压缩线程与进度回传不要在UI线程里做全文件循环压缩耗时不短几十MB的文件可能要跑几秒甚至更久。如果直接在按钮消息处理函数里执行整套压缩循环界面会一直转圈Windows消息队列被堵死系统甚至会弹“程序未响应”。所以这里一定要把压缩丢进独立线程UINT CompressThread(LPVOID pParam) { CMyDlg* pDlg reinterpret_castCMyDlg*(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换成_wfopenMFC工程如果使用Unicode字符集CString内部就是wchar_t字符串而标准fopen接受的是char路径。直接把CString强转const char*中文路径会变成一串乱码解压时甚至创建不出文件。两个方案一种是把项目字符集改成多字节但新工程默认Unicode改回去不划算另一种是核心文件读取全部换成宽字符版函数FILE* fpSrc _wfopen((LPCTSTR)m_srcPath, Lrb); FILE* fpDst _wfopen((LPCTSTR)m_dstPath, Lwb);_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, Lrb); if (!fpSrc) return FALSE; FILE* fpDst _wfopen((LPCTSTR)dst, Lwb); 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, Lrb); FILE* fb _wfopen(b, Lrb); 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从“能用”变成了“可控”少踩了很多玄学问题。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑