ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

哈夫曼编码不唯一?从原理到工程实现一次讲透

哈夫曼编码不唯一?从原理到工程实现一次讲透 今天刷到了每日一题系列的Day3题目是哈夫曼编码。这是数据结构与算法笔试面试中出现频率极高的一类题也是课程设计里“文件压缩”背后的基础模型。很多人第一眼看上去觉得无非就是建树、编码、求WPL三步走但真到动手写代码、或者被追问一句“哈夫曼编码唯一吗”的时候不少人都容易卡壳。这篇文章我就把这几个问题一次讲透从原理到构造、从唯一性到工程实现再用代码把结论验证一遍。适合正在刷题备考的在校学生、准备算法面试的开发者以及想搞懂数据压缩核心思想的读者。1. 哈夫曼编码到底在解决什么问题1.1 定长编码的浪费与变长编码的动机先想想最朴素的编码方式。假设我们要压缩一段文本里面只出现A、B、C、D四种字符最简单的方案是每个字符分配2位二进制比如A00、B01、C10、D11。这样编码简单直接解码时每2位切一刀就行。但问题也明显如果A出现1000次D只出现10次A和D却占用一样长的码位大部分空间就浪费了。哈夫曼编码的核心思路是变长编码高频字符给短码低频字符给长码整体平均码长做到最短。这个直觉在生活里也很常见。比如设计一套指令集常用指令用短操作码冷门指令用长操作码平均指令长度就能降下来。但要设计变长编码必须处理一个关键问题解码时的歧义。如果A的编码是0B的编码是01那么收到比特流01时到底应该解码成“A B”还是“B”这是前缀冲突。哈夫曼编码通过构造二叉树来保证任意一个字符的编码都不是另一个字符编码的前缀这一类编码叫前缀码。只要编码满足前缀性质解码器从左往右扫碰到一个完整前缀就输出一个字符永远不会卡壳。1.2 哈夫曼树本质是最优前缀编码要得到平均码长最短的前缀码可以把问题转换成二叉树模型。每个叶子节点代表一个待编码的字符叶子到根的路径长度就是该字符的码长从根出发到叶子每次向左写0、向右写1就得到完整编码。因为所有字符都分布在叶子节点所以不存在一个编码是另一个编码的前缀。衡量一个二叉树编码方案好坏的指标是带权路径长度WPLWeighted Path Length计算公式是每个字符的出现次数乘以其编码长度之和。编码总长度越小压缩率越高。哈夫曼编码的任务就是在所有前缀码方案里找一个WPL最小的而它使用的构建策略是贪心算法。这里有个容易忽略的直觉最优前缀码对应的二叉树一定是一棵“满二叉树”也就是每个内部节点都有两个孩子。如果某个内部节点只有一个孩子可以把该节点删掉直接让这个孩子上提所有叶子码长都会变短至少1WPL必然更小。这个性质后面判断编码是否可能是哈夫曼编码时很好用。1.3 WPL是核心衡量指标给定一组字符及其出现次数WPL是可以精确计算的。比如字符A、B、C、D出现次数分别是5、9、12、13如果全部用定长2位编码WPL就是(591213)x278。但采用哈夫曼编码后WPL可能降到更低的值。考试和面试里最常见的题型是两种一是给出一组字符频率要求画出哈夫曼树并写出编码二是给出一组编码要求判断它是不是哈夫曼编码。第二种题很多同学不知道从何下手实际只要验证两件事第一这套编码是否满足前缀码性质第二按这个编码得到的WPL是否等于理论最小WPL。只要满足这两条哪怕它的编码表和教材不一样它依然是一组合法的哈夫曼编码。2. 手把手构建哈夫曼树一个经典例题全流程拆解2.1 一道典型的构造题教材里最经典的例子是6个字符及其出现频次字符ABCDEF频率5912131645题目要求构建哈夫曼树并给出每个字符的哈夫曼编码。这种题目的标准解题流程是固定的每次从集合中选出两个频率最小的节点合并成一个新节点新节点的频率等于两者之和然后把这个新节点放回集合重复直到只剩一个节点。合并产生的新节点就是哈夫曼树的内部节点最后剩下的节点是根节点。2.2 一步步合并的过程初始集合5, 9, 12, 13, 16, 45。第一步选两个最小的5和9合并成14集合变成12, 13, 14, 16, 45。第二步当前最小的两个是12和13合并成25集合变成14, 16, 25, 45。第三步最小的两个是14和16合并成30集合变成25, 30, 45。第四步最小的两个是25和30合并成55集合变成45, 55。第五步剩下45和55合并成100这就是根节点。如果规定左子树路径记为0、右子树路径记为1可以得到编码表字符频率编码码长A511004B911014C121003D131013E161113F4501这里的左右分配并不是唯一答案但编码长度分布是确定的F最短因为它频率最高A和B最长因为它们合并最早、离根最远。2.3 WPL的口算技巧WPL在这个例子里是5×4 9×4 12×3 13×3 16×3 45×1 224。手算WPL时有一个非常实用的技巧在合并过程中把每次新产生的内部节点权值累加起来。刚才合并产生的内部节点是14、25、30、55、100五个数相加正好是14253055100 224。这不是巧合因为每个内部节点的权值等于它子树中所有叶子权值之和而每个叶子的权值会被累加的次数恰好等于它到根节点的路径长度所以内部节点权值总和必然等于WPL。用这个技巧口算比逐一乘码长快很多也方便检查结果。我实际刷题时一般两种方法交叉验证合并过程中边算边累加内部节点权值最后再把频率和码长相乘核对一遍。如果两个结果不一致说明某个步骤选错了合并对象或者码长标错了。2.4 构造时需要注意的三点细节第一每次合并之后必须重新选最小不能默认“一直沿着上一轮的顺序选”。很多新手合并前两步之后会把新生成的节点放在一边继续在原序列里挑最小这个操作在有些场景下恰好能得出WPL但严格来说没有保证最优性甚至可能直接算错。第二遇到频率相等的节点选谁先合并都合法。这个细节看起来无关紧要但恰恰是“哈夫曼编码唯一吗”这个问题最重要的触发点。我后面会用专门章节展开讲。第三练题时建议在纸上明确标出每个内部节点的权值。一方面是方便算WPL另一方面是如果最后编码对不上回头检查树的分支位置能更快定位错误。3. 哈夫曼编码唯一吗从三个维度拆解3.1 为什么很多人默认它唯一初学者普遍觉得哈夫曼编码是唯一的主要原因是教材和网课上展示的例子往往固定一张图、固定左0右1的规则最后输出一张固定的编码表。这种“标准答案”看多了很容易产生一种印象哈夫曼算法是确定性的编码结果自然也是确定性的。但严格回答“哈夫曼编码唯一吗”答案是不唯一。而且不只是“左右互换”那种无伤大雅的差异连二叉树结构本身都可能不同。下面我用一个极简例子说明。3.2 树形不唯一的根本原因等权节点与中间节点冲突考虑四个叶子节点频率分别为1、1、2、2。第一次合并时两个频率为1的节点必须合并合并后得到新节点N2权值为2。此时集合里剩下三个权值为2的节点两个原始节点一个刚生成的N2。按照哈夫曼算法第二次要选两个权值最小的节点合并。因为这三个节点权值都等于2选哪两个都合法但选择不同最终树形不同方案A选择N2和一个原始2合并得到权值4最后再和剩下的原始2合并成根节点6。这个方案里两个原始2节点一个深度为1一个深度为2两个原始1节点深度为3。方案B选择两个原始2合并得到权值4最后N2再和这个4合并成根节点6。这个方案里两个原始2节点深度都是2两个原始1节点深度也都为2。两种方案对应不同树形也对应不同编码表。但两种方案的WPL算下来完全相同都是12。这就是哈夫曼编码不唯一的第一个层级当多个节点权值相等时或者合并产生的新节点权值与其他节点权值相等时贪心选择存在多个等价分支树的结构就会发散。3.3 左右子树与0/1分配的、更“一眼可见”的不唯一即使树形完全确定同一棵树也可以对左右子树做镜像翻转。比如根节点的左子树编码为0、右子树为1和左子树编码为1、右子树为0两张编码表完全不同但压缩效果一模一样。更广义地说树的每一层内部节点都可以独立交换左右子树得到的编码表数量会成倍增长。这部分不唯一性在实际工程里完全不用纠结。通信双方只要使用同一棵哈夫曼树或者同一张编码表就能正常编解码。至于编码表具体长什么样并不影响压缩率也不需要和其他系统对齐。3.4 真正“唯一”的是什么虽然树形和编码表不唯一但有几样东西在给定频率表的条件下是确定的。第一最小的WPL值唯一。无论怎么选树形最终的带权路径长度必须相同因为哈夫曼算法保证全局最优而所有最优解的目标函数值当然相同。第二平均码长唯一。WPL除以总频率得到的平均码长是固定值。第三编码的最优性唯一。所有哈夫曼树的共同点是WPL最小不存在某一棵树优于另一棵树的说法。所以考试里如果问“哈夫曼编码是否唯一”答案是不唯一如果问“最短编码长度是多少”或“WPL是多少”答案是唯一的。这个区分很重要我自己见过不少同学在面试里张口就说“哈夫曼编码唯一”结果被追问等权节点场景后当场卡住。提示判断题目意图时先看清楚问的是编码表还是码长。问码长时只需要按最优性计算WPL纠结具体编码没有意义问编码时则必须在构造过程中把每一步合并画清楚。3.5 “所有叶子权值都不等”时是否就唯一有些人会想如果所有字符频率都严格不同哈夫曼树是不是就唯一了答案仍是不一定。因为问题还可能出现“中间节点权值与其他叶子权值相等”的情况。比如叶子权值为1、3、4、7第一次134新节点权值4与原始叶子4相等下一步选新4还是原始4合并会产生不同树形即便所有初始叶子权值互不相同。所以“唯一性”在哈夫曼编码里是一个很本质的、由并列最优选择导致的现象只要贪心的每一步存在多个同权候选树形就会发散。理解了这一点再回头看一些刷题平台上的多解判题逻辑就会明白为什么很多题目只校验WPL而不是校验具体编码。4. 用一段代码验证不唯一性4.1 一个最小可用的Python实现先写一个朴素但完整的哈夫曼树构建代码用优先队列维护节点。为了避免比较节点对象时出现“TypeError: not supported”把(权值, 序号, 节点)三元组放进堆里序号用来打破平局。import heapq class Node: def __init__(self, char, freq): self.char char self.freq freq self.left None self.right None def build_huffman(freq_map, tie_breakid): heap [] idx 0 for char, freq in freq_map.items(): node Node(char, freq) heapq.heappush(heap, (freq, idx, node)) idx 1 while len(heap) 1: f1, _, n1 heapq.heappop(heap) f2, _, n2 heapq.heappop(heap) parent Node(None, f1 f2) parent.left n1 parent.right n2 heapq.heappush(heap, (parent.freq, idx, parent)) idx 1 _, _, root heap[0] return root def encode(root, prefix, tableNone): if table is None: table {} if root.char is not None: table[root.char] prefix else: encode(root.left, prefix 0, table) encode(root.right, prefix 1, table) return table def wpl(root, depth0): if root.char is not None: return root.freq * depth return wpl(root.left, depth 1) wpl(root.right, depth 1)这段代码默认在堆中引入一个递增序号来打破平局。因为插入顺序固定第一次跑和第二次跑的结果是确定的。但只要你把tie_break改成不同策略比如随机选择或优先选新节点编码表就会变。4.2 同频率表、不同合并策略输出不同编码表用刚才提到的频率表{1:1, 2:1, 3:2, 4:2}代入代码采用两种不同的平局处理策略一种优先选原始节点一种优先选新节点打印出来的编码表不同但WPL相同。策略字符1编码字符2编码字符3编码字符4编码WPL优先原始节点01001100112优先新节点00101000112左0右1固定可能不同可能不同可能不同可能不同12这个表格里具体编码会因为实现细节产生变化但核心结论稳定树形可以不同WPL恒定。读者在自己的环境里多跑几轮把打印table的语句加上能更直观地看到编码表的多样性。4.3 解码验证只要同一棵树怎么编码都能还原很多人担心编码表不同会导致解码不一致。实际上只要编码和解码使用同一棵哈夫曼树或者同一张编码表数据就能正确还原。编码表相当于一个“约定”双方协商好即可。简单模拟一下解码过程从根节点出发遇到0走左子树、遇到1走右子树走到叶子节点就输出字符然后回到根继续扫描。这个流程只依赖树的结构不依赖编码表本身。所以工程里压缩文件时通常的做法是把哈夫曼树本身或编码表一并写入文件头解压时先读取这部分信息重建树再逐位解码数据区。4.4 工程实现中值得注意的几个问题第一堆节点的比较规则一定要处理。直接往heapq里放自定义Node对象Python会尝试调用运算符而Node没有定义比较代码会直接报错。用(freq, idx, node)三元组是最稳的做法idx保证即使freq相同也能明确排序。第二递归编码在树很深时可能爆栈。字符频率差距极大时哈夫曼树的深度有可能超过递归默认限制。工程上建议改成显式栈遍历或者限制递归深度。第三静态哈夫曼编码需要额外存储编码表对于短文本来说这个开销可能超过压缩收益。短文本场景可以用动态哈夫曼编码随着数据输入不断更新频率和树结构省去编码表传输。5. 刷题中的高频坑点与工程应用5.1 考试和面试中反复出现的坑第一个坑是“合并后忘记重新排序”。我上面已经强调过每次必须重新在所有节点里挑选最小的两个包括新生成的内部节点。有的同学习惯性把新节点放在原列表末尾最后算出来的不是最优。第二个坑是“WPL计算方式混乱”。建议直接用“所有内部节点权值之和”来验证。如果合并过程中记录的内部节点权值加起来跟你按叶子乘码长算出来的不一致一定有一个环节做错了。第三个坑是“零散位置搞混0和1”。判题系统如果要求输出编码通常默认左0右1但有的题目会明确说左1右0。先看清楚题干别默认成自己的习惯。如果题目没有规定任意方向都可以但输出时必须统一。第四个坑是“误把非哈夫曼编码判为哈夫曼编码”。有些题目给出一套编码问是否为哈夫曼编码。只验证前缀码是不够的。比如一套前缀码的WPL如果大于理论最小值它就不是最优编码也就不是哈夫曼编码。反过来如果一套前缀码的WPL等于理论最小值它就是合法哈夫曼编码不用管它树形是否和你画的一样。第五个坑是“频率相同字符的排序影响结果导致考生以为自己做错了”。两个频率相同的字符在构造过程中换一下顺序编码可能从01、00变成00、01但整体最优性不变。做题时不要在排序上耗费太多时间重点是保证合并规则正确。5.2 现实世界里哈夫曼编码用在哪哈夫曼编码最知名的应用是DEFLATE压缩算法它被用在ZIP、gzip、PNG图片格式中。DEFLATE先用LZ77做重复字符串消除再用哈夫曼编码压缩统计冗余。JPEG图片格式在熵编码阶段也使用了哈夫曼编码不过JPEG标准规定了对码长的限制所以实际实现往往采用受限哈夫曼编码而不是教材里最基本的版本。MP3等音频格式也使用哈夫曼编码对量化后的频域系数进行进一步压缩。这些场景里字符频率变成了符号出现概率哈夫曼编码要做的事情完全一致根据概率分配不等长码让高频符号用短码、低频符号用长码。5.3 受限哈夫曼编码与标准哈夫曼编码的差别标准哈夫曼编码允许每个字符的码长随概率任意变化但在某些硬件场景里我们希望所有编码长度不超过某个上限比如JPEG规定码长最多16位。这时标准贪心算法可能生成超过上限的码字需要改用包合并算法Package-Merge来求受限最优前缀码。这个知识点在刷题阶段不需要深挖但如果简历上写了自己熟悉图片压缩或音视频编解码面试官可能会追问。知道“标准哈夫曼编码不唯一且码长不受限受限场景需要用包合并算法”这一句话就能避免现场尴尬。5.4 哈夫曼编码的压缩上限从信息论角度看哈夫曼编码的平均码长一定大于等于信源熵但小于等于熵加1。也就是说它不会比理想最优编码差太多。对于高频字符非常集中、概率分布很不均匀的输入哈夫曼编码的压缩效果很显著对于所有字符概率基本均匀的输入哈夫曼编码与定长编码差距不大。这解释了为什么哈夫曼编码在实际压缩工具中是“最后一个环节”而不是全部。真正高压缩率来自前面去除相关性的步骤哈夫曼编码负责的是把已经消除相关性的符号概率分布压到极限。最后分享一点我的实际体会当初刷这道题的时候我也总在纠结“标准答案到底是哪一棵树的编码”。后来真去写了一个小的文件压缩工具才发现哈夫曼编码从来不是一个固定答案而是一类最优解。只要WPL最小且保持前缀码性质任何一棵合法哈夫曼树都能用编码表不同并不会让解码出现问题。备考和工程里真正要盯住的是“最优性”和“可解码性”不要因为两次写出来的编码表不一样就觉得出错。另一个很实用的小技巧是手算WPL时的自检方法每合并一次就把新节点的权值单独加到一个累计值里合并结束时这个累计值一定等于按所有叶子乘码长算出来的WPL。如果两个数字对不上几乎可以肯定是中间某一步选错了节点。Day3这道题把贪心、二叉树、前缀编码三个概念串在一起确实值得反复做尤其是把“不唯一”想明白之后再做其他变体题会顺手很多。
返回列表