這篇文章主要用來記錄我對《算法導論》 貪心算法一章中的“活動選擇問題”的動態規划求解和貪心算法求解 的思路和理解。
主要涉及到以下幾個方面的內容:
①什么是活動選擇問題---粗略提下,詳細請參考《算法導論》
②活動選擇問題的DP(Dynamic programming)求解--DP求解問題的思路
③活動選擇問題的貪心算法求解
④為什么這個問題可以用貪心算法求解?
⑤動態規划與貪心算法的一些區別與聯系
⑥活動選擇問題的DP求解的JAVA語言實現以及時間復雜度分析
⑦活動選擇問題的Greedy算法JAVA實現和時間復雜度分析
⑧一些有用的參考資料
①活動選擇問題
給定N個活動,以及它們的開始時間和結束時間,求N個活動中,最大兼容的活動個數。比如:
活動 i: 1 2 3 4.....
開始時間 si: 1 3 0 5....
結束時間 fi: 4 5 6 7.....
活動1的開始時間s(1)=1,結束時間f(1)=4,它與活動2是不兼容的。因為,活動1還沒有結束,活動2就開始了(s(2) < f(1))。
活動2 與 活動4 是兼容的。因為,活動2的進行區間是[3,5) 而活動4的進行區間是[5,7)
目標是:在N個活動中,找出最大兼容的活動個數。
②活動選擇問題的DP(Dynamic programming)求解
1)建模
活動 i 用 a(i)來表示,開始時間用 s(i)表示,結束時間用 f(i)表示,所有活動的集合為S
定義一個合適的子問題空間,設 S(i,j) 是與 a(i) 和 a(j)兼容的活動集合。S(i,j)={a(k), a(k) belongs to S: f(i)<=s(k)<f(k)<=s(j)}
2)問題一般化(不是很理解)
這里第一個活動和最后一個活動有點特殊。為了完整表示問題,構造兩個虛擬的活動: a(0) 和 a(n+1)
其中,s(0)=f(0)=0,s(n+1)=f(n+1)=Integer.MAX_VALUE
於是,S=S(0,n+1),從N個活動中找出最大兼容的活動,就轉化成了求解 S(0,n+1)集合中包含的最多元素個數
3)子問題分析
假設所有的活動都按結束時間遞增排序。子問題空間就是 從S(i,j)中選擇最大兼容活動子集,即max{S(i,j)}
max{S(i,j)}表示與 a(i) a(j) 兼容的最大活動集合。稱為為S(i,j)的解
假設 a(k)是 S(i,j)的解包含的一個活動。S(i,j)就分解為 max{S(i,k)} + max{S(k,j)}+1
從這里可以看到,將原問題分解成了兩個子問題。原問題就是:求解與活動 a(i) a(j) 兼容的最大活動個數,即max{S(i,j)}
而子問題則是:max{S(i,k)} 和 max{S(k,j)}
設A(i,j)就是S(i,j)的解。那么,A(i,j)=A(i,k) U A(k,j) U {a(k)}
A(0,n+1)就是我們所求的整個問題的最優解。
4)子問題的 選擇個數 分析
設c[i,j]為S(i,j)中最大兼容子集中的活動數,S(i,j)為空集時,c[i,j]=0,這是顯而易見的。因為S(i,j)中都沒有活動嘛,更別談什么兼容活動了呀。
若 i>=j,c[i,j]=0。這個也很好理解,因為它不符合常識。因為,我們假設活動是以結束時間來遞增排序的,在S(i,j)中,是f(i)<s(j)的。那 i 就不會大於 j
畢竟一個活動它不可能 即在 某個活動之前結束,又在該活動之后開始。哈哈。。。。。
前面提到 :假設 a(k)是 S(i,j)的解包含的一個活動。S(i,j)就分解為 max{S(i,k)} + max{S(k,j)}+1
這意味着,求S(i,j)的最優解,就需要知道 S(i,k) 和 S(k,j) 的最優解。那關鍵是怎么知道 S(i,k) 和 S(k,j) 的最優解呢?
答案是:一個 一個 地嘗試。k 的取值范圍是 (i,j),遍歷(i,j)內所有的值,計算 S(i,k) 和 S(k,j)的解。就可以找到S(i,j)的最優解了。
因此,當S(i,j)不為空時,c[i,j] = max{c[i,k] + c[k,j] + 1} 其中, k belongs to (i,j) a(k) belongs to S(i,j)
下面,就是DP中的狀態轉移方程(遞歸表達式),根據它,就可以寫代碼實現了。
從上面分析可以看出:原問題分解成了兩個子問題,要解決原問題,一共有 j-i+1中選擇,然后一 一遍歷求出所有的選擇。這就是動態規划的特點,先分析最優子問題,然后再做選擇。
③活動選擇問題的貪心算法求解
所謂貪心算法,就是每次在做選擇時,總是先選擇具有相同特征的那個解,即“貪心”解。在這里,“貪心”的那個解則是: 結束時間最早的那個活動
具體步驟是怎樣的呢?
第一步:先對活動按照結束時間進行排序。因為我們總是優先選擇結束時間最早的活動的嘛。排序之后,方便選擇嘛。。。
第二步:按照貪心原則 選中一個活動,然后排除 所有與該活動 有沖突的活動。
第三步:繼續選擇下一個活動。其實,第二步與第三步合起來就是:每次都選結束時間最早的活動,但是后面選擇的活動不能與前面選擇的活動有沖突。
從這里可以看出,貪心算法是在原問題上先做貪心選擇,然后得到一個子問題,再求解子問題。(求解子問題的過程,就是一個不斷貪心選擇的過程)
④為什么這個問題可以用貪心算法求解?
看了貪心算法之后,就會有疑問?憑什么這樣選就能得到最優解啊?或者說,這樣做到底對不對?
別急嘛,我們可以用數學來證明這樣做是正確的。而且從這個證明過程中,可以窺出動態規划與貪心算法的區別。
對於活動選擇問題而言:當可用貪心算法解時,貪心的效率要比動態規划高。為什么要高呢?后面再詳細講。
這個證明具體可參考《算法導論》上的證明。它的大致證明過程就是:
當選擇了貪心解時(結束時間最小的活動),也是將原問題划分成了兩個子問題,但是其中一個子問題是空的,而我們只需要考慮另一個非空的子問題就可以了。
具體而言就是:假設 a(m) 是 S(i,j)中具有最早結束時間的那個活動,那按照我們的貪心選擇,我們肯定會選擇a(m)的嘛。選了a(m)之后,就將問題分解成了兩個子問題:S(i,m) 和 S(m,j)。前面提到,活動是按結束時間排序了的,而現在a(m)又是最早結束的活動,因為,S(i,m)就是個空集,而我們只需要考慮S(m,j)
但是,這里有個重大的疑問還未解決---憑什么說 a(m) 就是 S(i,j)的最優解中的活動呢?或者說憑什么 活動m 就是最大兼容活動集合中的活動?
這里就用到經常用來證明貪心算法正確性的一個技巧---剪枝。關於這個技巧,可參考一篇博文:漫談算法(一)如何證明貪心算法是最優
對於活動選擇問題,咱就來簡要證明下吧。。。其實還是《算法導論》中講的證明,只不過我又復述一遍罷了。
慢着,我們要證明的是啥?再說一遍:憑什么說 a(m) 就是 S(i,j)的最優解中的活動呢?,我們證明的就是:a(m)是S(i,j)的最優解中的元素,即a(m)是S(i,j)最大兼容活動子集中的活動。
設A(i,j)是S(i,j)的最大兼容活動子集---也就是說,在所有與 活動a(i) 和 活動a(j) 相兼容的活動中,A(i,j)含有的活動個數最多。
將A(i,j)中的活動按結束時間遞增排序。設a(k)是A(i,j)中的第一個活動。若a(k)=a(m),那沒話說了。a(m)就是a(k)嘛,那a(m)肯定在A(i,j)中噻
若a(k) != a(m),這說明A(i,j)中的第一個元素(活動)不是a(m)。那我們可以運用剪枝思想,剪掉A(i,j)中的第一個活動a(k),再把活動a(m)貼到A(i,j)里面去。
這樣,A(i,j)中的活動個數還是沒有變化---少了個a(k),加了個a(m)啊
那么,可能你就會問了,憑什么能把 a(m)貼到 A(i,j)里面去啊?????我們可以這樣想想:a(k)是A(i,j)中的第一個活動,那為什么a(k)可以在A(i,j)中呢?
廢話!上面帶下划線且加粗的的都說了假設 a(k)是A(i,j)中的第一個活動了啊!!
其實,這不是本質 ,本質就是:a(k)是與 a(i) 和 a(j)兼容的活動啊,而且沒有和A(i,j)中的其他活動沖突啊!因為,S(i,j)的解 就是求與 a(i) 和 a(j)兼容的一組活動啊,而A(i,j)就是這樣的一組活動且它是最大的(活動個數最多),能夠放在A(i,j)中的活動,它一定是與a(i) 和 a(j) 兼容的。
那么,再回到a(m),a(m)同樣也具有 ”本質“ 中提到的兩個性質:❶a(m)是與a(i) 和 a(j) 兼容的活動 ❷a(m)沒有與A(i,j)中其他活動沖突。
下面來說明下為什么 a(m)沒有與A(i,j)中其他活動沖突?因為a(k)是沒有與A(i,j)中的其他活動沖突的,而a(m)又是S(i,j)中結束時間最早的活動
故:,完成時間:f(m)<f(k) ,a(m)都比a(k)更早完成,而a(k)都沒有與A(i,j)中的其他活動沖突,那a(m)就更不可能與A(i,j)中的其他活動沖突了。
整個思路就是:在證明中先考察一個全局最優解,然后證明可以對該解加以修改(比如運用“剪枝”技巧),使其采用貪心選擇(將貪心的那個選擇貼上去),這個選擇將原問題變成一個相似的、但更小的問題。
終於完成了證明。好累。
⑤動態規划與貪心算法的一些區別與聯系
這里只針對活動選擇問題作一下比較。其他的我也不懂。
a)動態規划是先分析子問題,再做選擇。而貪心算法則是先做貪心選擇,做完選擇后,生成了子問題,然后再去求解子問題。
b)從 a) 中可以看出,動態規划是自底向上解決問題,而貪心算法則是自頂向下解決問題。
c)動態規划每一步可能會產生多個子問題,而貪心算法每一步只會產生一個子問題。(比如這里的貪心算法產生了“二個”子問題,但是其中一個是空的。)
⑥活動選擇問題的DP求解的JAVA語言實現以及時間復雜度分析
1 /** 2 * //算法導論中活動選擇問題動態規划求解 3 * @param s 活動的開始時間 4 * @param f 活動的結束時間 5 * @param n 活動數目 6 * @return 最大兼容的活動個數 7 */ 8 public static int maxCompatiableActivity(int[] s, int[] f, int n){ 9 int[][] c = new int[n + 2][n + 2]; 10 11 for(int j = 0; j <= n+1; j++) 12 for(int i = n+1; i >= j; i--) 13 c[i][j] = 0;//if i>=j S(i,j)是空集合 14 15 int maxTemp = 0; 16 for(int j = 1; j <= n+1; j++) 17 { 18 for(int i = 0; i < j; i++)//i < j 19 { 20 for(int k = i+1; k < j; k++)// i< k <j 21 { 22 if(s[k] >= f[i] && f[k] <= s[j])//S(i,j)不空 23 { 24 if(c[i][k] + c[k][j] + 1 > maxTemp) 25 maxTemp = c[i][k] + c[k][j] + 1; 26 } 27 }//inner for 28 c[i][j] = maxTemp; 29 maxTemp = 0; 30 }//media for 31 }//outer for 32 return c[0][n+1]; 33 }
DP時間復雜度與問題的個數以及每個問題的選擇數 有關。
比如這里的 S(i,j)一共大約有N^2個, 因為 1=<j<=N, 1=<i<j ,這里求和大約是 (N^2)/2(對於S(i,j) i>j沒有實際意義嘛),每個S(i,j)一共有 j-i+1種 選擇
故時間復雜度為O(N^3)
⑦活動選擇問題的Greedy算法JAVA實現和時間復雜度分析
貪心算法即可以用遞歸實現,也可以用非遞歸實現。
1 //貪心算法的遞歸解 2 public static ArrayList<Integer> greedyActivitySelection(int[] s, int[] f, int i, int n, ArrayList<Integer> activities){ 3 //初始調用時 i = 0, 所以a(1)是必選的(注意:活動編號已經按結束時間排序) 4 int m = i + 1; 5 6 //s[m] < f[i] 意味着活動 a(m) 與 a(i)沖突了 7 while(m <= n && s[m] < f[i]) 8 m++;//選擇下一個活動 9 10 if(m <= n){ 11 activities.add(m); 12 greedyActivitySelection(s, f, m, n, activities); 13 } 14 return activities; 15 } 16 17 //貪心算法的非遞歸解, assume f[] has been sorted and actId 0/n+1 is virtually added 18 public static ArrayList<Integer> greedyActivitySelection2(int[] s, int[] f, int n, ArrayList<Integer> acitivities){ 19 //所有真正的活動(不包括 活動0和 活動n+1)中,結束時間最早的那個活動一定是最大兼容活動集合中的 活動. 20 int m = 1; 21 acitivities.add(m); 22 23 for(int actId = 2; actId <= n; actId++){ 24 if(s[actId] >= f[m])//actId的開始時間在 m 號活動之后.--actId 與 m 沒有沖突 25 { 26 m = actId; 27 acitivities.add(m); 28 } 29 } 30 return acitivities; 31 }
貪心算法的時間復雜度為O(N),why?你可以看代碼啊。只有一個循環啊。每個活動只會遍歷一次啊。
這里從理論上來分析下:因為對於貪心算法而言,每次只有一種選擇即貪心選擇,而DP中每個問題S(i,j)中 j-i+1種選擇。
貪心算法做出一次貪心選擇后,即選中某個活動后,活動個數減少1,即問題規模減少1。
⑧參考資料
https://www.zhihu.com/question/23995189
《背包九講》
http://www.cnblogs.com/hapjin/p/5572483.html
附完整代碼:
import java.util.ArrayList; public class ActivitySelection { /** * //算法導論中活動選擇問題動態規划求解 * @param s 活動的開始時間 * @param f 活動的結束時間 * @param n 活動數目 * @return 最大兼容的活動個數 */ public static int maxCompatiableActivity(int[] s, int[] f, int n){ int[][] c = new int[n + 2][n + 2]; for(int j = 0; j <= n+1; j++) for(int i = n+1; i >= j; i--) c[i][j] = 0;//if i>=j S(i,j)是空集合 int maxTemp = 0; for(int j = 1; j <= n+1; j++) { for(int i = 0; i < j; i++)//i < j { for(int k = i+1; k < j; k++)// i< k <j { if(s[k] >= f[i] && f[k] <= s[j])//S(i,j)不空 { if(c[i][k] + c[k][j] + 1 > maxTemp) maxTemp = c[i][k] + c[k][j] + 1; } }//inner for c[i][j] = maxTemp; maxTemp = 0; }//media for }//outer for return c[0][n+1]; } //貪心算法的遞歸解 public static ArrayList<Integer> greedyActivitySelection(int[] s, int[] f, int i, int n, ArrayList<Integer> activities){ //初始調用時 i = 0, 所以a(1)是必選的(注意:活動編號已經按結束時間排序) int m = i + 1; //s[m] < f[i] 意味着活動 a(m) 與 a(i)沖突了 while(m <= n && s[m] < f[i]) m++;//選擇下一個活動 if(m <= n){ activities.add(m); greedyActivitySelection(s, f, m, n, activities); } return activities; } //貪心算法的非遞歸解, assume f[] has been sorted and actId 0/n+1 is virtually added public static ArrayList<Integer> greedyActivitySelection2(int[] s, int[] f, int n, ArrayList<Integer> acitivities){ //所有真正的活動(不包括 活動0和 活動n+1)中,結束時間最早的那個活動一定是最大兼容活動集合中的 活動. int m = 1; acitivities.add(m); for(int actId = 2; actId <= n; actId++){ if(s[actId] >= f[m])//actId的開始時間在 m 號活動之后.--actId 與 m 沒有沖突 { m = actId; acitivities.add(m); } } return acitivities; } //for test purpose public static void main(String[] args) { //添加了 a(0) 和 a(n+1)活動. 其中s(0)=f(0)=0, s(n+1)=f(n+1)=Integer.MAX_VALUE int[] s = {0,1,3,0,5,3,5,6,8,8,2,12,Integer.MAX_VALUE};//start time int[] f = {0,4,5,6,7,8,9,10,11,12,13,14,Integer.MAX_VALUE};//finish time int n = 11;//活動的個數 int result = maxCompatiableActivity(s, f, n); System.out.println("最大兼容活動個數: " + result); ArrayList<Integer> acts = new ArrayList<Integer>(); greedyActivitySelection(s, f, 0, n, acts); for (Integer activityId : acts) System.out.print(activityId + " "); System.out.println(); ArrayList<Integer> acts2 = new ArrayList<Integer>(); greedyActivitySelection2(s, f, n, acts2); for (Integer activityId : acts2) System.out.print(activityId + " "); } }