文章目录
什么是动态规划
动态规划(Dynamic programming,简称 DP),通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划适用于有重叠子问题和最优子结构性质的问题。
什么是最优子结构性质?
问题的最优解包含子问题的最优解。反过来说就是,可以通过子问题的最优解,推导出问题的最优解。
如果把最优子结构,对应到动态规划问题模型上,可以理解为,后面阶段的状态可以通过前面状态推导出来。
而且这个最优子结构具有无后效性,即当前的状态与后面的决策没有关系,后面的决策能够安心地使用前面的局部最后解。
简单来说,动态规划就是,把给定问题拆成一个个子问题,直到子问题可以直接解决。然后把子问题答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法。
根据以上描述,可以提炼出动态规划的核心思想:
拆分子问题;保存子问题答案,记住过往,减少重复计算;
示例
leetcode原题:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个10级的台阶总共有多少种跳法。
可以整理下思路:前提:青蛙一次可以跳 1 or 2级台阶
- 如果青蛙要跳上10级台阶,它可以从 第8级台阶跳2级 or 第9级台阶跳1级 到第10级台阶,至此问题可以转移为 青蛙跳到第8 or 9级台阶有几种跳法;
- 如果青蛙要跳上9级台阶,它可以从 第7级台阶跳2级 or 第8级台阶跳1级 到第9级台阶,至此问题可以转移为 青蛙跳到第7 or 8级台阶有几种跳法;
- 如果青蛙要跳上8级台阶,它可以从 第6级台阶跳2级 or 第7级台阶跳1级 到第8级台阶,至此问题可以转移为 青蛙跳到第6 or 7级台阶有几种跳法;
- …
综上所述,将f(n)定义为跳上n级台阶的跳法数,则
f(10) = f(9) + f(8)
f(9) = f(8) + f(7)
f(8) = f(7) + f(6)
......
f(3) = f(2) + f(1)
f(2) = 2
f(1) = 1
可以总结出通项公式:
f(n) = f(n-1) + f(n-2),其中n >= 3
f(1) = 1
f(2) = 2
至此,已经可以根据以上公式,递归解决问题。
普通递归解法
public static int numWays(int n) {
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
return numWays(n-1) + numWays(n -2);
}
用此方法已经能解决问题,但是仔细分析这个递归过程,递归树:
这个递归过程的总 时间复杂度 = 解决一个子问题时间 * 子问题个数
其中解决一个子问题的时间 = f(n-1) + f(n-2),时间复杂度为O(1),子问题个数 = 递归树节点数 = 2^n - 1,时间复杂度为O(2^n);
因此以上递归算法的时间复杂度为 = O(1) * O(2^n) = O(2^n),当n比较大时,算法所需时间爆炸式增长,很容易超时。
但是分析以上递归树节点,可以发现有很多重复节点的计算,比如f(8)计算了2次,自然的优化思路是将这些重复计算的节点结果保存下来,当需要用到的时候直接取,能够节省很多计算时间,减去很多递归树的重复分支;
保存节点结果的递归解法(减支递归法)
public static void main(String[] args) {
StopWatch stopWatch = new StopWatch();
stopWatch.start("numWays");
System.out.println(numWays(45));
stopWatch.stop();
System.out.println(stopWatch.getLastTaskTimeMillis());
stopWatch.start("numWaysWithJianZhi");
System.out.println(numWaysWithJianZhi(45));
stopWatch.stop();
System.out.println(stopWatch.getLastTaskTimeMillis());
}
public static int numWays(int n) {
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
return numWays(n-1) + numWays(n -2);
}
static Map<Integer, Integer> fnMap = Maps.newHashMap();
public static int numWaysWithJianZhi(int n) {
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
Integer numWays = fnMap.get(n);
if (numWays != null) {
return numWays;
}
numWays = numWaysWithJianZhi(n-1) + numWaysWithJianZhi(n -2);
fnMap.put(n, numWays);
return numWays;
}
输出:
1836311903
3403
1836311903
0
可以看到如果将节点记录保存下来,会很快。那是保存已经计算过的节点结果,将递归树的分支剪掉了
递归树变成光秃秃的树干,即:
- 子问题个数 = 树节点数= n
- 解决一个子问题的时间复杂度 = O(1)
- 保存节点结果的递归算法的时间复杂度 = O(n)
- 由于需要保存 n 个节点的计算结果,所以空间复杂度 = O(n)
自底向上的动态规划解法
动态规划 的基本思想和上述 保存节点结果的递归算法一致,都是减少重复计算,时间复杂度也都是 O(n),有所区别的是:
- 可以看到,保存节点结果的递归算法是从 f(n) > f(n-1) > … > f(1)方向求解,即 自顶向下;
- 动态规划从较小问题的解,由交叠性质,逐步决策出较大问题的解,f(1) > f(2) > … > f(n-1) > f(n)方向求解,即自底向上;
动态规划的几个典型特征:最优子结构,状态转移方程,边界,重叠子问题。
在上述青蛙跳阶问题中:
- 最优子结构:f(n-1) , f(n-2)
- 状态转移方程:f(n) = f(n-1) + f(n-2),其中n >= 3
- 边界:f(1) = 1,f(2) = 2
- 重叠子问题:f(10) = f(9)+ f(8),f(9) = f(8)+ f(7),…
那么自底向上的解法为
可以看到用一个for循环就可以搞定了。
保存节点结果的递归解法,空间复杂度是O(n),但是,仔细观察上图,可以发现,f(n) 只依赖前面两个数,所以只需要两个变量a和b来存储,就可以满足需求了,因此空间复杂度 = O(1)
public static void main(String[] args) {
stopWatch.start("numWaysWithDP");
System.out.println(numWaysWithDP(45));
stopWatch.stop();
System.out.println(stopWatch.getLastTaskTimeMillis());
}
输出:
1836311903
0
总结
什么问题可以使用动态规划
如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。
经典应用场景:最长递增子序列、最小编辑距离、背包问题、凑零钱问题等。
解题思路
动态规划的核心思想就是拆分子问题,记住过往,减少重复计算。 并且动态规划一般都是自底向上的,动态规划的思路:
- 穷举分析
- 找出规律,确定最优子结构
- 确定边界
- 写出状态转移方程
- 用状态转移方程回归示例,确定正确性
- 转化为代码实现
leetcode实战案例
题目
给定一个整数数组 nums ,找到其中最长严格递增子序列的长度。
示例1:
输入:nums = [10,9,2,5,3,7,101,18,7]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例2:
输入:nums = [0,1,0,3,2,3]
输出:4
分析
分析问题时,可以思考下,动态规划主要思想是:是否具有最优子结构,并且是否无后效性,子问题子问题是否重复
以示例1进行问题分析。
自底向上穷举分析
- 当nums只有一个元素10,最长严格递增子序列是[10],长度为1;
- 当nums再加入一个元素9,最长严格递增子序列是[10] or [9],长度为1;
- 当nums再加入一个元素2,最长严格递增子序列是[10] or [9] or [2],长度为1;
- 当nums再加入一个元素5,最长严格递增子序列是[2,5],长度为2;
- 当nums再加入一个元素3,最长严格递增子序列是[2,5] or [2,3],长度为2;
- 当nums再加入一个元素7,最长严格递增子序列是[2,5,7] or [2,3,7],长度为3;
- 当nums再加入一个元素101,最长严格递增子序列是[2,5,7,101] or [2,3,7,101],长度为4;
- 当nums再加入一个元素18,最长严格递增子序列是[2,5,7,18] or [2,3,7,18] or [2,5,7,101] or [2,3,7,101],长度为4;
- 当nums再加入一个元素7,最长严格递增子序列是[2,5,7,18] or [2,3,7,18] or [2,5,7,101] or [2,3,7,101],长度为4;
找出规律,确定最优子结构
为方便说明,做如下定义:
subNums[i]:表示原数组nums[n]从nums[1]到nums[i]的子序列(1<=i<=n)
maxIncreaseSubSeq[i]:表示subNums[i]数组的最长严格递增子序列(1<=i<=n)
maxIncreaseSubSeqLen[i]:maxIncreaseSubSeq[i]的长度(1<=i<=n)
- 根据以上穷举分析,可以看到,maxIncreaseSubSeq[i]要么是以nums[i]结尾的最长严格递增子序列,要么是subNums[i-1]的最长严格递增子序列(maxIncreaseSubSeq[i-1]),即
maxIncreaseSubSeq[i] = max(以nums[i]结尾的最长严格递增子序列, subNums[i-1]的最长严格递增子序列)
-
接下来思考下,以nums[i]结尾的最长严格递增子序列,这个条件能否再做文章?
既然是以nums[i]结尾的最长严格递增子序列,显然可以得到这个最长子序列除结尾nums[i]外的序列也是以nums[j]结尾的最长严格递增子序列,其中 0<=j<i 且 nums[j] < nums[i]。
结合1,2可以得到:
maxIncreaseSubSeq[i] = max(max(以nums[j]结尾的最长严格递增子序列) + nums[i](0<=j<i 且 nums[j] < nums[i]))(1<=i<=n)
确定边界
当nums数组只有一个元素时,最长递增子序列的长度dp(1)=1,当nums数组有两个元素时,dp(2) = 2或者1, 因此边界就是dp(1)=1。
状态转移方程
最长递增子序列 = max(dp[i]),其中 dp(i)表示以nums[i]结尾的最长严格递增子序列长度。
用状态转移方程回归示例
- 当nums只有一个元素10,触达边界,dp[1] = 1;
- 当nums再加入一个元素9,dp[2] = 1;
- 当nums再加入一个元素2,dp[3] = 1;
- 当nums再加入一个元素5,dp[4] = dp[3] + 1 = 2;
- 当nums再加入一个元素3,dp[5] = dp[3] + 1 = 2;
- 当nums再加入一个元素7,dp[6] = max(dp[3],dp[4],dp[5]) + 1 =3;
- 当nums再加入一个元素101,dp[7] = max(dp[1],dp[2],dp[3],dp[4],dp[5],dp[6]) + 1 = 4;
- 当nums再加入一个元素18,dp[8] = max(dp[1],dp[2],dp[3],dp[4],dp[5],dp[6]) + 1 = 4;
- 当nums再加入一个元素7,dp[9] = max(dp[3],dp[4],dp[5]) + 1 = 3;
最长递增子序列 = max(dp[i])(1<=i<=9) = 4。
转化为代码实现
public static void main(String[] args) {
int[] nums = new int[]{10,9,2,5,3,7,101,18,7};
for (int i = 0; i < nums.length; i++) {
System.out.println("dp[" + i + "] = " + maxIncreaseSubSeqWithDP(nums, i));
}
}
public static int maxIncreaseSubSeqWithDP(int[] nums, int n) {
if (n == 0) {
return 1;
}
int maxDP = 1;
int[] dp = new int[n + 1];
//初始化就是边界情况
dp[0] = 1;
//自底向上遍历
for (int i = 1; i <= n; i++) {
dp[i] = 0;
//从下标0到i遍历
for (int j = 0; j < i; j++) {
//找到前面比nums[i]小的数nums[j],即有dp[i]= dp[j]+1
if (nums[j] < nums[i] && dp[j] > dp[i]) {
dp[i] = dp[j];
}
}
//加上自身
dp[i] ++;
//顺带求出max(dp)
maxDP = maxDP < dp[i] ? dp[i] : maxDP;
}
return maxDP;
}
输出:
dp[0] = 1
dp[1] = 1
dp[2] = 1
dp[3] = 2
dp[4] = 2
dp[5] = 3
dp[6] = 4
dp[7] = 4
dp[8] = 4
符合预期。
参考: