python实现贪心算法

Python算法:贪心策略

# [[‘i’, [[‘a’, ‘b’], ‘e’]], [[‘f’, ‘g’], [[‘c’, ‘d’], ‘h’]]]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from heapq import heapify , heappush , heappop
from itertools import count
 
def huffman ( seq , frq ) :
     num = count ( )
     trees = list ( zip ( frq , num , seq ) )              # num ensures valid ordering
     heapify ( trees )                                # A min-heap based on freq
     while len ( trees ) > 1 :                        # Until all are combined
         fa , _ , a = heappop ( trees )                # Get the two smallest trees
         fb , _ , b = heappop ( trees )
         n = next ( num )
         heappush ( trees , ( fa + fb , n , [ a , b ] ) )      # Combine and re-add them
     # print trees
     return trees [ 0 ] [ - 1 ]
 
seq = “abcdefghi”
frq = [ 4 , 5 , 6 , 9 , 11 , 12 , 15 , 16 , 20 ]
print huffman ( seq , frq )
# [[‘i’, [[‘a’, ‘b’], ‘e’]], [[‘f’, ‘g’], [[‘c’, ‘d’], ‘h’]]]

现在我们考虑另外一个问题,合并文件问题,假设我们将大小为 m 和大小为 n 的两个文件合并在一起需要 m+n 的时间,现在给定一些文件,求一个最优的合并策略使得所需要的时间最小。

如果我们将上面哈夫曼树中的叶子节点看成是文件,两个文件合并得到的大文件就是树中的内部节点,假设每个节点上都有一个值表示该文件的大小,合并得到的大文件上的值是合并的两个文件的值之和,那我们的目标是就是使得内部节点的和最小的合并方案,因为叶子节点的大小是固定的,所以实际上也就是使得所有节点的和最小的合并方案!

consider how each leaf contributes to the sum over all nodes: the leaf weight occurs as a summand once in each of its ancestor nodes—which means that the sum is exactly the same! That is, sum(weight(node) for node in nodes) is exactly the same as sum(depth(leaf)*weight(leaf) for leaf in leaves).

细想也就有了一个叶子节点的所有祖先节点们都有一份该叶子节点的值包含在里面,也就是说所有叶子节点的深度与它的值的乘积之和就是所有节点的值之和!可以看下下面的示例图,最终我们知道哈夫曼树就是这个问题的解决方案。

[哈夫曼树问题的一个扩展就是最优二叉搜索树问题,后者可以用动态规划算法来求解,感兴趣的话可以阅读算法导论中动态规划部分内容]

4.最小生成树

最小生成树是图中的重要算法,主要有两个大家耳熟能详的Kruskal和Prim算法,两个算法都是基于贪心策略,不过略有不同。

[如果对最小生成树问题的历史感兴趣的话作者推荐看这篇论文“On the History of the Minimum Spanning Tree Problem,” by Graham and Hell]

不了解Kruskal或者Prim算法的童鞋可以参考算法导论的示例图理解下面的内容

Kruskal算法

Prim算法

连通无向图G的生成树是指包含它所有顶点但是部分边的子图,假设每条边都有一个权值,那么权值之和最小的生成树就是最小生成树,它不一定是唯一的。如果图G是非连通的,那么它就没有生成树。

前面我们在介绍遍历的时候也得到过生成树,那里我们是一个顶点一个顶点进行遍历,下面我们通过每次添加一条边来得到最小生成树,而且每次我们贪心地选择剩下的边中权值最小的那条边,但是要保证不能形成环!

那怎么判断是否会出现环呢?

假设我们要考虑是否添加边(u,v),一个最直接的想法就是遍历已生成的树,看是否能够从 u 到 v,如果能,那么就舍弃这条边继续考虑后面的边,否则就添加这条边。很显然,采用遍历的方式太费时了。

再假设我们用一个集合来保存我们已经生成的树中的节点,如果我们要考虑是否添加边(u,v),那么我们就看下集合中这两个节点是否都存在,如果都存在的话说明这条边加进来的话会形成环。这么做可以在常数时间内确定是否会形成环,但是…它是错误的!除非我们每次添加一条边之后得到的局部解一直都只有一棵树才对,如果之前加入的节点 u 和节点 v 在不同的分支上的话,上面的判断不能确定添加这条边之后会形成环![后面的Prim算法采用的策略就能保证局部解一直都是一棵树]

下面我们可以试着让每个加入的节点都知道自己处在哪个分支上,而且我们可以用分支中的某一个节点作为该分支的“代表”,该分支中的所有节点都指向这个“代表”,显然我们接下来会遇到分支合并的问题。如果两个分支因为某条边的加入而连通了,那么它们就要合并了,那怎么合并呢?我们让两个分支中的所有节点都指向同一个“代表”就行了,但是这是一个线性时间的操作,我们可以做得更快!假设我们改变下策略,让每个节点指向另一个节点(这个节点不一定是分支的“代表”),如果我们顺着指向链一直找,就肯定能找到“代表”,因为“代表”是自己指向自己的。这样的话,如果两个分支要合并,只需要让其中的一个分支的“代表”指向另一个分支的“代表”就行啦!这就是一个常数时间的操作。

基于上面的思路我们就有了下面的实现

从上面的分析我们可以看到,虽然合并时修改指向的操作是常数时间的,但是通过指向链的方式找到“代表”所花的时间是线性的,而这里还可以做些改进。

首先,在合并(union)的时候我们让“小”分支指向“大”分支,这样平衡了之后平均查找时间肯定有所下降,那么怎么确定分支的“大小”呢?这个可以用平衡树的方式来思考,假设我们给每个节点都设置一个权重(rank or weight),其实重要的还是“代表”的权重,如果要合并的两个分支的“代表”的权重相等的话,在将“小”分支指向“大”分支之后,还要将“大”分支的权重加1。

其次,在查找(find)的时候我们一边查找一边修正经过的点的指向,让它直接指向“代表”,这个怎么做到呢?使用递归就行了,因为递归在找到了之后会回溯,回溯的时候就可以设置其他节点的“代表”了,这个叫做path compression技术,是Kruskal算法常用的一个技巧。

基于上面的改进就有了下面优化的Kruskal算法

接下来就是Prim算法了,它其实就是我们前面介绍的traversal算法中的一种,不同点是它对待办事项(to-do list,即前面提到的“边缘节点”,也就是我们已经包含的这些节点能够直接到达的那些节点)进行了一定的排序,我们在实现BFS时使用的是双端队列deque,此时我们只要把它改成一个优先队列(priority queue)就行了,这里选用heapq模块中的堆heap

Prim算法不断地添加新的边(也可以说是一个新的顶点),一旦我们加入了一条新的边,可能会导致某些原来的边缘节点到生成树的距离更加近了,所以我们要更新一下它们的距离值,然后重新调整下排序,那怎么修改距离值呢?我们可以先找到原来的那个节点,然后再修改它的距离值接着重新调整堆,但是这么做实在是太麻烦了!这里有一个巧妙的技巧就是直接向堆中插入新的距离值的节点!为什么可以呢?因为插入的新节点B的距离值比原来的节点A的距离值小,那么Prim算法添加顶点的时候肯定是先弹出堆中的节点B,后面如果弹出节点A的话,因为这个节点已经添加进入了,直接忽略就行了,也就是说我们这么做不仅很简单,而且并没有把原来的问题搞砸了。下面是作者给出的详细解释,总共三点,第三点是重复的添加不会影响算法的渐近时间复杂度

• We’re using a priority queue, so if a node has been added multiple times, by the time we remove one of its entries, it will be the one with the lowest weight (at that time), which is the one we want.

• We make sure we don’t add the same node to our traversal tree more than once. This can be ensured by a constant-time membership check. Therefore, all but one of the queue entries for any given node will be discarded.

• The multiple additions won’t affect asymptotic running time

[重新添加一次权值减小了的节点就相当于是松弛(或者说是隐含了松弛操作在里面),Re-adding a node with a lower weight is equivalent to a relaxation,这两种方式是可以相互交换的,后面图算法中作者在实现Dijkstra算法时使用的是relax,那其实我们还可以实现带relex的Prim和不带relax的Dijkstra]

根据上面的分析就有了下面的Prim算法实现


[扩展知识,另一个角度来看最小生成树 A SLIGHTLY DIFFERENT PERSPECTIVE]

In their historical overview of minimum spanning tree algorithms, Ronald L. Graham and Pavol Hell outline three algorithms that they consider especially important and that have played a central role in the history of the problem. The first two are the algorithms that are commonly attributed to Kruskal and Prim (although the second one was originally formulated by Vojtěch Jarník in 1930), while the third is the one initially described by Borůvka. Graham and Hell succinctly explain the algorithms as follows. A partial solution is a spanning forest, consisting of a set of fragments (components, trees). Initially, each node is a fragment. In each iteration, edges are added, joining fragments, until we have a spanning tree.

Algorithm 1: Add a shortest edge that joins two different fragments.

Algorithm 2: Add a shortest edge that joins the fragment containing the root to another fragment.

Algorithm 3: For every fragment, add the shortest edge that joins it to another fragment.

For algorithm 2, the root is chosen arbitrarily at the beginning. For algorithm 3, it is assumed that all edge weights are different to ensure that no cycles can occur. As you can see, all three algorithms are based on the same fundamental fact—that the shortest edge over a cut is safe. Also, in order to implement them efficiently, you need to be able to find shortest edges, detect whether two nodes belong to the same fragment, and so forth (as explained for algorithms 1 and 2 in the main text). Still, these brief explanations can be useful as a memory aid or to get the bird’s-eye perspective on what’s going on.


5.Greed Works. But When?

还是老话题,贪心算法真的很好,有时候也比较容易想到,但是它什么时候是正确的呢?

针对这个问题,作者提出了些建议和方法[都比较难翻译和理解,感兴趣还是阅读原文较好]

(1)Keeping Up with the Best

This is what Kleinberg and Tardos (in Algorithm Design) call staying ahead. The idea is to show that as you build your solution, one step at a time, the greedy algorithm will always have gotten at least as far as a hypothetical optimal algorithm would have. Once you reach the finish line, you’ve shown that greed is optimal.

(2)No Worse Than Perfect

This is a technique I used in showing the greedy choice property for Huffman’s algorithm. It involves showing that you can transform a hypothetical optimal solution to the greedy one, without reducing the quality. Kleinberg and Tardos call this an exchange argument.

(3)Staying Safe

This is where we started: to make sure a greedy algorithm is correct, we must make sure each greedy step along the way is safe. One way of doing this is the two-part approach of showing (1) the greedy choice property, that is, that a greedy choice is compatible with optimality, and (2) optimal substructure, that is, that the remaining subproblem is a smaller instance that must also be solved optimally.

[扩展知识:算法导论中还介绍了贪心算法的内在原理,也就是拟阵,贪心算法一般都是求这个拟阵的最大独立子集,方法就是从一个空的独立子集开始,从一个已经经过排序的序列中依次取出一个元素,尝试添加到独立子集中,如果新元素加入之后的集合仍然是一个独立子集的话那就加入进去,这样就形成了一个更大的独立子集,待遍历完整个序列时我们就得到最大的独立子集。拟阵的内容比较难,感兴趣不妨阅读下算法导论然后证明一两道练习题挑战下,嘻嘻]

用Python代码来形容上面的过程就是


练习:试试这道删数问题吧,这里对比了贪心算法和动态规划算法两种解法

var jiathis_config={ data_track_clickback:false, title:"分享从伯乐在线看到的一篇好文章 ", summary:"《Python算法:贪心策略》", pic:"http://ww2.sinaimg.cn/mw690/6941baebgw1esb0wm4cmbj20nc07fdgg.jpg", appkey:{ "tsina": 1473879058 }, ralateuid:{ "tsina": 5305630013 }, hideMore:false }

猜你喜欢

转载自blog.csdn.net/cloudxli/article/details/80698979