目录
题目解析
这是一个非常经典的算法题目,源自 POJ 1521 - Entropy。
题目的核心本质是要求你实现 哈夫曼编码(Huffman Coding) 算法,并将其压缩效果与定长的 8位 ASCII 编码进行对比。
1. 题目核心概念解析
- 原始编码(ASCII):固定长度编码。无论字符出现的频率如何,每个字符都占用 8 bits。
- 计算公式:
。
- 计算公式:
- 最优无前缀变长编码(Entropy/Huffman):根据字符出现的频率构建编码。
- 频率高的字符用短的比特串表示。
- 频率低的字符用长的比特串表示。
- 无前缀(Prefix-free):没有任何一个字符的编码是另一个字符编码的前缀(例如,如果 A 是
0,那么 B 不可能是01,否则解码会有歧义)。这正是哈夫曼树的特性。
2. 算法思路:哈夫曼树(Huffman Tree)
要计算最优编码的总长度,我们不需要真正生成每个字符的编码(如 “00”, “01”),只需要计算带权路径长度之和(WPL)。
具体步骤:
- 统计频率:遍历输入字符串,统计每个字符(包括下划线)出现的次数。
- 构建优先队列:将所有出现过的字符的频率放入一个小顶堆(Min-Priority Queue)。
- 贪心策略构建树:
- 从小顶堆中取出两个最小的频率值,设为
和 。 - 将它们合并,产生一个新的节点,频率为
。 - 这个
就是这两个节点在树中产生的“代价”(即编码长度贡献)。 - 将
重新放入堆中。 - 累加代价:将
加到总的压缩位长度变量中。 - 重复上述步骤,直到堆中只剩下一个节点。
- 从小顶堆中取出两个最小的频率值,设为
- 特殊情况处理:
- 如果字符串中只有一种字符(例如 “AAAAA”),哈夫曼树逻辑会直接结束。但在这种情况下,我们需要 1 个比特来表示该字符。
- 所以,如果字符种类数为 1,压缩后的长度 = 字符串长度
1。
3. 样例分析
以输入 AAAAABCD 为例:
- 统计:A:5, B:1, C:1, D:1。
- 堆:
{1, 1, 1, 5}。 - 合并 1:取出两个最小的
1(B)和1(C)。合并得2。- 当前代价累加:2。
- 堆变成:
{1, 2, 5}(剩下的D和新合成的BC)。
- 合并 2:取出
1(D) 和2(BC)。合并得3。- 当前代价累加:
。 - 堆变成:
{3, 5}。
- 当前代价累加:
- 合并 3:取出
3和5(A)。合并得8。- 当前代价累加:
。 - 堆变成:
{8}。停止。
- 当前代价累加:
- 结果:
- ASCII长度:
。 - 哈夫曼长度:13。
- 比率:
。
- ASCII长度:
4. C++ 代码实现
#include <iostream>
#include <string>
#include <queue>
#include <map>
#include <vector>
#include <cstdio> // 用于 printf
using namespace std;
int main() {
string s;
while (cin >> s && s != "END") {
map<char, int> freq;
// 1. 统计字符频率
for (char c : s) {
freq[c]++;
}
// 2. 将频率放入小顶堆
// priority_queue 默认是大顶堆,使用 greater 变成小顶堆
priority_queue<int, vector<int>, greater<int>> pq;
for (auto const& [key, val] : freq) {
pq.push(val);
}
long long original_len = s.length() * 8;
long long compressed_len = 0;
// 3. 特殊情况:只有一种字符
if (pq.size() == 1) {
compressed_len = s.length(); // 每个字符用1位表示
} else {
// 4. 哈夫曼树构建过程
while (pq.size() > 1) {
int a = pq.top(); pq.pop();
int b = pq.top(); pq.pop();
int combined = a + b;
compressed_len += combined; // 累加代价
pq.push(combined);
}
}
// 5. 输出结果
// %.1f 保留一位小数
printf("%lld %lld %.1f\n", original_len, compressed_len, (double)original_len / compressed_len);
}
return 0;
}
5. 代码关键点说明
- 数据类型:虽然题目描述的字符串长度通常不会溢出
int,但在累加过程中使用long long是个好习惯,防止溢出。 - 小顶堆:
priority_queue<int, vector<int>, greater<int>>是 C++ STL 实现哈夫曼编码最方便的数据结构。它能保证每次pop()出来的都是当前频率最小的两个节点。 - 计算逻辑:
- 传统的哈夫曼编码计算通常是:
。 - 代码中的简便算法利用了一个数学性质:非叶子节点的权值之和等于所有叶子节点的带权路径长度之和。每次合并操作
a+b实际上就是生成了一个内部节点,所以直接累加a+b即可得到最终总长度。
- 传统的哈夫曼编码计算通常是:
- Corner Case (边界情况):
- 务必注意
if (pq.size() == 1)的判断。如果没有这个判断,while (pq.size() > 1)循环一次都不会执行,导致compressed_len为 0,最终计算比率时会出现除以零错误(Runtime Error 或 NaN)。
- 务必注意
6. 复杂度分析
- 时间复杂度:
- 统计频率:
,其中 是字符串长度。 - 堆操作:假设字符集大小为
(这里是大写字母+数字+下划线,最多约 40 种)。构建堆和循环合并的复杂度为 。 - 由于
很小,整体复杂度主要取决于字符串长度,即 。
- 统计频率:
- 空间复杂度:
,用于存储字符频率映射和优先队列。
算法过程
- 频率统计与初始化:展示数据是如何被预处理的。
- 哈夫曼树的最终形态:展示
AAAAABCD是如何形成 13 bits 的编码的。 - 优先队列的构建过程:展示贪心算法是如何一步步合并节点的(对应代码中的
pq.pop()和pq.push())。
图形 1:频率统计 (Frequency Count)
这是算法的第一步,将字符串转化为字符频率表。
Graphviz (Dot)
digraph FrequencyCount {
rankdir=LR;
node [shape=record, fontname="Arial"];
// 标题
labelloc="t";
label="Step 1: Frequency Counting for 'AAAAABCD'";
// 输入字符串
input [label="Input String | { A | A | A | A | A | B | C | D }"];
// 映射表
map [label="Frequency Map | { <fA> A : 5 | <fB> B : 1 | <fC> C : 1 | <fD> D : 1 }", color=blue];
input -> map [label="Count", style=dashed];
}
图形 2:哈夫曼树结构与编码 (Huffman Tree)
这是核心图形。通过这个树,学生可以直观地看到:
- 叶子节点是具体的字符。
- 路径(左0右1)形成了编码。
- 高频字符(如 A)深度浅,低频字符(如 B, C)深度深。
Graphviz (Dot)
digraph HuffmanTree {
node [fontname="Arial"];
edge [fontname="Arial", fontsize=10];
// 标题
labelloc="t";
label="Huffman Tree for 'AAAAABCD'\nTotal Bits = Sum(Freq * Depth) OR Sum of Internal Nodes";
// 内部节点 (圆形)
node [shape=circle, style=filled, fillcolor=lightgrey];
root [label="Sum: 8"];
internal1 [label="Sum: 3"];
internal2 [label="Sum: 2"];
// 叶子节点 (矩形, 只有叶子才代表字符)
node [shape=record, style=filled, fillcolor=lightblue];
leaf_A [label="{Char: A | Freq: 5}"];
leaf_D [label="{Char: D | Freq: 1}"];
leaf_B [label="{Char: B | Freq: 1}"];
leaf_C [label="{Char: C | Freq: 1}"];
// 构建连接
root -> leaf_A [label="0", color=red, penwidth=2];
root -> internal1 [label="1"];
internal1 -> leaf_D [label="0"];
internal1 -> internal2 [label="1"];
internal2 -> leaf_B [label="0"];
internal2 -> leaf_C [label="1"];
// 解释说明
{rank=same; leaf_A; internal1}
}
图形 3:优先队列合并过程 (Priority Queue Process)
这个图形解释了代码中的 while(pq.size() > 1) 循环。它展示了“代码为什么将中间节点的权值累加”就能得到最终结果。
- Code Logic:
compressed_len += (a + b) - Cost Breakdown: 2 (First merge) + 3 (Second merge) + 8 (Third merge) = 13.
digraph MergeProcess {
rankdir=TB;
node [shape=plaintext, fontname="Arial"];
label="Priority Queue Merging Process (Greedy Strategy)";
// 初始状态
step0 [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
<TR><TD COLSPAN="4"><B>Step 0: Initial Heap</B></TD></TR>
<TR><TD BGCOLOR="yellow">1 (B)</TD><TD BGCOLOR="yellow">1 (C)</TD><TD>1 (D)</TD><TD>5 (A)</TD></TR>
</TABLE>>];
// 第一步合并
step1 [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
<TR><TD COLSPAN="4"><B>Step 1: Pop 1, 1 → Push 2</B></TD></TR>
<TR><TD BGCOLOR="lightblue">2 (BC)</TD><TD>1 (D)</TD><TD>5 (A)</TD><TD border="0">Cost += 2</TD></TR>
</TABLE>>];
// 第二步合并
step2 [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
<TR><TD COLSPAN="4"><B>Step 2: Pop 1, 2 → Push 3</B></TD></TR>
<TR><TD BGCOLOR="lightblue">3 (DBC)</TD><TD>5 (A)</TD><TD border="0"></TD><TD border="0">Cost += 3</TD></TR>
</TABLE>>];
// 第三步合并
step3 [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
<TR><TD COLSPAN="4"><B>Step 3: Pop 3, 5 → Push 8</B></TD></TR>
<TR><TD BGCOLOR="lightblue">8 (Root)</TD><TD border="0"></TD><TD border="0"></TD><TD border="0">Cost += 8</TD></TR>
</TABLE>>];
final [label="Total Cost = 2 + 3 + 8 = 13", fontcolor=red, fontsize=14];
step0 -> step1 [label="Merge min two"];
step1 -> step2 [label="Merge min two"];
step2 -> step3 [label="Merge min two"];
step3 -> final;
}
统计占用bit位总长度
这是一个非常好的问题。要理解为什么结果是 13 bits,我们需要看哈夫曼树是如何给不同频率的字符分配“不同长度”的编码的。
核心原则是:出现次数越多的字符,编码越短;出现次数越少的字符,编码越长。
1. 详细推导过程
针对字符串 AAAAABCD:
-
统计频率:
- A: 5次(大哥,出现最多)
- B: 1次
- C: 1次
- D: 1次
-
构建哈夫曼树(贪心合并):
- 第一步:取出最小的 B(1) 和 C(1)。
- 合并成新节点
BC,权重为。 - 此时 B 和 C 变成了子节点(深度增加)。
- 合并成新节点
- 第二步:取出最小的 D(1) 和刚才生成的
BC(2)。- 合并成新节点
DBC,权重为。 - 此时 D 变成了
BC的兄弟。
- 合并成新节点
- 第三步:取出最小的
DBC(3) 和 A(5)。- 合并成根节点
Root,权重为。 - A 成为
DBC的兄弟,直接挂在根节点下(深度最浅)。
- 合并成根节点
- 第一步:取出最小的 B(1) 和 C(1)。
-
分配编码长度:
- A:在根节点的直接子节点,深度为 1。 -> 编码长度 1 bit (例如
0) - D:在第 2 层。 -> 编码长度 2 bits (例如
10) - B:在第 3 层。 -> 编码长度 3 bits (例如
110) - C:在第 3 层。 -> 编码长度 3 bits (例如
111)
- A:在根节点的直接子节点,深度为 1。 -> 编码长度 1 bit (例如
-
最终计算:
2. 图形解析
为了让你直观地看到这 13 bits 是怎么来的,我绘制了下面的哈夫曼树和计算表。
Graphviz (Dot)
此图展示了树的结构,并在节点旁标注了比特长度的贡献。
digraph Calculation13Bits {
rankdir=TB;
node [fontname="Arial", shape=circle, fixedsize=true, width=0.9];
edge [fontname="Arial", fontsize=10];
// 标题
labelloc="t";
label="Why 13 Bits? Huffman Tree Structure for 'AAAAABCD'";
// 节点定义
root [label="Sum: 8", style=filled, fillcolor=lightgrey];
// A 离根最近
node_A [label="A\n(x5)", shape=doublecircle, style=filled, fillcolor="#90EE90"]; // Green for frequent
internal1 [label="Sum: 3", style=filled, fillcolor=lightgrey];
node_D [label="D\n(x1)", shape=circle, style=filled, fillcolor="#FFB6C1"];
internal2 [label="Sum: 2", style=filled, fillcolor=lightgrey];
node_B [label="B\n(x1)", shape=circle, style=filled, fillcolor="#FFB6C1"];
node_C [label="C\n(x1)", shape=circle, style=filled, fillcolor="#FFB6C1"];
// 连线与编码位
root -> node_A [label="0 (1 bit)", penwidth=3, color=green];
root -> internal1 [label="1 (1 bit)"];
internal1 -> node_D [label="0 (1 bit)"];
internal1 -> internal2 [label="1 (1 bit)"];
internal2 -> node_B [label="0 (1 bit)"];
internal2 -> node_C [label="1 (1 bit)"];
// 右侧添加说明表
subgraph cluster_explanation {
label = "Calculation";
style=dashed;
node [shape=note, width=2, fillcolor=white];
calc [label="A: 5 * 1 bit = 5\nD: 1 * 2 bits = 2\nB: 1 * 3 bits = 3\nC: 1 * 3 bits = 3\n------------------\nTotal = 13 bits"];
}
}
3. 两种计算方法的对比
在做题(比如写代码)和人脑理解时,通常有两种算 13 的方法,结果是一样的:
方法一:路径长度法(人脑直观) 对应上面的图。
- A 用了 1 个位 (0)
5次 = 5 - D 用了 2 个位 (10)
1次 = 2 - B 用了 3 个位 (110)
1次 = 3 - C 用了 3 个位 (111)
1次 = 3 - Total = 13
方法二:内部节点累加法(代码实现最快)
你在代码里可能没有显式地算深度,而是每次 compressed_len += (a+b)。
- 第一次合并 (B+C):产生内部节点权值 2
- 第二次合并 (D+BC):产生内部节点权值 3
- 第三次合并 (A+DBC):产生内部节点权值 8
- Total = 2 + 3 + 8 = 13
这也是为什么代码里不需要建树就能算出答案的原因,因为所有非叶子节点的权值之和 = 所有叶子节点的带权路径长度之和。
方法二:内部节点累加法 详解
? 所有非叶子节点的权值之和 = 所有叶子节点的带权路径长度之和
? 为什么内部节点累加法是正确的呢?
这是一个非常棒的思维卡点!理解了这一点,你就彻底理解了哈夫曼树的本质。
之所以**“内部节点累加和”** 等于 “所有叶子的(频率
每一个叶子节点的频率值,在向上合并的过程中,每一层都被“加”了一次。
叶子节点深度是多少,它就被合并了多少次,它的值也就被累加了多少次。
1. 直观推导(以 B 为例)
让我们追踪一下字符 B (频率 1) 在 compressed_len += (a+b) 这个过程中到底被加了多少次。
在 AAAAABCD 的例子中,B 的深度是 3。
-
第一次合并 (B + C = 2):
- 产生新节点
Internal_1(值 2)。 compressed_len加上了 2。- 本质:因为
Internal_1包含了 B,所以 B 被加了第 1 次。
- 产生新节点
-
第二次合并 (D + Internal_1 = 3):
- 产生新节点
Internal_2(值 3)。 compressed_len加上了 3。- 本质:因为
Internal_2包含了Internal_1,而Internal_1包含 B,所以 B 顺带被加了第 2 次。
- 产生新节点
-
第三次合并 (A + Internal_2 = 8):
- 产生根节点
Root(值 8)。 compressed_len加上了 8。- 本质:
Root包含Internal_2,所以 B 顺带被加了第 3 次。
- 产生根节点
结论:B 的值是 1,它在计算过程中累计出现了 3 次。贡献总和 =
2. 图形化演示:数值的“每层贡献”
下面的图展示了每个叶子节点的值是如何一层层向上“渗透”并被多次计算的。
Graphviz (Dot)
这个图侧重于展示累加的过程。看红色的线,它表示 B 的值是如何流向每一次加法操作的。
digraph InternalSumLogic {
rankdir=BT; // 从下往上画,符合树的生长和累加逻辑
node [shape=record, fontname="Arial"];
edge [dir=back, fontname="Arial", fontsize=10];
// 标题
labelloc="t";
label="Why Internal Sum works?\nTrace how many times 'B' is added.";
// 叶子节点
leaf_A [label="A (5)", style=filled, fillcolor=lightgrey];
leaf_D [label="D (1)", style=filled, fillcolor=lightgrey];
leaf_B [label="B (1)", style=filled, fillcolor="#FFB6C1"]; // 重点关注 B
leaf_C [label="C (1)", style=filled, fillcolor=lightgrey];
// 内部节点 (代表累加操作)
internal_1 [label="Merge 1\nSum: 2\n(Contains B)", style=dashed, color=red];
internal_2 [label="Merge 2\nSum: 3\n(Contains B via Merge 1)", style=dashed, color=red];
root [label="Merge 3 (Root)\nSum: 8\n(Contains B via Merge 2)", style=dashed, color=red];
// 结构关系
internal_1 -> leaf_B [color=red, penwidth=2, label="1st count"];
internal_1 -> leaf_C;
internal_2 -> leaf_D;
internal_2 -> internal_1 [color=red, penwidth=2, label="2nd count"];
root -> leaf_A;
root -> internal_2 [color=red, penwidth=2, label="3rd count"];
// 解释框
explanation [shape=note, label="B is at Depth 3.\nB is part of Merge 1.\nMerge 1 is part of Merge 2.\nMerge 2 is part of Root.\nTherefore, B is added 3 times."];
explanation -> leaf_B [style=dotted, constraint=false];
}
3. 总结
-
Code Perspective (代码视角): 我们维护一个堆。每次
pop两个数a,b,算出sum = a + b,然后把sum计入总代价。 -
Math Perspective (数学视角): 这个
sum代表了一个新的父节点。- 任何被合并的节点,其值都会成为父节点的一部分。
- 父节点再次被合并时,原来的值又被加了一次。
- 合并多少次 = 离根节点有多远 = 深度。
所以,直接累加每次合并产生的新值,就完美的等同于算出每个叶子的(频率
方法二的数学证明
我的理解的:
- 先看root 是 8 = 3 + 5 , 5 表示 A 的 频率
深度 (root 的其中一个是叶子的孩子) 这里的3,就是 另外一个 非叶子的树的值. 证明: 一个树的root值 : root+root 这个新的值相当于 把每个节点的dep+1,频率没有改变
使用数学归纳法证明(因为是递归的)
或者直接使用数学公式证明
你的直觉非常敏锐,特别是这一点:
“root + root 这个新的值相当于 把每个节点的dep+1, 频率没有改变”
这正是数学证明的核心!你的理解完全正确。
让我们用更严谨的数学推导(代数法 + 归纳法)来证明:WPL(带权路径长度) = 所有非叶子节点权值之和。
1. 核心思想图解:合并造成的“深度+1”
当你把左子树
Graphviz (Dot)
此图展示了合并操作如何物理上增加了路径长度。
digraph DepthIncrease {
rankdir=TD;
node [shape=record, fontname="Arial"];
edge [fontname="Arial", fontsize=10];
labelloc="t";
label="Geometric Proof: Merging adds 1 to everyone's depth\nNew Cost = Cost(L) + Cost(R) + Weight(Root)";
// 新的根节点
new_root [label="New Root\nWeight = W(L) + W(R)", style=filled, fillcolor=yellow];
// 左子树
subgraph cluster_L {
label="Left Subtree (Weight W_L)";
style=dashed;
node_L [label="Root of L"];
leaf_L1 [label="leaf..."];
node_L -> leaf_L1 [style=dotted];
}
// 右子树
subgraph cluster_R {
label="Right Subtree (Weight W_R)";
style=dashed;
node_R [label="Root of R"];
leaf_R1 [label="leaf..."];
node_R -> leaf_R1 [style=dotted];
}
// 这一步合并,创造了新的边(深度+1)
edge [color=red, penwidth=2, label="+1 Depth"];
new_root -> node_L;
new_root -> node_R;
}
2. 代数推导证明 (Algebraic Proof)
设
注:
就是我们要求的字符串的占用bit位总长度
目标: 计算合并后的新树
-
定义:
-
合并过程: 新树
由左子树 和右子树 构成。 对于 中的任意叶子节点 ,其在旧树中的深度是 ,在 中的深度变成了 。 对于 同理。 -
推导公式:
-
结论:
这说明:新树的总代价 = 左子树代价 + 右子树代价 + 当前新根节点的权值。
这是一个递归定义。如果我们一直递归到底(直到叶子节点,
为0),那么总代价显然就是所有合并过程中产生的“根节点权值”的累加和。
3. 数学归纳法证明 (Inductive Proof)
我们定义命题
1. 基础情况 (Base Case):
- 当
时:只有一个节点。WPL = 。非叶子节点集合为空,和为 0。 ,成立。 - 当
时:有两个叶子 。 - 生成一个根节点
,权值为 。 - WPL =
。 - 非叶子节点只有
,权值为 。 - 成立。
- 生成一个根节点
2. 归纳假设 (Inductive Hypothesis):
假设对于叶子节点数小于
注:
表示内部的非叶子节点的和 表示树上叶子节点的频率和
3. 归纳步骤 (Inductive Step):
考虑一个有
的权值 。 - 根据之前的代数推导:
- 根据归纳假设:
- 代入得:
- 而
的所有非叶子节点正是: 的非叶子节点 + 的非叶子节点 + 本身。 - 因此:
- 证毕。
总结图
用来总结归纳法逻辑的图。
digraph InductiveLogic {
node [shape=box, fontname="Arial"];
label="Visualizing the Recursive Proof";
eq1 [label="Cost(NewTree) = Cost(Left) + Cost(Right) + RootWeight"];
eq2 [label="Since Cost(Left) = Sum of Internals in Left\n(By Hypothesis)", style=filled, fillcolor=lightblue];
eq3 [label="Since Cost(Right) = Sum of Internals in Right\n(By Hypothesis)", style=filled, fillcolor=lightblue];
conclusion [label="Cost(NewTree) = \n(Internals in Left) + (Internals in Right) + RootWeight\n= Sum of ALL Internals", style=filled, fillcolor=green];
eq1 -> eq2 [style=dashed];
eq1 -> eq3 [style=dashed];
eq2 -> conclusion;
eq3 -> conclusion;
}
你的理解完全到位,这就是为什么哈夫曼编码的代码可以写得那么简洁(只用一个堆,不用建树)的原因!