1. 什么是动态规划
动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
1.1 斐波那契数列Fibonacci Sequence
// 递归求斐波那契数列
public class Solution1 {
//记录fib函数调用的时间
private int num = 0;
public int fib(int n) {
num++;
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
public int getNum() {
return num;
}
//public static void main(String[] args) {
// int n = 42;
// Solution1 solution = new Solution1();
// long startTime = System.currentTimeMillis();
// int res = solution.fib(n);
// long endTime = System.currentTimeMillis();
// System.out.println("fib(" + n + ") = " + res);
// System.out.println("time : " + (endTime - startTime) + " ms");
// System.out.println("run function fib() " + solution.getNum() + " times.");
// }
}
//fib(42) = 267914296
//time : 1685 ms
//run function fib() 866988873 times.
1.2 为什么这么慢
大量的重复计算,比如计算5的时候,2这个数字重复计算了3次
算法复杂度是指数级别的
1.3 记忆化搜索 - 自上向下的解决问题
增加缓存即可解决大量重复运算问题,每个数字实际上只计算了一次,其他的通过查询缓存数组的到
// 记忆化搜索
public class Solution2 {
private int num = 0;
public int fib(int n) {
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
return fib(n, memo);
}
private int fib(int n, int[] memo) {
num++;
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
//首先去缓存里面找,找不到才进行递归调用
if (memo[n] == -1){
memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
}
return memo[n];
}
public int getNum() {
return num;
}
//public static void main(String[] args) {
// //int n = 42;
// int n = 1000; // 注意: 我们使用n = 1000只是为了测试性能, 实际上会溢出
// // 斐波那契额数列是以指数速度上涨的
// Solution2 solution = new Solution2();
// long startTime = System.currentTimeMillis();
// int res = solution.fib(n);
// long endTime = System.currentTimeMillis();
// System.out.println("fib(" + n + ") = " + res);
// System.out.println("time : " + (endTime - startTime) + " ms");
// System.out.println("run function fib() " + solution.getNum() + " times.");
//}
}
//fib(1000) = 1556111435
//time : 0 ms
//run function fib() 1999 times.
1.4 动态规划 - 自下向上的解决问题
// 动态规划
public class Solution3 {
public int fib(int n) {
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
memo[0] = 0;
memo[1] = 1;
for (int i = 2; i <= n; i++){
memo[i] = memo[i - 1] + memo[i - 2];
}
return memo[n];
}
//public static void main(String[] args) {
// //int n = 42;
// int n = 1000; // 注意: 我们使用n = 1000只是为了测试性能, 实际上会溢出
// // 斐波那契额数列是以指数速度上涨的
// Solution3 solution = new Solution3();
// long startTime = System.currentTimeMillis();
// int res = solution.fib(n);
// long endTime = System.currentTimeMillis();
// System.out.println("fib(" + n + ") = " + res);
// System.out.println("time : " + (endTime - startTime) + " ms");
// }
}
//fib(1000) = 1556111435
//time : 0 ms
509. 斐波那契数
// 动态规划
public class Solution {
public int fib(int n) {
if(n == 0){
return 0;
}
if(n == 1){
return 1;
}
int a;
int b = 0;
int c = 1;
// a b c
// b c a
// c a b
for (int i = 2; i <= n; i++) {
a = b + c;
b = c;
c = a;
}
return c;
}
}
70. 爬楼梯
自上向下思考
重复求解,记忆化搜索
public class Solution {
private int[] memo;
public int climbStairs(int n) {
memo = new int[n + 1];
Arrays.fill(memo, -1);
return calcWays(n);
}
private int calcWays(int n) {
//只有一阶台阶了
if (n == 1){
return 1;
}
//两阶台阶
if(n == 2){
return 2;
}
if (memo[n] == -1){
memo[n] = calcWays(n - 1) + calcWays(n - 2);
}
return memo[n];
}
}
如果这个楼梯一个台阶都没有,这个时候有一种可能来走这个楼梯
n == 0 作为递归的终止条件
这个时候这个问题就是一个 斐波那契数 问题了
public class Solution {
private int[] memo;
public int climbStairs(int n) {
memo = new int[n + 1];
Arrays.fill(memo, -1);
return calcWays(n);
}
private int calcWays(int n) {
if (n == 0 || n == 1){
return 1;
}
if (memo[n] == -1){
memo[n] = calcWays(n - 1) + calcWays(n - 2);
}
return memo[n];
}
//public static void main(String[] args) {
// System.out.println((new Solution()).climbStairs(10));
//}
}
动态规划解决
/// 70. Climbing Stairs
/// https://leetcode.com/problems/climbing-stairs/description/
/// 动态规划
/// 时间复杂度: O(n)
/// 空间复杂度: O(n)
public class Solution {
public int climbStairs(int n) {
int[] memo = new int[n + 1];
memo[0] = 1;
memo[1] = 1;
for (int i = 2; i <= n; i++){
memo[i] = memo[i - 1] + memo[i - 2];
}
return memo[n];
}
//public static void main(String[] args) {
// System.out.println((new Solution()).climbStairs(10));
//}
}
问题拆解:
我们到达第 n 个楼梯可以从第 n - 1 个楼梯和第 n - 2 个楼梯到达,因此第 n 个问题可以拆解成第 n - 1 个问题和第 n - 2 个问题,第 n - 1 个问题和第 n - 2 个问题又可以继续往下拆,直到第 0 个问题,也就是第 0 个楼梯 (起点)
状态定义
“问题拆解” 中已经提到了,第 n 个楼梯会和第 n - 1 和第 n - 2 个楼梯有关联,那么具体的联系是什么呢?你可以这样思考,第 n - 1 个问题里面的答案其实是从起点到达第 n - 1 个楼梯的路径总数,n - 2 同理,从第 n - 1 个楼梯可以到达第 n 个楼梯,从第 n - 2 也可以,并且路径没有重复,因此我们可以把第 i 个状态定义为 “从起点到达第 i 个楼梯的路径总数”,状态之间的联系其实是相加的关系。
递推方程
“状态定义” 中我们已经定义好了状态,也知道第 i 个状态可以由第 i - 1 个状态和第 i - 2 个状态通过相加得到,因此递推方程就出来了
dp[i] = dp[i - 1] + dp[i - 2]
实现
你其实可以从递推方程看到,我们需要有一个初始值来方便我们计算,起始位置不需要移动
dp[0] = 0
,第 1 层楼梯只能从起始位置到达,因此dp[1] = 1
,第 2 层楼梯可以从起始位置和第 1 层楼梯到达,因此dp[2] = 2
,有了这些初始值,后面就可以通过这几个初始值进行递推得到。
public int climbStairs(int n) {
if (n == 1) {
return 1;
}
int[] dp = new int[n + 1]; // 多开一位,考虑起始位置
dp[0] = 0; dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
根本不需要这么多的辅助空间
/// 70. Climbing Stairs
/// https://leetcode.com/problems/climbing-stairs/description/
/// 动态规划
/// 时间复杂度: O(n)
/// 空间复杂度: O(1)
public class Solution {
public int climbStairs(int n) {
int a;
int b = 1;
int c = 1;
// a b c
// b c a
// c a b
for (int i = 2; i <= n; i++) {
a = b + c;
b = c;
c = a;
}
return c;
}
//public static void main(String[] args) {
// //89
// System.out.println((new Solution()).climbStairs(10));
//}
}
120. 三角形最小路径和
注意每一步只能移动到相邻的格子(比如第二行的3只能移动到6 5 不能移动到7那里去)
问题拆解:
这里的总问题是求出最小的路径和,路径是这里的分析重点,路径是由一个个元素组成的,和之前爬楼梯那道题目类似,
[i][j]
位置的元素,经过这个元素的路径肯定也会经过[i - 1][j]
或者[i - 1][j - 1]
,因此经过一个元素的路径和可以通过这个元素上面的一个或者两个元素的路径和得到。状态定义
状态的定义一般会和问题需要求解的答案联系在一起,这里其实有两种方式,一种是考虑路径从上到下,另外一种是考虑路径从下到上,因为元素的值是不变的,所以路径的方向不同也不会影响最后求得的路径和,如果是从上到下,你会发现,在考虑下面元素的时候,起始元素的路径只会从
[i - 1][j]
获得,每行当中的最后一个元素的路径只会从[i - 1][j - 1]
获得,中间二者都可,这样不太好实现,因此这里考虑从下到上的方式,状态的定义就变成了 “最后一行元素到当前元素的最小路径和”,对于[0][0]
这个元素来说,最后状态表示的就是我们的最终答案。递推方程
“状态定义” 中我们已经定义好了状态,递推方程就出来了
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
实现
这里初始化时,我们需要将最后一行的元素填入状态数组中,然后就是按照前面分析的策略,从下到上计算即可
class Solution {
/**
* [i][j] [i + 1][j] [i + 1][j - 1]
* 状态的定义(从下到上):最后一行元素到当前元素的最小路径和
* dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
*/
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] dp = new int[n][n];
//填充最后一行
List<Integer> lastRow = triangle.get(n - 1);
for (int i = 0; i < n; ++i) {
dp[n - 1][i] = lastRow.get(i);
}
for (int i = n - 2; i >= 0; --i) {
List<Integer> row = triangle.get(i);
for (int j = 0; j < row.size(); ++j) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + row.get(j);
}
}
return dp[0][0];
}
}
64. 最小路径和
暴力递归+记忆化
public class Solution {
private int[][] memo;
public int minPathSum(int[][] grid, int i, int j) {
//越界
if (i == grid.length || j == grid[0].length) {
return Integer.MAX_VALUE;
}
if (memo[i][j] != -1) {
return memo[i][j];
}
if (i == grid.length - 1 && j == grid[0].length - 1) {
return grid[i][j];
}
int res = grid[i][j] + Math.min(minPathSum(grid, i + 1, j), minPathSum(grid, i, j + 1));
memo[i][j] = res;
return res;
}
public int minPathSum(int[][] grid) {
memo = new int[grid.length][grid[0].length];
for (int i = 0; i < memo.length; i++) {
Arrays.fill(memo[i], -1);
}
return minPathSum(grid, 0, 0);
}
}
二维数组填充
- 时间复杂度 :O(mn)。遍历整个矩阵恰好一次。
- 空间复杂度 :O(mn)。额外的一个同大小矩阵。
public class Solution {
public int minPathSum(int[][] grid) {
int[][] dp = new int[grid.length][grid[0].length];
//从下到上
for (int i = grid.length - 1; i >= 0; i--) {
//从右到左
for (int j = grid[0].length - 1; j >= 0; j--) {
if (i == grid.length - 1 && j == grid[0].length - 1) {//右下角这个元素
dp[i][j] = grid[i][j];//直接填充
} else if (i == grid.length - 1 && j != grid[0].length - 1) {//最后一行非右下角那个元素
dp[i][j] = grid[i][j] + dp[i][j + 1];//填充右边的那个值
} else if (i != grid.length - 1 && j == grid[0].length - 1) {//非后一行最右边的那个元素
dp[i][j] = grid[i][j] + dp[i + 1][j];//填充下面的值(只能往下走)
} else if (i != grid.length - 1 && j != grid[0].length - 1) {//非后一行非最右边的那个元素
dp[i][j] = grid[i][j] + Math.min(dp[i + 1][j], dp[i][j + 1]);//填充 下面 右边 最小的那个值
}
}
}
return dp[0][0];
}
}
一维数组填充
- 时间复杂度 :O(mn)。遍历整个矩阵恰好一次。
- 空间复杂度 :O(n)。额外的一维数组,和一行大小相同。
public class Solution {
public int minPathSum(int[][] grid) {
int[] dp = new int[grid[0].length];
//从下到上
for (int i = grid.length - 1; i >= 0; i--) {
//从右到左
for (int j = grid[0].length - 1; j >= 0; j--) {
if (i == grid.length - 1 && j == grid[0].length - 1) {//右下角这个元素
dp[j] = grid[i][j];//直接填充
} else if (i == grid.length - 1 && j != grid[0].length - 1) {//最后一行非右下角那个元素
dp[j] = grid[i][j] + dp[j + 1];//填充右边的那个值
} else if (i != grid.length - 1 && j == grid[0].length - 1) {//非后一行最右边的那个元素
dp[j] = grid[i][j] + dp[j];//填充下面的值(只能往下走)
} else if (i != grid.length - 1 && j != grid[0].length - 1) {//非后一行非最右边的那个元素
dp[j] = grid[i][j] + Math.min(dp[j], dp[j + 1]);//填充 下面 右边 最小的那个值
}
}
}
return dp[0];
}
}
原来的数组填充
- 时间复杂度 :O(mn)。遍历整个矩阵恰好一次。
- 空间复杂度 :O(1)。不需要额外空间。
public class Solution {
public int minPathSum(int[][] grid) {
//从下到上
for (int i = grid.length - 1; i >= 0; i--) {
//从右到左
for (int j = grid[0].length - 1; j >= 0; j--) {
if (i == grid.length - 1 && j != grid[0].length - 1) {//最后一行非右下角那个元素
grid[i][j] = grid[i][j] + grid[i][j + 1];//填充右边的那个值
} else if (i != grid.length - 1 && j == grid[0].length - 1) {//非后一行最右边的那个元素
grid[i][j] = grid[i][j] + grid[i + 1][j];//填充下面的值(只能往下走)
} else if (i != grid.length - 1 && j != grid[0].length - 1) {//非后一行非最右边的那个元素
grid[i][j] = grid[i][j] + Math.min(grid[i + 1][j], grid[i][j + 1]);//填充 下面 右边 最小的那个值
}
}
}
return grid[0][0];
}
}
//public static void main(String[] args) {
// int[][] arr = {{1, 3, 1}, {1, 5, 1}, {4, 2, 1}};
// //7
// System.out.println(new Solution().minPathSum(arr));
//}
/// Dynamic Programming
/// with O(n^2) space
///
/// Time Complexity: O(n^2)
/// Space Complexity: O(n^2)
public class Solution {
public int minPathSum(int[][] grid) {
//行
int n = grid.length;
//列
int m = grid[0].length;
int[][] res = new int[n][m];
//第一行
res[0][0] = grid[0][0];
for (int j = 1; j < m; j++) {
//往右填充
res[0][j] = res[0][j - 1] + grid[0][j];
}
//第一列
for (int i = 1; i < n; i++) {
//往下填充
res[i][0] = res[i - 1][0] + grid[i][0];
}
//根据 上面 左边 那个值更小觉得填充的值
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
res[i][j] = grid[i][j] + Math.min(res[i - 1][j], res[i][j - 1]);
}
}
return res[n - 1][m - 1];
}
}
/// Dynamic Programming
/// with O(n) space, actually 2*n space
///
/// Time Complexity: O(n^2)
/// Space Complexity: O(n)
public class Solution {
public int minPathSum(int[][] grid) {
//行
int n = grid.length;
//列
int m = grid[0].length;
int[][] res = new int[2][m];
//for(int i = 0; i < n; i++){
// Arrays.fill(res[i], 0);
//}
res[0][0] = grid[0][0];
//第一行
for (int j = 1; j < m; j++) {
res[0][j] = grid[0][j] + res[0][j - 1];
}
for (int i = 1; i < n; i++) {
//每一列最左边这个点
res[i % 2][0] = grid[i][0] + res[(i - 1) % 2][0];
for (int j = 1; j < m; j++) {
res[i % 2][j] = grid[i][j] + Math.min(res[(i - 1) % 2][j], res[i % 2][j - 1]);
}
}
return res[(n - 1) % 2][m - 1];
}
}
/// Dynamic Programming
/// with O(n) space, just n space.
///
/// Time Complexity: O(n^2)
/// Space Complexity: O(n)
public class Solution {
public int minPathSum(int[][] grid) {
//行
int n = grid.length;
//列
int m = grid[0].length;
int[] res = new int[m];
//Arrays.fill(res, 0);
res[0] = grid[0][0];
for (int j = 1; j < m; j++) {
res[j] = grid[0][j] + res[j - 1];
}
for (int i = 1; i < n; i++) {
res[0] += grid[i][0];
for (int j = 1; j < m; j++) {
res[j] = grid[i][j] + Math.min(res[j], res[j - 1]);
}
}
return res[m - 1];
}
}
/// Dynamic Programming
/// with O(1) space, get the answer based on the original space
///
/// Time Complexity: O(n^2)
/// Space Complexity: O(1)
public class Solution {
public int minPathSum(int[][] grid) {
//行
int n = grid.length;
//列
int m = grid[0].length;
//第一行
for (int j = 1; j < m; j++) {
//往右填充
grid[0][j] += grid[0][j - 1];
}
//第一列
for (int i = 1; i < n; i++) {
//往下填充
grid[i][0] += grid[i - 1][0];
}
//根据 上面 左边 那个值更小觉得填充的值
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1]);
}
}
return grid[n - 1][m - 1];
}
}
53. 最大子序和
求最大子数组和,非常经典的一道题目,这道题目有很多种不同的做法,而且很多算法思想都可以在这道题目上面体现出来,比如动态规划、贪心、分治,还有一些技巧性的东西,比如前缀和数组,这里还是使用动态规划的思想来解题,套路还是之前的四步骤:
问题拆解:
问题的核心是子数组,子数组可以看作是一段区间,因此可以由起始点和终止点确定一个子数组,两个点中,我们先确定一个点,然后去找另一个点,比如说,如果我们确定一个子数组的截止元素在 i 这个位置,这个时候我们需要思考的问题是 “以 i 结尾的所有子数组中,和最大的是多少?”,然后我们去试着拆解,这里其实只有两种情况:
i 这个位置的元素自成一个子数组;
i 位置的元素的值 + 以 i - 1 结尾的所有子数组中的子数组和最大的值
你可以看到,我们把第 i 个问题拆成了第 i - 1 个问题,之间的联系也变得清晰
状态定义
通过上面的分析,其实状态已经有了,
dp[i]
就是 “以 i 结尾的所有子数组的最大值”递推方程
拆解问题的时候也提到了,有两种情况,即当前元素自成一个子数组,另外可以考虑前一个状态的答案,于是就有了
dp[i] = Math.max(dp[i - 1] + array[i], array[i])
化简一下就成了:
dp[i] = Math.max(dp[i - 1], 0) + array[i]
实现
题目要求子数组不能为空,因此一开始需要初始化,也就是
dp[0] = array[0]
,保证最后答案的可靠性,另外我们需要用一个变量记录最后的答案,因为子数组有可能以数组中任意一个元素结尾
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int result = dp[0];
for (int i = 1; i < n; ++i) {
dp[i] = Math.max(dp[i - 1], 0) + nums[i];
result = Math.max(result, dp[i]);
}
return result;
}
}