哈夫曼压缩

前言

这题是大四时,学弟学妹们问我的题目,哈夫曼压缩,也是我当年没有做好的题目,现在是来还债的。

哈夫曼压缩

哈夫曼压缩的本质是“非定长编码”,区别于 ASCII 码这样的定长编码
英文里哈夫曼编码也叫做 lossless data compression algorithm,即最小损失压缩算法

哈夫曼编码的特点

  1. 尽量使得出现频次高的符号,编码更短
  2. 任意两个符号的前缀码不重复

思路

核心思路

任何文件都是01的组合
01的组合,如果8位一组,就可以按字节将源文件读取到char型数据结构中
我们知道 char 型一共有 256 个字符
我们就可以将这256个字符,看作是哈夫曼树的叶子节点

压缩

对源文件8位一组的,按字节读取到char型数据结构中,进行词频统计
根据统计得到的词频,建立哈夫曼树,并将每个读取的字符都替换成对应的哈夫曼编码,写入到压缩文件中

解压缩

解压时,先恢复一颗哈夫曼树,再从压缩文件中读取哈夫曼编码,将其恢复成对应的字符,写入到解压缩文件中。

牢记一个原则:

无论是什么文件,无论以何种方式读取,其本质都是01的组合。

涉及的数据结构

词频统计(采用大小位256的数组存储,最多是256个字符)
哈夫曼树(采用数组作为存储结构,每个元素都是一个struct)
待压缩文件
压缩文件
解压缩后文件

步骤

压缩

  1. 读取待压缩文件内容,进行词频统计
  2. 建树
  3. 将建好的树写入压缩文件中
  4. 编码
  5. 将编码写入压缩文件中
  6. 关闭文件,结束

解压缩

  1. 读取压缩文件内容,恢复一颗哈夫曼树
  2. 读取编码,搜索哈夫曼树
  3. 搜索到叶子节点,则输出对应的字符到解压缩文件
  4. 直到读取完成压缩文件,关闭所有文件,结束

完整代码

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();
}

posted @ 2022-06-21 00:41  lucky_doog  阅读(557)  评论(3编辑  收藏  举报