本博客由Rcchio原创
我了解到很多压缩文件的程序是基于哈夫曼编码来实现的,所以产生了自己用哈夫曼编码写一个压缩软件的想法,经过查阅资料和自己的思考,我用c++语言写出了该程序,并通过这篇文章来记录一下自己写该程序学到的东西。因为本人写的程序在压缩率上,还有提升的空间,所以本文将不定期更新,但程序整体的思路不会有较大的改动。
一、基于哈夫曼编码可实现压缩文件的原理分析
在计算机中,数据的存储都是二进制的,并且以字节作为基本的存储单位,像英文字母在文本中占一个字节,汉字占两个字节,我们把这种每一个字符都通过相同的字节数来表达的编码形式称为定长编码。哈夫曼编码是一种不定长编码方式,即每个字符的编码的长度可以相等也可以不相等。在哈夫曼编码中,出现的频率越高的字符,其哈夫曼编码的长度就越短,相反,出现频率低的字符,哈夫曼编码的长度就越长,因此字符的哈夫曼编码的平均长度是最小的,这就是基于哈夫曼编码可实现文件压缩的基本原理。
二、程序的功能描述
该程序可以用来压缩任何格式的文件生成压缩文件,也可以解压任何由本程序生成的压缩格式的文件。
三、完成该程序所需要的知识
1、哈夫曼树,哈夫曼编码
2、c++的文件操作
3、c++的位操作
四、压缩解压文件的过程详解
众所周知,压缩文件的目的是使该文件在不需要使用时占用的存储空间减小。当使用到该文件时,将压缩文件解压为原文件。因此该程序应具有压缩和解压这两个基本功能。
1、压缩过程:
(1)对原文件中出现的所有字符进行哈夫曼编码。
为了实现对各类型文件的压缩,我们要以二进制的方式而不是文本文件的方式打开文件。文件中数据的存储以字节为单位。一个字节有八位,因此该字节中0和1的排列方式共有2的八次方——256种,每一种排列方式都有一种字符与之对应,即一个字节最多可以表示256类字符,也就是说,原文件中字符的种类数最多只有256种 。将原文件以二进制的方式打开以后,统计原文件中字符的种类及每种字符出现的频度(次数)。将每一种字符作为一个叶节点,字符的频度作为叶节点的权值,就可以构造哈夫曼树,对原文件中出现的所有字符进行哈夫曼编码。将字符和字符对应的哈夫曼编码保存在一个结构体数组中(暂且将它称之为编码表),为下面的向压缩文件输入哈夫曼编码做准备。
如果对于二进制文件和文本文件的概念和区别不了解,可以点击这里
如果对怎样构造哈夫曼树生成哈夫曼编码不清楚可以参考该ppt:点击这里
这里我讲一下如何统计原文件中字符的种类及频度:可以定义一个unsigned char 的变量x,该变量占用一个字节,用变量x依次读取原文件中的字符。定义一个大小为256的结构体数组a,结构体有两个成员unsigned char name和unsigned int weight, name用来存储字符,weight用来存储字符对应的频度。以字符的值为数组下标,例如变量x读取到字符\'b\',此时x的值为\'b\',\'b\'的ascall码为98,因此a[x].weight++就相当于a[98].weight++。x读取一个字符,就执行a[x].name=x;a[x].weight++。a[x].weight的值记录了字符x已经出现的频率,当原文件的所有字符被读取过一遍以后,字符的种类及对应的频率也就统计完毕。
当然,原文件中有可能256种字符没有完全出现,构造哈夫曼树时只需要将权值(频度)不为0的字符作为叶节点就可以了。构造好哈夫曼树以后将结构体数组占用的内存释放掉。
(2)向压缩文件中输入解压必需的信息
这就需要考虑如何由压缩文件还原为原文件。整个还原的思路是这样的:将压缩文件的结构分为三部分。第一部分,存储原文件中字符的种类数。第二部分,存储原文件中的字符及对应的频度。第三部分,按照原文件中字符出现的顺序依次输入字符对应的哈夫曼编码。解压时,通过读取压缩文件的第一部分和第二部分的内容,得到原文件中字符的种类和频度,可以重建哈夫曼树。然后读取原文件中的字符对应的哈夫曼编码(具体的实现过程在下面的五-4详述了)。根据字符对应的哈夫曼编码(若干个0-1序列),从重建的哈夫曼树的根节点出发向叶结点的方向移动,0代表向节点的左孩子移动,1代表向节点的右孩子移动。当移动到哈夫曼树的叶节点时,我们就找到了该字符,然后将它输入到解压以后的文件中去。然后再根据下一个字符对应的哈夫曼编码重复上述操作,直到原文件所有的字符都已经严格按顺序输入到解压的文件中去。
因此,我们要向压缩文件中输入三部分信息。第一部分就是原文件中字符的种类数,第二部分就是原文件中的出现的字符及对应的频度,第三部分就是严格根据原文件中字符的顺序输入字符对应的哈夫曼编码。
第一部分和第二部分的内容的输入是很简单的,这里便不再详述。向压缩文件中输入第三部分的内容时,需要再次用unsigned char 类型的变量将原文件中的字符依次读取一遍,每读取到一个字符,就在上面求得的编码表中找到其对应的哈夫曼编码,然后将该字符的哈夫曼编码存到一个string类型的变量unit中,若unit中0和1的个数大于等于8时,就将unit中的前八位0-1序列通过位操作输入到一个unsigned char变量中(具体的实现过程在下面的五-3详述了),再将该变量输入到压缩文件中。这里需要注意,因为哈夫曼编码的0和1都是用字符保存的,如果直接存入压缩文件中,这样会使得压缩文件的存储空间比原文件的还要大,这就违背的压缩的含义。因此,需要通过通过上面的方式来存储哈夫曼编码。
2、解压过程
整个解压的思路已经在上面提到过了。下面我提一个细节问题。
根据某个字符的哈夫曼编码序列从重构的哈夫曼树的根节点出发向叶节点移动的过程中,如何判断已经到达叶节点?是这样的,在构造哈夫曼树 时,我们使用一个结构体数组来保存叶节点和其他双亲结点的,对于有n个叶节点的哈夫曼树,需要大小为2n-1的结构体数组来保存叶节点和其他结点。其中a[0]—a[n-1]都是存放叶节点,a[n]-a[2n-2]存放的是其他节点。所以当从哈夫曼树的根节点出发向叶节点移动时,当某节点的下标在范围0-n-1中时,就说明该结点已经是叶节点了。
五、一些需要注意的细节问题在编码时可能需要注意或者需要参考,我将自己想到的情况举例出来并通过代码来增强博客的可读性(给出的代码只是为了说明某一个知识,并不严格的遵从c++的编程风格,请读者注意)。
1、向压缩文件输入字符种类数,字符及对应的频度的细节问题
向压缩文件中输入字符的种类数,字符及对应的频度时,建议用ofstream类的write函数操作。相对应的,读取压缩文件的前两部分信息,即原文件的字符种类数,字符及其频度时,用ifsream类的read函数操作。而不是通过文件的流来实现。我举一个例子,假设压缩文件的名字为"1.txt",保存字符种类数的变量为H_number。
ofstream out("1.txt");
int H_number=34;
unsigned char c=\'5\';
out<<H_number<<c;
ifstream in("1.txt");in>>H_number;
按我们希望的,H_number的值应为34,但通过流读取文件,H_number的值为345,这样一来,压缩文件就不能得到正确还原了。
如果代码改成这样就能避免上述问题:
ofstream out("1.txt");
int H_number=34;
unsigned char c=\'5\';
out.write((char*)&H_number,sizeof(int));
out.write((char*)&c,sizeof(char)); ifstream in("1.txt");in.read((char*)&H_number,sizeof(int));
2、c++中如何判断文件结束
在c++中,判断文件的结束是用ifstream类的成员函数eof()。用一个unsigned char x变量读取文件的字符,当没有读到文件的结尾时,eof()函数的值为false,当读取文件的最后一个字符时,由于eof()并不知道这是文件的最后一个字符,所以eof()的值仍为false。此时如果继续读取文件中的字符,由于到达了文件的结尾,没有新的字符可以读取,eof()函数的值变为true,而变量x中保存的仍然是文件的最后一个字符。因此如果在统计原文件的字符种类及频度时,代码这样写:
ifstream in("file.txt");
while(in.eof()) { x=in.get(); a[x].name=x; a[x].weight++; }
那么最后一个字符的频度就被多统计了一次,这显然错误的读取了原文件的信息,那么解压出来的文件的内容也会和原文件有出入。
该问题可以这样解决:
ifstream in("file.txt");while(true) { x=in.get(); if(in.eof()) break; a[x].name=x; a[x].weight++; }
先读取字符再判断是否到达文件结尾,就避免了将最后一个字符的频度多统计依次。
3、如何将8位的0-1序列通过位操作存入到一个字节的unsigned char变量中
假设有一个string类型的变量unit,存储了8位的0-1序列,现将该序列通过|操作符存入unsigned char变量x中
for(int i=0;i<8;i++) { x=x<<1; if(unit[i]==1) x=x|1; }
经过上述代码,变量x便存储了8位0-1序列。
4、如何读取占一个字节的字符对应的二进制比特流(0-1)序列
假设用变量unsigned char x读取了压缩文件中的第三部分的某个字符,现在要获取该字符载有的比特流(8位0-1序列),并保存在一个string 类型的变量s中
for(int i=0;i<8;++i) { if(x&128) { s[i]=1; } else { s[i]=0; } x=x<<1; }
5、解压文件时如何判断压缩文件的第三部分—哈夫曼编码 已经读完了?
可以通过计算原文件中的字符的频度之和,即所有字符的总个数,作为原文件的长度(假设用int length来保存)。解压文件时每次向解压文件中输出一个解压出的字符,就将length--。当length为0时,表示解压结束。
六、代码实现
下面我给出程序主类的声明,有助于理解程序的整体结构。
class Hnode //哈夫曼树的存储结构 { public: unsigned char name; //8位的存储单元来存储字符(256种) unsigned weight; //存储字符的频度 int p; //双亲节点 int lchild; //左孩子 int rchild; //右孩子 int Hnodeindex; //节点索引 Hnode() //初始化数据成员 { name = \'\0\'; weight = 0; p = 0; Hnodeindex = 0; lchild = rchild = 0; } class Huffmancode_node //字符的哈夫曼编码的存储结构 { public: unsigned char name; //字符的名称 vector<int> code; //用vector容器存储哈夫曼编码 Huffmancode_node() { name = \'\0\'; } };
//主类的声明 class ComAndEx //压缩解压类的声明 { public: void Compress(); //压缩文件的函数 void Extract(); //解压文件的函数 string ScanCharacter();//统计源文件种字符的种类及个数的函数 void CreateHuffmanTree();//建立哈夫曼树的函数 void CreateHuffmanCode();//生成哈夫曼编码的函数 protected: vector<Hnode> HuffmanTree; //存储哈夫曼树的数组 vector<Huffmancode_node>Huffmancode; //存储哈夫曼编码的数组 int H_number; //字符的种类数 };
这里讲一下为什么将构造哈夫曼树和生成哈夫曼编码分成两个函数实现。因为在压缩过程中需要构造哈夫曼树,生成原文件中出现的字符的哈夫曼编码,而在解压过程中只需要重建哈夫曼树,不需要编码。因此将这两个过程分为两个函数实 现。
由于程序是在哈夫曼编码的基础上实现的,是学习哈夫曼编码的一个很好的实践机会。所以希望不懂哈夫曼编码的同学查阅相关的资料或下载我前面给的有关哈夫曼编码的ppt来自己学习一下,争取会手动实现这一部分的代码。其他部分的代码如有关文件操作或位操作的,大家可以有选择的借鉴一下。如有高见也请大家不吝指教。
七、程序的局限
上面所实现的程序,虽然能够压缩任何格式的文件,如.txt文件,.docx文件,.ppt文件,.jpg文件,.exe文件等,但是在压缩效率上,往往是.txt文件最高,其他类型的文件压缩效果不明显,甚至原文件和压缩文件的大小比例是1:1的情况也时常见到。究其原因,原文件中字符的种类数越多,哈夫曼树的叶节点个数越多,整个哈夫曼树的深度也越大,哈夫曼编码的平均长度也越长。一些文件中字符的种类数接近或达到256,哈夫曼编码的平均长度接近定长码的长度,故压缩效果不明显。对于在此基础上如何提高文件的压缩率,若大家有高见请不吝赐教。我也将在此基础上继续寻找提高文件压缩率的方法。