在了解哈夫曼压缩(Huffman compression)之前,我们简要了解下为字符编码时频率相关和前缀不重复(prefix-free)两个重要的概念。
一、频率相关
首先我们看一看根据统计的字母频率
![90af36ed2a7498f2c09bf5bea91b5f17.png](https://i-blog.csdnimg.cn/blog_migrate/9bffee7e5a28afa01e54dbd1d826f1eb.jpeg)
再来看一看我们熟悉的摩尔斯电码(Morse code)
![7a39f5d4a36719bfb05b0ca4ba2dc7a6.png](https://i-blog.csdnimg.cn/blog_migrate/617736548954331a601488745417a6ac.jpeg)
我们可以很明显发现,频率越高的字母对应的摩尔斯电码越简单,频率越低的字母对应的摩尔斯电码越复杂。那么我们如果用点横的方式(或者用'1'、'0'的方式,在计算机我们显然是用这种方式)去为26个字母编码,然后用对应编码去翻译一篇文章,用摩尔斯电码这种与字母使用频率相关联的编码方式会为我们翻译省墨许多。
二、前缀不重复(prefix-free)
但是,用摩尔斯电码去翻译一篇文章,有时候会出现一些问题。比如:
SOS = · · · - - - · · ·
V7 = · · · - - - · · ·
这两个用摩尔斯电码表示出来是完全一样的
于是我们要如何区分他们两个呢?
实际上,摩尔斯电码是有小空格,或者小间断(为了清楚表示,这里用斜杠/来表示空格)来表示区分相邻的字符。比如你想表达"SOS",你就需要这样写"· · · / - - - / · · ·",想表达"V7"就需要写成"· · · - / - - · · ·"。
上述方法除了"·"、"-",我们还加入了第三种符号" "(空格,在这里用斜杠可以看得更清楚)。但是在计算机中,我们只有0和1,有没有什么方法不需要用空格标识也可以让一连串编码只能表示出一种意思吗?
答案是当然是有的!既然我们不能多引入编码的符号,我们可以考虑为为编码方式做出一点限制。考虑如下的编码方式:
A = 101 B = 101001 C = 01
考虑“BC”的编码“101001001”,我们想象我们就是计算机,我们一个一个数字去读取
- 1 查表,失败,该编码没有表示的字符,继续读取
- 10 查表,失败,该编码没有表示的字符,继续读取
- 101 查表,成功,该编码有对应的字符A,清空后继续读取
- 0 查表,失败,该编码没有表示的字符,继续读取
- 00 查表,失败,该编码没有表示的字符,继续读取
- 001 查表,失败,该编码没有表示的字符,继续读取
- 0010 查表,失败,该编码没有表示的字符,继续读取
- 00100 查表,失败,该编码没有表示的字符,继续读取
- 001001 查表,失败,该编码没有表示的字符,继续读取
- 扫描结束,但是我们还有一串001001是扫描失败,没有找到对应字符
最后我们扫描失败了,而问题我们可以清楚得看到是出自于第三步,我们查到101时,由于101可以对应A,于是计算机就在这地方停下来了。计算机并不知道还可以往后面继续读取,当读取到101001时是可以对应B,然后最后001对应C,这样才是成功的翻译。
出现问题的地方就是因为,A的编码是B的编码的前缀。当我们读取完前缀重复片段,作为一个会思考的人,我们会犹豫还要不要读下去?但是作为一个机器,他只管对上了表,然后把这段编码翻译。所以当某个编码是另一个编码前缀并且没有第三个符号分割开来的话,计算机是无法读取更长编码对应的字符的。
所以为了不引入第三个字符,我们要求每个字符对应的编码都不能是其他字符编码的前缀(prefix-free)。下面证明这种方式的翻译是唯一含义的。
- 假设每个字符对应的编码都不是其他字符编码的前缀
- 假设我们有一张编码表
- 假设还有一篇用编码写的文章
- 我们逐位读取,读到直到可以在编码表中找到对应的字符(假设为'A')
- 假设我们可以继续读取下去,读出来的新的编码可以对应另一个字符(假设为'B')
- 那么说明'A'的编码是'B'的编码的前缀,与前面矛盾
- 所以我们只能在读取到可以在编码表中找到对应的字符时停下来
- 最后编码就是唯一的
上面的编码方式只用两个符号和前缀不重复这两个特征我们可以用二叉树的结构来表示
对于如下编码方式
A = 11
B = 00
C = 010
D = 100
R = 011
! = 101
用二叉树结构表示如下
![9340f741a716ab89bf23dba2f1029a91.png](https://i-blog.csdnimg.cn/blog_migrate/41db6dba49ad1793ffd38bed526015c6.jpeg)
从根结点开始往下走,向左走为0,向右走为1。
字符的编码就是根节点到该字符所在结点的路径编码。
把需要编码的字符放在一颗树的叶子结点(无左子树和右子树),那么就不可能出现编码是别人的前缀。(假如'A'的编码是'B'的编码的前缀,那么去B的过程就会经过A,那么A就不是叶子结点,与假设矛盾)
三、哈夫曼编码(Huffman code)
对于同一段字符串"ABRACADABRA!"
不同的编码方式所用的内存是不一样的
像下面两幅图,第一种编码方式用了30bits,第二种编码方式用了29bits。
![07a3f83965dbe805194ac55abc4f8fef.png](https://i-blog.csdnimg.cn/blog_migrate/243ce611e54e278609d22ce5f3fd301e.jpeg)
![dfe6ac6a614893b5eafaf66e5f8dee29.png](https://i-blog.csdnimg.cn/blog_migrate/e4241ad0aa9110827e1fb3bf346c50ee.jpeg)
我们的目标是,结合频率相关和前缀不重复(prefix-free),构建一颗针对某一文本最优的二叉树,我们把这棵树就叫哈夫曼树(Huffman tree)。
下面只讲述Huffman树的构建方法,证明过程略。
- 统计文本中字符出现的次数
- 将字符按照频数升序排序
- 将频数最小的两个叶子结点结合成树,看作一个整体,整体的频数是叶子结点频数和
- 把这个树看作整体和其他的一起也进行升序排序
- 重复上述过程知道生成整棵树
下面是一个生动形象的Demo,展示了如何针对这一文本"ABRACADABRA!"构建出Huffman树
![d1a38a44094ee82c7b0379a380869545.png](https://i-blog.csdnimg.cn/blog_migrate/441193d4a1baeb0cb72700c06ee60629.jpeg)
四、哈夫曼编码的应用——数据压缩
这是某高校期末作业:
针对一幅 BMP 格式的图片文件,统计 256 种不同字节的重复次数,以每种字节重复次数作为权值,构造一颗有 256 个叶子节点的哈夫曼二叉树。利用上述哈夫曼树产生的哈夫曼编码对图片文件进行压缩。压缩后的文件与原图片文件同名,加上后缀.huf(保留原后缀)。如 pic.bmp压缩后 pic.bmp.huf。
针对BMP文件格式可以看下面两个网站的介绍
云无月:浅谈图像格式 .bmpzhuanlan.zhihu.com![b72116d0f75dfca7cc600b61fd64923d.png](https://i-blog.csdnimg.cn/blog_migrate/ae3788ac09bff3631bc9c67e4dcaa984.jpeg)
因为是处理BMP文件格式,所以我们可以创建一个类,构造函数读取文件,私有属性保存文件的属性,然后还要实现压缩的功能。所以API如下:
class BMP {
private:
//可以保存文件名和路径,方便压缩后添加后缀
string path;
string filename;
//下面四部分就是BMP文件的四个部分(需要#include <windows.h>)
BITMAPFILEHEADER fileHeader;
BITMAPINFOHEADER infoHeader;
RGBQUAD rgb[256];
char *rowData;
//每行字节数(BMP有补零的规则,每行的字节数有计算公式)
//每行需要跳过的字节数
int rowSize;
int skip;
//像素数据大小=(rowSize-skip)*图像高度
int pixelArraysize;
public:
//用Huffman编码压缩
void huffCompress() {}
//用文件头的Huffman编码生成的Huffman树去解压
void huffExpand() {}
}
我们并不想设计一个只适合BMP文件格式的哈夫曼压缩的类,所以我们可以设计一个通用的哈夫曼压缩类,具体细节放在BMP类中处理。
对于Huffman类,只需要实现两个公有方法,即压缩compress和解压expand。
一、压缩compress
压缩需要利用到Huffman编码,构造一个Huffman编码需要Huffman树。于是我们要需要考虑Huffman树的结构与构造方法,以及利用Huffman树构建编码这几个问题。
① Huffman树的结构(Node)
该树由Node组成,用root结点表示,私有属性有ch字符,freq频率,左结点和右节点。(并且重载了Node的小于号,以便后面用优先队列构造Huffman树)
② Huffman树构造方法(buildTrie)
读取文件中字符出现的频率→创建256个结点→放入优先队列循环构造树
③ 利用树对字符编码(buildCode)
递归生成编码,用数组保存编码
同样我们在考虑解压时,我们需要同样的Huffman树才能进行解压,故我们还需要把Huffman树按照特定顺序写入到压缩文件开头(writeTrie)。
所以对于公有的compress,我们还需要Node类和buildTrie、buildCode、writeTrie这三个私有辅助函数。
二、解压expand
解压需要利用压缩文件开头部分写入的Huffman树才能进行解压,故需要一个readTrie函数读取那一部分并且生成Huffman树,并利用这颗树解压,最后生成解压后的文件。
Huffman类的API如下:
class Huffman {
private:
class Node {};
static Node buildTrie(int *freq) {}
static void buildCode(string *st, Node x, string s) {}
static string to8(string s) {}
static string getByte(string s) {}
static void writeTrie(Node x, string filename) {}
static Node readTrie(string filename) {}
public:
Huffman() {}
static void compress(char *input, int len, string filename) {}
static void expand(string filename, string obj) {}
}
其中to8和getByte方法:我们是用一个string来表示一串01编码,而在计算机中,我们是按照一个字节八位来处理,而我们的编码有可能不是8的倍数,所以to8对我们的编码进行补零处理。getByte方法把string当作按位处理,比如字符串“0100000101000010”就被我们处理为“AB”。
完整代码如下:
#include <iostream>
#include "windows.h"
#include <string>
#include <fstream>
#include <queue>
#include <math.h>
using namespace std;
class Huffman {
private:
class Node {
private:
char Ch;
int Freq;
Node *Left;
Node *Right;
public:
Node(char Ch, int Freq, Node *Left, Node *Right) {
this->Ch = Ch;
this->Freq = Freq;
this->Left = Left;
this->Right = Right;
}
bool isLeaf() {
return (Left == NULL) && (Right == NULL);
}
char ch() {
return Ch;
}
int freq() {
return Freq;
}
Node left() {
return *Left;
}
Node right() {
return *Right;
}
bool operator<(const Node &other) const {
return this->Freq > other.Freq;
}
};
//构建Huffman树
static Node buildTrie(int *freq) {
//创建一个最小堆优先队列pq,把所有频数大于1的字符创造一个结点并且放入pq。
priority_queue<Node> pq;
for (int i = 0; i < 256; i++) {
char iChar = i;
if (freq[iChar] > 0) {
Node *temp = new Node(iChar, freq[iChar], NULL, NULL);
pq.push(*temp);
delete temp;
}
}
//到pq里的结点数大于1的时候,就弹出两个最小的结点,合并,并放入。知道最后只剩一个结点就为Huffman树
while (pq.size() > 1) {
Node left = pq.top();
pq.pop();
Node right = pq.top();
pq.pop();
Node *parent = new Node(NULL, left.freq() + right.freq(), &left, &right);
pq.push(*parent);
delete parent;
}
return pq.top();
}
//用Huffman树构建编码
static void buildCode(string *st, Node x, string s) {
/************************************************
* s是Huffman编码字符串
* 如果不是叶子结点,就递归调用
* 如果往左走,就让s+‘0’;如果往右走,就让s+'1'
* 如果是叶子结点,就可以把x结点的字符的编码保存到st中
************************************************/
if (!x.isLeaf()) {
buildCode(st, x.left(), s + '0');
buildCode(st, x.right(), s + '1');
} else {
st[x.ch()] = s;
}
}
//将字符串的长度转换成8的倍数
static string to8(string s) {
// 将st的长度补成8的倍数
int size = s.length();
int mod = 8 - (size % 8);
string bu = "";
for (int i = 0; i < mod; i++)
bu += "0";
s += bu;
return s;
}
//字符串为01串,把每个字节的’0‘、’1‘当做bit,然后转换为byte
static string getByte(string s) {
string sByte = "";
int round = s.length() / 8;
for (int i = 0; i < round; i++) {
int sum = 0;
for (int j = 0; j < 8; j++) {
if (s.at(8 * i + j) == '1')sum += (int) pow(2, 7 - j);
}
char temp = sum;
sByte += temp;
}
return sByte;
}
//按照特定规则将Huffman树写入文件
static void writeTrie(Node x, string filename) {
ofstream output(filename.c_str(), ios::out | ios::app | ios::binary);
//如果是叶子结点,就把true写入,并且把x结点的字符写入文件
if (x.isLeaf()) {
bool temp = true;
output.write((char *) &temp, sizeof(bool));
char xtemp = x.ch();
output.write((char *) &xtemp, sizeof(char));
return;
}
//否则如果不是叶子结点,就只把false写入文件
bool temp = false;
output.write((char *) &temp, sizeof(bool));
output.close();
writeTrie(x.left(), filename);
writeTrie(x.right(), filename);
}
public:
Huffman() {
}
//用Huffman编码压缩
static void compress(char *input, int len, string filename) {
//创建freq数组,用来保存每个字符出现频数,并且扫描每个字符,统计频数
int *freq = new int[256];
for (int i = 0; i < 256; i++)
freq[i] = 0;
for (int i = 0; i < len; i++)
freq[input[i]]++;
//调用buildTrie函数构造Huffman树
Node root = buildTrie(freq);
//创建一个symbol table并调用buildCode用来表示对应字符的Huffman编码
string *st = new string[256];
buildCode(st, root, "");
//把用‘0’、‘1’表示的编码字符转转化为字节
for (int i = 0; i < 256; i++) {
st[i] = to8(st[i]);
st[i] = getByte(st[i]);
}
//由于需要Huffman编码才能解码,所以用writeTrie把Huffman编码写入文件以便解码
writeTrie(root, filename);
//以二进制模式打开文件,逐个把对应字符的Huffman编码写入文件
ofstream output(filename.c_str(), ios::out | ios::app | ios::binary);
output.write((char *) &len, sizeof(int));
for (int i = 0; i < len; i++) {
string code = st[input[i]];
output.write((char *) &code, code.length());
}
delete freq;
delete st;
output.close();
}
};
class BMP {
private:
//可以保存文件名和路径,方便压缩后添加后缀
string path;
string filename;
//下面四部分就是BMP文件的四个部分
BITMAPFILEHEADER fileHeader;
BITMAPINFOHEADER infoHeader;
RGBQUAD rgb[256];
char *rowData;
//每行字节数(查阅资料发现BMP还有补零的规则,每行的字节数有计算公式)
//每行需要跳过的字节数
int rowSize;
int skip;
//像素数据大小=图像宽度*图像高度
int pixelArraysize;
public:
//读取文件
BMP(string path, string filename) {
this->path = path;
this->filename = filename;
string openFilename = path + filename;
//打开文件,保存文件前三个部分
ifstream file(openFilename.c_str(), ios::in | ios::binary);
file.read((char *) &fileHeader, sizeof(BITMAPFILEHEADER));
file.read((char *) &infoHeader, sizeof(BITMAPINFOHEADER));
file.read((char *) &rgb, 4 * sizeof(RGBQUAD));
//利用公式计算每行字节数和原始位图数据大小。
rowSize = ((infoHeader.biBitCount * infoHeader.biWidth + 31) >> 5) << 2;
pixelArraysize = (rowSize - skip) * infoHeader.biHeight;
//把Row Bitmap Data储存到字符数组里面
rowData = new char[pixelArraysize];
skip = 4 - ((infoHeader.biWidth * infoHeader.biBitCount) >> 3) & 3;
for (int i = 0; i < infoHeader.biHeight; i++) {
for (int j = 0; j < rowSize - skip; j++)
file.read((char *) (rowData + i * rowSize + j), 1);
//由于每行有补零,我们需要把补零的跳过
if (infoHeader.biWidth % 4 > 0)
file.seekg(skip, ios::cur);
}
file.close();
}
//用Huffman编码压缩
void huffCompress() {
//为文件名添加后缀
string objFilename = path + filename + ".huf";
//以二进制方式打开文件,并且把前三部分数据写入文件
ofstream output(objFilename.c_str(), ios::out | ios::app | ios::binary);
output.write((char *) &fileHeader, sizeof(BITMAPFILEHEADER));
output.write((char *) &infoHeader, sizeof(BITMAPINFOHEADER));
output.write((char *) &rgb, 4 * sizeof(RGBQUAD));
output.close();
//利用Huffman类里面compress方法对Row Bitmap Data进行压缩并写入目标文件尾
Huffman::compress(rowData, pixelArraysize, objFilename);
}
};
int main() {
cout << "enter the path:";
string path;
cin >> path;
cout << "enter the filename:";
string filename;
cin >> filename;
BMP bmp(path, filename);
bmp.huffCompress();
}
有错误处欢迎大佬指正
附上一些鬼刀的测试文件和实验结果