一、什么是哈夫曼树
哈夫曼树(Huffman Tree),又称最优二叉树,是一种带权路径长度最短的二叉树。在数据压缩领域,哈夫曼树有着广泛的应用,哈夫曼编码就是其在电讯通信中的经典应用之一。
在深入理解哈夫曼树之前,我们需要先掌握几个核心概念:
路径与路径长度:在一棵树中,从一个结点到另一个结点所经过的通路称为路径,路径上所经过的边的数量就是路径长度。从根结点到第 i 层结点的路径长度为 i-1。
结点的权:给树中每个结点赋予的一个有某种意义的数值。
结点的带权路径长度:从根结点到该结点的路径长度与该结点权值的乘积。
树的带权路径长度(WPL):树中所有叶子结点的带权路径长度之和。
哈夫曼树的定义就是:在叶子结点和权重确定的情况下,带权路径长度最小的二叉树。权重越大的结点离树根越近,这样能使WPL达到最小。
二、哈夫曼树的构造算法
哈夫曼树的构造过程遵循一个简单而巧妙的贪心算法:
根据给定的 n 个权值,构造 n 棵只有根结点的二叉树,这些二叉树构成一个森林
在森林中选取两棵根结点权值最小的树作为左右子树,构造一棵新的二叉树,新根结点的权值为两棵子树根结点权值之和
从森林中删除这两棵树,将新得到的二叉树加入森林
重复步骤2和3,直到森林中只剩一棵树为止
以权重 2、3、7、9、18、25 为例:先选出最小的2和3合并为5,再从当前集合{5,7,9,18,25}中选出最小的5和7合并为12,以此类推,直到所有结点合并成一棵树。
三、C语言代码实现
下面我们来分析一份完整的哈夫曼树C语言实现代码。
3.1 结点结构定义
typedef struct { int weight; // 权值 int parent; // 父结点下标 int lchild; // 左孩子下标 int rchild; // 右孩子下标 } HuffmanNode, *HuffmanTree;这里采用顺序存储结构(数组)来存储哈夫曼树。每个结点包含四个字段:权值、父结点下标、左右孩子下标。哈夫曼树一般没有增删改查的操作,一旦创建就直接使用,因此数组存储已经足够。
3.2 选择最小权值结点
static void selectMinNode(HuffmanTree tree, int n, int* s1, int* s2) { int minIndex = 0; // 找到第一个父节点为0的值,让他做参考 for (int i = 1; i <= n; ++i) { if (tree[i].parent == 0) { minIndex = i; break; } } // 比较下列的值,若有更小的值,则更新 for (int i = 1; i <= n; ++i) { if (tree[i].weight < tree[minIndex].weight && tree[i].parent == 0) { minIndex = i; } } *s1 = minIndex; // 找到第一个父节点为0的值,让他做参考(排除s1) for (int i = 1; i <= n; ++i) { if (tree[i].parent == 0 && i != *s1) { minIndex = i; break; } } // 比较下列的值,若有更小的值,则更新(排除s1) for (int i = 1; i <= n; ++i) { if (tree[i].weight < tree[minIndex].weight && tree[i].parent == 0 && i != *s1) { minIndex = i; } } *s2 = minIndex; }selectMinNode函数的作用是在前 n 个结点中,找出父结点为0(即尚未被合并)的两个权值最小的结点。函数分两次查找:第一次找出最小权值结点存入s1,第二次找出次小权值结点存入s2,每次查找时都要排除已选中的结点。
3.3 构建哈夫曼树
HuffmanTree createHuffmanTree(const int* w, int n) { HuffmanTree tree; int m = 2 * n - 1; // 哈夫曼树共有 2n-1 个结点 tree = malloc(sizeof(HuffmanNode) * (m + 1)); if (tree == NULL) { return NULL; } memset(tree, 0, sizeof(HuffmanNode) * (m + 1)); // 初始化前 n 个叶子结点 for (int i = 1; i <= n; ++i) { tree[i].weight = w[i - 1]; } int s1, s2; // 从 n+1 到 m,逐步构造内部结点 for (int i = n + 1; i <= m; ++i) { selectMinNode(tree, i - 1, &s1, &s2); tree[s1].parent = tree[s2].parent = i; tree[i].lchild = s1; tree[i].rchild = s2; tree[i].weight = tree[s1].weight + tree[s2].weight; } return tree; }核心逻辑如下:
一棵有 n 个叶子结点的哈夫曼树共有2n-1个结点
前 n 个位置存放初始的叶子结点
从 n+1 开始,每次选取两个权值最小的结点合并,产生新结点
新结点的权值为两子结点权值之和,左右孩子分别指向两个子结点
两个子结点的 parent 更新为新结点的下标
3.4 打印与测试
void showHuffmanTree(HuffmanTree tree, int m) { for (int i = 1; i <= m; ++i) { printf("%d\t%d\t%d\t%d\t%d\n", i, tree[i].weight, tree[i].parent, tree[i].lchild, tree[i].rchild); } } int main() { int w[] = { 5, 29, 7, 8, 14, 23, 3, 11 }; char show[] = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H' }; HuffmanTree tree = createHuffmanTree(w, sizeof(w) / sizeof(w[0])); showHuffmanTree(tree, 2 * (sizeof(w) / sizeof(w[0])) - 1); }测试数据为 8 个权值:5、29、7、8、14、23、3、11,对应字符 A 到 H。
四、哈夫曼编码
4.1 为什么需要变长编码
定长编码(如ASCII)每个字符使用固定长度的二进制位。对于仅包含ASCII字符的纯文本,每个字符占8位,存在明显的空间浪费。而变长编码的思想是:赋予高频字符短码字,低频字符长码字。
4.2 前缀码
变长编码面临一个问题:如何避免解码时的二义性?
前缀码的定义是:没有任何码字是其他码字的前缀。例如,如果 a 的编码是 0,那么其他所有字符的编码都不能以 0 开头。
哈夫曼编码正是一种前缀码。在哈夫曼树中,每个字符都对应一个叶子结点,从根到该叶子结点的路径就构成了该字符的编码——左子树为0,右子树为1。由于所有字符都在叶子结点上,没有任何一个字符的编码会是另一个字符编码的前缀。
4.3 压缩效果
哈夫曼编码的压缩率通常在 20% 到 90% 之间,具体取决于数据的特性。例如,对一个包含6个不同字符、共10万个字符的文件,定长编码需要30万位,而哈夫曼编码仅需22.4万位。在现代应用中,哈夫曼编码被广泛应用于ZIP压缩、PNG图像、HTTP/2头部压缩及多媒体处理等领域。
五、总结
哈夫曼树的核心价值在于通过贪心策略构建带权路径长度最小的二叉树,从而实现最优的数据压缩。理解哈夫曼树需要掌握以下要点:
带权路径长度(WPL)是衡量二叉树性能的关键指标,WPL越小,树的性能越优
构造算法本质是贪心——每次合并权值最小的两个结点
哈夫曼编码是一种前缀码,能保证解码的唯一性
代码实现采用顺序存储结构,通过数组模拟树形结构