ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

合并果子与哈夫曼编码:从贪心策略到文件压缩

2026/10/3 4:20:55 拓冰建站 浏览量
合并果子与哈夫曼编码:从贪心策略到文件压缩 刚看到“合并果子”这道题的时候我的第一反应是这不就是一个模拟每次挑两堆最小的合并不就行了但真正动手做起来才发现这题背后连着的是一整个信息论里的经典结构——哈夫曼编码。你可能在洛谷的P1090上见过它也可能在《算法导论》的优先队列章节里撞到过它名字都叫“合并果子/哈夫曼编码”但两处讲的侧重点完全不同。这篇文章我就把这棵树的来龙去脉一次说清楚包括为什么“每次合最小的两堆”是对的、优先队列实现里的各种坑、以及怎么把同一个贪心模型复刻成真正的压缩程序。1. 合并果子这题在考什么先把它看成一棵树1.1 原题到底在描述什么数据范围与答案形态原题背景是NOIP 2004普及组给定一堆果子总共n堆每堆重量不等。你每次可以合并任意两堆成一堆消耗的体力等于两堆重量之和。目标是合并成最后一堆要求总消耗体力最小。n最大到10000每堆重量最大到20000。很多人会误以为这题跟“合并石子”是一回事。合并石子是相邻才能合并那题要用区间DP加四边形不等式优化状态转移和这里完全不同。而合并果子是任意两堆都能合并性质好了不止一个量级解法自然也不一样。以后看到题目描述里有没有“相邻”两个字基本就能判断该走哈夫曼还是该走区间DP。另一个容易忽略的点是答案的上限。n10000、每堆20000的话总重量是2×10^8。如果所有堆重量接近每次合并后生成的中间堆也接近等量最终树高大概是log级别总消耗大约在总重量乘层级的量级可能到2.8×10^9左右已经超过int的21亿上限了。所以答案变量必须开long long。1.2 把合并顺序画成一棵二叉树假设有三堆果子重量分别是1、2、9。你可以尝试三种合并顺序先合并1和2消耗3再和9合并消耗12总消耗15。先合并1和9消耗10再和2合并消耗12总消耗22。先合并2和9消耗11再和1合并消耗12总消耗23。明显第一种最好。但光靠直觉选“小的先合并”在更多堆的情况下是否一定成立这就是贪心正确性要回答的问题。把每次合并看成在树上建立一个内部节点叶子是原始堆内部节点的权值等于两个子节点权值之和合并顺序就是树的形态。总消耗等于所有内部节点权值之和。换一种角度如果某个原始堆所在叶子的深度是d这堆果子会被累加到从根到叶子的每一个内部节点里也就是说它对总消耗的贡献是“自身重量 × 叶子深度”。于是合并果子就等价于给一组叶子权重构造一棵二叉树让每个叶子的“权重 × 深度”之和最小。这正是哈夫曼树要解决的问题。你从果子堆里看出来的树换个场景就是压缩文件里的编码表。1.3 为什么树变平凡了问题反而变难了从枚举到贪心如果n只有3枚举一下就行n到10000枚举合并顺序就爆炸了因为合并顺序对应的是不同的树结构。这个数量级用卡特兰数描述增长飞快。虽然理论上可以用动态规划做过区间划分但那是O(n^3)在这个数据范围下完全不现实。所以这道题的考点根本不是“你会不会模拟”而是“你能不能把一个看似离散组合的问题抽象成带权路径长度最小化”然后认出它背后的哈夫曼模型。认出来之后解法就一句话每次挑最小的两个数合并把合并结果放回集合重复n-1次。下面就来证明为什么这句话是对的。2. 贪心“每次取最小两堆”为什么一定是最优解两种证明角度2.1 直觉是怎么骗人的一个反例试探很多贪心算法都有反例比如“每次挑最大的两个合并”在某些情况下也不差。随便构造一个反例1、1、2、4。如果每次取最小的两个112再取224再取448总消耗24814。如果每次取最大的两个426再取617再取718总消耗67821。差距很明显。实际上哈夫曼贪心是一个相当少见的、局部决策真的能导出全局最优的例子。证明它正确不靠直觉靠结构。2.2 树视角的关键突破口最深的叶子一定是重量最小的那两个回到树的等价模型。假设存在一棵最优树我们看看最深的叶子长什么样。二叉树的每个内部节点有两个孩子所以如果一个叶子深度最大它的兄弟往往也是同深度的叶子否则那个兄弟位置可以继续往深处长。现在做这一步操作从这棵最优树里把两个重量最小的叶子a和b与最深处的那对兄弟叶子x和y交换位置。因为a和b的重量不超过x和y而交换后a、b所在的深度变深了x、y所在的深度变浅了。深度加深会让总代价变多但由于ab不超过xy总体代价不会增加。所以存在一棵最优树两个最小重量的叶子正好在最深层而且互为兄弟。这一步是关键它把全局最优问题压缩成了“先合并a和b”这个局部操作。合并a和b之后得到一棵只有n-1个叶子的新树。新树的总代价等于原树的总代价减去(ab)因为唯一改变的是a、b变成一个新节点。如果原树是最优的那新树也必须是n-1个叶子问题的最优树。于是递归地每次都应该合并当前最小的两个。2.3 用交换论证再走一遍加深理解把上面的过程写成标准的交换论证大概是设S是某个最优解对应的树叶子集合是原始果子堆。在S的任一最深位置取一对互为兄弟的叶子x、y。取全局重量最小的叶子a、b。因为a和b的权值不大于x和y把a、b换到x、y的位置带权路径长度只会下降或不变因此存在一个最优解其中a、b是叶子深度最大且互为兄弟。合并a、b问题规模从n变成n-1方案对应最优。归纳法完成。这一步结束之后“每次取最小两个”就不再是赌运气而是被数学锁定的结论。公式上如果叶子权重是w_i深度是d_i合并总消耗就是Σw_i d_i而贪心每次都在最小化“新增的内部节点成本”这和直接构建哈夫曼树完全等价。2.4 一句人话总结整个贪心逻辑说得再直白一点你现在有几堆果子。最省体力的做法就是让重量轻的果子尽可能晚地参与合并。深度越大参与累加的次数越多所以最轻的果子应该待在树的最底层而每一层的底部永远优先放当前最轻的两堆。这个过程本身就是自顶向下地建树只不过我们用了“先合并最小的两颗叶子、再反推结构”的bottom-up思路。如果你喜欢自顶向下看那也可以理解成最轻的两堆必须最后才被合并进主干所以它们会在最深处成为兄弟。3. 优先队列怎么实现得又快又稳代码细节和常见坑3.1 朴素做法为什么慢每次找两个最小值不是免费的不假思索的模拟是每次扫整个数组找最小的两个合并后把结果放回去把原来的两个位置“删除”。单次找最小是O(n)要合并n-1次整体O(n^2)。n10000时勉强能过但n到10^5就彻底不行了。还有更天真的做法是每次合并后重新排序那就是O(n^2 log n)更慢。要把它压到O(n log n)经典姿势是维护一个最小堆优先队列。建堆O(n)每次取出最小值O(log n)插入合并结果O(log n)总共n-1次出堆入堆整体O(n log n)。这种复杂度的差距在n10^6时就是10^12和10^7的差别完全两个维度的问题。3.2 C里priority_queue的默认行为是个陷阱C标准库里的priority_queue默认是大根堆即每次top取出来的是最大值。如果你二话不说直接push、pop、累加你会得到一组完全错误的答案而且很难察觉因为程序不报错跑出来的数字还“有点道理”。正确写法有两类。第一类用仿函数反转优先级#include bits/stdc.h using namespace std; int main() { int n; cin n; priority_queuelong long, vectorlong long, greaterlong long pq; for (int i 0; i n; i) { long long x; cin x; pq.push(x); } long long ans 0; while (pq.size() 1) { long long a pq.top(); pq.pop(); long long b pq.top(); pq.pop(); long long sum a b; ans sum; pq.push(sum); } cout ans endl; return 0; }第二类不改变比较器直接把入堆的数取负数取出来时再取负还原。虽然可行但代码可读性差如果合并的是结构体还可能出错。我更建议用greater 明明白白告诉读者这是个最小堆。还有一个细节循环终止条件是pq.size() 1不是n-1次循环那种写法。因为每轮合并会把堆大小减1用size判断可以避免边界混乱。如果你偏好固定循环那么循环n-1次完全等价但注意不要在循环里反复size()影响不大但习惯不好。3.3 Python版本heapq几乎是白送的Python的heapq天然支持最小堆写起来更直接import heapq n int(input()) a list(map(int, input().split())) heapq.heapify(a) ans 0 while len(a) 1: x heapq.heappop(a) y heapq.heappop(a) s x y ans s heapq.heappush(a, s) print(ans)这版代码我在本地和洛谷都跑过n10000时耗时几乎可以忽略。需要提醒的是heapq.heappop前一定要保证堆非空。这里因为有len(a)1保护pop两次不会出错。如果你写的是while a:pop到只剩一个元素时继续pop就会抛异常。3.4 长期踩坑后的三条经验第一记得合并结果一定要重新入堆而不是入一个固定变量。有人贪图省事只把当前最小的两个合出来的和大值留着下次比较时又用旧值比较导致堆里元素个数始终减少但根本没有维护正确的候选集。第二当果子重量相同或某些堆是0时算法依然正确。0参与合并没有副作用只是在树里常见的补虚节点思路后面讲k叉哈夫曼时还会用到。第三如果你在OJ上提交WA优先检查是不是把priority_queue默认的大根堆当成了小根堆。这个错误我见过太多次思路对代码错一个符号的事答案完全偏掉。4. 从果子到字符哈夫曼编码的构造与码长计算4.1 同一棵树换一种注释方式就是压缩编码果子问题里叶子是果子堆内部节点是合并消耗目标是总消耗最小。现在换个场景我要给一串字符做二进制编码。字符出现频率越高我希望它的编码越短这样总比特数才少。把每个字符看成一堆果子重量就是它的出现次数。建立一棵哈夫曼树然后把每个节点的左孩子标0、右孩子标1。从根到某个叶子字符的路径就是它的编码。出现频率高的字符深度小码长短出现频率低的字符深度大码长长。这恰好满足“总消耗最小”——在编码场景里总消耗就是总编码长度。4.2 为什么哈夫曼编码一定能无歧义解码前缀码哈夫曼编码有一个很重要的性质任意一个字符的编码都不是另一个字符编码的前缀。因为所有字符都位于叶子节点路径到叶子就停止了不存在“这条路径是另一条路径的前缀”。这就是前缀码。没有这个性质的话比如A编码是0、B编码是01收到比特串0时你永远不知道是直接输出A还是等下一位拼成B。前缀码保证了解码的确定性。哈夫曼树因为把所有符号放在叶子天然满足不需要额外判断。这也是为什么现实中很多压缩格式选择哈夫曼而不是随机发明的变长码解码条件简单清晰码表也容易传输。4.3 算一笔账哈夫曼平均码长和熵的关系信息论里一个离散信源的信息熵定义为H(X)-Σp_i log₂(p_i)单位是比特。它给出了无损压缩的理论下限平均码长不可能低于熵。哈夫曼编码的平均码长满足H(X) ≤ L H(X) 1也就是说哈夫曼编码最差也就是比理论最优多1比特。这个“1”的来源是因为哈夫曼是为每个符号分配整数比特的编码而熵允许分数比特。举个例子假设五个字符频率分别是0.4、0.2、0.2、0.1、0.1。熵约等于2.12比特。用哈夫曼构造后0.4的字符码长1两个0.2的字符码长约2到3两个0.1的字符码长约3到4平均码长大致在2.2到2.4之间。离熵不远但确实超过了下限。这就是整数编码付出的代价。你可能好奇为什么区间编码或者算术编码能逼近熵因为它们不用整数比特而是把整个消息映射到一个区间里。这是另一个故事但理解哈夫曼的“整数比特”局限对你的工程选型很重要。4.4 固定长度编码 vs 哈夫曼编码的直观对比如果五个字符都用3位固定编码不管频率平均码长就是3比特。哈夫曼能压到2.2到2.4左右节省约25%。要是文件里有大量重复字符差距会更明显。比如一个文本里字母e出现50%固定长度还是3位哈夫曼可以给e分配1位压缩率立刻拉开。这个对比在实际压缩软件里看得更清楚先把文件内容变成一串符号流再对符号做统计编码。符号流往往已经被LZ77之类的算法处理过哈夫曼只是最后一层熵编码。但即便如此它也是整个压缩链路里必不可少的一环。5. 哈夫曼编码用在真实压缩场景保存表、解码和不完美之处5.1 一个最小可用的压缩流程如果你要手写一个用哈夫曼编码的压缩器流程如下扫描原始文件统计每个字节的出现频率。用频率构建哈夫曼树。根据树生成编码表每个字节对应一段变长比特串。再次扫描原始文件把每个字节替换成编码串拼成一个大的比特流。把比特流按8位一组写成字节最后不足8位的部分补0。解压端是反向的读入符号频率重建同一棵哈夫曼树然后逐位从根走走到叶子输出一个字节再从根重新开始。看着简单真写起来会踩好几个坑。最常见的是比特流拼接时最后不足一字节怎么处理一般约定追加一个额外的“结束标记”或者保存原始总字节数。如果你只补0而不记录有效比特数解压端会把补位的0也当成有效码来解码要么解出多余字符要么直接解码失败。5.2 保存树的两套主流方案压缩包必须让解压端拿到编码信息否则无法解码。保存树有几种方式保存每个字符的频率表。解压端重新执行哈夫曼建树逻辑。简单直接但频率表可能比较大。保存树的先序遍历结构遇到内部节点记0遇到叶子记1然后是字符本身。树多大就存多大文件大时开销不小。工程上更常用的是规范哈夫曼编码canonical Huffman。不需要保存具体的码字只保存每个符号的码长解压端按固定的规则去重新分配码字。规则是码长相同的情况下按符号顺序依次递增分配二进制码不同码长之间有明确的递推关系。DEFLATE格式里的Huffman编码就是这么处理的压缩头只存码长数组。第三种方案的好处是省空间、可复现。代价是解压端必须和压缩端使用完全相同的规范规则。我第一次手写时没有严格按规则排序导致同一个文件压缩两次码表不同但都能解压看似正常其实已埋下兼容性隐患。5.3 工程里的两个隐蔽坑第一个坑是出现频率相同的符号合并顺序不同会导致码长不同。例如两个频率都是0.1的符号可能一个分到3位另一个分到4位。总长度可能一样但具体哪个符号拿短码取决于建树时怎么选。如果你在压缩时用稳定的排序规则比如频率相同时按字符编码升序合并那么码表就可复现。若省略这一步压缩结果可能不稳定。第二个坑是根节点的处理。如果整个文件只有一种字符送给哈夫曼编码就是只有一个叶子节点的树。编码表里这个字符对应空串解压时遇到空码会陷入死循环。多数压缩器会在这种情况下做特殊处理比如明确传输一个1位的固定编码或者干脆对这种平凡情况走单独的存储路径。我建议你写代码前先想清楚这个边界。另一个不算坑但很容易忽略的事实是哈夫曼编码是无损压缩但它并非永远有收益。如果文件本身是随机数据每个字符频率接近哈夫曼平均码长可能接近甚至超过定长编码。压缩后的体积可能比原文件还大因为你还要存频率表。真实压缩器一般会在压缩失败时回退到“存储”模式。5.4 哈夫曼编码为什么没有退休到现在压缩领域里算术编码和ANS非对称数字系统在压缩率上通常超过哈夫曼。那为什么DEFLATE、JPEG这些经典格式还坚持用哈夫曼我的理解是哈夫曼足够简单。编码和解码都只需要查表不需要高精度的小数运算硬件实现容易错误传播可控。算术编码在长消息上更接近熵但它对溢出、精度、结束条件很敏感一旦实现有bug整个流都会烂掉。ANS虽然现代但理解门槛更高专利和实现复杂度也让很多老格式不愿意迁移。所以“够用、可验证、实现成本低”这三个词就是哈夫曼编码的生存逻辑。6. 扩展玩法k叉哈夫曼、动态哈夫曼和Kraft不等式6.1 k叉哈夫曼为什么先要补几个0合并果子每次合并两堆对应的是二叉哈夫曼树。如果题目改成每次可以合并k堆对应的是k叉哈夫曼树。每合并k个节点节点总数减少k-1。要最终合并成一个根内部节点数I和叶子数n要满足n 1 (k-1)I。也就是说n-1必须是k-1的倍数。如果不满足就补若干个重量为0的虚拟叶子直到满足条件。我当年第一次做k叉变形时没补虚拟叶子直接贪心结果样例都过不了。后来想明白了补0节点相当于“凑数”让某些合并轮次里实际只有不到k个真实节点而0不影响合并结果只是让树形满足满k叉的条件。一个很常见的份量在很多最优k路归并的外排序场景里每个归并段长度不同想要让总归并代价最小就是建k叉哈夫曼树。归并段数量如果不满足约束就得补空段这是完全一样的逻辑。6.2 动态哈夫曼流式数据怎么做标准哈夫曼编码需要先扫一遍全文统计频率但在实时通信或流式压缩里你不可能先缓存全部数据再来一遍。动态自适应哈夫曼编码的思路是初始给所有符号一个相同频率随着处理每个符号不断更新频率和树结构。经典算法叫FGK后来改进成Vitter算法。更新树的代价不是每次重建整棵树而是局部调整。如果你深入了解过会发现它本质上是一个动态维护的“最小带权路径树”。这个方向平时做题用不上但看压缩库源码时会遇到比如一些实时的流压缩库就实现了类似机制。6.3 Kraft不等式前缀码的理论边界给定一组码长l₁, l₂, …, lₙ是否存在一个前缀码可以实现这些码长答案是Σ 2^(-lᵢ) ≤ 1。这个不等式叫Kraft不等式是前缀码存在性的充要条件。哈夫曼编码构造出来的码长天然满足这个不等式。反过来如果某组码长满足Kraft不等式我们就能构造一个前缀码。这在实际压缩协议里有重要价值有时候压缩端根本没空建完整的哈夫曼树而是直接根据频率分布去“猜测”一组码长然后验证Kraft不等式再按规范哈夫曼的方式生成码字。DEFLATE的静态哈夫曼方案里就有一张固定的码长表它必须满足这个条件。理解Kraft不等式还有一个额外好处判断一个压缩方案是否可行时不用真的构造树一个求和式就能初筛。这在和同事讨论协议设计时很能提升说服力。6.4 回到合并果子一个练习建议如果你想彻底掌握这块内容我的建议是不要只做原题改几个变体练手改成每次合并k堆验证补0的思路。改成输出每次合并的选中的两堆看合并顺序。把题目数据范围拉到10^6用自己实现的堆或库堆跑一遍感受O(n log n)和O(n²)的差距。给出一组频率要求构造哈夫曼编码并计算平均码长然后用Kraft不等式验证前缀码性质。做完这几个变体你对哈夫曼编码的理解就不再停留在“背代码”层面而是能迁移到文件压缩、归并优化、决策树设计这些真实场景里。最后分享一点个人体会。我第一次写合并果子时就是因为不知道priority_queue默认是大根堆跑出来的答案比样例大一圈排查半天才发现是优先队列方向搞反了。后来我写一个小的文本压缩工具又在保存频率表和解码结束条件上各踩了一次坑。这些错误说起来都“很简单”但在没踩过之前你很难凭空预判它们的存在。如果你现在正在刷这题或者正准备把哈夫曼编码用在项目里希望这篇东西能帮你把那几步绕坑的路省下来。