• 数据结构——动态规划


    前言

    “动态规划”在大一时,就知道了这个词,当时觉得好难好高大上,从此心生畏惧,闻词色变,心理阴影一直留存到现在。

    在校招时,也多次被问到动态规划相关的题目。

    本篇从一道经典动态规划题目说起,慢慢展开。

    从题目讲起

    【换钱的方法数】

    给定数组 arr,arr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币都可以使用任意张,再给定一个整数 aim 代表要找的钱数,求换钱有多少种方法。

    【举例】

    arr = [5, 10, 25, 1],aim = 15

    则组成 15 元的方法共有 6 种,即 1 张 10 元和 1 张 5元、1 张 10 元 5 张 1 元、3 张 5 元、2张 5 元和 5 张 1 元、1 张 5 元和 10 张 1 元、 15 张 1 元。

    【解析】

    这道题目足够的经典,经典到校招时面试官面我第一个问题就是这个 = =。

    之所以经典,是因为这道题通过暴力递归、记忆搜索和动态规划这 3 种方法均可以解决,同时这 3 种方法也是趋向最优解的过程。

    通常情况下,可以通过暴力递归解决的问题都可以通过,暴力递归 -> 记忆搜索 -> 动态规划,这样的优化轨迹来进行优化。

    从暴力递归到动态规划

    暴力递归方法:

    如果 arr[5, 10, 25, 1],aim = 100,分析如下:

    • 取 0 张 5 元,让剩下的 arr[1...N-1](即 10、25、1 元)种货币,组成 100 元,求其方法数,结果记为 r1;
    • 取 1 张 5 元,让剩下的 arr[1...N-1] 种货币,组成 100-5 元,求其方法数,结果记为 r2;
    • 取 2 张 5 元,让剩下的 arr[1...N-1] 种货币,组成 90 元,求其方法数,结果记为 r3;
    • ...
    • 取 20 张 5 元,让剩下的 arr[1...N-1] 中货币,组成 0 元,求其方法数,结果记为 r21;

    那么 r0 + r1 + r2 + r3 + ... + r21 即是总的方法数。

    在上面的分析中,“让剩下的 arr[..],组成。。。,求其方法数”,这句话其实就表示一个递归的过程。

    可定义递归方法 solve(int index, int[] arr, int aim),其中 index 表示用 arr[index...N-1] 种货币,组成 aim 元的方法数。源码如下:

     1     private int process(int[] arr, int aim) {
     2         if (arr == null || arr.length == 0 || aim < 0)
     3             return 0;
     4         return solve(0, arr, aim);
     5     }
     6 
     7     private int solve(int index, int[] arr, int aim) {
     8         int res = 0;
     9         if (index == arr.length) {
    10             res = aim == 0 ? 1 : 0;
    11         } else {
    12             for (int i = 0; arr[index] * i <= aim; i++) {
    13                 res += solve(index + 1, arr, aim - arr[index] * i);
    14             }
    15         }
    16         return res;
    17     }
    暴力递归方法

    暴力递归方法的时间复杂度为 O(aim^N),在暴力递归的过程中,其中有很多都是不必要的重复计算,比如当计算过 0 张 5 元加 1 张 10 元后,需要进行 arr[25, 1],aim = 90 的递归,同样的,当计算过 2 张 5 元加 0 张 10 元后,需要进行的也是 arr[25, 1],aim = 90 的递归,显然这两次的递归方法入参是相同的,是完全不必要的计算。

    如何减少不必要的计算?因为重复调用的递归方法入参是相同的(因为 arr 为全局变量,所以可以忽略,即只考虑 index 和 aim),所以将第一次调用的返回值保存起来,当下次调用递归方法时,首先判断该入参所对应的结果是否已经保存,如果存在对应的结果,则直接获取,不再进行递归计算。

    记忆搜索方法:

    为了保存入参对应的结果,这里通过二维数组来保存,即:dp[index][aim]。因为 dp[][] 为整数二维数组,所以其初始值为 0,这里我们就要区分是否进行过递归计算,如果入参对应的值在 dp 中没有保存,则进行递归计算,如果计算的结果为 0,则保存至 dp 中的值为 -1,以此来区分没有进行过递归计算和计算结果为0这两种情况。源码如下:

     1     private int process(int[] arr, int aim) {
     2         if (arr == null || arr.length == 0 || aim < 0)
     3             return 0;
     4         int[][] dp = new int[arr.length + 1][aim + 1];
     5         return solve(0, arr, aim, dp);
     6     }
     7 
     8     private int solve(int index, int[] arr, int aim, int[][] dp) {
     9         int res = 0;
    10         if (index == arr.length) {
    11             res = aim == 0 ? 1 : 0;
    12         } else {
    13             int dpV;
    14             for (int i = 0; arr[index] * i <= aim; i++) {
    15                 dpV = dp[index + 1][aim - arr[index] * i];
    16                 if (dpV != 0) {
    17                     res += dpV == -1 ? 0 : dpV;
    18                 } else {
    19                     res += solve(index + 1, arr, aim - arr[index] * i, dp);
    20                 }
    21             }
    22         }
    23         dp[index][aim] = res == 0 ? -1 : res;
    24         return res;
    25     }
    记忆搜索方法

    其实记忆搜索方法可以说是一种特别的动态规划方法,下面看经典的动态规划解决方法。

    动态规划方法:

    如果 arr 长度为 N,则生成行数为 N,列数为 aim + 1 的矩阵 dp。如下图所示:

    dp[i][j] 表示在使用 arr[0...i] 货币的情况下,组成钱数 j 需要 dp[i][j] 种方法。

    那么,dp[i][j] 就等于:

    1. dp 矩阵的第一行、第一列比较特殊,已在上图中说明,不再赘述
    2. 除第一行、第一列以外的位置,记为位置 dp[i][j],那么 dp[i][j] 就是以下值的累加:
      1. 完全不使用 arr[i] 货币,只使用 arr[0...i-1] 货币时,方法数为 dp[i-1][j]
      2. 使用 1 张 arr[i] 货币,剩下的钱使用 arr[0...i-1] 货币组成时,方法数为 dp[i-1][j-1*arr[i]]
      3. 使用 2 张 arr[i] 货币,剩下的钱使用 arr[0...i-1] 货币组成时,方法数为 dp[i-1][j-2*arr[i]]
      4. ...
      5. 使用 k 张 arr[i] 货币,剩下的钱使用 arr[0...i-1] 货币组成时,方法数为 dp[i-1][j-k*arr[i]] ,其中 j - k * arr[i] >= 0

    那么最终上图的右下角,dp[N-1][aim] 的值就是最终答案。源码如下:

     1 private int dynamic1(int[] arr, int aim) {
     2         int[][] dp = new int[arr.length][aim + 1];
     3         for (int i = 0;i < arr.length;i++) {
     4             dp[i][0] = 1;
     5         }
     6         for (int i = 1;i * arr[0] <= aim;i++) {
     7             dp[0][i * arr[0]] = 1;
     8         }
     9         for (int i = 1;i < arr.length;i++) {
    10             for (int j = 1;j <= aim;j++) {
    11                 int num = 0;
    12                 for (int k = 0;k * arr[i] <= j;k++) {
    13                     num += dp[i - 1][j - k * arr[i]];
    14                 }
    15                 dp[i][j] = num;
    16             }
    17         }
    18         return dp[arr.length - 1][aim];
    19     }
    动态规划方法

    现在来做个小结

    • 递归方法 -> 记忆搜索方法:递归方法存在大量的重复计算,优化为记忆搜索方法后,通过二维数组保存计算结果的方式,避免了重复计算,典型的用空间换时间的方法
    • 记忆搜索方法 -> 动态规划方法:记忆搜索方法的时间复杂度为 O(N * aim^2),记忆搜索方法也是一种动态规划,不过它对递归过程进行了记录,避免重复的递归过程,而动态规划方法,不仅记录了计算的过程(即 dp 矩阵),同时也规定了计算的过程(记忆搜索方法的每一个结果都依赖于下一次的递归结果,所以计算过程是未知的,而动态规划的每一个结果都依赖于上一次的计算结果,所以计算过程是已知的)。
      这两种方法有好有坏,比如,arr[10000, 1000, 100],aim = 100000 时,如果用动态规划方法,则要计算 3 * 100000 次,其中 aim = (0~99、101~999、1001~9999) 都是不需要计算的,因为通过 arr 数组的币种不可能组成这些钱数,但通过记忆搜索方法,就不会存在这种情况,它只对必须要计算的递归过程进行记录。

    动态规划方法的进一步优化

    在普通的动态规划方法中,其中最复杂的过程就是计算除第一行、第一列以为的 dp[i][j],下面直接截图过来方便描述:

    上图的步骤 2 中,第 1 种情况的方法数为 dp[i-1][j],而第 2 种情况到第 k 种情况的累加值,其实就等于 dp[i][j-arr[i]]。

    为什么是 dp[i][j-arr[i]] 呢?

    • 第 2 种情况到第 k 种情况的累加,含义为:使用 1~k 张 arr[i] 货币,剩下的钱使用 arr[0...i-1] 货币去组成的方法数
    • dp[i][j-arr[i]] 的含义为:使用 1 张 arr[i] 货币,剩下的钱使用 arr[0...i] 货币去组成的方法数

    其实这两种的含义是相同的,如果将 dp[i][j-arr[i]] 写成 dp[i][j-1*arr[i]],聪明的你是不是已经明白了呢?

    优化后的动态规划方法,源码如下:

     1     private int dynamic2(int[] arr, int aim) {
     2         int[][] dp = new int[arr.length][aim + 1];
     3         for (int i = 0;i < arr.length;i++) {
     4             dp[i][0] = 1;
     5         }
     6         for (int i = 1;i * arr[0] <= aim;i++) {
     7             dp[0][i * arr[0]] = 1;
     8         }
     9         for (int i = 1;i < arr.length;i++) {
    10             for (int j = 1;j <= aim;j++) {
    11                 dp[i][j] = dp[i - 1][j] + (j-arr[i] >= 0 ? dp[i][j - arr[i]] : 0);
    12             }
    13         }
    14         return dp[arr.length - 1][aim];
    15     }
    优化的动态规划方法

    可见 for 循环嵌套为两层,所以优化后的动态规划方法的时间复杂度为:O(N*aim)

  • 相关阅读:
    设计模式(三)--观察者模式
    设计模式(二)--单例模式
    tornado 资源
    复习 网络通信协议
    设置允许远程连接MySQL (Ubuntu为例)
    ubuntu 下安装ssh服务
    Python 运算内建函数
    py知识点拾遗之sort(),sorted(),reverse(),reversed()
    SQLite安装 以及 SQLite header and source version mismatch错误解决 (In debian)
    debian折腾笔记
  • 原文地址:https://www.cnblogs.com/zhengbin/p/7476713.html
Copyright © 2020-2023  润新知