动态规划(Dynamic Programming)
动态规划简称DP;
DP是求最优化问题的一种常用策略
通常的使用套路:(一步一步优化)
- 暴力递归(自顶向下,出现了重叠子问题,因此需要优化为2)
- 记忆化搜索(自顶向下,还可以优化为3)
- 递推(或者称为迭代、非递归)(自低向上)
上面的步骤是三种方法,每种方法都是对之前方法的优化
第一种方法,是最容易想到,也是时间复杂度比较高的一种
第二种方法,是对第一种方法的优化
第三种方法,就是使用动态规划来解决问题
其实可以理解为,我们学习动态规划,就是学习第三种方法
以上套路只是告诉你第三种方法递推比暴力递归和记忆化搜索都优的结果,并不是让你遇到一个问题,分别写三种方法。
动态规划的常规步骤
动态规划中的“动态”可以理解为是“会变化的状态”
-
定义状态(状态是原问题、子问题的解)
比如定义dp(i)的含义 -
设置初始状态(边界)
比如设置dp(0)的值 -
确定状态转移方程
比如确定dp(i)和dp(i - 1)的关系
实际考虑,记住例子比记文字更实用
动态规划的一些相关概念
- 将复杂的原问题拆解成若干个简单的子问题
- 每个子问题仅仅解决1次,并保存它们的解
- 最后推导出原问题的解
可以用动态规划来解决的问题,通常具备2个特点
- 最优子结构(最优化原理):通过求解子问题的最优解,可以获得原问题的最优解
- 无后效性
某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响(未来与过去无关)
在推导后面阶段的状态时,只关心前面阶段的具体状态值,不关心这个状态是怎么一步步推导出来的。
简而言之两句话:
大问题可以分解为小问题
无后效性
无后效性、有后效性举例
无后效性
定义状态:
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)
有后效性
通过一个练习题,我们分别看下以上几种解题方法的具体使用。
找零钱
假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?
找零钱这个问题,我们在数据结构与算法—贪心已经讲过,当时是使用的贪心策略:每次选择面值最大的硬币,但用眼观察可以看出,求出的结果并不是最优解,因此,我们来尝试使用其他解法来解决这个问题。
找零钱这种多解法问题最容易被面试问到
因为,它的解法多,一个面试题就可以考察面试者对贪心、暴力、暴力优化、动态规划的掌握情况。
因此,需要重点看
在LeetCode上也有这道题:322. 零钱兑换
假设dp(n)是凑到n分需要的最少硬币个数
如果第1次选择了25分的硬币,那么dp(n) = dp(n - 25) + 1;
假设n = 41,dp(41)是凑到41分需要的最少硬币个数
dp(41 - 25) = dp(16)凑到16分需要的最少硬币个数
1是已经选择了25,25是1枚硬币
很容易理解,你选择了一个25,求出剩下的n-25需要最少硬币个数 + 1就是n需要的最少硬币个数
同理
如果第1次选择了20分的硬币,那么dp(n) = dp(n - 20) + 1;
如果第1次选择了5分的硬币,那么dp(n) = dp(n - 5) + 1;
如果第1次选择了1分的硬币,那么dp(n) = dp(n - 1) + 1;
那么,我们要求的dp(n)的最小值,其实就是求dp(n - 25), dp(n - 20), dp(n - 5), dp(n - 1)的最小值 + 1
暴力递归
package dynamicProgramming;
public class CoinChange {
public static void main(String[] args) {
System.out.println(coins(41));
}
//输入找零钱的数目,求出所需硬币最小个数
static int coins(int n)
{
//如果n <= 0,则让它返回最大值,确保符合条件的对手可以获胜(最小值)
if(n <= 0) return Integer.MAX_VALUE;
//如果找零钱数是25,那么只需要1枚面值为25的硬币即可,所以,返回1
if(n == 25 || n == 20 || n == 5 || n == 1) return 1;
int min1 = Math.min(coins(n - 25), coins(n - 20));
int min2 = Math.min(coins(n - 5), coins(n - 1));
return Math.min(min1, min2) + 1;
}
}
上述代码,有些类似斐波那契数列
f(n) = f(n - 1) + f(n - 2)
我们知道,直接使用没有优化过的斐波那契数列会有重复计算的缺点,那么,上面的程序有没有呢?
举个例子:
假如n = 6
coins(6) 需要计算coins(1)、coins(5)
coins(5) 需要计算coins(4)
coins(4) 需要计算coins(3)
coins(3) 需要计算coins(2)
coins(2) 需要计算coins(1)
从上面例子可以看出,有可能存在重复计算的问题。
上面数字比较小,如果数字比较大的情况下,重复计算的几率会更高
因此,就需要对暴力解法进行优化
记忆化搜索
从名字就可以看出,大致就是需要将已经计算出的结果进行记忆化存储,等再次遇到已经计算出的结果直接赋值即可。
利用一个数组,每求出一个值,放入数组当中。
当每次计算的时候,先判断数组中是否有值,有的话直接取,没有的话再计算。
package dynamicProgramming;
public class CoinChange {
public static void main(String[] args) {
System.out.println(coins(41));
}
static int coins(int n)
{
if(n < 1) return -1;
//数组dp[n]是凑到n分需要的最少硬币个数
int[] dp = new int[n + 1];
//如果找零钱数是25,那么只需要1枚面值为25的硬币即可,所以,返回1
int[] faces = {1, 5, 20, 25};
for (int face : faces) {
//这句话,代表:如果传入的参数n,小于现有硬币金额,则不进行数组赋值
//(举例子:假入n=2,则2 < 5,break,后面的dp[5] = 1不执行。后续的dp[20],dp[25]也没有必要执行,因此是break,而不是continue)
if (n < face) break;
dp[face] = 1;
}
//上述代码实现效果:dp[1] = 1,dp[5] = 1,dp[20] = 1,dp[25] = 1
return coins(n, dp);
}
//输入找零钱的数目,求出所需硬币最小个数
static int coins(int n, int[] dp)
{
//如果n <= 0,则让它返回最大值,确保符合条件的对手可以获胜(最小值)
if(n <= 0) return Integer.MAX_VALUE;
if (dp[n] == 0) {
int min1 = Math.min(coins(n - 25, dp), coins(n - 20, dp));
int min2 = Math.min(coins(n - 5, dp), coins(n - 1, dp));
dp[n] = Math.min(min1, min2) + 1;
}
return dp[n];
}
}
递推(非递归)
从底向上,一步一步求
package dynamicProgramming;
import java.util.Iterator;
public class CoinChange {
public static void main(String[] args) {
System.out.println(coins(41));
}
static int coins(int n)
{
if(n < 1) return -1;
//数组dp[n]是凑到n分需要的最少硬币个数
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
//dp[i] = min{dp[i - 25], dp[i - 20], dp[i - 5], dp[i - 1]} + 1;
int min = Integer.MAX_VALUE;
if(i >= 1) min = Math.min(dp[i - 1], min);
if(i >= 5) min = Math.min(dp[i - 5], min);
if(i >= 20) min = Math.min(dp[i - 20], min);
if(i >= 25) min = Math.min(dp[i - 25], min);
dp[i] = min + 1;
}
return dp[n];
}
}
空间复杂度为:O(n)
int[] dp = new int[n + 1];
时间复杂度为:O(n)
一个for循环,遍历一遍即可。
动态规划的通用写法
上面都是将数组写进程序里面,不利于根据数组不同,进行相同操作,扩展型不行。
package dynamicProgramming;
import java.util.Iterator;
public class CoinChange {
public static void main(String[] args) {
System.out.println(coins(41, new int[] {1, 5, 20, 25}));
}
static int coins(int n, int[] faces)
{
//此处faces就是单存的硬币面值
if(n < 1 || faces == null || faces.length == 0) return -1;
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
int min = Integer.MAX_VALUE;
for (int face : faces) {
if(i < face) continue;
min = Math.min(dp[i - face], min);
}
dp[i] = min + 1;
}
return dp[n];
}
}
求出具体给的硬币面值
就是,不仅要求出最小硬币个数,还要写出具体如何实现的。
package dynamicProgramming;
import java.util.Iterator;
public class CoinChange {
public static void main(String[] args) {
System.out.println("最少需要" + coins(41) + "硬币");
}
static int coins(int n)
{
if(n < 1) return -1;
//数组dp[n]是凑到n分需要的最少硬币个数
int[] dp = new int[n + 1];
//faces[i] 代表凑够i分时,最后那枚硬币的面值
int[] faces = new int[n + 1];
for (int i = 1; i <= n; i++) {
//dp[i] = min{dp[i - 25], dp[i - 20], dp[i - 5], dp[i - 1]} + 1;
int min = Integer.MAX_VALUE;
if(i >= 1 && dp[i - 1] < min)
{
min = dp[i - 1];
faces[i] = 1;
}
if(i >= 5 && dp[i - 5] < min)
{
min = dp[i - 5];
faces[i] = 5;
}
if(i >= 20 && dp[i - 20] < min)
{
min = dp[i - 20];
faces[i] = 20;
}
if(i >= 25 && dp[i - 25] < min)
{
min = dp[i - 25];
faces[i] = 25;
}
dp[i] = min + 1;
}
//打印选中的面值
int temp = n;
while(temp > 0)
{
System.out.print(faces[temp] + " ");
temp -= faces[temp];
}
return dp[n];
}
}