基于Huffman编码的文件压缩算法详解
引言
文件压缩技术通过减少数据冗余提升存储和传输效率,其中Huffman编码作为一种经典的无损压缩算法,在文本、图像和通信领域广泛应用。本文详细介绍Huffman编码的核心原理、实现细节、工程挑战与优化方案,并提供完整的C++代码实现。
一、Huffman编码原理
1. 核心思想
变长编码:高频字符使用短编码,低频字符使用长编码,降低整体数据量。
前缀码特性:任意字符编码不为其他编码的前缀,确保解码唯一性。
2. 构建Huffman树
1. 统计字符频率:遍历文件,统计每个字符出现次数。
2. 构建最小堆:以字符频率为权值,生成最小优先队列。
3. 合并节点:重复选取权值最小的两个节点合并,直到形成完整二叉树。
4. 生成编码表:从根节点到叶子路径,左分支标记0,右分支标记1。
3. 压缩与解压流程
1. 压缩:
根据编码表替换源文件字符,生成二进制压缩文件。
保存字符频率表至压缩文件头部,供解压时重建Huffman树。 如果直接解压,那么多出来的几位比特位也被解压了,所以需要考虑总文件的大小,控制真正读取的位数。
//压缩文件格式:
txt(源文件后缀)
4(字符数量)
A:1
B:3
C:5
D:7
96 DF FC 00
2. 解压:
读取频率表重建Huffman树。
遍历二进制码流,从根节点按0/1走向叶子节点,还原原始字符。
形成编码文件后,可以使用unordered_map<string , char>根据key值也就是string查找到对应的char。
也可以利用huffman树解压缩,从根节点走,左0右1,直到到达叶子结点,用这种方法就不用每次查表了,但是要在压缩文件里保存每个字符对应的次数。
二、代码实现解析
1. 核心数据结构
// Huffman树节点
template<class W>
struct HuffmanTreeNode {
HuffmanTreeNode<W>* _left, *_right, *_parent;
W _weight; // 权值(字符频率)
HuffmanTreeNode(const W& weight = W())
: _left(nullptr), _right(nullptr), _parent(nullptr), _weight(weight) {}
};
// 优先队列比较规则
template<class W>
struct CompareNode {
bool operator()(const HuffmanTreeNode<W>* n1, const HuffmanTreeNode<W>* n2) {
return n1->_weight > n2->_weight; // 小根堆
}
};
2. 构建Huffman树
HuffmanTree(const std::vector<W>& vw, const W& valid) {
std::priority_queue<Node*, std::vector<Node*>, CompareNode<W>> q;
for (auto& e : vw) {
if (valid != e) q.push(new Node(e));
}
while (q.size() > 1) {
Node* left = q.top(); q.pop();
Node* right = q.top(); q.pop();
Node* parent = new Node(left->_weight + right->_weight);
parent->_left = left; left->_parent = parent;
parent->_right = right; right->_parent = parent;
q.push(parent);
}
_root = q.top();
}
3. 压缩与解压实现
压缩:
void CompressFile(const string& filePath) {
// 统计字符频率 -> 构建Huffman树 -> 生成编码表 -> 写入压缩文件
// ...
for (size_t i = 0; i < rdSize; ++i) {
string& strCode = _fileInfo[rdBuff[i]]._chCode;
for (char bit : strCode) { // 按编码替换字符
bits <<= 1;
if (bit == '1') bits |= 1;
if (++bitCount == 8) { // 满8位写入文件
fputc(bits, fout);
bits = bitCount = 0;
}
}
}
}
解压:
void UnCompressFile(const string& filePath) {
// 读取频率表 -> 重建Huffman树 -> 解析二进制码流
HuffmanTreeNode<ByteInfo>* cur = ht.GetRoot();
while (读取字节流) {
for (int j = 0; j < 8; j++) {
if (ch & 0x80) cur = cur->_right; // 按位遍历树
else cur = cur->_left;
if (cur是叶子节点) {
写入解码字符;
cur = 重置到根节点;
}
ch <<= 1;
}
}
}
三、实现中的挑战与解决方案
1. 读不了换行后的数据
问题:如果文件存在换行,则会默认读取结束。
解决:在读取新一行数据时,判断如果取到的是空,说明此时是换行,那么手动加上'\n',然后再读一遍,取到 :次数,就可以继续正确读取了。
2. 汉字编码错误
问题:汉字采用多字节编码(如UTF-8),char类型处理符号位引发数据错误。
描述:常见的汉字编码如 UTF - 8、GBK 等,通常使用多个字节来表示一个汉字。以 UTF - 8 为例,它是一种变长编码,一个汉字可能由 2 到 4 个字节组成,并且这些字节的最高位通常为 1,而char类型通常是 1 个字节,是有符号类型,其取值范围一般是 -128 到 127,也就是说这意味着char的最高位(第 7 位)被用作符号位,0 代表正数,1 代表负数。
因为unsigned char是无符号类型,取值范围是 0 到 255。它没有符号位,所有 8 位都用于表示数值。
解决:将char改用unsigned char类型,确保8位全用于数据存储。
3. 文件解压不完整
问题:文本文件与压缩文件处理结束方式不同,EOF 与 “FF” 。
描述:打开、写入的是文本文件,检测有没有读到末尾,就是看是否到EOF。
而压缩文件本质是二进制类型的文件,一旦读到文本文件转换成的FF,就认为结束不往后走了。
解决:将读取、写入的方式从文本文件改成二进制文件,r/w改成rb/wb
4. 文件解压未考虑末尾填充位
问题:未处理压缩数据末尾不足8位的填充位,导致解码错误。
解决:记录有效位数,解压时仅解析实际数据位。
四、性能分析与优化
1. 压缩率对比
文本文件:37% ~ 75%
二进制文件:24% ~ 41%
压缩的结果取决于字符种类的多少,字符种类的多少会影响哈夫曼树的结构和数据分布的复杂性。其中,平均下来要是每个字节的编码小于8位,那么文件会变小,要是多余8位,就会变大。
2. 优化策略
内存管理:使用智能指针(unique_ptr)避免内存泄漏。
I/O优化:引入缓冲区减少磁盘访问次数。
多线程:并行处理文件分块,提升大文件压缩速度。
五、扩展:范式Huffman树
核心改进:
强制同一层叶子节点左对齐,按字符顺序排列,无需存储完整频率表。
优势:
压缩头信息从O(n)降至O(1)。
解压时通过编码位长直接计算编码值,无需遍历树。
具体:
huffman树压缩完成后,必须在压缩文件中保存字节频率信息后,才可以解压缩,如果字节频率信息比较大,也会影响压缩率。且解压时需要通过不断遍历还原的huffman树,效率也会打折扣,因此在工程中一般很少使用huffman直接进行压缩,而是使用范式huffman树。
范式huffman树,是在huffman树的基础之上,进行了一些强制性的约定,即:对于同一层节点中,所有的叶子节点都调整到左边,然后,对于同一层的叶子节点按照符号顺序从小到大调整,最后按照左0右1的方式分配编码。
使用范式huffman树,可以提高压缩效率,提高不了压缩率,就不用从叶子往根去推,编码可以算出来,同一层的编码位长一样,从左往右编码加一,下一层编码加一,再左移层数差位。
怎么得到编码表?排序字节频率表,以编码位长为第一字段,编码大小为第二字段,不用保存频率,也就是说只需要保存256个字节,同时也不用走递归了。
解压缩时,也只需要编码和编码位长,算出编码,得出解码表。
六、结语
Huffman编码以简洁高效的特点成为无损压缩的基石。本次通过完整代码实现和问题剖析,展现了从理论到工程的完整过程。查阅资料了解到实际应用中可结合LZ77等算法(如ZIP)进一步提升压缩率。未来可探索范式Huffman树与多线程加速的深度融合。
附录:完整代码与测试用例


4046

被折叠的 条评论
为什么被折叠?



