1. 寫在前面
在之前的5篇博客中,我們學習了動態規划算法。我們可以看到,在求解最優化問題的算法中,通常需要經過一系列的步驟,在每個步驟中都面臨多種選擇。對於許多最優化問題,使用動態規划算法來求解最優解有些殺雞用牛了,可以使用更加簡單的算法。貪心算法(greedy algorithm)就是其中之一:它在每一步做出的選擇都是當時看起來的最優選擇。也即是說,它總是做出局部最優選擇,以此希望這樣的選擇能夠產生全局的最優解。
2. 選擇活動問題
2.1. 提出問題
我們先來看看一個適應貪心算法求解的問題:選擇活動問題。
假定有一個\(n\)個活動的集合\(S = \{ a_1, a_2,...,a_n \}\),每個活動\(a_i\)的舉辦時間為\([s_i, f_i),0 \leqslant s_i < f_i\),並且假定集合\(S\)中的活動都已按結束時間遞增的順序排列好。由於某些原因,這些活動在同一時刻只能有一個被舉辦,即對於任意兩個活動\(a_i\)與\(a_j(i \neq j)\),區間\([s_i, f_i)\)與區間\([s_i, f_i)\)不能重疊,此時我們稱活動\(a_i\)與\(a_j\)兼容。在選擇活動問題中,我們希望選擇出一個最大兼容活動集。
例如,給出如下活動集合\(S\):
\(i\) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
\(s_i\) | 1 | 3 | 0 | 5 | 3 | 5 | 6 | 8 | 8 | 2 | 12 |
\(f_i\) | 4 | 5 | 6 | 7 | 9 | 9 | 10 | 11 | 12 | 14 | 16 |
子集${a_3,a_9,a_{11} } $是相互兼容的,但它不是一個最大集,因為子集 \(a_i, a_4, a_8, a_{11}\)更大。實際上,\({a_1, a_4, a_8, a_{11}}\)是一個最大兼容活動集,另一個最大集是:\(a_2, a_4, a_9, a_{11}\)。
2.2. 分析問題
2.2.1. 動態規划算法
我們先嘗試使用動態規划算法來解決該問題。首先驗證該問題是否具有最優子結構性質。
令\(S_{ij}\)表示在\(a_i\)結束之后開始,且在\(a_j\)開始之前結束的活動集合。我們的任務是求\(S_{ij}\)的一個最大兼容的活動子集。假設\(A_{ij}\)就是這樣一個子集,其中包含\(a_k\),於是原問題就被分解為了兩個子問題:求解 \(S_{ik}\)與\(S_{kj}\)中的兼容活動。顯然,這兩個子問題的兼容活動子集的並是原問題的一個兼容活動子集。
現在問題的關鍵是兩個子問題的最優兼容活動子集的並是否是原問題的最優解。或者反過來,原問題的一個最優解\(A_{ij}\)是否包含兩個子問題的最優解。答案是肯定的,同樣可以用剪切-粘貼證明,這里不再贅述。
證明了該問題具有最優子結構性質,於是我們可以用一個遞歸式來描述原問題的最優解。設\(c[i, j]\)表示集合\(S_{ij}\)的最優解的大小,則有:
於是接下來就可以設計一個自頂向上的動態規划算法。
2.2.2. 貪心選擇
我們看到,在上述的動態規划算法中,由於我們不確定到底選擇哪一個\(a_k\),會產生最優解,因此我們必須考察每一種\(a_k\)的選取情況。自然地,我們便會想,對於每一次\(a_k\)的選擇,我們可不可以直接找出“最優的\(a_k\)”的呢?如果能這樣,那么算法的效率會大大地提升。
事實上,對於該問題,我們在面臨選擇時,還真的可以只考慮一種選擇(貪心選擇)。直觀上我們可以想象,要想使活動更多的舉辦,我們在每次選擇活動時,應盡量選擇最早結束的活動,這樣可以把更多的時間留給其他的活動。
更確切地說,對於活動集合\(S_{ij}\),由於活動都按照結束時間遞增的順序排列好,因此貪心選擇是\(a_i\)。如果貪心選擇是正確的,按照該選擇方法,我們便能將所有選擇的結果組合成原問題的最優解。
現在問題的關鍵是,貪心選擇選擇出來的元素總是最優解的一部分嗎?答案同樣還是肯定的,下面的定理說明了這一點:
設\(S_k = \{a_i \in S:s_i \geq f_k\}\)表示在活動\(a_k\)結束后開始的活動集合。考慮任意非空子問題\(S_k\),令\(a_m\)是\(S_k\)中最早結束的活動,則\(a_m\)在\(S_k\)的某個最大兼容活動子集中。
我們可以如下證明該定理:設\(A_i\)是\(S_i\)的一個最大兼容活動子集,且\(a_j\)是其中最早結束的活動。若\(a_m = a_j\),則自然滿足上述結論;若\(a_m \neq a_j\),用\(a_m\)替代\(A_i\)中的\(a_j\),得到子集\(A'\),即 \(A' = (A - a_j) \cup a_m\),則\(A'\)也是\(S_i\)的一個最大兼容活動子集。因為\(a_m\)是\(S_k\)中最早結束的活動,於是有\(f_m \leq f_j\),因此\(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]