笨辦法理解動態規划算法


動態規划在編程中有着廣泛的應用,對於某些問題我們可以通過動態規划顯著的降低程序的時間復雜度。本質上動態規划並不是一種算法,而是解決一類問題的思想。本篇博客通過一些非常簡單而又經典的問題(比如數塔、0-1背包、完全背包、走樓梯問題、最長公共子序列等)來幫助大家理解動態規划的一般套路。

歡迎探討,如有錯誤敬請指正

如需轉載,請注明出處 http://www.cnblogs.com/nullzx/

 


 

1. 動態規划的基本思想

如果我們解決一個問題的時候能將一個大問題轉換成一個或者若干個規模較小的同等性質的問題,當我們求解出這些小問題的答案后,大問題的答案很容易解決,對於這樣的情況,顯然我們可以遞歸(或者說分治)的方式解決問題。如果在求解這些小問題的過程中發現有些小問題我們需要重復計算多次,那么我們就干脆把已經求解過的小問題的答案記錄下來放在一張表中,這樣下次遇到這個小問題,我們只需要查表就可以直接得到結果,這個就是動態規划的白話講解。動態規划的難點在於如何定義問題及子問題。

2. 笨辦法的套路

1)如果可以將一個規模較大的問題轉換成一個或若干個規模較小的子問題,也就是能找到遞推關系,這個時候我們不妨先將程序寫成遞歸的形式。

2)如果使用遞歸求解規模較小的問題上存在子問題重復求解的現象,那么我們就建立一張表(有可能這個表只有一行)記錄需要重復求解的子問題。填表的過程和將大問題划分為子問題的方式相反,我們會從最簡單的子問題開始填表。現在我們就利用這個套路解決下面這些經典的問題。

3. 利用套路解題

3.1 菲波那切數列

問題描述:菲波那契數列的定義f(n) = f(n-1) + f(n-2), 且f(1)=1, f(2) = 1,求f(n)的值。斐波那契數列的定義本身就是將大問題轉換成兩個同性質的子問題,所以我們可以直接根據定義寫成遞歸形式。

 

	public static int recursion(int n) {
		
		if (n < 0) {
			return 0;
		}
		
		if (n == 1 || n == 2) {
			return 1;
		}
		
		return recursion(n-1) + recursion(n-2);
	}

我們以f(6)為例現在把遞歸的過程畫出來

clip_image002

我們發現在求解F(6)時,需要求解F(2)四次,求解F(1)三次,求解F(3)三次,F(4)兩次,所以說我們的算法的效率是很低的。提高效率的辦法就是將F(1),F(2),F(3) ….的結果放在表中,下次要計算這些問題的時候我們直接從表中獲取就好了,這就是一個最簡單的動態規划的例子。現在我們按照套路,從最小的子問開始填表就好了。

	public static int dynamic(int n) {
		
		int[] table = new int[n+1];
		
		table[1] = 1;
		table[2] = 1;
		
		/*從小到大填表*/
		for (int i = 3; i < table.length; i++) {
			table[i] = table[i-1] + table[i-2];
		}
		
		return table[n];
	}

需要說明的是,這個例子只是一個入門的例子,實際上它不存在最優子結構的問題,而且也不需要長度為n+1的table數組,只需要兩個變量即可(可以理解為動態規划的優化版本),而我們之所以這樣講解只是為了讓大家從動態規划的角度去理解問題。

3.2 走樓梯問題

問題描述:總共有n個樓梯,每次可以走2個台階,或者每次走3個台階,或者每次走5個台階,請問這n個樓梯總共有幾種走法。

n個階梯的問題,可以分解成三個小問題,即n-2個階梯有幾種走法,n-3個階梯有幾種走法,n-5個階梯有幾種走法,而n個階梯的走法就是這三種走法的和。或者可以反過來思考,你已經站在最后一個台階上了,那么到達最后一個台階的情況只能是三種情況,最后一步恰好走2個台階恰好到達,最后一步恰好走3個台階恰好到達,最后一步恰好走5個台階恰好到達。通過這個思想,我們就可以寫出遞歸形式的代碼。

	public static int recursion(int n) { 
		
		if (n < 0) {
			return 0;
		}
		
		if (n == 0) {
			return 1;
		}
		
		return recursion(n - 5) + recursion(n - 3) + recursion(n - 2);
	}

顯然上面遞歸的處理方式需要重復計算很多子問題,畫出遞歸調用的圖就一目了然,由於該圖和上一個問題的圖很類似,這里就省略了。因此就創建一張表,把子問題的結果都記錄下來,dp[i]表示走到第i個階梯有多少種走法。按照套路,我們應該從小的階梯數開始填表。

	public static int dynamic(int n) {
		
		int[] record = new int[n+1];
		
		record[0] = 1;
		
		for (int i = 0; i < record.length; i++) {
			
			int n2 = i - 2 >= 0 ? record[i-2] : 0;
			int n3 = i - 3 >= 0 ? record[i-3] : 0;
			int n5 = i - 5 >= 0 ? record[i-5] : 0;
			
			record[i] = n2 + n3 + n5;
		}
		
		return record[n];
	}

同樣,這里例子中也不存在最優問題。

3.3 數塔問題

問題描述:從頂部出發在每一個節點可以選擇向下或者向右下走,一直走到底層,要求找出一條路徑,使得路徑上的數字之和最大。

clip_image002[6]

對於上圖所示的數塔:最大值為379, 綠色的的數字就是被選擇的節點。

這個問題不能使用貪心算法,請大家自己用三層的階梯列舉出反例。我們現在試着將這個問題分解成子問題,如下圖所示。想求得最大值,我們只要選擇的紅色邊框數塔最大值和藍色邊框數塔的最大值中更大的那個,然后加上32,就整個數塔的最大值。這樣我們就將一個大的問題轉化成了兩個規小的問題,然后這兩個規模較小的問題還可以繼續分解成更小的子問題。根據這個思路,我們可以得到如下遞歸形式的代碼。

clip_image004

	/*我們用一個二維數組的左下半數組表示數塔*/
	public static int recursion(int[][] a){
		return recursion(a, 0, 0);
	}
	
	/*參數i表示所在的行,j表示所在的列*/
	private static int recursion(int[][] a, int i, int j){
		
		/*
		 * 當分解問題到最下一層時,
		 * (a.length - 1, j)位置為頂點的數塔實際上數塔只有一個元素,
		 * 直接返回
		*/
		if (i == a.length - 1){
			return a[i][j];
		}
		
		/*求(i+1, j)位置為頂點的數塔最大值*/
		int r1 = recursion(a, i+1, j);
		
		/*求(i+1, j+1)位置為頂點的數塔最大值*/
		int r2 = recursion(a, i+1, j+1);
		
		/*返回(i,j)為頂點位置的數塔的最大值*/
		return Math.max(r1, r2) + a[i][j];
	}

上述代碼能夠得到正確的結果,但是我們發現計算大一點的數塔計算會很費時間,這主要是重復計算的問題,我們現在來分析一下為什么會出現重復計算的問題。clip_image002[8]

上圖中的紫色邊框數塔既存在於紅色邊框數塔中,也存在於藍色邊框數塔中,會重復計算兩次。實際上,我們使用遞歸時重復計算的問題顯然不止這一個,所以效率不高。為此我們應該創建一張和數塔形狀一樣的三角形表用來記錄更小的數塔的最大值。我們table表示這個表,表中table[i][j]位置的值表示以(i,j)為頂點的數塔的最大值。我們用a[i][j]表示數塔中第i行,第j列的值。那么table[i][j] = a[i][j] + Math.max(table[i-1][j], table[i-1][j-1])。按照套路,我們應該從最小的數塔開始填表。按照table[i][j]的定義,table表的最下面一行就應該等於數塔表中的最下面一行。

clip_image004[4]

按照定義,我們就可以填倒數第二行的dp[i][j]。

table[4][0] = 79 + Math.max(0, 71) = 150
table[4][1] = 69 + Math.max(71, 51) = 140
table[4][2] = 78 + Math.max(51, 82) = 160
table[4][3] = 29 + Math.max(82, 91) = 120
table[4][4] = 63 + Math.max(91, 64) = 154

填入到table表的倒數第二行,如下圖所示

clip_image002[10]

有了倒數第二行,我們就可以推出倒數第三行,依次類推,我們就可以得到最上面table [0][0]的數值,它就表示了整個數塔的最大值。除了最大值,如果我們還需要知道走了哪些路徑,我們還應該定義一個path表,在填table[i][j]時,同時填寫path[i][j]。path[i][j]表示了以(i, j)為頂點的數塔的最大值是由兩個子數塔(table[i-1][j]為頂點的數塔和table[i-1][j+1]為頂點的數塔)中的哪一個得到的。

public class NumbericalTower {

	/*最大值對應的各個頂點位置*/
	private LinkedList<Map.Entry<Integer, Integer>> pathList;
		
	/*存儲整個數塔的最大值*/
	private int result;

	public NumbericalTower(int[][] a) {

		pathList = new LinkedList<Map.Entry<Integer, Integer>>();
		dynamic(a);
	}

	
	private void dynamic(int[][] a){

		final int N = a.length;
		
		/*path[i][j] 表示(i+1, j)為頂點的數塔和(i+1,j+1)為頂點的數塔
		 *中較大的那個*/
		int[][] path = new int[N][N];
		
		/*動態規划對應的表*/
		int[][] table = new int[N][N];

		/*從最小的數塔開始填表*/
		for (int i = N - 1; i >= 0; i--) {
			
			/*根據下層數塔的最大值計算上層的數塔的最大值*/
			for (int j = 0; j <= i; j++) {
				
				if (i == N - 1) {
					table[i][j] = a[i][j];
					path[i][j] = -1;
					
				}else if (table[i+1][j] > table[i+1][j+1]) {
					table[i][j] = table[i+1][j] + a[i][j];
					path[i][j] = j;
				}else{
					table[i][j] = table[i+1][j+1] + a[i][j];
					path[i][j] = j+1;
				}
			}
		}
		
		result = table[0][0];
		
		/*記錄最大值對應的頂點*/
		int i = 0, j = 0;
		pathList.add(new SimpleEntry<Integer, Integer>(0, 0));
		
		while (true) {
			j = path[i][j];
			i = i + 1;
			pathList.add(new SimpleEntry<Integer, Integer>(i, j));
			
			if (path[i][j] == -1) {
				break;
			}
		}
	}
	
	int max(){
		return result;
	}
	
	List<Map.Entry<Integer, Integer>> path(){
		return pathList;
	}
	
	public static void main(String[] args) {
		int[][] a = {
			{32},
			{83, 68},
			{40, 37, 47},
			{ 5,  4, 67, 22},
			{79, 69, 78, 29, 63},
			{ 0, 71, 51, 82, 91, 64}
		};

		NumbericalTower nt = new NumbericalTower(a);
		int max = nt.max();
		List<Map.Entry<Integer, Integer>> path = nt.path();
		System.out.println("最大值:" + max);
		System.out.println("\n\n路徑為:");
		for (Map.Entry<Integer, Integer> entry : path) {
			int r = entry.getKey();
			int c = entry.getValue();
			System.out.println("行 : " + r + ", 列:"+ c);
		}
	}
}

運行結果

最大值:379

路徑為:
行 : 0, 列:0
行 : 1, 列:0
行 : 2, 列:1
行 : 3, 列:2
行 : 4, 列:2
行 : 5, 列:3
3.4 零-壹背包問題

問題描述:有n 個物品,它們有各自的重量(weight)和價值(value),現有給定容量的背包,如何讓背包里裝入的物品具有最大的價值總和此時背包中的物品?一個物品只有不拿和拿兩種情況,可以用0和1表示,所以稱之為0-1背包問題。

我們來看一個具體的例子。假設有如下物品:

clip_image002

求背包容量在10的時候的能裝物品的最大價值,以及裝了哪些物品?

3.4.1 解決背包的最大價值

我們可能首先想到的是貪心算法,我們算出每種物品的單位重量價值(weight/value),然后按照單位重量價值排序。我們放入物品時首先選擇單位重量價值高的物品,直到放不下為止。但是很遺憾,這樣得不到最優解。我們不妨列舉一個極端的例子,假設只有兩個物品,A的value = 2.9, weight = 2.1;B的value = 3, weight = 3,顯然物品A的單位重量價值要大於B的單位重量價值,但對於容量為3的背包,我們應該選擇物品B,所以貪心算法失效。對於0-1背包問題,貪心選擇之所以不能得到最優解是因為:它無法保證最終能將背包裝滿,而部分閑置的背包空間使每公斤背包空間的價值降低了。

回到上面具體的這個問題,它可以表述為

maxValue{寶石、剃須刀、ipad、充電寶、iphone | 背包容量10},

每個物品只有選和不選兩種結果,我們不妨從第一個物品開始。如果選了寶石,那么問題轉化為當前背包已有價值為50,並在剩下的背包容量(10 - 4)的前提下,再剩下的物品中(即剃須刀、ipad、充電寶、iphone)選取出最大的價值;如果不選寶石,那么問題轉化為當前背包價值為0,並在剩下的背包容量10的前提下,在剩下的物品中(即剃須刀、ipad、充電寶、iphone)選取出最大的價值。我們只需要選擇:

50 + maxValue{剃須刀、ipad、充電寶、iphone | 背包容量6}

0 + maxValue{剃須刀、ipad、充電寶、iphone | 背包容量10}

中較大的那個。而這就直接轉化成兩個子問題的求解,顯然我們已經可以用分治的方式解決這個問題了。我們不妨把遞歸樹(或者說分治樹)畫出來。

clip_image004[6]

上圖就是0-1背包問題的遞歸樹,圖左文字邊表示當前可選的物品,節點中的值表示背包的容量。我們沒有把整個遞歸樹全部都畫出來,因為圖中我們就已經發現了需要重復計算的子問題。如果背包容量變大,物品種類變多,那么需要重復計算的子問題就越多。需要說明的是上圖中有三個背包容量為7的子問題,但是只有被標記的兩個子問題才是重復的子問題,因為這兩個子問題的背包容量一樣,可選物品一樣。為了避免子問題的重復求解,我們就建立一張動態規划表,下次遇到重復的子問題,我們就直接查表。下圖表示了動態規划表和遞歸樹之間的關系。

clip_image006

那我們現在的主要問題就變成了如何填這樣一張表。我們用一個名為dp的二維數組表示這張表,dp[0]行需要單獨初始化,從dp[1]行開始填表,規則:從左到右,從上到下。

       clip_image008

dp[i][j]表示前i個物品(包括物品i),在背包容量為j時能裝的最大價值。

dp[i][j]為下面兩者的最大值:

1)物品i不放入背包中:背包容量為j時,前i-1個物品組合出的最大價值

2)物品i放入背包中:物品i的價值 + 除去物品i占用的重量后,剩余背包容量j-weight(i)由前i-1個物品組合出的最大價值

用公式表示為

clip_image010

3.4.2 解決背包有哪些物品
通過dp表,我們還可以知道哪些物品放入了背包中。從表格的右下角開始(第0個物品要單獨處理):

1)如果dp[i][j] > dp[i-1][j],說明該物品i被放入到了背包中,令i = i – 1, j = j – weight[i],然后重復步驟1。

2)如果dp[i][j] == dp[i-1][j],且只想得到背包最大價值的其中一種的物品一種組合方式,不妨認為該物品i沒有被放入到了背包中,令i = i – 1, 重復步驟1)。

clip_image012

對於步驟2),如果

dp[i][j] == dp[i-1][j] && dp[i][j – weight(i)] + value(i) == dp[i][j]

說明物品i可以放入背包中(令i = i – 1, j = j – weight[i]),也可以不用放入背包中(令i = i - 1)。這里就產生分支,說明放入背包中的物品組合方式不唯一,為了簡單起見,我們找到一種物品的組合方式即可。

package demo;

import java.util.LinkedList;
import java.util.List;


public class KnapsacProblem {
	/*動態規划表*/
	private int[][] dp;
	
	/*背包裝的最大價值*/
	private int maxVal;
	
	/*背包最大價值時對應的商品編號*/
	private List<Integer> goodsNumber; 
	
	public KnapsacProblem(int[] weight, int[] values, int capacity){
		
		if ( weight.length != values.length ){
			throw new IllegalArgumentException();
		}
		
		int goodsLen = weight.length;
		
		/*第0列不使用*/
		this.dp = new int[goodsLen][capacity + 1];
		
		goodsNumber = new LinkedList<Integer>();
		
		
		/*單獨初始化第0行*/
		for ( int j = 1; j < capacity + 1; j++){
			if (j >= weight[0]){
				dp[0][j] = values[0];
			}
		}
		
		/*填dp表*/
		for ( int i = 1; i < goodsLen; i++ ) {
			for ( int j = 1; j < capacity + 1; j++ ) {
				if ( weight[i] <= j ) {
					dp[i][j] = Math.max(dp[i-1][j], values[i] + dp[i-1][j - weight[i]]);
				} else {
					dp[i][j] = dp[i-1][j];
				}
			}
		}
		
		maxVal = dp[goodsLen - 1][capacity - 1];
		
		/*找出使用了哪些物品*/
		int j = capacity;
		for (int i = goodsLen - 1; i > 0; i-- ) {
			if ( dp[i][j] > dp[i-1][j] ) {
				goodsNumber.add(i);
				j = j - weight[i];
			}
		}
		
		/*單獨處理第0行,回退到第0行時發現背包中還有物品,說明物品0在背包中*/
		if (j > 0){
			goodsNumber.add(0);
		}
	}
	
	public int  getPackageMaxValue(){
		return this.maxVal;
	}
	
	public List<Integer> getGoodsNumber(){
		return this.goodsNumber;
	}
	
	public static void main(String[] args){
		
		int[] weight = {4, 5, 2, 1, 2};
		int[] values = {50, 40, 60, 20, 30};
		int capacity = 10;
		
		KnapsacProblem kp = new KnapsacProblem(weight, values, capacity);
		
		System.out.println(kp.getPackageMaxValue());
		System.out.println(kp.getGoodsNumber());
	}

}

運行結果

160
[4, 3, 2, 0]

如果我們僅僅需要知道最大的價值,不需要知道裝了哪些物品,我們就可以對空間復雜度進行優化,動態規划表只需要一維,因為dp[i][?]僅和dp[i-1][?]有關。

3.5 切分“和相等”的子集

Given a non-empty array containing only positive integers, find if the array can be partitioned into two subsets such that the sum of elements in both subsets is equal.

Note:

1. Each of the array element will not exceed 100.

2. The array size will not exceed 200.

Example 1:

Input: [1, 5, 11, 5]

Output: true

Explanation: The array can be partitioned as [1, 5, 5] and [11].

Example 2:

Input: [1, 2, 3, 5]

Output: false

Explanation: The array cannot be partitioned into equal sum subsets.

這是LeetCode的原題。這個問題本質上還是0-1背包問題,背包容量是數組之和的一半,物品的價值和體積是1比1的關系,額外條件是需要把背包裝滿。

3.6 完全背包問題

問題描述:有n 種物品,它們有各自的重量(weight)和價值(value),現有給定容量的背包,每種物品可以拿任意多個,如何讓背包里裝入的物品具有最大的價值,以及每種物品裝了幾個?

clip_image002

假設,我們還是利用0-1背包中的物品,背包容量為11。

完全背包問題也可以轉化成0-1背包問題。因為第i個物品最多拿“背包重量/(物品i的重量)”個,也就是說在0-1背包問題中每個物品i占一行,完全背包問題中,每個物品占“背包重量/(物品i的重量)” 個行,按照這個思路顯然已經能夠解決這個問題。現在我們不把這個問題轉化為0-1背包問題,而從這個問題的根源直接思考。

3.6.1  解決背包的最大價值

完全背包問題可以表述為

maxValue{寶石、剃須刀、ipad、充電寶、iphone | 背包容量10}

每個物品只有選和不選兩種結果,我們不妨從第一個物品開始。如果選了寶石,那么問題轉化為當前背包已有價值為50,並在剩下的背包容量(10 - 4)的前提下,繼續在{寶石、剃須刀、ipad、充電寶、iphone}選取出最大的價值;如果不選寶石,那么我們就在{剃須刀、ipad、充電寶、iphone}中選擇一種,那么問題轉化為當前背包價值為0,並在剩下的背包容量10的前提下,再剩下的物品中即{剃須刀、ipad、充電寶、iphone }選取出最大的價值。

因此我們只需要選擇:

50 + maxValue{寶石、剃須刀、ipad、充電寶、iphone | 背包容量6}

0 + maxValue{剃須刀、ipad、充電寶、iphone | 背包容量10}

中較大的那個。

而這就直接轉化成兩個子問題的求解,顯然我們已經可以用分治的方式解決這個問題了。我們同樣可以把遞歸樹畫出來,同樣還會發現存在需要重復求解的子問題,為了避免子問題的重復求解,我們還是建立一張動態規划表,下次遇到重復的子問題,我們就直接查表。這里我們直接給出動態規划表,我們用一個名為dp的二維數組表示這張表,dp[0]行單獨初始化,從dp[1]行開始填表,規則:從左到右,從上到下。

clip_image004

dp[i][j]表示前i個物品(包括物品i),在背包容量為j時能裝的最大價值。

dp[i][j]為下面二者的最大值:

clip_image006[4]

clip_image008[5]

3.6.2 解決背包中物品的種類和個數

同樣,從dp表中我們還可以知道哪些物品被選擇了,選擇多少次。我們還是從右下角開始回溯。

1)dp[i][j] > dp[i-1][j] 說明i號物品被選擇了,j = j – weight[i]

2)dp[i][j] == dp[i-1][j] 為了簡單起見,我們認為i號物品沒有被選擇,令i = i -1(實際上這里同樣可能存在分支,即最大價值時物品的組合方式和數量並不唯一,我們這里為了簡單處理,就不考慮這個問題了)。

clip_image009

package demo;

import java.util.AbstractMap.SimpleEntry;
import java.util.LinkedList;

public class AllKnapsacProblem {
	
	private int maxVal;
	
	private LinkedList<SimpleEntry<Integer, Integer>> goodsIdCount;
	
	public int getPackageMaxValue(){
		return maxVal;
	}
	
	public LinkedList<SimpleEntry<Integer, Integer>> getGoodsCount(){
		return goodsIdCount;
	}
	
	public AllKnapsacProblem(int[] weight, int[] values, int capacity){
		/*處理最大價值問題============================================*/

		if ( weight.length != values.length ){
			throw new IllegalArgumentException();
		}
		
		int goodsLen = weight.length;
		
		/*第0列不使用*/
		int[][] dp = new int[goodsLen][capacity + 1];
		
		/*第0行單獨處理*/
		for (int j = weight[0]; j <= capacity; j++){
			dp[0][j] = dp[0][j - weight[0]] + values[0];
		}
		
		for (int i = 1; i < goodsLen; i++){
			
			for (int j = 1; j <= capacity; j++){
				
				int max1 = dp[i-1][j];
                int max2 = j - weight[i] >= 0 ? values[i] + dp[i][j - weight[i]] : 0;
                
                dp[i][j] = Math.max(max1, max2);
			}
		}
		
		maxVal = dp[goodsLen-1][capacity];
		
		/*處理物品種類和個數問題問題============================================*/
		
		/*SimpleEntry<Integer, Integer>:key表示物品編號,value表示物品個數*/
		goodsIdCount = new LinkedList<SimpleEntry<Integer, Integer>>();
		
		int i = goodsLen - 1;
		int j = capacity;
		
		SimpleEntry<Integer, Integer> entry = new SimpleEntry<Integer, Integer>(i, 0);
		while (i > 0){
			
			if (dp[i][j] > dp[i-1][j]){
				int n = entry.getValue();
				entry.setValue(n+1);
				j = j - weight[i];
			}
			
			if (dp[i][j] == dp[i-1][j]){
				if (entry.getValue() > 0) {					
					goodsIdCount.add(entry);
				}
				i--;
				entry = new SimpleEntry<Integer, Integer>(i, 0);
			}
		}
				
		/*單獨處理第0行*/
		if (j > 0) {
			goodsIdCount.add(new SimpleEntry<Integer, Integer>(0, j/weight[0]));
		}
	}
	
	public static void main(String[] args){
		
		int[] values = {50, 40, 60, 20, 30};
		int[] weight = {4,   5,  2,  1, 2};
		int capacity = 11;
		
		AllKnapsacProblem ap = new AllKnapsacProblem(weight, values, capacity);
		
		System.out.println("背包價值" + ap.getPackageMaxValue());
		for (SimpleEntry<Integer, Integer> entry : ap.goodsIdCount) {
			System.out.printf("物品%d : %d個\n", entry.getKey(), entry.getValue());
		}
		
	}
	
}
運行結果
320
物品3 : 1個
物品2 : 5個

 

3.7 找零錢問題

You are given coins of different denominations ([dɪˌnɑ:mɪˈneɪʃn] 面額) and a total amount of money amount. Write a function to compute the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1。

Example 1:

Input: coins = [1, 2, 5], amount = 11

Output: 3

Explanation: 11 = 5 + 5 + 1

Example 2:

Input: coins = [2], amount = 3

Output: -1

這道題目是LeetCode上面的原題。假設在一堆面值為 1,2,5,11面值的硬幣,問最少需要多少個硬幣才能找出總值為以兌換15元。面對這個問題我們也會首先想到貪心算法,但是貪心算法給出的組合方案為{11,1,1,1,1},但其實最優方案為{5,5,5}。如果使用枚舉算法,每種硬幣都有選0個,選1個,選2個,選…,這樣時間復雜度太高。這個問題本質上還是完全背包問題,物品的價值和重量比是1比1,額外條件是需要把背包裝滿,所以我們可以使用動態規划算法去解決它,代碼這里就不給出了。

3.8 最長公共子序列

我們首先看一下子序列的定義。假設給定一個字符串,我們抽取任意多個不超過字符串長度的字符,並保持它們的前后關系,這樣的字符我們稱之為子序列。對於字符串ABCDEFG而言, BEF、C、AG等等都是它的一個子序列。

Longest common sequence問題:給定兩個字符串s1和s2,求這兩個字符串中最長的公共子序列。比如給定兩個字符串s1:bdcaba和s2:abcbdab,它們的公共子序列

長度為4,最長公共子序列是:bcba。

字符串s1的長度用n表示,字符產s2的長度用m表示,字符串s1和s2的最長公共字串用lcs(n,m)。那么這個問題可以轉化為三個子問題

1)求lcs(n-1, m-1)

2)求lcs(n-1, m)

3)求lcs(n, m-1)

當我們求的上述三個子問題的答案,那么lcs(n, m)的結果就可以通過如下方式得到:

如果s1[n] == s2[m]

    lcs(n, m) = lcs(n-1, m-1)+1

如果s1[n] != s2[m] :

    lcs(n, m) = max{ lcs(n-1, m-1), lcs(n-1, m), lcs(n, m-1) }

但是實際上lcs(n,m)只要轉化成兩個子問題lcs(n-1, m)和lcs(n, m-1)就好了。

而子問題lcs(n-1, m-1)是沒有必要的,因為lcs(n-1, m-1)必定小於等於lcs(n-1, m)和lcs(n, m-1)中的en任意一個。從常理上來說很好理解,不可能兩個字符串中的任意一個變長了,公共子序列反而減少了。而本質上是由於lcs(n-1, m-1)也是lcs(n-1, m)和lcs(n, m-1)這兩個問題的子問題。

通過上面的分析,我們把大的問題轉化成小的問題,就可以通過遞歸(或者說分治)的方式把問題解決了,下面就是遞歸對應的代碼。

	public static void recursion (char[] s1, char[] s2) {
		maxLen = recursion0 (s1, s1.length-1, s2, s2.length-1);
	}
	
	private static int recursion0 (char[] s1, int idx1, char[] s2, int idx2){
		
		if(idx1 < 0 || idx2 < 0){
			return 0;
		}
		
		int max1, max2;
		
		max1 = recursion0 (s1, idx1, s2, idx2 - 1);
		max2 = recursion0 (s1, idx1 - 1, s2,  idx2);
		
		if (s1[idx1] == s2[idx2]){
			return Math.max(max1, max2) + 1;
		}else{
			return Math.max(max1, max2);
		}
	}

顯然上述也同樣存在很多重復計算的子問題,為了降低時間復雜度,要一張二維表記錄重復計算的子問題的結果,這張表我們用dp表示, dp[i][j]就表示以s1[i]和s2[j]結尾的字符串最長公共子序列。按照套路填表規則要從最小的子問題開始,

clip_image002[3]

第0行,表示“b”和“bdcaba”的公共子序列,可以單獨處理,同理第0列也可以單獨處理,填表完成后如上圖所示。從第二行開始,dp表按照從上到下,從左到右的填表順序填表。根據子遞歸中子問題的定義,dp[i][j]的取值如下:

clip_image002[1]

clip_image006[4]

當填完整張表時,右下角的值就是公共子序列的最大長度。如果我們還需要知道公共子序列是什么,那么我們可以從右下角開始回溯,如果dp[i][j] > dp[i-1][j] 且 dp[i][j] > dp[i][j-1], 說明s1[i]或者s2[j]是公共子序列,否則選擇走dp[i-1][j]和dp[i][j-1]中較大的那個,同樣第0行要單獨處理。

package demo;

public class LongestCommonSequence {
	
	private int[][] dp;
	private int maxLen;
	private String lcs;
	
	private char[] s1, s2;
		
	public int maxLen(){
		return maxLen;
	}
	
	public String getLCS() {
		return lcs;
	}
	
	public LongestCommonSequence(String str1, String str2) {
		s1 = str1.toCharArray();
		s2 = str2.toCharArray();
		dynamic();
		getString();
	}
	
	/*動態規划算法*/
	private void dynamic(){
		
		dp = new int[s1.length][s2.length];
		
		/*單獨處理第0行*/
		for(int j = 0, x = 0; j < s2.length; j++){
			if (s1[0] == s2[j]){
				x = 1;
			}
			dp[0][j] = x;
		}
		
		/*單獨處理第0列*/
		for (int i = 0, x = 0; i < s1.length; i++) {
			if (s2[0] == s1[i]){
				x = 1;
			}
			dp[i][0] = x;
		}
		
		for (int i = 1; i < s1.length; i++) {
			
			for(int j = 1; j < s2.length; j++){
				
				if(s1[i] == s2[j]){
					dp[i][j] = 1 + dp[i-1][j-1];
				}else{
					dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
				}
			}
		}
		
		maxLen = dp[s1.length - 1][s2.length - 1];
		
	}
	
	/*回溯求出公共子序列*/
	private void getString(){
		
		int cnt = maxLen;
		StringBuffer sb = new StringBuffer();
		
		int i = s1.length - 1, j = s2.length - 1;
		
		while (i > 0 && j > 0){
			if (dp[i][j] > dp[i-1][j] && dp[i][j] > dp[i][j-1]){
				sb.append(s1[i]);
				i--;
				j--;
				cnt--;
			}else{
				if (dp[i-1][j] > dp[i][j-1]){
					i--;
				}else{
					j--;
				}
			}
		}
		
		/*單獨處理第0行, i和j必然有一個為0*/
		if (cnt > 0){
			
			while (true){
				
				if (s1[i] == s2[j]){
					sb.append(s1[i]);
					break;
				}
				
				if (i > 0){
					i--;
				}
				
				if (j > 0){
					j--;
				}
			}
			
			cnt--;
		}
		
		lcs = sb.reverse().toString();
	}
	
	public static void main(String[] args){
		LongestCommonSequence lcs = new LongestCommonSequence("bcba", "bdcaba");
		System.out.println(lcs.maxLen);
		System.out.println(lcs.getLCS());
	}

}

4. 動態規划算法總結

枚舉算法:如果為了方便的解決這個問題,我們需要將大問題化簡成小問題,將所有小問題中的最優解作為我們解決大問題的基礎。

貪心算法:如果為了方便的解決這個問題,我們需要將大問題化簡成小問題,在所有小問題中,僅選擇對當前最有利的小問題作為我們解決大問題的基礎。

動態規划:如果為了方便的解決這個問題,我們需要將大問題化簡成小問題,記錄已解決過的小問題,將所有小問題中的最優解作為我們解決大問題的基礎。換句話說,能用貪心算法解決的,動態規划算法也肯定能解決,反之不成立。

能用動規解決的問題的特點

1) 問題具有最優子結構性質。如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質。

2) 無后效性。當前的若干個狀態值一旦確定,則此后過程的演變就只和這若干個狀態的值有關和之前是采取哪種手段或經過哪條路徑演變到當前的這若干個狀態,沒有關系。

5. 參考內容

[1]. 動態規划:最長上升子序列(LIS)

[2]. 什么是動態規划?動態規划的意義是什么?

[3]. 漫畫:什么是動態規划?

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM