貪心算法之區間調度問題


什么是貪心算法呢?貪心算法可以認為是動態規划算法的一個特例,相比動態規划,使用貪心算法需要滿足更多的條件(貪心選擇性質),但是效率比動態規划要高。

比如說一個算法問題使用暴力解法需要指數級時間,如果能使用動態規划消除重疊子問題,就可以降到多項式級別的時間,如果滿足貪心選擇性質,那么可以進一步降低時間復雜度,達到線性級別的。

什么是貪心選擇性質呢,簡單說就是:每一步都做出一個局部最優的選擇,最終的結果就是全局最優。注意哦,這是一種特殊性質,其實只有一部分問題擁有這個性質。

比如你面前放着 100 張人民幣,你只能拿十張,怎么才能拿最多的面額?顯然每次選擇剩下鈔票中面值最大的一張,最后你的選擇一定是最優的。

然而,大部分問題明顯不具有貪心選擇性質。比如打斗地主,對手出對兒三,按照貪心策略,你應該出盡可能小的牌剛好壓制住對方,但現實情況我們甚至可能會出王炸。這種情況就不能用貪心算法,而得使用動態規划解決,參見前文「動態規划解決博弈問題」。

一、問題概述

言歸正傳,本文解決一個很經典的貪心算法問題 Interval Scheduling(區間調度問題)。給你很多形如 [start, end] 的閉區間,請你設計一個算法,算出這些區間中最多有幾個互不相交的區間

int intervalSchedule(int[][] intvs) {}

舉個例子,intvs = [[1,3], [2,4], [3,6]],這些區間最多有 2 個區間互不相交,即 [[1,3], [3,6]],你的算法應該返回 2。注意邊界相同並不算相交。

這個問題在生活中的應用廣泛,比如你今天有好幾個活動,每個活動都可以用區間 [start, end] 表示開始和結束的時間,請問你今天最多能參加幾個活動呢?顯然你一個人不能同時參加兩個活動,所以說這個問題就是求這些時間區間的最大不相交子集。

二、貪心解法

這個問題有許多看起來不錯的貪心思路,卻都不能得到正確答案。比如說:

也許我們可以每次選擇可選區間中開始最早的那個?但是可能存在某些區間開始很早,但是很長,使得我們錯誤地錯過了一些短的區間。或者我們每次選擇可選區間中最短的那個?或者選擇出現沖突最少的那個區間?這些方案都能很容易舉出反例,不是正確的方案。

正確的思路其實很簡單,可以分為以下三步:

  1. 從區間集合 intvs 中選擇一個區間 x,這個 x 是在當前所有區間中結束最早的(end 最小)。
  2. 把所有與 x 區間相交的區間從區間集合 intvs 中刪除。
  3. 重復步驟 1 和 2,直到 intvs 為空為止。之前選出的那些 x 就是最大不相交子集。

把這個思路實現成算法的話,可以按每個區間的 end 數值升序排序,因為這樣處理之后實現步驟 1 和步驟 2 都方便很多:

1

現在來實現算法,對於步驟 1,由於我們預先按照 end 排了序,所以選擇 x 是很容易的。關鍵在於,如何去除與 x 相交的區間,選擇下一輪循環的 x 呢?

由於我們事先排了序,不難發現所有與 x 相交的區間必然會與 x 的 end 相交;如果一個區間不想與 x 的 end 相交,它的 start 必須要大於(或等於)x 的 end

2

看下代碼:

public int intervalSchedule(int[][] intvs) {
    if (intvs.length == 0) return 0;
    // 按 end 升序排序
    Arrays.sort(intvs, new Comparator<int[]>() {
        public int compare(int[] a, int[] b) {
            return a[1] - b[1];
        }
    });
    // 至少有一個區間不相交
    int count = 1;
    // 排序后,第一個區間就是 x
    int x_end = intvs[0][1];
    for (int[] interval : intvs) {
        int start = interval[0];
        if (start >= x_end) {
            // 找到下一個選擇的區間了
            count++;
            x_end = interval[1];
        }
    }
    return count;
}

三、應用舉例

下面舉例幾道 LeetCode 題目應用一下區間調度算法。

第 435 題,無重疊區間:

title1

我們已經會求最多有幾個區間不會重疊了,那么剩下的不就是至少需要去除的區間嗎?

int eraseOverlapIntervals(int[][] intervals) {
    int n = intervals.length;
    return n - intervalSchedule(intervals);
}

第 452 題,用最少的箭頭射爆氣球:

title2

其實稍微思考一下,這個問題和區間調度算法一模一樣!如果最多有 n 個不重疊的區間,那么就至少需要 n 個箭頭穿透所有區間:

3

只是有一點不一樣,在 intervalSchedule 算法中,如果兩個區間的邊界觸碰,不算重疊;而按照這道題目的描述,箭頭如果碰到氣球的邊界氣球也會爆炸,所以說相當於區間的邊界觸碰也算重疊:

4

所以只要將之前的算法稍作修改,就是這道題目的答案:

int findMinArrowShots(int[][] intvs) {
    // ...

    for (int[] interval : intvs) {
        int start = interval[0];
        // 把 >= 改成 > 就行了
        if (start > x_end) {
            count++;
            x_end = interval[1];
        }
    }
    return count;
}

這么做的原因也不難理解,因為現在邊界接觸也算重疊,所以 start == x_end 時不能更新 x。

如果本文對你有幫助,歡迎關注我的公眾號 labuladong,致力於把算法問題講清楚~

我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!

目錄

歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!

labuladong


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM