【数据结构】哈夫曼树的原理、实现与考研真题解析

一、什么是哈夫曼树

哈夫曼树(Huffman Tree) 也叫最优二叉树 ,是一种带权路径长度(WPL)最小的二叉树。

  • 节点带有权值(比如字符出现的频率)
  • 树的带权路径长度 WPL = 所有叶子节点的权值 × 该节点到根的路径长度 之和
  • 哈夫曼树的特点:
    1. 权值越大的节点,离根越近
    2. 只有度为 0(叶子)度为 2的节点,没有度为 1 的节点
    3. n 个叶子节点的哈夫曼树,总共有 2n−1 个节点

二、哈夫曼编码与前缀码

哈夫曼树最经典的应用就是哈夫曼编码,它是一种无损数据压缩算法,核心是利用字符出现的频率(权值),为频率高的字符分配更短的二进制编码,频率低的字符分配更长的编码,从而大幅减少数据传输的总长度。

前缀码的定义

前缀码 :任意一个字符的编码,都不是其他字符编码的前缀。前缀码的核心作用是保证解码的唯一性:在没有分隔符的二进制串中,不会出现歧义,能唯一还原出原始字符。哈夫曼编码天然是前缀码,因为哈夫曼树中所有叶子节点的编码对应根到叶子的唯一路径,不存在一个叶子节点的路径是另一个叶子节点路径的前缀。

三、哈夫曼树构造步骤

哈夫曼树就是每次选两个最小权值节点合并,直到只剩一棵树,最终让整棵树的带权路径长度最小。

核心思想

权值越大的节点离根越近使带权路径长度(WPL)最小

构造步骤

步骤 操作
1. 初始化 将n个带权叶子节点各自作为独立的树(森林)
2. 选最小 每次从森林中选取两个根节点权值最小的树
3. 合并 以这两棵树为左右子树,新建根节点(权值=两者之和)
4. 重复 将新树放回森林,重复步骤2-3,直到只剩一棵树

以 6 个字母的频率表为例,完整演示哈夫曼编码的生成过程:

字母 A B C D E F
频率 27 8 15 15 30 5
步骤 1:构造哈夫曼树

按照哈夫曼树的构造规则,以频率为权值,构造出对应的哈夫曼树,并约定:左分支记为 0,右分支记为 1

步骤 2:生成哈夫曼编码

从根节点出发,沿着到每个叶子节点的路径,记录 0/1 序列,即为该字母的哈夫曼编码:

  • A:根→0→1 → 编码 01
  • B:根→1→0→0→1 → 编码 1001
  • C:根→1→0→1 → 编码 101
  • D:根→0→0 → 编码 00
  • E:根→1→1 → 编码 11
  • F:根→1→0→0→0 → 编码 1000

最终编码表如下:

字母 A B C D E F
等长二进制编码 000 001 010 011 100 101
哈夫曼编码 01 1001 101 00 11 1000
步骤 3:编码效果对比

以字符串 BADCADFEED 为例,对比两种编码的总长度:

  • 原等长编码二进制串:001 000 011 010 000 011 101 100 100 011,总长度为 3×10=30 位
  • 哈夫曼编码二进制串:1001 01 00 101 01 00 1000 11 11 00,总长度为 4+2+2+3+2+2+4+2+2+2=25 位

可以看到,哈夫曼编码通过为高频字符分配短编码,成功压缩了数据长度,同时因为是前缀码,解码时不会出现歧义。

四、哈夫曼树的 C 语言实现

结合不同成绩等级占比的场景(不及格 5%、及格 15%、中等 40%、良好 30%、优秀 10%),用 C 语言实现哈夫曼树的构建与结构打印,代码遵循哈夫曼树的构造规则,注释清晰且易理解。

1. 数据结构设计

定义哈夫曼树的节点结构体,包含权值父节点下标左孩子下标右孩子下标四个核心字段,通过数组存储哈夫曼树的所有节点(顺序存储结构),便于节点的查找与合并。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#define MAX_N 100  // 定义最大节点数,满足2n-1的需求
// 哈夫曼树节点结构体
typedef struct {
    int weight;   // 节点权值(如成绩等级占比)
    int parent;   // 父节点下标,-1表示无父节点
    int lchild;   // 左孩子下标,-1表示无左孩子
    int rchild;   // 右孩子下标,-1表示无右孩子
} HTNode;
typedef HTNode HuffmanTree[MAX_N];  // 数组形式存储哈夫曼树

2. 核心辅助函数:选择两个最小权值节点

实现selectTwoMin函数,在未被合并的节点(父节点为 - 1)中选出权值最小的两个节点,返回其下标,这是哈夫曼树构造的核心步骤,直接体现贪心策略。

cpp 复制代码
// 在 HT[0...n-1] 中选出两个无父节点的最小权值节点,下标存入 s1 和 s2
void selectTwoMin(HuffmanTree HT, int n, int *s1, int *s2) {
    int i;
    *s1 = *s2 = -1;  // 初始化下标为-1,表示未找到
    for (i = 0; i < n; ++i) {
        if (HT[i].parent == -1) { // 仅选择未被合并的节点
            if (*s1 == -1 || HT[i].weight < HT[*s1].weight) {
                *s2 = *s1;
                *s1 = i;
            } else if (*s2 == -1 || HT[i].weight < HT[*s2].weight) {
                *s2 = i;
            }
        }
    }
}

3. 哈夫曼树的构建函数

实现createHuffmanTree函数,遵循构造规则完成哈夫曼树的构建,核心步骤为初始化节点循环合并最小权值节点,最终生成总节点数为2n−1的哈夫曼树。

cpp 复制代码
// 创建哈夫曼树,weights为权值数组,n为叶子节点个数
void createHuffmanTree(HuffmanTree HT, int weights[], int n) {
    int i;
    int m = 2 * n - 1;  // 哈夫曼树总节点数:2n-1
    // 初始化所有节点
    for (i = 0; i < m; ++i) {
        HT[i].weight = (i < n) ? weights[i] : 0;  // 前n个为叶子节点,赋权值;其余初始化为0
        HT[i].parent = -1;
        HT[i].lchild = -1;
        HT[i].rchild = -1;
    }
    // 构造内部节点(从n到m-1)
    for (i = n; i < m; ++i) {
        int s1, s2;
        selectTwoMin(HT, i, &s1, &s2);  // 找到两个最小权值的未合并节点
        // 合并s1和s2,创建新节点i
        HT[i].weight = HT[s1].weight + HT[s2].weight;
        HT[i].lchild = s1;
        HT[i].rchild = s2;
        HT[s1].parent = i;  // 给s1和s2设置父节点
        HT[s2].parent = i;
    }
}

4. 打印哈夫曼树结构

实现printHuffmanTree函数,打印哈夫曼树所有节点的索引、权值、双亲、左子、右子信息,便于验证哈夫曼树的构造结果。

cpp 复制代码
// 打印哈夫曼树结构,直观查看节点关系
void printHuffmanTree(HuffmanTree HT, int n) {
    int i;
    int total = 2 * n - 1;
    printf("索引  权值  双亲  左子  右子\n");
    for (i = 0; i < total; ++i) {
        printf("%3d  %4d  %4d  %4d  %4d\n",
               i, HT[i].weight, HT[i].parent, HT[i].lchild, HT[i].rchild);
    }
}

5. 主函数测试

成绩等级占比为权值,调用上述函数完成哈夫曼树的构建与打印,验证代码的正确性。

cpp 复制代码
int main() {
    // 权值数组:不及格5、及格15、中等40、良好30、优秀10
    int weights[] = {5, 15, 40, 30, 10};
    int n = sizeof(weights) / sizeof(weights[0]);  // 叶子节点个数n=5
    HuffmanTree HT;
    createHuffmanTree(HT, weights, n);  // 构建哈夫曼树
    printHuffmanTree(HT, n);            // 输出节点信息
    return 0;
}

6.完整可运行代码如下

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAXSIZE 100  // 最大节点数
#define MAXCODE 100   // 编码最大长度

// 哈夫曼树节点结构体定义
typedef struct {
    int weight;              // 节点权值
    int parent, lchild, rchild;  // 双亲、左孩子、右孩子下标
} HTNode, *HuffmanTree;

// 哈夫曼编码结构体:存储每个叶子节点的编码字符串
typedef char **HuffmanCode;

// ===================== 核心函数1:挑选两个权值最小的节点 =====================
// 在HT[0]~HT[n-1]中,选parent为-1(未合并)的两个最小权值节点,下标存入s1、s2
void Select(HuffmanTree HT, int n, int *s1, int *s2) {
    int i, min1, min2;  // min1最小权值,min2次小权值
    min1 = min2 = 9999; // 初始化一个大数
    *s1 = *s2 = -1;

    // 遍历所有节点,找最小的两个
    for (i = 0; i < n; i++) {
        // 只处理没有双亲的节点
        if (HT[i].parent == -1) {
            // 当前节点权值比最小还小
            if (HT[i].weight < min1) {
                // 原来最小变成次小
                min2 = min1;
                *s2 = *s1;
                // 更新最小
                min1 = HT[i].weight;
                *s1 = i;
            }
            // 当前节点权值比次小小
            else if (HT[i].weight < min2) {
                min2 = HT[i].weight;
                *s2 = i;
            }
        }
    }
}

// ===================== 核心函数2:构建哈夫曼树 =====================
// HT:哈夫曼树数组
// w:权值数组
// n:叶子节点个数
void CreateHuffmanTree(HuffmanTree *HT, int w[], int n) {
    int i, s1, s2, m;

    // 特殊情况:只有1个叶子节点
    if (n <= 1) return;

    // 哈夫曼树总结点数:2n-1
    m = 2 * n - 1;

    // 分配内存
    *HT = (HTNode *)malloc((m) * sizeof(HTNode));

    // ===================== 初始化所有节点 =====================
    for (i = 0; i < m; i++) {
        (*HT)[i].weight = 0;
        (*HT)[i].parent = -1;   // 双亲初始-1
        (*HT)[i].lchild = -1;   // 左孩子初始-1
        (*HT)[i].rchild = -1;   // 右孩子初始-1
    }

    // 给前n个叶子节点赋权值
    for (i = 0; i < n; i++) {
        (*HT)[i].weight = w[i];
    }

    // ===================== 开始构建哈夫曼树 =====================
    // 循环创建 n-1 个内部节点(从n到m-1)
    for (i = n; i < m; i++) {
        // 挑选两个最小权值节点
        Select(*HT, i, &s1, &s2);

        // 合并两个节点,生成新节点i
        (*HT)[s1].parent = i;
        (*HT)[s2].parent = i;
        (*HT)[i].lchild = s1;
        (*HT)[i].rchild = s2;
        (*HT)[i].weight = (*HT)[s1].weight + (*HT)[s2].weight;
    }
}

// ===================== 核心函数3:生成哈夫曼编码(前缀码) =====================
// HT:哈夫曼树
// HC:存储哈夫曼编码
// n:叶子节点数
void CreateHuffmanCode(HuffmanTree HT, HuffmanCode *HC, int n) {
    int i, start, c, p;
    char *cd;

    // 分配编码存储空间
    *HC = (HuffmanCode)malloc(n * sizeof(char *));
    // 临时存放编码的数组
    cd = (char *)malloc(n * sizeof(char));
    // 编码结束符
    cd[n - 1] = '\0';

    // 为每个叶子节点求编码
    for (i = 0; i < n; i++) {
        // 编码起始位置(从后往前存)
        start = n - 1;
        // c:当前节点
        c = i;
        // p:c的双亲节点
        p = HT[c].parent;

        // 向上遍历到根节点(根节点parent=-1)
        while (p != -1) {
            // 如果c是左孩子,编码为0
            if (HT[p].lchild == c)
                cd[--start] = '0';
            // 如果c是右孩子,编码为1
            else
                cd[--start] = '1';

            // 继续向上
            c = p;
            p = HT[c].parent;
        }

        // 为第i个编码分配空间
        (*HC)[i] = (char *)malloc((n - start) * sizeof(char));
        // 复制编码
        strcpy((*HC)[i], &cd[start]);
    }
    // 释放临时空间
    free(cd);
}

// ===================== 计算带权路径长度WPL =====================
int GetWPL(HuffmanTree HT, int n) {
    int i, wpl = 0;
    int c, p, len;

    // 遍历每个叶子节点
    for (i = 0; i < n; i++) {
        len = 0;
        c = i;
        p = HT[c].parent;
        // 统计路径长度
        while (p != -1) {
            len++;
            c = p;
            p = HT[c].parent;
        }
        // 累加 权值×路径长度
        wpl += HT[i].weight * len;
    }
    return wpl;
}

// ===================== 打印哈夫曼树结构 =====================
void PrintHT(HuffmanTree HT, int n) {
    int i, m = 2 * n - 1;
    printf("\n===== 哈夫曼树节点信息 =====\n");
    printf("下标\t权值\t双亲\t左孩子\t右孩子\n");
    for (i = 0; i < m; i++) {
        printf("%d\t%d\t%d\t%d\t%d\n",
               i, HT[i].weight, HT[i].parent, HT[i].lchild, HT[i].rchild);
    }
}

// ===================== 主函数:测试 =====================
int main() {
    // 叶子节点权值(示例:不及格5、及格15、中等40、良好30、优秀10)
    int w[] = {5, 15, 40, 30, 10};
    // 叶子节点名称(对应上面权值)
    char name[][20] = {"不及格", "及格", "中等", "良好", "优秀"};

    int n = sizeof(w) / sizeof(w[0]);  // 叶子节点数量 n=5
    HuffmanTree HT;                   // 哈夫曼树
    HuffmanCode HC;                   // 哈夫曼编码
    int i, wpl;

    // 1. 创建哈夫曼树
    CreateHuffmanTree(&HT, w, n);

    // 2. 打印哈夫曼树结构
    PrintHT(HT, n);

    // 3. 生成哈夫曼编码
    CreateHuffmanCode(HT, &HC, n);

    // 4. 打印哈夫曼编码(前缀码)
    printf("\n===== 哈夫曼编码(前缀码) =====\n");
    for (i = 0; i < n; i++) {
        printf("%s\t(权值:%d)\t编码:%s\n", name[i], w[i], HC[i]);
    }

    // 5. 计算并打印WPL
    wpl = GetWPL(HT, n);
    printf("\n带权路径长度 WPL = %d\n", wpl);

    // 6. 前缀码说明
    printf("\n===== 前缀码特性说明 =====\n");
    printf("1. 哈夫曼编码是前缀码:任意编码都不是其他编码的前缀\n");
    printf("2. 解码无歧义,可唯一还原原始数据\n");

    // 释放内存
    for (i = 0; i < n; i++) {
        free(HC[i]);
    }
    free(HC);
    free(HT);

    return 0;
}

代码运行结果

结果分析:总节点数为2×5−1=9,根节点为索引 8(权值 100,无父节点),

所有叶子节点(0-4)无孩子节点,内部节点均有左右两个孩子,符合哈夫曼树的特性。


五、哈夫曼树考研真题解析

哈夫曼树是数据结构考研的高频考点,题型主要包括哈夫曼树的构造带权路径长度(WPL)的计算哈夫曼编码的生成前缀码的判断等,以下结合考研经典真题,讲解解题思路与步骤。

真题 1:2010 年统考真题

答案:A

解析

  • A 错误:哈夫曼树是带权路径长度最短的二叉树,不是完全二叉树,完全二叉树是按层序编号的结构,哈夫曼树不满足该特性。
  • B 正确:哈夫曼树的构造规则是每次合并两个节点,因此不存在度为 1 的节点。
  • C 正确:构造哈夫曼树时,权值最小的两个节点会被优先合并为兄弟节点。
  • D 正确:非叶节点的权值是子节点权值之和,因此一定大于等于子节点的权值,也一定不小于下一层的任意节点权值。

真题 2:2014 年统考真题

答案:D

解析 :前缀码的核心要求是任意编码都不是其他编码的前缀

  • D 选项中,1101100 的前缀,因此不是前缀编码,其余选项均满足前缀码要求。

真题 3:2021 年统考真题

答案:B

解析

最小带权路径长度即哈夫曼树的 WPL,构造哈夫曼树后计算:

  • 合并 10 和 12 → 22;合并 22 和 16 → 38;合并 38 和 21 → 59;合并 59 和 30 → 89
  • WPL = 16×2 + 21×2 + 30×2 + 10×3 + 12×3 = 32 + 42 + 60 + 30 + 36 = 200(也可通过累加内部节点权值计算:22+38+59+89=200)

真题 4:2019 年统考真题

答案:C

解析

哈夫曼树的总节点数公式为 m=2n−1,代入 m=115:2n−1=115→2n=116→n=58


五、哈夫曼树的应用场景

哈夫曼树的核心价值是带权路径长度最短,基于这一特性,在实际开发和算法设计中有着广泛应用:

  1. 数据压缩:哈夫曼编码是无损数据压缩的经典算法,如 JPEG、ZIP、GZIP 等格式均采用了哈夫曼编码,对出现频率高的字符赋予短编码,频率低的字符赋予长编码,大幅减少数据存储体积。
  2. 权重决策:在任务调度、资源分配等场景中,对优先级(权值)高的任务赋予更短的处理路径,提高处理效率。
  3. 最优判定:在判定树设计中(如成绩等级判定、商品等级判定),哈夫曼树可使平均判定次数最少,优化判定逻辑。
  4. 通信编码:在远距离通信中,哈夫曼编码可减少数据传输的总长度,降低传输成本,同时保证解码的唯一性。

六、总结

哈夫曼树是数据结构中贪心算法的典型应用,其核心是通过 "每次合并最小权值节点" 的策略,构造出带权路径长度最短的二叉树。本文从基础概念出发,结合成绩占比场景实现了哈夫曼树的 C 语言代码,详解了哈夫曼编码与前缀码的原理,又通过考研真题讲解了构造、WPL 计算、编码生成等高频考点,核心要点总结:

  1. 哈夫曼树的结构特性:2n−1个总节点,无度为 1 的节点,是严格二叉树。
  2. 构造的核心是贪心策略,考研中需遵循固定的左右孩子约定。
  3. WPL 的简便计算方法为累加所有内部节点的权值,可快速解题。
  4. 哈夫曼编码是前缀编码,由根到叶子的左右分支 0/1 序列生成,保证解码唯一性。
  5. 前缀码的判断核心:任意编码都不是其他编码的前缀,是哈夫曼编码的核心特性。
相关推荐
知识浅谈1 天前
DeepSeek V4 和 GPT-5.5 在同一天发布了??我也很懵,但对比完我悟了
算法
DeepModel1 天前
通俗易懂讲透 Q-Learning:从零学会强化学习核心算法
人工智能·学习·算法·机器学习
田梓燊1 天前
力扣:19.删除链表的倒数第 N 个结点
算法·leetcode·链表
简简单单做算法1 天前
基于GA遗传优化双BP神经网络的时间序列预测算法matlab仿真
神经网络·算法·matlab·时间序列预测·双bp神经网络
阿豪学编程1 天前
面试题map/unordered相关
数据结构
guygg881 天前
利用遗传算法解决列车优化运行问题的MATLAB实现
开发语言·算法·matlab
武藤一雄1 天前
19个核心算法(C#版)
数据结构·windows·算法·c#·排序算法·.net·.netcore
sali-tec1 天前
C# 基于OpenCv的视觉工作流-章52-交点查找
图像处理·人工智能·opencv·算法·计算机视觉
梦想的颜色1 天前
mongoTemplate + Java 增删改查基础介绍
数据结构·数据库·mysql
yu85939581 天前
MATLAB连续线性化模型预测控制(SL-MPC)
算法·机器学习·matlab