证明kruskal算法求解图的最小生成树具有贪心选择性质_透过面试说算法(2) - 贪婪法...

引子

在开始讲解算法之前,先跟大家聊一下我(以前)的两大爱好:下棋和打乒乓球。

下棋的时候,我们不仅要考虑当前这一步怎么走,还要考虑接下来的几步甚至数十步棋的情况。举一个国际象棋中的例子,比如现在轮到你走棋,而接下来的这一步你可以吃掉对方的后(子力价值最高的棋子),这看起来是当前局面下最优的走法,但是几步之后你可能会因为被对方将死而输掉比赛,这应该不是你想要的结果。事实上,这样的弃子战术在国际象棋早期浪漫主义对局中经常出现。被后人称为“不朽的对局”中,当时世界最顶尖的棋手阿道夫·安德森就弃掉了所有的重子(两个车和一个皇后),最后用一个象和两个马将死了对方。这局棋相当的精彩,对于像我这样的初学者也有着教科书般的意义,对局如下图所示。

1ab1a42c78ac1f18974a920609072100.png
说明:国际象棋中的棋子包括兵(Pawn)、车(Rook)、马(Knight)、象(Bishop)、后(Queen)、王(King)六种,这种称呼其实是参照了中国象棋中棋子的名字。事实上,Knight应该译为骑士更加精准,而Bishop通常被称为主教。从子力价值来看,兵、车、马、象、后分别为1分、4-5分、3分、3分、8-10分,当然这只是一个参考值,当马处于棋盘中心位置或象处于开放的对角线上时,子力价值会发生一定的变化,而兵还可以通过升变变成除国王之外的其他棋子。

打乒乓球跟下棋就不太一样了。当我们在击球的时候,只需要做出当前情况下最正确的动作就可以了,几乎不用去想下一回合甚至下下一个回合的状况。即便你发球的时候就设计好了一个“调短拉长”的战术,但是对手的回球的方式和落点都未必跟你的预期一致,所以你能做的就是处理好当前这个回合。这件事情告诉我们:在某些情况下,只要保证每一步都是正确的,就能够得到最优的结果;或者说,我们可能无法追求最优的结果(例如打乒乓球的时候一个回合就击败对手),只需要一个令人满意的结果,贪婪法就适合解决这两种类型的问题。

基本策略和应用场景

贪婪法是分阶段执行的,每一阶段都根据当前情况作出判断,不用考虑之后的情况。通常我们每一步找出的解是局部最优解,而通常情况下我们认为全局最优解可以由局部最优解推导出来或者只需要一个满意解并不需要最优解

具有下面两个条件的问题就可以使用贪婪法进行求解,而且满足这两个条件是可以求出最优解的:

  1. 具备贪心选择性质 - 全局最优解可以由局部最优解推导出来,这个条件通常不那么容易满足。
  2. 具备最优子结构 - 整个问题的最优解由子问题的最优解构成。

我们耳熟能详的很多算法其实都是对贪婪法的应用,例如:

  1. 霍夫曼编码压缩算法
  2. 图的最小生成树算法(Prim算法Kruskal算法
  3. 带权图的最短路径算法(Dijkstra算法
  4. 背包问题
  5. 找零问题

贪婪法的热身题

我们先给大家来一个热身的题目。其实,面试的时候并没有那么多可以使用贪婪法来求解的算法题,但是这种算法却是大家应该了解和掌握的,因为它在很多场景下可能是一种非常好的解决问题的思路。

题目:小偷有一个背包,最多能装20公斤赃物,他闯入一户人家,发现如下表所示的物品,问他应该拿哪些东西才能使偷到的物品总价值最大。

bc2ff76cce5fe24289e27bc6a8f77029.png

对于上面这个题目,最为简单的思路就是计算每件物品的价格重量比,小偷取物品的时候,总是先取剩下的物品中价格重量比最大的物品先拿,这就是局部最优。当然,有的时候局部最优未必能够推导出全局最优。这个题目的参考代码可以在我的Python-100-Days上《Python语言进阶》一文中找到,有兴趣的可以自行查阅。

霍夫曼编码问题

霍夫曼编码是一种用于无损数据压缩的熵编码(权编码)算法。霍夫曼编码使用变长编码表对源符号(如文件中的一个字母)进行编码,简单的说就是出现几率高的字母使用较短的编码,出现几率低的字母使用较长的编码,而且要避开两个字符编码互为前缀的情况(避免产生二义性),这就使得编码之后的内容对应的二进制比特减少,从而达到无损压缩数据的目的。

我们以this is an example of a huffman tree为例,该字符串的长度为36,如果使用utf-8编码,那么需要保存或传输288比特。接下来我们看看如何使用霍夫曼编码来压缩数据。我们可以先统计出每个字母出现的频率,如下表所示。

c4a6c295c7645d90fcc0ba26bbeafe3f.png

接下来,我们为每个字符创建一个节点,最开始的时候,每个节点都可以视为一棵只有根节点的二叉树,对于上面的例子,一共有16棵树。霍夫曼编码在每一轮中都要从这些二叉树中找出根节点的值最小的那两棵树,然后创建一个新节点。新节点对应的值是刚才那两棵树的根节点值之和,同时刚才的两棵树分别作为新节点的左子树和右子树。反复执行这个过程,注意每次都是选根节点的值最小的两棵树进行合并创建出新节点,直到最后只剩下一棵树为止,如下图所示。

6904b3de16408dd743d06eb7c1e47220.png

把树创建好之后,每个叶子节点就对应某个字符的霍夫曼编码。如果想获得某个字符串的编码,可以从根节点出发,向叶子节点前进,遇到左子树就记为0,遇到右子树就记为1,最终的编码如下表所示。

8e28823201b28dcc042ba731c991a846.png

我们可以简单的计算一下,霍夫曼编码的长度为135比特,比之前的288比特减少了一半还多。当然,实际应用中存储编码的树结构还需要花费额外的存储空间,但是通常情况下相较于要压缩的内容来说,这部分空间几乎可以忽略不计。回顾一下刚才的算法,每一步我们都是优先选择根节点值最小的两个节点进行合并(贪婪的做法),那么很显然,越早合并的节点最后会出现在二叉树越靠下面的位置,这样对应的字符编码就越长;而出现频率高的字符对应的节点在较晚的时候才会进行合并,那么它在二叉树的位置比较靠上,这样对应的字符编码就很短。

说明:上面的例子来自于 维基百科上关于霍夫曼编码的介绍。

简单的总结

这里我们就不再用代码来展示贪婪算法了,我相信像霍夫曼编码这种代码在网上应该可以找到很多。实现霍夫曼编码的要点就是要有一个优先队列,确保每次都可以取出节点值最小的两个节点进行合并(贪婪就体现在这个地方,因为每次取的都是剩下节点中值最小的),合并之后的节点重新放入队列中时,也能够处在一个恰当的位置。需要注意的是,霍夫曼编码压缩可以节省空间(如网络传输带宽),但是编码和解码都需要花费额外的时间,这又是典型的空间跟时间的置换

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值