《演算法筆記》12. 用暴力遞迴解法推匯出動態規劃

Inky發表於2020-08-21

1 暴力遞迴到動態規劃

本篇是演算法基礎筆記的最後一篇,前面所記錄的和該篇共同覆蓋了平時刷題常用的資料結構和演算法,之後就是通過刷題量來鞏固所學的內容。接下來我準備針對基礎資料結構和演算法做一些刷題記錄,主要是分類別刷leetcode上的題,傳送門為:https://github.com/Dairongpeng/leetcode 一起刷題吧^_^。

轉載註明出處,原始碼地址: https://github.com/Dairongpeng/algorithm-note ,歡迎star

動態規劃最核心的是暴力遞迴的嘗試過程,一旦通過嘗試寫出來暴力遞迴的解法,那麼動態規劃在此基礎上就很好改了

1、暴力遞迴之所以暴力,是因為存在大量的重複計算。加入我們定義我們的快取結構,用來查該狀態有沒有計算過,那麼會加速我們的遞迴

2、在我們加入快取結構之後,消除了大量的重複計算,快取表就是我們的dp表。那麼這種去除重複計算的遞迴,就是最粗糙的動態規劃,也叫記憶化搜尋

3、如果我們把我們的dp表,從簡單到複雜列出來,那麼就是經典的動態規劃。我們無需考慮轉移方程怎麼寫,而是根據我們的遞迴來推導。看下面例子:

1.1 例一 : 機器人運動問題(2018阿里面試題目)

認識暴力遞迴改動態規劃的過程

假設有排成一行的N個位置,記為1~N,N一定大於等於2。開始時機器人在其中的M位置上(M一定是1~N中的一個)。

如果機器人來到1位置,那麼下一步只能往右來到2位置;

如果機器人來到N位置,那麼下一步只能往左來到N-1的位置;

如果機器人來到中間位置,那麼下一步可以往左走或者往右走;

規定機器人必須走K步,最終能來到P位置(P也是1~N中的一個)的方法有多少種?

給定四個引數N,M,K,P。返回方法數

暴力遞迴ways1呼叫的walk函式,就是暴力遞迴過程,存在重複計算。waysCache方法對應的walkCache就是在純暴力遞迴的基礎上加了快取

假設我們的引數N=7,M=2,K=5,P=3。我們根據我們的遞迴加快取的過程來填我們的dp表:

1、int[][] dp = new int[N+1][K+1];是我們的DP表的範圍

2、當rest = 0的時候,dp[cur][rest] = cur == P ? 1 : 0; 我們只有cur=P的時候為1,其他位置都為0。

3、當cur=1的時候,dp[cur][rest] = walkCache(N, 2, rest - 1, P, dp); 我們的dp當前的值,依賴於cur的下一個位置,rest的上一個位置

4、當cur = N的時候,dp[cur][rest] =walkCache(N, N - 1, rest - 1, P,dp); 我們dp當前位置依賴於N-1位置,和rest - 1位置

5、當cur在任意中間位置時。dp[cur][rest] = walkCache(N, cur + 1, rest - 1, P,dp) + walkCache(N, cur - 1, rest - 1, P, dp); dp的當前位置依賴於dp的cur+1和rest-1位置加上dp的cur-1和rest-1的位置

那麼我們可以得到我們的DP表為:


    0   1   2   3   4   5   K 座標
0   x   x   x   x   x   x

1   0   0   1   0   3   0

2   0   1   0   3   0   9

3   1   0   2   0   6   0

4   0   1   0   3   0   10

5   0   0   1   0   5   0

6   0   0   0   1   0   5

7   0   0   0   0   1   0

cur
坐
標

所以任何的動態規劃,都可以由暴力遞迴改出來。也就是所任意的動態規劃都來自於某個暴力遞迴。反之任何暴力遞迴不一定能改成動態規劃,jia'ru某暴力遞迴併沒有重複計算,沒有快取的必要

動態規劃實質就是把引數組合完成結構化的快取

package class12;

public class Code01_RobotWalk {

	public static int ways1(int N, int M, int K, int P) {
		// 引數無效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		// 總共N個位置,從M點出發,還剩K步可以走,返回最終能達到P的方法數
		return walk(N, M, K, P);
	}

	// N : 位置為1 ~ N,固定引數
	// cur : 當前在cur位置,可變引數
	// rest : 還剩res步沒有走,可變引數
	// P : 最終目標位置是P,固定引數
	// 該函式的含義:只能在1~N這些位置上移動,當前在cur位置,走完rest步之後,停在P位置的方法數作為返回值返回
	public static int walk(int N, int cur, int rest, int P) {
		// 如果沒有剩餘步數了,當前的cur位置就是最後的位置
		// 如果最後的位置停在P上,那麼之前做的移動是有效的
		// 如果最後的位置沒在P上,那麼之前做的移動是無效的
		if (rest == 0) {
			return cur == P ? 1 : 0;
		}
		// 如果還有rest步要走,而當前的cur位置在1位置上,那麼當前這步只能從1走向2
		// 後續的過程就是,來到2位置上,還剩rest-1步要走
		if (cur == 1) {
			return walk(N, 2, rest - 1, P);
		}
		// 如果還有rest步要走,而當前的cur位置在N位置上,那麼當前這步只能從N走向N-1
		// 後續的過程就是,來到N-1位置上,還剩rest-1步要走
		if (cur == N) {
			return walk(N, N - 1, rest - 1, P);
		}
		// 如果還有rest步要走,而當前的cur位置在中間位置上,那麼當前這步可以走向左,也可以走向右
		// 走向左之後,後續的過程就是,來到cur-1位置上,還剩rest-1步要走
		// 走向右之後,後續的過程就是,來到cur+1位置上,還剩rest-1步要走
		// 走向左、走向右是截然不同的方法,所以總方法數要都算上
		return walk(N, cur + 1, rest - 1, P) + walk(N, cur - 1, rest - 1, P);
	}
	
	public static int waysCache(int N, int M, int K, int P) {
		// 引數無效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		
		// 我們準備一張快取的dp表
		// 由於我們的cur範圍是1~N,我們準備N+1。
		// rest範圍在1~K。我們準備K+1。
		// 目的是把我們的可能結果都能裝得下
		int[][] dp = new int[N+1][K+1];
		// 設定這張表的初始值都為-1,代表都還沒用過
		for(int row = 0; row <= N; row++) {
			for(int col = 0; col <= K; col++) {
				dp[row][col] = -1;
			}
		}
		return walkCache(N, M, K, P,dp);
	}

	// HashMap<String, Integer>   (19,100)  "19_100"
	// 我想把所有cur和rest的組合,返回的結果,加入到快取裡
	public static int walkCache(int N, int cur, int rest, int P, int[][] dp) {
	        // 當前場景已經計算過,不要再暴力展開,直接從快取中拿
		if(dp[cur][rest] != -1) {
			return dp[cur][rest];
		}
		if (rest == 0) {
		        // 先加快取
			dp[cur][rest] = cur == P ? 1 : 0;
			return dp[cur][rest];
		}
		if (cur == 1) {
		        // 先加快取
			dp[cur][rest] = walkCache(N, 2, rest - 1, P, dp);
			return dp[cur][rest];
		}
		if (cur == N) {
		        // 先加快取
			dp[cur][rest] =walkCache(N, N - 1, rest - 1, P,dp);
			return dp[cur][rest];
		}
		// 先加快取
		dp[cur][rest] = walkCache(N, cur + 1, rest - 1, P,dp) 
				+ walkCache(N, cur - 1, rest - 1, P, dp);
		return dp[cur][rest];
	}
	
	public static int ways2(int N, int M, int K, int P) {
		// 引數無效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		int[][] dp = new int[K + 1][N + 1];
		dp[0][P] = 1;
		for (int i = 1; i <= K; i++) {
			for (int j = 1; j <= N; j++) {
				if (j == 1) {
					dp[i][j] = dp[i - 1][2];
				} else if (j == N) {
					dp[i][j] = dp[i - 1][N - 1];
				} else {
					dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1];
				}
			}
		}
		return dp[K][M];
	}

	public static int ways3(int N, int M, int K, int P) {
		// 引數無效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		int[] dp = new int[N + 1];
		dp[P] = 1;
		for (int i = 1; i <= K; i++) {
			int leftUp = dp[1];// 左上角的值
			for (int j = 1; j <= N; j++) {
				int tmp = dp[j];
				if (j == 1) {
					dp[j] = dp[j + 1];
				} else if (j == N) {
					dp[j] = leftUp;
				} else {
					dp[j] = leftUp + dp[j + 1];
				}
				leftUp = tmp;
			}
		}
		return dp[M];
	}

	// ways4是你的方法
	public static int ways4(int N, int M, int K, int P) {
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		return process(N, 0, P, M, K);
	}

	// 一共N個位置,從M點出發,一共只有K步。返回走到位置j,剩餘步數為i的方法數
	public static int process(int N, int i, int j, int M, int K) {
		if (i == K) {
			return j == M ? 1 : 0;
		}
		if (j == 1) {
			return process(N, i + 1, j + 1, M, K);
		}
		if (j == N) {
			return process(N, i + 1, j - 1, M, K);
		}
		return process(N, i + 1, j + 1, M, K) + process(N, i + 1, j - 1, M, K);
	}

	// ways5是你的方法的dp優化
	public static int ways5(int N, int M, int K, int P) {
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		int[][] dp = new int[K + 1][N + 1];
		dp[K][M] = 1;
		for (int i = K - 1; i >= 0; i--) {
			for (int j = 1; j <= N; j++) {
				if (j == 1) {
					dp[i][j] = dp[i + 1][j + 1];
				} else if (j == N) {
					dp[i][j] = dp[i + 1][j - 1];
				} else {
					dp[i][j] = dp[i + 1][j + 1] + dp[i + 1][j - 1];
				}
			}
		}
		return dp[0][P];
	}

	public static void main(String[] args) {
		System.out.println(ways1(7, 4, 9, 5));
		System.out.println(ways2(7, 4, 9, 5));
		System.out.println(ways3(7, 4, 9, 5));
		System.out.println(ways4(7, 4, 9, 5));
		System.out.println(ways5(7, 4, 9, 5));
	}

}

1.2 例二:揹包問題改動態規劃

暴力遞迴過程中,我們如何組織我們的決策過程。實質就是動態規劃的狀態轉移

package class12;

public class Code03_Knapsack {

	public static int getMaxValue(int[] w, int[] v, int bag) {
	        // 主函式需要0 bag的返回值
		return process(w, v, 0, 0, bag);
	}

	// index... 最大價值
	public static int process(int[] w, int[] v, int index, int alreadyW, int bag) {
		if (alreadyW > bag) {
			return -1;
		}
		// 重量沒超
		if (index == w.length) {
			return 0;
		}
		int p1 = process(w, v, index + 1, alreadyW, bag);
		int p2next = process(w, v, index + 1, alreadyW + w[index], bag);
		int p2 = -1;
		if (p2next != -1) {
			p2 = v[index] + p2next;
		}
		return Math.max(p1, p2);

	}

	public static int maxValue(int[] w, int[] v, int bag) {
		return process(w, v, 0, bag);
	}

	// 只剩下rest的空間了,
	// index...貨物自由選擇,但是不要超過rest的空間
	// 返回能夠獲得的最大價值
	public static int process(int[] w, int[] v, int index, int rest) {
		if (rest < 0) { // base case 1
			return -1;
		}
		// rest >=0
		if (index == w.length) { // base case 2
			return 0;
		}
		// 有貨也有空間
		int p1 = process(w, v, index + 1, rest);
		int p2 = -1;
		int p2Next = process(w, v, index + 1, rest - w[index]);
		if(p2Next!=-1) {
			p2 = v[index] + p2Next;
		}
		return Math.max(p1, p2);
	}

        // dpWay就是把上述的暴力遞迴改為動態規劃
	public static int dpWay(int[] w, int[] v, int bag) {
		int N = w.length;
		// 準備一張dp表,行號為我們的重量範圍。列為我們的價值範圍
		int[][] dp = new int[N + 1][bag + 1];
		// 由於暴力遞迴中index==w.length的時候,總是返回0。所以:
		// dp[N][...] = 0。整形陣列初始化為0,無需處理
		// 由於N行已經初始化為0,我們從N-1開始。填我們的dp表
		for (int index = N - 1; index >= 0; index--) {
		        // 剩餘空間從0開始,一直填寫到bag
			for (int rest = 0; rest <= bag; rest++) { // rest < 0
			
			    // 通過正常位置的遞迴處理
			    // 我們轉而填寫dp表格,註釋位置是正常遞迴處理
			    
		//	    int p1 = process(w, v, index + 1, rest);
		//        int p2 = -1;
		//        int p2Next = process(w, v, index + 1, rest - w[index]);
		//        if(p2Next!=-1) {
		//	        p2 = v[index] + p2Next;
		//        }
		//        return Math.max(p1, p2);
			    
			        // 所以我們p1等於dp表的下一層向上一層返回
				int p1 = dp[index+1][rest];
				int p2 = -1;
				// rest - w[index] 不越界
				if(rest - w[index] >= 0) {
					p2 = v[index] + dp[index + 1][rest - w[index]];
				}
				// p1和p2取最大值
				dp[index][rest] = Math.max(p1, p2);
				
			}
		}
		// 最終返回dp表的0,bag位置,就是我們暴力遞迴的主函式呼叫
		return dp[0][bag];
	}

	public static void main(String[] args) {
		int[] weights = { 3, 2, 4, 7 };
		int[] values = { 5, 6, 3, 19 };
		int bag = 11;
		System.out.println(maxValue(weights, values, bag));
		System.out.println(dpWay(weights, values, bag));
	}

}

1.3 動態規劃解題思路

1、拿到題目先找到某一個暴力遞迴的寫法(嘗試)

2、分析我們的暴力遞迴,是否存在重複解。沒有重複解的遞迴沒必要改動態規劃

3、把暴力遞迴中的可變引數,做成快取結構,不講究組織。即沒算過加入快取結構,算過的直接拿快取,就是記憶化搜尋

4、如果把快取結構做精細化組織,就是我們經典的動態規劃

5、以揹包問題舉例,我們每一個重量有要和不要兩個選擇,且都要遞迴展開。那麼我們的遞迴時間複雜度尾O(2^N)。而記憶化搜尋,根據可變引數得到的長為N價值為W的二維表,那麼我們的時間複雜度為O(N*bag)。如果遞迴過程中狀態轉移有化簡繼續優化的可能,例如列舉。那麼經典動態規劃可以繼續優化,否則記憶化搜尋和動態規劃的時間複雜度是一樣的

1.3.1 湊貨幣問題(重要)

該題是對動態規劃完整優化路徑的演示

有一個表示貨幣面值的陣列arr,每種面值的貨幣可以使用任意多張。arr陣列元素為正數,且無重複值。例如[7,3,100,50]這是四種面值的貨幣。

問:給定一個特定金額Sum,我們用貨幣面值陣列有多少種方法,可以湊出該面值。例如P=1000,用這是四種面值有多少種可能湊出1000

ways1為暴力遞迴的解題思路及實現

ways2為暴力遞迴改記憶化搜尋的版本

ways3為記憶化搜尋版本改動態規劃的版本

package class12;

public class Code09_CoinsWay {

	// arr中都是正數且無重複值,返回組成aim的方法數,暴力遞迴
	public static int ways1(int[] arr, int aim) {
		if (arr == null || arr.length == 0 || aim < 0) {
			return 0;
		}
		return process1(arr, 0, aim);
	}

	public static int process1(int[] arr, int index, int rest) {
	        // base case
	        // 當在面值陣列的arr.length,此時越界,沒有貨幣可以選擇。
	        // 如果當前目標金額就是0,那麼存在一種方法,如果目標金額不為0,返回0中方法
		if(index == arr.length) {
			return rest == 0 ? 1 : 0 ;	
		}
		
		// 普遍位置
		int ways = 0;
		// 從0號位置開始列舉,選擇0張,1張,2張等
		// 條件是張數乘以選擇的面值,不超過木匾面值rest
		for(int zhang = 0;  zhang * arr[index] <= rest ;zhang++) {
		    // 方法數加上除去當前選擇後所剩面額到下一位置的選擇數,遞迴
			ways += process1(arr, index + 1, rest -  (zhang * arr[index])  );
		}
		return ways;
	}


        // ways1暴力遞迴,改為記憶化搜尋。ways2為記憶化搜尋版本
	public static int ways2(int[] arr, int aim) {
		if (arr == null || arr.length == 0 || aim < 0) {
			return 0;
		}
		// 快取結構,且只和index和rest有關,跟arr無關
		int[][] dp = new int[arr.length+1][aim+1];
		// 一開始所有的過程,都沒有計算呢,dp二維表初始化為-1
		// dp[..][..]  = -1
		for(int i = 0 ; i < dp.length; i++) {
			for(int j = 0 ; j < dp[0].length; j++) {
				dp[i][j] = -1;
			}
		}
		// 快取結構向下傳遞
		return process2(arr, 0, aim , dp);
	}
	
	// 如果index和rest的引數組合,是沒算過的,dp[index][rest] == -1
	// 如果index和rest的引數組合,是算過的,dp[index][rest]  > - 1
	public static int process2(int[] arr, 
			int index, int rest,
			int[][] dp) {
		if(dp[index][rest] != -1) {
			return dp[index][rest];
		}
		if(index == arr.length) {
			dp[index][rest] = rest == 0 ? 1 :0;
			return  dp[index][rest];	
		}
		int ways = 0;
		for(int zhang = 0;  zhang * arr[index] <= rest ;zhang++) {
		        // 返回之前加入快取
			ways += process2(arr, index + 1, rest -  (zhang * arr[index]) , dp );
		}
		// 返回之前加入快取
		dp[index][rest] = ways;
		return ways;
	}
	
	// 記憶化搜尋改造為動態規劃版本,ways3。
	// 如果沒有列舉行為,該動態該規劃為自頂向下的動態規劃和記憶化搜尋等效,但這題存在列舉行為。
	public static int ways3(int[] arr, int aim) {
		if (arr == null || arr.length == 0 || aim < 0) {
			return 0;
		}
		int N = arr.length;
		// dp表
		int[][] dp = new int[N + 1][aim + 1];
		// 根據遞迴方法,N為arr的越界位置,但是我們的dp表定義的是N+1。
		// N位置,如果aim為0,則dp[N][0] = 1;
		dp[N][0] = 1;// dp[N][1...aim] = 0;
		
		// 每個位置依賴自己下面的位置,那麼我們從下往上迴圈
		for(int index = N - 1; index >= 0; index--) {
		    // rest從左往右
			for(int rest = 0; rest <= aim; rest++) {
				int ways = 0;
				for(int zhang = 0;  zhang * arr[index] <= rest ;zhang++) {
					ways += dp[index + 1] [rest -  (zhang * arr[index])];
				}
				dp[index][rest] = ways;
			}
		}
		// 最終我們需要[0,aim]位置的解
		return dp[0][aim];
	}
	
	// 由於存在列舉行為,我們可以進一步優化我們的動態規劃。ways4是優化的動態規劃
	// 根據列舉,用具體化例子來找出規律,省掉列舉
	public static int ways4(int[] arr, int aim) {
		if (arr == null || arr.length == 0 || aim < 0) {
			return 0;
		}
		int N = arr.length;
		int[][] dp = new int[N + 1][aim + 1];
		dp[N][0] = 1;// dp[N][1...aim] = 0;
		for(int index = N - 1; index >= 0; index--) {
			for(int rest = 0; rest <= aim; rest++) {
				dp[index][rest] = dp[index+1][rest];
				if(rest - arr[index] >= 0) {
					dp[index][rest] += dp[index][rest - arr[index]];
				}
			}
		}
		return dp[0][aim];
	}
	

	public static void main(String[] args) {
		int[] arr = { 5, 10,50,100 };
		int sum = 1000;
		System.out.println(ways1(arr, sum));
		System.out.println(ways2(arr, sum));
		System.out.println(ways3(arr, sum));
		System.out.println(ways4(arr, sum));
	}

}

1.3.2 貼紙問題

給定一個字串str,給定一個字串型別的陣列arr。arr裡的每一個字串,代表一張貼紙,你可以把單個字元剪開使用,目的是拼出str來。

返回需要至少多少張貼紙可以完成這個任務。

例如:str="babac",arr={"ba","c","abcd"}

至少需要兩張貼紙"ba"和"abcd",因為使用這兩張貼紙,把每一個字串單獨剪開,含有2個a,2個b,1個c。是可以拼出str的,所以返回2。

思路1 minStickers1:由於任何貼紙都可以剪下的很碎,跟貼紙的順序沒關係。那麼目標str我們先進行排序,那麼也不會有影響。babac排序為aabbc,我們再去選擇貼紙,str被貼紙貼好後,剩下的接著排序,再選擇......

由於我們的可變引數,只有一個目標字串。但是目標字串的可能性太多,沒辦法精細化動態規劃。傻快取的暴力遞迴已經是最優解了。所以只有一個可變引數,又存在重複解,那麼傻快取就是最優解。

思路2 minStickers2,我們每一張貼紙列舉所有可能的張數,後續過程不再考慮這張貼紙。但是方法一會好一點,因為只有一個可變引數。而方法二有兩個可變引數,我們在設計遞迴的時候,儘量少的使用可變引數,這樣我們快取結構的命中率會提升

package class12;

import java.util.Arrays;
import java.util.HashMap;

public class Code02_StickersToSpellWord {

        // 暴力遞迴加快取dp
	public static int minStickers1(String[] stickers, String target) {
		
		int n = stickers.length;
		// stickers -> [26] [26] [26]
		// 表示把每張貼紙轉化為26個字母的詞頻陣列
		int[][] map = new int[n][26];
		// 把每一張貼紙轉化的字母26個字母有多少個
		for (int i = 0; i < n; i++) {
			char[] str = stickers[i].toCharArray();
			for (char c : str) {
				map[i][c - 'a']++;
			}
		}
		
		HashMap<String, Integer> dp = new HashMap<>();
		dp.put("", 0);
		return process1(dp, map, target);
	}

	// dp 傻快取,如果rest已經算過了,直接返回dp中的值
	// rest 剩餘的目標
	// 0..N每一個字串所含字元的詞頻統計
	// 返回值是-1,map 中的貼紙  怎麼都無法rest
	public static int process1(
			HashMap<String, Integer> dp,
			int[][] map, 
			String rest) {
		if (dp.containsKey(rest)) {
			return dp.get(rest);
		}
		// 以下就是正式的遞迴呼叫過程
		int ans = Integer.MAX_VALUE; // ans -> 搞定rest,使用的最少的貼紙數量 
		int n = map.length; // N種貼紙
		int[] tmap = new int[26]; // tmap 去替代 rest
		// 把目標target剩餘的rest進行詞頻統計
		char[] target = rest.toCharArray();
		for (char c : target) {
			tmap[c - 'a']++;
		}
		
		// map表示所有貼紙的詞頻資訊,tmap表示目標字串的詞頻資訊
		// 用map去覆蓋tmap
		for (int i = 0; i < n; i++) {
			// 列舉當前第一張貼紙是誰?
			// 第一張貼紙必須包含當前目標字串的首字母。和所有貼紙去選等效
			// 目的是讓當前貼紙的元素有在目標字串的對應個,否則試過之後仍然是原始串,棧會溢位
			if (map[i][target[0] - 'a'] == 0) {
				continue;
			}
			StringBuilder sb = new StringBuilder();
			// i 貼紙, j 列舉a~z字元
			for (int j = 0; j < 26; j++) { // 
				if (tmap[j] > 0) { // j這個字元是target需要的
					for (int k = 0; k < Math.max(0, tmap[j] - map[i][j]); k++) {
					    // 新增剩餘
						sb.append((char) ('a' + j));
					}
				}
			}
			// sb ->  i 此時s就是當前貼紙搞定之後剩餘的
			String s = sb.toString();
			// tmp是後續方案需要的貼紙數
			int tmp = process1(dp, map, s);
			// 如果-1表示貼紙怎麼都無法覆蓋目標字串
			if (tmp != -1) {
			    // 不是-1,最終返回當前後續貼紙數,加上當前貼紙數與ans的最小值
				ans = Math.min(ans, 1 + tmp);
			}
		}
		// 經過上述迴圈,ans仍然系統最大,表示怎麼都無法覆蓋rest,返回-1
		dp.put(rest, ans == Integer.MAX_VALUE ? -1 : ans);
		return dp.get(rest);
	}


    
	public static int minStickers2(String[] stickers, String target) {
		int n = stickers.length;
		int[][] map = new int[n][26];
		for (int i = 0; i < n; i++) {
			char[] str = stickers[i].toCharArray();
			for (char c : str) {
				map[i][c - 'a']++;
			}
		}
		char[] str = target.toCharArray();
		int[] tmap = new int[26];
		for (char c : str) {
			tmap[c - 'a']++;
		}
		HashMap<String, Integer> dp = new HashMap<>();
		int ans = process2(map, 0, tmap, dp);
		return ans;
	}

	public static int process2(int[][] map, int i, int[] tmap, HashMap<String, Integer> dp) {
		StringBuilder keyBuilder = new StringBuilder();
		keyBuilder.append(i + "_");
		for (int asc = 0; asc < 26; asc++) {
			if (tmap[asc] != 0) {
				keyBuilder.append((char) (asc + 'a') + "_" + tmap[asc] + "_");
			}
		}
		String key = keyBuilder.toString();
		if (dp.containsKey(key)) {
			return dp.get(key);
		}
		boolean finish = true;
		for (int asc = 0; asc < 26; asc++) {
			if (tmap[asc] != 0) {
				finish = false;
				break;
			}
		}
		if (finish) {
			dp.put(key, 0);
			return 0;
		}
		if (i == map.length) {
			dp.put(key, -1);
			return -1;
		}
		int maxZhang = 0;
		for (int asc = 0; asc < 26; asc++) {
			if (map[i][asc] != 0 && tmap[asc] != 0) {
				maxZhang = Math.max(maxZhang, (tmap[asc] / map[i][asc]) + (tmap[asc] % map[i][asc] == 0 ? 0 : 1));
			}
		}
		int[] backup = Arrays.copyOf(tmap, tmap.length);
		int min = Integer.MAX_VALUE;
		int next = process2(map, i + 1, tmap, dp);
		tmap = Arrays.copyOf(backup, backup.length);
		if (next != -1) {
			min = next;
		}
		for (int zhang = 1; zhang <= maxZhang; zhang++) {
			for (int asc = 0; asc < 26; asc++) {
				tmap[asc] = Math.max(0, tmap[asc] - (map[i][asc] * zhang));
			}
			next = process2(map, i + 1, tmap, dp);
			tmap = Arrays.copyOf(backup, backup.length);
			if (next != -1) {
				min = Math.min(min, zhang + next);
			}
		}
		int ans = min == Integer.MAX_VALUE ? -1 : min;
		dp.put(key, ans);
		return ans;
	}
	
	public static void main(String[] args) {
		String[] arr = {"aaaa","bbaa","ccddd"};
		String str = "abcccccdddddbbbaaaaa";
		System.out.println(minStickers1(arr, str));
		System.out.println(minStickers2(arr, str));
		
		
	}

}

1.4 什麼暴力遞迴可以繼續優化?

1、有重複呼叫同一個子問題的解,這種遞迴可以優化

2、如果每一個子問題都是不同的解,無法優化也不用優化

1.5 暴力遞迴和動態規劃的關係

1、某個暴力遞迴,有解的重複呼叫,就可以把這個暴力遞迴優化成動態規劃

2、任何動態規劃問題,都一定對應著某一個有解的重複呼叫的暴力遞迴

3、不是所有的暴力遞迴,都一定對應著動態規劃

1.6 面試題中和動態規劃的關係

1、解決一個問題,可能有很多嘗試方法

2、可能在很多嘗試方法中,又有若干個嘗試方法有動態規劃的方式

3、一個問題,可能有若干種動態規劃的解法

1.7 如何找到某個問題的動態規劃方式?

1、設計暴力遞迴:重要原則+4中常見嘗試模型!重點

2、分析有沒有重複解:套路解決

3、用記憶化搜尋 -> 用嚴格表結構實現動態規劃:套路解決

4、看看能否繼續優化:套路解決

1.8 面試中設計暴力遞迴過程的原則

怎麼猜是不對的?

1、每一個可變引數,一定不要比int型別更加複雜

比如可變引數是int a和int b 那麼我們的快取結構可以是a*b的二維陣列。大小取決於a和b的範圍

但是如果我們的可變引數是一個陣列,int[] a。那麼如果過多,我們不太容易設計那麼大的快取結構。如果只有一個這種可變引數就是原則2

不管什麼問題,我們在腦海中想象可變引數就不會突破整形範圍

2、原則1可以違反,讓型別突破到一維線性結構,那必須是唯一可變引數。例如上述貼紙問題

3、如果發現原則1被違反,但沒違反原則2。只需要做到記憶化搜尋就是最優解

3、可變引數個數,能少則少

1.9 筆試面試過程中怎麼設計暴力遞迴?

1、一定逼自己找到不違反1.8原則情況下的暴力嘗試!

2、如果你找到暴力嘗試,不符合原則,馬上舍棄,找新的!

3、如果某個題目突破了設計原則,一定極難極難,面試中出現的概率低於5%

1.10 常見的4種嘗試模型

每一個模型背後都有很多題目

1.10.1 從左往右的嘗試模型

例如揹包問題

1.10.2 範圍上的嘗試模型

例如紙牌博弈問題

1.10.3 多樣本位置全對應的嘗試模型

1.10.3.1 兩個字串的最長公共子序列問題

例如“ab1cd2ef345gh”和“opq123rs4tx5yz”的最長公共子序列為“12345”。即在兩個字串所有相等的子序列裡最長的。所以返回子序列的長度5

假如"a123bc"和"12dea3fz"兩個字串,我們把這兩個樣本的下標一個作為行,一個作為列。觀察這樣的結構所表示的意義。dp表這樣設計就是str1從0到i位置,str2從0到j位置,兩個位置的最長公共子序列。

dp[i][j] 表示的含義是,str1字串在i位置,
str2在j位置,兩個最長公共子序列多長。
那麼str1和str2的最長公共子序列,就是dp[str1.length][str2.length]

對於任何位置dp[i][j]:

  1. 如果str1的i位置和str2的j位置的最長公共子序列跟str1的i位置字元和str2的j位置字元無關,那麼此時的最長公共子序列長度就是dp[i-1][j-1]
  1. 此時與str1的i位置結尾的字元有關係,和str2的j位置結尾的字元沒關係。此時跟str2的j位置沒關係,最長公共子序列式dp[i][j-1]
  1. 此時與str1的i位置結尾的字元沒關係,和str2的j位置結尾的字元有關係。此時跟str1的j位置沒關係,最長公共子序列式dp[i-1][j]
  1. 此時即與str1的i字元結尾,有關係。又與str2的j位置結尾,有關係。只有str1[i]==str2[j]才有可能存在這種情況,且為dp[i-1][j-1] + 1

    0    1    2    3    4    5    6    7   str2
0   0    0    0    0    1    1    1    1

1   0

2   1

3   1

4   1

5   1

str1

package class12;

public class Code05_PalindromeSubsequence {

	public static int lcse(char[] str1, char[] str2) {
		
		int[][] dp = new int[str1.length][str2.length];
		
		dp[0][0] = str1[0] == str2[0] ? 1 : 0;
		
		
		// 填第0列的所有值
		// 一旦st1r的i位置某個字元等於str2的0位置,那麼之後都是1
		for (int i = 1; i < str1.length; i++) {
			dp[i][0] = Math.max(dp[i - 1][0], str1[i] == str2[0] ? 1 : 0);
		}
		// 填第0行的所有值
		// 一旦str2的j位置某個字元等於str1的0位置,那麼之後都是1
		for (int j = 1; j < str2.length; j++) {
			dp[0][j] = Math.max(dp[0][j - 1], str1[0] == str2[j] ? 1 : 0);
		}
		
		for (int i = 1; i < str1.length; i++) {
			for (int j = 1; j < str2.length; j++) {
			
			        // dp[i - 1][j]表示可能性2
			        // dp[i][j - 1] 表示可能性3
				dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
				if (str1[i] == str2[j]) {
					dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1] + 1);
				} 
			}
		}
		return dp[str1.length - 1][str2.length - 1];
	}

	public static void main(String[] args) {

	}

}

1.10.4 尋找業務限制的嘗試模型

四種模型中最難的一種,暫時只看process方法,process更改為動態規劃的dp方法。後面會展開說明其他方法,

例題:給定一個陣列,代表每個人喝完咖啡準備刷杯子的時間,只有一臺咖啡機,一次只能洗一個杯子,時間耗費a,洗完才能洗下一杯。每個咖啡杯也可以自己揮發乾淨,時間耗費b,咖啡杯可以並行揮發。返回讓所有咖啡杯變乾淨的最早完成時間。三個引數:int[]arr , int a , int b(京東)

package class12;

import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;

// 題目
// 陣列arr代表每一個咖啡機衝一杯咖啡的時間,每個咖啡機只能序列的製造咖啡。
// 現在有n個人需要喝咖啡,只能用咖啡機來製造咖啡。
// 認為每個人喝咖啡的時間非常短,衝好的時間即是喝完的時間。
// 每個人喝完之後咖啡杯可以選擇洗或者自然揮發乾淨,只有一臺洗咖啡杯的機器,只能序列的洗咖啡杯。
// 洗杯子的機器洗完一個杯子時間為a,任何一個杯子自然揮發乾淨的時間為b。
// 四個引數:arr, n, a, b
// 假設時間點從0開始,返回所有人喝完咖啡並洗完咖啡杯的全部過程結束後,至少來到什麼時間點。
public class Code06_Coffee {

	// 方法一:暴力嘗試方法
	public static int minTime1(int[] arr, int n, int a, int b) {
		int[] times = new int[arr.length];
		int[] drink = new int[n];
		return forceMake(arr, times, 0, drink, n, a, b);
	}

	// 方法一,每個人暴力嘗試用每一個咖啡機給自己做咖啡
	public static int forceMake(int[] arr, int[] times, int kth, int[] drink, int n, int a, int b) {
		if (kth == n) {
			int[] drinkSorted = Arrays.copyOf(drink, kth);
			Arrays.sort(drinkSorted);
			return forceWash(drinkSorted, a, b, 0, 0, 0);
		}
		int time = Integer.MAX_VALUE;
		for (int i = 0; i < arr.length; i++) {
			int work = arr[i];
			int pre = times[i];
			drink[kth] = pre + work;
			times[i] = pre + work;
			time = Math.min(time, forceMake(arr, times, kth + 1, drink, n, a, b));
			drink[kth] = 0;
			times[i] = pre;
		}
		return time;
	}

	// 方法一,暴力嘗試洗咖啡杯的方式
	public static int forceWash(int[] drinks, int a, int b, int index, int washLine, int time) {
		if (index == drinks.length) {
			return time;
		}
		// 選擇一:當前index號咖啡杯,選擇用洗咖啡機刷乾淨
		int wash = Math.max(drinks[index], washLine) + a;
		int ans1 = forceWash(drinks, a, b, index + 1, wash, Math.max(wash, time));

		// 選擇二:當前index號咖啡杯,選擇自然揮發
		int dry = drinks[index] + b;
		int ans2 = forceWash(drinks, a, b, index + 1, washLine, Math.max(dry, time));
		return Math.min(ans1, ans2);
	}

	// 方法二:稍微好一點的解法
	public static class Machine {
		public int timePoint;
		public int workTime;

		public Machine(int t, int w) {
			timePoint = t;
			workTime = w;
		}
	}

	public static class MachineComparator implements Comparator<Machine> {

		@Override
		public int compare(Machine o1, Machine o2) {
			return (o1.timePoint + o1.workTime) - (o2.timePoint + o2.workTime);
		}

	}

	// 方法二,每個人暴力嘗試用每一個咖啡機給自己做咖啡,優化成貪心
	public static int minTime2(int[] arr, int n, int a, int b) {
		PriorityQueue<Machine> heap = new PriorityQueue<Machine>(new MachineComparator());
		for (int i = 0; i < arr.length; i++) {
			heap.add(new Machine(0, arr[i]));
		}
		int[] drinks = new int[n];
		for (int i = 0; i < n; i++) {
			Machine cur = heap.poll();
			cur.timePoint += cur.workTime;
			drinks[i] = cur.timePoint;
			heap.add(cur);
		}
		return process(drinks, a, b, 0, 0);
	}

	// 方法二,洗咖啡杯的方式和原來一樣,只是這個暴力版本減少了一個可變引數

	// process(drinks, 3, 10, 0,0)
	// a 洗一杯的時間 固定變數
	// b 自己揮發乾淨的時間 固定變數
	// drinks 每一個員工喝完的時間 固定變數
	// drinks[0..index-1]都已經乾淨了,不用你操心了
	// drinks[index...]都想變乾淨,這是我操心的,washLine表示洗的機器何時可用
	// drinks[index...]變乾淨,最少的時間點返回
	public static int process(int[] drinks, int a, int b, int index, int washLine) {
		if (index == drinks.length - 1) {
			return Math.min(Math.max(washLine, drinks[index]) + a, drinks[index] + b);
		}
		// 剩不止一杯咖啡
		// wash是我當前的咖啡杯,洗完的時間
		int wash = Math.max(washLine, drinks[index]) + a;// 洗,index一杯,結束的時間點
		// index+1...變乾淨的最早時間
		int next1 = process(drinks, a, b, index + 1, wash);
		// index....
		int p1 = Math.max(wash, next1);
		int dry = drinks[index] + b; // 揮發,index一杯,結束的時間點
		int next2 = process(drinks, a, b, index + 1, washLine);
		int p2 = Math.max(dry, next2);
		return Math.min(p1, p2);
	}

	public static int dp(int[] drinks, int a, int b) {
		if (a >= b) {
			return drinks[drinks.length - 1] + b;
		}
		// a < b
		int N = drinks.length;
		int limit = 0; // 咖啡機什麼時候可用
		for (int i = 0; i < N; i++) {
			limit = Math.max(limit, drinks[i]) + a;
		}
		int[][] dp = new int[N][limit + 1];
		// N-1行,所有的值
		for (int washLine = 0; washLine <= limit; washLine++) {
			dp[N - 1][washLine] = Math.min(Math.max(washLine, drinks[N - 1]) + a, drinks[N - 1] + b);
		}
		for (int index = N - 2; index >= 0; index--) {
			for (int washLine = 0; washLine <= limit; washLine++) {
				int p1 = Integer.MAX_VALUE;
				int wash = Math.max(washLine, drinks[index]) + a;
				if (wash <= limit) {
					p1 = Math.max(wash, dp[index + 1][wash]);
				}
				int p2 = Math.max(drinks[index] + b, dp[index + 1][washLine]);
				dp[index][washLine] = Math.min(p1, p2);
			}
		}
		return dp[0][0];
	}

	// 方法三:最終版本,把方法二洗咖啡杯的暴力嘗試進一步優化成動態規劃
	public static int minTime3(int[] arr, int n, int a, int b) {
		PriorityQueue<Machine> heap = new PriorityQueue<Machine>(new MachineComparator());
		for (int i = 0; i < arr.length; i++) {
			heap.add(new Machine(0, arr[i]));
		}
		int[] drinks = new int[n];
		for (int i = 0; i < n; i++) {
			Machine cur = heap.poll();
			cur.timePoint += cur.workTime;
			drinks[i] = cur.timePoint;
			heap.add(cur);
		}
		if (a >= b) {
			return drinks[n - 1] + b;
		}
		int[][] dp = new int[n][drinks[n - 1] + n * a];
		for (int i = 0; i < dp[0].length; i++) {
			dp[n - 1][i] = Math.min(Math.max(i, drinks[n - 1]) + a, drinks[n - 1] + b);
		}
		for (int row = n - 2; row >= 0; row--) { // row 咖啡杯的編號
			int washLine = drinks[row] + (row + 1) * a;
			for (int col = 0; col < washLine; col++) {
				int wash = Math.max(col, drinks[row]) + a;
				dp[row][col] = Math.min(Math.max(wash, dp[row + 1][wash]), Math.max(drinks[row] + b, dp[row + 1][col]));
			}
		}
		return dp[0][0];
	}

	// for test
	public static int[] randomArray(int len, int max) {
		int[] arr = new int[len];
		for (int i = 0; i < len; i++) {
			arr[i] = (int) (Math.random() * max) + 1;
		}
		return arr;
	}

	// for test
	public static void printArray(int[] arr) {
		System.out.print("arr : ");
		for (int j = 0; j < arr.length; j++) {
			System.out.print(arr[j] + ", ");
		}
		System.out.println();
	}

	public static void main(String[] args) {
		int[] test = { 1, 1, 5, 5, 7, 10, 12, 12, 12, 12, 12, 12, 15 };
		int a1 = 3;
		int b1 = 10;
		System.out.println(process(test, a1, b1, 0, 0));
		System.out.println(dp(test, a1, b1));

		int len = 5;
		int max = 9;
		int testTime = 50000;
		for (int i = 0; i < testTime; i++) {
			int[] arr = randomArray(len, max);
			int n = (int) (Math.random() * 5) + 1;
			int a = (int) (Math.random() * 5) + 1;
			int b = (int) (Math.random() * 10) + 1;
			int ans1 = minTime1(arr, n, a, b);
			int ans2 = minTime2(arr, n, a, b);
			int ans3 = minTime3(arr, n, a, b);
			if (ans1 != ans2 || ans2 != ans3) {
				printArray(arr);
				System.out.println("n : " + n);
				System.out.println("a : " + a);
				System.out.println("b : " + b);
				System.out.println(ans1 + " , " + ans2 + " , " + ans3);
				System.out.println("===============");
				break;
			}
		}

	}

}

1.11 如何分析有沒有重複解

1、列出呼叫過程,可以只列出前幾層

2、有沒有重複解,一看便知

1.12 暴力遞迴到動態規劃的套路

1、你已經有了一個不違反原則的暴力遞迴,而且的確存在重複呼叫

2、找到哪些引數的變化會影響返回值,對每一個列出變化範圍

3、引數間的所有組合數量,意味著快取表(DP)大小

4、記憶化搜尋的方法就是傻快取,非常容易得到

5、規定好嚴格的表大小,分析位置的依賴順序,然後從基礎填寫到最終解

6、對於有列舉行為的決策過程,進一步優化

1.13 動態規劃的進一步優化

主要就是用來優化動態規劃的單個位置的列舉行為

1、空間壓縮

2、狀態化簡

3、四邊形不等式

4、其他優化技巧

相關文章