1. 程式人生 > >從硬幣找零問題:看分治/動態規劃/貪心演算法的區別與聯絡

從硬幣找零問題:看分治/動態規劃/貪心演算法的區別與聯絡

硬幣找零問題存在一堆面值為 v1、v2、v3 … 個單位的硬幣,問最少需要多少個硬幣才能找出總值為x單位的零錢?這裡我們假設v[]={0, 1, 2, 5, 10, 20, 50}。0是用來充位數的,這樣v1、v2與下標1、2對上。這裡v1必須為1,若不為1的話,給定一個x,可能無法得到一個解,即找不開。比如v[]={2, 5, 10, 20, 50}, x=18,沒有解。這裡我們為了方便,也設定陣列v是排序好的,如果沒有排序,用一個排序演算法即可搞定。

1、分治策略

在設計分治策略演算法之前,我們必須得到解決該問題的一個遞推式,即如何將一個大問題劃分為若干個小問題。

(1)找遞推公式:

f(x, i)表示用v[1],v[2],...,v[i]來找零x所需的最少硬幣數,可得遞推式如下:

f(x, i)=min( f(x, i-1),  x/v[i] + f(x%v[i], vmax(x%v[i], v)))

其中vmax(x, v)返回i,且i滿足v[i]<=x<v[i+1],即找到v陣列中可找零的最大的那個面值

這裡在求f(x, i)這個大問題的時候,我們對它進行分解:(Divide)

case1,我們跳過v[i],而直接用v[1],v[2],...,v[i-1]來找零x,即f(x, i-1);(Conquer)

case2,我們要用v[i]來找零,可得找零數為x/v[i] + f(x%v[i], vmax(x%v[i], v))(Conquer)

那麼case1,case2最小的那個即是f(x, i)。

	int min(int a, int b)
	{
	    if(a < b)
	        return a;
	    return b;
	}
	/*
	x:要找零的數
	v:找零面值陣列
	length:陣列的長度
	return i, v[i]<=x<v[i+1]
	*/
	int vmax(int x, int v[], int length)
	{
	    int i = 0;
	    while((x >= v[i]) && (i < length))
	    {
	        i++;
	    }
	    return i-1;
	}
	/*
	v:找零面值陣列
	x:要找零的數
	i:f(x,i)中的i,表示從v[1],v[2],...,v[i]中找零
	length:找零陣列的長度
	*/
	int change_dc(int v[], int x, int i, int length)
	{
	    if(x == 1)
	        return 1;
	    if(x == 0)
	        return 0;
	    if(i == 1) //當i為1時,必須有這個出口;沒有的話在下一個f(x,i-1)中,i會為0
	        return x;
	    return min(change_dc(v, x, i-1, length), x/v[i]+change_dc(v, x%v[i], vmax(x,v, length), length));
	}
}

(2)第二種方法:

遞推式如下:c(x)為換取x面值零錢所需的最小數量

c(x) = min(1+c(x-v[i])),i的取值範圍為i:x>=v[i],這個限制條件必須有,否則會出現負數。

注意:這裡的min函式,是遍歷完所有的面值後取得最小值。

上面給出的文件應該說的很詳細了,不多說,直接上程式碼。
//演算法真的很巧妙啊
	public static int change_dc2(int v[], int x, int length)
	{
	    int i;
	    int min;
	    int temp;
	                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
	    if(x == 0)//遞迴出口
	        return 0;
	    
	    min = x;//預設全部拿一元硬幣,所以預設最小的數量是x本身
	    for(i=2; i<length; i++)//從面值比一元大的開始,選擇
	    {
	        if(x >= v[i])
	        {
	            temp = change_dc2(v, x-v[i], length) + 1;//分治精髓啊!!!這次就是拿,下次迴圈就是不拿,然後依次比較其最小值
	            if(temp < min)
	                min = temp;
	        }
	    }
	    return min;
	}
	
2.動態規劃求解

可能你已經發現了,f(1, 1)、f(1, 2),c(1)、 c(2)等很多子問題會被計算多次,那麼我們就可以用動態規劃來解決此類問題了。這裡有兩種不同的方式,一種為自頂向下的備忘錄方式(memoization),一種為自底向上的方式。

(1)自頂向下的備忘錄方式

我們可以很容易的將分治策略演算法改為自頂向下的備忘錄方式的演算法,因為分治策略演算法一般都用遞迴的思想,而遞迴就是自頂向下的,這裡我們只要加入備忘錄的機制就ok了。就是在每求解一個子問題時,我們都判斷該子問題時候已求解,若已求解,直接給出解;若沒有,我們求解該子問題,並儲存該子問題的解,那麼在下次求解這個問題的時候可以直接用。

分治策略(1)的自頂向下的備忘錄方式:

/*
v:找零面值陣列
x:需要找零的數
i:f(x, i)中的i,表示用v[1],v[2],...,v[i]中找零x
length:找零面值陣列的長度
c:儲存f(x, i)的值,c[x][i]對應f(x, i),呼叫該函式之前對其所有元素賦值-1,表示沒有被求解
*/
int change_dp_memoization(int v[], int x, int i, int length, int c[][])
{
    if(c[x][i] >= 0)
        return c[x][i];
                                                                                                                                                                                                            
    if(x == 1)
    {
        c[x][i] = 1;
        return c[x][i];
    }
    if(x == 0)
    {
        c[x][i] = 0;
        return c[x][i];
    }
    if(i == 1)
    {
        c[x][i] = x;
        return c[x][i];
    }
    c[x][i] = (change_dp_memoization(v, x, i-1, length, c), x/v[i]+change_dp_memoization(v, x%v[i], vmax(x,v, length), length, c));
    return c[x][i];
}

分治策略(2)的自頂向下的備忘錄方式:
*/
int change_dp_memoization2(int v[], int x, int length, int c[])
{
    int i;
    int temp;
                                                                                                                                                                                                    
    if(c[x] >= 0)            //if c[x] has caculated
        return c[x];
                                                                                                                                                                                                    
    if(x == 0)
        return 0;
                                                                                                                                                                                                
    c[x] = x;
    for(i=1; i<length; i++)
    {
        if(x >= v[i])
        {
            temp = change_dp_memoization2(v, x-v[i], length, c) + 1;
            if(temp < c[x])
                c[x] = temp;
        }
    }
    return c[x];
}
(2)自底向上的方式

自底向上的方式與自頂向下剛好相反,我們先求解規模小的問題,然後逐層遞進,在求規模稍大的問題時,需要用的規模較小的問題已被解決。這裡我們要區分自頂向下和自底向上兩種不同的方式。自頂向下的方式在求解大問題時,其需要的小問題可能還沒求解,可能已被其他稍大的問題所解決,所以我們需要一個數組來儲存那些已求解的。而自底向上的方式在求解一個稍大問題的之前,保證比其規模小的所有問題都已解決,這樣再求解大問題時,其所包含的小問題都已求解,直接拿來用。還有一個就是,自頂向下一般用遞迴實現,因為在求解大問題的時候,有的小問題還沒有求解,只能遞迴計算,上面我們已經看到了,可以直接拿分治策略的演算法加上備忘錄機制就行了,而自底向上一般用迭代實現,逐層向上,直至你所求的問題。

分治策略(1)的自底向上的方式:

int change_dp_bottomup(int v[], int x, int i, int length, int c[][MAX])
{
    int m, n;
    int imax;
                                                                                                    
//當x=0時,不管m為多少,都c[0][m]=0,後面稍大的問題會用到此解,必須事先賦值
    for(m=0; m<length; m++)
        c[0][m] = 0;
                                                                                                      
    for(m=1; m<=x; m++)
    {
        for(n=1; n<=i; n++)
        {
            imax = vmax(m, v, length);
            //這裡必須做此判斷,因為要保證是從v[1],...,v[i]中找零x,而不是從v[1],...,v[imax]中找零
            if(imax > n)
                c[m][n] = min(c[m][n-1], m/v[n] + c[m%v[n]][n]);
            else
                c[m][n] = min(c[m][n-1], m/v[imax] + c[m%v[imax]][n]);
        }
    }
    return c[x][i];
}
分治策略(2)的自底向上的方式:
int change_dp_bottomup2(int v[], int x, int length, int c[])
{
    int i, j;
    int temp;
                                                                                
    for(i=1; i<=x; i++)
    {
        c[i] = i;
        for(j=2; j<length; j++)
        {
            if(i >= v[j])
            {
                temp = c[i-v[j]] + 1;
                if(c[i] > temp)
                    c[i] = temp;
            }
        }
    }
    return c[x];
}

3、貪心選擇

這裡我們給出的找零面值陣列v[]是滿足貪心選擇性質的,這裡就不證明了。其實只要2v[i]<=v[i+1],就滿足貪心選擇性質。如果不滿足2v[i]<=v[i+1],則找零問題不能用貪心演算法解決。比如v[]={0, 1, 2, 5, 8, 10, 20, 50}, x=16, 最優解應該為2,即用兩個8面值的硬幣找零,而貪心演算法的解則為10, 5, 1。下面給出貪心演算法的程式碼:

int change_greedy(int v[], int x, int length)
{
    int count = 0;
    int i;
            
    while(x > 0)
    {
        i = vmax(x, v, length);
        count += x/v[i];
        x = x%v[i];
    }
            
    return count;
}

本文出自 “我的黑客” 部落格,請務必保留此出處http://1661518.blog.51cto.com/1651518/1396590