*C++哈夫曼树与哈夫曼编码

1. 什么是哈夫曼树,基础概念扫盲

假设我们有若干叶子结点,每个结点有一个权值 (可以理解成权重、出现次数),我们可以搭建很多形态不一样的二叉树。

其中带权路径长度WPL最小的那一棵二叉树,就是哈夫曼树

先把4个核心名词搞懂:

名词 解释
路径长度 从根结点走到目标叶子,经过的线段数量。根到自己路径长度为0。
结点的权 结点携带的数值,可以代表数字大小、字符出现频率。
结点带权路径长度 路径长度 × 结点权值。
WPL树的带权路径长度 所有叶子结点的带权路径长度相加。公式:WPL=W1L1+W2L2+...+WnLnWPL=W_1L_1+W_2L_2+...+W_nL_nWPL=W1L1+W2L2+...+WnLn

举个简单例子:叶子权值15,路径长度2,该结点带权路径长度 = 15∗215*215∗2。整棵树WPL把所有叶子这个数值加起来。

哈夫曼树定义 :以n个权值作为叶子结点,构造出来,WPL最小的二叉树就是哈夫曼树。

✨哈夫曼树五大特点:

  1. 原始输入结点全部是叶子结点。
  2. 权值越大的叶子,距离根结点越近;权值越小,离根越远(贪心思想,减少总代价)。
  3. 所有非叶子结点,一定有左右两个子结点,没有单分支结点。
  4. 如果叶子数量为n,整棵树总结点数量 = 2n−12n-12n−1。
  5. 哈夫曼树形态不唯一:合并时交换左右子树可以长出不一样树形,但是WPL一定完全相同。

2. 哈夫曼树如何构造?核心步骤

核心贪心策略:每次挑出现有集合里面权值最小的两棵树合并。

完整步骤:

  1. 将n个权值,生成n棵独立的单结点树,放到候选集合。
  2. 在候选集合选出权值最小的两棵树。
  3. 创建新父结点,权值等于两棵子树根权值之和;把两棵树分别作为新结点的左右孩子。
  4. 将这个新生成的树放回候选集合。
  5. 重复2‑4,直到候选集合只剩下唯一一棵树,这棵树就是哈夫曼树。

示例:输入权值集合:{5,3,8,2,9}

  1. 最小两个:2、3,合并为5,集合变成 {5, 5, 8,9},本次代价 5
  2. 最小两个:5、5,合并为10,集合变成 {8,9,10},本次代价10
  3. 最小两个:8、9,合并为17,集合变成 {10,17},本次代价17
  4. 最小两个:10、17,合并为27,集合 {27},本次代价27

总代价:5+10+17+27=595+10+17+27 = 595+10+17+27=59,这个总代价等价于哈夫曼树WPL。

注意:总代价就是WPL,这也是经典哈夫曼树编程题的考点。

3. 双队列法实现哈夫曼树(C++思路)

如果每次都遍历全部元素找最小值,效率比较差。这里使用两个队列q1、q2完成构造。

  • q1:原始叶子结点,先升序排序,全部入队q1。q1内部保持从小到大有序。
  • q2:存放合并之后新生成的内部结点。因为每次合并出的新结点权值只会越来越大,q2天然保持升序有序

每次取最小结点的时候,只对比q1、q2两个队列队首元素,选出更小的那个出队。不用遍历全部数组,提升效率。

函数getMinFromQueues()规则(重点,极易写错):

  1. q2为空,q1不为空:取q1队首弹出返回
  2. q1为空,q2不为空:取q2队首弹出返回
  3. q1、q2都不为空:对比两个队首权值,取权更小的,弹出返回
  4. 两个队列都空,返回nullptr

拿到两个最小结点a、b,合并生成新结点;新结点压入q2。循环直到只剩下一个结点,就是哈夫曼树根。

4. WPL带权路径长度递归计算

递归思路:函数参数len代表当前结点的路径长度。

  1. 如果结点为空,返回0;
  2. 如果当前是叶子结点:返回 weight * len
  3. 否则递归算左子树、右子树,往下一层路径长度+1;返回左右子树WPL相加。
cpp 复制代码
int getWPL(HuffmanNode* root, int len)
{
    if(root == nullptr) return 0;
    //叶子结点
    if(root->left == nullptr && root->right == nullptr)
    {
        return root->weight * len;
    }
    int leftWpl = getWPL(root->left, len+1);
    int rightWpl = getWPL(root->right, len+1);
    return leftWpl + rightWpl;
}

5. 哈夫曼编码:数据压缩的秘密

等长编码 vs 变长编码

  • 等长编码:每个字符二进制位数固定。例如 ASCII 码每个字符占 8bit。简单,但是浪费存储空间。
  • 变长编码 :高频出现字符分配短编码,低频字符分配长编码,整体总比特数变少,实现压缩。哈夫曼编码就是经典变长编码

举例子字符串:AAAAABCCCCCCDDD

字符统计:A 出现 5 次,B 出现 1 次,C 出现 6 次,D 出现 3 次。

  • ASCII 等长编码总比特:(15*8=120bit)
  • 哈夫曼编码结果:C:0A:11B:100D:101

总比特计算:(61 + 5 2 +13 +33 = 28bit),空间大幅压缩!

哈夫曼编码生成规则

  1. 将字符频率作为权值,构建哈夫曼树;字符全部放在叶子结点。
  2. 约定:走向左分支写 0,走向右分支写 1
  3. 从根走到叶子结点,一路上收集 0、1 字符串,就是该字符哈夫曼编码。

✨哈夫曼编码两大特性:

  1. 前缀特性:任意一个字符编码,不会是另一个编码的前缀,解码不会歧义。
  2. 编码不唯一:左右分支 0、1 可以互换,得到另一套合法编码;压缩效果不变。

题目要求输出:编码长度短的放前面;编码长度相同,按字母 a‑z 字典序输出。

6. 完整可编译 C++ 全部代码

C++11 及以上均可编译,头文件齐全,修复教案全部 OCR 错误。

复制代码
#include <iostream>
#include <queue>
#include <algorithm>
#include <string>
using namespace std;

#define MAX 20

//哈夫曼树结点结构体
struct HuffmanNode
{
    char ch;                //叶子结点保存对应字符,内部结点无意义
    int weight;             //权值,字符出现频率
    HuffmanNode* left;      //左孩子指针
    HuffmanNode* right;     //右孩子指针

    //构造函数
    HuffmanNode(char c = '\0', int val = 0)
    {
        ch = c;
        weight = val;
        left = nullptr;
        right = nullptr;
    }
};

//两个全局队列,双队列构造哈夫曼树
queue<HuffmanNode*> q1;
queue<HuffmanNode*> q2;

//保存哈夫曼编码的结构体
struct HuffmanCode
{
    char ch;
    string code;
} codeList[MAX];

int index = 0; //codeList数组当前存储位置下标

/**
 * @brief 从q1 q2取出权最小结点,并且出队
 * @return 最小结点指针,空返回nullptr
 */
HuffmanNode* getMinFromQueues()
{
    //q2空,q1不为空
    if(q2.empty() && !q1.empty())
    {
        HuffmanNode* temp = q1.front();
        q1.pop();
        return temp;
    }
    //q1空,q2不为空
    if(q1.empty() && !q2.empty())
    {
        HuffmanNode* temp = q2.front();
        q2.pop();
        return temp;
    }
    //两个队列都不为空,对比队首
    if(!q1.empty() && !q2.empty())
    {
        HuffmanNode* t1 = q1.front();
        HuffmanNode* t2 = q2.front();
        if(t1->weight < t2->weight)
        {
            q1.pop();
            return t1;
        }
        else
        {
            q2.pop();
            return t2;
        }
    }
    //两个队列都为空
    return nullptr;
}

/**
 * @brief 构建哈夫曼树,返回根结点指针
 */
HuffmanNode* buildHuffmanTree()
{
    while(true)
    {
        HuffmanNode* a = getMinFromQueues();
        HuffmanNode* b = getMinFromQueues();
        if(b == nullptr) //只剩最后一个结点
        {
            return a;
        }
        //合并两个结点生成新内部结点
        HuffmanNode* newNode = new HuffmanNode('\0', a->weight + b->weight);
        newNode->left = a;
        newNode->right = b;
        q2.push(newNode);
    }
}

/**
 * @brief 递归计算WPL带权路径长度
 * @param len 当前结点的路径长度
 */
int getWPL(HuffmanNode* root, int len)
{
    if(root == nullptr) return 0;
    if(root->left == nullptr && root->right == nullptr)
    {
        return root->weight * len;
    }
    int left = getWPL(root->left, len + 1);
    int right = getWPL(root->right, len + 1);
    return left + right;
}

/**
 * @brief DFS深度优先遍历,收集哈夫曼编码存入codeList
 * @param code 当前路径累积的01字符串
 */
void genHuffmanCode(HuffmanNode* root, string code)
{
    if(root == nullptr) return;
    //到达叶子结点,保存编码
    if(root->left == nullptr && root->right == nullptr)
    {
        codeList[index].ch = root->ch;
        codeList[index].code = code;
        index++;
        return;
    }
    genHuffmanCode(root->left, code + '0');  //左分支0
    genHuffmanCode(root->right, code + '1'); //右分支1
}

/**
 * @brief sort排序比较函数
 * 规则:编码长度从小到大;长度相等,按字符字典序升序
 */
bool cmp(HuffmanCode x, HuffmanCode y)
{
    if(x.code.size() != y.code.size())
    {
        return x.code.size() < y.code.size();
    }
    else
    {
        return x.ch < y.ch;
    }
}

int main()
{
    //========样例1:计算哈夫曼总费用(WPL) 输入5,5 3 8 2 9 输出59========
    /*
    int n;
    cin >> n;
    int arr[105];
    for(int i = 0; i < n; i++)
    {
        cin >> arr[i];
    }
    sort(arr, arr + n);
    for(int i = 0; i < n; i++)
    {
        q1.push(new HuffmanNode('\0', arr[i]));
    }
    HuffmanNode* root = buildHuffmanTree();
    cout << getWPL(root,0) << endl;
    return 0;
    */

    //========样例2:生成哈夫曼编码 输入4,B 1 D 3 A5 C6========
    int n;
    cin >> n;
    for(int i = 0; i < n; i++)
    {
        char c; int w;
        cin >> c >> w;
        q1.push(new HuffmanNode(c, w));
    }
    HuffmanNode* root = buildHuffmanTree();
    genHuffmanCode(root, "");
    sort(codeList, codeList + index, cmp);
    for(int i = 0; i < index; i++)
    {
        cout << codeList[i].ch << " " << codeList[i].code << endl;
    }
    return 0;
}

使用提示:main 函数里面两段代码二选一。注释掉一段,放开另一段,分别测试两道例题。

7. 程序运行示例演示

示例 1(求 WPL 总费用)

输入:

复制代码
5
5 3 8 2 9

输出:

复制代码
59

示例 2(生成哈夫曼编码)

输入:

复制代码
4
B 1
D 3
A 5
C 6

输出:

复制代码
C 0
A 11
B 100
D 101

8. 代码分段解析

  1. 结构体 HuffmanNode:存储字符、权值,左右孩子指针,构造函数初始化指针为空。
  2. getMinFromQueues:双队列挑选权最小结点,是整个算法核心,注意条件判断顺序,取完元素必须 pop 出队。
  3. buildHuffmanTree:循环取出两个最小结点合并,新结点放入 q2,直到只剩一个根。
  4. getWPL 递归:叶子结点计算 weight*len,递归向下路径长度 + 1,累加左右子树结果。
  5. genHuffmanCode 深度遍历 :左拼接'0',右拼接'1';遇到叶子,把字符和编码存入数组。
  6. cmp 比较函数:sort 排序,先比较编码字符串长度,长度相同按字符字典序。
  7. main 函数两套逻辑:①只算 WPL;②读字符 + 频率生成哈夫曼编码输出。

9. 时间 & 空间复杂度通俗分析

  • 时间复杂度 (O(n\log n))
    n 代表叶子结点数量。初始排序 q1 数组(O(n\log n));每次合并取队首操作(O(1)),一共 n‑1 次合并;递归遍历树(O(n))。整体复杂度主要来自初始排序。
  • 空间复杂度 (O(n))
    哈夫曼树总结点(2n‑1),队列、数组存储全部结点,占用和 n 成正比内存。

适合初高中理解,不用深究复杂数学推导。

10. 高频踩坑易错点汇总

  1. ===混淆:判断空指针写成if(b=nullptr),少写一个等号,直接赋值,逻辑完全错乱。
  2. 指针访问符号错误:newNode‑left,应该是newNode->left是减号,->才是指针访问成员。
  3. getMinFromQueues 条件写反,没有执行 pop (),结点没有出队,队列无限循环卡死。
  4. 递归生成编码写成code+0',单引号位置错误,必须写code+'0'
  5. 递归求 WPL 忘记len+1,路径长度不会向下累加,WPL 计算结果错误。
  6. sort 的 cmp 排序函数写反逻辑,编码从长到短输出,或者同长度字符乱序。
  7. 双队列构造哈夫曼树,q1 没有提前排序,队列无序,取最小值出错。
  8. 哈夫曼编码递归的时候,没有传 string 的新副本,直接原地修改字符串,编码串错乱。
  9. 忘记哈夫曼树非叶子结点没有有效字符,只有叶子结点收集编码。

11. 课后练习题

  1. 手动计算:权值集合 {2,7,4,5},手动画出哈夫曼树,算出 WPL 等于多少。
  2. 修改代码:输出每个字符的哈夫曼编码的编码长度。
  3. 思考题:为什么哈夫曼编码可以实现数据压缩?什么场景压缩效果会非常明显?什么场景压缩提升很小?
  4. 拓展:哈夫曼编码前缀特性有什么好处?如果没有前缀特性,解码会发生什么问题?

12. 全文总结

  1. 哈夫曼树是叶子带权、WPL 最小的二叉树,核心思想是贪心算法。
  2. 构造规则:每次选出权最小两棵树合并,新结点权值等于子结点权之和,反复合并直到只剩一棵树。
  3. 双队列 q1、q2 可以高效构造哈夫曼树,q1 存原始排序叶子,q2 存合并后的内部结点。
  4. WPL 是所有叶子结点「权 × 路径长度」总和,可以递归遍历整棵树求得。
  5. 哈夫曼编码属于变长压缩编码:高频字符分配短编码,低频分配长编码。约定左分支 0、右分支 1,根到叶子路径得到编码。
  6. 哈夫曼编码具有前缀特性,解码不会歧义;树形不唯一,编码也不唯一,但是压缩效果不变。
  7. 编程重点:指针操作、递归遍历、排序比较函数、双队列取最小结点逻辑。
  8. 写代码时刻警惕==->、字符字面量'0'这些语法坑。

相关推荐
啦啦啦啦啦zzzz1 小时前
利用RAII机制进行日志的采集
linux·服务器·c++·网络编程·c++20
nLif2 小时前
基于GTK的简易MFC对话框兼容层 -- MfcToGTK
linux·c++·mfc·gtk
CRMEB系统商城2 小时前
CRMEB标准版系统(Java)v3.1正式发布
java·spring·微信小程序·php·教育电商
写后端的胖头鱼2 小时前
【高频面试题】Java 基础类型 + 包装类 + String 互相转换
java·开发语言
weixin_460443562 小时前
企业考试系统从.NET迁移到Java+Linux:数据库迁移、接口兼容与灰度切换实践
java·煤矿培训考试系统·煤矿在线考试系统·煤矿安全培训·煤矿考试系统·一人一档
香菜TTT2 小时前
Redis的哨兵机制
java·数据库·redis
withoutfear3 小时前
IntelliJ IDEA卡顿内存分配不足和索引被频繁触发问题解决办法
java·ide·intellij-idea
大圣编蚕3 小时前
Java StringWriter 详解:从入门到实战
java·开发语言·python
老苗项目实战4 小时前
Java工程师高频面试题(基于近一年的真实面试记录整理)
java·开发语言·面试·预见猿份·老苗项目实战