【戀上數據結構】動態規劃(找零錢、最大連續子序列和、最長上升子序列、最長公共子序列、最長公共子串、0-1揹包)

數據結構與算法筆記戀上數據結構筆記目錄

動態規劃(Dynamic Programming),簡稱 DP

  • 是求解最優化問題的一種常用策略

通常的使用套路(一步一步優化):
① 暴力遞歸(自頂向下,出現了重疊子問題)
② 記憶化搜索(自頂向下
③ 遞推(自底向上

直接學習動態規劃的概念有點難以理解,先理解透一道例題再學習概念。

練習1:找零錢

leetcode_322_零錢兌換:https://leetcode-cn.com/problems/coin-change/

假設有25分、20分、5分、1分的硬幣,現要找給客戶41分的零錢,如何辦到硬幣個數最少

  • 此前用貪心策略得到的並非是最優解(貪心得到的解是 5 枚硬幣:25、5、5、5、1)

假設 dp(n)湊到 n 分需要的最少硬幣個數

  • 如果第 1 次選擇了 25 分的硬幣,那麼 dp(n) = dp(n - 25) + 1
  • 如果第 1 次選擇了 20 分的硬幣,那麼 dp(n) = dp(n - 20) + 1
  • 如果第 1 次選擇了 5 分的硬幣,那麼 dp(n) = dp(n - 5) + 1
  • 如果第 1 次選擇了 1 分的硬幣,那麼 dp(n) = dp(n - 1) + 1
  • 所以 dp(n) = min { dp(n - 25), dp(n - 20), dp(n - 5), dp(n - 1) } + 1

找零錢 - 暴力遞歸

類似於斐波那契數列的遞歸版,會有大量的重複計算,時間複雜度較高。

/**
 * 暴力遞歸(自頂向下的調用, 出現了重疊子問題)
 */
static int coins(int n) {
	// 遞歸基
	if (n < 1) return Integer.MAX_VALUE;
	if (n == 1 || n == 5 || n == 20 || n == 25) return 1; // 邊界情況
	
	// 求出四種取法的最小值
	int min1 = Math.min(coins(n - 1), coins(n - 5));
	int min2 = Math.min(coins(n - 20), coins(n - 25));
	return Math.min(min1, min2) + 1;
}

找零錢 - 記憶化搜索

static int coins(int n) {
	if (n < 1) return -1; // 處理非法數據
	int[] dp = new int[n + 1];
	int[] faces = { 1, 5, 20, 25 }; // 給定的面值數組
	
	for (int face : faces) {
		// 如果我要湊的錢是20元, 那麼我肯定用不到25元面值
		if (face > n) break; // 用不到的面值不用初始化
		dp[face] = 1; // 初始化可能用到的面值
	}
	return coins(n, dp);
}
static int coins(int n, int[] dp) {
	// 遞歸基
	if (n < 1) return Integer.MAX_VALUE;
	if (dp[n] == 0) { // 記憶化搜索, dp[n] == 0 表示以前沒有算過, 那便初始化一下
		int min1 = Math.min(coins(n - 5, dp), coins(n - 1, dp));
		int min2 = Math.min(coins(n - 25, dp), coins(n - 20, dp));
		dp[n] = Math.min(min1, min2) + 1;
	}
	return dp[n];
}

找零錢 - 遞推

/**
 * 遞推(自底向上)
 */
static int coins(int n) {
	if (n < 1) return -1; // 處理非法數據
	int[] dp = new int[n + 1];
	// 自底向上的遞推
	for (int i = 1; i <= n; i++) {
		int min = Integer.MAX_VALUE;
		if (i >= 1) min = Math.min(min, dp[i - 1]);
		if (i >= 5) min = Math.min(min, dp[i - 5]);
		if (i >= 20) min = Math.min(min, dp[i - 20]);
		if (i >= 25) min = Math.min(min, dp[i - 25]);
		dp[i] = min + 1;
	}
	return dp[n];
}

可以修改一下寫法:

static int coins(int n) {
	if (n < 1) return -1; // 處理非法數據
	int[] dp = new int[n + 1];
	// 遞推(自底向上)過程
	for (int i = 1; i <= n; i++) {
		int min = dp[i - 1]; // 由於下面兩行是必然執行的, 直接這麼寫就行了
		// int min = Integer.MAX_VALUE;
		// if (i >= 1) min = Math.min(min, dp[i - 1]);
		if (i >= 5) min = Math.min(min, dp[i - 5]);
		if (i >= 20) min = Math.min(min, dp[i - 20]);
		if (i >= 25) min = Math.min(min, dp[i - 25]);
		dp[i] = min + 1;
	}
	return dp[n];
}

思考題:請輸出找零錢的具體方案(具體是用了哪些面值的硬幣)

static int coins4(int n) {
	if (n < 1) return -1; // 處理非法數據
	int[] dp = new int[n + 1];
	// faces[i]是湊夠i分時最後選擇的那枚硬幣的面值
	int[] faces = new int[dp.length]; // 存放硬幣面值(爲了輸出)
	for (int i = 1; i <= n; i++) {
		int min = Integer.MAX_VALUE;
		if (i >= 1 && dp[i - 1] < min) {
			min = dp[i - 1];
			faces[i] = 1;
		}
		// 上面一步其實必然執行, 可以直接寫成下面這樣
		// int min = dp[i - 1];
		// faces[i] = 1;
		if (i >= 5 && dp[i - 5] < min) {
			min = dp[i - 5];
			faces[i] = 5;
		}
		if (i >= 20 && dp[i - 20] < min) {
			min = dp[i - 20];
			faces[i] = 20;
		}
		if (i >= 25 && dp[i - 25] < min) {
			min = dp[i - 25];
			faces[i] = 25;
		}
		dp[i] = min + 1;
		print(faces, i); // 打印湊夠面值 1 ~ n 的方案
	}
	// print(faces, n); // 打印湊夠面值 n 的方案
	return dp[n];
}
// 打印湊夠面值 n 的方案
static void print(int[] faces, int n) {
	System.out.print("[" + n + "] = ");
	while (n > 0) {
		System.out.print(faces[n] + " ");
		n -= faces[n];
	}
	System.out.println();
}

嘗試打印了 n = 41 的情況,打印出了湊夠 1~41 所有面值的情況:

[1] = 1 
[2] = 1 1 
[3] = 1 1 1 
[4] = 1 1 1 1 
[5] = 5 
[6] = 1 5 
[7] = 1 1 5 
[8] = 1 1 1 5 
[9] = 1 1 1 1 5 
[10] = 5 5 
[11] = 1 5 5 
[12] = 1 1 5 5 
[13] = 1 1 1 5 5 
[14] = 1 1 1 1 5 5 
[15] = 5 5 5 
[16] = 1 5 5 5 
[17] = 1 1 5 5 5 
[18] = 1 1 1 5 5 5 
[19] = 1 1 1 1 5 5 5 
[20] = 20 
[21] = 1 20 
[22] = 1 1 20 
[23] = 1 1 1 20 
[24] = 1 1 1 1 20 
[25] = 25 
[26] = 1 25 
[27] = 1 1 25 
[28] = 1 1 1 25 
[29] = 1 1 1 1 25 
[30] = 5 25 
[31] = 1 5 25 
[32] = 1 1 5 25 
[33] = 1 1 1 5 25 
[34] = 1 1 1 1 5 25 
[35] = 5 5 25 
[36] = 1 5 5 25 
[37] = 1 1 5 5 25 
[38] = 1 1 1 5 5 25 
[39] = 1 1 1 1 5 5 25 
[40] = 20 20 
[41] = 1 20 20 
3

找零錢 - 通用實現

public static void main(String[] args) {
	System.out.println(coins5(41, new int[]{1, 5, 20, 25})); // 3
}

static int coins(int n, int[] faces) {
	if (n < 1 || faces == null || faces.length == 0 ) return -1;
	int[] dp = new int [n + 1];
	for (int i = 1; i <= n; i++) {
		int min = Integer.MAX_VALUE;
		for (int face : faces) {
			// 假如給我的面值是20, 要湊的是15, 則跳過此輪循環
			if (face > i) continue; // 如果給我的面值比我要湊的面值還大, 跳過此輪循環
			min = Math.min(dp[i - face], min);
		}
		dp[i] = min + 1;
	}
	return dp[n];
}

改進,如果不能湊成則返回 -1:

static int coins5(int n, int[] faces) {
	if (n < 1 || faces == null || faces.length == 0 ) return -1;
	int[] dp = new int [n + 1];
	for (int i = 1; i <= n; i++) {
		int min = Integer.MAX_VALUE;
		for (int face : faces) {
			// 假如給我的面值是20, 要湊的是15, 則跳過此輪循環
			if (face > i) continue; // 如果給我的面值比我要湊的面值還大, 跳過此輪循環
			// 比如給的面值是{4}, 要湊的是6, 先給出一張4, 再看6-4=2, 是否能湊成
			// 2無法湊成, 則跳過此輪循環	 
			int v = dp[i - face];
			if (v < 0 || v >= min) continue;
			min = v;
		}
		// 說明上面的循環中每次都是continue, 要湊的面值比給定的所有面值小
		if (min == Integer.MAX_VALUE) {
			dp[i] = -1;
		} else {
			dp[i] = min + 1;
		}
	}
	return dp[n];
}

動態規劃(Dynamic Programming)

動態規劃的常規

動態規劃,簡稱 DP

  • 是求解最優化問題的一種常用策略

通常的使用套路(一步一步優化):
① 暴力遞歸(自頂向下,出現了重疊子問題)
② 記憶化搜索(自頂向下
③ 遞推(自底向上

動態規劃中的 “動態” 可以理解爲是 “會變化的狀態”

  • 定義狀態狀態是原問題、子問題的解
    比如定義 dp(i)dp(i) 的含義
  • 設置初始狀態邊界
    比如設置 dp(0)dp(0) 的值
  • 確定狀態轉移方程
    比如確定 dp(i)dp(i)dp(i1)dp(i - 1) 的關係

動態規劃的一些概念

維基百科的解釋

Dynamic Programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems, solving each of those subproblems just once, and storing their solutions.

① 將複雜的原問題拆解成若干個簡單的子問題
② 每個子問題僅僅解決1次,並保存它們的解
③ 最後推導出原問題的解

可以用動態規劃來解決的問題,通常具備2個特點:

  • 最優子結構最優化原理):通過求解子問題的最優解,可以獲得原問題的最優解
  • 無後效性
    某階段的狀態一旦確定,則此後過程的演變不再受此前各狀態及決策的影響(未來與過去無關
    在推導後面階段的狀態時,只關心前面階段的具體狀態值,不關心這個狀態是怎麼一步步推導出來的

有後效性與無後效性

首先了解一下什麼是有後效性
在這裏插入圖片描述
然後再去理解什麼是無後效性
在這裏插入圖片描述

練習2:最大連續子序列和

題目:給定一個長度爲 n 的整數序列,求它的最大連續子序列和

  • 比如 -2、1、-3、4、-1、2、1、-5、4 的最大連續子序列和是 4 + (-1) + 2 + 1 = 6;
    nums[0] -2 結尾的最大連續子序列是 -2,所以 dp(0)=2dp(0) = -2
    nums[1] 1 結尾的最大連續子序列是 1,所以 dp(1)=1dp(1) = 1
    nums[2] -3 結尾的最大連續子序列是 1、-3,所以 dp(2)=dp(1)+(3)=2dp(2) = dp(1) + (-3) = -2
    nums[3] 4 結尾的最大連續子序列是 4,所以 dp(3)=4dp(3) = 4
    nums[4] -1 結尾的最大連續子序列是 4、-1,所以 dp(4)=dp(3)+(1)=3dp(4) = dp(3) + (-1) = 3
    nums[5] 2 結尾的最大連續子序列是 4、-1、2,所以 dp(5)=dp(4)+2=5dp(5) = dp(4) + 2 = 5
    nums[6] 1 結尾的最大連續子序列是 4、-1、2、1,所以 dp(6)=dp(5)+1=6dp(6) = dp(5) + 1 = 6
    nums[7] -5 結尾的最大連續子序列是 4、-1、2、1、-5,所以 dp(7)=dp(6)+(5)=1dp(7) = dp(6) + (-5) = 1
    nums[8] 4 結尾的最大連續子序列是 4、-1、2、1、-5、4,所以 dp(8)=dp(7)+4=5dp(8) = dp(7) + 4 = 5

狀態轉移方程和初始狀態
狀態轉移方程

  • 如果 dp(i1)0dp(i – 1) ≤ 0,那麼 dp(i)=nums[i]dp(i) = nums[i]
  • 如果 dp(i1)>0dp(i – 1) > 0,那麼 dp(i)=dp(i1)+nums[i]dp(i) = dp(i – 1) + nums[i]

初始狀態

  • dp(0)dp(0) 的值是 nums[0]nums[0]

最終的解

  • 最大連續子序列和是所有 dp(i)dp(i) 中的最大值 max{dp(i)}i[0,nums.length)max \{ dp(i) \},i ∈ [0, nums.length)

最大連續子序列和 – 動態規劃 – 實現

static int maxSubArray(int[] nums) {
	if (nums == null || nums.length == 0) return 0;
	int[] dp = new int[nums.length];
	int max = dp[0] = nums[0];
	for (int i = 1; i < dp.length; i++) {
		if (dp[i - 1] > 0) {
			dp[i] = dp[i - 1] + nums[i];
		} else {
			dp[i] = nums[i];
		}
		max = Math.max(max, dp[i]);
	}
	return max;
}

最大連續子序列和 – 動態規劃 – 優化

static int maxSubArray(int[] nums) {
	if (nums == null || nums.length == 0) return 0;
	int dp = nums[0];
	int max = dp;
	for (int i = 1; i < nums.length; i++) {
		if (dp > 0) {
			dp = dp + nums[i];
		} else {
			dp = nums[i];
		}
		max = Math.max(max, dp);
	}
	return max;
}

練習3:最長上升子序列(LIS)

最長上升子序列最長遞增子序列,Longest Increasing Subsequence,LIS)

leetcode_300_最長上升子序列: https://leetcode-cn.com/problems/longest-increasing-subsequence/

題目:給定一個無序的整數序列,求出它最長上升子序列的長度(要求嚴格上升)

  • 比如 [10, 2, 2, 5, 1, 7, 101, 18] 的最長上升子序列是 [2, 5, 7, 101]、[2, 5, 7, 18],長度是 4

假設數組是 nums, [10, 2, 2, 5, 1, 7, 101, 18]

  • dp(i)dp(i) 是以 nums[i]nums[i] 結尾的最長上升子序列的長度,i[0,nums.length)i ∈ [0, nums.length)
    nums[0] 10 結尾的最長上升子序列是 10,所以 dp(0)=1dp(0) = 1
    nums[1] 2 結尾的最長上升子序列是 2,所以 dp(1)=1dp(1) = 1
    nums[2] 2 結尾的最長上升子序列是 2,所以 dp(2)=1dp(2) = 1
    nums[3] 5 結尾的最長上升子序列是 25,所以 dp(3)=dp(1)+1=dp(2)+1=2dp(3) = dp(1) + 1 = dp(2) + 1 = 2
    nums[4] 1 結尾的最長上升子序列是 1,所以 dp(4)=1dp(4) = 1
    nums[5] 7 結尾的最長上升子序列是 257,所以 dp(5)=dp(3)+1=3dp(5) = dp(3) + 1 = 3
    nums[6] 101 結尾的最長上升子序列是 257101,所以 dp(6)=dp(5)+1=4dp(6) = dp(5) + 1 = 4
    nums[7] 18 結尾的最長上升子序列是 25718,所以 dp(7)=dp(5)+1=4dp(7) = dp(5) + 1 = 4

狀態方程
在這裏插入圖片描述
狀態的初始值

  • dp(0)=1dp(0) = 1
  • 所有的 dp(i)dp(i) 默認都初始化爲 1

最終的解

  • 最長上升子序列的長度是所有 dp(i)dp(i) 中的最大值 max{dp(i)}i[0,nums.length)max \{ dp(i) \},i ∈ [0, nums.length)

最長上升子序列 – 動態規劃 – 實現

時間複雜度:O(n2),空間複雜度:O(n)

static int lengthOfLIS(int[] nums) {
  	if (nums == null || nums.length == 0) return 0;
  	int[] dp = new int[nums.length];
  	int max = dp[0] = 1; // 只有一個元素則長度爲1
  	for (int i = 1; i < dp.length; i++) {
  		dp[i] = 1; // 默認只有一個元素時長度爲1
  		for (int j = 0; j < i; j++) {
  			// 無法成爲一個上升子序列
  			if (nums[j] >= nums[i]) continue;
  			dp[i] = Math.max(dp[j] + 1, dp[i]);
  		}
  		max = Math.max(dp[i], max);
  	}
  	return max;
  }

最長上升子序列 – 二分搜索 – 實現

mark

練習4 – 最長公共子序列(LCS)

最長公共子序列(Longest Common Subsequence,LCS)

leetcode_1143_最長公共子序列:https://leetcode-cn.com/problems/longest-common-subsequence/

題目:求兩個序列的最長公共子序列長度

  • [1, 3, 5, 9, 10][1, 4, 9, 10] 的最長公共子序列是 [1, 9, 10],長度爲 3
  • ABCBDABBDCABA 的最長公共子序列長度是 4,可能是
    ABCBDABBDCABA > BDAB
    ABCBDABBDCABA > BDAB
    ABCBDABBDCABA > BCAB
    ABCBDAB 和 BDCABA > BCBA

思路
在這裏插入圖片描述

最長公共子序列 – 遞歸實現

  • 空間複雜度:O(k) , k = min{n, m},n、m 是 2 個序列的長度
  • 時間複雜度:O(2n) ,當 n = m 時
/**
 * 遞歸實現
 */
static int lcs(int[] nums1, int[] nums2) {
	if (nums1 == null || nums1.length == 0) return 0; // 檢測非法數據
	if (nums2 == null || nums2.length == 0) return 0; // 檢測非法數據
	return lcs(nums1, nums1.length, nums2, nums2.length);
}
/**
 * 求nums1前i個元素和nums2前j個元素的最長g公共子序列長度
 * @param nums1
 * @param i
 * @param nums2
 * @param j
 */
static int lcs(int[] nums1, int i, int[] nums2, int j) {
	if (i == 0 || j == 0) return 0;
	// 最後一個元素相等, 返回前面的公共子序列長度 + 1
	if (nums1[i - 1] == nums2[j - 1]) {
		return lcs(nums1, i - 1, nums2, j - 1) + 1;
	}
	return Math.max(
		lcs(nums1, i - 1, nums2, j), 
		lcs(nums1, i, nums2, j - 1)
	);
}

在這裏插入圖片描述

最長公共子序列 – 非遞歸實現

在這裏插入圖片描述

  • 空間複雜度:O(n ∗ m)
  • 時間複雜度:O(n ∗ m)
static int lcs(int[] nums1, int[] nums2) {
	if (nums1 == null || nums1.length == 0) return 0;
	if (nums2 == null || nums2.length == 0) return 0;
	int[][] dp = new int[nums1.length + 1][nums2.length + 1];
	
	for (int i = 1; i <= nums1.length; i++) {
		for (int j = 1; j <= nums2.length; j++) {
			if (nums1[i - 1] == nums2[j - 1]) {
				dp[i][j] = dp[i - 1][j - 1] + 1;
			} else {
				dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
			}
		}
	}
	return dp[nums1.length][nums2.length];
}

最長公共子序列 – 非遞歸實現 – 滾動數組優化

可以使用滾動數組化空間複雜度

/**
 * 非遞歸實現(滾動數組優化)
 */
static int lcs(int[] nums1, int[] nums2) {
	if (nums1 == null || nums1.length == 0) return 0;
	if (nums2 == null || nums2.length == 0) return 0;
	int[][] dp = new int[2][nums2.length + 1];
	
	for (int i = 1; i <= nums1.length; i++) {
		int row = i & 1;
		int prevRow = (i - 1) & 1;
		for (int j = 1; j <= nums2.length; j++) {
			if (nums1[i - 1] == nums2[j - 1]) {
				dp[row][j] = dp[prevRow][j - 1] + 1;
			} else {
				dp[row][j] = Math.max(dp[prevRow][j], dp[row][j - 1]);
			}
		}
	}
	return dp[nums1.length & 1][nums2.length];
}

最長公共子序列 – 非遞歸實現 – 一維數組


練習5 – 最長公共子串

練習6 – 0-1揹包

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章