算法导论 第十六章:贪心算法 笔记(活动选择问题、贪心策略的基本内容、赫夫曼编码)

版权声明:站在巨人的肩膀上学习。 https://blog.csdn.net/zgcr654321/article/details/83180670

贪心算法是使所做的选择看起来都是当前最佳的,期望通过所做的局部最优选择来产生出一个全局最优解。

一般的贪心算法有以下的步骤:

1、确定问题的最优子结构。

2、设计一个递归算法。

3、证明如果我们做出一个贪心选择,则只剩下一个子问题。

4、证明贪心选择总是安全的。(步骤3、4的顺序可以调换)

5、设计一个递归算法实现贪心策略。

6、将递归算法转换为迭代算法。

有许多许多应用了贪心策略的算法,如赫夫曼编码,最小生成树, Dijkstra的单源最短路径等。

活动选择问题:

假设有一个n个活动的集合S=a1,a2,...,an,这些活动使用同一个资源,而这个资源在某个时刻只能供一个活动使用。每个活动都有一个开始时间si和一个结束时间fi。若si≥fi或sj≥fi,则ai和aj是兼容的。在活动选择问题中,我们希望选出一个最大兼容活动集。假设活动已按结束时间的单调递增顺序排序。

如:

从图中可以看出S中共有11个活动,最大的相互兼容的活动子集为:{a1,a4,a8,a11,}和{a2,a4,a9,a11}。

活动选择问题的最优子结构:

令Sij表示在ai结束之后开始,且在aj开始之前结束的那些活动的集合。假定我们希望求Sij的一个最大的相互兼容的活动子集。假定Aij就是这样一个子集,包含活动ak。

我们得到两个子问题:寻找Sik中的兼容活动(在ai结束之后开始且在ak开始之前结束的那些活动)以及寻找Skj中的兼容活动(在ak结束之后开始且在aj开始之前结束的那些活动)

Aij=Aik U {ak} U Akj

而且Sij中最大兼容任务子集|Aij|=|Aik|+|Akj|+1

用c[i,j]表示集合Sij的最优解的大小,所以有:

c[i,j] = c[i,k] + c[k, j] + 1。

这是在已经知道最优解包含k的情况下,如果k未知,则面临多个选择,因而有递归式:

将动态规划解转化为贪心解:

直观上,我们应该选择这样一个活动,选出它剩下的资源应能被尽量多的其他任务所用。我们优先选择最早结束的活动,因为它剩下的资源应能被尽量多的其他任务所用。换句话说,由于活动已经按结束时间单调递增的顺序排序,贪心选择就是活动1,选择最早结束的活动并不是本问题的唯一的贪心选择方法。下面列出选择最早结束的活动的两种算法。

递归贪心算法:

Recursive-Activity-Selector(s, f, k, n) 
    m = k + 1
    // find the first activity in Sk to finish
    while m <= n and s[m] < f[k]
        m = m + 1
    if m <= n
        return {am} U Recursive-Activity-Selector(s, f, m, n)
    else 
        return Ø

如:

针对上面的例子,递归贪心算法的计算过程:

出现在水平线之间的是每次递归要考虑的活动。

虚构活动ao在时间0结束,而在初始过程调用RECURSIVE-ACTIVITY·SELECTOR(s, f,0,12) 中,活动a1被选中。在每次的递归调用中,已经被选中的活动以阴影标识,而以白色标识的活动表示正在被考虑的对象。如果活动的开始时间先于刚刚被加入活动的结束时间(它们之间的箭头指向左边),则将被丢弃。否则(箭头指向上或向右), 此活动被选中。最后一个递归调用RECURSJVE-ACTMTY-SELEGTOR (s, f, 11. 12)返回空集。被选中活动的结果集为{ a1, a4, a8, a11}。

迭代贪心算法:

Greedy-Activity-Selector(s, f)
    n = s.length
    A = {a1}
    k = 1
    for m = 2 to n
        if s[m] >= f[k]
            A = A U {am}
            k = m
    return A

贪心策略的基本内容:

开发一个贪心算法时,一般经过了如下步骤:

1、决定问题的最优子结构;

2、设计出一个递归解;

3、证明在递归的任一阶段,最优选择之一总是贪心选择。那么,做贪心选择总是安全的;

4、证明通过做贪心选择,所有子问题( 除一个以外)都为空;

5、设计出一个实现贪心策略的递归算法;

6、将递归算法转换成迭代算法。

通过这些步骤,可以清楚地发现现动态规划是贪心算法的基础。

某个问题是否适合贪心算法,贪心选择性质和最优子结构性质是两个关键要素。

贪心选择性质:

通过局部最优选择来构造最优解。也就是说,当考虑做何选择时,我们只考虑对当前问题最佳的选择而不考虑子问题的结果。这一点是贪心算法不同于动态规划之处。

在动态规划中,每一步都要做出选择,但是这些选择依赖于子问题的解(比如钢条切割的第一切割点)。因此,解动态规划问题一般是自底向上,从小子问题处理至大子问题。在贪心算法中,我们所做的总是当前看似最佳的选择、然后再解决选择之后所出现的子问题。贪心算法不依赖于将来的选择或者子问题的解。

因此,不像动态规划方法那样自底向上地解决子间题,贪心策略通常是自顶向下地做的,一个一个地做出贪心选择,不断地将给定的问题实例归约为更小的问题。

当然,必须证明每个步骤作出贪心选择能生成局部最优解。

最优子结构:

最优子结构也是应用于贪心算法的关键要素。在贪心算法中使用最优子结构时,通常是用更直接的方式:如前所述,假设在原问题中做了一个贪心选择而得到了一个子问题。真正要做的是证明将此子问题的最优解与所做的贪心选择合并后,的确可以得到原问题的一个最优解。这个方案意味着要对子问题采用归纳法,来证明每个步骤中所做的贪心选择最终会产生出一个最优解。

贪心法与动态规划:

贪心算法和动态规划不容易区分,背包问题例子如下:

0-1背包问题:

有一个贼在偷窃一家商店时发现有n个物品,第i件物品价值为vi,重量为wi 。此处vi和wi都是整数。他希望带走的东西越值钱越好,但他的背包中至多只能装下W磅的东西,W为一整数。应该带走哪几样东西?(这个问题之所以称为0-1背包问题,是因为每件物品或被带走,或被留下。小愉不能只带走某个物品的一部分或带走两次以上的同一物品。)

部分背包问题:

场景等与上面问题一样,但是窃贼可以带走物品的一部分,而不必做出0-1的二分选择。可以把0-1背包问题的一件物品想像成一个金锭,而部分背包问题中的一件物品则更像金粉。

两种背包问题都具有最优子结构性质,但部分背包问题可用贪心策略来解决,而0-1背包问题却不行。

为解决部分背包问题,先对每件物品计算其每磅的价值vi/wi。按照一种贪心策略,窃贼开始时对具有最大每磅价值的物品尽量多拿一些。如果他拿完了该物品而仍可以取一些其他物品时,他就再取具有次大的每磅价值的物品,一直继续下去,直到不能再取为止,这样,通过按每磅价值来对所有物品排序,贪心算法就可以O(nlgn)时间内运行。关于贪心选择的证明类似于上一节活动选择的证明。

对于0-1背包问题,当我们在考虑是否要把一件物品加到背包中时,必须对把该物品加进去的子问题的解与不取该物品的子问题的解进行比较。由这种方式形成的问题导致了许多重叠子问题(这是动态规划的一个特点)。所以,我们可以用动态规划来解决。递归方程如下:

f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n)}

f(n,m)表示前n个物品,放入总承重m的包中的最大价值。对于第n个物品,考虑放入和不放入的最大值。

赫夫曼编码:

赫夫曼编码可以有效的压缩数据,通常可以节省20%~30%的空间。、这里考虑的数据指的是字符串序列。赫夫曼贪心算法使用了一张字符出现频度表,根据它来构造一种将每个字符表示成二进制串的最优方式。

几个概念:

叶子节点的路径长度:从根到叶子节点的边的个数;

叶子节点的带权路径长度(WPL):叶子节点的权值X路径长度;

最优二叉树:一颗二叉树的所有叶子节点的带权路径长度之和最小。

如:

赫夫曼编码的思想就是变长编码变长编码就是让字符表中出现概率高的字符的编码长度尽可能小,而出现概率高的字符的编码长度相对较长。然后还要遵循前缀码的要求,就是任意一个编码都不是其他编码的前缀码,这样方便解码。

前缀编码:

没有一个编码是另一个编码的前缀,成为前缀编码。

如:

构造赫夫曼编码:

在下面的伪代码中,假设C是一个包含n个字符的集合,且每个字符c属于C都是一个出现频度为f[c] 的对象。算法以自底向上的方式构造出最优编码所对应的树T。它从ICI个叶结点的集合开始,并依次执行ICI-1次“合并“操作来构造最终的树。Q 是一个以f为关键字的最小优先级队列,用来识别出要合并的两个频度最低的对象。两个对象合并的结果是一个新对象,其频度为被合并的两个对象的频度之和。

伪代码:

HUFFMAN(C)
    n = C.length
    Q = C
    for i =1 to n-1
        allocate a new node z
        z.left = x = EXTRACT-MIN(Q)
        z.right = y = EXTRACT-MIN(Q)
        z.freq = x.freq + y.freq
        INSERT(Q,z)
    return EXTRACT-MIN(Q) 
    //return the root of the tree

如:

还是这个例子:

对上面这个例子而言,赫夫曼算法的执行过程如下图所示。因为字母表中共有6个字母,故初始队列的规模为n=6, 要构造编码树共需五步合并。最终的树就表示了最优前缀编码,其中某一字母的编码就是从根至该字母的路径上边标记的序列。

时间分析:

huffman中建堆的过程花费时间为O(n), for循环共执行n-1次,每次都要重新调整堆一次,需要O(lgn)时间,所有循环总共花费On(lgn),所以总的运行时间是O(nlgn)。

猜你喜欢

转载自blog.csdn.net/zgcr654321/article/details/83180670