1. 題目
- 爬樓梯:假設需要n階能夠爬到頂樓,每一次只能爬1階或者2階,求問,有多少種不同的方法爬上樓頂? https://leetcode-cn.com/problems/climbing-stairs/description/
- 三角形最小路徑和:https://leetcode-cn.com/problems/triangle/description/
- 乘積最大子序列:https://leetcode-cn.com/problems/maximum-product-subarray/description/
- 最長上升子序列:https://leetcode-cn.com/problems/longest-increasing-subsequence/
2. 基本知識
2.1 動態規劃
動態規劃和分治類似,但他們有異同點:
- 都是通過組合子問題的方式來解決原始問題
- 動態規劃的子問題是會重合的,而分治的子問題則是不相交的
- 不同的子問題中會有一些公共的子問題,動態規劃不會重複計算子問題,而分治則會重複計算,做更多的不必要工作
2.2 動態規劃的核心
使用動態規劃都可以使用遞歸(分治)來完成,在遞歸的基礎上加上記憶化即可去除重複的計算,推導出最優解的方程(當前解和之前已經算出來的解之間的關係)來得出結果。
- 遞歸 + 記憶化 -> 遞推(從小到大計算)
- 狀態的定義,用數組輔助存儲已計算的值,避免多次計算
- 狀態轉移方程:opt[n] = best_of(opt[n-1], opt[n-2],…)
- 最優子結構
2.3 簡單的例子
2.3.1 斐波那契數列
計算斐波那契數列的和: 0,1,2,3,5,8…
-
遞歸算法
時間複雜度 O(2n),空間複雜度O(1)
private int fib(int n) { return (n <= 1) ? n : fib(n - 1) + fib(n - 2); }
-
動態規劃
時間複雜度O(n),空間複雜度O(n)
private int dpFib(int n) { int[] memo = new int[n]; memo[0] = 0; memo[1] = 1; for (int i = 2; i < n; i++) { // 從小到大遞推,如果從大到小,又是一堆重複計算 memo[i] = memo[i - 1] + memo[i - 2]; } return memo[n-1]; }
2.3.2 障礙棋盤路徑
給定一個棋盤,從最左上角到最右下角有多少條路徑,棋盤中有些格子中有石頭無法通過。
-
遞歸解法
private int paths(boolean[][] isStone, int m, int n) { if (isStone[m][n]) return 0; if (m == 0 || n == 0) return 1; return paths(isStone, m - 1, n) + paths(isStone, m, n - 1); }
-
動態規劃
private int dpPaths(boolean[][] isStone, int m, int n) { int[][] opt = new int[m + 1][n + 1]; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { // opt[i,j] = opt[i-1,j] + opt[i,j-1] if (i == 0 || j == 0) { opt[i][j] = 1; continue; } if (isStone[i][j]) { //碰到石頭 opt[i][j] = 0; } else { //可以通過 opt[i][j] = opt[i - 1][j] + opt[i][j - 1]; } } } return opt[m][n]; }
3. 算法題解
3.1 爬樓梯
假設需要n階能夠爬到頂樓,每一次只能爬1階或者2階,求問,有多少種不同的方法爬上樓頂?
分析:由於每次只能爬1階或者2階,所以,從站在樓頂那一刻倒推,倒推1階,假設有f(n-1)種辦法,倒推2階,假設有f(n-2)種辦法,則,f(n) = f(n-1) + f(n-2),這是一個標準的斐波那契數列的和解法。見 2.3.1 斐波那契數列
3.2 三角形最小路徑
給定一個三角形,找出自頂向下的最小路徑和,每一步只能移動到下一行的相鄰節點上(角標:(i+1,j+1);(i+1,j))
例如:
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
得到的結果爲:2+3+5+1 = 11
解法1:遞歸
當前節點值加上下面兩個分支的最小值,加起來總和爲最小路徑和。
該解法時間複雜度:O(2n),空間複雜度爲O(1)
private int getResult(int[][] num) {
return dfsSum(num, 0, 0);
}
private int dfsSum(int[][] num, int m, int n) {
if (m >= num.length || n >= num[0].length) return 0;
int min = Math.min(dfsSum(num, m + 1, n), dfsSum(num, m + 1, n + 1));
return num[m][n] + min;
}
解法2:動態規劃
最後一行分別賦值給初始最小路徑節點值,然後根據公式,sum[i][j] = Math.min(sum[i+1][j], sum[i+1][j+1]) + num[i][j];
層層倒推,得到sum[0][0]即爲當前的解。
private int dpSum(int[][] num) {
int row = num.length;
int col = num[num.length - 1].length;
int[][] sum = new int[row][col];
for (int i = row-1; i >=0; i--) {
for (int j = 0; j < num[i].length; j++) {
if (i == row - 1) {
// 最後一行
sum[i][j] = num[i][j];
continue;
}
// 下一行相鄰兩個路徑的最小值加上當前節點值
sum[i][j] = Math.min(sum[i+1][j], sum[i+1][j+1]) + num[i][j];
}
}
return sum[0][0];
}
上面使用的是二維數組來存放中間計算的值,空間複雜度爲O(n2),下面優化爲O(n)
private int dpSum2(List<List<Integer>> triangle) {
int row = triangle.size();
// 初始化一個一維數組,用來輔助存儲單次循環內的路徑和
int[] dp = new int[row];
for (int i = 0; i < row; i++) {
// 將最後一行的值賦值爲初始值
dp[i] = triangle.get(row - 1).get(i);
}
for (int i = row - 2; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
// 將路徑和更新
dp[j] = triangle.get(i).get(j) + Math.min(dp[i], dp[i + 1]);
}
}
return dp[0];
}
3.3 乘積最大子序列
給定一個數組nums,找出一個序列中乘積最大的連續子序列(該序列至少包含一位數)
示例:
輸入:[2,3,-2,4]
輸出:6
解釋:子數組[2,3]有最大乘積
解題思路:
動態規劃兩個核心步驟:
-
定義狀態:使用二維數組存儲子乘積的最大和最小值
-
定義狀態方程:如果當前數大於0:取最大數*當前數,得最大子乘積;如果當前數小於0,則取最小數,乘以當前數,得最小數。
private int maxProduct(int[] nums) {
//第二列爲了表示是最大還是最小值,第一行爲最大值,第二行爲最小值
int[][] dp = new int[2][2];
//初始化最大最小值
dp[0][0] = dp[0][1] = nums[0];int max = 0; for (int i = 1; i < nums.length; i++) { int x = i % 2; int y = (i - 1) % 2; // 最大值 dp[x][0] = Math.max(dp[y][0] * nums[i], dp[y][1] * nums[i]); // 最小值 dp[x][1] = Math.min(dp[y][0] * nums[i], dp[y][1] * nums[i]); max = Math.max(max, dp[x][0]); } return max;
}
也可以只定義兩個變量代替數組:
private int maxProduct2(int[] nums) {
int icMax = nums[0];
int icMin = nums[0];
for (int i = 1; i < nums.length; i++) {
icMax = max(icMax * nums[i], icMax * nums[i], icMax);
icMin = min(icMax * nums[i], icMax * nums[i], icMin);
}
return icMax;
}
3.4 最長上升子序列
給定一個無序的整數數組,找到其中最長上升子序列的長度。
示例:
輸入:[10,9,2,5,3,7,101,18]
輸出:4
解釋:最長子序列:[2,3,7,101]
解法1: 遞歸
從頭開始遞歸,如果當前節點比上一個節點值大,那麼就 + 1,否則繼續遞歸,直到遍歷完成爲止。
該解法時間複雜度:O(2n),空間複雜度O(1)
private int lengthOfLIS(int[] nums) {
return lengthofLIS(nums, Integer.MIN_VALUE, 0);
}
private int lengthofLIS(int[] nums, int pre, int cur) {
if (cur == nums.length) return 0;
int taken = 0;
if (pre < nums[cur]) {
//當前節點是有效的上升子序列的一個
taken = 1 + lengthofLIS(nums, nums[cur], cur + 1);
}
// 當前節點不是上升子序列中的節點
int noTaken = lengthofLIS(nums, pre, cur + 1);
return Math.max(taken, noTaken);
}
解法2: 動態規劃
動態規劃核心步驟:
-
定義狀態:使用一個一位數組來存放當前升序的最大子序列長度
-
定義狀態方程:如果當前值和之前的所有元素遍歷對比,當前值較大,且該子序列是前面所有子序列長度的最大值,那麼+1就是當前的最大子序列的長度。
private int lengthOfLIS(int[] nums) { if (nums.length == 0) return 0; int[] dp = new int[nums.length]; dp[0] = 1; int maxas = 1; for (int i = 1; i < nums.length; i++) { int maxval = 0; for (int j = 0; j < i; j ++) { if (nums[i] > nums[j]) { maxval = Math.max(maxval, dp[j]); } } dp[i] = maxval + 1; maxas = Math.max(maxas, dp[i]); } return maxas; }