leetcode08.11.coins 状态转移方程的定义及优化

   题目如上,一个背包类 dp 问题。感觉该题的解题思路算是相当经典了,小记一下。

  做这个题有如下感悟:

  1、dp 是一个运筹学问题,做题时不要忽略方程的本质:用小问题表示大问题,用函数表示函数。大问题可以用小问题表示,反过来,小问题的组合可以合并为大问题。在多维 dp 中要尤其注意,这可以帮助我们将一个变量的变化转移到另一个变量上,将极大的优化我们的时间复杂度。

  2、 我们定义一个函数,并确定状态转移方程,将这个函数用其本身表示。参数的变化决定着问题的层次,参数的定义决定着问题的含义。如果需要函数有更丰富的含义(比如本题的去重),想办法用参数将其表示出来。

  3、 参数的变化必然带来状态转移关系的变化,其本质是我们对问题切割方式的改变。切割问题时,按需求切割。比如本题需要去除重复的组合,那么,我们添加使用某个硬币个数的切割维度,并将其用新的参数和状态转移关系表示出来。

  4、 再回归到递归函数的性质,其实现依赖于自身。我们的逻辑需要由直接计算转为逐步推演,在确定问题具有最优子结构后,我们只需要关注每一步该做什么。着眼于每一步的逻辑,并适当的考虑整体来验证我们划分问题的方式是否具有最优子结构性质。

  5、如何分割子问题,按照哪些维度分割?首先我们需要确定,哪些参数确定了问题规模。比如本题中 n 与 硬币面值 确定了问题规模,那么我们在分割问题时,需要按照这两个维度分割。另外,比如股票问题,股票公示价格的天数确定了问题规模,但我们单纯的按照天数分割问题是不行的。因为每天我们可以做三个动作:买入、卖出、什么也不做,而且买入和卖出的动作取决于当前我们手中有没有股票。如果仅按天数分割问题,那么我们无法确定状态转移方程,因为子问题仅能体现最优解,不能体现与解对应的我们是否持有股票的状态。

  因此分割问题,我们需要决定问题规模的参数。如果这不足以我们定义状态转移方程,将分割粒度从这些 参数的范围 缩小到 具体的每一个参数对应的具体情况 。最终要得到的是相互独立的、可以完整覆盖解空间的、可以列出状态转移关系的子问题。

  首先是一个错误的定义和实现:

/**
     * @Author Nxy
     * @Date 2020/4/29 19:26
     * @Description W(n)=W(n-5)+W(n-10)+W(n-25)+W(n-1)
     */
    public final int W(int n) {
        if (n == 0) {
            return 1;
        }
        if (n < 0) {
            return 0;
        }
        return W(n - 5) + W(n - 10) + W(n - 25) + W(n - 1);
    }

//优化为加缓存的方式
    public final int W(int n, int[] cache) {
        if (n == 0) {
            return 1;
        }
        if (n < 0) {
            return 0;
        }
        if (cache[n] != 0) {
            return cache[n];
        }
        cache[n] = W(n - 5) + W(n - 10) + W(n - 25) + W(n - 1);
        return cache[n];
    }
//优化为递推
    public final int dp(int n) {
        if (n <= 0) {
            return 0;
        }
        int[] cache = new int[n + 1];
        cache[0] = 0;
        cache[1] = 1;
        for (int i = 2; i <= n; i++) {
            int b0 = i - 1;
            int b1 = i - 5;
            int b2 = i - 10;
            int b3 = i - 25;
            if (b0 > 0) {
                cache[i] += cache[b0];
            }
            if (b1 > 0) {
                cache[i] += cache[b1];
            }
            if (b2 > 0) {
                cache[i] += cache[b2];
            }
            if (b3 > 0) {
                cache[i] += cache[b3];
            }
        }
        return cache[n];
    }
* 前面的状态转移方程 W(n)=W(n-5)+W(n-10)+W(n-25)+W(n-1) 看似没有问题,但存在着大量的重复情况
* 本质便是一个集合,不断的与自己做笛卡尔积,最终我们得到的组合在考虑顺序的情况下是唯一的,但不考虑顺序的情况下便会有很多重复项
* 比如 W(n-5) 中已经包含了 W(n-10) 虽然不同,但与不同面值的组合为 n 后,会出现重复的组合(顺序无关),我们必须去除这些重复情况
* 去除重复情况最直接的思路便是添加缓存,每多一种组合记录该组合中各面值硬币的数量,以此为依据进行去重
* 但缓存的方式会消耗大量的额外空间,且限制了我们去优化时间复杂度,因为我们不得不记录每种组合的具体组成
* 这样做已经不是在用子问题解决问题,而是在暴力的遍历解空间
* 另一种方式便是在方程中体现各面值硬币的数量,换一种问题定义的方式
* 所以,本题的核心在于,如何巧妙的在状态转移方程中体现硬币的数量;如何借助一个或几个入参,结合这些入参在状态转移方程中的表示来做到到这一点。
* 我们定义 G(i,n) 表示前 i 种硬币可以组成 n 的组合数量,那么用子问题表示便是:
* G(i, n)=G(i-1,n-0*ci)+G(i-1,n-1*ci)+G(i-1,n-2*ci)+...+G(i-1,n-k*ci)
* 其中 k 为 n/ci
* 这样一来,我们便将第 i 种面值的硬币的数量包含在了方程中:G(i-1,n-0*ci)表示包含 0 个ci的组合数;G(i-1,1*ci)表示包含 1 个 ci 的组合数
* 这样一来,G(i-1,1*ci) 与 G(i-1,n-0*ci) 中必定没有重复的组合,因为它们包含的 ci 的个数不同;同时,该表示可以覆盖所有包含 ci 的组合
* 递归表示的巧妙之处在于,复杂的问题简单化,我们确定了问题具有最优子结构,那么我们只考虑 G(i, n) 即可,一层如此,层层如此
* 子问题的组合足够全面,从不包含 ci 到全是(几乎) ci 的组合足够覆盖全部解空间,且不存在重复的组合,状态转移方程便这么定了
* 依赖 “前 i 种” 这个逻辑定义,i 从小到大,n 从小到大去推演这个二维解空间,便可以得到我们的最终解
public int dp(int[] coins, int i, int n) {
        if (i == 0) {
            return 1;
        }
        int re = 0;
        int k = n / coins[i];
        for (int j = 0; j <= k; j++) {
            re += dp(coins, i - 1, n - j * coins[i]);
        }
        return re % 1000000007;
    }

//优化为加缓存的方式
    public static int dpCache(int[] coins, int i, int n, int[][] cache) {
        if (i == 0) {
            return 1;
        }
        if (cache[i][n] != 0) {
            return cache[i][n];
        }
        int re = 0;
        int k = n / coins[i];
        for (int j = 0; j <= k; j++) {
            re += dpCache(coins, i - 1, n - j * coins[i], cache);
        }
        cache[i][n] = re % 1000000007;
        return re;
    }
//优化为递推
    public static int dpCache0(int[] coins, int n, int[][] cache) {
        for (int j = 0; j < cache[0].length; j++) {
            cache[0][j] = 1;
        }
        int width = cache[0].length;
        for (int i = 1; i < coins.length; i++) {
            for (int j = 0; j < width; j++) {
                int s = j / coins[i];
                for (int k = 0; k <= s; k++) {
                    cache[i][j] += cache[i - 1][j - k * coins[i]];
                }
                cache[i][j] %= 1000000007;
            }
        }
        return cache[cache.length - 1][n];
    }
上述做法还是超时了。来回顾我们的状态转移方程,看看是否还有优化的余地:
* G(i, n)=G(i-1,n-0*ci)+G(i-1,n-1*ci)+G(i-1,n-2*ci)+...+G(i-1,n-k*ci)
* 我们以 i , n 为路径定义 函数 G 与自身的关系,在转移方程中, i 的依赖比较单一,n 的依赖较为复杂。
* 直觉上,我们可以尝试找出一种转变方式,将 n 的部分依赖转移到 i 上。我们将等式的后半部分看做一个整体:
* G(i-1,n-1*ci)+G(i-1,n-2*ci)+...+G(i-1,n-k*ci)=G(i,n-ci)
* 状态转移方程本就是找出函数自己与自己的关系,将项数较多的多项式按照该关系再次合并为函数本身表示的大问题,将一个参数的变化转移到另一个变化上
* 那么状态转移方程便转变为了:G(i, n)= G(i-1,n) + G(i,n-ci)
    public static int dpCache1(int[] coins, int n, int[][] cache) {
        int width = cache[0].length;
     //边界处理
for (int i = 0; i < width; i++) { cache[0][i] = 1; } for (int i = 0; i < cache.length; i++) { cache[i][0] = 1; }
     //递推表示
for (int i = 1; i < cache.length; i++) { for (int j = 1; j < width; j++) { if (j - coins[i] < 0) { cache[i][j] = cache[i - 1][j]; continue; } cache[i][j] = cache[i - 1][j] + cache[i][j - coins[i]]; cache[i][j] %= 1000000007; } } return cache[cache.length - 1][n]; }

 



posted @ 2020-05-03 00:09  牛有肉  阅读(276)  评论(0编辑  收藏  举报