动态规划基础问题的固定解法思路

Dynamic Programming

First tastes

Before we go into dynamic programming, let see one problem: the classic problem Fibonacci Number:

F = F + F

F = F = 1

Obvious, O(n) = 2 if we use recurtion, so do we have other solution?

1.Memorization

public class Solution2 {
    int fibonacci(int n) {
        int[] res = new int[n]; 
        res[0] = 1;
        res[1] = 1;
        return fibonacci(n - 1, res);
    }

    int fibonacci(int n, int[] res) {
        if (res[n] != 0) { // if we have already calculated it 
            return res[n];
        }
        return fibonacci(n - 1, res) + fibonacci(n - 2, res);
    }
}

2.Tabulation

public class Solution {
    int fibonacci(int n) {
        int[] res = new int[n];
        res[0] = 1;
        res[1] = 1;
        for (int i = 2; i < n; i ++) {
            res[i] = res[i - 1] + res[i - 2];
        }
        return res[n - 1];
    }
}

3.Normal

public class Solution3 {
    int fibonacci(int n) {
        int a = 1;
        int b = 1;
        int c = 0;
        for (int i = 3; i <= n; i ++) {
            c = a + b;
            a = b;
            b = c;
        }
        return c;
    }
}

Do you see anything in common?

we memorized some values to avoid solving the overlapping problems. I think this is one of the reasons that we use DP.

what is dynamic programming

In order to solve a large problem(mostly optimal problem), we solve subproblems and store their values in table.

  • DP is applicable when the subproblems are greatly overlap.

  • DP is not greedy. DP tries every choice before solving the problem while greedy only travel one branch. So DP is much more expensive than greedy.

  • In every subproblem, we get the solution by obtains the optimal solution of its subproblem.

0 - 1 Knapstack problem(we cannot choose an item more that once)

Given a set of n unbreakable unique items, each with a weight W and a value V, determine the subset(the items we choose) of the items such that the total weighe is less than or equal to a given knapsack capacity W and the total value as large as possible.

First step, define the subproblem:

Let OPT[k, x] be the max value of knapsack with number k() and size w () items

if we choose the number k item, OPT[k, x] = OPT[k - 1, x - W] + V

if we not, OPT[k, x] = OPT[k - 1, x]

There are some other situation we need to consider, we will talk about it later.

Second step, define the base case:

when x = 0, OPT[k, x] = 0

when k = 0, OPT[k, x] = 0

Third step, write the equation:

OPT[k, x] = MAX(OPT[k, x] = OPT[k - 1, x - W] + V, OPT[k, x] = OPT[k - 1, x] )

now we see a problem, it is not guaranteed that x - W > 0,
so we need to handle it in different condition which we will see in out code.

Final step, coding:

/**
 * 最常规解法 会得到一个n * weight 大小的表
 */
public class Solution {
    /**
     *
     * @param weight 背包重量上限
     * @param w 商品重量数组
     * @param v 商品价值数组
     * @param n 待选择的商品数
     * @return
     */
    int knapsack(int weight, int[] w, int[] v, int n) {

        int[][] dp = new int[n + 1][weight + 1];

        /* base case 可以不写 */
        for (int j = 0; j < weight + 1; j ++) { // 无货物可选 必然为0
            dp[0][j] = 0;
        }

        for (int i = 0; i < n + 1; i ++) { // 无重量放货物 必然为0
            dp[i][0] = 0;
        }

        /* dp */
        for (int i = 1; i < n + 1; i++) { // 待选货物
            for(int j = 1; j < weight + 1; j ++) { // 背包重量
                if (w[i] > j) { // 货物重量大于背包重量 肯定无法选择
                    dp[i][j] = dp[i - 1][j];
                    continue;
                }
                dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]); // 做选择
            }

        }

        return dp[n][weight];
    }
}

Runtime complexity:

=

Coins choosing problem(we can choose an item more than once)

you are to compute the minus number of coins needed to make change for a given number , Assume that we have an unlimited supply of coins. Given n denominations , d1 = 1, d2 = 5, d3 = 10 ...

First step: define the subproblem

Let OPT[k, x] be the minimum number of coins to given a change to x () using k ( ) denominations.

if we choose denominations k, OPT[k, x] = OPT[k, x - d[k]] + 1

attention, here the k is still k not k - 1, because we can choose a coin as many times as we want

if we not, OPT[k, x] = OPT[k - 1, x]

second step:define the base case

when k = 1, OPT[k, x] = x

when x = 0, OPT[k, x] = 0;

third step: define equation:

OPT[k, x] = Min(OPT[k - 1, x], OPT[k, x - d[k]) + 1)

same to above, x-d[k] is not guarantee > 0

final step: coding

public class Solution {
    /**
     *
     * @param m 要组成的金额数
     * @param d 硬币数组 记录硬币的面值
     * @return
     */
    public int coins(int m, int[] d) {
        int len = d.length;
        int[][] dp = new int[len][m + 1];

        /* base case */
        for (int j = 0; j < m + 1; j ++) { // dp[0]的面值为1 组成金额j自然需要j个
            dp[0][j] = j;
        }

        for (int i = 0; i < len; i ++) { // 要组成的面值为0
            dp[i][0] = 0;
        }

        /* dp */
        for (int i  = 1; i < len; i ++) { // 待选硬币
            for (int j = 1; j < m + 1; j ++) { // 要组成的金额
                if (d[i] > m) { // 当前硬币金额大于当前金额 无法选择
                    dp[i][j] = dp[i - 1][j];
                    continue;
                }
                dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - d[i]] + 1);
            }
        }

        return dp[len - 1][m];
    }

}

Runtime Complexity:

=

深入理解不可重复的背包问题

在进入这个问题之前,我们在仔细说一下之前解法:

首先dp中所存的值都是当时情况下最优值

两层for循环所代表的意思:

第一层for循环代表待选货物:当i = 1时,我们只有第一个货物可以选;当i = 2时,我们有第一个和第二个货物可以选,我们在只有第一个货物可供选择时作出的决策所导致的结果的基础上来决定是否选择第二个货物;i = 3时亦然。

第二次for循环代表背包重量:在当前重量下我们是否选择货物i,我们只需要考虑是否选择,无需担心选完之后的事情(也是状态方程解释)。

如果选择:我们将j减去当前所选货物的重量w[i],并将i - 1 — 意为剩下的背包重量由之前所做过的决策来决定(上一行的数据)得到(一定是最优)。

如果不选择:我们保持j不变,并将i - 1,意为剩下的背包重量由之前所做过的来决定得到(一定是最优)。再啰嗦一句,这里i - 1是因为,在货物不可重复选择的前提下,一个货物只能选择一次,所以一旦我们作出选择,选择完该货物后剩余的重量就只能由 之前没有该货物选项时 的决策 导致的结果 来决定了

这个做法其实就体现了dp的核心思想,将大问题转换为子问题,每个子问题拿到的都是该子问题所处情况下的最优解,通过解决子问题来到最终答案(但是注意,当前层最优解并不一定会被下一层的最优解采用!)。一定要理解,第二个货物选择的决定时在第一个货物的基础上进行的,不要理解成每个i为单独的。

可否优化?

上面我们所用的解法是最直接的解法,他得到的是一个_k大小的表,也就是说它尝试过所有情况(包括一些其实不必尝试的情况,比如当待选货物为1时,它对所有重量都做了尝试,然后在问题的解决过程中我们发现,这些尝试可能都是徒劳,因为在剩余背包重量很大的情况下,我们有更好的选择而不是去选择1)

所以我们考虑,如果将外层for循环换成重量,会不会好一些呢?我觉得是会的,他会让我们的表的大小缩小一半。考虑当i = 1时,一旦货物j的重量超过i,我们就不会再去决策而是直接从上一列取值。但是在之前解法中,当i = 1时,他对所有重量都做了决策而非直接取值(这里可以把决策理解为没有走if分支)。

/**
 * 行列交换 会得到一个weight * n / 2 大小的表
 */
public class Solution2 {
    int knapsack(int weight, int[] w, int[] v, int n) {
        int[][] dp = new int[weight + 1][n + 1];

        /* base case 是0 直接省略*/

        /* dp */
        for (int i = 1; i < weight + 1; i ++) { // 背包重量
            for (int j = 1; j < n + 1; j ++) { // 待选货物
                if (w[j] > i) {
                    dp[i][j] = dp[i][j - 1];
                    continue;
                }
                dp[i][j] = Math.max(dp[i][j - 1], dp[i - w[j]][j - 1] + v[j]);
            }
        }

        return dp[weight + 1][n + 1];
    }
}

可否继续优化?

我们继续考虑,我们真的有必要将所有情况都记录进数组嘛?答案是没必要。如果你观察的仔细,你会发现我们每次计算的时候都只是使用了上一层的值,对于再之前的值,我们不再需要。因此我们只需要保存上一行的数据,所以我们只需要维护一个一维数组即可。

在此,我们主要解释决策,if分支不再解释

如果选择当前货物:那么需要拿到上一层中重量为j - w[i]时做的决策,也就是d[j - w[i]](这个决策一定是对这个重量目前最优的解)再加上当前货物的价值

如果不选当前货物:那么决策还是当前重量对应的上一层的决策,也就是d[j]

由此可见,在这个想法下,我们维护的一维数数组的下标表示的应该是重量

/**
 * 降维解法 只保留上一行数据
 * 实际解法维solution1,但是采取降维
 */
public class Solution3 {
    int knapsack(int weight, int[] w, int[] v, int n) {
        int[] dp = new int[n + 1];
        
        /* base case */
        dp[0] = 0;
        
        for(int i = 1; i < n + 1; i ++) { // 待选货物
            for(int j = 1; j < weight + 1; j ++) { // 背包重量
                if (w[i] > j) {
                    continue;
                }
                dp[j] = Math.max(dp[j - w[i]] + v[i], dp[j]); // 作出选择
            }
        }

        return dp[weight + 1];
    }
}

深入理解可重复的背包问题

两层for循环所代表的意思:

第一层for循环代表待选硬币:当i = 1时,我们只有第一个硬币可以选;当i = 2时,我们有第一个和第二个硬币可以选,我们在第一个硬币选择与否所造成结果的基础上来决定是否选择第二个硬币;i = 3时亦然。

第二次for循环代表要组成的面值,在当前面值下我们是否选择硬币i,我们只需要考虑是否选择,无需担心选完之后的事情(也是状态方程解释)。

如果选择:我们将j减去当前所选硬币 的面值d[i],注意这里,i不变,这是因为,在硬币可以重复选择的前提下,我们作出使用这个硬币的决定后,剩余的重量还是可以继续使用该硬币。试想,当前选择完硬币后,待组成面额一定会减小,这个待组成面额还是要使用包括当前硬币在内的所有可选择的硬币,所以我们要依赖之前所作出的所有决策而不仅仅是没有该面额硬币时所作出的决策(到当前行的前几列以及前一行的前几列)。

如果不选择:我们保持j不变,并将i - 1,这里i-1是因为,如果当前选择不用,那么代表我们对当前面额的硬币的使用已经结束(可以理解为我们在更大重量时或许多次使用这个硬币,但是在这次选择之后,剩余的重量我们将会不会再使用该面额),剩下待组成面值依靠之前没有该面额的硬币时 所作出的决策 导致的结果(上一行) 来得到(一定是最优)。

可否优化?

同不可重复的背包问题一样,如果外层循环为硬币面值,会有很多无用数据,比如当i = 0,也就是硬币面值为1时,如果代组成金额为100,我们就会把0 - 100都便利一遍,这显然是没有必要的,因为100个1组成100显然不是最好的组成方法。

所以我们考虑把外层换成待组成的金额值,原因与可重复背包问题一直,可以减少决策(去到if分支的情况更多)

public class Solution2 {
    public int coins(int m, int[] d) {
        int len = d.length;
        int[][] dp = new int[m + 1][len];

        /* base case */
        for(int j = 0; j < len; j ++) { // 待组成金额为0
            dp[0][j] = 0;
        }

        for (int i = 0; i < m + 1; i ++) { //硬币面值为1
            dp[i][0] = i;
        }

        /* dp */
        for (int i = 1; i < m + 1; i ++) {
            for (int j = 1; j < len; j ++) {
                if (d[j] > i) {
                    dp[i][j] = dp[i][j - 1];
                    continue;
                }
                dp[i][j] = Math.min(dp[i][j - 1], dp[i - d[j]][j - 1] + 1);
            }
        }
        
        return dp[m][len - 1];
    }
}

可否继续优化

既然不可重复背包问题的空间复杂度是能够继续优化的,那么是否可以采取相同的思路对可重复背包问题来进行优化呢?我们来思考一下。

首先,我们之所以可以优化不可重复背包问题,是因为我们计算当前情况时,只需使用到上一行的数据,所以我们只需要维护一个维护一维数组。但是,可重复背包问题并不是这样。

如果选择:我们将j减去当前所选硬币 的面值d[i],注意这里,i不变,因为这个硬币可以重复使用,意为剩下面值依赖与有该面额硬币以及仅没有当前面额硬币时所做的决策(上一行 或者当前行的之前列)得到(一定是最优)。

如果不选择:我们保持j不变,并将i - 1,如果我们选择不用,那么表示我们对当前面额的硬币的使用结束,意为剩下的面值由之前没有当前面额硬币时所做过的来决策来完成(上一行,因为现在决定当前行所对应的硬币不再使用,所以剩下的面值只能由之前的硬币得到而不能再使用当前硬币)得到(一定是最优)。

很明显,我们在当前做决定时,不仅仅用到之前的行,还可能会用到之前的列,所以,不可重复背包问题的优化方式不再可取。

难道这就说明我们可重复背包问题的空间复杂度无法优化嘛?并不是,我们可以采取别的思路。

我们再次思考,我们如何能够基于之前作出的决策来完成当前决策呢?其实有一种方法:当我们金额增加时,我们必然是要调整我们硬币的组成,可能是只增加一枚新的硬币,也可能是增加一个较大的新硬币拿走一个较小的之前选择的硬币。不管是哪种情况,我们都是基于之前的决策完成的。直接增加硬币不需要解释了,我们直接挑最合适的就可以(虽然我觉得这种情况不会出现)。将较小的硬币替换成一个较大的硬币这种情况我们可以思考一下。

我们可以换一种方式来实现这个替换:因为我们总归要选一枚硬币,只是我们不知道应该选哪个。解决这个问题很简单,我们将每个硬币都选一下试试看,然后再看减掉这个新选择的硬币面额后剩下的待组成金额的dp(这个剩下的待组成金额一定是我们已经在之前的dp决策中得到过的,所以我们有组成这个剩下待组成金额的最优解),选完哪个硬币后得到的dp + 1最小,我们就应该选择哪个硬币。

/**
 * 降维 但是思路与不可重复背包问题的降维思路不同
 * 可重复背包问题无法采用不可重复背包问题的降维思路,因为所需要的数据不一定只来源与上一行,也可能来源于当前行的之前列
 */
public class Solution3 {
    public int coins(int m, int[] d) {
        int len = d.length;
        int[] dp = new int[m + 1];

        /* base case 待组成金额为0 其他待组成金额待求 所以要设置成无穷 */
        dp[0] = 0;
        for(int i = 1; i < m + 1; i ++) {
            dp[i] = Integer.MAX_VALUE;
        }

        /* dp */
        for (int i = 1; i < m + 1; i ++) { // 待组成金额
            for (int j = 0; j < len; j ++) { // 硬币面额
                if (d[j] > i) { // 硬币面额大于代组成的面额
                    continue;
                }
                dp[i] = Math.min(dp[i - d[j]] + 1, dp[i]); //决策 选取当前硬币,剪掉当前硬币面额得到已经决策过的dp,拿到最优
            }
        }

        return dp[m + 1];
    }
}

背包问题的内在规则

背包问题总会有一个可供选择的“集合” 和一个上限

集合可能对应一个值,这个值对应上限,从集合中选取的个数对应解

集合也可能对应一系列数值,有的值对应上限,有的值对应解

  • 0-1背包问题,n种商品为选择集合, W为上限。这个题目比较特殊,那就是它的选择集合并不单单是一个值n,对于每一个n,都有一个[w, v]与其关联,w用来对应上限, v用来对应问题所求。

  • 硬币问题,各种硬币为选择集合,要组成的钱数为上限,这个题目就是比较常规的题目 对于每个硬币, 都有一个面额与其关联,硬币的面额用来决定上限,硬币的个数用来对应所求

在github上有更多DP问题的一些解法

你可能感兴趣的:(动态规划基础问题的固定解法思路)