摘要
动态规划是求解最优化的一种常用策略,它依然需要拆分成若干个子问题,但是每个子问题只有求解一次,并保存它的解。可以用动态规划解决的问题需要有最优子结构和无后效性两个特定。
动态规划简称 DP,是求解最优化问题的一种常用策略。动态规划通常使用的套路大致分为这三种:
1、 暴力递归,自顶向下,会出现重叠子问题;
2、 记忆搜索,自顶向下;
3、 递归,自底向上;
假设动态规划的函数为 dp()
,其中的“动态”可以理解是“状态是会变化的”,动态规划的处理通常有以下三个步骤:
1、 定义状态,就是原问题和子问题的解,也就是定义dp(i)
的含义;
2、 设置初始状态,也就是边界,作为DP的结束条件,即设置dp(0)
的值;
3、 确定状态转移方程,比如确定dp(i)
和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、 每个子问题仅仅解决1次,并保存这些子问题的解;
3、 最后推导出原问题的解;
所以使用动态规划解决的问题,需要具有以下两个特点:
1、 最优子结构,就是通过求解子问题的最优解,可以获得原问题的最优解;
2、 无后效性,某个阶段的状态一旦确定,则此后过程的演变不再受到此前各状态或者决策的影响,也就是未来与过去无关;在推导后面阶段的状态时,只需要关心前面阶段的具体状态值,不用关心这个状态是怎么一步步推导出来的;
无后效性
比如在一个 5x5 的格子中,从起点(0,0)走到终点(4,4) 总共有多少种走法?前提是只能向右、向下走。
假设dp(i, j) 是从 (0,0) 走到 (i, j) 的走法,那么当 dp(i,0) = dp(0,j) = 1,dp(i, j) = dp(i, j-1) + dp(i-1, j)。
当推导dp(i, j) 时,只需要用到 dp(i, j-1) 和 dp(i-1, j) 的值,不需要关心 dp(i, j-1) 和 dp(i-1, j) 的值是怎么求出来的,这就是无后效性。
如果更改一下前提,就是可以向下、向上、向左、向右走,并且同一个格子不可以走2次。那么求 dp(i, j) 时,需要关心 dp(i, j-1) 和 dp(i-1, j) 是如何获得的,这就是有后效性。
找零钱——案例
假设有25分、20分、5分、1分的硬币,现在要找给客户41分零钱,如何办到找的硬币个数最少?
这个案例在贪心策略([[220527 数据结构与算法 - 实战(十八)贪心]])中被求解过,但是贪心策略求得的结果不是最优解。
运用动态规划求解这个问题时,假设 dp(n) 是找 n 分需要的最少硬币个数,有以下几种可能:
- 如果第一次选择了25分硬币,那么 dp(n) = dp(n-25) + 1;
- 如果第一次选择了20分硬币,那么 dp(n) = dp(n-20) + 1;
- 如果第一次选择了5分硬币,那么 dp(n) = dp(n-5) + 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 coins1(int n) {
if (n < 1) return Integer.MAX_VALUE;
if (n == 1 || n == 5 || n == 20 || n == 25) return 1;
int min1 = Math.min(coins1(n - 1), coins1(n-5));
int min2 = Math.min(coins1(n-20), coins1(n-25));
return Math.min(min1, min2) + 1;
}
因为使用了递归的思想,所以会有大量的重复计算,时间复杂度也是比较高的。那么就可以使用数组记录计算出的值,避免重复的计算,即记忆搜索的思想,实现代码如下:
static int coins2(int n) {
if (n < 1) return -1;
int[] dp = new int[n+1];
int[] faces = {
1, 5, 20, 25};
for (int face : faces) {
if (n < face) break;
dp[face] = 1;
}
return coins2(n, dp);
}
static int coins2(int n, int[] dp) {
if (n < 1) return Integer.MAX_VALUE;
if (dp[n] == 0) {
int min1 = Math.min(coins1(n - 1), coins1(n-5));
int min2 = Math.min(coins1(n-20), coins1(n-25));
dp[n] = Math.min(min1, min2) + 1;
}
return dp[n];
}
代码中可以看到,创建了一个长度为 n+1
的数组 dp
存放找零硬币的个数,为什么要创建 n+1
的长度呢?
因为找的 n-1
有可能是任意小于 n 的值,并且索引和要找的 n 保持一致,不用时刻操心 index = n -1
的事情,所以创建的数组长度为 n+1
。
梳理上面的代码,可以看到递归的规律,就是先拆解原问题为子问题,然后从子问题的解推导出原问题的解,即自顶向下。那么可不可以直接从子问题的解去推导呢?
答案是肯定的,也就是自底向上,在代码部分,要多做一些边界判断处理,代码如下:
static int coins3(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];
if (i >=5) min = Integer.min(dp[i-5], min);
if (i >=20) min = Integer.min(dp[i-20], min);
if (i >=25) min = Integer.min(dp[i-25], min);
dp[i] = min + 1;
}
return dp[n];
}
总结
- 动态规划是求解最优化的一种常用策略,实现过程时要用到递归思想;
- 动态规划常用的套路是暴力递归,记忆搜索和自底向上的递归;
- 动态规划可以弥补贪心策略”局部最优,推导到全局,并不是全局最优“的弊端。