1. 程式人生 > >動態規劃與貪心演算法的區別

動態規劃與貪心演算法的區別

本來這次是該總結動態規劃的,但在學習過程中發現動態規劃和上一節的貪心演算法有很大聯絡,而在演算法設計過程中主要是對兩種演算法的選擇,所以決定這次以對比的方式做總結,既可以更深入地瞭解動態規劃,又可以對貪心演算法有個新的認識。

背景介紹:這兩種演算法都是選擇性演算法,就是從一個候選集合中選擇適當的元素加入解集合。

貪心演算法的選擇策略即貪心選擇策略,通過對候選解按照一定的規則進行排序,然後就可以按照這個排好的順序進行選擇了,選擇過程中僅需確定當前元素是否要選取,與後面的元素是什麼沒有關係。

動態規劃的選擇策略是試探性的,每一步要試探所有的可行解並將結果儲存起來,最後通過回溯的方法確定最優解,其試探策略稱為決策過程。

主要不同:兩種演算法的應用背景很相近,針對具體問題,有兩個性質是與演算法選擇直接相關的,上一次我們也提到了,那就是最優子結構性質和貪心選擇性質。

最優子結構性質是選擇類最優解都具有的性質,即全優一定包含局優,上一次選擇最短路線的例子已經對此作了說。

當時我們也提到了貪心選擇性質,滿足貪心選擇性質的問題可用貪心演算法解決,不滿足貪心選擇性質的問題只能用動態規劃解決。可見能用貪心演算法解決的問題理論上都可以利用動態規劃解決,而一旦證明貪心選擇性質,用貪心演算法解決問題比動態規劃具有更低的時間複雜度和空間複雜度。

零錢問題:上一次舉得例子是說明了兩個性質的不同點,順便說明了什麼問題可以用動態規劃但不能用貪心選擇,今天我們再來舉一個更有針對性的例子,即找零錢問題。

大家在生活中都有找零錢的經歷,假如每種面額的錢幣的數量都足夠多,那麼如果讓找出14塊錢,那絕大多數人會用一種10元的兩張2元(假設還有2元的紙幣)的來湊足14元。或許你沒有察覺到,其實在這個過程中我們已經用到了演算法,即貪心演算法。正如前文提到的,貪心演算法就是根據人思維模式設計的演算法,所以平常生活中有很多這樣的例子。

先來說說找錢的具體過程。仔細琢磨一下我們的選擇過程:因為14>10,所以選擇一張10元的,還剩4元,正好用兩個2元的湊足。這個例子比較小,可能還不夠說明問題,我們這樣來想,假入要找177元,該用幾張1元的?快點兒,仔細想一下,到底用幾張?怎麼樣,發現問題了吧。沒人可以直接回答找177要用幾張1元的,我們都是想先用一張100的,剩77再用一張50的……最後確定用幾張1元的。這個過程大家已經經歷了無數次了,所以已經形成條件發射了。但仔細想想,這就說明了貪心選擇前的排序過程,也就是說我們的貪心選擇必須從大面值的開始,而不能從小面額的開始。

再來看看我們這樣做的依據。為什麼我們要這麼選擇錢的種類呢?為什麼我們可以這樣選擇呢?首先要明確我們的目標,為什麼我們不用7張2元的來湊14元,顯然我們的目的是使錢的總張數最少。那我們這樣選擇一定可以保證無論找多少零錢,總的張數都是最少的嗎?答案彷彿是不言自明的,但這裡面仍有一些細節需要了解。假如我們的紙幣系統的面額不是1、2、5、10這樣的數,假如是我們的是1、2、7、10,那我們要找14元,還可以用貪心選擇策略來選擇嗎?顯然就不能了,兩個7元的顯然是最優解,即用的張數最少的解。那問題來了,為什麼1、2、5、10就可以1、2、7、10就不行呢?不行了該怎麼辦呢?

找錢問題的貪心選擇性質。前面已經提到,找錢問題實質是貪心選擇性質的應用,而現在1、2、7、10用不了了,那就說明貪心選擇性質失效了。失效了就不能用貪心選擇了,只能用動態規劃了!先來看一下為什麼會失效,也就是說,什麼樣的數字組合才滿足貪心選擇性質呢?非常抱歉,這個問題本人還沒有找到答案。但我們可以肯定的是某些組合時不具有貪心選擇性質的,對於這樣的組合我們舉個反例就可以了,就像上面的兩個7可以湊足14一樣。

這裡再多說幾句,權當題外話。一是有人認為貪心策略的失效是因為7超過了10的一半兒,所以兩個7大於10而導致出現了問題,所以只要兩個相鄰數都相差一半兒或一半兒多久滿足貪心策略,這個看法並不準確,例如1,9,20裡湊27,顯然不能用貪心策略,這個問題(即什麼樣的數字組和可以用貪心策略)比較複雜,我們不再深追究。二是為什麼我們和其他很多國家都用1、2、5、10的錢幣系統,這是因為首先這個陣列是滿足貪心選擇性質的,因為數比較少,所以很容易證明。其次就是我們用的是10進位制數字。而為什麼我們要用10進位制是因為我們有10跟手指,為什麼我們有10根手指就是上帝的事情了。其實,如果拋開10進位制,我們的紙幣系統用1、2、4、8……是最好的選擇,這樣既滿足貪心選擇性質(容易證明)又和計算機的二進位制正好對應上,要知道,用計算機表示10進位制數是很複雜的,而用8進位制或者16進位制則會簡單很多。但沒有辦法,我們是先有的10進位制再有的計算機,所以只能這麼湊合弄了。試想假如我們有16根手指,那現在計算機中那些10進位制和16進位制的轉化都不用做了,這該多麼方便啊。不過到時候人家問你多大了,你就得回答2E歲了。

動態規劃解找零錢問題。好了,我們回到正題。我們已經分析了錢的面額合適的時候可以用貪心選擇,而這個策略並不具有通用性,錢幣面額稍作改變就不滿足貪心策略了,我們的方法就不能用了。那下面我們來研究一下通用的演算法,即用動態規劃來找零錢。

我們設錢幣的種類數為N,並且假設每種錢幣的數量都足夠多。

錢幣的面額為ai(0≤i≤N-1),且ai為正整數,即假設不需要找1元以下的錢。同時我們假設a0=1,否則會有找不開的情況。

要找的零錢數為j(0≤j≤J),同上,j也是正整數。

我們確定決策過程T(i,j)為用前i種錢幣找出j元所用的最少張數。則有,T(i,0)=0(其中,0≤i≤N-1),即如果要找的零錢為0,那肯定一張也不用。T(0,j)=j(其中,0≤j≤J),即只用1元的紙幣,那找多少錢就用多少張。

T(i,j)=min{T(i-1,j),T(i,j-ai)+1}(其中,j≥ai,即當我們的錢幣種類增加到第i種時,這個第i種是可以用上的,如果用了就是T(i,j-ai)+1,如果沒有用就是T(i-1,j)。還有一種情況是這個錢不能用,也就是j<ai,這個錢的面額超過了我們要找的錢,自然就用不上了,此時T(i,j)=T(i-1,j)。

最後就是回溯確定錢幣種類和張數的問題了,這個就不細講了,基本是填表過程的逆過程。我們可以看一下程式碼

void dp_giveChange(const int n)
{
	const unsigned int N = 7;//錢幣的種類數
	const unsigned int a[N] = {1,2,5,10,20,50,100};//錢幣的面額
	const unsigned int J = n;//要找的零錢數

	//分配記憶體
	int **T = new int*[N];
	for(size_t i = 0;i < N;i++){
		T[i] = new int[J+1];
	}
	//填表
	for(size_t i = 0;i < N;i++)	T[i][0] = 0;
	for(size_t j = 0;j <= J;j++) T[0][j] = j;
	for(size_t i = 1;i < N;i++){
		for(size_t j = 1;j <= J;j++){
			if(j>=a[i])//能用
				T[i][j] = min(T[i-1][j],T[i][j-a[i]]+1);
			else//不能用
				T[i][j] = T[i-1][j];
		}
	}
	//輸出dp表
	ofstream os;
	os.open("./outData/動態規劃.txt");
	for(size_t i = 0;i < N;i++){
		for(size_t j = 0;j <= J;j++){
			cout<<T[i][j]<<" ";
			os<<T[i][j]<<" ";
		}
		cout<<endl;
		os<<endl;
	}
	//回溯確定找錢的種類和張數
	int m=N-1,n=J,r[N]={0};
	while(n>0 && m!=0){
		if(T[m][n]==T[m-1][n])
			r[--m]=0;
		else {
			r[m]++;
			n-=a[m];
		}
	}
	if(m==0) r[m]=a[m];
	//輸出結果
	for(size_t i=0;i<N;i++){
		dp_sum += r[i];
		cout<<r[i]<<" ";
		os<<r[i]<<" ";
	}
	os<<endl;
	cout<<endl;
	os.close();
	//釋放記憶體
	for(size_t i = 0;i < N;i++){
		delete[] T[i];
	}
	delete[] T;
}
為了方便對比,我們同樣給出貪婪演算法的程式碼
void greedy_giveChange(const int n)
{
	const unsigned int N = 7;//錢幣的種類數
	const unsigned int a[N] = {1,2,5,10,20,50,100};//錢幣的面額
	const unsigned int J = n;//要找的零錢數

	int i=N-1,j=J,r[N]={0};
	while(j>0){
		r[i]=j/a[i];j-=r[i]*a[i];i--;
	}
	for(size_t i=0;i<N;i++){
		cout<<r[i]<<" ";
		greedy_sum += r[i];
	}
	cout<<endl;
}
從程式碼量可以明顯看出兩種演算法的顯著不同,動態規劃是要先填一個二維表,然後回溯找到結果。而貪婪演算法是一次歷遍搞定問題(前提資料已經排好序了)。時間複雜度更顯而易見了,一個O(mn),一個O(n)。而且貪心演算法的空間複雜度也佔絕對優勢。

但正如我們前面提到的,貪心演算法是受輸入限制的,當我們把錢幣種類中的5改為7,再輸入14時貪婪演算法就不靈了。而動態規劃卻仍可以找到問題的最優解。而兩個問題的關鍵就是一個滿足貪心選擇策略一個不滿足。由此我們可以看到,如果能證明一個問題滿足貪心選擇策略,那貪心演算法無疑比動態規劃更有優勢,但有一部分問題是不滿足這個策略的,這時候就只能用動態規劃了。

揹包問題:上一次我們用0-1揹包問題講解了貪婪演算法的應用,其中也提到了揹包問題和0-1揹包的區別。而這個區別也是貪心選擇策略能否適用的區別,也是該用貪心演算法還是改用動態規劃的區別。這裡我們不再仔細分析兩個問題,只定性的看一下這兩個問題的關鍵區別。

為什麼物體可以分割時能用貪婪演算法,而物體不能分割時就不行呢?這裡數學證明就不細講了,本人能力有限,而且各位估計也沒心情看。這裡只想讓大家有一個什麼情況需要“回退”的概念,這個需要“回退”的概念就是貪心選擇性質失敗的意思。

在揹包問題中,如果物體可以分割,那我們裝入單位質量最貴的東西“顯然”是正確的,而如果物品不可分割,那就可能出現揹包裝了一個單價很貴的東西但沒有裝滿,而後面一個雖然單價比較低但體積也比較大,這樣就裝不進去了,如果把前面那個東西倒出來把這個大的裝進去可能就會使得總價值更大。總之這個問題在於揹包可能裝不滿,而如果有一個物體單價低但佔的空間更充分的話就有可能會得到更好的解。所以這個問題就需要往回試探的過程,這個就是要使用動態規劃的標識。

在這次的找零錢問題中也是這樣,就因為兩個7等於14,而選擇10的時候不知道後面有沒有4可以選擇,如果後面發現沒有4再回頭把10拿出來就會使整個搜尋過程陷入混亂,這也是貪心策略失效的標識。

最後總結:貪心選擇策略是本文著重說的一點,也是兩種演算法的根本區別所在。要想深切理解這個性質,還是要多做一些演算法例項,形成一種定性的分析,當然,如果數學功底好也可以直接給出證明。我們想做的是在不能給出完整的數學證明前提下,對演算法有個比較深入的把握,最起碼能判斷出一些貪心策略是否可以實現。