算法复习——贪心策略篇之霍夫曼编码
以下内容主要参考中国大学MOOC《算法设计与分析》,墙裂推荐希望入门算法的童鞋学习!
1. 问题背景
在计算机中,常用二进制串对不同字符进行编码,通常我们会使用编码树进行字符编码。对于编码树,
-
顶点到左结点的边标记为0,到右结点的边标记1,通过编码方案构造编码树;
-
每条根到叶子的路径对应每个字符的二进制串;
-
叶子结点的深度就是对应的字符编码的二进制串长度
一种编码方案构造出的编码树如图所示:
现给出另一种编码方式,如下表所示。
a | b | c | d | e | f | |
---|---|---|---|---|---|---|
编码方式1 | 0 | 101 | 100 | 111 | 1101 | 1100 |
编码方式2 | 0 | 1 | 00 | 01 | 10 | 11 |
但很容易发现,当我们在用编码方式2时,解码会出现问题,例如00110既可以解码成aabba,也可以解码成cfa,解码结果不唯一。其实,问题在于编码树我们要求的是一条根结点到叶子结点的路径对应一个字符的编码方式,但是如果根据编码方式2构造编码树,可以发现,a和b对应的结点不是编码树的叶子结点。
因此,可以提出前缀码的概念,即编码的任意前缀不是其他编码,则解码结果唯一,编码方式可行。
如果要求得到最优的编码方式,那不同的前缀码如何比较它们的优劣呢?我们可以通过比较编码后二进制串的总长,总长越短,说明这种编码方式越优。而编码后二进制的总长,依赖于待编码字符的频数,假设我们给出的带编码字符及其频数和两种不同的前缀码,如下表所示。
a | b | c | d | e | f | |
---|---|---|---|---|---|---|
频数(千次) | 45 | 13 | 12 | 16 | 9 | 5 |
前缀码1 | 0 | 101 | 100 | 111 | 1101 | 1100 |
前缀码2 | 1100 | 1101 | 111 | 100 | 101 | 0 |
通过计算可知,使用前缀码1编码后二进制串的总长是224000,使用前缀码2编码后二进制串的总长是348000,显然,前缀码1要优于前缀码2。
那么,本文要解决的问题就是如何求得编码后二进制串总长最短的前缀码。
2. 问题定义
最优前缀码问题(Optimal Prefix Code Problem)
输入:
- 字符数n以及各个字符的频数F = <f1, f2, …, fn>
输出:
- 解析结果唯一的二进制编码方案C = < c1, …, cn>,令
m i n ∑ i = 1 n ∣ c i ∣ ∗ f i , min\sum_{i=1}^{n}|c_{i}|*f~i~, mini=1∑n∣ci∣∗f i ,
上式为优化结果,其中|ci|为字符i的编码二进制串长度。
3. 贪心策略初窥
编码方案适应频数大小,短二进制串编码高频字符。
3.1 优先处理高频字符
第一种贪心策略是优先处理高频字符,就是先尽可能地用短二进制串编码高频字符。首先将字符频数从大到小排序 F = <f1, f2, …, fn> (f1 ≥ \geq ≥ f2 ≥ \geq ≥ … \dots … ≥ \geq ≥ fn)。然后采用优先处理高频字符的策略,依次编码高频字符,编码树如下图所示。
但是,经过验算,使用这种编码方式的二进制串总长为234000,并非最优解。原因在于这种贪心策略要求词频从大到小的词的编码长度从小到大,且长度不相同,这导致了该编码树的结点左孩子固定为叶子结点,但其实这个条件并不是必须要求的。
3.2 优先处理低频字符
第二种贪心策略是优先处理低频字符,就是先尽可能地用长字符串编码低频字符。首先将字符频数从小到大排序 F = <f1, f2, …, fn> (f1 ≤ \leq ≤ f2 ≤ \leq ≤ … \dots … $\leq $ fn);然后选择两个最小的频数f1,f2,合并为f’ = f1 + f2;再在F’ = <f’, f3, …, fn>中重复选择合并过程。在上面的案例就是先将词频最小的f和e合并为一个词频为14的结点f’,然后选择词频最小的c和b合并为一个词频为25的结点,再选择词频最小的f’和d合并为一个词频为30的结点,之后的过程依次类推,编码树如下所示。
这种编码方式便是最优的前缀码,即霍夫曼编码。
4. 正确性证明
正如在前一章“算法复习——贪心策略篇之部分背包问题”中所说,贪心策略有时不难想到,但关键在于证明其最优性。
假设我们按词频从小到大排序后,频数F = <f1, f2, …, fn> (f1 ≤ \leq ≤ f2 ≤ \leq ≤ … \dots … ≤ \leq ≤ fn),需证明存在最优方案,使得f1, f2在编码树最底层。证明如下:
假设最优方案最底层两结点fx, fy (fx ≤ \leq ≤ fy),最小总长度为Tmin。根据原始条件有
- 条件1:f1 ≤ \leq ≤ fx, f2 ≤ \leq ≤ fy
- 条件2: Δ \Delta Δ1 = dT(fx) - dT(f1) ≥ \geq ≥ 0
- 条件3: Δ \Delta Δ2 = dT(fy) - dT(f2) ≥ \geq ≥ 0
因为我们我们已经排好顺序了,所以f1和f2一定是所有词频中最小的两个,所以条件1成立。如果fx和fy在编码树的最底层,所以条件2和3一定成立。
在以上的条件下,可以交换f1和fx,f2和fy可得
T
=
T
m
i
n
−
Δ
1
f
x
−
Δ
2
f
y
+
Δ
1
f
1
+
Δ
2
f
2
=
T
m
i
n
−
Δ
1
(
f
x
−
f
1
)
−
Δ
2
(
f
y
−
f
2
)
≤
T
m
i
n
\begin{aligned} T &= T_{min} - \Delta_1f_x-\Delta_2f_y+\Delta_1f_1+\Delta_2f_2\\ &=T_{min} - \Delta_1(f_x-f_1)-\Delta_2(f_y-f_2)\\ &\leq T_{min} \end{aligned}
T=Tmin−Δ1fx−Δ2fy+Δ1f1+Δ2f2=Tmin−Δ1(fx−f1)−Δ2(fy−f2)≤Tmin
因为Tmin是最优方案,而T
≤
\leq
≤ Tmin,所以T也就是最优方案,也就意味着存在最优方案,使得f1, f2在编码树的最底层。
5. 伪代码
Huffman(F, n)
输入:字符数n,各字符频数F
输出:霍夫曼编码树
// 预处理
按F递增排序 // 字符频数从小到大排序
新建结点数组P[1..n]和Q[1..n]
for i <- 1 to n do
P[i].freq <- F[i]
P[i].left <- NULL
P[i].right <- NULL
end
Q <- 0
for i <- 1 to n-1 do
新建结点z
x <- ExtractMin(P, Q)
y <- ExtractMin(P, Q)
z.freq <- x.freq + y.freq
z.left <- x
z.right <- y
Q.Add(z)
end
return ExtractMin(P, Q)
时间复杂度分析:
对F排序的时间复杂度是O(n log n);初始化的时间复杂度是O(n);在合并的循环中,由于P和Q数组(由于每次合并都是当前词频最小的两个元素合并,再加入到Q中,所以不可能后加入Q的结点的词频小于先前加入Q的结点的词频)已经排好顺序,因此操作的时间复杂度是O(1);所以整体算法的时间复杂度是O(n log n)。
6. 相关例题
这是一道k叉Huffman树问题,需要注意的是k叉Huffman树必须是一颗满k叉树(找不到明确的定义,即树中的任意结点要么有k个子节点,要么没有节点)。因此,我们可以用(n-1) % (k-1)来判断这n个叶子结点能不能构成满k叉树(假设满k叉树有m个节点,n个叶子节点,那么其一定满足(m-n)*k=m-1,简单推导可得(n-1) % (k-1) == 0)。如果上式不等于0的话,要进行Huffman编码,则需要添加k - 1 - (n-1) % (k-1)个词频为0的叶子节点在Huffman树的最底层。