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; }