赫夫曼编码
赫夫曼编码主要用于信息压缩中,是目前压缩效率较高的一种编码方式。在实现赫夫曼编码的时候,就是使用二叉树去进行实现。
这里简单列举一个赫夫曼编码的例子,对于一个文章,提取出其中所有的词以及出现的次数,那么如何来根据这些词出现的次数来对这些词进行编码,以使得最终由编码组成的文章尽可能的短。
最优二叉树
在介绍如何实现赫夫曼编码之前,首先对二叉树中的最优二叉树进行学习和理解。先来对其中一些名词进行定义:
- 路径:从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径。
- 路径长度:路径上的分支数目。
- 树的路径长度:从树根到每个结点的路径长度之和。
- 结点的带权路径长度:从结点到树根之间的路径长度与结点上权的乘积。
- 树的带权路径长度( W P L WPL WPL):树中所有叶子结点的带权路径长度之和。
之后主要使用到的就是树的带权路径长度(
W
P
L
WPL
WPL),这里简单给出一个例子。
上述例子中的 W P L = 5 ∗ 2 + 3 ∗ 3 + 4 ∗ 2 = 27 WPL=5*2+3*3+4*2=27 WPL=5∗2+3∗3+4∗2=27。
接下来给出最优二叉树的定义,假设二叉树由 n n n个叶子,每个叶子结点带权 w i w_i wi,则带权路径长度 W P L WPL WPL最小的二叉树称为最优二叉树或赫夫曼树。
根据上述定义以及 W P L WPL WPL的定义可以得知,对于最优二叉树来说,权重值越低的叶子结点,其深度应该越大,也就是应该在底层,而权重值越高的叶子结点,其深度应该越小,只有满足这样,才能保证 W P L WPL WPL尽可能的小。
赫夫曼树—构造
在构造赫夫曼树的时候,首先我们原有的数据只有每个叶子结点所代表的符号以及对应的权重值。同时也知道应该将权重较低的尽可能的放在较深的位置,权重值较高的放在较浅的位置。
参考之前二叉树的建立过程,一般都是都是从上往下一层一层建立的。但是赫夫曼树则恰恰相反,它是从下往上建立的,因为要满足之前提到的关于位置的需求,那么赫夫曼树的建立过程如下所示。
- 根据给定的 n n n个权值 ( w 1 , w 2 , . . . , w n ) (w_1,w_2,...,w_n) (w1,w2,...,wn)构成 n n n棵二叉树的集合 F = { T 1 , T 2 , . . . , T n } F=\left\{T_1,T_2,...,T_n \right\} F={T1,T2,...,Tn},其中每棵二叉树 T i T_i Ti中只有一个带权为 w i w_i wi的根节点,左右子树均为空。
- 在 F F F中选取两棵根节点的权值最小的树作为左右子树构造一棵新的二叉树且置其根节点的权值为其左右子树根节点的权值之和
- 在 F F F中删除这两棵树,同时将新得到的二叉树加入 F F F中。
- 重复2,3,直到 F F F中只含一棵树为止。
根据上述过程就可以将多个二叉树合并为一个二叉树,这里通过几个例子来简单执行一遍上述算法。
具体到代码实现部分,这里使用静态链表来进行实现,但是总体上还是分为两个结构,首先是树中的结点,其次是整个赫夫曼树。
对于每个结点,由于后序编码过程是由下至上,解码过程是由上至下,故对于每个结点,都需要知道其左右孩子以及双亲是谁,同时还有编码以及原始数据,权重等信息,故对于结点类定义如下。
#include <iostream>
#include <string>
using namespace std;
// 结点
typedef struct HuffmanNode
{
char data; // 被编码的符号
int weight; // 权重,频率
int left; // 左孩子
int right; // 右孩子
int parent; // 双亲
string code; // 编码后的符号
// 构造函数
HuffmanNode()
{
// cout << "node" << endl;
weight = -1;
left = -1;
right = -1;
parent = -1;
data = '#';
code = "";
}
}HuffmanNode;
在实现赫夫曼树时,使用静态链表,也就是使用数组来保存每个结点。假设开始时有 n n n个叶子结点,那么根据前面的算法,两两节点合二为一,那么一共会生成 n − 1 n-1 n−1个新结点,故一共会有 2 n − 1 2n-1 2n−1个结点。所以数组开辟 2 n − 1 2n-1 2n−1个空间即可,在初始化时,前 n n n个空间用于存放叶子结点,后面的空间根据生成的结点按序存放。
// 树
typedef struct HuffmanTree
{
string info; // 原信息
HuffmanNode* tree; // Huffman树
int num; // 叶子结点数量
// 构造函数
HuffmanTree()
{
info = "";
tree = NULL;
num = 0;
}
}
在前面的算法中介绍到,每次需要先找出权重最低的两个根结点,之后将这两棵二叉树进行合并,这里按照小的在左边,大的在右边进行排放。在查找过程中,直接使用 C + + C++ C++中的引用以及简单的暴力遍历当前已经存在的结点即可。但是需要注意的是,需要找那些没有被添加过的,也就是没有双亲的结点。
// 找到权重最小和第二小的结点
void Select2Min(int pos, int& min1, int& min2)
{
int w1 = 1000000;
// 找到权重最小的结点
for (int i = 0; i < pos; i++)
{
// cout << tree[i].parent << " " << tree[i].weight << endl;
// 没有双亲且权重更小
if (tree[i].parent == -1 && w1 > tree[i].weight)
{
w1 = tree[i].weight;
min1 = i;
}
}
int w2 = 1000000;
// 找到权重第二小的结点
for (int i = 0; i < pos; i++)
{
if (i == min1) // 避免和前面相同
continue;
if (tree[i].parent == -1 && w2 > tree[i].weight)
{
w2 = tree[i].weight;
min2 = i;
}
}
}
在查找到权重最小的两个结点的下标之后,接下来需要生成一个新节点,并按照操作将两个结点的双亲设置为新节点的下标,同时设置新结点的左孩子和右孩子的关系,同时更新新节点的权重值。
// 建立Huffman树
void createTree(char element[], int weights[], int n)
{
if (n < 2) // 防止数组越界
return;
// 初始化
num = n; // 待编码数量
tree = new HuffmanNode[2 * n - 1]; // 开辟空间
if (tree == NULL) // 申请成功
return;
for (int i = 0; i < n; i++) // 先初始化叶子结点
{
tree[i].data = element[i];
tree[i].weight = weights[i];
// cout << tree[i].data << " " << tree[i].weight << endl;
}
for (int i = n; i < 2 * n - 1; i++)
{
int min1 = -1; // 最小的结点
int min2 = -1; // 第2小的结点
Select2Min(i, min1, min2); // 挑选出最小和第二小的结点
// cout << min1 << " " << min2 << endl;
tree[i].weight = tree[min1].weight + tree[min2].weight; // 权重计算
// 关系确认
tree[min1].parent = i;
tree[min2].parent = i;
tree[i].left = min1;
tree[i].right = min2;
}
}
赫夫曼树—编码
在完成了赫夫曼树的构造和建立之后,并没有完成编码过程。总体来说想要的是对叶子结点进行编码,在赫夫曼树中,构造了一个总树将所有的待编码的结点以叶子节点的形式保存在总树中。那么在编码的时候,从根节点开始,向左孩子遍历一次,则编码加 “ 0 ” “0” “0”,向右孩子遍历一次,则编码加 “ 1 ” “1” “1”。
为了方便,在编码时通常从叶子结点从下往上遍历,对于每个叶子结点往上走即可。不过这样得到的编码是反过来的,最后需要将得到的编码转置一次。
// 计算叶子编码
void getCode()
{
if (tree == NULL) // 指针不为空
return;
// 逐个叶子结点进行编码
for (int i = 0; i < num; i++)
{
int now = i; // 子结点
int par = tree[i].parent; // 父节点
while (par != -1) // 一直往上递推到根节点
{
if (now == tree[par].left) // 如果子节点是父节点的左孩子,则加0
tree[i].code += "0";
if (now == tree[par].right) // 如果子节点是父节点的右孩子,则加1
tree[i].code += "1";
now = par; // 更新结点和父节点
par = tree[par].parent;
}
reverse(tree[i].code.begin(), tree[i].code.end()); // 字符串反转
// cout << tree[i].data << " " << tree[i].code << endl;
}
}
在获得了编码之后,接下来就是实例,也就是根据用户输入的一串信息来编码,这个实现起来就很简单了,直接根据信息中的词进行查找,找到对应的叶子节点后,将其编码加上即可。
// 编码
string encode(string s)
{
if (tree == NULL) // 指针不为空
return "NULL";
string ans = ""; // 编码结果
// 逐个字符编码
for (int i = 0; i < (int)s.size(); i++)
{
// 根据每个字符进行匹配,找到对应的编码并加上即可
for (int j = 0; j < num; j++)
{
if (tree[j].data == s[i]) // 判断叶子结点字符中是否和当前字符匹配
ans += tree[j].code;
}
}
return ans;
}
赫夫曼树—解码
对于解码过程,也就是根据用户输入的一串编码来解析出其原来代表的含义。首先忽略出现错误的情况,假设编码正确,那么在解码的时候,都是从根节点开始,如果遇到 0 0 0则遍历当前结点的左孩子,如果遇到 1 1 1则遍历当前结点的右孩子。那么如果遍历时遇到了叶子结点,那就说明成功解析了一个符号,之后回到根节点继续之前的编码进行解码。
接下来考虑错误的情况,首先就是在左拐或右拐之后,没有结点,也就是访问到了不存在的结点,那么这时候说明中间编码出现了错误。之后就是末尾有多余数据或少数据的情况,这种情况下中间编码都是正常的,但是在所有编码解析之后,发现当前指针并没有指向根节点,这就说明最后剩余一部分编码并未解析成功。
// 解码
string decode(string s)
{
if (tree == NULL) // 指针不为空
return "NULL";
string ans = ""; // 解码结果
int index = 2 * num - 2; // 从根节点往下进行解码
// 逐个字符进行解码
for (int i = 0; i < (int)s.size(); i++)
{
if (s[i] == '0') // 遇到0就左拐
index = tree[index].left;
if (s[i] == '1') // 遇到1就右拐
index = tree[index].right;
if (index == -1) // 如果访问到不存在的结点,则说明解码错误
return "error";
// 遇到叶子结点则成功解码一个字符,之后重新从根节点开始遍历
if (tree[index].left == -1 && tree[index].right == -1)
{
ans += tree[index].data;
index = 2 * num - 2;
}
}
if (index != 2 * num - 2) // 末尾有多余的编码也是属于解码错误
return "error";
return ans;