哈夫曼压缩
前言
这题是大四时,学弟学妹们问我的题目,哈夫曼压缩,也是我当年没有做好的题目,现在是来还债的。
哈夫曼压缩
哈夫曼压缩的本质是“非定长编码”,区别于 ASCII 码这样的定长编码
英文里哈夫曼编码也叫做 lossless data compression algorithm,即最小损失压缩算法
哈夫曼编码的特点
- 尽量使得出现频次高的符号,编码更短
- 任意两个符号的前缀码不重复
思路
核心思路
任何文件都是01的组合
01的组合,如果8位一组,就可以按字节将源文件读取到char型数据结构中
我们知道 char 型一共有 256 个字符
我们就可以将这256个字符,看作是哈夫曼树的叶子节点
压缩
对源文件8位一组的,按字节读取到char型数据结构中,进行词频统计
根据统计得到的词频,建立哈夫曼树,并将每个读取的字符都替换成对应的哈夫曼编码,写入到压缩文件中
解压缩
解压时,先恢复一颗哈夫曼树,再从压缩文件中读取哈夫曼编码,将其恢复成对应的字符,写入到解压缩文件中。
牢记一个原则:
无论是什么文件,无论以何种方式读取,其本质都是01的组合。
涉及的数据结构
词频统计(采用大小位256的数组存储,最多是256个字符)
哈夫曼树(采用数组作为存储结构,每个元素都是一个struct)
待压缩文件
压缩文件
解压缩后文件
步骤
压缩
- 读取待压缩文件内容,进行词频统计
- 建树
- 将建好的树写入压缩文件中
- 编码
- 将编码写入压缩文件中
- 关闭文件,结束
解压缩
- 读取压缩文件内容,恢复一颗哈夫曼树
- 读取编码,搜索哈夫曼树
- 搜索到叶子节点,则输出对应的字符到解压缩文件
- 直到读取完成压缩文件,关闭所有文件,结束
完整代码
1. 压缩.cpp
#include <iostream>
#include <fstream>
using namespace std;
string input, output;
//INF: https://blog.csdn.net/Alearn_/article/details/79618300
#define INF 0x3f3f3f3f
#define N 256
#define BYTE 8
#define PADDING 384// 到边界的距离,保证写入数据不超过1字节表示范围
#define EOI 127 //End Of Index 127 下标的最大值 127 + 384 = 511
//哈夫曼树的叶子节点
struct node
{
int weight;//权重,对于普通的叶子节点来说,就是其出现次数,初始为 0
int parent;//父节点在HT中的下标 初始为 -1
int lchild;
int rchild;
int len;//初始为0
int* code;//指向存放了长度为 len 的编码的区域,初始为 NULL
};
int wordcount[N];//统计词频
struct node HT[2 * N - 1];//数组实现的哈夫曼树,前256个是叶子节点,后面是生成的中间节点,预留了 最大的空间
int root = N;//根节点下标
//压缩时,在内存中建立一颗哈夫曼树
//需要不断找权重最小的叶子节点,生成新节点,并维护父子关系
void buildTree()
{
int pa_index = N;//第一个父节点从下标N开始
//初始化树的节点
for (int i = 0; i < N; i++)
{
HT[i].weight = wordcount[i] == 0 ? INF : wordcount[i];
HT[i].parent = -1;
HT[i].lchild = -1;
HT[i].rchild = -1;
HT[i].len = 0;
HT[i].code = NULL;
}
for (int i = N; i < 2 * N - 1; i++)
{
HT[i].weight = INF;
HT[i].parent = -1;
HT[i].lchild = -1;
HT[i].rchild = -1;
HT[i].len = 0;
HT[i].code = NULL;
}
//建树
while (1)
{
int loc1 = -1;//权重最小的节点的下标
int loc2 = -1;//权重次小的节点的下标
int min = INF;//最小权重
//找权重最小的节点
for (int i = 0; i <root ; i++)
{
//若扫描到的节点,比最小权重小,更新最小权重
//该节点还要是确实存在的节点
if (HT[i].parent == -1 && HT[i].weight < min)
{
loc1 = i;
min = HT[i].weight;
}
}
//一轮扫描结束,能得出最小权重的叶子节点
//到达最后一个节点就结束
if (loc1 == -1)
{
break;
}
//找权重次小的节点(可以是叶子也可以是生成的节点)
min = INF;
//注意这里 i 的范围是0 到 root
for (int i = 0; i <= root; i++)
{
//若扫描到的节点,比最小权重小,更新最小权重
//注意,要保证该节点尚未被建树的节点
if (HT[i].parent == -1 && HT[i].weight < min && i != loc1)
{
loc2 = i;
min = HT[i].weight;
}
}
//2轮扫描结束,能得出次小权重的节点
//找好了最小的两个节点了,就要新建父节点,并维护父子关系
//父节点的下标需要记录好
HT[pa_index].lchild = loc1 < loc2 ? loc1 : loc2;//默认下标较小者为左孩子
HT[pa_index].rchild = loc1 < loc2 ? loc2 : loc1;
HT[pa_index].weight = HT[loc1].weight + HT[loc2].weight;
HT[loc1].parent = pa_index;
HT[loc2].parent = pa_index;
root = pa_index;//根是最后一个父节点
pa_index++;
}
}
//压缩时,根据哈夫曼树,对所有出现过的字符进行编码,编码结果存放在HT的节点中
void encode()
{
//扫描叶子节点
for (int i = 0; i < N; i++)
{
int len = 0;
int curr = i;//回溯时,记录当前节点的位置
//TODO 查 节点深度的定义
if (HT[curr].parent == -1)
{
continue;//跳过非叶子节点
}
//回溯,从叶子到根,叶子节点对应字符的哈夫曼编码的长度 = 节点深度 - 1
while (HT[curr].parent != -1)
{
curr = HT[curr].parent;
len++;
}
//确定编码长度,并分配存储空间
HT[i].len = len;
HT[i].code = new int[len];
}
//再次扫描叶子节点
//回溯,将编码存放在对应内存空间
for (int i = 0; i < N; i++)
{
int curr = i;//回溯时,记录当前节点的位置
if (HT[i].parent == -1)
{
continue;
}
int pa_index = HT[i].parent;//父节点下标
int len = HT[i].len;
//TODO 查 节点深度的定义
//todo 比较从叶子到根,和从根到叶子的区别
//回溯,从叶子到根,叶子节点对应字符的哈夫曼编码的长度 = 节点深度 - 1
while (pa_index != -1)
{
//code数组的高位存放的是编码的低位
//'a'->10
//则 code = {1,0} code[0]=1, code[1]=0
//好拧巴啊
//当前节点是父节点的左孩子,则当前位的编码为0,否则为1
HT[i].code[--len] = (HT[pa_index].lchild == curr) ? 0 : 1;
//更新当前节点和父节点
curr = pa_index;
pa_index = HT[curr].parent;
}
//todo 试着写一下从根到叶子
}
}
void count()
{
ifstream ifs;
char c;
//todo 查一下 ifs 怎么打开文件
ifs.open(input, ios_base::binary);//打开
if (!ifs)
{
exit(1);
}
//todo 查一下 ifs 如何读取单个字符,直到结束
// 读取每个单词 c 直到文件结束
while (ifs.get(c))
{
wordcount[c + 128]++;
}
//关闭文件
ifs.close();
}
void writeTree()
{
ifstream ifs;
ofstream ofs;
char c;
char byte = 0;//凑齐一个字节,就写入文件
int count = 0;//监视是否凑齐一个字节
ifs.open(input, ios_base::binary);
ofs.open(output, ios_base::binary);
//写入补了多少bit,先占一位
ofs.put(0);
//part1 将树写入新文件
//写入 root 的下标
//保证写入的数据不超过1字节的表示范围 -128~127
ofs.put(root - PADDING);
//写入树,每个节点都保存父节点的下标
for (int i = 0; i < N * 2 - 1; i++)
{
if (HT[i].parent != -1)
ofs.put(HT[i].parent - PADDING);//保证写入的数据不超过1字节的表示范围 -128~127
else
ofs.put(EOI);//节点的下标范围是 0 到 0x1FF-1 (0~510)而 0x1FF -1 - PADDING 是 510 - 384 = 126 是 0x7E , EOI 是 0x7F > 0x7E ,可以作为特殊结束符
}
//part 2 将重新编码后的文件写入压缩文件
//循环读入,并编码
while (ifs.get(c))
{
for (int i = 0; i < HT[c + 128].len; i++)
{
if (count == BYTE)
{
ofs.put(byte);
byte = 0;
count = 0;
}
//byte的左边,应该是code的左边,即byte == code[0] << 7
byte = (byte << 1) | HT[c + 128].code[i];
count++;
}
}
//对不定长编码,补0,凑齐一个字节
if (count < BYTE)
{
for (int i = count; i < BYTE; i++)
{
byte = byte << 1;
}
ofs.put(byte);
//将补了多少0,写入前面
ofs.seekp(ios_base::beg);
ofs.put(BYTE - count);
}
//关闭所有文件
ofs.close();
ifs.close();
}
void compress()
{
//统计词频
count();
//建树
buildTree();
//编码
encode();
//将树写入文件
writeTree();
}
//todo 考虑一个特殊情况,文件只有1个字节大小,则没有必要去压缩,
void main()
{
cout << "请选择待压缩文件(输入不带空格的完整路径):";
cin >> input;
cout << "请输入它压缩后的文件名:";
cin >> output;
compress();
return;
}
2. 解压缩.cpp
#include <iostream>
#include <fstream>
using namespace std;
#define N 256
#define BYTE 8
#define PADDING 384
#define RANGE 128//从下标到char的转换参数
int root;//根节点下标
char lacknum;//补位数目
//树的节点数是
int HT[2 * N - 1];
struct node
{
int lchild;
int rchild;
};
//新树的节点,每个节点包含 lchild rchild 字段
struct node newTree[2 * N - 1];
//解压时,在内存中恢复一颗哈夫曼树
//此时,哈夫曼树的节点只需要保存 lchild rchild即可
void rebuildTree(ifstream& ifs)
{
char r;
//获取补位数量
ifs.get(lacknum);
//获取根节点下标
ifs.get(r);
root = r + PADDING;//恢复到正常下标
//初始化所有的新树节点
for (int i = 0; i < 2 * N - 1; i++)
{
newTree[i].lchild = -1;
newTree[i].rchild = -1;
}
//获取原树
for (int i = 0; i < 2 * N - 1; i++)
{
char c;
ifs.get(c);
if (c == 0x7F)//0X7F代表特殊的符号,即不存在父节点的节点
{
HT[i] = -1;
}
else
{
HT[i] = c + PADDING;
}
}
//原树只有父节点的下标,新树里面是左右孩子的下标
for (int i = 0; i < N * 2 - 1; i++)
{
if (HT[i] != -1)
{
//若父节点尚未产生左孩子,则下标小的就默认是左孩子
if (newTree[HT[i]].lchild == -1)
{
newTree[HT[i]].lchild = i;
}
else if (newTree[HT[i]].rchild == -1)
{
newTree[HT[i]].rchild = i;
}
}
}
}
//根据哈夫曼树解析哈夫曼编码,从根开始到叶子节点就转成对应字符写入文件即可
//解压缩
void decompress()
{
ifstream ifs;
ofstream ofs;
string input, output;
cout << "请选择待解压缩文件(输入不带空格的完整路径):" ;
cin >> input;
cout << "请输入它解压缩后的文件名:";
cin >> output;
ifs.open(input, ios_base::binary);
if (!ifs)
{
exit(1);
}
ofs.open(output, ios_base::binary);
if (!ofs)
{
ifs.close();
exit(1);
}
//恢复哈夫曼树
rebuildTree(ifs);
//创建解压缩文件
int currNode;//在深度遍历时,当前节点的下标
int loc_start = 513;//编码开始的位置 前面有 2+511 个元素,此时下标为 513
int bitCount = 0;//为凑齐一个字节的bit数,范围是 0 - 7
int len;//编码后的文件的字节数
char readByte = 0;//当前获取的字节
int bit;//当前获取的比特 要么是 0x80 要么是 0x00
int numBits = 0;//遍历计数器
ifs.seekg(ios_base::beg);
ifs.seekg(0, ios_base::end);
//tellg如何计算文件大小,获取文件字节数:https://blog.csdn.net/xdz78/article/details/72637635
//tellg 返回 streampos 类型,需要强制转换
len = (int)ifs.tellg() - loc_start;
//跳转到编码开始的位置 loc_start
//seekg如何跳转到文件特定位置:http://c.biancheng.net/view/1541.html
ifs.seekg(loc_start, ios_base::beg);
//循环读取编码,直到遍历计数器到达 编码bit数 - lacknum 为止
numBits = 0;
for (int i = 0; i < len * BYTE - lacknum; )
{
//从根节点,按照编码的规则往下遍历
//0往左,1往右,直到叶子节点为止,输出叶子节点对应的字符
currNode = root;
while (newTree[currNode].lchild != -1)
{
if (bitCount % BYTE == 0)
{
//每次读取一个字节
ifs.get(readByte);
bitCount = 0;
}
//位运算,获取当前比特位,即byte字节的第一位
bit = readByte & 0x80;
if (bit == 0)
{
currNode = newTree[currNode].lchild;
}
else
{
currNode = newTree[currNode].rchild;
}
//字节左移一位,以便下一次获取1bit
readByte = readByte << 1;
bitCount++;
i++;
}
ofs.put(currNode - RANGE);
}
//关闭所有文件
ofs.close();
ifs.close();
}
int main()
{
decompress();
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步