哈夫曼编码是一种基于二叉树生成的不等长编码,通过赋予高频次字符更短的编码来减小文件体积。本例中,将详细地讲解如何使用C++语言完成哈夫曼编码的压缩与解压,共包括以下六个部分:
目录
一、编码的读取与写入
1.字节流
在信息理论中,位(Bit)是最小的信息单位,而在计算机中,字节(Byte)是存储数据的基本单位,并且是硬件所能访问的最小单位。
内存里面存放的全是二进制代码。内存里面有很多“小格子”,每个“格子”中只能存放一个 0 或 1。一个“小格子”就是一位,所以“位”要么是 0,要么是 1,不可能有比位更小的单位。那么字节和位是什么关系呢?8 个“小格子”就是一字节,即一字节等于 8 位。
那么为什么硬件所能访问的最小单位是字节,而不是位呢?因为硬件是通过地址总线访问内存的,而地址是以字节为单位进行分配的,所以地址总线只能精确到字节。因此在二进制文件的读取中,只能以字节为单位进行读取和写入。
2.二进制文件的读取与写入
在C语言中,文件流有两种模式,分别是文本模式和二进制模式。
用文本方式存储信息不但浪费空间,而且不便于检索。例如,一个学籍管理程序需要记录所有学生的学号、姓名、年龄信息,并且能够按照姓名查找学生的信息。程序中可以用一个类来表示学生:
class CStudent
{
char szName[20]; //假设学生姓名不超过19个字符,以 '\0' 结尾
char szId[l0]; //假设学号为9位,以 '\0' 结尾
int age; //年龄
};
如果用文本方式将学生对象的三个信息依次存入硬盘中,那么在读取的时候,则其中的数据都经过二次编码,需要更多的空间和时间。
而如果使用二进制方式直接存储学生信息,即相当于把CStudent对象的内存信息直接存入文件,在该文件中,每个学生的信息都占用 sizeof(CStudent) 个字节。占用空间更少,最终读取时也可以整条对象直接读入。
读写二进制文件不能使用类似于 cin、cout 从流中读写数据的方法。这时可以调用 ifstream 类和 fstream 类的 read 成员函数从文件中读取数据,调用 ofstream 和 fstream 的 write 成员函数向文件中写入数据。
用 ostream::write 成员函数写文件
ofstream 和 fstream 的 write 成员函数实际上继承自 ostream 类,原型如下:
ostream & write(char* buffer, int count);
该成员函数将内存中 buffer 所指向的 count 个字节的内容写入文件,返回值是对函数所作用的对象的引用,如 obj.write(…) 的返回值就是对 obj 的引用。
write 成员函数向文件中写入若干字节,可是调用 write 函数时并没有指定这若干字节要写入文件中的什么位置。那么,write 函数在执行过程中到底把这若干字节写到哪里呢?答案是从文件写指针指向的位置开始写入。
文件写指针是 ofstream 或 fstream 对象内部维护的一个变量。文件刚打开时,文件写指针指向文件的开头(如果以 ios::app 方式打开,则指向文件末尾),用 write 函数写入 n 个字节,写指针指向的位置就向后移动 n 个字节。
下面的程序从键盘输入几名学生的姓名和年龄(输入时,在单独的一行中按 Ctrl+Z 键再按回车键以结束输入。假设学生姓名中都没有空格),并以二进制文件形式存储,成为一个学生记录文件 students.dat。
#include <iostream>
#include <fstream>
using namespace std;
class CStudent
{
public:
char szName[20];
int age;
};
int main()
{
CStudent s;
ofstream outFile("students.dat", ios::out | ios::binary);
//指定文件的打开模式是 ios::out|ios::binary,即以二进制写模式打开。在 Windows平台中,用二进制模式打开是必要的,否则可能出错。
while (cin >> s.szName >> s.age)
outFile.write((char*)&s, sizeof(s));
//将 s 对象写入文件。s 的地址就是要写入文件的内存缓冲区的地址。但是 &s 不是 char * 类型,因此要进行强制类型转换。
outFile.close();
//文件使用完毕一定要关闭,否则程序结束后文件的内容可能不完整。
return 0;
}
输入:
Tom 60↙
Jack 80↙
Jane 40↙
^Z↙
则形成的 students.dat 总大小为 72 字节,用“记事本”程序打开呈现乱码:
Tom烫烫烫烫烫烫烫烫 Jack烫烫烫烫烫烫烫? Jane烫烫烫烫烫烫烫?
用 istream::read 成员函数读文件
ifstream 和 fstream 的 read 成员函数实际上继承自 istream 类,原型如下:
istream & read(char* buffer, int count);
该函数从文件中读取count个字节的内容,并存放到buffer指向的缓冲区空间中。返回值是对函数所作用的istream对象的引用。
如果想知道一共成功读取了多少个字节(读到文件尾时,未必能读取 count 个字节),可以在 read 函数执行后立即调用文件流对象的 gcount 成员函数,其返回值就是最近一次 read 函数执行时成功读取的字节数。gcount 是 istream 类的成员函数,原型如下:
int gcount();
read 成员函数从文件读指针指向的位置开始读取若干字节。文件读指针是 ifstream 或 fstream 对象内部维护的一个变量。文件刚打开时,文件读指针指向文件的开头(如果以ios::app 方式打开,则指向文件末尾),用 read 函数读取 n 个字节,读指针指向的位置就向后移动 n 个字节。因此,打开一个文件后连续调用 read 函数,就能将整个文件的内容读取出来。
下面的程序将前面创建的学生记录文件 students.dat 的内容读出并显示。
#include <iostream>
#include <fstream>
using namespace std;
class CStudent
{
public:
char szName[20];
int age;
};
int main()
{
CStudent s;
ifstream inFile("students.dat",ios::in|ios::binary); //二进制读方式打开
if(!inFile) {
cout << "error" <<endl;
return 0;
}
//判断文件是否已经读完的方法和 while(cin>>n) 类似,归根到底都是因为 istream 类重载了 bool 强制类型转换运算符。
while(inFile.read((char *)&s, sizeof(s))) { //一直读到文件结束
int readedBytes = inFile.gcount(); //看刚才读了多少字节
cout << s.szName << " " << s.age << endl;
}
inFile.close();
return 0;
}
程序的输出结果是:
Tom 60
Jack 80
Jane 40
3.位运算
前面已经提到,由于在计算机中,数据是以字节的方式进行存储的,因此我们选择对原始文件的每字节(即8位,0-255)数据进行编码,编码完毕后会生成不等长编码,为了能将哈夫曼编码写入I/O流中,我们需要进行更加精细的位操作,以生成编码后的字节,因此需要涉及到位运算的知识。
这里给出在本例中需要用到的位运算符号,关于位运算的具体使用,不清楚的小伙伴可以参考Matrix67的《位运算简介及实用技巧博文》。
本例中用到的位运算符:
& 按位与 | 按位或
<< 位左移 >> 位右移
基于上述的前置知识,我们可以写出下述文件操作的代码,下面这段代码将 text.txt 文件中的信息按字节读取到fileData中,接下来将谈谈如何使用哈夫曼编码对文件进行压缩与解压。
char fileName[] = "test.txt";
int amountOfBytes = 0;
unsigned char c = '\0';
//To count the number of bytes in the file.
ifstream Ifile;
Ifile.open(fileName, ios::in | ios::binary);
while (Ifile.read((char*)&c, sizeof(char)))
amountOfBytes++;
Ifile.close();
//To write the fileData into memory.
unsigned char* fileData = new unsigned char[amountOfBytes + 1];
Ifile.open(fileName, ios::in | ios::binary);
int index = 0;
while (Ifile.read((char*)&c, sizeof(char)))
{
fileData[index] = c;
index++;
}
二、基于哈夫曼编码实现文件压缩
1.按字节统计种类和频度
代码分析
//To count the frequency of the bytes in the file.
int freq[256] = { 0 };
int allCodes = 0;
for (int i = 0; i <= amountOfBytes - 1; i++)
{
freq[(int)fileData[i]] ++;
}
for (int i = 0; i <= 255; i++)
{
if(freq[i] != 0) allCodes ++;
}
cout << allCodes << endl;
//allCodes用于记录所有出现的字节编码的种类树,这对应着相应的哈夫曼树将有多少叶子结点。
上述代码中,使用freq数组记录0-255的字节编码的种类和频度,用编码内容作为下标,频度对应当前下标的值。
2.根据频度构建哈夫曼树
①结构体定义
由于在哈夫曼树中,一旦知道总的叶子节点数为allCodes(即编码总数),那么所有的结点数将为 2*allCodes-1 个。结点数已知,建议使用顺序结构进行哈夫曼树的存储。以下为每个结点的结构,其中lChild、rChild分别为其左孩子和右孩子在顺序结构中的索引,huffmanCode用于保存叶子结点对应的编码。
typedef struct _HuffmanNode
{
int freq = 0;//频度,即结点值
unsigned char byteData = '\0';
int lChild = -1;
int rChild = -1;
char *huffmanCode = nullptr;
bool hasParent = false;
}HuffmanNode;
②代码分析
//Create a Huffman Tree with the freqs.
HuffmanNode* huffmanTree = new HuffmanNode[allCodes * 2 - 1];
int nodeAmount = 0;
for (int i = 0; i <= 255; i++)
{
if (freq[i] != 0)
{
cout << i << endl;
huffmanTree[nodeAmount].byteData = i;
huffmanTree[nodeAmount].lChild = -1;
huffmanTree[nodeAmount].rChild = -1;
huffmanTree[nodeAmount].freq = freq[i];
huffmanTree[nodeAmount].hasParent = false;
nodeAmount ++;
}
}
在上述代码中, 首先将所有叶子结点加入到哈夫曼树中。
while (nodeAmount < allCodes * 2 - 1)
{
int min1 = 2147483647, min2 = 2147483647;
int minIndex1 = -1, minIndex2 = -1;
for (int i = 0; i <= nodeAmount - 1; i++)
{
if (huffmanTree[i].hasParent == true) continue;
if (huffmanTree[i].freq < min1)
{
min2 = min1;
minIndex2 = minIndex1;
min1 = huffmanTree[i].freq;
minIndex1 = i;
}
else if (huffmanTree[i].freq < min2)
{
min2 = huffmanTree[i].freq;
minIndex2 = i;
}
}
assert(minIndex1 != -1 && minIndex2 != -1);
//断言:一定存在可以继续结合的子树。
huffmanTree[nodeAmount].byteData = 0;
huffmanTree[nodeAmount].freq = min1 + min2;
huffmanTree[nodeAmount].lChild = minIndex1;
huffmanTree[minIndex1].hasParent = true;
huffmanTree[nodeAmount].rChild = minIndex2;
huffmanTree[minIndex2].hasParent = true;
huffmanTree[nodeAmount].hasParent = false;
nodeAmount++;
}
在上述代码中,我们循环找出根结点值最小的两个子树,将它们拼合成一个新子树,新的结点被加入到哈夫曼树中,直至总结点数量达到allCodes*2-1。
3.通过哈夫曼树生成哈夫曼编码
代码分析
void calculateHuffmanCode(HuffmanNode* huffmanTree, int root, int index, char* code)
{
if (huffmanTree[root].lChild != -1 && huffmanTree[root].rChild != -1)
{
code[index] = '1';
calculateHuffmanCode(huffmanTree, huffmanTree[root].lChild, index + 1, code);
code[index] = '0';
calculateHuffmanCode(huffmanTree, huffmanTree[root].rChild, index + 1, code);
}
else
{
code[index] = '\0';
huffmanTree[root].huffmanCode = new char[strlen(code)];
strcpy(huffmanTree[root].huffmanCode, code);
}
}
在上述代码中,通过递归对哈夫曼树进行遍历,在遍历过程中记录叶子节点对应的哈夫曼编码,并在走到叶子结点时把编码存储到叶子节点的huffmanCode中。
//Calculate the huffmanCode.
char* tempStr = new char[nodeAmount];
calculateHuffmanCode(huffmanTree, nodeAmount - 1, 0, tempStr);
for (int i = 0; i < allCodes * 2 - 1; i++)
{
cout << (int)(unsigned char)huffmanTree[i].byteData << " " << huffmanTree[i].freq << " " << huffmanTree[i].hasParent
<< " " << huffmanTree[i].lChild << " " << huffmanTree[i].rChild
<< " " << ((huffmanTree[i].huffmanCode != nullptr)? huffmanTree[i].huffmanCode :"")<< endl;
}
在上述代码中,调用calculateHuffmanCode函数计算哈夫曼编码,并输出整个哈夫曼树。
如test.txt文件中的数据为
Hello World!This is an blog by MiHu.
经过上述代码可得到如下输出:
每行顺序分别为:字符 频度 是否有父节点 左孩子索引 右孩子索引 哈夫曼编码
6 1 -1 -1 001
! 1 1 -1 -1 10101
. 1 1 -1 -1 10100
H 2 1 -1 -1 1101
M 1 1 -1 -1 10011
T 1 1 -1 -1 10010
W 1 1 -1 -1 10001
a 1 1 -1 -1 10000
b 2 1 -1 -1 1100
d 1 1 -1 -1 01111
e 1 1 -1 -1 01110
g 1 1 -1 -1 01101
h 1 1 -1 -1 01100
i 3 1 -1 -1 0001
l 4 1 -1 -1 111
n 1 1 -1 -1 01011
o 3 1 -1 -1 0000
r 1 1 -1 -1 01010
s 2 1 -1 -1 1011
u 1 1 -1 -1 01001
y 1 1 -1 -1 01000
2 1 1 2
2 1 4 5
2 1 6 7
2 1 9 10
2 1 11 12
2 1 15 17
2 1 19 20
4 1 3 8
4 1 18 21
4 1 22 23
4 1 24 25
4 1 26 27
6 1 13 16
8 1 14 28
8 1 29 30
8 1 31 32
12 1 0 33
16 1 34 35
20 1 36 37
36 0 38 39
前面所有带哈夫曼编码的为叶子节点,其余为非叶子结点,最后一个结点为根节点。
4.文件头部信息的写入
哈夫曼编码是一种基于字符频度的编码,因此在写入编码前,有必要将字符的频度信息写入,这样在解压缩时,只需要根据频度信息再次构建哈夫曼树,进行解压缩。
//Write the head infomation (include byte frequencies) into file.
ofstream Ofile("output.mihupack",ios::out|ios::binary);
const char *headInfo = "[MIHUPACK]This is a MiHu Pack,please use MiHuPacker to unpack.\n";
Ofile.write((char*)headInfo, strlen(headInfo));
Ofile.write((char*)&allCodes,sizeof(allCodes));
Ofile.write((char*)&amountOfBytes, sizeof(amountOfBytes));
int lengthOfFilename = strlen(fileName);
Ofile.write((char*)&lengthOfFilename, sizeof(lengthOfFilename));
Ofile.write((char*)&fileName, strlen(fileName));
for (int i = 0; i <= allCodes - 1; i++)
{
Ofile.write((char*)&huffmanTree[i].byteData, sizeof(huffmanTree[i].byteData));
Ofile.write((char*)&huffmanTree[i].freq, sizeof(huffmanTree[i].freq));
}
在上述代码中,我们依次写入了一个特征字符串(用于识别是否为我们的压缩程序创建的特定格式,占空间大小与特征字符串长度有关)、所有的编码种类allCodes(int,占4字节)、整个文件的大小amountOfBytes(单位为字节,int,占4字节)、原文件名的长度lengthOfFileName(int,占4字节)、原文件名fileName、字符和对应频度(char + int,占allCode*(1+4)字节)。
写入上面的这些头部信息完全是为了能够进行正确的解压缩,虽然其中某些信息理论上可以省略,但为了能正确解压缩,更完整的信息有利于信息的正确性校验,因此是有必要的。
5.创建字节编码和哈夫曼树结点索引的键值对
有了以上的前置准备,我们可以开始将真正的编码信息写入文件中了。但是,如何根据原文件的每一个字节编码,快速找到它所对应的哈夫曼编码呢?
一种方案是每次都根据当前的字节编码,查找哈夫曼树,如果字符编码匹配,即为我们需要的哈夫曼编码。
huffCode = nullptr;
for(int i=0;i< allCodes;i++)
{
if(huffmanTree[i].byteData == curByteData)
huffCode = huffmanTree[i].huffmanCode;
}
assert(huffCode != nullptr);
//这时一定会找到对应的哈夫曼编码,如果找不到,
//说明前面的字节编码频度统计和哈夫曼树的创建存在问题,跳出程序。
但这种方案每个编码都需要遍历编码表,很浪费时间。因此考虑建立一个键值对huffKey,用于保存字节编码和对应下标的关系,实现如下:
//Create the key-value pairs of byteData-huffmantreeIndex.
int HuffKey[256];
memset(HuffKey, -1, sizeof(HuffKey));
for (int i = 0; i <= allCodes - 1; i++)
{
HuffKey[(int)(unsigned char)huffmanTree[i].byteData] = i;
}
6.把新的编码信息写进文件中
//Write the huffmanCodes into file.
char byte = '\0';
bool isByteEmpty = true;
int bitIndex = 0;
for (int i = 0; i <= amountOfBytes - 1; i++)
{
int codeIndex = 0;
char tempChar = '\0';
//cout << (int)fileData[i] << endl;
assert(huffmanTree[HuffKey[fileData[i]]].huffmanCode != nullptr);
char* code = huffmanTree[HuffKey[fileData[i]]].huffmanCode;
while (codeIndex < strlen(code))
{
char bit = code[codeIndex] - '0';
//cout << (int)bit;
byte = byte | (bit << (7 - bitIndex) );
bitIndex ++;
codeIndex ++;
isByteEmpty = false;
if (bitIndex == 8)
{
//cout << "!" << (int)(unsigned char)byte << endl;
Ofile.write((char*)&byte, sizeof(byte));
byte = '\0';
isByteEmpty = true;
bitIndex = 0;
}
}
}
if(!isByteEmpty) Ofile.write((char*)&byte, sizeof(byte));
用 bitIndex 表示当前的字节已经写到了第几位,codeIndex 用于表示已经处理了多少字节编码,总字节编码数即待压缩文件的大小(字节)。
bitIndex每满8位就要将当前的字节写进文件中,并且清零。
在最后可能会有一个字节未写满的状况,依然需要写到文件中,注意:写入的最后一个字节可能会存在垃圾位。
Ofile.close();
最后,压缩完毕,关闭文件流。
三、文件的解压
解压文件的程序与压缩文件相同,开始时需要通过文件流将文件读入到内存中,具体实现上方已有,不再赘述。
1.特征字符串的校验
//To verify file format.
const char* headInfo = "[MIHUPACK]This is a MiHu Pack,please use MiHuPacker to unpack.\n";
char* fileHead = new char[strlen(headInfo) + 1];
for(index = 0 ; index <= min(strlen(headInfo) - 1,strlen((const char*)fileData)) ; index ++)
{
fileHead[index] = fileData[index];
}
fileHead[index] = '\0';
if (strcmp(fileHead, headInfo) == 0)
{
cout << "Valid file format,decoding..." << endl;
}
else
{
cout << "Invalid file format!" << endl;
return 0;
}
接下来,需要对文件信息进行校验,校验特征字符串(用于识别是否为我们的压缩程序创建的特定格式,占空间大小与特征字符串长度有关),以确定是否是我们的压缩程序压缩出的文件。
2.从文件中读取头部信息
//To get the frequency of the bytes in the source file.
int allCodes = 0;
int amountOfSourceBytes = 0;
memcpy(&allCodes,fileData + index,sizeof(int));
index += sizeof(int);
memcpy(&amountOfSourceBytes,fileData + index,sizeof(int));
index += sizeof(int);
int i = 0;
int lengthOfFilename = 0;
memcpy(&lengthOfFilename,fileData + index,sizeof(int));
index += sizeof(int);
char *sourceFileName = new char[lengthOfFilename + 1];
for(i = 0;i < lengthOfFilename;i ++)
{
sourceFileName[i] = fileData[index + i];
}
sourceFileName[i] = '\0';
index += i;
cout << allCodes << " " << amountOfSourceBytes << " " << sourceFileName << endl;
int freq[256] = { 0 };
for (int i = 0; i <= allCodes - 1; i++)
{
unsigned char byteData = '\0';
int frequency = 0;
memcpy(&byteData,fileData + index,sizeof(char));
index += sizeof(char);
memcpy(&frequency,fileData + index,sizeof(int));
index += sizeof(int);
freq[byteData] = frequency;
}
通过index指向已经读取到的位置,依次使用 memcpy 读取所有的编码种类allCodes(int,占4字节)、整个文件的大小amountOfBytes(单位为字节,int,占4字节)、原文件名的长度lengthOfFileName(int,占4字节)、原文件名fileName、字符和对应频度(char + int,占allCode*(1+4)字节)。
这一部分没什么技术含量,因此请读者自行审阅代码。
3.通过哈夫曼树生成哈夫曼编码
接下来的操作与压缩时一致,通过已有的字节编码频度生成哈夫曼树、计算哈夫曼编码,代码可以直接复用。
4.解码文件
在完成一系列操作后,就可以解码文件。
//Decode the file.
ofstream Ofile(sourceFileName, ios::out | ios::binary);
int sourceByteIndex = 0;
int curByteIndex = index;
int curBitIndex = 0;
const int rootNode = allCodes * 2 - 1 - 1;
int curTreeNode = rootNode;
char curByte = '\0';
memcpy(&curByte,fileData + curByteIndex,sizeof(char));
while(sourceByteIndex < amountOfSourceBytes)
{
int curBit = ( curByte >> (7 - curBitIndex) ) & 1 ;
if(curBit == 1)
{
curTreeNode = huffmanTree[curTreeNode].lChild;
}
else
{
curTreeNode = huffmanTree[curTreeNode].rChild;
}
if(huffmanTree[curTreeNode].huffmanCode != nullptr)
{
//cout << huffmanTree[curTreeNode].byteData << " " << sourceByteIndex << " " << amountOfSourceBytes << endl;
Ofile.write((char*)&huffmanTree[curTreeNode].byteData, sizeof(huffmanTree[curTreeNode].byteData));
curTreeNode = rootNode;
sourceByteIndex ++;
}
curBitIndex ++;
if(curBitIndex == 8)
{
curByteIndex ++;
curBitIndex = 0;
memcpy(&curByte,fileData + curByteIndex,sizeof(char));
}
}
这一部分我们准备了一个curTreeNode,用于遍历哈夫曼树。当读取到1时向左子树走,读取到0时向右子树走,走到叶子结点时就将叶子结点存储的字符编码写进源文件中,同时curTreeNode回到根结点。如此往复,直到原文件中的字节编码全部被解码出来为止。
四、结果演示
压缩一张BMP图片时达到了53%的压缩效率。对于其他文件基本能稳定在50%-90%之间,某些特殊文件压缩后没有变化。
对于压缩包文件,很难有压缩效果,并且由于添加了头部信息,还变大了1-2字节。
五、总结
基于哈夫曼编码的文件解压缩是一个涉及知识很多的工程,总的来说还是对综合能力的一种检验吧......这也是我到目前为止遇到的内容最多最复杂的工程。
这个程序总的来说可以处理大部分的正常输入,但在测试过程中还遇到极端情况比如:一个全是a的txt文件就会被解压为全是t的txt文件。
本人能力有限,还请看到的大神和我沟通,争取把这个错误解决。