資料結構與演算法——貪心演算法

天然呆dull 發表於 2021-09-25
演算法 資料結構

應用場景-集合覆蓋問題

貪心演算法可以解決很多場景的問題,這裡以集合覆蓋問題為例。

假設存在下面需要付費的廣播臺,以及廣播臺訊號可以覆蓋的地區。如何選擇最少的廣播臺,讓所有的地區都可以接收到訊號?

廣播臺 覆蓋地區
K1 "北京", "上海", "天津"
K2 "廣州", "北京", "深圳"
K3 "成都", "上海", "杭州"
K4 "上海", "天津"
K5 "杭州", "大連"

例如:k4 中有上海、天津,那麼我們選擇 k1,裡面包含了他們,還多了一個地區。

貪心演算法介紹

**貪婪演算法(貪心演算法)(英語:greedy algorithm) **是指在對問題進行求解時,在 每一步選擇中都採取最好或者最優(即最有利)的選擇,從而希望能夠導致結果是最好或者最優的演算法

貪婪演算法所得到的 結果不一定是最優的結果(有時候會是最優解),但是都是相對近似(接近)最優解的結果

思路分析

如何找出覆蓋所有地區的廣播臺的集合呢,最容易想到的是使用窮舉法實現,列出每個可能的廣播臺的集合,這被稱為 冪集。假設總的有 n 個廣播臺,則廣播臺的組合總共有 2ⁿ -1 個,假設每秒可以計算 10 個子集, 如圖:

廣播臺數量n 子集總數2ⁿ 需要的時間
5 32 3.2秒
10 1024 102.4秒
32 4294967296 13.6年
100 1.26*100³º 4x10²³年

由此可見:在進行組合的場景下,使用組合效率是很低的。(注意:子集總數2ⁿ 之所以少了一個減 1,是因為當n很大時,那個減1是可以忽略的)

那麼貪心演算法的思路如下:

廣播臺 覆蓋地區
K1 "北京", "上海", "天津"
K2 "廣州", "北京", "深圳"
K3 "成都", "上海", "杭州"
K4 "上海", "天津"
K5 "杭州", "大連"

目前並沒有演算法可以快速計算得到準確的值, 使用貪婪演算法,則可以得到非常接近的解,並且效率高。選擇策略上,因為需要覆蓋全部地區的最小集合,思路如下:

  1. 將所有需要覆蓋的地區找出來(allAreas)

    也就是所有電臺中的覆蓋地區去重後的列表

  2. 遍歷所有的廣播電臺,找到一個 覆蓋了最多未覆蓋的地區 的電臺

    此電臺可能包含一些已覆蓋的地區,但是沒有關係。

    比如:k1 中有三個地區,在上面找出來的列表中去判定是否覆蓋其中的地區,找到則 k1 為 覆蓋了最多未覆蓋的地區 的電臺。(不懂沒關係,往後看)

  3. 將這個電臺加入到一個集合中(如 ArrayList),並想辦法把該電臺覆蓋的地區在下次比較時去掉。

    比如:前面 k1 為 覆蓋了最多未覆蓋的地區,把 k1 加到該集合中,並從把 k1 已經覆蓋過的地區從 allAreas 中移除

  4. 重複第 2 步,直到覆蓋了全部的地區

圖解

給定的廣播電臺如下

廣播臺 覆蓋地區
K1 "北京", "上海", "天津"
K2 "廣州", "北京", "深圳"
K3 "成都", "上海", "杭州"
K4 "上海", "天津"
K5 "杭州", "大連"
  1. 找出所有需要覆蓋的地區

    // 遍歷所有電臺的覆蓋區域,然後去重,得到如下列表:共需要覆蓋 8 個地區
    allAreas = {"北京", "上海", "天津", "廣州", "深圳", "成都", "杭州", "大連"}
    
  2. 遍歷廣播電臺列表:找出一個覆蓋了最多地區的電臺,重點:如何確定覆蓋了最多的電臺?

    可以這樣做:遍歷廣播臺,計算每個電臺中覆蓋的地區在未覆蓋地區列表中,覆蓋了幾個?

    // 所有暫時還未覆蓋的地區列表
    allAreas = {"北京", "上海", "天津", "廣州", "深圳", "成都", "杭州", "大連"}
    
    廣播臺 覆蓋地區 覆蓋數量(未覆蓋地區的數量)
    K1 "北京", "上海", "天津" 3
    K2 "廣州", "北京", "深圳" 3
    K3 "成都", "上海", "杭州" 3
    K4 "上海", "天津" 2
    K5 "杭州", "大連" 2

    上圖覆蓋數量計算,例如:k1 覆蓋地區有三個,這三個地區現在都在 未覆蓋地區(allAreas),所以:k1 的覆蓋數量則是 3

  3. 找到覆蓋數量最大的電臺(每一步的選擇都選擇最優)

    // 未覆蓋地區
    allAreas = {"北京", "上海", "天津", "廣州", "深圳", "成都", "杭州", "大連"}
    

    上第 2 步驟中,計算出的覆蓋數量,k1 為最大的(k2 也是 3,但是不大於 k1 的覆蓋數量),計為 maxKey,將它新增到 選擇列表中,表示該電臺已被選擇,同時將 k1 中覆蓋地區,從 allAreas 列表中去掉,那麼現在的情況就如下:

    // 已選電臺
    selects =  {"k1"}
    // 未覆蓋地區
    allAreas = {廣州", "深圳", "成都", "杭州", "大連"}
    
  4. 重新計算未被選擇的電臺的覆蓋數量

    // 已選電臺
    selects =  {"k1"}
    // 未覆蓋地區
    allAreas = {廣州", "深圳", "成都", "杭州", "大連"}
    
    廣播臺 覆蓋地區 覆蓋數量(未覆蓋地區的數量)
    K1 "北京", "上海", "天津" 0
    K2 "廣州", "北京", "深圳" 2
    K3 "成都", "上海", "杭州" 2
    K4 "上海", "天津" 0
    K5 "杭州", "大連" 2

    注意:因為 k1,已經被選擇過,可以不重新對它計數,也可以重新計數,對效能影響不太大。

    上圖覆蓋數量計算,例如:

    • k1 覆蓋地區有三個,這三個地區現在在 未覆蓋地區(allAreas)中一個都沒有,所以:k1 的覆蓋數量則是 0
    • k2 覆蓋的確有三個,這三個地區現在在 未覆蓋地區(allAreas)中有 2 個:廣州、深圳,而北京已經被覆蓋掉了(k1),所以:k2 的覆蓋數量則是 2
  5. 找到覆蓋數量最大的電臺

    當前狀態:

    // 已選擇電臺
    selects =  {"k1"}
    // 所有暫時還未覆蓋的地區列表
    allAreas = {廣州", "深圳", "成都", "杭州", "大連"}
    
此時找到下一個覆蓋數量最大的電臺後的狀態:
   
   ```java
   // 已選擇電臺
   selects =  {"k1","K2"}
   // 所有暫時還未覆蓋的地區列表
   allAreas = {"成都", "杭州", "大連"}
  1. ... 以此類推,最後完成狀態如下
// 所有暫時還未覆蓋的地區列表
selects =  {"k1","K2","K3","K5"}
allAreas = {}

程式碼實現

/**
 * 貪心演算法
 */
public class GreedyAlgorithm {

    /**
     * 構建廣播電臺 與 覆蓋的地區
     * 
     * k: 電臺
     * v:覆蓋的地區
     * 
     *
     * @return
     */
    public Map<String, Set<String>> buildBroadcasts() {
        Map<String, Set<String>> broadcasts = new HashMap<>();
        Set<String> k1 = new HashSet<>();
        k1.add("北京");
        k1.add("上海");
        k1.add("天津");
        Set<String> k2 = new HashSet<>();
        k2.add("廣州");
        k2.add("北京");
        k2.add("深圳");
        Set<String> k3 = new HashSet<>();
        k3.add("成都");
        k3.add("上海");
        k3.add("杭州");
        Set<String> k4 = new HashSet<>();
        k4.add("上海");
        k4.add("天津");
        Set<String> k5 = new HashSet<>();
        k5.add("杭州");
        k5.add("大連");

        broadcasts.put("k1", k1);
        broadcasts.put("k2", k2);
        broadcasts.put("k3", k3);
        broadcasts.put("k4", k4);
        broadcasts.put("k5", k5);

        return broadcasts;
    }

    /**
     * 貪心演算法: 選擇最少的電臺,覆蓋所有的地區
     *
     * @param broadcasts 電臺資訊
     * @return 返回選擇的電臺列表
     */
    public Set<String> greedy(Map<String, Set<String>> broadcasts) {
        // 構建待覆蓋的所有地區
        Set<String> allAreas = new HashSet<>();
        broadcasts.forEach((k, v) -> {
            allAreas.addAll(v);
        });
        System.out.println("需要覆蓋的地區:" + allAreas);

        // 存放已選擇的電臺
        Set<String> selects = new HashSet<>();

        // 當所有需要覆蓋的地區還有時,則可以繼續選擇

        String maxKey = null; // 當次覆蓋地區最多的電臺
        int maxKeyCoverNum = 0; // maxKey 覆蓋的數量
        Set<String> temp = new HashSet<>();  // 臨時變數,用於計算電臺中的覆蓋地區:在剩餘要覆蓋地區中  覆蓋的數量
        while (!allAreas.isEmpty()) {
            // 選擇出當次還未選擇中:覆蓋地區最多的電臺
            for (String key : broadcasts.keySet()) {
                Set<String> areas = broadcasts.get(key);
                temp.addAll(areas);
                //求出temp 和   allAreas 集合的交集, 交集會賦給 temp
                temp.retainAll(allAreas);
                // 如果:當前嘗試選擇的電臺,覆蓋數量比 maxKey 還大,則把它設定為 maxKey
                if (temp.size() > 0 && temp.size() > maxKeyCoverNum) {
                    maxKey = key;
                    maxKeyCoverNum = temp.size();
                }
                //注意 要清空temp
                temp.clear();
            }
            if (maxKey == null) {
                continue;
            }
            // 迴圈完成後,找到了本輪的 maxKey
            // 新增到已選擇列表中,並且從 未覆蓋列表 中刪除已經覆蓋過的地區
            selects.add(maxKey);
            allAreas.removeAll(broadcasts.get(maxKey));
            // 清空臨時變數,方便下次查詢
            maxKey = null;
            maxKeyCoverNum = 0;
        }
        return selects;
    }

    @Test
    public void fun() {
        Map<String, Set<String>> broadcasts = buildBroadcasts();
        System.out.println("電臺列表" + broadcasts);
        Set<String> greedy = greedy(broadcasts);
        System.out.println("選擇好的電臺列表:" + greedy);
    }
}

測試輸出

電臺列表{k1=[上海, 天津, 北京], k2=[廣州, 北京, 深圳], k3=[成都, 上海, 杭州], k4=[上海, 天津], k5=[大連, 杭州]}
需要覆蓋的地區:[成都, 上海, 廣州, 天津, 大連, 杭州, 北京, 深圳]y
選擇好的電臺列表:[k1, k2, k3, k5]

貪婪演算法注意事項

貪婪演算法所得到的結果 不一定是最優的結果(有時候會是最優解),但是都是相對近似(接近)最優解的結果

比如上題的演算法選出的是 K1, K2, K3, K5,符合覆蓋了全部的地區,但是我們發現 K2, K3,K4,K5 也可以覆蓋全部地區,如果 K2 的使用成本低於 K1 ,那麼我們上題的 K1, K2, K3, K5 雖然是滿足條件,但是並不是最優的.

但是筆者覺得上述舉例並不是問題:如果加上成本:那麼只要在 maxKey 覆蓋數量相等的情況下,判定採用成本更低的 key,則可解決這個問題。

總的來說,有需求,必定有解決需求的辦法。