动态规划(dynamic programming,简称 dp)是工程中很是重要的解决问题的思想,从咱们在工程中地图软件上应用的最短路径问题,再在生活中的在淘宝上如何凑单以便利用满减券来最大程度地达到咱们合理薅羊毛的目的 ,不少时候都能看到它的身影。不过动态规划对初学者来讲确实比较难,dp状态,状态转移方程让人摸不着头脑,网上不少人也反馈不太好学,其实就像咱们以前学递归那样,任何算法的学习都是有它的规律和套路的,只要掌握好它的规律及解题的套路,再加上大量的习题练习,相信掌握它不是什么难事,本文将会用比较浅显易懂地讲解来帮助你们掌握动态规划这一在工程中很是重要的思想,相信看完后,动态规划的解题套路必定能手到擒来(文章有点长,建议先收藏再看,看完后必定会对动态规划的认知上升到一个台阶!)java
本文将会从如下角度来说解动态规划:web
如下是我综合了动态规划的特色给出的动态规划的定义:
动态规划是一种多阶段决策最优解模型,通常用来求最值问题,多数状况下它能够采用自下而上的递推方式来得出每一个子问题的最优解(即最优子结构),进而天然而然地得出依赖子问题的原问题的最优解。算法
划重点:shell
简单总结一下,最优子结构,状态转移方程,重叠子问题就是动态规划的三要素,这其中定义子问题的状态与写出状态转移方程是解决动态规划最为关键的步骤,状态转移方程若是定义好了,解决动态规划就基本不是问题了。数组
既然咱们知道动态规划的基本概念及特征,那么怎么判断题目是否能够用动态规划求解呢,其实也很简单,当问题的定义是求最值问题,且问题能够采用递归的方式,而且递归的过程当中有大量重复子问题的时候,基本能够判定问题能够用动态规划求解,因而咱们得出了求解动态规划基本思路以下(解题四步曲)缓存
画外音:递归怎么求解,强烈建议看下这篇文章,好评如潮,总结了常见的递归解题套路数据结构
可能很多人看了以上的动态规划的一些介绍仍是对一些定义如 DP 状态,状态转移方程,自底而上不了解,不要紧 ,接下来咱们会作几道习题来强化一下你们对这些概念及动态规划解题四步曲的理解,每道题咱们都会分别用递归,递归+备忘录,动态规划来求解一遍,这样也进一步帮助你们来巩固咱们以前学的递归知识app
接下来咱们来看看怎么用动态规划解题四步曲来解斐波那契数列
画外音:斐波那契数列并非严格意义上的动态规划,由于它不涉及到求最值,用这个例子旨在说明重叠子问题与状态转移方程函数
一、判断是否可用递归来解
显然是能够的,递归代码以下学习
public static int fibonacci(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return fibonacci(n - 1) + fibonacci(n - 2);
}
二、分析在递归的过程当中是否存在大量的重复子问题
怎么分析是否有重复子问题,画出递归树
能够看到光是求 f(6),就有两次重复的计算, f(4) 求解了两次,f(3) 求解了两次,时间复杂度是指数级别,递归时间复杂度怎么看,解决每一个子问题须要的时间乘以子问题总数,每一个子问题须要的时间即 f(n) = f(n-1) + f(n-2) 只作了一次加法运算,子问题的个数有多少呢,每一个问题一分为二,是个二叉树,能够看到第一层 1 个,第二层 2 个,第三层 4 个,即 1 + 2 + 2^2 + …. 2^n,因此总的来讲时间复杂度是)O(2^n),是指数级别
画外音:求解问题 f(6),转成求 f(5),f(4),从原问题出发,分解成求子问题,子问题再分解成子子问题,。。。,直到不再能分解,这种从 原问题展开子问题进行求解的方式叫自顶向下
三、采用备忘录的方式来存子问题的解以免大量的重复计算
既然以上中间子问题中存在着大量的重复计算,那么咱们能够把这些中间结果给缓存住(能够用哈希表缓存),以下
public static int fibonacci(int n) {
if (n = 1) return 1;
if (n = 2) return 2;
if (map.get(n) != null) {
return map.get(n);
}
int result = fibonacci(n - 1) + fibonacci(n - 2);
map.put(n, result);
return result;
}
这么缓存以后再看咱们的递归树
能够看到经过缓存中间的数据,作了大量地剪枝的工做,一样的f(4),f(3),f(2),都只算一遍了,省去了大量的重复计算,问题的规模从二叉树变成了单链表(即 n),时间复杂度变成了 O(n),不过因为哈希表缓存了全部的子问题的结果,空间复杂度是 O(n)。
四、改用自底向上的方式来递推,即动态规划
咱们注意到以下规律
f(1) = 1
f(2) = 2
f(3) = f(1) + f(2) = 3
f(4) = f(3) + f(2) = 5
....
f(n) = f(n-1) + f(n-2)
因此只要依次自底向上求出 f(3),f(4),…,天然而然地就求出了 f(n)
画外音:从最终地不能再分解的子问题根据递推方程(f(n) = f(n-1) + f(n-2))逐渐求它上层的问题,上上层问题,最终求得一开始的问题,这种求解问题的方式就叫自底向上。
f(n) 就是定义的每一个子问题的状态(DP 状态),f(n) = f(n-1) + f(n-2) 就是状态转移方程,即 f(n) 由 f(n-1), f(n-2) 这两个状态转移而来,因为每一个子问题只与它前面的两个状态,因此咱们只要定义三个变量,自底向上不断循环迭代便可,以下
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int result = 0;
int pre = 1;
int next = 2;
for (int i = 3; i < n + 1; i ++) {
result = pre + next;
pre = next;
next = result;
}
return result;
}
这样时间复杂度虽然仍是O(n),但空间复杂度只因为只定义了三个变量(result,pre,next)因此是常量 O(1)。
经过简单地斐波那契的例子,相信你们对自底向上,DP 状态, DP 转移方程应该有了比较深刻地认识,细心的同窗必定发现了最优子结构怎么没有,由于前面咱们也说了,斐波那契数列并非严格意义上的动态规划,只是先用这个简单地例子来帮助你们了解一下一些基本的概念。在以后的习题中咱们将会见识到真正的动态规划
如图示,以上三角形由一连串的数字构成,要求从顶点 2 开始走到最底下边的最短路径,每次只能向当前节点下面的两个节点走,如 3 能够向 6 或 5 走,不能直接走到 7。
如图示:从 2 走到最底下最短路径为 2+3+5+1 = 11,即为咱们所求的
首先咱们须要用一个二维数组来表示这个三个角形的节点,用二维数组显然能够作到, 第一行的 2 用 a[0][0] 表示,第二行元素 3, 4 用 a[1][0],a[1][1],依此类推。
定义好数据结构以后,接下来咱们来看看如何套用咱们的动态规划解题套路来解题
一、 判断是否可用递归来解
若是用递归,就要穷举全部的路径和,最后再求全部路径和的最小值,咱们来看看用递归怎么作。
对于每一个节点均可以走它的左或右节点,假设咱们定义 traverse(i, j) 为节点 a[i][j] 下一步要走的节点,则能够得出递归公式的伪代码以下
traverse(i, j) = {
traverse(i, j+1); 向节点i,j 下面的左节点走一步
traverse(i+1, j+1); 向节点i,j 下面的右节点走一步
}
何时终止呢,显然是遍历到三角形最后一条边的节点时终止,发现了吗,对每一个节点来讲,在往下(不管是往左仍是往右)遍历的过程当中,问题规模不断地在缩小,也有临界条件(到达最后一条边的节点时终止),分解的子问题也有相同的解决问题的思路(对于每一个节点的遍历都是往左或往右),符合递归的条件!因而咱们获得递归代码以下
private static int[][] triangle = {
{2, 0, 0, 0},
{3, 4, 0, 0},
{6, 5, 7, 0},
{4, 1, 8, 3}
};
public static int traverse(int i, int j) {
int totalRow = 4; // 总行数
if (i >= totalRow - 1) {
return 0;
}
// 往左下节点走时
int leftSum = traverse(i+1, j) + triangle[i+1][j];
// 往右下节点走时
int rightSum = traverse(i+1, j+1) + triangle[i+1][j+1];
// 记录每一个节点往左和往右遍历的路径和的最小值
return Math.min(leftSum, rightSum);
}
public static void main(String[] args) throws Throwable {
int sum = traverse(0, 0) + triangle[0][0];
System.out.println("sum = " + sum);
}
时间复杂度是多少呢,从如下伪代码能够看出
traverse(i, j) = {
traverse(i, j+1); 向节点i,j 下面的左节点走一步
traverse(i+1, j+1); 向节点i,j 下面的右节点走一步
}
对于每一个节点,要么向左或向右,每一个问题都分解成了两个子问题,和斐波那契数列同样,若是画出递归树也是个二叉树,因此时间复杂度是 O(2^n),也是指数级别。
二、分析在递归的过程当中是否存在大量的重复子问题
为啥时间复杂度是指数级别呢,咱们简单分析一下:
对于节点 3 和 4 来讲,若是节点 3 往右遍历, 节点 4 往左遍历,都到了节点 5,节点 5 往下遍历的话就会遍历两次,因此此时就会出现重复子问题
三、采用备忘录的方式来存子问题的解以免大量的重复计算(剪枝)
既然出现了,那咱们就用备忘录把中间节点缓存下来
因而咱们的代码改成以下所示
private static int[][] triangle = {
{2, 0, 0, 0},
{3, 4, 0, 0},
{6, 5, 7, 0},
{4, 1, 8, 3}
};
// 记录中间状态的 map
private static HashMap<String, Integer> map = new HashMap();
public static int traverse(int i, int j) {
String key = i + "" + j;
if (map.get(key) != null) {
return map.get(key);
}
int totalRow = 4; // 总行数
if (i >= totalRow - 1) {
return 0;
}
// 往左下节点走时
int leftSum = traverse(i+1, j) + triangle[i+1][j];
// 往右下节点走时
int rightSum = traverse(i+1, j+1) + triangle[i+1][j+1];
// 记录每一个节点往左和往右遍历的路径和的最小值
int result = Math.min(leftSum, rightSum);
map.put(key, result);
return result;
}
这么一来,就达到了剪枝的目的,避免了重复子问题,时间复杂度一会儿降低到 O(n), 空间复杂度呢,因为咱们用哈希表存储了全部的节点的状态,因此空间复杂度是 O(n)。
四、改用自底向上的方式来递推,即动态规划
重点来了,如何采用自底向上的动态规划来解决问题呢? 咱们这么来看,要求节点 2 到底部边的最短路径,只要先求得节点 3 和 节点 4 到底部的最短路径值,而后取这二者之中的最小值再加 2 不就是从 2 到底部的最短路径了吗,同理,要求节点 3 或 节点 4 到底部的最小值,只要求它们的左右节点到底部的最短路径再取二者的最小值再加节点自己的值(3 或 4)便可。
咱们知道对于三角形的最后一层节点,它们到底部的最短路径就是其自己,因而问题转化为了已知最后一层节点的最小值怎么求倒数第二层到最开始的节点到底部的最小值了。先看倒数第二层到底部的最短路径怎么求
同理,第二层对于节点 3 ,它到最底层的最短路径转化为了 3 到 7, 6 节点的最短路径的最小值,即 9, 对于节点 4,它到最底层的最短路径转化为了 4 到 6, 10 的最短路径二者的最小值,即 10。
接下来要求 2 到底部的路径就很简单了,只要求 2 到节点 9 与 10 的最短路径便可,显然为 11。
因而最终的 11 即为咱们所求的值,接下来咱们来看看怎么定义 DP 的状态与状态转移方程。
咱们要求每一个节点到底部的最短路径,因而 DP 状态 DP[i,j] 定义为 i,j 的节点到底部的最小值,DP状态转移方程定义以下:
DP[i,j] = min(DP[i+1, j], D[i+1, j+1]) + triangle[i,j]
这个状态转移方程表明要求节点到最底部节点的最短路径只须要求左右两个节点到最底部的最短路径二者的最小值再加此节点自己!仔细想一想咱们上面的推导过程是否是都是按这个状态转移方程推导的,实在不明白建议多看几遍上面的推导过程,相信不难明白。
DP 状态 DP[i,j] 有两个变量,须要分别从下而上,从左到右循环求出全部的 i,j, 有了状态转移方程求出代码就比较简单了,以下
private static int[][] triangle = {
{2, 0, 0, 0},
{3, 4, 0, 0},
{6, 5, 7, 0},
{4, 1, 8, 3}
};
public static int traverse() {
int ROW = 4;
int[] mini = triangle[ROW - 1];
// 从倒数第二行求起,由于最后一行的值自己是固定的
for (int i = ROW - 2; i >= 0; i--) {
for (int j = 0; j < triangle[j].length; j++) {
mini[j] = triangle[i][j] + Math.min(mini[j], mini[j+1]);
}
}
return mini[0];
}
public static void main(String[] args) throws Throwable {
int minPathSum = traverse();
System.out.println("sum = " + minPathSum);
}
可能有一些人对 mini 数组的定义有疑问,这里其实用了一个比较取巧的方式,首先咱们定义 mini 的初始值为最后一行的节点,由于最后一行的每一个节点的 DP[i,j] 是固定值,只要从倒数第二行求起便可,其次咱们知道每一个节点到底部的最短路径只与它下一层的 D[I+1,j], D[i+1, j] 有关,因此只要把每一层节点的 DP[i,j] 求出来保存到一个数组里便可,就是为啥咱们只须要定义一下 mini 一维数组的缘由
如图示:要求节点 2 到底部的最短路径,它只关心节点 9, 10,以前层数的节点无需再关心!由于 9,10 已是最优子结构了,因此只保存每层节点(即一维数组)的最值便可!
当自下而上遍历完成了,mini[0] 的值即为 DP[0,0],即为节点 2 到 底部的最短路径。mini 的定义可能有点绕,你们能够多思考几遍,固然,你也能够定义一个二维数组来保存全部的 DP[i,j],只不过多耗些空间罢了。
这里咱们再来谈谈最优子结构,在以上的推导中咱们知道每一层节点到底部的最短路径依赖于它下层的左右节点的最短路径,求得的下层两个节点的最短路径对于依赖于它们的节点来讲就是最优子结构,最优子结构对于子问题来讲属于全局最优解,这样咱们没必要去求节点到最底层的全部路径了,只须要依赖于它的最优子结构便可推导出咱们所要求的最优解,因此最优子结构有两层含义,一是它是子问题的全局最优解,依赖于它的上层问题只要根据已求得的最优子结构推导求解便可得全局最优解,二是它有缓存的含义,这样就避免了多个依赖于它的问题的重复求解(消除重叠子问题)。
总结:仔细回想一下咱们的解题思路,咱们先看了本题是否可用递归来解,在递归的过程当中发现了有重叠子问题,因而咱们又用备忘录来消除递归中的重叠子问题,既然咱们发现了此问题能够用递归+备忘录来求解,天然而然地想到它能够用自底向上的动态规划来求解。是的,求解动态规划就按这个套路来便可,最重要的是要找出它的状态转移方程,这须要在自下而上的推导中仔细观察。
给定不一样面额的硬币 coins 和一个总金额 amount。编写一个函数来计算能够凑成总金额所需的最少的硬币个数。若是没有任何一种硬币组合能组成总金额,返回 -1。
输入: coins = [1, 2, 5], amount = 11,输出: 3 解释: 11 = 5 + 5 + 1
输入: coins = [2], amount = 3,输出: -1
来套用一下咱们的动态规划解题四步曲
1、判断是否可用递归来解
对于 amount 来讲,若是咱们选择了 coins 中的任何一枚硬币,则问题的规模(amount)是否是缩小了,再对缩小的 amount 也选择 coins 中的任何一枚硬币,直到不再能选择(amount <= 0, amount = 0 表明有符合条件的解,小于0表明没有符合条件的解),从描述中咱们能够看出问题能够分解成子问题,子问题与原问题具备相同的解决问题的思路,同时也有临界条件,符合递归的条件,由此可证能够用递归求解,接下来咱们来看看,如何套用递归四步曲来解题
一、定义这个函数,明确这个函数的功能,函数的功能显然是给定一个 amount,用定义好的 coins 来凑,因而咱们定义函数以下
private static int f(int amount, int[] coins) {
}
二、寻找问题与子问题的关系,即递推公式
这题的递推关系比较难推导,咱们一块儿看下,假设 f(amount, coins) 为零钱 amount 的所须要的最少硬币数,当选中了coins 中的第一枚硬币以后(即为 coins[0]),则需再对剩余的 amount - coins[0] 金额求最少硬币数,即调用 f(amount - coins[0], coins) ,由此可知当选了第一枚硬币后的递推公式以下
f(amount, coins) = f(amount-coins[0]) + 1 (这里的 1 表明选择了第一枚硬币)
若是选择了第二,第三枚呢,递推公式以下
f(amount, coins) = f(amount-coins[1]) + 1 (这里的 1 表明选择了第二枚硬币)
f(amount, coins) = f(amount-coins[2]) + 1 (这里的 1 表明选择了第三枚硬币)
咱们的目标是求得全部以上 f(amount, coins) 解的的最小值,因而能够获得咱们的总的递推公式以下
f(amount, coins) = min{ f(amount - coins[i]) + 1) }, 其中 i 的取值为 0 到 coins 的大小,coins[i] 表示选择了此硬币, 1 表示选择了coins[i] 这一枚硬币
三、将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中
得出了递推公式用代码实现就简单了,来简单看一下
public class Solution {
private static int exchange(int amount, int[] coins) {
// 说明零钱恰好凑完
if (amount == 0) {
return 0;
}
// 说明没有知足的条件
if (amount < 0) {
return -1;
}
int result = Integer.MAX_VALUE;
for (int i = 0; i < coins.length; i++) {
int subMin = exchange(amount - coins[i], coins);
if (subMin == -1) continue;
result = Math.min(subMin + 1, result);
}
// 说明没有符合问题的解
if (result == Integer.MAX_VALUE) {
return -1;
}
return result;
}
public static void main(String[] args) throws Throwable {
int amount = 11;
int[] coins = {1,2,5};
int result = exchange(amount, coins);
System.out.println("result = " + result);
}
}
四、计算时间复杂度
这道题的时间复杂度很难看出来,通常看不出来的时候咱们能够画递归树来分析,针对 amount = 11 的递归树 以下
前文咱们说到斐波那契的递归树是一颗二叉树,时间复杂度是指数级别,而凑零钱的递归树是一颗三叉树 ,显然时间复杂度也是指数级别!
2、分析在递归的过程当中是否存在大量的重叠子问题(动态规划第二步)
由上一节的递归树可知,存在重叠子问题,上一节中的 9, 8,都重复算了,因此存在重叠子问题!
3、采用备忘录的方式来存子问题的解以免大量的重复计算(剪枝)
既然咱们知道存在重叠子问题,那么就能够用备忘录来存储中间结果达到剪枝的目的
public class Solution {
// 保存中间结果
private static HashMap<Integer, Integer> map = new HashMap();
// 带备忘录的递归求解
private static int exchangeRecursive(int amount, int[] coins) {
if (map.get(amount) != null) {
return map.get(amount);
}
// 说明零钱已经凑完
if (amount == 0) {
return 0;
}
// 说明没有知足的条件
if (amount < 0) {
return -1;
}
int result = Integer.MAX_VALUE;
for (int i = 0; i < coins.length; i++) {
int subMin = exchangeRecursive(amount - coins[i], coins);
if (subMin == -1) continue;
result = Math.min(subMin + 1, result);
}
// 说明没有符合问题的解
if (result == Integer.MAX_VALUE) {
return -1;
}
map.put(amount, result);
return result;
}
public static void main(String[] args) throws Throwable {
int amount = 11;
int[] coins = {1,2,5};
int result = exchangeRecursive(amount, coins);
System.out.println("result = " + result);
}
}
4、改用自底向上的方式来递推,即动态规划
前面咱们推导出了以下递归公式
f(amount, coins) = min{ f(amount - coins[i]) + 1) }, 其中 i 的取值为 0 到 coins 的大小,coins[i] 表示选择了此硬币, 1 表示选择了coins[i] 这一枚硬币
从以上的递推公式中咱们能够获取 DP 的解题思路,咱们定义 DP(i) 为凑够零钱 i 须要的最小值,状态转移方程以下
DP[i] = min{ DP[ i - coins[j] ] + 1 } = min{ DP[ i - coins[j] ]} + 1, 其中 j 的取值为 0 到 coins 的大小,i 表明取了 coins[j] 这一枚硬币。
因而咱们只要自底向上根据以上状态转移方程依次求解 DP[1], DP[2],DP[3].,….DP[11],最终的 DP[11],即为咱们所求的解
// 动态规划求解
private static int exchangeDP(int amount, int[] coins) {
int[] dp = new int[amount + 1];
// 初始化每一个值为 amount+1,这样当最终求得的 dp[amount] 为 amount+1 时,说明问题无解
for (int i = 0; i < amount + 1; i++) {
dp[i] = amount + 1;
}
// 0 硬币原本就没有,因此设置成 0
dp[0] = 0;
for (int i = 0; i < amount + 1; i++) {
for (int j = 0; j < coins.length; j++) {
if (i >= coins[j]) {
dp[i] = Math.min(dp[i- coins[j]], dp[i]) + 1;
}
}
}
if (dp[amount] == amount + 1) {
return -1;
}
return dp[amount];
}
画外音:以上只是求出了凑成零钱的的最小数量,但若是想求由哪些面值的硬币构成的,该如何修改呢?
凑零钱这道题还能够用另一道经典的青蛙跳台阶的思路来考虑,从最底部最少跳多少步能够跳到第 11 阶,一次能够跳 1,2,5次 。由此可知最后一步必定是跳 1 或 2 或 5 步,因而若是用 f(n) 表明跳台阶 n 的最小跳数,则问题转化为了求 f(n-1),f(n-2) ,f(n-5)的最小值。
如图示:最后一跳必定是跳 1 或 2 或 5 步,只要求 f(n-1),f(n-2) ,f(n-5)的最小值便可
写出递推表达式, 即:
f(n) = min{ f(n-1),f(n-2),f(n-5)} + 1 (1表明最后一跳)
咱们的 DP 状态转移方程对比一下,能够发现二者实际上是等价的,只不过这种跳台阶的方式可能更容易理解。
本文经过几个简单的例子强化了你们动态规划的三要素:最优子结构,状态转移方程,重叠子问题的理解,相信你们对动态规划的理解应该深入了许多,怎么看出是否能够用动态规划来解呢,先看题目是否能够用递归来推导,在用递归推导的过程若是发现有大量地重叠子问题,则有两种方式能够优化,一种是递归 + 备忘录,另外一种就是采用动态规划了,动态规划通常是自下而上的, 经过状态转移方程自下而上的得出每一个子问题的最优解(即最优子结构),最优子结构其实也是穷举了全部的状况得出的最优解,得出每一个子问题的最优解后,也就是每一个最优解实际上是这个子问题的全局最优解,这样依赖于它的上层问题根据状态转移方程天然而然地得出了全局最优解。动态规划自下而上的求解方式还有一个好处就是避免了重叠子问题,由于依赖于子问题的上层问题可能有不少,若是采用自顶而下的方式来求解,就有可能形成大量的重叠子问题,时间复杂度会急剧上升。
参考:
动态规划详解: https://mp.weixin.qq.com/s/Cw39C9MY9Wr2JlcvBQZMcA
欢迎关注公众号交流哦