欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

基于Huffman算法和LZ77算法的文件压缩(二)

程序员文章站 2022-07-14 22:05:23
...

基于Huffman算法和LZ77算法的文件压缩(二)

根据前面基于Huffman算法和LZ77算法的文件压缩(一)的铺垫,先来看基于Huffman思想的文件压缩的实现过程。

一、 利用huffman编码对源文件进行压缩

压缩的整个流程:

  1. 统计源文件中每个字符出现的次数
  2. 以字符出现的次数为权值创建huffman树
  3. 通过huffman树获取每个字符对应的huffman编码
  4. 读取源文件,对源文件中的每个字符使用获取的huffman编码进行改写,将改写结果写到压缩文件中, 直到文件结束,并写入相关标记信息
    标记信息
    源文件的后缀
    字符次数对的总行数
    字符以及字符出现次数(为简单期间,每个字符放置一行)
    压缩数据

二、构建Huffman树

接下来就按照项目的顺序一个环节一个环节的讲解

要执行1和2步骤,得有一个Huffman树,所以我们先封装一个Huffman树

整体框架:

加入_pParent指针是为了后序找父亲节点方便

Huffman节点的代码:


//定义Huffman节点
template<class T>
struct HuffmanTreeNode
{
  //如果T是内置类型,T()就等于0 ,如果T是自定义类型,T()就等于调用默认无参构造函数
	HuffmanTreeNode(const T& weight = T())
		: _pLeft(nullptr)//每个都有一个哈夫曼节点的左指针
		, _pRight(nullptr)//每个都有一个哈夫曼节点的右指针
		, _pParent(nullptr)//加入指向父亲节点的指针,方便遍历
		, _Weight(weight)//每个节点的权值信息,如每个字符出现的次数
	{}

	HuffmanTreeNode<T>* _pLeft;
	HuffmanTreeNode<T>* _pRight;
	HuffmanTreeNode<T>* _pParent;
	T _Weight;  //权值
};

//封装Huffman树,并实现相关函数
template<class T>
class HuffmanTree
{
	typedef HuffmanTreeNode<T> Node;

public:
	HuffmanTree()
		: _pRoot(nullptr)
	{}

	HuffmanTree(const std::vector<T> vWeight, const T& invalid_weight)
		: _pRoot(nullptr)
	{
		CreateHuffmanTree(vWeight, invalid_weight);
	}

	~HuffmanTree()
	{
		Destory(_pRoot);
	}

	
private:
  //哈夫曼树的根节点
	Node* _pRoot;
};

有了Huffman节点和Huffman的基本框架,现在就开始构建Huffman

  • 因为在构造Huffman树时。每次选取最小的两个权值信息,把这两个权值pop掉
  • 并把这两个权值信息之和放回数组中,如何能够快速找到两个最小 的权值
  • 并能够在插入新元素的情况下也可以保证快速找到最小的两个权值信息
  • 这里通过排序选择两个最小的权值信息肯定是可以实现的,但是效率比较低
  • 所以此处采用priority_queue
  • 不知道大家是否记得priority_queue默认创建的是大堆还是小堆??大堆。
  • 所以要自己提供一个比较函数
//自定义比较方式
template<class T>
class Less
{
	typedef HuffmanTreeNode<T> Node;

public:
	bool operator()(const Node* pLeft, const Node* pRight)
	{
		return pLeft->_Weight > pRight->_Weight;  //根据大于的方式构造出小根堆
	}
};

创建Huffman树完整代码:

//vWeight代表权值信息,即用来构建Huffman树的,如{1,2,3,4,5} 
//invalid_weight代表的信息后面解释
void CreateHuffmanT ree(const std::vector<T> vWeight, const T& invalid_weight)
	{
		//1、构造森林 
		
		//此处采用priority_queue
		std::priority_queue<Node*, std::vector<Node*>, Less<T>> queue;

		//用来过滤,如果是给的无效字符,就直接判断下一个
		for (auto e : vWeight)
		{
			if (e == invalid_weight)
			{
        //无效的字符,即未出现的字符,_count == 0
				continue;
			}
			queue.push(new Node(e));
		}

		//2、将森林中的树不断合并,构造haffman树
		while (queue.size() > 1)
		{
      //因为上面构造的是小堆,所以每次取都是取的最小的权值
			Node* pLeft = queue.top();
			queue.pop();

			Node* pRight = queue.top();
			queue.pop();

      //根据哈夫曼的构造原理,创建出来的新节点是选出来的最小的两个权值之和
			Node* pParent = new Node(pLeft->_Weight + pRight->_Weight);

      //更新相关指针的指向
			pParent->_pLeft = pLeft;
			pParent->_pRight = pRight;
			pLeft->_pParent = pParent;
			pRight->_pParent = pParent;

      //最后把创建出来的新节点放到优先级队列中继续构造哈夫曼树
			queue.push(pParent);
		}

    //当queue中只剩下一个元素时,返回构造完成的哈夫曼树的根节点
		_pRoot = queue.top();
	}

Huffman树创建完毕,就得有一个销毁函数 ,毕竟是new出来的!!!

注意:

  • 但是销毁也要注意,按后序遍历的方式销毁,要不然就找不到自己的孩子了
  • 如果Destory函数直接传Node*为参数,会有什么问题????此处要注意如果你传递Node*,那么在函数内部确实改变了,但是无法带到函数外面去,相当于外部Node*还是没有销毁,只是拷贝过来的临时拷贝Node *成功释放(不明白想想Swap函数),此处要么传二级指针,要么传一级指针的引用

销毁Huffman树的代码:

void Destory(Node*& pRoot)//注意注意
	{
		if (pRoot)
		{
			Destory(pRoot->_pLeft);
			Destory(pRoot->_pRight);
			delete pRoot;
			pRoot = nullptr;
		}
	}

到此Huffman树已经创建完毕,那么就 可以开始基于Huffman的文件压缩 !!

三、统计字符的出现次数

首先看一下整体框架:

每个函数的作用后面会详细解释!!!莫及。

//基于huffman的压缩
#pragma once

#include <assert.h>
#include <vector>
#include <string>
#include "HuffManTree.hpp"


//将权值用结构体表示,所以需要在huffman中的操作符进行重载
//存储字符信息的struct
struct CharInfo;

//压缩文件的类
class FileCompressHuffman
{
public:
	FileCompressHuffman();

  //压缩文件,path代表压缩文件的路径
	void CompressFile(const std::string& path);

  //解压缩,path代表待解压缩文件路径
	void UnCompressFile(const std::string& path);

private:

  //获得哈弗曼编码
	void GenerateHuffmanCode(HuffmanTreeNode<CharInfo>* pRoot);  //生成huffman编码

  //获取压缩后文件的头部信息 
	void WriteHead(FILE* fOut, const std::string& filePostFix);

  //获取文件的后缀
	std::string GetFilePostFix(const std::string& fileName);

  //在文件中读取一行
	void ReadLine(FILE* fIn, std::string& strInfo);

private:
	//存储字符信息的数组
	std::vector<CharInfo> _fileInfo;
};

前面说过Huffman压缩的第一步是统计每个字符出现的次数,那么用什么来保存每个字符出现的次数呢?所以定义一个保存字符信息的struct CharInfo;

那么这个struct CharInfo有什么注意的呢??

  • 首先Huffman树中在构建priority_queue时用到了自定义的比较方式Less、权值相加等,所以在CharInfo需要提供相关运算符的重载
  • _ch可不可以为char类型???答案是不可以。因为字符的范围是[0,255],而char范围是[0,127],如果为char类型,那么在进行压缩文件时就会出错

struct CharInfo
{
	unsigned char _ch;  //具体字符
	size_t _count;  //字符出现次数
	std::string _strCode;  //字符编码

	//构造函数
	CharInfo(size_t count = 0)
		: _count(count)
	{}

  	//相关操作符的重载
  	//因为在Huffman中,是以权值信息来构建Huffman树的
  	//在_fileInfo数组中存放的是每个字符的信息即CharInfo
  	//此处如果返回size_t即(_count + ch._count)来表示两个字符的权值和,
  	//那么就会报错,因为前后类型不符,HuffmanTree<CharInfo>中
  	//的每个节点是CharInfo,所以此处需要返回
  	//CharInfo(_count + ch._count),每次用其中的count即可
	CharInfo operator+(const CharInfo& ch) const
	{
		return CharInfo(_count + ch._count);
	}

	bool operator>(const CharInfo& ch) const
	{
		return _count > ch._count;
	}

	bool operator==(const CharInfo& ch) const
	{
		return _count == ch._count;
	}
};

现在保存字符信息的结构有了,那么就可以初始化字符信息数组std::vector<CharInfo> _fileInfo

FileCompressHuffman::FileCompressHuffman()
{
  //给自己的成员初始化
  //因为字符种类不可能超过256,直接resize(256)即可
	_fileInfo.resize(256);
	for (int i = 0; i < 256; i++)
	{
		_fileInfo[i]._ch = i;
		_fileInfo[i]._count = 0;
	}
}

在进行统计文件中字符出现的次数时的注意事项:

  • 待压缩文件的打开方式是以二进制可读"rb"的方式打开
  • _fileInfo[pReadBuff[i]]._count++的理解:pReadBuff[i]代表从文件中读取到某个的字符,_fileInfo是用来存储文件当中字符信息的(字符出现次数、编码),_fileInfo[pReadBuff[i]]代表以pReadBuff[i]这个字符的ASCII码为下标把其对应位置的出现次数++即可
  • 注意存放读取文件数据的数组pReadBuff也必须是unsigned char* 类型,否则在处理汉字上就会出错
    基于Huffman算法和LZ77算法的文件压缩(二)
//注意这里的打开方式为 "b"代表以二进制的方式打开
	FILE* fIn = fopen(path.c_str(), "rb");
	if (nullptr == fIn)
	{
		//assert(false);
    perror("open file is error!\n");
		return;
	}

	//1、统计源文件中每个字符出现的次数,方便作为构建哈夫曼树的权值
  //注意这里用的是unsigned char 类型,是因为如果这里不是unsigned char类型,那么在压缩文件中有汉字
  //的时候就会崩溃
	unsigned char* pReadBuff = new unsigned char[1024];
	size_t readSize = 0;//记录每次读到的字节数
	while (true)
	{
		readSize = fread(pReadBuff, 1, 1024, fIn);
		if (0 == readSize)//如果读到的字节数为0,说明文件指针已经走到文件的末尾,可以结束读取文件的操作了
		{
			break;
		}

    //每次对从文件中读取到的内容进行记录,保存相应字符出现的次数
		for (size_t i = 0; i < readSize; i++)
		{
			//pReadBuff[i]代表从文件中读取到某个的字符
			//_fileInfo是用来存储文件当中字符信息的(字符出现次数、编码)
			//_fileInfo[pReadBuff[i]]代表以pReadBuff[i]这个字符的ASCII码为
			//下标把其对应位置的出现次数++即可 
			_fileInfo[pReadBuff[i]]._count++;
		}
	}

到这里每个字符的出现次数已经求出来了,并且都保存在_fileInfo数组中。

四、根据字符信息来构建Huffman树

接下来就可以开始第二步以字符出现的次数来构建Huffman树

注意:

  • 因为CharInfo是自定义类型的结构,在构建Huffman树时要用到++、比较等操作,需要进行相关运算符重载(前面已经实现 )
  • 因为构建Huffman树时,我们传的是_fileInfo(文件中字符出现的信息),而_fileInfo中可不止只文件中出现的字符,还有出现0次的字符,所以此处就需要过滤掉出现0次的字符,避免影响Huffman树的构造,这里就圆了前面的坑,const T& invalid_weight就是用来过滤的
  • 在创建Huffman树是传递的是CharInfo()就代表调用CharInfo的构造函数,count的缺省值为0,就刚好可以过滤掉
HuffmanTree<CharInfo> Tree(_fileInfo, CharInfo());  //出现0次的无效的字符将不会参与huffman树的构造

四、根据Huffman树来获取Huffman编码

接下来就可以开始第三步获取每个字符Huffman编码了

有了Huffman树,就可以知道每个字符的Huffman编码。

  • 规定Huffman树往左走为0,往右走为1;
  • 该函数拿到pRoot指针后,如何获取每个字符的Huffman编码,根据递归实现,递归到Huffman的叶子节点后,还是没有获取到Huffman编码,如果能够从下往上遍历pRoot这颗Huffman树,那么叶子节点的父亲节点肯定知道该叶子节点是左孩子还是右孩子,就可以获取到Huffman编码了(左补0,右补1),所以这就是Huffman树加pParent指针的原因(其他方式也可以,这种加pParent的方式方便)。
  • 注意,因为是从叶子节点往根节点遍历的,那么拿到的Huffman编码肯定是反的,所以需要reverse
//生成字符的编码----注意是从叶子节点往根节点获取的
void FileCompressHuffman::GenerateHuffmanCode(HuffmanTreeNode<CharInfo>* pRoot)  //生成huffman编码
{
	if (nullptr == pRoot)
	{
		return;
	}

	GenerateHuffmanCode(pRoot->_pLeft);
	GenerateHuffmanCode(pRoot->_pRight);

	if (nullptr == pRoot->_pLeft && nullptr == pRoot->_pRight)
	{
        //叶子节点,要编码的字符
		std::string strCode;
		HuffmanTreeNode<CharInfo>* pCur = pRoot;
		HuffmanTreeNode<CharInfo>* pParent = pCur->_pParent;
 
    //根据前面的规定,右子树为1; 左子树为0
		while (pParent)
		{
			if (pCur == pParent->_pLeft)
			{
				strCode += '0';
			}
			else
			{ 
				strCode += '1';
			}
			pCur = pParent;
			pParent = pCur->_pParent;
		}

    //因为是从叶子节点往根节点获取的,所以编码是反的,需要进行反转
		reverse(strCode.begin(), strCode.end());

		//到这里就代表获取到一个字符的Huffman编码,就把它保存到字符信息中即可
		//_fileInfo[pRoot->_Weight._ch]._strCode:
		//Huffman的每个节点的权值信息都是CharInfo,
		//_fileInfo[pRoot->_Weight._ch]以当前节点的字符的ASCII码为下标,
		//把_fileInfo中对应位置的Huffman编码置为strCode即可
		_fileInfo[pRoot->_Weight._ch]._strCode = strCode;
	}
}

五、根据Huffman编码来改写源文件

到这里字符编码已经成功获取,那么就可以 开始第四步根据字符编码来改写源文件写入标记信息

注意:

  • 在前面步骤中,我们统计源文件的每个字符的出现次数,那么fIn文件指针就已经走到文件末尾,此时在此遍历源文件的每个字符来改写源文件,需要把文件指针移动到文件开始处fseek(fIn, 0, SEEK_SET)

  • 我们采用bitcount来记录ch中使用过的bite位,如果bitcount等于8,就代表ch这一个字节已经全部使用完,就可以把ch往文件中写了,但是如果最后一次bitcount不够8位,就需要另外判断!!!

//4、用获取到的编码重新改写源文件
	FILE* fOut = fopen("2.txt", "wb");
	if (nullptr == fOut)
	{
		//assert(false);
         perror("open file is error\n");
		return;
	}

  //写入头部信息,方便解压缩
	WriteHead(fOut, path);           //1111111111

  //注意,fIn文件指针在上面的读取结束后,已经走到文件的结尾,所以这里要把文件指针移到开始的位置
	fseek(fIn, 0, SEEK_SET);

	char ch = 0;//每次往ch中写
	int bitCount = 0;//记录已经读取到的字节数
	while (true)
	{
		readSize = fread(pReadBuff, 1, 1024, fIn);
		if (0 == readSize)
		{
			//没有读取到数据,直接break即可 
			break;
		}
		//根据字节的编码对读取到的内容进行重写
		for (size_t i = 0; i < readSize; i++)
		{
			std::string strCode = _fileInfo[pReadBuff[i]]._strCode;
			for (size_t j = 0; j < strCode.size(); j++)
			{
        //如果是0,进不去if就直接左移,如果是1就把相应位置换为1
				ch <<= 1;
				if ('1' == strCode[j])
				{
					ch |= 1;
				}

				bitCount++;

        //因为ch只能放8个字节,所以每次都要进行判断
				if (8 == bitCount)
				{
					fputc(ch, fOut);  //往文件中一次写入一个字节
					bitCount = 0;//清空
					ch = 0;
				}
			}
		}
	}

	//最后一次ch中可能不够8个bit位
	if (bitCount < 8)
	{
		ch <<= (8 - bitCount);
		fputc(ch, fOut);
	}

到这里一个简单的压缩方法基本完成,但是如果只把压缩数据保存到压缩文件当中是不够的,就相当于你拿到了 10111010101010(二进制)这样的一个整数,不知道具体的含义, 所以只有压缩数据就无法去解压缩,还需要写入一些标记信息方便解压缩

  • 标记信息怎么写呢??如果我们可以成功还原源文件的Huffman树,那么叶子节点就是每个字符,也就知道10111010101010(二进制)这样的一个整数中的1代表往Huffman树的右边走,0往左边走
  • 那么现在问题来了,如何还原Huffman树呢?这里就用到标记信息了。所以标记信息里至少还需要添加 [字符]:[字符出现的次数]这样的标记信息,方便在解压缩时用来构建Huffman树
  • 那么标记信息还需要写=些什么呢?我们可能会想到记录最后一个字节的信息,因为最后一个字节的8个bite位不一定用完,如果解压过了就不好了,但是这里是不必要的,因为我们Huffman树的根节点中的权值信息就是整个文件的大小,如果文件解压完了就不进行解压了,所以此处无需标记。
  • 前面刚刚说过要写[字符]:[字符出现的次数]这样的标记信息用来构建Huffman树,那么我们如何区分[字符]:[字符出现的次数]和压缩数据呢?我们肉眼肯定是可以区分,但是计算机不行,所以需要写入标记信息的行数即可(在前面已经铺垫过,为了方便标记信息一个一行),我们知道了标记信息的行数,那么往下读取标记信息的行数大小就是文件的[字符]:[字符出现的次数]标记信息,再下来就是压缩数据
  • 还有就是标记信息因该记录带压缩文件的后缀。如压缩的文件是.txt,那么我们在解压缩后的文件后缀也因该是.txt。

综上:
先封装一个获取文件后缀的函数:

//获得文件的后缀
std::string  FileCompressHuffman::GetFilePostFix(const std::string& fileName) 
{
	return fileName.substr(fileName.rfind('.'));
}
//写文件的头部信息,方便解压缩
void FileCompressHuffman::WriteHead(FILE* fOut, const std::string& fileName) {
	assert(fOut);
	std::string strHead;

  //首先是文件的后缀
	strHead += GetFilePostFix(fileName);
	strHead += '\n';

  //其次是文件的数据行数
	size_t lineCount = 0;//记录数据的行数

	std::string strChCount;//记录每行数据的内容

	char szValue[32] = { 0 };//记录每个字符出现的次数
	for (int i = 0; i < 256; ++i) 
  {
		CharInfo& charInfo = _fileInfo[i];
		if (charInfo._count) //判断是否为有效字符
    {
			lineCount++;
			strChCount += _fileInfo[i]._ch;
			strChCount += ':';
			//_itoa(charInfo._count, szValue, 10);
      sprintf(szValue,"%lu",charInfo._count);

			strChCount += szValue;
			strChCount += '\n';
		}
	}
	//_itoa(lineCount, szValue, 10);
  sprintf(szValue,"%lu",lineCount);

	strHead += szValue;
	strHead += '\n';
	strHead += strChCount;

	fwrite(strHead.c_str(), 1, strHead.size(), fOut);
}

到这里标记信息已经写入完毕!!!!整个压缩文件的函数就写完了。

压缩文件完整代码

//压缩文件,指明文件名
void FileCompressHuffman::CompressFile(const std::string& path)
{
  //注意这里的打开方式为 "b"代表以二进制的方式打开
	FILE* fIn = fopen(path.c_str(), "rb");
	if (nullptr == fIn)
	{
		//assert(false);
    perror("open file is error!\n");
		return;
	}

	//1、统计源文件中每个字符出现的次数,方便作为构建哈夫曼树的权值
  //注意这里用的是unsigned char 类型,是因为如果这里不是unsigned char类型,那么在压缩文件中有汉字
  //的时候就会崩溃
	unsigned char* pReadBuff = new unsigned char[1024];
	size_t readSize = 0;//记录每次读到的字节数
	while (true)
	{
		readSize = fread(pReadBuff, 1, 1024, fIn);
		if (0 == readSize)//如果读到的字节数为0,说明文件指针已经走到文件的末尾,可以结束读取文件的操作了
		{
			break;
		}

    //每次对从文件中读取到的内容进行记录,保存相应字符出现的次数
		for (size_t i = 0; i < readSize; i++)
		{
			_fileInfo[pReadBuff[i]]._count++;
		}
	}

	//2、以字符出现的次数为权值创建huffman树
	HuffmanTree<CharInfo> Tree(_fileInfo, CharInfo());  //出现0次的无效的字符将不会参与huffman树的构造

	//3、获取每个字符的编码
	GenerateHuffmanCode(Tree.GetRoot());

	//4、用获取到的编码重新改写源文件
	FILE* fOut = fopen("2.txt", "wb");
	if (nullptr == fOut)
	{
		//assert(false);
         perror("open file is error\n");
		return;
	}

  //写入头部信息,方便解压缩
	WriteHead(fOut, path);           //1111111111

  //注意,fIn文件指针在上面的读取结束后,已经走到文件的结尾,所以这里要把文件指针移到开始的位置
	fseek(fIn, 0, SEEK_SET);

	char ch = 0;//每次往ch中写
	int bitCount = 0;//记录已经读取到的字节数
	while (true)
	{
		readSize = fread(pReadBuff, 1, 1024, fIn);
		if (0 == readSize)
		{
			break;
		}
		//根据字节的编码对读取到的内容进行重写
		for (size_t i = 0; i < readSize; i++)
		{
			std::string strCode = _fileInfo[pReadBuff[i]]._strCode;
			for (size_t j = 0; j < strCode.size(); j++)
			{
        //如果是0,进不去if就直接左移,如果是1就把相应位置换为1
				ch <<= 1;
				if ('1' == strCode[j])
				{
					ch |= 1;
				}

				bitCount++;

        //因为ch只能放8个字节,所以每次都要进行判断
				if (8 == bitCount)
				{
					fputc(ch, fOut);  //往文件中一次写入一个字节
					bitCount = 0;//清空
					ch = 0;
				}
			}
		}
	}

	//最后一次ch中可能不够8个bit位
	if (bitCount < 8)
	{
		ch <<= (8 - bitCount);
		fputc(ch, fOut);
	}

	delete[] pReadBuff;
	fclose(fIn);
	fclose(fOut);
}
相关标签: C/C++