算法 - 动态规划

动态规划技术的核心是“子问题划分”和“记忆化”。
比如,leetcode#70. 爬楼梯与leetcode#120. 三角形最小路径和。

  1. leetcode#70的子问题划分是这样的 ---- 由于每次只能向上爬一个或两个台阶,所以,爬到最高层的楼梯的路径数 \(f(n)\) 一定是爬到最高层往下一层的路径数 \(f(n-1)\) 与往下两层的路径数 \(f(n-2)\) (如果存在的话)之和。而#70的记忆化是这样的 ---- 如果按照从目标到起点的子问题划分方式来计算的话,那就是将递归思想实现为递归算法。但是,递归算法存在重复计算的情况,比如计算 \(f(n-1)\) 的时候也会计算一次 \(f(n-2)\)。记忆化就是一种用空间换取时间的算法,毕竟,如果问题规模即楼梯数有限的话,将中间数据全部列出来所消耗的内存一般情况也是可以接受的。而且,大多数情况又是可以用“滚动数组”的思想,将内存消耗压缩到\(O(1)\)。斐波那契数列问题的求解也是如此。
  2. 对于leetcode#120来说,子问题的划分是这样的 ---- 假设到最高层以下所有节点的路径花费都已经计算出来了,那么从跟节点到叶子节点的最佳路径就是从所有第二层节点中选择一个最小的。于是,算法就是从最底层一直往上求解。只是这里的记忆化是无法用滚动数组来压缩内存消耗到\(O(1)\)的,至少需要最底层叶子节点个数的额外空间来进行腾挪。
  3. 另外,再来看leetcode#322. 零钱兑换 中的一个细节点,即,某些子孙问题可能是不存在结果的,这种不存在在个别情况下是无需考虑的,比如leetcode#70和leetcode#120中所有的子问题都是有解且每个子问题对最终解都是有用的。这里可以统一为记忆化数组填充一个最值,或者像Java的Optional类型那样单独用一个字段或是否为空来表示存在与否,示例代码如下。虽然,这样做只是为了更好理解,完全可以用一个特殊的数字(比如,leetcode#322这个问题就可以用待兑换的总额加一)来表示某些子问题无解。
// leetcode#322
import java.util.Arrays;
import java.util.OptionalInt;

class Solution {
    public static void main(String[] args) {
        var s = new Solution();
        var coins = new int[][]{{1, 2, 5}, {2}, {1}};
        var amount = new int[]{11, 3, 0}; // 5+5+1,
        var ans = new int[]{3, -1, 0};
        for (int i = 0; i < ans.length; ++i) {
            System.out.println(ans[i] == s.coinChange(coins[i], amount[i]));
        }
    }

    public int coinChange(int[] coins, int amount) {
        OptionalInt[] ans = new OptionalInt[amount + 1];
        Arrays.fill(ans, OptionalInt.empty());
        ans[0] = OptionalInt.of(0);
        for (int i = 1; i <= amount; ++i) {
            for (var c : coins) {
                int iMinusCoin = i - c;
                if (iMinusCoin < 0 || ans[iMinusCoin].isEmpty()) { // breakdown
                    continue;
                }
                if (ans[i].isPresent()) {
                    ans[i] = OptionalInt.of(Math.min(ans[iMinusCoin].getAsInt() + 1, ans[i].getAsInt()));
                } else {
                    ans[i] = OptionalInt.of(ans[iMinusCoin].getAsInt() + 1);
                }
            }
        }
        return ans[amount].orElse(-1);
    }
}

如上,动态规划类问题的求解,似乎总是从划分子问题开始的,是从预期的最终结果开始分解问题的,递归的终止处就是最小的子问题(往往是对第一个数据对应的子问题进行求解)。换句话说,对于给定的数据,给定问题的预期结果,其最优解“一定是存在且唯一的”。因为,给定了数据,数据所能产生的问题空间就是一定的,我们就可以遍历所有可能性,而给定问题只不过是将问题空间的大小进一步坍缩了。人们有时候习惯于从理论上分析,但是,对于现实的情况,从给定预期出发去分解可能的最优实现方案是更省力的;否则,如果从理论上看,会有很多可能性并非我们所预期的结果而要被排除。在动态规划问题中,我们甚至可以直接去想象某个结果就是最优解,以此来分析如何划分子问题。
比如,leetcode#53. 最大子数组和 ---- 可以设想某个子数组a[i to j](数组的内涵即是连续的)就是最优解,那么,用\(f(a[i\enspace to\enspace j])\)表示数组a[i to j]的最大子数组,用\(f(a[..i\enspace to\enspace j])\)表示从任何标号小于(等于)i开始到j结束的子数组的最大子数组和,那么,对于给定的一个数组a来说,这两个问题的解是相同的,即数组a[i to j]的和。这也是数据大小是有限的所决定的。因此,我们将问题最优解的一端固定下来;否则,动态规划这个递归过程将很难终止。这样,真正的动态规划问题就是求\(max{ f(a[0\enspace to\enspace i]) }\),即求\(f(a[0\enspace to\enspace j])\),这是一个可以递归的问题。

public class Solution {
    public static void main(String[] args) {
        var s = new Solution();
        int[][] nums = {{5, 4, -1, 7, 8}, {-2, 1, -3, 4, -1, 2, 1, -5, 4}, {1}};
        int[] maxSubArraySum = {23, 6, 1};
        for (int i = 0; i < nums.length; ++i) {
            System.out.println(s.maxSubArray(nums[i]) == maxSubArraySum[i]);
        }
    }

    public int maxSubArray(int[] nums) {
        if (nums.length == 1) return nums[0];
        //         ..., i-1's, i's,
        // f(i) = max{ i's, i's + i-1's, max{i's + i-1's + ...1} }
        //      = max{ i's, i's + i-1's + max{i-2's + ...1} }
        //      = max{ i's, i's + f(i-1) }
        // f(i-1) = max{ i-1's, max{i-2's + ...2} }
        var f_i_minus_1 = nums[0]; // 0
        var f_i = Math.max(nums[1], nums[1] + f_i_minus_1); // 1
        var f_max = Math.max(f_i, f_i_minus_1);
        for (int i = 1; ++i < nums.length; ) { // 2, ...
            f_i_minus_1 = f_i;
            f_i = Math.max(nums[i], nums[i] + f_i_minus_1);
            f_max = Math.max(f_max, f_i);
        }
        return f_max;
    }
}

像leetcode#53这样比较难以想到如何划分子问题的还有leetcode#188. 买卖股票的最佳最佳时机(k次交易)。在做这类题目的时候,一个容易犯的错误还是习惯于自底向上去想,而不是将问题分解为与原问题具有一致性的子问题,这种惯性的缘由在于我们在从根问题进行分解的时候,还并不知道这样的递归是否可以终止于一个可靠的叶子问题。比如leetcode#188 ---- 首先,n天最多k次交易内,最终获利最多的问题,可以等价于在第n天的时候,不持有股票且获利最多的问题。这样,就将n天内获利最多的问题变成了关于第n天的一个函数,而k次交易与问题规模n无关,是额外的维度,是可以被枚举的(而leetcode#53是一种特殊的枚举,即在n个结果之中进行枚举)。同时,第n天不持有股票,要么是在第n-1天的时候就不持有;要么是第n天卖了,那么,第n-1天的时候就是持有的,为了获得最终的最大获利,就要求一个在第n-1天的时候持有股票的时候的最大获利值,于是就有了一个伴随问题是持有股票的时候的最大获利值。我们容易对这个伴随问题产生疑惑的一个原因是,既然,持有股票要减去某一天的股价,又如何获得最大收益值呢?其实,这种疑惑就是混淆了这两个问题。正确的认识是,不持有股票的最大收益问题,依赖于持有股票的“获益”最大问题,后者伴随问题中的收益则可能是负的,仅此而已。最终,递归终止条件就是第一天持有股票的“获益”最大问题及其值:hold(i=0)[j=0]=-price[i=0]holdNot(i=0)[j=0]=0。而当动态规划的解法落定后,再从这个终止条件出发往根问题去推想,就会更容易些。

import java.util.Arrays;

class Solution {
    public static void main(String[] args) {
        var s = new Solution();
        var k = new int[]{2, 2};
        var prices = new int[][]{{2, 4, 1}, {3, 2, 6, 5, 0, 3}};
        var maxProfit = new int[]{2, 7};
        for (int i = 0; i < k.length; ++i) {
            System.out.println(s.maxProfit(k[i], prices[i]) == maxProfit[i]);
        }
    }

    public int maxProfit(int k, int[] prices) {
        var n = prices.length;
        if (n <= 1 || k <= 0) return 0;
        k = Integer.min(k, n / 2);
        // ans: max{holdNot[n-1][j]}, j in [0, k]
        // holdNot[i][j] = max{holdNot[i-1][j], prices[i] + hold[i-1][j-1]}
        // hold[i][j] = max{hold[i-1][j], -price[i] + holdNot[i-1][j]}
        var tradeTimes = k+1;
        var hold_i_minus = new int[tradeTimes];    // 0
        var holdNot_i_minus = new int[tradeTimes];
        var hold_i = new int[tradeTimes];    // 1
        var holdNot_i = new int[tradeTimes];
        Arrays.fill(hold_i_minus, Integer.MIN_VALUE / 2);
        Arrays.fill(holdNot_i_minus, Integer.MIN_VALUE / 2);
        hold_i_minus[0] = -prices[0];
        holdNot_i_minus[0] = 0;
        for (int i = 1; i < n; ++i) {  // i in [1, n-1]
            hold_i[0] = Integer.max(hold_i_minus[0], -prices[i] + holdNot_i_minus[0]);
            for (int j = 1; j < tradeTimes; ++j) {
                holdNot_i[j] = Integer.max(holdNot_i_minus[j], prices[i] + hold_i_minus[j - 1]);
                hold_i[j] = Integer.max(hold_i_minus[j], -prices[i] + holdNot_i_minus[j]);
            }
            System.arraycopy(hold_i, 0, hold_i_minus, 0, tradeTimes);
            System.arraycopy(holdNot_i, 0, holdNot_i_minus, 0, tradeTimes);
        }
        return Arrays.stream(holdNot_i).max().getAsInt();
    }
}
posted @ 2022-10-02 21:10  joel-q  阅读(18)  评论(0编辑  收藏  举报