【数据结构与算法】动态规划实战入门

·  阅读 737

引入

20211024205956.png

本文主要内容就是描述将一个使用暴力递归解决的问题如何一步一步改成动态规划的。

整体脉络是首先要找到一种尝试的方法,如果已经确定了尝试方法,就可以只通过尝试方法的本身来去做优化。尝试方法本身是和题目业务有关的,本质上就是一种递归的调度方式。在递归的调度方式确定了之后,接下来的优化和原始的递归含义是没有太大关系的,从递归的结构就能改出一个动态规划的版本。

从暴力递归改成动态规划的路线中,最好改的就是记忆化搜索的动态规划,暴力递归使用固定的策略就可以改出记忆化搜索的动态规划。

比记忆化搜索性能更好的就是严格表结构的动态规划,暴力递归也可以使用固定的策略改出严格表结构的动态规划。

在某些问题上,记忆化搜索的动态规划和严格表结构的动态规划有着相同的时间复杂度,但是严格表结构的动态规划由于已经推导出来位置依赖关系,由此可以在严格表结构的基础上进行更进一步的优化。

机器人运动问题

1. 题目

20211021201319.png

int型整数N,表示有N个位置(1~N)。

int型整数s,表示机器人一开始停在哪个位置。

int型整数e,表示机器人结束运动的位置。

int型整数k,表示机器人必须走k步。

整个模型就是一个机器人从 s 走 k 步到达 e,在走的过程中,可以向左走也可以向右走,但是不能越界和原地踏步。

比如说机器人来到 1 位置下一步只能向右走到 2 位置;机器人来到 N 位置下一步只能向左走到 N-1 位置。

一个机器人在只能走 k 步的情况下,从 s 到达 e ,一共有多少种走法?

2. 暴力递归

本道题的尝试方法就是:在k步内所有可能的路径都走一遍,然后查看最后停止的位置。

public static int walkWays(int N, int s, int e, int k) {
    if (s < 1 || s > N || e < 1 || e > N || N < 1) {
        return 0;
    }

    return process(N, e, k, s);
}

/**
 * @param N 表示有N个位置(1~N)
 * @param e 表示机器人结束运动的位置
 * @param rest 还有多少步数
 * @param cur 当前位置
 * @return
 */
public static int process(int N, int e, int rest, int cur) {
    // basecase
    // 如果步数走完了,停止的位置如果在e位置则是一种走法,如果不在e位置则此种走法失败
    if (rest == 0) {
        return cur == e ? 1 : 0;
    }

    // 如果当前来到1,下一步只能走2
    if (cur == 1) {
        return process(N, e, rest - 1, 2);
    }

    // 如果当前来到N,下一步只能走N-1
    if (cur == N) {
        return process(N, e, rest - 1, N - 1);
    }

    // 如果当前来到到既不是1,也不是N,下一步既可以向左走,也可以向右走
    return process(N, e, rest - 1, cur - 1) + process(N, e, rest - 1, cur + 1);
}
复制代码

由如上对于递归方法的设计,有4个参数,前两个 N 和 e 是固定参数,后两个 rest 和 cur 是可变参数。

固定参数对递归方法的状态没有影响,可变参数是描述递归状态的唯一指标,因此只要确定了方法的可变参数,就相当于确定了递归方法什么时候结束。

那么我们思考一下这个递归方法被我们以什么样的方式调用呢?

我们首先将该递归方法简化,省略两个固定参数,只保留两个可变参数,假设为:p( int rest,int cur )。

按照原题还是从 p( 4,2 ),我们来分析一下递归调用过程:

20211022162142.png

如图所示,在递归调用过程中,调用到 P( 2,2 ) 时,需要将 P( 2,2 ) 继续递归展开,直到完全展开到最底层的情况后依次向上汇总,才能得到 P( 2,2 ) 的值。而计算完一个 P( 2,2 ) ,下次再次遇到 P( 2,2 ) 时,还是得按照上面的流程将 P( 2,2 ) 递归展开,这样一点都没有必要,进行了大量的重复计算,这就是为什么此方法被称为暴力递归。

暴力递归解决该问题的时间复杂度如何估算?

最差情况是机器人当前位置在可运动范围的中间,机器人每一步都可以向左走或者向右走,整个递归调用过程可以整体看作是一棵深度为 k 的二叉树,因此时间复杂度为O(2^k)。

如果存在一个表结构,将 P( 2,2 ) 这个递归过程的计算结果记录到这个表结构中,如果再次遇到 P( 2,2 ) 就可以直接从这个表结构中拿结果,不需要再将 P( 2,2 ) 递归展开了。

也就是说,我们使用了更多的空间,来减少暴力递归的时间

那么现在还有一个问题,就是只要确定了递归方法的可变参数,那么就能一定能确定返回值嘛?也就是说如上图所示,P( 3,1 ) 调用的 P( 2,2 ) 和 P( 3,3 ) 调用的 P( 2,2 ),这两个 P( 2,2 ) 的返回值一样嘛?

一样。具备这种特性的尝试叫做无后效性尝试,也就是说之前的决定不会影响后面决定的结果。这种尝试类型非常适合改成动态规划

一般在面试过程中出的动态规划题目都可以写出无后效性尝试方法。

3. 记忆化搜索DP

暴力递归版本 ——> 记忆化搜索版本

记忆化搜索相当于在原来暴力递归的基础上添加缓存结构,这个缓存结构就是后面所说的dp表。

修改流程:

  1. 有几个可变参数就创建一个几维的dp表。

  2. 确定每个可变参数的变化范围,从而确定dp表的规模,也就是dp表各个维度的初始容量。

    一般情况下,假如一个可变参数的变化范围是 1~N,那么我们会给该参数在dp数组中的对应维度开 N+1 的初始空间,该维度的第 0 位我们不会去使用,只使用第 1 位到第 N 位的空间。

  3. 将dp表的每一个位置初始化。

    一般情况下,都初始化为 -1,-1 表示该位置所代表的递归状态没有被计算过。

  4. 将dp表加入原递归方法的形参列表,作为参数之一。

    让递归方法带着dp表一起递归。

  5. 使用缓存结构,具体使用方法为:如果当前递归状态所对应的dp表位置的值不是 -1,缓存命中,那么直接返回dp表对应位置的值。

  6. 建立缓存结构,具体建立方法为:如果当前递归状态所对应的dp表位置的值是 -1,缓存没有命中,将计算结果存入dp表对应位置,再继续递归展开。

代码:

public static int walkWays(int N, int s, int e, int k) {
    if (s < 1 || s > N || e < 1 || e > N) {
        return 0;
    }
	
    // 一维是还有几步结束,二维是当前位置
    int[][] dp = new int[k + 1][N + 1];

    // 初始化缓存
    for (int i = 0; i <= k; i ++) {
        for (int j = 0; j <= N; j ++) {
            dp[i][j] = -1;
        }
    }

    return process(N, e, k, s, dp);
}

/**
 * @param N 表示有N个位置(1~N)
 * @param e 表示机器人结束运动的位置
 * @param rest 还有多少步数
 * @param cur 当前位置
 * @param dp 缓存结构
 * @return
 */
public static int process(int N, int e, int rest, int cur, int[][] dp) {
    // 命中缓存
    if (dp[rest][cur] != -1) {
        return dp[rest][cur];
    }
	
    // 没有命中缓存
    // basecase
    // 如果步数走完了,停止的位置如果在e位置则是一种走法,如果不在e位置则此种走法失败
    if (rest == 0) {
        dp[rest][cur] = cur == e ? 1 : 0;
    }
    // 如果当前来到1,下一步只能走2
    else if (cur == 1) {
        dp[rest][cur] = process(N, e, rest - 1, 2, dp);
    }
    // 如果当前来到N,下一步只能走N-1
    else if (cur == N) {
        dp[rest][cur] = process(N, e, rest - 1, N - 1, dp);
    }
    // 如果当前来到到既不是1,也不是N,下一步既可以向左走,也可以向右走
    else {
        dp[rest][cur] = process(N, e, rest - 1, cur - 1, dp) + process(N, e, rest - 1, cur + 1, dp);
    }

    return dp[rest][cur];
}
复制代码

记忆化搜索解决该问题的时间复杂度如何估算?

dp表的规模是 k×N,dp表中的 k×N 个位置在计算时只计算一次,同一个位置在重复访问时直接返回,代价是O(1),因此记忆化搜索解决该题的时间复杂度是O(k×N)。

4. 严格表结构DP

暴力递归版本 ——> 严格表结构的动态规划版本

严格表结构的动态规划就不需要使用递归了,也不需要将dp表中每个位置都初始化为 -1 了,我们通过找到位置与位置的依赖关系,然后规定好依赖的顺序,最后使用迭代的方式将暴力递归版本中调用递归函数的代码改成dp表的替代即可。

在改成严格表结构的动态规划的过程中建议边画图边分析,如下先将架子搭好:

20211026211524.png

修改流程:

  1. 分析dp表中有哪些位置是无效的

    20211023100120.png

    无效位置有的题有,有的没有。有的话在遍历时直接跳过,不需要初始值。

  2. 通过原递归方法中的basecase,分析dp表中有哪些位置是能够直接得到答案的

    20211023100245.png

    if (rest == 0) {
        return cur == e ? 1 : 0;
    }
    复制代码

    由代码可得:e = 4,当 rest = 0 && cur = 4 时可以直接得到 dp[0][4] = 1;当 rest = 0 && cur != 4 && cur != 0 时可以直接得到 dp[0][cur] = 0。

  3. 通过题意,确定终止位置

    在本题中,rest = 4 && cur = 2 是终止位置,在 dp[4][2] 上做个标记。

    20211023103927.png

    终止位置也是由直接得到答案的位置通过位置依赖最终要推向的位置。

  4. 通过原递归方法推出边界位置的值的位置依赖

    20211023102920.png

    • 如下代码推出:dp[rest][1] 都依赖 dp[rest - 1][2]

      if (cur == 1) {
          return process(N, e, rest - 1, 2);
      }
      复制代码
    • 如下代码推出:dp[rest][N] 都依赖 dp[rest - 1][N - 1]

      if (cur == N) {
          return process(N, e, rest - 1, N - 1);
      }
      复制代码

    推出位置依赖后,边界位置的值可以通过位置依赖直接将依赖位置的值拷贝到自身即可。

  5. 通过原递归方法推出普遍位置的值的位置依赖

    20211023102950.png

    如下代码推出:dp[rest][cur] = dp[rest - 1][cur - 1] + dp[rest - 1][cur + 1]

    return process(N, e, rest - 1, cur - 1) + process(N, e, rest - 1, cur + 1);
    复制代码
  6. 确定依赖计算的顺序

    因为本题中每个位置的依赖要不然依赖相邻行的左上方的位置或右上方的位置,要不然同时依赖相邻行的左上方的位置和右上方的位置,所以本题中依赖计算的顺序是从上到下,从左到右。

    其实从上到下,从右到左也是可以的,因为同一行各个位置之间没有位置依赖关系。

    如下题目如果同一行没有位置依赖关系,就不特殊指出了,默认为从左到右。

  7. 通过位置依赖关系和依赖计算顺序,dp中每一个位置的值都能直接得到,一直推到终止位置,得到答案

    20211023104510.png

代码:

public static int walkWays(int N, int s, int e, int k) {
    if (N < 1 || s < 1 || s > N || e < 1 || e > N) {
        return 0;
    }

    int[][] dp = new int[k + 1][N + 1];

    // 当cur==0时,无论rest是多少都是不符合题意的,全部置-1
    for (int rest = 0; rest <= k; rest ++) {
        dp[rest][0] = -1;
    }

    // 当rest==0时,无论cur是都少都可以直接得到是否到达终点的判断
    for (int cur = 1; cur <= N; cur ++) {
        dp[0][cur] = 0;
    }
    dp[0][4] = 1;

    // 从上往下,从左往右根据位置依赖关系填表
    // cur==0和rest==0的情况已被规避
    for (int rest = 1; rest <= k; rest ++) {
        for (int cur = 1; cur <= N; cur ++) {
            if (cur == 1) {
                dp[rest][cur] = dp[rest - 1][2];
            } else if (cur == N) {
                dp[rest][cur] = dp[rest - 1][N - 1];
            } else {
                dp[rest][cur] = dp[rest - 1][cur - 1] + dp[rest - 1][cur + 1];
            }
        }
    }

    return dp[4][2];
}
复制代码

严格表结构的动态规划和记忆化搜索是有区别的,记忆化搜索不整理位置之间的依赖关系,而严格表结构的动态规划是需要考虑位置之间的依赖顺序的。

严格表结构的动态规划解决该问题的时间复杂度如何估算?

从dp表的确定值通过位置依赖一步步推到初始位置的代价就是整体的时间复杂度。得到每个位置的值的代价是O(1),dp表的规模是 k×N,因此严格表结构的动态规划解决该题的时间复杂度也是O(k×N)。记忆化搜索的方法和严格表结构的动态规划方法等效。

为什么到现在没有出现状态转移方程?

状态转移方程就是暴力递归中尝试的方法,只要确定尝试方法,状态转移方程就出来了。千万不要反着想,看到题目先去列状态转移方程。如果这样去学,永远搞不定动态规划,因为你对尝试的模型一点都不熟悉。一定要从最初的暴力递归一步一步稳扎稳打到最终写出优化版本才是学动态规划最好的路线,不要去想状态转移方程,新手也很难写出状态转移方程。

最少硬币问题

1. 题目

有一个正数数组coins存放当前拥有的所有硬币,数组中每一个位置代表一枚硬币,每一个位置的值是该硬币的面额,有可能有重复值。现在给你一个总额total,求出最少用多少硬币能够正好凑出total?

假如当前 coins = { 2,7,3,5,3 },total = 10;则求出最少使用 2 枚硬币(7 + 3)可以凑出total。

2. 暴力递归

该题的尝试方法就是从左往右每一个硬币选择要还是不要,最终决策出一个最少硬币且能正好凑出total的方案的硬币数。

public static int minCoins(int[] coins, int total) {
    if (coins == null || coins.length == 0) {
        return 0;
    }

    return process(coins, 0, total);
}

/**
 * @param coins 存放硬币的数组
 * @param coinIndex 当前硬币的在数组中的下标
 * @param restMoney 要凑整total还需要的钱数
 * @return 当前决策路径需要的最少的硬币数
 */
public static int process(int[] coins, int coinIndex, int restMoney) {
    // basecase1
    // 如果该条尝试路径还有硬币没有选择,就已经凑不整total了,该条尝试路径失败
    if (restMoney < 0) {
        return -1;
    }
    
    // basecase2
    // 如果该条路径正好凑整total,该条路径成功,当前不需要再选择硬币,直接返回
    if (restMoney == 0) {
        return 0;
    }
    
    // basecase3
    // 如果该条尝试路径所有硬币都可以选择,但还是没有凑整total,该条尝试路径失败
    if (restMoney > 0 && coinIndex == coins.length) {
        return -1;
    }

    // 如果该条尝试路径还有硬币没有选择,还没有凑整total,做决策

    // 不选择当前硬币
    int p1 = process(coins, coinIndex + 1, restMoney);
    // 选择当前硬币
    int p2 = process(coins, coinIndex + 1, restMoney - coins[coinIndex]);

    // 无论选不选当前硬币都不能成功凑出total
    if (p1 == -1 && p2 == -1) {
        return -1;
    } else {
        // 不选择当前硬币不能凑出total,选择当前硬币可以凑出total
        if (p1 == -1) {
            return p2 + 1;
        }
        // 不选择当前硬币可以凑出total,选择当前硬币不能凑出total
        else if (p2 == -1) {
            return p1;
        }
        // 不选择当前硬币和选择当前硬币都可以凑出total
        else {
            return Math.min(p2 + 1, p1);
        }
    }
}
复制代码

由如上对于递归方法的设计,有3个参数,第一个参数 coins 是固定参数,后两个 coinIndex 和 restMoney 是可变参数。

3. 记忆化搜索DP

暴力递归版本 ——> 记忆化搜索版本

在将暴力递归版本改成记忆化搜索的版本步骤和上面一致,需要注意的点是:

  • 在确定dp表规模的时候,dp表的二维所代表的 restMoney 是可能会小于 0 的,可以认为dp表的纵坐标 rest = 0 左侧全都是 -1,但是不需要在dp表中进行实现。因此可以将如下代码放到判断缓存是否命中操作的上方,相当于是一个硬逻辑。同样也相当于是一个缓存,但是无需和dp表进行交互,只是人为想象的缓存,相当于如下代码和判断缓存是否命中的代码共同组成一个完整的判断缓存是否命中的代码。

    if (restMoney < 0) {
        return -1;
    }
    复制代码
  • 在初始化dp表的时候,因为 -1 在解决过程中都有实际的含义,所以可以将dp表的所有位置初始化为 -2。

    -1 表示算过但是是无效解。

代码:

public static int minCoins(int[] coins, int total) {
    if (coins == null || coins.length == 0) {
        return 0;
    }

    int[][] dp = new int[coins.length + 1][total + 1];

    for (int i = 0; i <= coins.length; i ++) {
        for (int j = 0; j <= total; j ++) {
            dp[i][j] = -2;
        }
    }

    return process(coins, 0, total, dp);
}


/**
 * @param coins 存放硬币的数组
 * @param coinIndex 当前硬币的在数组中的下标
 * @param restMoney 要凑整total还需要的钱数
 * @param dp 缓存结构
 * @return 当前决策路径需要的最少的硬币数
 */
public static int process(int[] coins, int coinIndex, int restMoney, int[][] dp) {
    // 三个都是basecase
    // 如果该条尝试路径还有硬币没有选择,就已经凑不整total了,该条尝试路径失败
    if (restMoney < 0) {
        return -1;
    }

    // 判断缓存是否命中
    if (dp[coinIndex][restMoney] != -2) {
        return dp[coinIndex][restMoney];
    }

    // 如果该条路径正好凑整total,该条路径成功,当前不需要再选择硬币,直接返回
    if (restMoney == 0) {
        dp[coinIndex][restMoney] = 0;
    }
    // 如果该条尝试路径所有硬币都可以选择,但还是没有凑整total,该条尝试路径失败
    else if (restMoney > 0 && coinIndex == coins.length) {
        dp[coinIndex][restMoney] = -1;
    }
    // 如果该条尝试路径还有硬币没有选择,还没有凑整total,做决策
    else {
        // 不选择当前硬币
        int p1 = process(coins, coinIndex + 1, restMoney, dp);
        // 选择当前硬币
        int p2 = process(coins, coinIndex + 1, restMoney - coins[coinIndex], dp);

        // 无论选不选当前硬币都不能成功凑出total
        if (p1 == -1 && p2 == -1) {
            dp[coinIndex][restMoney] = -1;

        } else {
            // 不选择当前硬币不能凑出total,选择当前硬币可以凑出total
            if (p1 == -1) {
                dp[coinIndex][restMoney] = p2 + 1;
            }
            // 不选择当前硬币可以凑出total,选择当前硬币不能凑出total
            else if (p2 == -1) {
                dp[coinIndex][restMoney] = p1;
            }
            // 不选择当前硬币和选择当前硬币都可以凑出total
            else {
                dp[coinIndex][restMoney] = Math.min(p2 + 1, p1);
            }
        }
    }
    
    return dp[coinIndex][restMoney];
}
复制代码

4. 严格表结构DP

暴力递归版本 ——> 严格表结构的动态规划版本

假设:coins[] = { 2,3,5,7,2 },total = 10。

  1. 通过原递归方法中的basecase,分析dp表中有哪些位置是能够直接得到答案的

    20211024103904.png

    if (restMoney < 0) {
        return -1;
    }
    
    if (restMoney == 0) {
        return 0;
    }
    
    if (restMoney > 0 && coinIndex == coins.length) {
        return -1;
    }
    复制代码
  2. 通过题意,确定终止位置

    在本题中,coinIndex = 0 && restMoney = 10 是终止位置,在 dp[0][10] 上做个标记,最终要返回 dp[0][10] 的值。

    20211024103924.png

  3. 通过原递归方法推出边界位置的值的位置依赖

    本题没有边界位置的值的位置依赖,本题所有位置依赖关系都是一样的。

  4. 通过原递归方法推出普遍位置的值的位置依赖

    20211024154703.png

    本题的位置依赖关系比较复杂,不能用一个式子表示,可以说 dp[coinIndex][restMoney] 依赖 dp[coinIndex + 1][restMoney] 和 dp[coinIndex + 1][restMoney - coins[coinIndex]] 这两个位置。

    int p1 = process(coins, coinIndex + 1, restMoney);
    int p2 = process(coins, coinIndex + 1, restMoney - coins[coinIndex]);
    
    if (p1 == -1 && p2 == -1) {
        return -1;
    } else {
        if (p1 == -1) {
            return p2 + 1;
        }
        else if (p2 == -1) {
            return p1;
        }
        else {
            return Math.min(p2 + 1, p1);
        }
    }
    复制代码
  5. 确定依赖计算的顺序

    在本题中,由于dp表中每一个位置都依赖它相邻行正下方的位置和相邻行左下方的位置,因此本题依赖计算的顺序是从下往上,从左往右。

  6. 通过位置依赖关系和依赖计算顺序能推出dp中每一个位置的值,一直推到起始位置,得到答案

    本题就不手工推了,直接让代码来推。

代码:

public static int minCoins(int[] coins, int total) {
    if (coins == null || coins.length <= 0) {
        return 0;
    }

    // row: 0 ~ coins.length
    // length: 0 ~ total
    int[][] dp = new int[coins.length + 1][total + 1];

    // 将dp表已知位置的值初始化
    for (int i = 0; i <= coins.length; i ++) {
        dp[i][0] = 0;
    }
    for (int i = 1; i <= total; i ++) {
        dp[coins.length][i] = -1;
    }

    // 推dp表每一个位置的值,从下往上,从左往右
    for (int coinIndex = coins.length - 1; coinIndex >= 0; coinIndex --) {
        for (int restMoney = 1; restMoney <= total; restMoney ++) {
            // 在DP迭代的过程中已经规避了restMoney=0和coinIndex=coins.length-1的情况,这些情况的在dp表中对应的值已经被事先填上了
            // 不选择当前硬币
            int p1 = dp[coinIndex + 1][restMoney];
            // 选择当前硬币,需要判断当前是否越界
            int p2 = -1;
            if (restMoney - coins[coinIndex] >= 0) {
                p2 = dp[coinIndex + 1][restMoney - coins[coinIndex]];
            }

            if (p1 == -1 && p2 == -1) {
                dp[coinIndex][restMoney] = -1;
            } else {
                if (p1 == -1) {
                    dp[coinIndex][restMoney] = p2 + 1;
                } else if (p2 == -1) {
                    dp[coinIndex][restMoney] = p1;
                } else {
                    dp[coinIndex][restMoney] = Math.min(p1, p2 + 1);
                }
            }
        }
    }

    return dp[0][10];
}
复制代码

硬币支付问题

1. 题目

有一个正数数组coins存放当前拥有的所有硬币,数组中每一个位置代表一枚硬币,每一个位置的值是该硬币的面额,有可能有重复值。现在给你一个总额total,求出凑出total有多少种方案?

2. 暴力递归

该题的尝试方法就是从左往右每一个位置选择要还是不要。

public static int payWays(int[] coins, int total) {
    if (coins == null || coins.length == 0 || total <= 0) {
        return 0;
    }

    return process(coins, 0, total);
}

/**
 * @param coins 存放硬币的数组
 * @param cur 当前使用到的硬币的下标,该下标往后的硬币都还没有被使用
 * @param restMoney 当前还需要凑多少钱
 * @return
 */
public static int process(int[] coins, int cur, int restMoney) {
    // 无论cur是多少,只要restMoney<0一定凑不整
    if (restMoney < 0) {
        return 0;
    }
	
    // 如果已经没有硬币可以选择,而restMoney!=0,则一定凑不整
    if (cur == coins.length) {
        return restMoney == 0 ? 1 : 0;
    }

    // 不选择当前硬币
    int p1 = process(coins, cur + 1, restMoney);

    // 选择当前硬币
    int p2 = process(coins, cur + 1, restMoney - coins[cur]);

    // 两种决策的可行的方法总数
    return p1 + p2;
}
复制代码

3. 记忆化搜索DP

public static int payWays(int[] coins, int total) {
   if (coins == null || coins.length == 0 || total <= 0) {
       return 0;
   }

   // row: cur  col: restMoney
   int[][] dp = new int[coins.length + 1][total + 1];

   // 初始化dp
   for (int i = 0; i <= coins.length; i ++) {
       for (int j = 0; j <= total; j ++) {
           dp[i][j] = -1;
       }
   }

   return process(coins, 0, total, dp);
}

public static int process(int[] coins, int cur, int restMoney, int[][] dp) {
   if (restMoney < 0) {
       return 0;
   }

   // 查看缓存是否命中
   if (dp[cur][restMoney] != -1) {
       return dp[cur][restMoney];
   }

   // 如果缓存没有命中
   if (cur == coins.length) {
       dp[cur][restMoney] = restMoney == 0 ? 1 : 0;
   } else {
       // 没有选择当前硬币
       int p1 = process(coins, cur + 1, restMoney, dp);
       // 选择了当前硬币
       int p2 = process(coins, cur + 1, restMoney - coins[cur], dp);
   	
       dp[cur][restMoney] = p1 + p2;
   }

   return dp[cur][restMoney];
}
复制代码

4. 严格表结构DP

20211025170051.png

本题在使用严格表结构的动态规划解决时,对于dp表的初始值设计有别于上一道问题,没有设置 -1,仅仅使用了 0 和 1。每一种dp表初始值的设计会根据题意有所不同,两道不同的题目即使都设置了 0 和 1,也有可能0 和 1 在两道题目中含义都是不一样的。

比如本题的 0 指的是 不是成功的方案,1 指的是 1 种成功的方案。上一题的 0 指的是 0 个硬币,1 指的是 1 个硬币。

public static int payWays(int[] coins, int total) {
    if (coins == null || coins.length == 0 || total <= 0) {
        return 0;
    }

    // row: cur  col: restMoney
    int[][] dp = new int[coins.length + 1][total + 1];

    int cur, restMoney;

    // 当restMoney==0时,只要cur不越界,都是一种可行的方案
    for (cur = 0; cur < coins.length; cur ++) {
        dp[cur][0] = 1;
    }

    // 当cur越界,无论restMoney还有多少都不是可行的方案
    for (restMoney = 0; restMoney <= total; restMoney ++) {
        dp[coins.length][restMoney] = 0;
    }

    // 从下往上,从左往右填表
    for (cur = coins.length - 1; cur >= 0; cur --) {
        for (restMoney = 1; restMoney <= total; restMoney ++) {
            // 如果restMoney<0,则永远凑不整
            if (restMoney - coins[cur] < 0) {
                dp[cur][restMoney] = 0;
            } 
            // 如果restMoney==0,是一种可行的方案
            else if (restMoney - coins[cur] == 0) {
                dp[cur][restMoney] = 1;
            } 
            // 如果restMoney>0 && cur!=coins.length,则将选择该硬币和不选择该硬币各自后续的方案数相加
            else {
                dp[cur][restMoney] = dp[cur + 1][restMoney] + dp[cur + 1][restMoney - coins[cur]];
            }
        }
    }

    return dp[0][total];
}
复制代码

拿牌问题

1. 题目

给定一个正整型数组arr,代表数值不同的纸牌排成一条线。玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌。玩家A和玩家B都绝顶聪明,请返回最后获胜者的分数。

例如:

arr = [1, 2, 100, 4]

开始时,玩家A只能拿走1或4。如果开始时玩家A拿走1,则排列变为[2, 100, 4],接下来玩家B可以拿走2或4,然后继续轮到玩家A...

如果开始时玩家A拿走4,则排列变为[1, 2, 100],接下来玩家B可以拿走1或100,然后继续轮到玩家A...

玩家A作为绝顶聪明的人不会先拿4,因为拿4之后,玩家B将拿走100。所以玩家A会先拿1,让排列变为[2, 100, 4],接下来玩家B不管怎么选,100都会被玩家A拿走。玩家A会获胜,分数为101。所以返回101。

2. 暴力递归

尝试方法在题目中就已经给出,每个人每一轮只能拿走最边上的两张牌中的一张,在所有牌都被拿完后,谁的分数高谁获胜。

本题是一种零和博弈的问题,如果玩家A拿的多,那么导致玩家B拿的就必然少。所以在决策过程中,先拿牌的人选择拿的牌一定会在拿过后让后拿牌的人在拿牌时拿的少。

public static int pokerGame(int[] arr) {
    if (arr == null || arr.length == 0) {
        return -1;
    }

     // 0~arr.length-1上先拿牌和后拿牌哪个分高哪个赢
    return Math.max(choose(arr, 0, arr.length - 1), latter(arr, 0, arr.length - 1));
}

// 拿牌的函数
public static int choose(int[] arr, int L, int R) {
    // basecase 如果只有一张牌,直接拿走
    if (L == R) {
        return arr[L];
    }

    // 选择拿左边的牌
    int p1 = latter(arr, L + 1, R) + arr[L];
    // 选择拿右边的牌
    int p2 = latter(arr, L, R - 1) + arr[R];

    // 拿牌的人一定会给自己一个更好的选择
    return Math.max(p1, p2);
}

// 拿牌的后续函数
public static int latter(int[] arr, int L, int R) {
    // basecase 如果只有一张牌,已经被拿走了
    if (L == R) {
        return 0;
    }

    // 对手先拿了左边的牌,轮到你拿牌
    int p1 = choose(arr, L + 1, R);
    // 对手先拿了右边的牌,轮到你拿牌
    int p2 = choose(arr, L, R - 1);

    // 先拿牌的人一定会给你一个更差的选择
    return Math.min(p1, p2);
}
复制代码

3. 记忆化搜索DP

public static int pokerGame(int[] arr) {
    if (arr == null || arr.length == 0) {
        return -1;
    }

    int[][] dp = new int[arr.length][arr.length];

    for (int i = 0; i < arr.length; i ++) {
        for (int j = 0; j < arr.length; j ++) {
            dp[i][j] = -1;
        }
    }

     // 0~arr.length-1上先拿牌和后拿牌哪个分高哪个赢
    return Math.max(choose(arr, 0, arr.length - 1, dp), latter(arr, 0, arr.length - 1, dp));
}

// 拿牌的函数
public static int choose(int[] arr, int L, int R, int[][] dp) {
    // 防止越界
    if (L >= arr.length || R <= 0) {
        return 0;
    }

    // 判断缓存是否命中
    if (dp[L][R] != -1) {
        return dp[L][R];
    }

    // basecase 如果只有一张牌,直接拿走
    if (L == R) {
        dp[L][R] = arr[L];
    }
    // 如果不止一张牌
    else {
        // 选择拿左边的牌
        int p1 = latter(arr, L + 1, R, dp) + arr[L];
        // 选择拿右边的牌
        int p2 = latter(arr, L, R - 1, dp) + arr[R];

        // 拿牌的人一定会给自己一个更好的选择
        dp[L][R] = Math.max(p1, p2);
    }

    return dp[L][R];
}

// 拿牌的后续函数
public static int latter(int[] arr, int L, int R, int[][] dp) {
    if (L >= arr.length || R <= 0) {
        return 0;
    }

    if (dp[L][R] != -1) {
        return dp[L][R];
    }

    // basecase 如果只有一张牌,已经被拿走了
    if (L == R) {
        dp[L][R] = 0;
    } 
    // 如果不止一张牌
    else {
        // 对手先拿了左边的牌,轮到你拿牌
        int p1 = choose(arr, L + 1, R, dp);
        // 对手先拿了右边的牌,轮到你拿牌
        int p2 = choose(arr, L, R - 1, dp);

        // 先拿牌的人一定会给你一个更差的选择
        dp[L][R] = Math.min(p1, p2);
    }

    return dp[L][R];
}
复制代码

4. 严格表结构DP

这道题是一道范围尝试的题目,这种题目从暴力递归改成动态规划特别简单。

流程:

  1. 首先分析choose函数两个可变参数的变化范围:L 和 R 的可变范围都是 0 ~ (arr.length - 1),因此可以构建出一个dp1二维表,该表规模为 arr.length × arr.length,是一个正方形。

    20211026102541.png

  2. 再分析latter函数两个可变参数的变化范围:L 和 R 的可变范围都是 0 ~ (arr.length - 1),因此可以构建出一个dp2二维表,该表规模为 arr.length × arr.length,也是一个正方形。

    20211026102553.png

  3. 分析pokerGame主函数分别需要choose函数和latter函数的什么状态,确定dp1和dp2中的终止位置。

    20211026103734.png

    由如下代码可知,dp1的终止位置是:dp1[0][arr.length-1];dp2的终止位置是:dp2[0][arr.length-1]。

    return Math.max(choose(arr, 0, arr.length - 1), latter(arr, 0, arr.length - 1));
    复制代码
  4. 分析dp1和dp2表中有哪些位置是无效的

    因为是范围上进行尝试,L 是左边界,R 是右边界,因此如果该范围是一个有效范围,L 是永远不会大于 R 的,因此dp1和dp2的左下半区是无效的。

    无效位置在计算位置依赖时不遍历到那即可,不需要初始化值。

    20211026110512.png

    所有范围上尝试模型的dp表左下半区都无效。

  5. 通过原递归方法中的basecase,分析dp1个dp2表中有哪些位置是能够直接得到答案的

    20211026110523.png

    choose方法:

    if (L == R) {
        return arr[L];
    }
    复制代码

    latter方法:

    if (L == R) {
        return 0;
    }
    复制代码
  6. 通过原递归方法推出边界位置的值的位置依赖

    本题没有边界位置的值的位置依赖,本题所有位置依赖关系都是一样的。

  7. 通过原递归方法推出普遍位置的值的位置依赖

    在原递归方法中,我们发现choose方法调用的是latter方法的子过程,latter方法调用的是choose方法的子过程,这两个方法是相互调用的,因此两个表的位置也是相互依赖的。

    • 由如下代码推出,dp1表普遍位置依赖是:dp1表中的位置依赖于在dp2表中相同位置的正下方和正左方的相邻位置。

    20211026162208.png

    int p1 = latter(arr, L + 1, R) + arr[L];
    int p2 = latter(arr, L, R - 1) + arr[R];
    
    return Math.max(p1, p2);
    复制代码
    • 由如下代码推出,dp2表普遍位置依赖是:dp2表中的位置依赖于在dp1表中相同位置的正下方和正左方的相邻位置。

    20211026162219.png

    int p1 = choose(arr, L + 1, R);
    int p2 = choose(arr, L, R - 1);
    
    return Math.min(p1, p2);
    复制代码
  8. 确定依赖计算的顺序

    因为本题是两个表相互依赖的,所以在两个表联合计算依赖的时候,可以针对某个位置和它在另一张表上的对应位置的值一起计算,从而让两张表的计算顺序保持一致。

    因为表中每一个位置依赖于另一张表的正下方和正左方相邻位置,因此可以让两张表同时从下往上,从左往右的顺序计算依赖。

代码:

public static int pokerGame(int[] arr) {
    if (arr == null || arr.length == 0) {
        return -1;
    }

    int[][] dp1 = new int[arr.length][arr.length];
    int[][] dp2 = new int[arr.length][arr.length];

    // 初始化
    for (int i = 0; i < arr.length; i ++) {
        dp1[i][i] = arr[i];
        dp2[i][i] = 0;
    }

    // 从下往上,从左往右遍历,在遍历计算位置依赖时就规避无效位置
    for (int L = arr.length - 2; L >= 0; L --) {
        for (int R = L + 1; R < arr.length; R ++) {
            dp1[L][R] = Math.max(dp2[L + 1][R] + arr[L], dp2[L][R - 1] + arr[R]);
            dp2[L][R] = Math.min(dp1[L + 1][R], dp1[L][R - 1]);
        }
    }

    return Math.max(dp1[0][arr.length - 1], dp2[0][arr.length - 1]);
}
复制代码

象棋问题

1. 问题

一个象棋棋盘,有一个棋子 "马",停在下图(0,0)位置。给定一个位置(a,b),"马" 要通过跳 k 次 "日" 去那里。求马从(0,0)通过跳 k 次 "日" 到达(a,b)方法数是多少。

20211026215340.png

2. 暴力递归

本题尝试方法题目中已经固定,就是从(0,0)位置尝试所有 "日" 最终能够到达(a,b)的方法数。

public static int chess(int a, int b, int k) {
    if (a < 0 || b < 0 || k < 0) {
        return -1;
    }

    return process(a, b, 0, 0, k);
}

/**
 * @param a 目标横坐标
 * @param b 目标纵坐标
 * @param x 当前横坐标
 * @param y 当前纵坐标
 * @param step 剩余多少步
 * @return
 */
public static int process(int a, int b, int x, int y, int step) {
    // baascase1 判断越界
    if (x < 0 || y < 0 || x > 9 || y > 8) {
        return 0;
    }

    // basecase2 判断没有步数时是否到达(a,b)
    if (step == 0) {
        return (x == a && y == b) ? 1 : 0;
    }

    // 一个普遍位置会有八种跳法
    int p1 = process(a, b, x - 2, y - 1, step - 1);
    int p2 = process(a, b, x - 1, y - 2, step - 1);
    int p3 = process(a, b, x + 1, y - 2, step - 1);
    int p4 = process(a, b, x + 2, y - 1, step - 1);
    int p5 = process(a, b, x - 2, y + 1, step - 1);
    int p6 = process(a, b, x - 1, y + 2, step - 1);
    int p7 = process(a, b, x + 1, y + 2, step - 1);
    int p8 = process(a, b, x + 2, y + 1, step - 1);

    return p1 + p2 + p3 + p4 + p5 + p6 + p7 + p8;
}
复制代码

3. 记忆化搜索DP

由于暴力递归改成记忆化搜索的方式很简单,所以往后的题目就不再写记忆化搜索的解法。

将记忆化搜索中确定dp表的维度、确定dp表的规模这些流程都放到严格表结构DP的流程中去。

4. 严格表结构DP

  1. 确定dp表维度

    本题的原递归函数中有三个可变参数,分别是:x,y,step,因此需要构建一个三维的dp表。

  2. 确定dp表规模

    x 的变化范围是:0 ~ 9;y 的变化范围是:0 ~ 8;step 的变化范围是:0 ~ k 。

    因此dp表的规模应该是 int[10][9][k + 1],是一个立方体。

    20211028000523.png

  3. 判断dp表中哪些位置是无效的

    立方体外所有的位置都是无效的。

  4. 通过原递归方法中的basecase,分析dp表中有哪些位置是能够直接得到答案的

    通过以下代码,可知当 step == 0 时,在dp表第 0 层中除了(a,b,0)是 1 以外,所有(x,y,0)都是 0 。

    if (step == 0) {
        return (x == a && y == b) ? 1 : 0;
    }
    复制代码
  5. 通过题意,确定终止位置

    由题意可得,由于马是从棋盘的(0,0)开始出发,因此终止位置是(0,0,k)。

  6. 通过原递归方法推出普遍位置的值的位置依赖

    如下代码直接就给出了位置依赖:

    int p1 = process(a, b, x - 2, y - 1, step - 1);
    int p2 = process(a, b, x - 1, y - 2, step - 1);
    int p3 = process(a, b, x + 1, y - 2, step - 1);
    int p4 = process(a, b, x + 2, y - 1, step - 1);
    int p5 = process(a, b, x - 2, y + 1, step - 1);
    int p6 = process(a, b, x - 1, y + 2, step - 1);
    int p7 = process(a, b, x + 1, y + 2, step - 1);
    int p8 = process(a, b, x + 2, y + 1, step - 1);
    
    return p1 + p2 + p3 + p4 + p5 + p6 + p7 + p8;
    复制代码
  7. 确定依赖计算的顺序

    在本题中,由于dp表中每一个位置都依赖它相邻行正下方一层的若干个位置,而同一层的若干个位置之间没有依赖关系。第 0 层的所有数据已经直接得出,因此本题依赖计算的顺序是从下往上逐层推出各个位置的值。

代码:

public static int chess(int a, int b, int k) {
    if (a < 0 || b < 0 || k < 0) {
        return -1;
    }

    int[][][] dp = new int[10][9][k + 1];

    // 初始化
    for (int i = 0; i < 10; i ++) {
        for (int j = 0; j < 9; j ++) {
            dp[i][j][0] = 0;
        }
    }
    dp[a][b][0] = 1;

    // 从下往上逐层计算
    for (int step = 1; step <= k; step ++) {
        for (int x = 0; x < 10; x ++) {
            for (int y = 0; y < 9; y ++) {
                dp[x][y][step] += getValue(dp, x - 2, y - 1, step - 1);
                dp[x][y][step] += getValue(dp, x - 1, y - 2, step - 1);
                dp[x][y][step] += getValue(dp, x + 1, y - 2, step - 1);
                dp[x][y][step] += getValue(dp, x + 2, y - 1, step - 1);
                dp[x][y][step] += getValue(dp, x - 2, y + 1, step - 1);
                dp[x][y][step] += getValue(dp, x - 1, y + 2, step - 1);
                dp[x][y][step] += getValue(dp, x + 1, y + 2, step - 1);
                dp[x][y][step] += getValue(dp, x + 2, y + 1, step - 1);
            }
        }
    }

    return dp[0][0][k];
}

public static int getValue(int[][][] dp, int x, int y, int step) {
    // 判断越界
    if (x < 0 || y < 0 || x > 9 || y > 8) {
        return 0;
    }

    return dp[x][y][step];
}
复制代码

越界死亡问题

1. 问题

有一个人叫Bob,他在一个规格为 M × N 的区域内行走。Bob一开始的位置是(a,b),每一次移动等概率向上、下、左、右移动一格。规定Bob只能移动 k 次,如果Bob越界,就会死亡。

问:Bob活下来的概率是多少?

20211027215010.png

2. 暴力递归

Bob如何能活下来?一定是在移动了 k 次的过程后没有越界,并且在移动 k 次的过程中也没有越界。

首先要知道如何Bob越界不会死,一共会有多少种移动路径?4^k ;其次计算Bob能活下来的方法数是多少,然后相除得到的结果就是Bob活下来的概率。

public static double bobWalk(int M, int N, int a, int b, int k) {
    if (M < 0 || N < 0 || k < 0) {
        return -1;
    }

    int aliveCount = bobAlive(M, N, a, b, k);

    return (double) aliveCount / Math.pow(4, k);
}

public static int bobAlive(int M, int N, int a, int b, int step) {
    // 判断越界
    if (a == M || b == N || a < 0 || b < 0) {
        return 0;
    }

    // 走完所有步数没有越界,是一种可以存活的路径
    if (step == 0) {
        return 1;
    }

    // 上下左右走一步的方法数
    int p1 = bobAlive(M, N, a - 1, b, step - 1);
    int p2 = bobAlive(M, N, a + 1, b, step - 1);
    int p3 = bobAlive(M, N, a, b - 1, step - 1);
    int p4 = bobAlive(M, N, a, b + 1, step - 1);

    return p1 + p2 + p3 + p4;
}
复制代码

3. 严格表结构DP

  1. 确定dp表维度

    本题的原递归函数中有三个可变参数,分别是:a,b,step,因此需要构建一个三维的dp表。

  2. 确定dp表规模

    a 的变化范围是:0 ~ (M-1);b 的变化范围是:0 ~ (N-1);step 的变化范围是:0 ~ k 。

    因此dp表的规模应该是 int[M][N][k + 1],是一个立方体。

    20211028002007.png

  3. 判断dp表中哪些位置是无效的

    立方体外所有的位置都是无效的。

  4. 通过原递归方法中的basecase,分析dp表中有哪些位置是能够直接得到答案的

    通过以下代码,可知当 step == 0 时,在dp表第 0 层中所有(a,b,0)都是 1 。

    if (step == 0) {
        return 1;
    }
    复制代码
  5. 通过题意,确定终止位置

    由题意可得,由于人是从区域中(a,b)开始出发,因此终止位置是(a,b,k)。

  6. 通过原递归方法推出普遍位置的值的位置依赖

    如下代码直接就给出了位置依赖:

    int p1 = bobAlive(M, N, a - 1, b, step - 1);
    int p2 = bobAlive(M, N, a + 1, b, step - 1);
    int p3 = bobAlive(M, N, a, b - 1, step - 1);
    int p4 = bobAlive(M, N, a, b + 1, step - 1);
    
    return p1 + p2 + p3 + p4;
    复制代码
  7. 确定依赖计算的顺序

    在本题中,由于dp表中每一个位置都依赖它相邻行正下方一层的若干个位置,而同一层的若干个位置之间没有依赖关系。第 0 层的所有数据已经直接得出,因此本题依赖计算的顺序是从下往上逐层推出各个位置的值。

代码:

/**
 * @param M 一共多少行
 * @param N 一共多少列
 * @param a 当前行数
 * @param b 当前列数
 * @param k 总步数
 * @return
 */
public static double bobWalk(int M, int N, int a, int b, int k) {
    if (M < 0 || N < 0) {
        return -1;
    }

    // 确定dp表的维度与规模
    int[][][] dp = new int[M][N][k + 1];

    // dp表中可以直接确定的值:当step==0时,一定是活着的
    for (int i = 0; i < M; i ++) {
        for (int j = 0; j < N; j ++) {
            dp[i][j][0] = 1;
        }
    }

    // 位置依赖计算顺序是:从下网上逐层计算
    for (int step = 1; step <= k; step ++) {
        for (int x = 0; x < M; x ++) {
            for (int y = 0; y < N; y ++) {
                // 上下左右四个方向能够存货的方法数之和
                dp[x][y][step] += getValue(dp, M, N, x - 1, y, step - 1);
                dp[x][y][step] += getValue(dp, M, N, x + 1, y, step - 1);
                dp[x][y][step] += getValue(dp, M, N, x, y - 1, step - 1);
                dp[x][y][step] += getValue(dp, M, N, x, y + 1, step - 1);
            }
        }
    }

    return dp[a][b][k] / Math.pow(4, k);
}

public static int getValue(int[][][] dp, int M, int N, int a, int b, int step) {
    // 越界判断
    if (a < 0 || a >= M || b < 0 || b >= N) {
        return 0;
    }

    return dp[a][b][step];
}
复制代码

经典货币问题

1. 题目

有一个正数无重复数组coins,该数组中每一个位置存储的是一种硬币的面值,每一种面值的硬币有无数个。现在给一个钱数total,让你使用coins中提供的硬币种类凑出来。

请问方法数一共有多少种?

2. 暴力递归

本题的尝试思路和经典背包问题一致,都是从左往右对每一种货币都尝试若干种然后组合一起,计算最后能否凑出total。

public static int payWays(int[] coins, int total) {
    if (coins == null || coins.length == 0 || total < 0) {
        return -1;
    }

    return process(coins, 0, total);
}

public static int process(int[] coins, int cur, int restMoney) {
    // 判断越界,是否所有种类的硬币都已经尝试完
    if (cur == coins.length) {
        return restMoney == 0 ? 1 : 0;
    }

    // 如果还没有尝试玩所有种类的硬币就凑出,可是一种凑钱方案
    if (restMoney == 0) {
        return 1;
    }

    int ways = 0;
    // 对于每一种硬币尝试最多的个数取决于只使用该硬币凑到当前restMoney<=0时最少的数量
    for (int count = 0; restMoney - count * coins[cur] >= 0; count ++) {
        // 下一个货币尝试时的restMoney是当前货币尝试时的restMoney-当前货币搞定的钱
        ways += process(coins, cur + 1, restMoney - count * coins[cur]);
    }

    return ways;
}
复制代码

3. 严格表结构DP

  1. 确定dp表维度

    本题的原递归函数中有两个可变参数,分别是:cur,restMoney 因此需要构建一个二维的dp表。

  2. 确定dp表规模

    cur 的变化范围是:0 ~ (coins.length-1);restMoney 的变化范围是:0 ~ total 。

    因此dp表的规模应该是 int[coins.length + 1][total + 1]。

    20211028111336.png

  3. 判断dp表中哪些位置是无效的

    表中所有位置都是有效的。

  4. 通过原递归方法中的basecase,分析dp表中有哪些位置是能够直接得到答案的

    if (cur == coins.length) {
        return restMoney == 0 ? 1 : 0;
    }
    复制代码

    由代码可得,dp[coins.length][0] 是 1,dp[coins.length][restMoney]是 0。

    20211028112756.png

  5. 通过题意,确定终止位置

    在本题中,cur = 0 && restMoney = 10 是终止位置,在 dp[0][10] 上做个标记。

    20211028112938.png

  6. 通过原递归方法推出普遍位置的值的位置依赖

    20211028162101.png

    本题的依赖关系中包含枚举的过程,只能确定每一个位置都依赖相邻正下方位置,至于依不依赖相邻左下方位置,依赖多少个,每一个位置都不一样。

    int ways = 0;
    for (int count = 0; restMoney - count * coins[cur] >= 0; count ++) {
        ways += process(coins, cur + 1, restMoney - count * coins[cur]);
    }
    复制代码
  7. 确定依赖计算的顺序

    在本题中,由于dp表中每一个位置都依赖它相邻行正下方的位置并且可能会依赖相邻行左下方的位置,因此本题依赖计算的顺序是从下往上,从左往右。

代码:

public static int payWays(int[] coins, int total) {
    if (coins == null || coins.length == 0 || total <= 0) {
        return 0;
    }

    // row: 0~coins.length-1  col: 0~total
    int[][] dp = new int[coins.length + 1][total + 1];

    // 初始化
    for (int i = 0; i <= total; i ++) {
        dp[coins.length][i] = 0;
    }
    dp[coins.length][0] = 1;

    // 从下往上,从左往右计算依赖
    for (int cur = coins.length - 1; cur >= 0; cur --) {
        for (int restMoney = 0; restMoney <= total; restMoney ++) {
            // 枚举过程
            int ways = 0;
            for (int count = 0; restMoney - count * coins[cur] >= 0; count ++) {
                ways += dp[cur + 1][restMoney - count * coins[cur]];
            }
            dp[cur][restMoney] = ways;
        }
    }

    return dp[0][total];
}
复制代码

4. 斜率优化DP

在构建了dp表,确定了位置依赖之后,如果在原递归函数中包含枚举过程的,还可以对严格表结构DP做出进一步优化。

如果不对严格表结构DP进行优化,严格表结构DP的时间复杂度是多少?

dp表的规模是 coins.length × total,但是在每计算一个位置的依赖位置时有枚举行为,最差情况就是在coins中提供有面值为 1 的币种,那么在计算位置依赖时,会计算从该位置的相邻正下方位置和其位于同一行之前的所有位置,因此这个枚举的行为就是 O(total) 的复杂度。整体的时间复杂度就是 O(coins.length × total × total) 。

枚举行为真的有必要吗?为什么?

没有。见如下分析:

20211028184713.png

如图所示,如果按照未优化的严格表结构DP,那么在计算(1,6)位置的值时,需要从(2,6)一直枚举到(2,3)和(2,0)这三个依赖位置的值求和。

通过观察位置依赖关系发现,(2,3)和(2,0)这两个位置的值和正好等价于(1,3)位置的值,且(2,3)和(2,0)两个位置横坐标正好差一个coins[1]。

因此,计算(1,6)位置的值可以只求得(2,6)位置的值,然后直接去拿(1,3)位置的值再求和即可;

同理,计算(1,3)位置的值可以只求的(2,3)位置的值,然后直接去拿(1,0)位置的值再求和即可。

以上,就是优化步骤,每个位置的值只需要获取正下方相邻位置的值和同行正左方某一个位置的值(该位置不越界)即可,无需枚举。

此种优化方式就是斜率优化

斜率优化就是:当填dp表的时候,如果有枚举行为,就观察邻近的位置能不能替代枚举行为。只和位置依赖关系有关,和原题意无关。

使用斜率优化版本的代码可能是完全没有逻辑的,很难解释为什么这样优化就可以,也没必要去解释,这就是一种通过观察然后优化的一种统一的技巧。

代码:

public static int payWays(int[] coins, int total) {
    if (coins == null || coins.length == 0 || total <= 0) {
        return 0;
    }

    // row: 0~coins.length-1  col: 0~total
    int[][] dp = new int[coins.length + 1][total + 1];

    // 初始化
    for (int i = 0; i <= total; i ++) {
        dp[coins.length][i] = 0;
    }
    dp[coins.length][0] = 1;

    // 从下往上,从左往右计算依赖
    for (int cur = coins.length - 1; cur >= 0; cur --) {
        for (int restMoney = 0; restMoney <= total; restMoney ++) {
            // 优化枚举过程
            dp[cur][restMoney] = dp[cur + 1][restMoney];
            if (restMoney - coins[cur] >= 0) {
                dp[cur][restMoney] += dp[cur][restMoney - coins[cur]];
            }
        }
    }

    return dp[0][total];
}
复制代码

尝试方法的选择

1. 评价方式

在做一道动态规划题目时,可能有多种尝试方法,我怎么知道哪一种尝试方法好?

尝试的方式如同人生,千奇百怪。怎么去尝试一个问题,这是个哲学问题。

人生无法评价好坏,但是尝试方法可以。

假设可以搞出一种尝试,那么如何评价这个尝试方法?

在评价尝试方法时,需要注意两点:

  • 可变参数的个数
  • 每一个可变参数的维度(第一原则)

2. 可变参数个数

只有一个可变参数,就是一维dp表;如果有两个可变参数,就是二维dp表;如果有三个可变参数,就是三维dp表;

可变参数的个数越少越好。因为可变参数个数越少,意味着在改动态规划时dp表的维度小,从而构建和操作dp表的代价就小。

3. 可变参数维度

以上题目中每一个可变参数都是整形int,我们将这种可变参数称作0维可变参数,因为一个整形int数在空间中只能代表一个点。0维可变参数的可变化范围是非常好估计的。

如果有一个可变参数是整形数组int[],那么该参数被称作1维可变参数。1维可变参数的可变化范围是非常大的,比如一个int[4],那么可变范围是:0000,0001,0002,0003,...,9998,9999。

尽量让代表状态的可变参数都是0维可变参数,因为1维参数的可变化范围非常大,会直接导致dp表的规模非常大。

因为可变参数的维度对dp的规模影响太大,因此在评价尝试方法好坏时,首先应该评估的指标就是可变参数的维度。

分类:
代码人生
分类:
代码人生
收藏成功!
已添加到「」, 点击更改