目录
一、哈夫曼树的基本概念
二、哈夫曼树的构造算法
2.1 - 哈夫曼树的构造过程
2.2 - 哈夫曼树的存储表示
2.3 - 算法实现
三、哈夫曼编码
3.1 - 哈夫曼编码的主要思想
3.2 - 哈夫曼编码的性质
3.3 - 算法实现
一、哈夫曼树的基本概念
哈夫曼树的定义,涉及路径、路径长度、权等概念,下面先给出这些概念的定义,然后再介绍哈夫曼树。
-
路径:从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径。
-
路径长度:路径上的分支数目称作路径长度。
-
树的路径长度:从树根到每一结点的路径长度之和。
-
权:赋予某个实体的一个量,对实体的某个或某些属性的数值化描述。在数据结构中,实体有结点(元素)和边(关系)两大类,所以对应有结点权和边权。结点权和边权具体代表什么意义,由具体情况决定。如果在一棵树中的结点上带有权值,则对应就有带权树等概念。
-
结点的带权路径长度:从树根到该结点的路径长度与该结点上权值的乘积。
-
树的带权路径长度:树中所有叶子结点的带权路径长度之和,通常记作 。
在含有 n 个叶子结点的二叉树中,每个叶子结点的权值为 ,则其中 WPL 最小的二叉树称作最优二叉树或哈夫曼(Huffman)树。
例如,下图所示的 3 棵二叉树中,都含有 4 个叶子结点 a、b、c、d,分别带权 7、5、2、4。
其中 (c) 树的带权路径长度最小,可以验证,它恰为哈夫曼树。
哈夫曼树中具有不同权值的叶子结点的分布有什么特点呢?从上面的例子中,可以直观地发现,在哈夫曼树中,权值越大的结点离根结点越近。根据这个特点,哈夫曼最早给出了一个构造哈夫曼树的方法,称哈夫曼算法。
二、哈夫曼树的构造算法
2.1 - 哈夫曼树的构造过程
-
根据给定的 n 个权值 ,构造 n 棵只有根结点的二叉树,这 n 棵二叉树构成一个森林 F(Forest)。
-
在森林 F 中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左、右子树上根结点的权值之和。
-
在森林 F 中删除这两棵树,同时将新得到的二叉树加入 F 中。
-
重复 2 和 3,直到 F 中只含一棵树为止,这棵树便是哈夫曼树。
在构造哈夫曼树时,首先选择权值小的结点,这样保证权值大的结点离根结点较近,这样一来,在计算树的带权路径长度时,自然会得到最小带权路径长度,这种生成算法是一种典型的贪心法。
例如,下图所示为上图 (c) 所示的哈夫曼树的构造过程。其中,根结点上标注的数字是所赋的权。
注意:哈夫曼树并不唯一,但 WPL 必然相同且最优。
2.2 - 哈夫曼树的存储表示
typedef struct HTNode
{
int weight; // 结点的权值
int parent; // 结点的双亲的下标
int left; // 结点的左孩子的下标
int right; // 结点的右孩子的下标
}HTNode;
typedef struct HTree
{
HTNode* data;
int size;
}HTree;
哈夫曼树中的各结点存储在 data 指向的一个大小为 2n - 1 的动态分配的数组中。
解释 1:n 个叶子结点进行 n - 1 次合并,生成 n - 1 个度为 2 的新结点,所以总结点数 N 为 2n - 1。
解释 2:因为哈夫曼树中没有度为 1 的结点,所以一棵有 n 个叶子结点的哈夫曼树总共有 2n - 1 结点,即 N = N0 + N1 + N2 = 2N0 - 1 = 2n - 1。
将叶子结点集中存储在 data[0] ~ data[n - 1] 中,将非叶子结点存储在 data[n] ~ data[2n - 2] 中。
2.3 - 算法实现
构造哈夫曼树算法的实现可以分为两大部分:
-
初始化:首先动态申请 2n - 1 个单元,然后循环 2n - 1 次,将所有单元中结点的双亲、左孩子、右孩子的下标都初始化为 -1,如果是前 n 个单元,还要初始化这 n 个单元中叶子结点的权值。
-
创建树:循环 n - 1 次,通过 n - 1 次的选择、删除与合并来创建哈夫曼树。
HuffmanTree.c:
#include "HuffmanTree.h"
#include <stdlib.h>
// 在 HT->data[0] ~ HT->data[end] 中选择两个双亲的下标为 -1 且权值最小的结点,
// 并通过输出型参数 pMinIndex1 和 pMinIndex2 返回它们在 HT->data 中的下标
void Select(HTree* HT, int end, int* pMinIndex1, int* pMinIndex2)
{
int min1 = INT_MAX;
for (int i = 0; i <= end; ++i)
{
if (HT->data[i].parent == -1 && HT->data[i].weight < min1)
{
min1 = HT->data[i].weight;
*pMinIndex1 = i;
}
}
int min2 = INT_MAX;
for (int i = 0; i <= end; ++i)
{
if (i != *pMinIndex1 &&
HT->data[i].parent == -1 && HT->data[i].weight < min2)
{
min2 = HT->data[i].weight;
*pMinIndex2 = i;
}
}
}
HTree* CreateHuffmanTree(int* weight, int n)
{
// 初始化
HTree* HT = (HTree*)malloc(sizeof(HTree));
HT->data = (HTNode*)malloc(sizeof(HTNode) * (2 * n - 1));
HT->size = 2 * n - 1;
for (int i = 0; i < 2 * n - 1; ++i)
{
if (i < n)
HT->data[i].weight = weight[i];
HT->data[i].parent = -1;
HT->data[i].left = -1;
HT->data[i].right = -1;
}
// 创建树
for (int i = n; i < 2 * n - 1; ++i)
{
int minIndex1, minIndex2;
// 选择
Select(HT, i - 1, &minIndex1, &minIndex2);
// 删除
HT->data[minIndex1].parent = i;
HT->data[minIndex2].parent = i;
// 合并
HT->data[i].left = minIndex1;
HT->data[i].right = minIndex2;
HT->data[i].weight = HT->data[minIndex1].weight + HT->data[minIndex2].weight;
}
return HT;
}
Test.c:
#include "HuffmanTree.h" // 包含了哈夫曼树的存储表示以及函数声明
#include <stdio.h>
#include <stdlib.h>
void PreOrder(HTree* HT, int rootIndex)
{
if (rootIndex == -1)
return;
printf("%d ", HT->data[rootIndex].weight);
PreOrder(HT, HT->data[rootIndex].left);
PreOrder(HT, HT->data[rootIndex].right);
}
void DestroyHuffmanTree(HTree* HT)
{
free(HT->data);
HT->data = NULL;
HT->size = 0;
}
int main()
{
int weight[8] = { 5, 29, 7, 8, 14, 23, 3, 11 };
HTree* HT = CreateHuffmanTree(weight, 8);
PreOrder(HT, HT->size - 1);
// 100 42 19 8 3 5 11 23 58 29 29 14 15 7 8
printf("\n");
DestroyHuffmanTree(HT);
return 0;
}
三、哈夫曼编码
3.1 - 哈夫曼编码的主要思想
在数据通信、数据压缩问题中,需要将数据文件转换成二进制字符 0、1 组成的二进制串,称之为编码。
假设待压缩的数据为 "abcdabcdaaaaabbbdd",数据包含 18 个字符,其中只有 a(7 个)、b(5 个)、c(2 个)、d(4 个) 四种字符,如果采用等长编码,每个字符编码取两位即可,编码总长度为 36 位。下表所示为一种等长编码方案。
字符 | 编码 |
---|---|
a | 00 |
b | 01 |
c | 10 |
d | 11 |
但这并非最优的编码方案,因为每个字符出现的频率不同,如果在编码时考虑字符出现的频率,使频率高的字符采用尽可能短的编码,频率低的字符采用稍长的编码,来构造一种不等长编码,则会获得更好的空间效率,这也是文件压缩技术的核心思想。下表所示为一种不等长编码方案,采用这种编码方案,编码总长度为 35 位。
字符 | 编码 |
---|---|
a | 0 |
b | 10 |
c | 110 |
d | 111 |
但是对于不等长编码,如果设计得不合理,便会给解码带来困难。例如,对于下表所示的另一种不等长编码方案。
字符 | 编码 |
---|---|
a | 0 |
b | 01 |
c | 010 |
d | 111 |
采用该编码方案后,上述数据编码后为 "00101111100101111100000010101111111"。但是这样的编码数据无法翻译,例如,传过去的字符串中前 4 个字符的子串 "0010" 就可有不同的译法,或是 "aba",或是 "ac"。因此,若要设计长度不等的编码,必须满足一个条件:任何一个字符的编码都不是另一个字符的编码的前缀(最左子串)。
那么如何设计有效的用于数据压缩的二进制编码呢?我们可以利用哈夫曼树来设计。第二个表格所示的编码是以字符 a、b、c、d 在数据串 "abcdabcdaaaaabbbdd" 中出现的次数 7、5、2、4 为权值,构造下图所示的哈夫曼树,约定左分支标记为 0,右分支标记为 1,则根结点到每个叶子结点路径上的 0、1 序列即为相应字符的编码。
a、b、c、d 的哈夫曼编码分别为 0、10、110 和 111。
3.2 - 哈夫曼编码的性质
-
性质一:哈夫曼编码是前缀编码。
前缀编码:如果在一个编码方案中,任一个编码都不是其他任何编码的前缀(最左子串),则称编码是前缀编码。
证明:哈夫曼编码是从根结点到叶子结点路径上的编码序列,由树的特点可知,若路径 A 是另一条路径 B 的左部分,则 B 经过了 A,则 A 的终点一定不是叶子结点。而哈夫曼编码对应路径的终点一定为叶子结点,因此,任一哈夫曼编码都不会与任意其他哈夫曼编码的前缀部分完全重叠,因此哈夫曼编码是前缀编码。
-
性质二:哈夫曼编码是最优前缀编码。
对于包含 n 个字符的数据文件,分别以它们出现次数为权值构造哈夫曼树,则利用该树对应的哈夫曼编码对文件进行编码,能使文件压缩后对应的二进制文件的长度最短。、
证明:由哈夫曼树的构造算法可知,出现次数较多的字符对应的编码较短,这便直观地说明了该定理是成立的。
3.3 - 算法实现
在构造哈夫曼树之后,求哈夫曼编码的主要思想是:依次以叶子结点出发,向上回溯至根结点位置。回溯时走左分支则生成代码 0,走右分支则生成代码 1。
由于每个哈夫曼编码是变长编码,因此使用一个字符指针数组来存放每个字符编码串的首地址。
#include "HuffmanTree.h"
#include <stdlib.h>
#include <string.h>
char** CreateHuffmanCode(HTree* HT)
{
int n = (HT->size + 1) / 2;
char** HC = (char**)malloc(sizeof(char*) * n);
char* tmp = (char*)malloc(sizeof(char) * n); // 字符编码的长度一定小于 n
tmp[n - 1] = '\0';
// 逐个求 n 个字符的哈夫曼编码
for (int i = 0; i < n; ++i)
{
int pos = n - 2;
int cur = i;
int parent = HT->data[i].parent;
while (parent != -1)
{
if (HT->data[parent].left == cur)
tmp[pos--] = '0';
else
tmp[pos--] = '1';
// 向上回溯
cur = parent;
parent = HT->data[parent].parent;
}
HC[i] = (char*)malloc(sizeof(char) * (n - 1 - pos));
strcpy(HC[i], &tmp[pos + 1]);
}
free(tmp);
tmp = NULL;
return HC;
}