贪心算法(1)——算法导论(21)

1. 写在前面

在之前的5篇博客中,我们学习了动态规划算法。我们可以看到,在求解最优化问题的算法中,通常需要经过一系列的步骤,在每个步骤中都面临多种选择。对于许多最优化问题,使用动态规划算法来求解最优解有些杀鸡用牛了,可以使用更加简单的算法。贪心算法(greedy algorithm)就是其中之一:它在每一步做出的选择都是当时看起来的最优选择。也即是说,它总是做出局部最优选择,以此希望这样的选择能够产生全局的最优解。

2. 选择活动问题

2.1. 提出问题

我们先来看看一个适应贪心算法求解的问题:选择活动问题

假定有一个n个活动的集合S={a1,a2,...,an},每个活动ai的举办时间为[si,fi)0si<fi,并且假定集合S中的活动都已按结束时间递增的顺序排列好。由于某些原因,这些活动在同一时刻只能有一个被举办,即对于任意两个活动aiajij,区间[si,fi)与区间[si,fi)不能重叠,此时我们称活动aiaj兼容。在选择活动问题中,我们希望选择出一个最大兼容活动集。

例如,给出如下活动集合S

i 1 2 3 4 5 6 7 8 9 10 11
si 1 3 0 5 3 5 6 8 8 2 12
fi 4 5 6 7 9 9 10 11 12 14 16

子集a3,a9,a11是相互兼容的,但它不是一个最大集,因为子集 ai,a4,a8,a11更大。实际上,a1,a4,a8,a11是一个最大兼容活动集,另一个最大集是:a2,a4,a9,a11

2.2. 分析问题

2.2.1. 动态规划算法

我们先尝试使用动态规划算法来解决该问题。首先验证该问题是否具有最优子结构性质

Sij表示在ai结束之后开始,且在aj开始之前结束的活动集合。我们的任务是求Sij的一个最大兼容的活动子集。假设Aij就是这样一个子集,其中包含ak,于是原问题就被分解为了两个子问题:求解 SikSkj中的兼容活动。显然,这两个子问题的兼容活动子集的并是原问题的一个兼容活动子集。

现在问题的关键是两个子问题的最优兼容活动子集的并是否是原问题的最优解。或者反过来,原问题的一个最优解Aij是否包含两个子问题的最优解。答案是肯定的,同样可以用剪切-粘贴证明,这里不再赘述。

证明了该问题具有最优子结构性质,于是我们可以用一个递归式来描述原问题的最优解。设c[i,j]表示集合Sij的最优解的大小,则有:

c[i,j]={0Sij= maxakSij{c[i,k]+c[k,j]+1}Sij

于是接下来就可以设计一个自顶向上的动态规划算法。

2.2.2. 贪心选择

我们看到,在上述的动态规划算法中,由于我们不确定到底选择哪一个ak,会产生最优解,因此我们必须考察每一种ak的选取情况。自然地,我们便会想,对于每一次ak的选择,我们可不可以直接找出“最优的ak”的呢?如果能这样,那么算法的效率会大大地提升。

事实上,对于该问题,我们在面临选择时,还真的可以只考虑一种选择(贪心选择)。直观上我们可以想象,要想使活动更多的举办,我们在每次选择活动时,应尽量选择最早结束的活动,这样可以把更多的时间留给其他的活动。

更确切地说,对于活动集合Sij,由于活动都按照结束时间递增的顺序排列好,因此贪心选择是ai。如果贪心选择是正确的,按照该选择方法,我们便能将所有选择的结果组合成原问题的最优解。

现在问题的关键是,贪心选择选择出来的元素总是最优解的一部分吗?答案同样还是肯定的,下面的定理说明了这一点:

Sk={aiS:sifk}表示在活动ak结束后开始的活动集合。考虑任意非空子问题Sk,令amSk中最早结束的活动,则amSk的某个最大兼容活动子集中。

我们可以如下证明该定理:设AiSi的一个最大兼容活动子集,且aj是其中最早结束的活动。若am=aj,则自然满足上述结论;若amaj,用am替代Ai中的aj,得到子集A,即 A=(Aaj)am,则A也是Si的一个最大兼容活动子集。因为amSk中最早结束的活动,于是有fmfj,因此A仍然是兼容的。并且显然|A|=|A|,所以得出上面的结论,也就得出了定理中的结论。

2.3 解决问题

有了上述分析的基础,我们可以很容易设计出一个贪心算法来解决原问题。和动态规划算法相比,由于我们每次都是一次性的找出了当时的最优解,而不必像动态规划算法那样需要考虑每种可能的选择情况,因此贪心算法就不必考虑子问题是否重叠,也就不需要解决重叠问题的“备忘录”了。因此,与动态规划算法相反的是,贪心算法通常都是自顶向下进行设计的。

下面给出一种Python的实现:

def recursive_activity_selector(s, f, k, n, ls):
    m = k + 1
    while m <= n:
        if s[m] >= f[k]:
            ls.append(m)
            recursive_activity_selector(s, f, m, n, ls)
            return ls
        m += 1

对于文章开头给出的例子,做如下测试:

# 测试
if __name__ == '__main__':
    # 注意,这里添加了一个开始时间为0,结束时间也为0的活动。
    s = [0, 1, 3, 0, 5, 3, 5, 6, 8, 8, 2, 12]
    f = [0, 4, 5, 6, 7, 9, 9, 10, 11, 12, 14, 16]
    k = 0
    n = 11
    ls = []
    recursive_activity_selector(s, f, k, n, ls)
    print(ls)

打印结果为:

[1, 4, 8, 11]
posted @   学数学的程序猿  阅读(4986)  评论(2编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?
点击右上角即可分享
微信分享提示