ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ MFC 实现 Huffman 压缩解压:原理、文件格式与边界处理

C++ MFC 实现 Huffman 压缩解压:原理、文件格式与边界处理

简介:基于C++与MFC框架实现的哈夫曼压缩/解压缩完整工程,适合正在学习数据压缩算法、需要掌握MFC文件操作与图形界面开发的初中级开发者使用。工程按照统计字符出现频率、构建最优二叉树、遍历生成编码表、完成二进制压缩与反向解压等流程展开,并利用CFileDialog实现文件选择,CFile完成数据读写,可以清晰看到算法与界面如何结合。压缩包共46个文件,大小约40.81MB,包括6个h头文件与4个cpp源文件、MFC工程配置、资源脚本、生成的exe可执行程序以及pdb、tlog等调试中间文件,同时附带可直接运行的Debug目录,便于立即验证效果。已有206人学习下载,内容对理解哈夫曼无损压缩原理、MFC工程结构和程序调试都有实际帮助。对照源码还可掌握优先级队列构建哈夫曼树、位运算打包压缩数据等关键编码技巧。

1. c++MFC huffman 压缩解压:先摆正应用场景和压缩率预期

把 c++MFC huffman 压缩解压 拆开看,是一道典型的 Windows 桌面工具题:界面用 MFC 对话框,算法用 Huffman 编码,目标是把任意文件压缩成一个自定义格式的压缩包,再能无损解回原文件。先说一个反直觉的结论:Huffman 对纯文本通常只有 30%~50% 的压缩率,遇上图片、视频这类已经压缩过的数据反而会膨胀,所以它的价值从来不在跟 WinRAR 比谁压得狠,而在课程设计、自研文件格式瘦身、以及必须把编码流程完全握在手里的工具型项目。

适合读这篇的人大概有三类:正在做课程设计、需要交一个可演示压缩器的学生;接手了某公司老旧 MFC 工具、要给内部文件格式加压缩能力的维护者;以及想在自研协议里嵌入无损压缩模块、又不愿意引第三方库的工程师。后面的内容按原理、压缩端、解压端、踩坑、交付这个顺序推进,代码按可以直接复制进工程的标准来写。

核心判断先立住:Huffman 树本身不复杂,复杂的是文件格式设计和边界处理。编码表怎么落盘、末尾不足一个字节的位怎么对齐、空文件和单字符文件怎么解,这些才是实际开发里最耗时间的地方,也正是这篇想讲透的东西。

2. Huffman 编码原理与 MFC 下的技术选型:变长码如何形成

2.1 频率统计与 Huffman 树构建:最小堆合并的完整逻辑

Huffman 编码的本质一句话:给出现频率高的符号分配短码,给频率低的符号分配长码,而且所有码都不是另一个码的前缀。这个「前缀码」特性保证了压缩后的比特流可以逐位解码而不产生歧义。

构建过程是公认的贪心算法。先统计整个文件中 256 种字节值各自出现的次数,把每个出现过的字节值当成一个叶子节点,节点带一个权重(出现次数)。然后反复做一件事:从所有节点里挑出权重最小的两个,合并成一个父节点,父节点的权重是两者之和,再放回候选集合。重复到只剩一个节点时,这个节点就是 Huffman 树的根。从根出发向左记为 0、向右记为 1,走到某个叶子节点的路径就是该字节的编码。

理解这个算法要抓住一个关键点:Huffman 树是一棵满二叉树,每个内部节点必然有两个孩子。这保证了任何编码都不是另一个编码的前缀,也决定了后续解码时只需要从根一路走到叶子,不需要额外的分隔符。

优先级队列是这一步的标准工具。C++ 标准库的std::priority_queue默认是大顶堆,所以比较器要反转一下才能拿到权重最小的节点:

struct HuffNode { unsigned char ch; // 叶子节点保存的字节值,0~255 unsigned long long freq; // 出现频率,用 64 位避免大文件计数溢出 HuffNode* left; HuffNode* right; HuffNode(unsigned char c, unsigned long long f) : ch(c), freq(f), left(nullptr), right(nullptr) {} }; struct NodeCmp { bool operator()(HuffNode* a, HuffNode* b) const { // 标准库优先队列默认大顶堆,这里反着比较就变成小顶堆 return a->freq > b->freq; } };

freq类型选择值得多说一句。32 位无符号整数最大能数到 40 亿出头,4GB 文件里单字节值最多出现约 16M 次,理论上够用,但两个节点频率相加时可能翻倍累加,64 位可以直接消除这个隐患,内存上几乎没代价。

建树函数如下:

HuffNode* BuildHuffTree(const unsigned long long freq[256]) { std::priority_queue<HuffNode*, std::vector<HuffNode*>, NodeCmp> pq; for (int i = 0; i < 256; ++i) { if (freq[i] > 0) pq.push(new HuffNode((unsigned char)i, freq[i])); } if (pq.empty()) return nullptr; // 空文件,没有活动符号 if (pq.size() == 1) return pq.top(); // 单符号文件,根就是叶子 while (pq.size() > 1) { HuffNode* left = pq.top(); pq.pop(); HuffNode* right = pq.top(); pq.pop(); HuffNode* parent = new HuffNode(0, left->freq + right->freq); parent->left = left; parent->right = right; pq.push(parent); } return pq.top(); }

两个边界判断不是多余的。空文件时优先队列为空,如果不拦就会解引用空指针;单符号文件时队列里只有一个叶子,合并循环根本不会执行,直接返回叶子。这两个分支在压缩和解压两端都必须一致,否则就会出现「压缩端正常、解压端崩」的诡异现象。

合并结束后的根节点带着整棵树,用完后必须递归释放,这一点在 MFC 程序里尤其容易被忽略——对话框关掉后内存泄漏不会立刻暴露,但多压几个文件后任务管理器里的内存占用会一路涨。

2.2 为什么在 MFC 项目里选 Huffman:三个选型理由与 .huf 文件结构

在 MFC 工程里做文件压缩,常规选项就三个:zlib、LZW、Huffman。zlib 的压缩率最好,但它是第三方库,在老 MFC 工程里引入 DLL 依赖、处理 Unicode 工程下的编译配置都是额外成本,而且它是个黑匣子,压缩流程不受自己控制;LZW 的查表逻辑和字典管理对课程设计来说偏绕,专利问题虽然早已过期,但很多人对它仍有顾虑;Huffman 是三者里代码量最小、最容易对着调试器讲清楚的一个。

选型理由归纳成三条:算法逻辑直观,能用一张树图讲完整条流程;代码量可控,核心部分两百行左右就能写完;不依赖任何第三方库,纯 C++ 标准库加 MFC 的CFile就能跑。对课程设计和内部工具这两个场景,这三条比压缩率更重要。

自定义压缩格式的设计是另一个容易被低估的工作。我在 模拟项目X 里把扩展名定为.huf,文件结构按下面这张表来排:

字段大小说明
Magic 标识4 字节固定写"HUF1",用于快速识别格式
原始文件长度8 字节小端序,解码时靠它截断末尾补零位
活动符号数2 字节值为 1~256,0 表示空文件
符号频率表N × (1+8) 字节每项一个字节值 + 8 字节小端频率
编码码流变长按位紧凑排列,最后补零到整字节

这里有个设计决策值得解释:为什么不存 Huffman 树的结构,而是存频率表?两种方案都能让解码端重建出同一棵树,但存树结构要按先序或层序把每个节点的「是否为叶子、左孩子指针、右孩子指针」描述出来,序列化复杂且容易出错;存频率表则简单得多,解码端调用同一个BuildHuffTree就能重建出结构完全相同的树。代价是每个符号多占 8 字节,256 个符号全满时也就多 2KB,换来的是实现可靠性,这笔交易划算。

文件头直接写结构体是新手最常见的翻车点。CFile::Write(&headerStruct, sizeof(headerStruct))在 x86 上能跑,但结构体里有对齐填充字节,这些填充内容不确定;同时字节序依赖编译平台。正确做法是每个字段按字节显式写出,我在 模拟项目X 里的做法是写两个小工具函数:

void WriteU16(CFile& f, USHORT v) { BYTE b[2] = { (BYTE)(v & 0xFF), (BYTE)((v >> 8) & 0xFF) }; f.Write(b, 2); } void WriteU64(CFile& f, unsigned long long v) { BYTE b[8]; for (int i = 0; i < 8; ++i) { b[i] = (BYTE)(v & 0xFF); v >>= 8; } f.Write(b, 8); }

这两个函数把字节序问题锁死在小端,解码端按同样顺序逐字节读回。别看它笨,做自定义二进制格式时,显式逐字节读写是避免「在这台机器上正常、换台机器就崩」的最稳手段。

3. 压缩端实现:从频率表到 .huf 压缩文件的完整编码流程

3.1 用优先队列构建 Huffman 树:节点结构与比较器参数

上一章给出了BuildHuffTree的核心代码,这一节把它放进完整的压缩函数里看。压缩端需要两遍扫描源文件:第一遍统计每个字节值的出现次数,第二遍根据编码表逐字节编码输出。两遍扫描的内存占用是固定的 64KB 缓冲区,不随文件大小增长,这是对几百 MB 日志文件也敢用的关键。

如果源文件不大,比如小于 100MB,也可以一次性读进内存,把两遍扫描合成一遍:读入时统计频率,统计完建树、生成编码表,然后再遍历内存里的缓冲做编码。代码更短,但内存占用是文件大小的若干倍,我一般只在做快速原型时用这个省事版本,交付给别人的工具一律走两遍扫描。

第一遍统计的代码:

CFile src, dst; if (!src.Open(srcPath, CFile::modeRead | CFile::shareDenyNone)) return false; if (!dst.Open(dstPath, CFile::modeCreate | CFile::modeWrite)) return false; const ULONGLONG fileSize = src.GetLength(); unsigned long long freq[256] = {0}; BYTE buf[1 << 16]; UINT nRead; while ((nRead = src.Read(buf, sizeof(buf))) > 0) { for (UINT i = 0; i < nRead; ++i) freq[buf[i]]++; // 统计时用 BYTE 做下标,天然 0~255 } src.SeekToBegin(); // 第二遍还要重新读文件,必须回到开头

CFile::shareDenyNone是个容易被忽略的参数。打开源文件时如果不带它,MFC 默认会拒绝其他进程再打开这个文件,用户开着 Word 再拖一个 .docx 进来压缩就会打开失败。加上shareDenyNone允许共享读,避免这种莫名的失败。

统计完频率后建树、生成编码表,编码表的生成用递归遍历:

void GenerateCodes(HuffNode* node, const std::string& code, std::string codes[256]) { if (node->left == nullptr && node->right == nullptr) { codes[node->ch] = code; return; } if (node->left) GenerateCodes(node->left, code + "0", codes); if (node->right) GenerateCodes(node->right, code + "1", codes); }

递归深度在这里是安全的。Huffman 树的构造保证叶子深度不超过 255,而 256 层的递归在默认栈空间下毫无压力,不需要改成迭代版本。唯一要注意的是编码表用std::string存,不能用 CString——CString 遇到字节值为 0 的编码会当作字符串结尾截断,这是个非常隐蔽的坑,放到第 5 章细说。

3.2 递归生成编码表与文件头写出:magic 与频率对的字节布局

编码表生成后,紧接着就是写文件头。文件头的关键是各项的顺序和长度必须与解压端严格一致,差一个字节都对不上。顺序是:magic、原始长度、活动符号数、符号频率对、码流。

BYTE magic[4] = { 'H', 'U', 'F', '1' }; dst.Write(magic, 4); WriteU64(dst, fileSize); int symCnt = 0; for (int i = 0; i < 256; ++i) if (freq[i] > 0) ++symCnt; WriteU16(dst, (USHORT)symCnt); for (int i = 0; i < 256; ++i) { if (freq[i] > 0) { BYTE ch = (BYTE)i; dst.Write(&ch, 1); WriteU64(dst, freq[i]); } }

写活动符号数和频率对时,一个细节是符号字节与频率分开写而不是拼成结构体数组。每项长度严格是 9 字节(1 字节符号 + 8 字节频率),解码端按 9 字节为单位循环读取,不依赖偏移计算,代码里不容易出越界。

活动符号数的类型选择也要统一。我用USHORT(2 字节)而不是 1 字节是有原因的:256 这个数量级用无符号 8 位整数刚好能表示 0~255,但 256 是上限,表示「256 个符号」时 8 位的最大值 255 不够用,必须用 16 位。这个 1 字节差异在头文件结构里要跟解压端对齐成同样的宽度。

写完头之后就可以进入第二遍扫描,逐字节查编码表、逐位写出。码流的写入需要专门的位缓冲类,因为编码长度是变长的,几乎不可能恰好凑满整数个字节。

3.3 BitWriter 位写入与两遍扫描:压缩主流程的 CFile 写法

位写入类是压缩端最容易写错的部分。核心思路:维护一个字节缓冲和一个计数器,每写入一个比特就左移一次缓冲并把该比特放进去,计数器到 8 就落盘一个字节。

class BitWriter { public: explicit BitWriter(CFile& f) : m_file(f), m_buf(0), m_bitCnt(0) {} void WriteBit(int bit) { m_buf = (m_buf << 1) | (bit & 1); // 第一个写入的比特位于最高位 if (++m_bitCnt == 8) { m_file.Write(&m_buf, 1); m_buf = 0; m_bitCnt = 0; } } void Flush() { if (m_bitCnt > 0) { while (m_bitCnt < 8) { m_buf <<= 1; // 低位补 0,凑满一个字节 ++m_bitCnt; } m_file.Write(&m_buf, 1); m_buf = 0; m_bitCnt = 0; } } private: CFile& m_file; unsigned char m_buf; int m_bitCnt; };

Flush是压缩端最容易漏的调用。当文件末尾的编码凑不满 8 位时,缓冲里还有 1~7 个有效位,如果不调用 Flush,这几个位就丢了,解压结果会少数据。Flush 里补的零位在解码端不会造成多余输出,因为解码端以文件头里的原始长度为终止条件,读够原始长度就停,补零位根本不会被消费。

第二遍扫描的完整循环:

BitWriter bw(dst); unsigned long long processed = 0; while ((nRead = src.Read(buf, sizeof(buf))) > 0) { for (UINT i = 0; i < nRead; ++i) { const std::string& code = codes[buf[i]]; for (size_t j = 0; j < code.size(); ++j) bw.WriteBit(code[j] == '1' ? 1 : 0); } processed += nRead; // 按已处理字节数计算进度百分比,用 PostMessage 回 UI 线程 ::PostMessage(pDlg->GetSafeHwnd(), WM_USER_PROGRESS, (WPARAM)(processed * 100 / fileSize), 0); } bw.Flush();

循环里对每个字节做一次std::string的operator[]遍历,性能开销不大,256 字节的表查询很快。processed * 100 / fileSize这个算式里乘法在前,避免先除后乘导致的精度损失——文件较大时方括号内先乘后除,百分比数值还能看。

压缩主流程的最后别忘了递归释放树节点。释放树的代码很直白:

void FreeTree(HuffNode* node) { if (!node) return; FreeTree(node->left); FreeTree(node->right); delete node; }

这个释放动作放在函数末尾,压缩失败提前返回的分支也要记得调用,否则每个失败路径都泄漏一整棵树。我一般把整棵树的生命周期管理交给一个 RAII 包装,析构里统一释放,省得每个 return 出口都担心。

4. 解压端实现:读头重建 Huffman 树、逐位解码还原

4.1 文件头解析与树重建:按字节读回频率表避免端序坑

解压端的第一件事是打开文件、读文件头、做基础校验。这里校验要严格:magic 不对直接报「不是有效的 .huf 文件」,原始长度为 0 就直接生成空文件返回,活动符号数为 0 或大于 256 都属于格式损坏。

bool ReadHeader(CFile& f, unsigned long long& origSize, unsigned long long freq[256]) { BYTE magic[4]; if (f.Read(magic, 4) != 4) return false; if (memcmp(magic, "HUF1", 4) != 0) return false; BYTE b[8]; origSize = 0; if (f.Read(b, 8) != 8) return false; for (int i = 0; i < 8; ++i) origSize |= ((unsigned long long)b[i]) << (8 * i); USHORT symCnt; BYTE cntBuf[2]; if (f.Read(cntBuf, 2) != 2) return false; symCnt = (USHORT)(cntBuf[0] | (cntBuf[1] << 8)); if (symCnt == 0 || symCnt > 256) return false; for (USHORT i = 0; i < symCnt; ++i) { BYTE ch; if (f.Read(&ch, 1) != 1) return false; if (f.Read(b, 8) != 8) return false; unsigned long long cnt = 0; for (int j = 7; j >= 0; --j) cnt = (cnt << 8) | b[j]; freq[ch] = cnt; } return true; }

这段代码里所有多字节字段都是手动按小端拼出来的,与压缩端的WriteU16、WriteU64严格对应。读频率对时用freq[ch] = cnt,符号ch的类型是BYTE,天然 0~255,不存在符号扩展问题。

校验通过、频率表读齐之后,调用与压缩端同一个BuildHuffTree重建树。这里的核心保证是:只要文件头里的频率表没被破坏,重建出来的树结构与压缩时完全一致,解码路径就与编码路径一一对应。所以解压端代码里不需要再写一份建树逻辑,直接复用,这也是当初选择存频率表而不是存树结构的最大回报。

4.2 BitReader 逐位解码:以原始文件长度为终止条件

解码端需要一个与 BitWriter 对称的位读取类:

class BitReader { public: explicit BitReader(CFile& f) : m_file(f), m_buf(0), m_bitCnt(0) {} int ReadBit() { if (m_bitCnt == 0) { if (m_file.Read(&m_buf, 1) != 1) return -1; // 数据截断 m_bitCnt = 8; } int bit = (m_buf >> (m_bitCnt - 1)) & 1; // 从最高位开始取 --m_bitCnt; return bit; } private: CFile& m_file; BYTE m_buf; int m_bitCnt; };

ReadBit返回 -1 表示文件提前结束,这是检测「码流被截断」的重要防线。解码主循环如下:

bool DecompressFile(const CString& srcPath, const CString& dstPath, unsigned long long& outBytes) { CFile src, dst; if (!src.Open(srcPath, CFile::modeRead | CFile::shareDenyNone)) return false; if (!dst.Open(dstPath, CFile::modeCreate | CFile::modeWrite)) return false; unsigned long long origSize = 0; unsigned long long freq[256] = {0}; if (!ReadHeader(src, origSize, freq)) return false; if (origSize == 0) return true; // 空文件的解压就是建个空文件 HuffNode* root = BuildHuffTree(freq); std::vector<BYTE> symbols; CountActiveSymbols(freq, symbols); // 收集活动符号,解压端备用 BitReader reader(src); BYTE outBuf[1 << 16]; UINT outPos = 0; unsigned long long written = 0; while (written < origSize) { HuffNode* node = root; while (node->left || node->right) { int bit = reader.ReadBit(); if (bit < 0) return false; // 码流提前耗尽 node = (bit == 0) ? node->left : node->right; } outBuf[outPos++] = node->ch; ++written; if (outPos == sizeof(outBuf)) { dst.Write(outBuf, outPos); outPos = 0; } } if (outPos > 0) dst.Write(outBuf, outPos); FreeTree(root); return written == origSize; }

终止条件的设计是整个解码正确性的核心。while (written < origSize)保证恰好输出原始长度个字节,文件头里的原始长度字段在这里派上用场。如果改用「读到 EOF 为止」,结尾补零位会被错误地解成额外字节,这也是为什么文件头里必须有原始长度。

单符号文件的解码在这个循环里自然成立:root 是叶子节点,while (node->left || node->right)判断为假,内层循环一次都不执行,直接输出node->ch,重复原始长度次。不需要单独写特殊分支,这就是当初在BuildHuffTree里保留pq.size() == 1分支的回报。

4.3 用 AfxBeginThread 把压缩解压搬进后台线程

MFC 对话框程序里最容易犯的一个错误是把压缩逻辑直接写在按钮点击消息处理函数里。压几十 MB 文件时,两遍扫描加位写入会让界面卡死几秒到几十秒,用户第一反应是程序死机了,于是疯狂点鼠标,消息队列越积越多。正确做法是开工作线程,界面线程只负责响应消息。

UINT CompressThreadProc(LPVOID pParam) { CCompressDlg* pDlg = (CCompressDlg*)pParam; pDlg->RunCompress(); // 实际压缩逻辑在后台线程执行 return 0; } void CCompressDlg::OnBnClickedStart() { m_bCancel = FALSE; AfxBeginThread(CompressThreadProc, this, THREAD_PRIORITY_NORMAL); } void CCompressDlg::RunCompress() { bool ok = CompressFile(m_srcPath, m_dstPath, m_bCancel, this); ::PostMessage(GetSafeHwnd(), WM_USER_COMPRESS_DONE, ok ? 1 : 0, 0); }

关键约束:工作线程里绝不能直接操作 UI 控件。GetDlgItem、SetDlgItemText、访问进度条控件成员变量这些操作在 MFC 里都要求 UI 线程,工作线程里做轻则进度条不刷新,重则断言崩溃。正确做法是工作线程只通过PostMessage把进度值和完成状态发回对话框,由对话框的OnProgress消息处理函数更新控件。

m_bCancel要用volatile BOOL声明,工作线程在每轮缓冲区处理完后检查它,用户点取消按钮时把它置 TRUE。volatile在这里是必要的,否则编译器可能把循环里的检查优化掉,导致取消永远不生效。

取消时的清理也有讲究:压缩到一半取消,目标目录可能留下一个半截的 .huf 文件,下次再压会因为modeCreate直接覆盖所以问题不大,但解压了一半取消会留下半截目标文件。我一般会在取消点把已经打开的目标文件关闭,并调用CFile::Remove删掉半成品,避免垃圾文件堆积。

5. Huffman 压缩解压的 5 个典型踩坑与排查记录

5.1 最后几位没补零:解压结果比原文件短

现象:压缩一段中文文本再解压,解出来的文件比原文件少了几个字节,末尾一段内容完全消失;换个文件可能反过来,末尾多出几个乱码字节。

原因:变长编码下码流总位数几乎不可能恰是 8 的倍数。编码循环结束后没有调用BitWriter::Flush,最后 1~7 个有效位就丢在缓冲里没落盘,解压端读不够原始长度,自然缺字节。反过来多出乱码,是解码端用「读到 EOF」当终止条件,把 Flush 补进去的零位也当成了有效编码。

解决:压缩端循环结束后无条件调用Flush();文件头里必须存原始长度;解码端只用written < origSize做终止条件,补零位一律不要消费。这三个动作缺一不可,缺任何一个都会在文件末尾出问题。

5.2 char 符号扩展:二进制文件解压后字节错乱的元凶

现象:文本文件压缩解压一切正常,一压 .exe、.zip 这类二进制文件,解压出来的内容大量字节错乱,解出来的文件大小对但内容对不上。

原因:代码里某处把字节值存进了char类型的变量。char在有符号平台上取值范围是 -128~127,当字节值大于 127 时,node->ch取出来是负数,拿它当数组下标访问codes[ch]就越界了;还有一种变体是用 CString 存编码串,编码位串里出现 0x00 时被 CString 当成字符串结尾截断,后面的编码全部丢失。这类问题在纯文本文件上往往不发作,因为普通 ASCII 字符都在 127 以内,一碰二进制就现原形。

解决:所有存字节值的成员、下标、索引一律用unsigned char或BYTE。排查时先压缩一个大一点的带 0x80~0xFF 字节的测试文件,解压后逐字节对比,凡是错乱密集的地方基本都是符号扩展或者 CString 截断,重点查这两处。

5.3 大文件压缩界面卡死的玄学:逻辑全压在 UI 线程

现象:点击压缩按钮后窗口白屏、无法拖动,任务管理器里 CPU 单核跑满,看起来像死循环,实际上程序还在吭哧吭哧地压缩。

原因:压缩主流程直接写在OnBnClicked里,两遍大规模文件扫描把 UI 线程的消息循环堵死了。MFC 的界面刷新、鼠标响应全部依赖 UI 线程,线程忙则界面假死。以前接维护的一个旧工具就是这么写的,压 500MB 日志时用户每次都要强杀进程,印象极深。

解决:用AfxBeginThread起工作线程,工作线程只做事不碰控件,进度和完成状态通过PostMessage回报。按钮在压缩期间要用EnableWindow(FALSE)禁用,防止用户重复点击启动多个压缩线程互相踩目标文件。

5.4 空文件与单字符文件:两个必须提前拦住的边界

现象:空文件压缩后解压时报错;全是同一个字符的 100KB 文件压缩后解压失败或解出乱码。

原因:空文件时优先队列为空,BuildHuffTree返回 nullptr,解压端拿到空指针直接崩;单符号文件时树根就是叶子,如果压缩端没做特殊处理,GenerateCodes生成的是空串编码,码流部分什么都不写,解压端如果还按「先读一位再走树」的老逻辑就会读到 EOF 然后报错。

解决:空文件在压缩端直接写magic + origSize=0 + symCnt=0,解压端看到origSize==0直接创建空文件返回;单符号文件在压缩端只写文件头不写码流,解压端靠while (node->left || node->right)的天然判断跳过位读取,直接循环输出叶子字节。两个分支在压缩和解压两端都要写得对称,这是边界处理的第一原则。

5.5 压缩率不升反降:四步排查路径

现象:压一个 10MB 的源码文本,结果只小了 2%,甚至比原文件还大。

原因:分四种情况看。(1)文件头开销吃掉收益:如果头里固定写 256 个频率项,每项 9 字节就是 2304 字节的开销,对 1KB 的小文件来说直接膨胀;此时应改成只写活动符号对应的频率对。(2)第二遍扫描忘了SeekToBegin,源文件指针停留在文件末尾,第二遍一个字节都读不到,码流为空或者只有半截,这种文件解压出来必然不对,压缩率也会异常地好看或难看。(3)压的本来就是已压缩数据,zip、jpg、png 这类文件再压反而变大是正常现象,不是 bug。(4)频率统计与编码表生成不匹配,比如统计完改了频率数组但没重建树,编码表用的是旧树,码流长度正常但内容错乱。

排查时先打印四组数字:原始文件大小、压缩文件总大小、文件头大小、码流大小。码流大小接近「信息熵 × 字节数」就是压到底了,文件头占比高就优化头结构,码流明显低于熵值基本可以断定统计或建树环节出了错。

6. 交付前的三个实用细节:文件名、CRC32 与可取消性

6.1 在 .huf 文件头里扩展保存原文件名

只压内容不压文件名,解压时用户还得手工填写目标文件名,体验很差。在频率表之后追加一段扩展信息就能解决:先写 1 字节文件名长度,再写 UTF-8 编码的文件名字节串。用 UTF-8 是为了避免中文文件名在 CString 的 ANSI 与 Unicode 编码之间转换出错。

CString strName = PathFindFileName(srcPath); CStringA utf8Name = CW2A(strName, CP_UTF8); BYTE nameLen = (BYTE)min(utf8Name.GetLength(), 255); dst.Write(&nameLen, 1); dst.Write(utf8Name, utf8Name.GetLength());

解压端读完频率表后按同样顺序读 1 字节长度,再读出名字串,用CA2W(..., CP_UTF8)转回 CString 作为默认输出文件名。长度上限 255 对文件名足够,不放心可以放宽到 2 字节长度字段,格式上留好余量。

6.2 用 CRC32 校验替代盲目信任解码结果

Huffman 解码有个特性:码流里错一位,往往后面全部解错,而且有时候解出来的文件大小恰好正确,肉眼很难发现数据坏了。给格式加一个 CRC32 校验字段很便宜,20 行代码的事,却能把「解压成功但数据全错」变成「校验不过,明确报错」。

标准 CRC32 用查表法实现,表生成和更新函数如下:

static DWORD s_crcTable[256]; void InitCrcTable() { for (DWORD i = 0; i < 256; ++i) { DWORD c = i; for (int k = 0; k < 8; ++k) c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); s_crcTable[i] = c; } } DWORD CrcUpdate(DWORD crc, const BYTE* p, UINT n) { crc = ~crc; for (UINT i = 0; i < n; ++i) crc = s_crcTable[(crc ^ p[i]) & 0xFF] ^ (crc >> 8); return ~crc; }

压缩端在读完源文件后对原始字节计算一次 CRC,写进文件头;解压端在写出全部数据后对解压结果算一次 CRC,与头里的值比对。不匹配时立即报「数据校验失败」,不要保留半截文件。校验的计算放在第二遍扫描的循环里同步进行,读多少算多少,不额外增加一遍 I/O。

取消按钮的线程安全实现前面已经提过,这里补齐最后一块:工作线程在每轮缓冲区处理完检查m_bCancel,为真则关闭文件、删除半成品、返回取消状态。对话框收到完成消息后依据状态决定弹成功提示还是取消提示。压缩结束后用CFile::GetStatus对比目标文件是否生成、大小是否合理,当作最后一道自检。

我现在的习惯是,任何自定义文件格式第一版就把版本号、原始长度、CRC32 一起写进头里——格式要演进的时候有后悔药,校验字段是给所有使用者的定心丸。这篇里从建树到文件头、从位写入到线程配合的完整链路,照着搭一遍就能跑通,剩下的就是边界和校验这些收尾功夫。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进