首页 > 其他分享 >哈夫曼树学习笔记

哈夫曼树学习笔记

时间:2024-07-24 15:17:50浏览次数:12  
标签:哈夫曼 队列 元素 笔记 学习 二叉树 权值 节点

哈夫曼树学习笔记

定义

设二叉树具有 \(n\) 个带权叶结点,从根结点到各叶结点的路径长度与相应叶节点权值的乘积之和称为 树的带权路径长度(Weighted Path Length of Tree,WPL)

设 \(w_i\) 为二叉树第 \(i\) 个叶结点的权值,\(l_i\) 为从根结点到第 \(i\) 个叶结点的路径长度,则 WPL 计算公式如下:

\[WPL = \sum_{i = 1}^{n} w_i l_i \]

WPL 有什么用呢?我们可以用两种方式理解 WPL。

  1. 合并果子)考虑这样的一棵二叉树:它每个节点的权值都是两个子节点的权值之和。我们可以证明:这棵二叉树的 WPL 就是它所有非叶节点的权值之和。而这棵二叉树又可以和这样的情景对应:假设你有 \(n\) 堆苹果,每堆苹果有 \(w_i\) 个。你可以进行 \((n-1)\) 次操作,每次操作把相邻的两堆苹果合并成一堆,消耗的体力是两堆苹果的数量之和。不难看出,经过 \((n-1)\) 次操作后,恰好剩下一堆苹果。

    可以发现,每个合并苹果的操作序列,都可以和一棵上述的二叉树对应:二叉树的 \(n\) 个叶节点的权值分别对应一开始每堆苹果的个数,非叶节点的权值代表合并两个子节点消耗的体力。一开始,每堆苹果可以看作是只有一个节点的二叉树。选择两堆苹果合并,就相当于把两颗二叉树的根节点连到一个新建的父节点上,这个父节点的权值就是它两个子节点的权值之和。这样,操作消耗的总体力就是二叉树所有非叶节点的权值之和。上文已经说明,这个值就是二叉树的 WPL。所以,想要最小化消耗的体力,就是要在给定的权值序列上建立一棵二叉树,使得该树的叶节点的权值与原序列的权值一一对应,并且该树的 WPL 最小。(实际上就是这道题:P1090 合并果子

  2. 最优前缀编码)略

这样的 WPL 最小的树,就是哈夫曼树。注意,哈夫曼树的定义是基于某个权值序列来说的:我们是在某个权值序列上构建哈夫曼树。

哈夫曼树的构建及最优性证明

构建

  1. 初始化:由给定的 \(n\) 个权值构造 \(n\) 棵只有一个根节点的二叉树,得到一个二叉树集合 \(\mathcal{F}\)。
  2. 选取与合并:从二叉树集合 \(\mathcal{F}\) 中选取根节点权值 最小的两棵 二叉树分别作为左右子树构造一棵新的二叉树,这棵新二叉树的根节点的权值为其左、右子树根结点的权值和。
  3. 删除与加入:从 \(\mathcal{F}\) 中删除作为左、右子树的两棵二叉树,并将新建立的二叉树加入到 \(\mathcal{F}\) 中。
  4. 重复 2、3 步,当集合中只剩下一棵二叉树时,这棵二叉树就是霍夫曼树。

该图片展示了由权值序列 2,4,5,3 构建哈夫曼树的过程

最优性证明

咕咕咕……

代码实现

这里,我们不建树,只求 WPL。

\(O(n \log n)\) 方法

使用一个优先队列维护每棵二叉树的根节点的权值,每次弹出堆顶的两个元素,把这两个元素的和加入队列,同时统计答案。

这个方法比较简单,不再赘述。

cin >> n;
for(int i = 1, x; i <= n; i++)
{
    cin >> x;
    que.push(x);
}

while(que.size() >= 2)
{
    ll a, b;
    a = que.top(); que.pop();
    b = que.top(); que.pop();
    ans += a + b;
    que.push(a + b);
}

cout << ans << endl;

AC 记录(P1090)

\(O(n)\) 方法

上一种方法中的时间复杂度瓶颈来自优先队列,这次我们不用它了,而用两个队列来代替它。

没有优先队列,怎么保证每次取出的元素是两个最小的元素呢?

先考虑初始化的问题。显然,一开始我们还是要保证队列中的元素是递增的。在值域较小时,我们可以采用桶排,这样就做到了 \(O(n)\) 排序。(值域很大怎么办?可以使用基数排序,但我还不会)

然后我们采用如下算法:

  1. 把排序后的初始元素依次插入队列 1 中。此时队列 2 为空。

  2. 每次从队列 1 和队列 2 首共弹出两个元素,使得这两个元素的和最小。分三种情况:

    • 从队列 1 首弹出两个元素。
    • 从队列 2 首弹出两个元素。
    • 从队列 1 首和队列 2 首分别弹出一个元素。

    分类讨论即可。

  3. 计算这两个元素的和,并插入队列 2 尾。同时答案加上这个和。

  4. 重复步骤 2、3 \((n-1)\) 次。

这个算法与 \(O(n \log n)\) 的算法的区别主要在于第 2 步。要证明它的正确性,只需证明第 2 步中弹出的两个元素就是最小的两个元素即可。

由于一开始,我们就已将元素排序再插入进队列 1 中,而之后我们不会向队列 1 中插入任何元素,所以队列 1 中元素时刻都是递增的。而由于我们每次弹出的是最小的两个元素,所以越往后,弹出元素的和是递增的。我们把弹出元素的和插入到队列 2 中,所以队列 2 中元素也是时刻保持递增的。加上第 2 步中我们讨论了三种情况,这就保证了弹出的两个元素一定是最小的两个元素。故算法的正确性得证。

cin >> n;
for(int i = 1, x; i <= n; i++)
{
    read(x);
    cnt[x]++;
}

for(int i = 1; i < MAXV; i++)
    while(cnt[i]) que1.push(i), cnt[i]--;

for(int i = 1; i < n; i++)
{
    ll a, b;
    if((!que1.empty() && que1.front() < que2.front()) || que2.empty())
        a = que1.front(), que1.pop();
    else a = que2.front(), que2.pop();
    if((!que1.empty() && que1.front() < que2.front()) || que2.empty())
        b = que1.front(), que1.pop();
    else b = que2.front(), que2.pop();

    que2.push(a + b), ans += a + b;
}

cout << ans << endl;

AC 记录(P6033)

\(k\) 叉哈夫曼树

哈夫曼树也可以推广到 \(k\) 叉的情况。

考虑 \(k\) 进制下的最优前缀编码问题:每个单词用一个 \(k\) 进制代码表示,求出最短的编码方案。这等价于在一个权值序列上建立一棵 \(k\) 叉树,使得 WPL 最小。

这个问题几乎可以完全套用二叉哈夫曼树的构建方法,只需要把每次合并两棵树改成每次合并 \(k\) 棵树即可。但有个小问题:合并到最后,可能只剩下不到 \(k\) 棵树,于是最终的树的根节点度数小于 \(k\),这是不优的:任取某个叶节点改为根的子节点,都会使 WPL 变小。

解决这个问题的方法是在权值序列中加入若干个 \(0\),使得最后恰好剩下 \(k\) 棵树。加入多少个 \(0\) 呢?我们每次弹出 \(k\) 棵树,加入 \(1\) 棵树,相当于每次操作后树的数量减少了 \(k-1\)。而最后恰好留下一棵树,所以一开始应该满足 \((n - 1) \bmod (k-1) = 0\)。

如果在满足 WPL 最小的情况下,还要让最深的叶节点的深度尽可能小,应该怎么办?

在优先队列中,还要根据每棵树的深度排序:如果根节点权值不同,优先合并权值小的;如果权值相同,优先合并深度小的。但我还没搞懂为什么……

AC 记录(P2168)

参考资料

  1. OI wiki - 霍夫曼树
  2. CSDN - 哈夫曼树构造过程及最优证明

标签:哈夫曼,队列,元素,笔记,学习,二叉树,权值,节点
From: https://www.cnblogs.com/dengstar/p/18320976

相关文章

  • JAVA笔记十四
    十四、集合1.集合概述(1)集合是存储其它对象的特殊对象,可以将集合当作一个容器(2)集合的相关接口和类位于java.util包中(3)集合中的接口和类是一个整体、一个体系2.集合接口接口定义了一组抽象方法,实现该接口的类需要实现这些抽象方法,从而实现接口的类就具备了接口所规......
  • 后端开发工程师vue2初识的学习
    博客主页:音符犹如代码系列专栏:JavaWeb关注博主,后期持续更新系列文章如果有错误感谢请大家批评指出,及时修改感谢大家点赞......
  • 操作系统概念(黑皮书)阅读笔记
    操作系统概念(黑皮书)阅读笔记进程和内存管理部分章节导论:操作系统类似于政府,其本身不能实现任何有用功能,而是提供一个方便其他程序执行有用工作的环境​个人理解:os是government的作用,有着最高权限,去管理和分配资源,有效且公平计算机系统的根本目的是,执行用户程序并更......
  • 关于学习.NET的历程回顾与今后的探索实践方向
    关于学习.NET的历程回顾自从2023年9月11日注册公众号以来,这次还是第一次介绍自己。我今年24岁,双非本,211硕,非计算机相关专业。大学期间接触过计算机相关的课程可能就《大学生计算机基础》、《C语言程序设计》,并且也没掌握多好。22年4月研究生复试结束,联系好导师后,由于导师研究方......
  • 昇思25天学习打卡营第19天|计算机视觉
    昇思25天学习打卡营第19天文章目录昇思25天学习打卡营第19天VisionTransformer图像分类VisionTransformer(ViT)简介模型结构模型特点环境准备与数据读取模型解析Transformer基本原理Attention模块TransformerEncoderViT模型的输入整体构建ViT模型训练与推理模型训......
  • Java并发编程实战读书笔记(四)
    显示锁Lock与ReentrantLockLock接口定义了一组抽象的加锁操作,与内置加锁机制不同,Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺......
  • Java并发编程实战读书笔记(二)
    对象的组合在设计线程安全的类时,确保数据的一致性和防止数据竞争是至关重要的。这通常涉及三个基本要素:确定构成对象状态的所有变量,明确约束这些状态变量的不变性条件,以及建立管理对象状态并发访问的策略。要确定构成对象状态的所有变量相对简单,但需注意状态应封装在对象......
  • 应用数学与机器学习基础 - 数值计算之梯度之上Jacobian和Hessian矩阵篇
    序言在数值计算与优化理论的广阔天地里,梯度作为一阶导数的向量表示,是理解函数局部变化率及进行最优化求解的基础工具。然而,当问题的复杂度提升,单一梯度信息往往不足以全面刻画函数的多变量间相互作用及更高阶的变化特性。此时,Jaco......
  • 学习STM32的SPI总线通信
    学习STM32的SPI总线通信需要了解SPI的基本原理和STM32的库函数使用方法。SPI(SerialPeripheralInterface)是一种全双工的同步串行通信总线,用于在微处理器或微控制器与外围设备之间传输数据。在STM32中,SPI总线通信需要使用SPI外设和相关的库函数。SPI外设包括多个SPI控制器,每个......
  • zed使用笔记
    安装curl-fhttps://zed.dev/install.sh|sh也可以用包管理器安装:#ArchLinuxsudopacman-Szed#Nixnix-env-iAnixos.zed-editor然后输入命令zed就可以打开zed编辑器。如果报错VulkanError(ERROR_INCOMPATIBLE_DRIVER),则需要根据自己的硬件配置安装相应的vulkan包......