一文搞懂动态规划

动态规划(Dynamic programming,简称DP)是一种经过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。javascript

从面试的角度看,动态规划是正规算法面试中不管如何都逃不掉的必考题,曾经有一个伟人说过这样一句话:java

2020-01-19-16-30-05

那么为何动态规划会在面试中这么重要?node

其实最主要的缘由就是动态规划很是适合面试,由于动态规划没办法「背」。面试

咱们不少求职者实际上是经过背题来面试的,而以前这个作法屡试不爽,什么翻转二叉树、翻转链表,快排、归并、冒泡一顿背,基本上也能在面试中浑水摸鱼过去,其实这哪是考算法能力、算法思惟,这就是考谁的备战态度好,愿意花时间去背题而已,把连背都懒得背的筛出去就完事了。算法

可是随着互联网遇冷,人才供给进一步过热,背题的人愈来愈多,面试的门槛被增长了,所以这个时候须要一种很是考验算法思惟、变化无穷并且容易设计的题目类型,动态规划就完美符合这个要求。编程

好比 LeetCode 中有1261道算法类题目,其中动态规划题目占据了近200道,动态规划能占据总题目的 1/6 的比例,可见其火热程度。数组

更重要的是,动态规划的题目难度以中高难度为主:缓存

2020-01-19-21-38-06

因此,既然咱们已经知道这是算法面试的必考题了,咱们怎么准备都不为过,本文尽笔者最大努力把动态规划讲清楚。bash

从「钱」讲起

咱们在前面内容了解到了贪心算法能够解决「硬币找零问题」,可是那只是在部分状况下能够解决而已,由于题目中给出的钱币面值为 一、五、25,咱们现实生活中咱们现行的第五套人民币面值分别为100、50、20、十、五、1,咱们的人民币是能够用贪心算法找零的。编程语言

那么有什么状况下不能用贪心算法吗?好比一个算法星球的央行发行了奇葩币,币值分别为一、五、11,要凑够15元,这个时候贪心算法就失效了。

咱们能够算一下,按照贪心算法的策略,咱们先拿出最大面值的11,剩下的4个分别对应四个1元的奇葩币,这总共须要五个奇葩币才能凑够15元。

而实际上咱们简单一算,就知道最少状况是拿出3个五元的奇葩币才能凑够15元。

这里就有问题了,贪心算法的弊端在这种特殊面值钱币面前展露无疑,缘由就在于「只顾眼前,无大局观」,在先拿出最大的 11 面值的奇葩币后就完全把周旋余地堵死了,由于剩下的 4 要想凑足付出的代价是很是高的,咱们须要依次拿出4个面值为1的奇葩币。

改进计算策略

那么既然贪心算法已经不适用于这种场景了,咱们应该如何改变计算策略呢?

当咱们面试过程当中遇到这种问题时,若是一时没有思路,也要想到一种万能算法--暴力破解。

咱们分析一下上述题目,它的问题实际上是「给定一组面额的硬币,咱们用现有的币值凑出n最少须要多少个币」。

咱们要凑够这个 n,只要 n 不为0,那么总会有处在最后一个的硬币,这个硬币刚好凑成了 n,好比咱们用 {11,1,1,1,1} 来凑15,前面咱们拿出 {11,1,1,1},最后咱们拿出 {1} 正好凑成 15。

2020-03-09-18-15-22

若是用 {5,5,5} 来凑15,最后一个硬币就是5,咱们按照这个思路捋一捋,:

  • 那么假设最后一个硬币为11的话,那么剩下4,这个时候问题又变成了,咱们凑出 n-11 最少须要多少个币,此时n=4,咱们只能取出4个面值为1的币
  • 若是假设最后一个硬币为 5 的话,这个时候问题又变成了,咱们用现有的币值凑出 n-5 最少须要多少个币

你们发现了没有,咱们的问题提能够不断被分解为「咱们用现有的币值凑出 n 最少须要多少个币」,好比咱们用 f(n) 函数表明 「凑出 n 最少须要多少个币」.

把「原有的大问题逐渐分解成相似的可是规模更小的子问题」这就是最优子结构,咱们能够经过自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解。

这个时候咱们分别假设 一、五、11 三种面值的币分别为最后一个硬币的状况:

  • 最后一枚硬币的面额为 11: min = f(4) + 1
  • 最后一枚硬币的面额为 5: min = f(10) + 1
  • 最后一枚硬币的面额为 1: min = f(14) + 1

这个时候你们发现问题所在了吗?最少找零 min 与 f(4)、f(10)、f(14) 三个函数解中的最小值是有关的,毕竟后面的「+1」是你们都有的。

假设凑的硬币总额为 n,那么 f(4) = f(n-11)f(10) = f(n-5)f(14) = f(n-1),咱们得出如下公式:

f(n) = min{f(n-1), f(n-5), f(n-11)} + 1
复制代码

咱们再具体到上面公式中 f(n-1) 凑够它的最小硬币数量是多少,是否是又变成下面这个公式:

f(n-1) = min{f(n-1-1), f(n-1-5), f(n-1-11)} + 1
复制代码

以此类推...

这真是似曾相识,这不就是递归吗?

是的,咱们能够经过递归来求出最少找零问题的解,代码以下:

function f(n) {
    if(n === 0) return 0
    let min = Infinity
    if (n >= 1) {
        min = Math.min(f(n-1) + 1, min)
    }

    if (n >= 5) {
        min = Math.min(f(n-5) + 1, min)
    }

    if (n >= 11) {
        min = Math.min(f(n-11) + 1, min)
    }

    return min
}

console.log(f(15)) // 3

复制代码
  • 当n=0的时候,直接返回0,增长程序鲁棒性
  • 咱们先设最少找零 min 为 「无限大」,方便以后Math.min 求最小值
  • 当最后一个硬币为1的时候,咱们递归 min = Math.min(f(n-1) + 1, min),求此种状况下的最小找零
  • 当最后一个硬币为5的时候,咱们递归 min = Math.min(f(n-5) + 1, min),求此种状况下的最小找零
  • 当最后一个硬币为11的时候,咱们递归 min = Math.min(f(n-11) + 1, min),求此种状况下的最小找零

递归的弊端

咱们看似已经把问题解决了,可是别着急,咱们继续测试,当n=70的时候,咱们测试要凑出这个数最少咱们须要多少个硬币。

答案是8,可是咱们的耗时以下:

2020-01-21-23-02-13

若是n=270呢?在八代i7处理器和node.js 12.x版本的加持下我跑了这么长时间都没算出来:

2020-01-21-23-04-26

当n=27000的时候,咱们成功的爆栈了:

2020-01-21-23-05-56

因此为何会形成如此长的执行耗时?归根究竟是递归算法的低效致使的,咱们看以下图:

2020-01-21-23-12-00

咱们若是计算f(70)就须要分别计算最后一个币为一、五、11三种面值时的不一样状况,而这三种不一样状况做为子问题又能够被分解为三种状况,依次类推...这样的算法复杂度有 O(3ⁿ),这是极为低效的。

咱们再仔细看图:

2020-01-21-23-17-13

咱们用红色标出来的都是相同的计算函数,好比有两个f(64)、f(58)、f(54),这些都是重复的,这些只是咱们整个计算体系下的冰山一角,咱们还有很是多的重复计算没办法在图中展现出来。

可见咱们重复计算了很是多的无效函数,浪费了算力,到底有多浪费咱们已经从上面函数执行时间测试上有了必定的认识。

咱们不妨再举一个简单的例子,好比咱们要计算 「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」的和。

咱们开始数数...,直到咱们数出上面计算的和为 8,那么,咱们再在上述 「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」 后面 「+ 1」,那么和是多少?

这个时候你确定数都不会数,脱口而出「9」。

为何咱们在后面的计算这么快?是由于咱们已经在大脑中记住了以前的结果 「8」,咱们只须要计算「8 + 1」便可,这避免了咱们重复去计算前面的已经计算过的内容。

咱们用的递归像什么?像继续数「1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1」来计算出「9」,这是很是耗时的。

咱们假设用 m 种面值的硬币凑 n 最少须要多少硬币,在上述问题下递归的时间复杂度是惊人的O(nᵐ),指数级的时间复杂度能够说是最差的时间复杂度之一了。

咱们已经发现问题所在了,大量的重复计算致使时间复杂度奇高,咱们必须想办法解决这个问题。

备忘录与递归

既然已经知道存在大量冗余计算了,那么咱们可不能够创建一个备忘录,把计算过的答案记录在备忘录中,再有咱们须要答案的时候,咱们去备忘录中查找,若是能查找到就直接返回答案,这样就避免了重复计算,这就是算法中典型的空间换时间的思惟,咱们用备忘录占用的额外内存换取了更高效的计算。

有了思路后,其实代码实现很是简单,咱们只须要创建一个缓存备忘录,在函数内部校验校验是否存在在结果,若是存在返回便可。

javascript 代码展现
function f(n) {
    function makeChange(amount) {
        if(amount <= 0) return 0
    
        // 校验是否已经在备忘录中存在结果,若是存在返回便可
        if(cache[amount]) return cache[amount]
    
        let min = Infinity
        if (amount >= 1) {
            min = Math.min(makeChange(amount-1) + 1, min)
        }
    
        if (amount >= 5) {
            min = Math.min(makeChange(amount-5) + 1, min)
        }
    
        if (amount >= 11) {
            min = Math.min(makeChange(amount-11) + 1, min)
        }
    
        return (cache[amount] = min)
    }
    // 备忘录
    const cache = []
    return makeChange(n)
}
console.log(f(70)) // 8
复制代码
java 代码展现
public class Cions {

    public static void main(String[] args) {
        int a = coinChange(70);
        System.out.println(a);
    }
    private static HashMap<Integer,Integer> cache = new HashMap<>();
    public static int coinChange(int amount) {
        return makeChange(amount);
    }
    public static int makeChange(int amount) {
        if (amount <= 0) return 0;

        // 校验是否已经在备忘录中存在结果,若是存在返回便可
        if(cache.get(amount) != null) return cache.get(amount);

        int min = Integer.MAX_VALUE;
        if (amount >= 1) {
            min = Math.min(makeChange(amount-1) + 1, min);
        }

        if (amount >= 5) {
            min = Math.min(makeChange(amount-5) + 1, min);
        }

        if (amount >= 11) {
            min = Math.min(makeChange(amount-11) + 1, min);
        }

        cache.put(amount, min);
        return min;

    }
}
复制代码

咱们的执行时间只有:

2020-01-22-09-52-37

实际上利用备忘录来解决递归重复计算的问题叫作「记忆化搜索」。

2020-01-21-23-17-13

这个方法本质上跟回溯法的「剪枝」是一个目的,就是把上图中存在重复的节点所有剔除,只保留一个节点便可,固然上图没办法把全部节点所有展现出来,若是剔除所有重复节点最后只会留下线性的节点形式:

2020-01-22-10-04-33

这个带备忘录的递归算法时间复杂度只有O(n),已经跟动态规划的时间复杂度相差不大了。

那么这不就能够了吗?为何还要搞动态规划?

还记得咱们上面提到递归的另外一大问题吗?

爆栈!

这是咱们备忘录递归计算 f(27000) 的结果:

2020-01-22-10-07-19

编程语言栈的深度是有限的,即便咱们进行了剪枝,在五位数以上的状况下就会再次产生爆栈的状况,这致使递归根本没法完成大规模的计算任务。

这是递归的计算形式决定的,咱们这里的递归是「自顶向下」的计算思路,即从 f(70) f(69)...f(1) 逐步分解,这个思路在这里并不彻底适用,咱们须要一种「自底向上」的思路来解决问题。

「自底向上」就是 f(1) ... f(70) f(69)经过小规模问题递推来解决大规模问题,动态规划一般是用迭代取代递归来解决问题。

「自顶向下」的思路在另外一种算法思想中很是常见,那就是分治算法

除此以外,递归+备忘录的另外一个缺陷就是再没有优化空间了,由于在最坏的状况下,递归的最大深度是 n。

所以,咱们须要系统递归堆栈使用 O(n) 的空间,这是递归形式决定的,而换成迭代以后咱们根本不须要如此多的的储存空间,咱们能够继续往下看。

动态转移方程

还记得上面咱们利用备忘录缓存以后各个节点的形式是什么样的吗,咱们把它这个「备忘录」做为一张表,这张表就叫作 DP table,以下:

2020-01-22-22-06-59

注意: 上图中 f[n] 表明凑够 n 最少须要多少币的函数,方块内的数字表明函数的结果

咱们不妨在上图中找找规律?

咱们观察f[1]: f[1] = min(f[0], f[-5], f[-11]) + 1

因为f[-5] 这种负数是不存在的,咱们都设为正无穷大,那么f[1] = 1

再看看f[5]: f[1] = min(f[4], f[0], f[-6]) + 1,这实际是在求f[4] = 4f[0] = 0f[-6]=Infinity中最小的值即0,最后加上1,即1,那么f[5] = 1

发现了吗?咱们任何一个节点均可以经过以前的节点来推导出来,根本无需再作重复计算,这个相关的方程是:

f[n] = min(f[n-1], f[n-5], f[n-11]) + 1
复制代码

还记得咱们提到的动态规划有更大的优化空间吗?递归+备忘录因为递归深度的缘由须要 O(n) 的空间复杂度,可是基于迭代的动态规划只须要常数级别的复杂度。

看下图,好比咱们求解 f(70),只须要前面三个解,即 f(59) f(69) f(65) 套用公式便可求得,那么 f(0)f(1) ... f(58) 根本就没有用了,咱们能够再也不储存它们占用额外空间,这就留下了咱们优化的空间。

2020-03-09-19-10-43

上面的方程就是动态转移方程,而解决动态规划题目的钥匙也正是这个动态转移方程。

固然,若是你只推导出了动态转移方程基本上能够把动态规划题作出来了,可是每每不少人却作不对,这是为何?这就得考虑边界问题。

边界问题

部分的边界问题其实咱们在上面的部分已经给出解决方案了,针对这个找零问题咱们有如下边界问题。

处理f[n]中n为负数的问题: 针对这个问题咱们的解决方案是凡是n为负数的状况,一概将f[n]视为正无穷大,由于正常状况下咱们是不会有下角标为负数的数组的,因此其实 n 为负数的 f[n] 根本就不存在,又由于咱们要求最少找零,为了排除这种不存在的状况,也便于咱们计算,咱们直接将其视为正无穷大,能够最大程度方便咱们的动态转移方程的实现。

处理f[n]中n为0的问题n=0 的状况属于动态转移方程的初始条件,初始条件也就是动态转移方程没法处理的特殊状况,好比咱们若是没有这个初始条件,咱们的方程是这样的: f[0] = min(f[-1], f[-5], f[-11]) + 1,最小的也是正无穷大,这是特殊状况没法处理,所以咱们只能人肉设置初始条件。

处理好边界问题咱们就能够获得完整的动态转移方程了:

f[0] = 0 (n=0)
f[n] = min(f[n-1], f[n-5], f[n-11]) + 1 (n>0)
复制代码

找零问题完整解析

那么咱们再回到这个找零问题中,此次咱们假设给出不一样面额的硬币 coins 和一个总金额 amount。编写一个函数来计算能够凑成总金额所需的最少的硬币个数。若是没有任何一种硬币组合能组成总金额,返回 -1。

好比:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1
复制代码

其实上面的找零问题就是咱们一直处理的找零问题的通用化,咱们的面额是定死的,即一、五、11,此次是不定的,而是给了一个数组 coins 包含了相关的面值。

有了以前的经验,这种问题天然就再也不话下了,咱们再整理一下思路。

肯定最优子结构: 最优子结构即原问题的解由子问题的最优解构成,咱们假设最少须要k个硬币凑足总面额n,那么f(n) = min{f(n-cᵢ)}, cᵢ 便是硬币的面额。

处理边界问题: 依然是老套路,当n为负数的时候,值为正无穷大,当n=0时,值也为0.

得出动态转移方程:

f[0] = 0 (n=0)
f[n] = min(f[n-cᵢ]) + 1 (n>0)
复制代码

咱们根据上面的推导,得出如下代码:

javascript 代码展现
const coinChange = (coins, amount) => {
  // 初始化备忘录,用Infinity填满备忘录,Infinity说明该值不能够用硬币凑出来
  const dp = new Array(amount + 1).fill(Infinity)

  // 设置初始条件为 0
  dp[0] = 0

  for (var i = 1; i <= amount; i++) {
    for (const coin of coins) {
      // 根据动态转移方程求出最小值
      if (coin <= i) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1)
      }
    }
  }

  // 若是 `dp[amount] === Infinity`说明没有最优解返回-1,不然返回最优解
  return dp[amount] === Infinity ? -1 : dp[amount]
}
复制代码
java 代码展现
class Solution {
    public int coinChange(int[] coins, int amount) {
        // 初始化备忘录,用amount+1填满备忘录,amount+1 表示该值不能够用硬币凑出来
        int[] dp = new int[amount + 1];
        Arrays.fill(dp,amount+1);
        // 设置初始条件为 0
        dp[0]=0;
        for (int coin : coins) {
            for (int i = coin; i <= amount; i++) {
                // 根据动态转移方程求出最小值
                if(coin <= i) {
                    dp[i]=Math.min(dp[i],dp[i-coin]+1);
                }
            }
        }
        // 若是 `dp[amount] === amount+1`说明没有最优解返回-1,不然返回最优解
        return dp[amount] == amount+1 ? -1 : dp[amount];
    }
}
复制代码

小结

咱们总结一下学习历程:

  1. 从贪心算法入手来解决找零问题,发现贪心算法并非在任何状况下都能找到最优解
  2. 咱们决定换一种思路来解决存在的问题,咱们最终发现了关键点,即「最优子结构」
  3. 借助上面的两个发现,咱们用递归的方式解决了最少找零问题
  4. 可是通过算法复杂度分析和实际测试,咱们发现递归的方法效率奇低,咱们必须用一种方法来解决当前问题
  5. 咱们用备忘录+递归的形式解决了时间复杂度问题,可是自顶向下的思路致使咱们没法摆脱爆栈的阴霾,咱们须要一种「自底向上」的全新思路
  6. 咱们经过动态转移方程以迭代的方式高效地解出了此题

其实动态规划本质上就是被一再优化过的暴力破解,咱们经过动态规划减小了大量的重叠子问题,此后咱们讲到的全部动态规划题目的解题过程,均可以从暴力破解一步步优化到动态规划。

本文咱们学习了动态规划究竟是怎么来的,在此后的解题过程当中咱们若是没有思路能够在脑子里把这个过程再过一遍,可是咱们以后的题解就不会再把整个过程走一遍了,而是直接用动态规划来解题。

可能你会问面试题这么多,到底哪一道应该用动态规划?如何判断?

其实最准确的办法就是看题目中的给定的问题,这个问题能不能被分解为子问题,再根据子问题的解是否能够得出原问题的解。

固然上面的方法虽然准确,可是须要必定的经验积累,咱们能够用一个虽然不那么准确,可是足够简单粗暴的办法,若是题目知足如下条件之一,那么它大几率是动态规划题目:

  • 求最大值,最小值
  • 判断方案是否可行
  • 统计方案个数

欢迎关注公众号:

相关文章
相关标签/搜索