CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/37 | 贪心算法:如何用贪心算法实现Huffman压缩编码?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

134 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="37 | 贪心算法如何用贪心算法实现Huffman压缩编码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/02/53/028452c9c6fb12e3199738f61f9c6053.mp3"></audio>
基础的数据结构和算法我们基本上学完了,接下来几节,我会讲几种更加基本的算法。它们分别是贪心算法、分治算法、回溯算法、动态规划。更加确切地说,它们应该是算法思想,并不是具体的算法,常用来指导我们设计具体的算法和编码等。
贪心、分治、回溯、动态规划这4个算法思想原理解释起来都很简单但是要真正掌握且灵活应用并不是件容易的事情。所以接下来的这4个算法思想的讲解我依旧不会长篇大论地去讲理论而是结合具体的问题让你自己感受这些算法是怎么工作的是如何解决问题的带你在问题中体会这些算法的本质。我觉得这比单纯记忆原理和定义要更有价值。
今天我们先来学习一下贪心算法greedy algorithm。贪心算法有很多经典的应用比如霍夫曼编码Huffman Coding、Prim和Kruskal最小生成树算法、还有Dijkstra单源最短路径算法。最小生成树算法和最短路径算法我们后面会讲到所以我们今天讲下霍夫曼编码看看**它是如何利用贪心算法来实现对数据压缩编码,有效节省数据存储空间的**。
## 如何理解“贪心算法”?
关于贪心算法,我们先看一个例子。
假设我们有一个可以容纳100kg物品的背包可以装各种物品。我们有以下5种豆子每种豆子的总量和总价值都各不相同。为了让背包中所装物品的总价值最大我们如何选择在背包中装哪些豆子每种豆子又该装多少呢
<img src="https://static001.geekbang.org/resource/image/f9/c7/f93f4567168d3bc65688a785b76753c7.jpg" alt="">
实际上这个问题很简单我估计你一下子就能想出来没错我们只要先算一算每个物品的单价按照单价由高到低依次来装就好了。单价从高到低排列依次是黑豆、绿豆、红豆、青豆、黄豆所以我们可以往背包里装20kg黑豆、30kg绿豆、50kg红豆。
这个问题的解决思路显而易见,它本质上借助的就是贪心算法。结合这个例子,我总结一下贪心算法解决问题的步骤,我们一起来看看。
**第一步,当我们看到这类问题的时候,首先要联想到贪心算法**:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
类比到刚刚的例子限制值就是重量不能超过100kg期望值就是物品的总价值。这组数据就是5种豆子。我们从中选出一部分满足重量不超过100kg并且总价值最大。
**第二步,我们尝试看下这个问题是否可以用贪心算法解决**:每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。
类比到刚刚的例子,我们每次都从剩下的豆子里面,选择单价最高的,也就是重量相同的情况下,对价值贡献最大的豆子。
**第三步,我们举几个例子看下贪心算法产生的结果是否是最优的**。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。
实际上,用贪心算法解决问题的思路,并不总能给出最优解。
我来举一个例子。在一个有权图中我们从顶点S开始找一条到顶点T的最短路径路径中边的权值和最小。贪心算法的解决思路是每次都选择一条跟当前顶点相连的权最小的边直到找到顶点T。按照这种思路我们求出的最短路径是S-&gt;A-&gt;E-&gt;T路径长度是1+4+4=9。
<img src="https://static001.geekbang.org/resource/image/2d/42/2de91c0afb0912378c5acf32a173f642.jpg" alt="">
但是这种贪心的选择方式最终求的路径并不是最短路径因为路径S-&gt;B-&gt;D-&gt;T才是最短路径因为这条路径的长度是2+2+2=6。为什么贪心算法在这个问题上不工作了呢
在这个问题上贪心算法不工作的主要原因是前面的选择会影响后面的选择。如果我们第一步从顶点S走到顶点A那接下来面对的顶点和边跟第一步从顶点S走到顶点B是完全不同的。所以即便我们第一步选择最优的走法边最短但有可能因为这一步选择导致后面每一步的选择都很糟糕最终也就无缘全局最优解了。
## 贪心算法实战分析
对于贪心算法,你是不是还有点懵?如果死抠理论的话,确实很难理解透彻。掌握贪心算法的关键是多练习。只要多练习几道题,自然就有感觉了。所以,我带着你分析几个具体的例子,帮助你深入理解贪心算法。
### 1.分糖果
我们有m个糖果和n个孩子。我们现在要把糖果分给这些孩子吃但是糖果少孩子多m&lt;n所以糖果只能分配给一部分孩子。
每个糖果的大小不等这m个糖果的大小分别是s1s2s3……sm。除此之外每个孩子对糖果大小的需求也是不一样的只有糖果的大小大于等于孩子的对糖果大小的需求的时候孩子才得到满足。假设这n个孩子对糖果大小的需求分别是g1g2g3……gn。
我的问题是,如何分配糖果,能尽可能满足最多数量的孩子?
我们可以把这个问题抽象成从n个孩子中抽取一部分孩子分配糖果让满足的孩子的个数期望值是最大的。这个问题的限制值就是糖果个数m。
我们现在来看看如何用贪心算法来解决。对于一个孩子来说,如果小的糖果可以满足,我们就没必要用更大的糖果,这样更大的就可以留给其他对糖果大小需求更大的孩子。另一方面,对糖果大小需求小的孩子更容易被满足,所以,我们可以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对我们期望值的贡献是一样的。
我们每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。
### 2.钱币找零
这个问题在我们的日常生活中更加普遍。假设我们有1元、2元、5元、10元、20元、50元、100元这些面额的纸币它们的张数分别是c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付K元最少要用多少张纸币呢
在生活中我们肯定是先用面值最大的来支付如果不够就继续用更小一点面值的以此类推最后剩下的用1元来补齐。
在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少,这就是一种贪心算法的解决思路。直觉告诉我们,这种处理方法就是最好的。实际上,要严谨地证明这种贪心算法的正确性,需要比较复杂的、有技巧的数学推导,我不建议你花太多时间在上面,不过如果感兴趣的话,可以自己去研究下。
### 3.区间覆盖
假设我们有n个区间区间的起始端点和结束端点分别是[l1, r1][l2, r2][l3, r3],……,[ln, rn]。我们从这n个区间中选出一部分区间这部分区间满足两两不相交端点相交的情况不算相交最多能选出多少个区间呢
<img src="https://static001.geekbang.org/resource/image/f0/cd/f0a1b7978711651d9f084d19a70805cd.jpg" alt="">
这个问题的处理思路稍微不是那么好懂,不过,我建议你最好能弄懂,因为这个处理思想在很多贪心算法问题中都有用到,比如任务调度、教师排课等等问题。
这个问题的解决思路是这样的我们假设这n个区间中最左端点是lmin最右端点是rmax。这个问题就相当于我们选择几个不相交的区间从左到右将[lmin, rmax]覆盖上。我们按照起始端点从小到大的顺序对这n个区间排序。
我们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间。这实际上就是一种贪心的选择方法。
<img src="https://static001.geekbang.org/resource/image/ef/b5/ef2d0bd8284cb6e69294566a45b0e2b5.jpg" alt="">
## 解答开篇
今天的内容就讲完了,我们现在来看开篇的问题,如何用贪心算法实现霍夫曼编码?
假设我有一个包含1000个字符的文件每个字符占1个byte1byte=8bits存储这1000个字符就一共需要8000bits那有没有更加节省空间的存储方式呢
假设我们通过统计分析发现这1000个字符中只包含6种不同字符假设它们分别是a、b、c、d、e、f。而3个二进制位bit就可以表示8个不同的字符所以为了尽量减少存储空间每个字符我们用3个二进制位来表示。那存储这1000个字符只需要3000bits就可以了比原来的存储方式节省了很多空间。不过还有没有更加节省空间的存储方式呢
```
a(000)、b(001)、c(010)、d(011)、e(100)、f(101)
```
霍夫曼编码就要登场了。霍夫曼编码是一种十分有效的编码方法广泛用于数据压缩中其压缩率通常在20%90%之间。
霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。霍夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。如何给不同频率的字符选择不同长度的编码呢?根据贪心的思想,我们可以把出现频率比较多的字符,用稍微短一些的编码;出现频率比较少的字符,用稍微长一些的编码。
对于等长的编码来说我们解压缩起来很简单。比如刚才那个例子中我们用3个bit表示一个字符。在解压缩的时候我们每次从文本中读取3位二进制码然后翻译成对应的字符。但是霍夫曼编码是不等长的每次应该读取1位还是2位、3位等等来解压缩呢这个问题就导致霍夫曼编码解压缩起来比较复杂。为了避免解压缩过程中的歧义霍夫曼编码要求各个字符的编码之间不会出现某个编码是另一个编码前缀的情况。
<img src="https://static001.geekbang.org/resource/image/02/29/02ad3e02429b294412fb1cff1b3d3829.jpg" alt="">
假设这6个字符出现的频率从高到低依次是a、b、c、d、e、f。我们把它们编码下面这个样子任何一个字符的编码都不是另一个的前缀在解压缩的时候我们每次会读取尽可能长的可解压的二进制串所以在解压缩的时候也不会歧义。经过这种编码压缩之后这1000个字符只需要2100bits就可以了。
<img src="https://static001.geekbang.org/resource/image/83/45/83921e609c8a4dc81ca5b90c8b4cd745.jpg" alt="">
尽管霍夫曼编码的思想并不难理解,但是如何根据字符出现频率的不同,给不同的字符进行不同长度的编码呢?这里的处理稍微有些技巧。
我们把每个字符看作一个节点并且附带着把频率放到优先级队列中。我们从队列中取出频率最小的两个节点A、B然后新建一个节点C把频率设置为两个节点的频率之和并把这个新节点C作为节点A、B的父节点。最后再把C节点放入到优先级队列中。重复这个过程直到队列中没有数据。
<img src="https://static001.geekbang.org/resource/image/7b/7a/7b6a08e7df45eac66820b959c64f877a.jpg" alt="">
现在我们给每一条边加上画一个权值指向左子节点的边我们统统标记为0指向右子节点的边我们统统标记为1那从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。
<img src="https://static001.geekbang.org/resource/image/cc/ed/ccf15d048be005924a409574dce143ed.jpg" alt="">
## 内容小结
今天我们学习了贪心算法。
实际上,贪心算法适用的场景比较有限。这种算法思想更多的是指导设计基础算法。比如最小生成树算法、单源最短路径算法,这些算法都用到了贪心算法。**从我个人的学习经验来讲,不要刻意去记忆贪心算法的原理,多练习才是最有效的学习方法。**
贪心算法的最难的一块是如何将要解决的问题抽象成贪心算法模型,只要这一步搞定之后,贪心算法的编码一般都很简单。贪心算法解决问题的正确性虽然很多时候都看起来是显而易见的,但是要严谨地证明算法能够得到最优解,并不是件容易的事。所以,很多时候,我们只需要多举几个例子,看一下贪心算法的解决方案是否真的能得到最优解就可以了。
## 课后思考
<li>
在一个非负整数a中我们希望从中移除k个数字让剩下的数字值最小如何选择移除哪k个数字呢
</li>
<li>
假设有n个人等待被服务但是服务窗口只有一个每个人需要被服务的时间长度是不同的如何安排被服务的先后顺序才能让这n个人总的等待时间最短
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。