1. 程式人生 > >經典遞迴問題與動態規劃

經典遞迴問題與動態規劃

一些經典遞迴問題

斐波那契數列問題

經典生兔子問題,不再贅述。
從該演算法改進過程看遞迴問題的優化求解方式。
version 1:


long Fibonacci(int n){
	if(n == 0)
		return 0;
	else if(n == 1)
		return 1;
	else if(n > 1)
		return Fibonacci(n - 1) + Fibonacci(n - 2);
	else return -1;
		
}

最基本的遞迴問題最基本的寫法,但其效率之低令人髮指,複雜度為O(2^n);重複了大量的遞迴呼叫,很多結果計算了多次。因此為了避免重複計算,可以將計算過的值記錄下來。
Version 2:


long tempResult[max]={0};
long FIbonacci2(int n){
	if(n == 0)
		return 0;
	else if(n == 1)
		return 1;
	else if(n > 1){
		if(tempResult[n] != 0)
			return tempResult[n];
		else{
			tempResult[n] = Fibonacci2(n - 1) + Fibonacci2(n - 2);
			return tempResult[n];
		}
	}
}

遞迴就會大量使用棧來儲存呼叫資訊和變數,要真正提高效率就得放棄遞迴。我們可以將從大到小分解問題的遞迴變為從小問題通往大問題的迴圈。



public class Fibonacci3 {
	public static long Fibonacci3(int n) {
		if (n < 0) return -1;

		long[] temp = new long[n+1];
		temp[0] = 0;
		if(n>0) temp[1] = 1;
		for(int i= 2;i <= n; i++) {
			temp[i] = temp[i-2] + temp[i-1];
		}
		
		return temp[n];
	}
	public static void main(String[] args)
{ // TODO 自動生成的方法存根 System.out.println(Fibonacci3(0)); } }

a,b,c=b,c,a+c
一次迴圈的O(n);再用分治策略優化還可以得到O(logn)複雜度的方法。

漢諾塔問題

架設3根柱子分別為A、B、C,圓盤數目為n。

1:如果A有一個圓盤,則直接移動至c。

2:如果A有2個圓盤,則A->B,A->C,B->C。

好了這個時候已經可以解決問題了,結束條件為 n==1;


void hano1(char A, char B, char C,int n){
	if( n == 1){
		print(A+"->"+C);
	}
	else{
		hanoi(A, C, B, n-1);
		print(A+"->"+C);
		hanoi(B, A, C, n-1);
	}
}

從遞迴到動態規劃

對於可用動態規劃求解的問題,一般有兩個特徵:①最優子結構;②重疊子問題

找零錢問題

有陣列arr,arr中所有的值都為正數且不重複。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,再給定一個整數aim(小於等於1000)代表要找的錢數,求換錢有多少種方法。

給定陣列arr及它的大小(小於等於50),同時給定一個整數aim,請返回有多少種方法可以湊成aim。

測試樣例:
[1,2,4],3
返回:2
所有的動態規劃題本質都是優化後的暴力求解,一般動態規劃題是構造一個dp矩陣,第一行和第一列賦初值,然後根據遞推關係,由一個個子問題求出整個問題,即把剩餘位置的值填滿,說白了就是空間換時間。因為暴力求解會有大量的重複計算,動態規劃可以有效地避免重複計算。

比如找零錢問題,我們可以看成0個arr[0],讓剩餘的組成aim,1個arr[0],讓剩餘的組成aim - 1 * arr[0],2個arr[0],讓剩餘的組成aim - 2 * arr[0],以此類推。為什麼會產生重複計算,是因為比方我用了1個10元,0個5元,然後讓剩下的組成aim - 10和我用0個10元,2個5元,讓剩下的組成aim - 10本質是一樣的。
遞迴呼叫:



public class process1 {
	public static int process1(int[] arr, int index, int aim) {
		int res = 0;
		if(index == arr.length) 
			res = aim == 0 ? 1 : 0;
			else {
				for(int i = 0; i * arr[index] <= aim; i++) {
					res += process1(arr, index + 1, aim - i * arr[index]);
				}
			}
	return res;
	}
	public static void main(String[] args) {
		// TODO 自動生成的方法存根
		int[] arr = {1,2,5};
		
		System.out.println(process1(arr, 0, 1000000));
	}

}

動態規劃法:

首先思考如何設計dp矩陣,這裡我們把行設定成arr下標,代表的就是利用[0…i]區間內組成aim的值的方法數,列代表的是aim值,從0取到aim。
我們先給第一列賦值,因為aim是0,所以只有一種組合方式,就是每個價值的紙幣都取0個,所以第一列全取1。
接下來看第一行,就是求arr[0]能夠湊成的錢的方案,只要是其倍數的都能湊成,所以相應位置應該填寫1。
最後我們確定其他位置,完全不用arr[i]貨幣,只用剩下的,則方法數dp[i - 1][j].
用arr[i],方法數是dp[i - 1][j - arr[i]]。
以此類推,是上面那一行,經過化簡,可以簡化成dp[i][j] = dp[i - 1][j] + dp[i][j - arr[i]]。這就是狀態轉移方程。


public class process2 {
	public static int process2(int[] arr, int aim) {
		//dp[i][j]表示數i用arr[j-1]之前的數劃分的方法數(含arr[j-1])
		//如,若arr為[1,2,5,10],dp[10][2]表示 10用1,2劃分的方法數
		int[][] dp = new int[aim+1][arr.length];
		for(int i = 0; i < dp[0].length; i++) {
			dp[0][i] = 1;			
		}
		for(int j = 1; j * arr[0] <= aim; j++) {
			dp[j * arr[0]][0] = 1;
		}
		for(int j = 1; j < dp[0].length; j++) {
			for(int i = 1; i < dp.length; i++) {
				dp[i][j] = dp[i][j-1];
				dp[i][j] += i - arr[j] >= 0 ? dp[i - arr[j]][j] : 0;
			}
		}
		return dp[aim][arr.length-1];
		
	}
	public static void main(String[] args) {
		// TODO 自動生成的方法存根
		int[] arr = {1,2,5};
		
		System.out.println(process2(arr,1000000));
	}

}

整數劃分問題

描述
將正整數n 表示成一系列正整數之和,n=n1+n2+…+nk, 其中n1>=n2>=…>=nk>=1 ,k>=1 。
正整數n 的這種表示稱為正整數n 的劃分。

輸入
標準的輸入包含若干組測試資料。每組測試資料是一行輸入資料,包括兩個整數N 和 K。
(0 < N <= 50, 0 < K <= N)

輸出
對於每組測試資料,輸出以下三行資料:
第一行: N劃分成K個正整數之和的劃分數目
第二行: N劃分成若干個不同正整數之和的劃分數目
第三行: N劃分成若干個奇正整數之和的劃分數目

分析
整數劃分問題這幾個變形確實很經典,需要一個個說明下:

N劃分成若干個可相同正整數之和
劃分分兩種情況:

劃分中每個數都小於m:則劃分數為dp[n][m-1]。
劃分中至少有一個數等於m:則從n中減去去m,然後從n-m中再劃分,則劃分數為dp[n-m][m]。
動態轉移方程:dp[n][m]=dp[n][m-1]+dp[n-m][m]。

package test;

public class Divide {
	public static int divide_int(int num) {
		if (num <= 0) return 0;
		int[][] dp = new int[num+1][num+1];
		for(int i = 0; i < dp.length; i++) {
			dp[i][1] = 1;
		}
		for(int i = 1; i < dp.length; i++ )
			for(int j = 1; j < dp[0].length; j++)
			{
				if(i < j)
					dp[i][j] = dp[i][i];
				else if( i > j)
					dp[i][j] = dp[i-j][j] + dp[i][j-1]; 
				else 
					dp[i][j] = dp[i][j-1] + 1;
					
			}
		return dp[num][num];
	}
	public static void main(String[] args) {
		// TODO 自動生成的方法存根
		System.out.println(divide_int(4));
	}

}

N劃分成若干個不同正整數之和
劃分分兩種情況:

劃分中每個數都小於m:則劃分數為dp[n][m-1]。
劃分中至少有一個數等於m:則從n中減去m,然後從n-m中再劃分,且再劃分的數中每個數要小於m, 則劃分數為dp[n-m][m-1]。
動態轉移方程:dp[n][m]=dp[n][m-1]+dp[n-m][m-1]。
在上面那個程式基礎上修改動態轉移方程即可。

N劃分成K個正整數之和
設dp[n][k]表示數n劃分成k個正整數之和時的劃分數。
劃分分兩種情況:

劃分中不包含1:則要求每個數都大於1,可以先拿出k個1分到每一份,之後在n-k中再劃分k份,即dp[n-k][k]。
劃分中包含1:則從n中減去1,然後從n-1中再劃分k-1份, 則劃分數為dp[n-1][k-1]。
動態轉移方程:dp[n][k]=dp[n-k][k]+dp[n-1][k-1]。

package test;

public class Divide2 {
	public static int divide_int2(int num, int k) {
		if (num <= 0) return -1;
		if (k < 1) return -1;
		if (num < k) return 0;
		
		int[][] dp = new int[num+1][k+1];
		for(int i = 0; i < dp.length; i++) {
			dp[i][1] = 1;
		}
		for(int i = 1; i < dp.length; i++ )
			for(int j = 1; j < dp[0].length; j++)
			{
				if(i < j)
					dp[i][j] = 0;
				else if( i > j)
					dp[i][j] = dp[i-1][j-1] + dp[i-j][j]; 
				else 
					dp[i][j] = 1;
					
			}
		return dp[num][k];
	}
	public static void main(String[] args) {
		// TODO 自動生成的方法存根
		System.out.println(divide_int2(0,2));
	}

}

N劃分成若干個奇正整數之和
設f[i][j]表示將數i分成j個正奇數,g[i][j]表示將數i分成j個正偶數。
首先如果先給j個劃分每個分個1,因為奇數加1即為偶數,所以可得:
f[i-j][j] = g[i][j]。
劃分分兩種情況:

劃分中不包含1:則要求每個數都大於1,可以先拿出k個1分到每一份,剛可將問題轉換為”從i-j中劃分j個偶數”,即g[i-j][j]。
劃分中包含1:則從n中減去1,然後從n-1中再劃分k-1份, 則劃分數為dp[n-1][k-1]。
動態轉移方程:f[i][j]=f[i-1][j-1]+g[i-j][j]。

package test;

public class Divide3 {
	public static int divide_int3(int num) {
		if (num <= 0) return -1;
		int sum = 0;
		
		int[][] f = new int[num+1][num+1];
		int[][] g = new int[num+1][num+1];
		//初始化
		for(int i = 1; i <= num; i++) {
			
		} 

		//動態規劃
		for(int i = 1; i <= num; i++ ) {
			for(int j = 1; j <= i; j++)
			{	
				if(j == 1) {
					if(i%2 == 1)
						f[i][1] = 1;
					else 
						g[i][1] = 1;
				} 
				else {
					g[i][j] = f[i-j][j];
					f[i][j] = f[i-1][j-1] + g[i-j][j];
				}
				
			}
		}
		for (int i = 1; i <= num; i++) {
	        sum += f[num][i];
		}
	    return sum;
	
    }
	public static void main(String[] args) {
		// TODO 自動生成的方法存根
		System.out.println(divide_int3(5));
	}

}

最長遞增子序列

這是一個經典的LIS(即最長上升子序列)問題,請設計一個儘量優的解法求出序列的最長上升子序列的長度。

給定一個序列A及它的長度n(長度小於等於500),請返回LIS的長度。

public static int[] getLIS(int[] A) {
        // write code here
        List<Integer> list = new ArrayList<>();
        
        int[] dp = new int[A.length];
        dp[0] = 1;
        
        for (int i = 1; i < dp.length; i++) {
            dp[i] = 1;
            for(int j = 0; j < i; j++){
                if(A[j] < A[i]){
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
        }
        
        int maxIndex = dp.length - 1;
        for (int i = dp.length - 2; i >= 0; i--) {
            if(dp[i] > dp[maxIndex]){
                maxIndex = i;    
            }
        }
        
        list.add(A[maxIndex]);
        for (int i = maxIndex - 1; i >= 0; i--) {
            if(A[maxIndex] > A[i] && dp[maxIndex] == dp[i] + 1){
                list.add(A[i]);
                maxIndex = i;
            }
        }
        
        int[] nums = new int[list.size()];
        for(int i = 0; i < nums.length; i++){
            nums[nums.length - 1 - i] = list.get(i);
        }
        return nums;
    }

其他一些經典問題參考http://www.cnblogs.com/DarrenChan/p/8734203.html

晚上對該帖子的演算法進行實現並改進自己的這篇部落格,自律給我自由。
2018/09/18 已實現整數劃分前的所有演算法