算法图解之Swift实践【第八章 贪婪算法】

126 阅读7分钟

本人已参与「新人创作礼」活动,一起开启掘金创作之路。

何为贪婪算法

对于一个问题,根据问题要求的目标,每步都选择局部最优解,最终得到的就是全局最优解。 在某些特定的情况下,贪婪算法能够得到最优解,但通常情况下,只能够得到一个接近最优解的解。

练习一

8.1 你在一家家具公司工作,需要将家具发往全国各地,为此你需要将箱子装上卡车。每个箱子的尺寸各不相同,你需要尽可能利用每辆卡车的空间,为此你将如何选择要装上卡车的箱子呢?请设计一种贪婪算法。使用这种算法能得到最优解吗?

运用贪婪策略,依次将所有剩余箱子中体积最大的装上卡车,直到不能再装入箱子为止。

不能得到最优解。

8.2 你要去欧洲旅行,总行程为7天。对于每个旅游胜地,你都给它分配一个价值——表示你有多想去那里看看,并估算出需要多长时间。你如何将这次旅行的价值最大化?请设计一种贪婪算法。使用这种算法能得到最优解吗?

依次挑选可在剩余时间内完成的价值最大的活动,直到余下的时间不足于完成任何活动为止。

不能得到最优解。

集合覆盖问题

假设你办了个广播节目,要让全美50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。 在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出。 现有广播台名单如下:

截屏2022-09-30 14.43.52.png

每个广播台都覆盖特定的区域,不同广播台的覆盖区域可能重叠。 如何找出覆盖全美50个州的最小广播台集合呢?

可以使用近似算法(approximation algorithm):

  1. 选出这样的一个广播台,即它覆盖了最多未覆盖的州,即便这个广播台覆盖了一些已覆盖的州。
  2. 重复第一步,直到覆盖了所有的州。

在获得精确解需要的时间太长时,可以使用近似算法。 判断近似算法优劣的标准是:

  • 速度有多快
  • 得到的近似解与最优解的接近程度。

贪婪算法是不错的选择,它们不仅简单,而且通常运行速度很快。 在本例中,贪婪算法的运行时间为O(n^2),其中n为广播台数量。

var statesNeeded: Set<String> = ["mt", "wa", "or", "id", "nv", "ut", "ca", "az"]

var stations: [String: Set<String>] = [:]
stations["kone"] = ["id", "nv", "ut"]
stations["ktwo"] = ["wa", "id", "mt"]
stations["kthree"] = ["or", "nv", "ca"]
stations["kfour"] = ["nv", "ut"]
stations["kfive"] = ["ca", "az"]

func greedy(_ statesNeeded: Set<String>) -> Set<String> {
    var varStates = statesNeeded
    var res: Set<String> = []

    while varStates.count > 0 {
        var bestState = ""
        var stateCovered: Set<String> = []
        
        for (station, states) in stations {
            let covered = varStates.intersection(states)
            if covered.count  > stateCovered.count {
                bestState = station
                stateCovered = covered
            }
        }
        
        for sc in stateCovered {
            varStates.remove(sc)
        }
        
        res.insert(bestState)
    }
    
    return res
}

greedy(statesNeeded)

使用上述贪婪算法的运行时间仅为O(n^2),比起遍历所有子集来获得精确解的精确算法O(2^n)来说非常快。

集合类似于列表,只是不能包含重复的元素。 集合的特性:

  • 并集意味着将集合合并。
  • 交集意味着找出两个集合中都有的元素。
  • 差集意味着将从一个集合中剔除出现在另一个集合中的元素。

练习二

下面各种算法,是否是贪婪算法? 8.3 快速排序

不是

8.4 广度优先搜索

8.5 狄克斯特拉算法

NP完全问题

回到之前讨论过的旅行商问题。旅行商需要前往5个不同的城市,如何找出前往这5个城市的最短路径? 为此必须计算每条可能的路径,随着城市个数的个数增加,可能存在路径为城市个数的阶乘。

旅行商问题和集合覆盖问题有一些共同之处:你需要计算所有的解,并从中选出最小/最短的那个。这两个问题都属于NP完全问题

NP完全问题的简单定义是,以难解著称的问题,如旅行商问题和集合覆盖问题。很多非常聪明的人都认为,根本不可能编写出可快速解决这些问题的算法。

对于NP完全问题,我们可能找不到最优解,但是可以近似求解。

如何识别NP完全问题

  • 元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
  • 涉及“所有组合”的问题通常是NP完全问题。
  • 不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
  • 如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。
  • 如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
  • 如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。

练习三

8.6 有个邮递员负责给20个家庭送信,需要找出经过这20个家庭的最短路径。请问这是一个NP完全问题吗?

8.7 在一堆人中找出最大的朋友圈(即其中任何两个人都相识)是NP完全问题吗?

8.8 你要制作美国地图,需要用不同的颜色标出相邻的州。为此,你需要确定最少需要使用多少种颜色,才能确保任何两个相邻州的颜色都不同。请问这是NP完全问题吗?

小结

  • 贪婪算法寻找局部最优解,企图以这种方式获得全局最优解。
  • 对于NP完全问题,还没有找到快速解决方案。
  • 面临NP完全问题时,最佳的做法是使用近似算法。
  • 贪婪算法易于实现、运行速度快,是不错的近似算法。

拓展与应用

LeetCode 409. 最长回文串

给定一个包含大写字母和小写字母的字符串 s ,返回 通过这些字母构造成的 最长的回文串 。

在构造过程中,请注意 区分大小写 。比如 "Aa" 不能当做一个回文字符串。

示例 1:

输入: s = "abccccdd"
输出: 7
解释:
我们可以构造的最长的回文串是"dccaccd", 它的长度是 7。

示例 2:

输入: s = "a"
输入: 1

提示:

  • 1 <= s.length <= 2000
  • s 只由小写 和/或 大写英文字母组成

解题思路

  1. 先使用一个数组统计每个字符出现的次数。
  2. 再循环这个数组,判断每个字符出现的次数的奇偶。如果为偶数,则回文串的长度就是该字符出现的次数;如果为奇数,则回文串的长度就是该字符出现的次数再减一。
  3. 判断是否有剩余的字符串还剩下一次,则可将这个字符串插入到回文串的中间位置,这样总的结果需再加一。
func longestPalindrome(_ s: String) -> Int {
    if s.count == 0return 0 }
    
    // 如果题目给出的只是英文字母的话,最好开一个数组而不是哈希表。
    // 因为数组比哈希表更加单纯、简单。
    // 这一小部分代码就是先将字符串 s 转换成字符,然后统计每个字符所出现的次数
    var freqs: [Int] = Array(repeating: 0, count: 128)
    for c in s.utf8CString {
        let i = Int(c)
        if i != 0 { 
            freqs[i] += 1 
        }
    }

    var res = 0 // 用于保存最后的结果,进行返回
    var odd = 0 // 用于统计是否有最后落单的那个字符
    for freq in freqs {
        // 如果某个字符出现的次数为偶数次,则回文串的长度就是该字符出现的次数。
        // 如果某个字符出现的次数为奇数次,则回文串的长度就是该字符出现的次数再减一。
        res += (freq % 2 == 0) ? freq : (freq - 1)
        if freq % 2 == 1 {
            // 如果某个字符最后还剩下 1 次,则将这个字符插入到回文串的中间位置
            odd = 1
        }
    }

    res += odd
    return res
}


let str = "aahajkldah"
longestPalindrome(str)

Output: 7

这里运用了贪婪算法的思想。

时间复杂度:O(N),其中N为字符串s的长度。我们需要遍历每个字符一次。

空间复杂度:O(S),其中S为字符集大小。