简介:这份资源是用C++实现的霍夫曼树(最优二叉树)项目,面向正在学习数据结构、算法与数据压缩编码的开发者及学生,帮助理解霍夫曼编码的构造原理与工程落地方式。项目围绕频率表建立、优先队列合并、树构建、编码生成与解码等核心环节展开,并借助CMake跨平台构建系统组织编译流程,适合作为课程设计或算法练手的参考实现。压缩包为zip格式,整体约257KB,包内以C++源代码文件为主,辅以CMakeLists.txt构建脚本及可能的测试用例与说明文档,便于直接编译运行与阅读调试。目前已有223人学习关注。通过研读源码,读者可以掌握Node节点与HuffmanTree类的职责划分、优先队列在合并最小频率节点时的用法,以及从根到叶路径生成0/1前缀编码的具体实现,从而把霍夫曼编码的理论知识转化为可运行的C++程序,并借助CMake快速完成构建与验证。
1. 从一份压缩率不理想的日志文件说起
前阵子帮朋友看一个日志归档工具,他抱怨说同样的文本日志,别人压完体积只有原来的六成,他压完还有八成多。我扫了一眼代码,发现他直接调了现成的压缩库,参数全是默认值,压根没管字符频率分布。其实这类场景里,霍夫曼编码是最该被拿出来用的——它按字符出现频率动态生成变长前缀码,高频字符用短码、低频字符用长码,理论上能逼近信息熵。这份用 C++ 手写的霍夫曼树项目,就是把这套经典算法从零实现了一遍,包含建树、编码表生成、位流写入和译码还原。它适合两类人:一类是正在学数据结构、想找个能跑能改的完整案例的;另一类是想把压缩逻辑嵌进自己工具链、需要一份干净可读实现的。项目用 CMake 组织构建,跨平台编译不折腾。
2. 项目骨架与构建链路:CMake 怎么把源码串起来
2.1 目录结构长什么样
拿到源码包,第一件事是看目录。这个项目的组织方式很典型,没有花哨的分层,核心逻辑集中,适合快速通读:
HuffmanTree/ ├── CMakeLists.txt ├── include/ │ ├── HuffmanTree.h │ └── PriorityQueue.h ├── src/ │ ├── HuffmanTree.cpp │ ├── PriorityQueue.cpp │ └── main.cpp └── test/ └── test_huffman.cppinclude/放头文件,src/放实现,test/放测试入口。PriorityQueue.h是项目自己实现的最小堆,没有直接用 STL 的priority_queue,这一点后面会讲为什么。main.cpp是命令行入口,负责读文件、调压缩/解压、写输出。
2.2 CMakeLists.txt 的关键配置
构建脚本不长,但每行都有用。常见做法是把编译标准和输出路径都显式写死,避免不同机器上默认值不一致导致玄学问题:
cmake_minimum_required(VERSION 3.10) project(HuffmanTree CXX) # 强制 C++11,霍夫曼树里用了智能指针和 lambda set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 头文件搜索路径 include_directories(${CMAKE_SOURCE_DIR}/include) # 核心库:把算法逻辑编成静态库,方便被其他目标链接 add_library(huffman_core src/HuffmanTree.cpp src/PriorityQueue.cpp ) # 命令行可执行文件 add_executable(huffman_cli src/main.cpp) target_link_libraries(huffman_cli huffman_core) # 测试目标,默认不编译,需要时用 -DBUILD_TEST=ON 打开 option(BUILD_TEST "Build unit tests" OFF) if(BUILD_TEST) add_executable(huffman_test test/test_huffman.cpp) target_link_libraries(huffman_test huffman_core) endif()这里有几个参数值得说。CMAKE_CXX_STANDARD 11是硬性要求,源码里用了std::shared_ptr管理树节点,用auto简化迭代器声明,低于 C++11 编不过。add_library把算法部分单独编成huffman_core,而不是把所有.cpp一股脑塞进可执行文件,好处是测试目标能直接链接同一个库,保证测的就是跑的那份代码。BUILD_TEST默认关掉,日常编译不拖慢速度,要跑测试时加-DBUILD_TEST=ON重新生成即可。
2.3 编译与运行的具体步骤
在项目根目录下执行:
mkdir build && cd build cmake .. make -j4编译完成后,build/下会生成huffman_cli。运行方式:
# 压缩:把 input.txt 压成 input.huff ./huffman_cli -c ../input.txt ../input.huff # 解压:把 input.huff 还原成 output.txt ./huffman_cli -d ../input.huff ../output.txt参数含义很直白:-c走压缩分支,-d走解压分支,后面跟输入路径和输出路径。如果路径带空格,记得用引号包住。Windows 下用 MinGW 或 MSVC 生成的是.exe,命令里加上后缀就行。编译报错的话,先确认 CMake 版本不低于 3.10,再确认编译器支持 C++11——老版本的 Visual Studio 2015 以前对 C++11 支持不全,容易在智能指针那块翻车。
3. 核心模块拆解:从字符频率到前缀码的完整链路
3.1 节点定义与频率统计
霍夫曼树的节点需要存字符、频率、左右孩子指针。项目里用结构体加智能指针的方式管理内存:
// include/HuffmanTree.h struct HuffNode { char ch; // 叶子节点存字符,内部节点存 0 unsigned long long freq; // 出现频率,用无符号长整型防溢出 std::shared_ptr<HuffNode> left; // 左孩子 std::shared_ptr<HuffNode> right; // 右孩子 HuffNode(char c, unsigned long long f) : ch(c), freq(f), left(nullptr), right(nullptr) {} };用shared_ptr而不是裸指针,是因为建树过程中节点会被反复合并、重新挂载,手动delete很容易漏掉某个分支导致内存泄漏。freq用unsigned long long是血泪经验——如果处理的是大文件,字符总数可能超过int上限,用int会在统计阶段就溢出,后面建出来的树频率全是错的。
频率统计在HuffmanTree.cpp里,逻辑就是遍历输入缓冲区,用std::unordered_map<char, unsigned long long>累加:
std::unordered_map<char, unsigned long long> countFrequency( const std::vector<char>& data) { std::unordered_map<char, unsigned long long> freqMap; for (char c : data) { freqMap[c]++; // 每个字符出现一次就加一 } return freqMap; }data是读进来的原始字节流。这里没有区分文本和二进制,所以这个实现不仅能压文本,也能压任意字节序列。unordered_map的键是char,值是该字符的总出现次数。统计完之后,每个不同的字符对应一个频率,后续建树只关心这些频率值。
3.2 最小堆的选型与实现
建霍夫曼树的核心操作是:每次从候选节点里取两个频率最小的,合并成一个新节点,再把新节点放回去。这个“取最小”的动作如果每次都用线性扫描,复杂度是 O(n),整体建树会退化到 O(n²)。项目自己写了一个最小堆,而不是直接用 STL 的priority_queue,原因是 STL 的堆不支持高效地“更新已有元素”,而霍夫曼建树只需要插入和取最小,自己写反而更可控。
// include/PriorityQueue.h class MinHeap { private: std::vector<std::shared_ptr<HuffNode>> heap; void siftUp(int idx); // 上浮,插入时用 void siftDown(int idx); // 下沉,弹出时用 public: void push(std::shared_ptr<HuffNode> node); std::shared_ptr<HuffNode> pop(); // 弹出并返回最小节点 bool empty() const; size_t size() const; };siftUp和siftDown是标准堆操作。push把新节点放到数组末尾然后上浮,pop把堆顶和末尾交换、删掉末尾、再对堆顶下沉。比较依据是节点的freq字段。这个堆的复杂度是 O(log n),建树整体 O(n log n),对于几十万字符的文件完全够用。
3.3 建树与编码表生成
有了频率表和最小堆,建树过程就是不断合并:
std::shared_ptr<HuffNode> buildTree( const std::unordered_map<char, unsigned long long>& freqMap) { MinHeap heap; // 每个字符建一个叶子节点,全部入堆 for (auto& pair : freqMap) { heap.push(std::make_shared<HuffNode>(pair.first, pair.second)); } // 特殊情况:只有一种字符时,直接给它一个根节点 if (heap.size() == 1) { auto only = heap.pop(); auto root = std::make_shared<HuffNode>(0, only->freq); root->left = only; return root; } // 反复取两个最小节点合并,直到堆里只剩一个根 while (heap.size() > 1) { auto left = heap.pop(); auto right = heap.pop(); auto parent = std::make_shared<HuffNode>( 0, left->freq + right->freq); parent->left = left; parent->right = right; heap.push(parent); } return heap.pop(); }这里有个容易忽略的边界:如果文件里只出现一种字符,堆里只有一个节点,直接合并会取不出两个节点。代码里单独处理了这种情况,给它造一个根,左孩子是那个唯一节点。没有这个分支的话,空文件或单字符文件会直接崩。
编码表通过一次深度优先遍历生成,往左走记0,往右走记1:
void generateCodes(std::shared_ptr<HuffNode> node, const std::string& prefix, std::unordered_map<char, std::string>& codeMap) { if (!node) return; // 叶子节点:记录当前路径作为编码 if (!node->left && !node->right) { codeMap[node->ch] = prefix.empty() ? "0" : prefix; return; } generateCodes(node->left, prefix + "0", codeMap); generateCodes(node->right, prefix + "1", codeMap); }prefix是递归过程中累积的路径串。叶子节点没有孩子,说明走到了尽头,把当前prefix存进codeMap。注意prefix.empty()那个判断——单字符文件时根节点就是叶子,路径为空,给个默认"0"保证编码非空。
3.4 位流写入与译码还原
编码表有了之后,把原始数据逐字符替换成对应的01串,再按位打包成字节写入文件。这里的关键是不能按字符串直接写,否则每个0或1占一个字节,压缩等于没压。项目里用一个位缓冲区累积:
// 位写入器:攒够 8 位就输出一个字节 class BitWriter { std::ofstream& out; unsigned char buffer = 0; int bitCount = 0; public: BitWriter(std::ofstream& os) : out(os) {} void writeBit(int bit) { buffer = (buffer << 1) | (bit & 1); bitCount++; if (bitCount == 8) { out.put(buffer); buffer = 0; bitCount = 0; } } // 收尾:剩余不足 8 位的补零写出 void flush() { if (bitCount > 0) { buffer <<= (8 - bitCount); out.put(buffer); } } };writeBit每次把buffer左移一位再或上当前位,攒满 8 位就写一个字节。flush处理末尾不足 8 位的情况,左移补零后写出。译码时反过来,从文件读字节,逐位拆开,沿着霍夫曼树走:遇到0往左,遇到1往右,走到叶子就输出对应字符并回到根节点。译码不需要编码表,只需要树本身,所以压缩文件里必须把频率表或树结构存进去,否则解压端无法重建。
4. 避坑与排查:编译、运行、结果异常怎么定位
4.1 编译报错 “undefined reference to HuffNode”
现象:链接阶段报错,提示找不到HuffNode相关符号。原因通常是HuffmanTree.cpp没有加入add_library的源文件列表,或者头文件里声明了函数但实现文件里忘了写。解决:检查CMakeLists.txt里huffman_core是否包含了所有.cpp,再检查头文件里的函数声明在对应.cpp里是否都有定义。改完CMakeLists.txt后必须重新跑cmake ..,只跑make不会重新生成构建规则。
4.2 压缩后文件反而变大
现象:对一个很小的文本文件压缩,输出比输入还大。原因:霍夫曼压缩需要额外存储频率表或树结构,小文件本身信息量少,头部开销占比高,压缩收益为负。解决:这是正常现象,不是 bug。常见做法是在压缩前判断文件大小,低于某个阈值(比如 1KB)就直接原样拷贝,加个标志位区分。项目当前版本没做这个优化,二次开发时可以补上。
4.3 解压出来的文件末尾多出乱码
现象:解压后的文件比原始文件多几个字节,内容是乱码。原因:压缩时最后一个字节补了零,译码时这些补位被当成有效编码继续走树,走到了不该走的叶子。解决:需要在压缩文件头里记录原始数据的字符总数,译码时计数达到总数就停止,忽略后面的补位。项目里如果没存这个长度,就得靠文件尾标记或固定长度字段来截断。
4.4 处理大文件时程序被系统杀掉
现象:压缩几百 MB 的文件时,进程内存暴涨然后被系统终止。原因:countFrequency把整个文件读进std::vector<char>,建树时又为每个字符创建节点,内存占用是文件大小的数倍。解决:改成流式读取,分块统计频率,建树只依赖频率表(256 个条目封顶),编码阶段再流式读第二遍。这样内存占用与文件大小无关,只与字符种类数有关。
4.5 不同平台编译出的压缩文件不互通
现象:在 Linux 上压的文件,拿到 Windows 上解压失败。原因:char的符号性在不同平台可能不同,频率表的键如果按有符号char存,遇到高位为 1 的字节会变成负数,导致键不匹配。解决:统一用unsigned char作为频率表的键类型,或者在读写文件头时显式做类型转换。跨平台项目里这种细节不注意,就会出现“在我机器上好好的”这类玄学问题。
5. 二次开发:把霍夫曼树嵌进自己的工具链
5.1 改造方向一:流式处理大文件
当前实现是一次性读入内存,适合学习和小文件,但生产环境里动辄几个 GB 的日志文件扛不住。改造思路是把countFrequency改成按块读取,每次读 64KB,统计完频率后丢弃数据块,只保留频率表。建树和编码表生成不受影响,因为频率表最多 256 个条目。编码阶段再打开文件流式读第二遍,边读边编码边写。这样内存占用从 O(文件大小) 降到 O(1),代价是文件要读两遍。如果磁盘 IO 是瓶颈,可以牺牲一点内存,把第一遍读到的数据缓存到临时文件里。
5.2 改造方向二:支持多文件打包压缩
单个文件压缩在实际使用中不够用,更常见的是把多个文件打成一个包再压。可以在文件头里加一个索引区,记录每个文件的名称、原始大小、压缩后偏移量。压缩时逐个文件独立建树,或者把所有文件拼成一个流统一建树。前者实现简单,每个文件独立解压;后者压缩率更高,但解压时需要先读完整棵树。我一般会选前者,因为解压时能按需提取单个文件,不用全部还原。
5.3 改造方向三:替换为自适应霍夫曼编码
静态霍夫曼需要先统计频率再建树,压缩端要读两遍文件。自适应霍夫曼(Adaptive Huffman)边读边更新树,只需要读一遍,适合网络流或管道场景。改造的核心是把建树逻辑从“一次性构建”改成“每读一个字符就更新频率并调整树结构”。调整树时要用到兄弟节点交换和父节点重挂,实现复杂度比静态版高不少,但省掉了频率表的存储开销,对短数据更友好。
5.4 验证改造是否正确的三个手段
改完代码怎么确认没改坏?第一,用同一份输入分别跑改造前后的版本,对比压缩文件的字节数和解压后的内容是否完全一致。第二,构造边界用例:空文件、单字符文件、全相同字符文件、包含全部 256 种字节的文件,逐个跑一遍。第三,用valgrind或 AddressSanitizer 检查内存泄漏和越界访问,智能指针虽然能挡大部分问题,但递归建树时栈溢出它管不了。我自己的习惯是每次动核心逻辑之前,先把原始版本跑一遍留个基准输出,改完立刻 diff,不一致就回滚重查。从那以后我每次改压缩算法都强制走一遍“基准对比 + 边界用例 + 内存检查”这三步,省了很多事后排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取