算法之动态规划

1 初探动态规划

「动态规划 dynamic programming」是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。
Q:爬楼梯
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶。

 如图所示,对于一个 3 阶楼梯,共有 3 种方案可以爬到楼顶。

本题的目标是求解方案数量, 我们可以考虑通过回溯来穷举所有可能性 。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 1 阶或 2 阶,每当到达楼梯顶部时就将方案数量加 1 ,当越过楼梯顶部时就将其剪枝。
/* 回溯 */
    void backtrack(List<Integer> choices, int state, int n, List<Integer> res) {
        // 当爬到第 n 阶时,方案数量加 1
        if (state == n)
            res.set(0, res.get(0) + 1);
        // 遍历所有选择
        for (Integer choice : choices) {
            // 剪枝:不允许越过第 n 阶
            if (state + choice > n)
                break;
            // 尝试:做出选择,更新状态
            backtrack(choices, state + choice, n, res);
        // 回退
        }
    }

    /* 爬楼梯:回溯 */
    int climbingStairsBacktrack(int n) {
        List<Integer> choices = Arrays.asList(1, 2); // 可选择向上爬 1 或 2 阶
        int state = 0; // 从第 0 阶开始爬
        List<Integer> res = new ArrayList<>();
        res.add(0); // 使用 res[0] 记录方案数量
        backtrack(choices, state, n, res);
        return res.get(0);
    }

1.1 方法一:暴力搜索

回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
我们可以尝试从问题分解的角度分析这道题。设爬到第 𝑖 阶共有 𝑑𝑝[𝑖] 种方案,那么 𝑑𝑝[𝑖] 就是原问题,其子问题包括:
                                𝑑𝑝[𝑖 − 1], 𝑑𝑝[𝑖 − 2], … , 𝑑𝑝[2], 𝑑𝑝[1]
由于每轮只能上 1 阶或 2 阶,因此当我们站在第 𝑖 阶楼梯上时,上一轮只可能站在第 𝑖 − 1 阶或第 𝑖 − 2 阶上。换句话说,我们只能从第 𝑖 − 1 阶或第 𝑖 − 2 阶前往第 𝑖 阶。
由此便可得出一个重要推论: 爬到第 𝑖 − 1 阶的方案数加上爬到第 𝑖 − 2 阶的方案数就等于爬到第 𝑖 阶的方 案数 。公式如下:

                                         𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2]

这意味着在爬楼梯问题中,各个子问题之间存在递推关系, 原问题的解可以由子问题的解构建得来 。下图展示了该递推关系。

我们可以根据递推公式得到暴力搜索解法。以 𝑑𝑝[𝑛] 为起始点, 递归地将一个较大问题拆解为两个较小问 题的和 ,直至到达最小子问题 𝑑𝑝[1] 𝑑𝑝[2] 时返回。其中,最小子问题的解是已知的,即 𝑑𝑝[1] = 1 、 𝑑𝑝[2] = 2 ,表示爬到第 1 2 阶分别有 1 2 种方案。
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。
  /* 搜索 */
    int dfs(int i) {
        // 已知 dp[1] 和 dp[2] ,返回之
        if (i == 1 || i == 2)
            return i;
        // dp[i] = dp[i-1] + dp[i-2]
        int count = dfs(i - 1) + dfs(i - 2);
        return count;
    }
    /* 爬楼梯:搜索 */
    int climbingStairsDFS(int n) {
        return dfs(n);
    }
下图展示了暴力搜索形成的递归树。对于问题 𝑑𝑝[𝑛] ,其递归树的深度为 𝑛 ,时间复杂度为 𝑂(2 𝑛 ) 。指数阶属于爆炸式增长,如果我们输入一个比较大的 𝑛 ,则会陷入漫长的等待之中。

 

观察上图 , 指数阶的时间复杂度是由于“重叠子问题”导致的 。例如 𝑑𝑝[9] 被分解为 𝑑𝑝[8] 𝑑𝑝[7] ,𝑑𝑝[8] 被分解为 𝑑𝑝[7] 𝑑𝑝[6] ,两者都包含子问题 𝑑𝑝[7] 。以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。

1.2 方法二:记忆化搜索 

为了提升算法效率, 我们希望所有的重叠子问题都只被计算一次 。为此,我们声明一个数组 mem 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
1. 当首次计算 𝑑𝑝[𝑖] 时,我们将其记录至 mem[i] ,以便之后使用。
2. 当再次需要计算 𝑑𝑝[𝑖] 时,我们便可直接从 mem[i] 中获取结果,从而避免重复计算该子问题。
 int dfs(int i, int[] mem) {
        // 已知 dp[1] 和 dp[2] ,返回之
        if (i == 1 || i == 2)
            return i;
        // 若存在记录 dp[i] ,则直接返回之
        if (mem[i] != -1)
            return mem[i];
        // dp[i] = dp[i-1] + dp[i-2]
        int count = dfs(i - 1, mem) + dfs(i - 2, mem);
        // 记录 dp[i]
        mem[i] = count;
        return count;
    }
    /* 爬楼梯:记忆化搜索 */
    int climbingStairsDFSMem(int n) {
        // mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录
        int[] mem = new int[n + 1];
        //将数组所有元素填充为-1
        Arrays.fill(mem, -1);
        return dfs(n, mem);
    }
观察下图,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 𝑂(𝑛) ,这是一个巨大的飞跃。

1.3 方法三:动态规划 

记忆化搜索是一种“从顶至底”的方法 :我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯将子问题的解逐层收集,构建出原问题的解。与之相反,动态规划是一种“从底至顶”的方法 :从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。
由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 dp 来存储子问题的解,它起到了记忆化搜索中数组 mem 相同的记录作用。
    /* 爬楼梯:动态规划 */
    int climbingStairsDP(int n) {
        if (n == 1 || n == 2)
            return n;
        // 初始化 dp 表,用于存储子问题的解
        int[] dp = new int[n + 1];
        // 初始状态:预设最小子问题的解
        dp[1] = 1;
        dp[2] = 2;
        // 状态转移:从较小子问题逐步求解较大子问题
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }

 下图模拟了上述代码的执行过程:

与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 𝑖
根据以上内容,我们可以总结出动态规划的常用术语。
‧ 将数组 dp 称为「 𝑑𝑝 表」, 𝑑𝑝[𝑖] 表示状态 𝑖 对应子问题的解。
‧ 将最小子问题对应的状态(即第 1 2 阶楼梯)称为「初始状态」。
‧ 将递推公式 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2] 称为「状态转移方程」。

 1.4 空间优化

细心的你可能发现, 由于 𝑑𝑝[𝑖] 只与 𝑑𝑝[𝑖 − 1] 𝑑𝑝[𝑖 − 2] 有关,因此我们无须使用一个数组 dp 来存储所 有子问题的解 ,而只需两个变量滚动前进即可。
 /* 爬楼梯:空间优化后的动态规划 */
    int climbingStairsDPComp(int n) {
        if (n == 1 || n == 2)
            return n;
        int a = 1, b = 2;
        for (int i = 3; i <= n; i++) {
            int tmp = b;
            b = a + b;
            a = tmp;
        }
        return b;
    }
观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 𝑂(𝑛) 降低至 𝑂(1)
在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维” 来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”

2 动态规划问题特性

在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
‧ 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
‧ 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
‧ 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。
实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。

         1最优子结构:原问题的最优解可以通过子问题的最优解来构造。

         2无后效性:一个阶段的状态一旦确定,就不受后续决策的影响,有助于简化问题的建模和解决。

2.1最优子结构

我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。
Q:
爬楼梯最小代价
给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你在
该台阶所需要付出的代价。给定一个非负整数数组 𝑐𝑜𝑠𝑡 ,其中 𝑐𝑜𝑠𝑡[𝑖] 表示在第 𝑖 个台阶需
要付出的代价, 𝑐𝑜𝑠𝑡[0] 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
如图所示,若第 1 2 3 阶的代价分别为 1 10 1 ,则从地面爬到第 3 阶的最小代价为 2
𝑑𝑝[𝑖] 为爬到第 𝑖 阶累计付出的代价,由于第 𝑖 阶只可能从 𝑖 − 1 阶或 𝑖 − 2 阶走来,因此 𝑑𝑝[𝑖] 只可能等于 𝑑𝑝[𝑖−1]+𝑐𝑜𝑠𝑡[𝑖] 𝑑𝑝[𝑖−2]+𝑐𝑜𝑠𝑡[𝑖] 。为了尽可能减少代价,我们应该选择两者中较小的那一个:
                𝑑𝑝[𝑖] = min(𝑑𝑝[𝑖 − 1], 𝑑𝑝[𝑖 − 2]) + 𝑐𝑜𝑠𝑡[𝑖]
这便可以引出最优子结构的含义: 原问题的最优解是从子问题的最优解构建得来的
本题显然具有最优子结构:我们从两个子问题最优解 𝑑𝑝[𝑖 − 1] 𝑑𝑝[𝑖 − 2] 中挑选出较优的那一个,并用 它构建出原问题 𝑑𝑝[𝑖] 的最优解。
那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,虽然题目修改前后是等价的,但最优子结构浮现出来了
𝑛 阶最大方案数量等于第 𝑛 − 1 阶和第 𝑛 − 2 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
根据状态转移方程,以及初始状态 𝑑𝑝[1] = 𝑐𝑜𝑠𝑡[1] 𝑑𝑝[2] = 𝑐𝑜𝑠𝑡[2] ,我们就可以得到动态规划代码。

 

    /* 爬楼梯最小代价:动态规划 */
    int minCostClimbingStairsDP(int[] cost) {
        //获取楼梯总层数
        int n = cost.length - 1;
        if (n == 1 || n == 2)
            return cost[n];
        // 初始化 dp 表,用于存储子问题的解,数组的长度为n+1,表示楼梯的总层数。
        int[] dp = new int[n + 1];
        // dp[i] 表示爬到第i层楼梯的最小代价,初始状态是第一层和第二层的代价。
        dp[1] = cost[1];
        dp[2] = cost[2];
        // 状态转移:从较小子问题逐步求解较大子问题
        for (int i = 3; i <= n; i++) {
            // 取 dp[i - 1] 和 dp[i - 2] 中的较小值,因为你可以选择每次爬 1 层或 2 层楼梯,所以选择代价较小的那个。
            //+ cost[i] 表示加上爬到第 i 层楼梯的实际代价。因为你需要支付当前层楼梯的代价。
            //dp[i - 1] 和 dp[i - 2] 分别表示爬到第 i - 1 层和第 i - 2 层楼梯的最小代价。你可以将它们理解为从底层到
            // 第 i - 1 层和从底层到第 i - 2 层的最小步数(或最小代价)。然后,通过比较这两个值,选择其中较小的一个,再加上爬到
            // 第 i 层楼梯的代价 cost[i]。这样得到的 dp[i] 就是从底层到第 i 层的最小步数或最小代价。
            dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
        }
        return dp[n];
    }
下图展示了上述代码的动态规划过程:

空间优化:

    /* 爬楼梯最小代价:动态规划 */
    int minCostClimbingStairsDPComp(int[] cost) {
        //获取楼梯总层数
        int n = cost.length - 1;
        if (n == 1 || n == 2)
            return cost[n];
        // 初始化 dp 表,用于存储子问题的解,数组的长度为n+1,表示楼梯的总层数。
//        int[] dp = new int[n + 1];
        // dp[i] 表示爬到第i层楼梯的最小代价,初始状态是第一层和第二层的代价。
        int a = cost[1];
        int b = cost[2];
        // 状态转移:从较小子问题逐步求解较大子问题
        for (int i = 3; i <= n; i++) {
            int temp=b;
            b = Math.min(a, temp) + cost[i];
            a=temp;
        }
        return b;
    }

2.2 无后效性

无后效性是动态规划能够有效解决问题的重要特性之一,定义为: 给定一个确定的状态,它的未来发展只与 当前状态有关,而与当前状态过去所经历过的所有状态无关
以爬楼梯问题为例,给定状态 𝑖 ,它会发展出状态 𝑖 + 1 和状态 𝑖 + 2 ,分别对应跳 1 步和跳 2 步。在做出 这两种选择时,我们无须考虑状态 𝑖 之前的状态,它们对状态 𝑖 的未来没有影响。
然而,如果我们向爬楼梯问题添加一个约束,情况就不一样了。
Q:
带约束爬楼梯
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶, 但不能连续两轮跳 1 ,请问有多
少种方案可以爬到楼顶。

例如下图 ,爬上第 3 阶仅剩 2 种可行方案,其中连续三次跳 1 阶的方案不满足约束条件,因此被舍弃。  

在该问题中,如果上一轮是跳 1 阶上来的,那么下一轮就必须跳 2 阶。这意味着, 下一步选择不能由当前状 态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关
不难发现,此问题已不满足无后效性,状态转移方程 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2] 也失效了,因为
𝑑𝑝[𝑖 − 1] 代表本轮跳 1 阶,但其中包含了许多“上一轮跳 1 阶上来的”方案,而为了满足约束,我们就不能将 𝑑𝑝[𝑖 − 1] 直接计入 𝑑𝑝[𝑖] 中。
为此,我们需要扩展状态定义: 状态 [𝑖, 𝑗] 表示处在第 𝑖 阶、并且上一轮跳了 𝑗 ,其中 𝑗 ∈ {1, 2} 。此状 态定义有效地区分了上一轮跳了 1 阶还是 2 阶,我们可以据此来判断当前状态是从何而来的。
‧ 当上一轮跳了 1 阶时,上上一轮只能选择跳 2 阶,即 𝑑𝑝[𝑖, 1] 只能从 𝑑𝑝[𝑖 − 1, 2] 转移过来。
‧ 当上一轮跳了 2 阶时,上上一轮可选择跳 1 阶或跳 2 阶,即 𝑑𝑝[𝑖, 2] 可以从 𝑑𝑝[𝑖−2, 1] 𝑑𝑝[𝑖−2, 2] 转移过来。
如下图所示,在该定义下, 𝑑𝑝[𝑖, 𝑗] 表示状态 [𝑖, 𝑗] 对应的方案数。此时状态转移方程为:

 

 最终,返回 𝑑𝑝[𝑛, 1] + 𝑑𝑝[𝑛, 2] 即可,两者之和代表爬到第 𝑛 阶的方案总数。

 /* 带约束爬楼梯:动态规划 */
    int climbingStairsConstraintDP(int n) {
        if (n == 1 || n == 2) {
            return 1;
        }
        // 初始化 dp 表,用于存储子问题的解
        int[][] dp = new int[n + 1][3];
        // 初始状态:预设最小子问题的解
        dp[1][1] = 1;// 表示从底层到第 1 层,且当前层采用从当前层向上爬一层的方式的数量是 1。
        dp[1][2] = 0;
        dp[2][1] = 0;//: 表示从底层到第 2 层,且当前层采用从当前层向上爬一层的方式的数量是 0。因为不能连续爬1
        dp[2][2] = 1;
        // 状态转移:从较小子问题逐步求解较大子问题
        for (int i = 3; i <= n; i++) {
            dp[i][1] = dp[i - 1][2];
            dp[i][2] = dp[i - 2][1] + dp[i - 2][2];
        }
        return dp[n][1] + dp[n][2];
    }
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
Q:爬楼梯与障碍生成
给定一个共有 n 阶的楼梯,你每步可以上 1 阶或者 2 阶。规定当爬到第i阶时,系统自动会
给第 2i阶上放上障碍物,之后所有轮都不允许跳到第 2i 阶上。例如,前两轮分别跳到了第 2
3 阶上,则之后就不能跳到第 4、6 阶上。请问有多少种方案可以爬到楼顶。
在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
实际上,许多复杂的组合优化问题(例如旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。

 3 动态规划解题思路

上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题:
1. 如何判断一个问题是不是动态规划问题?
2. 求解动态规划问题该从何处入手,完整步骤是什么?

1. 问题判断


总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解。然而,我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。

适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。在此基础上,动态规划问题还有一些判断的“加分项”:
- 问题包含最大(小)或最多(少)等最优化描述。
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。

相应地,也存在一些“减分项”:
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。

如果一个问题满足决策树模型,并具有较为明显的“加分项”,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。

2. 求解步骤

动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立𝑑𝑝 表,推导状态转移方程,确定边界条件等。
为了更形象地展示解题步骤,我们使用一个经典问题“最小路径和”来举例。
Q:
给定一个 𝑛 × 𝑚 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格
的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角
单元格。请返回从左上角到右下角的最小路径和。

 第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝

本题的每一轮的决策就是从当前格子向下或向右一步。设当前格子的行列索引为
[𝑖, 𝑗] ,则向下或向右走一步后,索引变为[i+1,j]或[i,j+1],因此状态应该包含行索引和列索引两个变量,记为[i,j],状态 [𝑖, 𝑗] 对应的子问题为:从起始点[0,0,],走到[𝑖, 𝑗] 的最小路径和,解记为dp[i,j]
至此,我们就得到了下图所示的二维 𝑑𝑝 矩阵,其尺寸与输入网格 𝑔𝑟𝑖𝑑 相同。

 

动态规划和回溯过程可以被描述为一个决策序列,而状态由所有决策变量构成。它应当包含
描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
每个状态都对应一个子问题,我们会定义一个 𝑑𝑝 表来存储所有子问题的解,状态的每个独立
变量都是 𝑑𝑝 表的一个维度。本质上看, 𝑑𝑝 表是状态和子问题的解之间的映射。

第二步:找出最优子结构,进而推导出状态转移方程

对于状态[𝑖, 𝑗] ,它只能从上边格子[𝑖 − 1, 𝑗] 和左边格子[𝑖, 𝑗 − 1] 转移而来。因此最优子结构为:到达 [𝑖, 𝑗] 的最小路径和由 [𝑖, 𝑗 − 1] 的最小路径和与 [𝑖 − 1, 𝑗] 的最小路径和,这两者较小的那一个决定。
根据以上分析,可推出下图所示的状态转移方程:
                        𝑑𝑝[𝑖, 𝑗] = min(𝑑𝑝[𝑖 − 1, 𝑗], 𝑑𝑝[𝑖, 𝑗 − 1]) + 𝑔𝑟𝑖𝑑[𝑖, 𝑗]

 根据定义好的 𝑑𝑝 表,思考原问题和子问题的关系,找出通过子问题的最优解来构造原问题的

最优解的方法,即最优子结构。 一旦我们找到了最优子结构,就可以使用它来构建出状态转移方程。

 

第三步:确定边界条件和状态转移顺序

在本题中,首行的状态只能从其左边的状态得来,首列的状态只能从其上边的状态得来,因此首行
𝑖 = 0 和 首列 𝑗 = 0 是边界条件。
如图所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵, 外循环遍历各行、内循环遍历各列。
边界条件在动态规划中用于初始化 𝑑𝑝 表,在搜索中用于剪枝。
状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经
被正确地计算出来。
根据以上分析,我们已经可以直接写出动态规划代码。然而子问题分解是一种从顶至底的思想,因此按照 “暴力搜索 → 记忆化搜索 动态规划”的顺序实现更加符合思维习惯。

 1. 方法一:暴力搜索

从状态 [𝑖, 𝑗] 开始搜索,不断分解为更小的状态 [𝑖 − 1, 𝑗] [𝑖, 𝑗 − 1] ,递归函数包括以下要素。

递归参数:状态[i,j]

返回值:从[0,0,]到[i,j]的最小路径和dp[i,j]

终止条件:当i或j等于0时返回代价grid[0,0]

剪枝:当i<0或j<0索引越界,此时返回代价正无穷,代表不可行

    public static void main(String[] args) {
        int[][] grid = {
                {1, 3, 1, 2},
                {1, 5, 1, 5},
                {4, 2, 1, 9}
        };
        int i = minPathSumDFS(grid, 2, 3);
        System.out.println(i);//16
    } 

   /* 最小路径和:暴力搜索 */
    int minPathSumDFS(int[][] grid, int i, int j) {
        // 若为左上角单元格,则终止搜索
        if (i == 0 && j == 0) {
            return grid[0][0];
        }
        // 若行列索引越界,则返回 +∞ 代价
        if (i < 0 || j < 0) {
            return Integer.MAX_VALUE;
        }
        // 计算从左上角到 (i-1, j) 和 (i, j-1) 的最小路径代价
        int up = minPathSumDFS(grid, i - 1, j);
        int left = minPathSumDFS(grid, i, j - 1);
        // 返回从左上角到 (i, j) 的最小路径代价
        return Math.min(left, up) + grid[i][j];
    }
下图 给出了以 𝑑𝑝[2, 1] 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸 变大而急剧增多。 本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格

每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 𝑚 + 𝑛 − 2 步,所以最差时间复杂度为 𝑂(2𝑚+𝑛) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
2. 方法二:记忆化搜索

我们引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,并将重叠子问题进行剪枝。  

  /* 最小路径和:记忆化搜索 */
    int minPathSumDFSMem(int[][] grid, int[][] mem, int i, int j) {
        // 若为左上角单元格,则终止搜索
        if (i == 0 && j == 0) {
            return grid[0][0];
        }
        // 若行列索引越界,则返回 +∞ 代价
        if (i < 0 || j < 0) {
            return Integer.MAX_VALUE;
        }
        // 若已有记录,则直接返回
        if (mem[i][j] != -1) {
            return mem[i][j];
        }
        // 左边和上边单元格的最小路径代价
        int up = minPathSumDFSMem(grid, mem, i - 1, j);
        int left = minPathSumDFSMem(grid, mem, i, j - 1);
        // 记录并返回左上角到 (i, j) 的最小路径代价
        mem[i][j] = Math.min(left, up) + grid[i][j];
        return mem[i][j];
    }
如图 所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 𝑂(𝑛𝑚)
3. 方法三:动态规划
基于迭代实现动态规划解法。
        
/* 最小路径和:动态规划 */
    //总体来说,这段代码通过动态规划的思想,从左上角到右下角逐步计算出每个位置的最小路径和,最终得到整个矩阵的最小路径和。
    int minPathSumDP(int[][] grid) {
        //获取矩阵grid有几行几列
        int n = grid.length, m = grid[0].length;
        //初始化一个与输入矩阵相同大小的二维数组 dp,用于存储从左上角到达每个位置的最小路径和。
        int[][] dp = new int[n][m];
        //初始化左上角的最小路径和,即 dp 数组的第一个元素
        dp[0][0] = grid[0][0];

        // 状态转移:首行,对于第一行,从左到右计算最小路径和。
        for (int j = 1; j < m; j++) {
            dp[0][j] = dp[0][j - 1] + grid[0][j];
        }
        // 状态转移:首列
        for (int i = 1; i < n; i++) {
            dp[i][0] = dp[i - 1][0] + grid[i][0];
        }
        // 状态转移:其余行列
        for (int i = 1; i < n; i++) {
            for (int j = 1; j < m; j++) {
                dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
            }
        }
        return dp[n - 1][m - 1];
    }
下图展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 𝑂(𝑛𝑚)
数组 dp 大小为 𝑛 × 𝑚 因此空间复杂度为 𝑂(𝑛𝑚)

 

4. 空间优化
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 𝑑𝑝 表。
请注意,因为数组 dp 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行中更新它。
/* 最小路径和:空间优化后的动态规划 */
    int minPathSumDPComp(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        // 初始化 dp 表
        int[] dp = new int[m];
        // 状态转移:首行
        dp[0] = grid[0][0];
        for (int j = 1; j < m; j++) {
            dp[j] = dp[j - 1] + grid[0][j];
        }
        // 状态转移:其余行
        for (int i = 1; i < n; i++) {
            // 状态转移:首列
            dp[0] = dp[0] + grid[i][0];
            // 状态转移:其余列
            for (int j = 1; j < m; j++) {
                dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j];
            }
        }
        return dp[m - 1];
    }

4 0‑1 背包问题

背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0‑1 背包问题、完全背包问题、多重背包问题等。 在本节中,我们先来求解最常见的 0‑1 背包问题。
给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1] 、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝
背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
观察下图,由于物品编号 𝑖 从 1 开始计数,数组索引从 0 开始计数,因此物品 𝑖 对应重量 𝑤𝑔𝑡[𝑖 − 1] 和 价值 𝑣𝑎𝑙[𝑖 − 1] 。
我们可以将 0‑1 背包问题看作是一个由 𝑛 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。
该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝
对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 𝑖 和剩余背包容量 𝑐 ,记为[i,c]。
状态 [𝑖, 𝑐] 对应的子问题为: 𝑖 个物品在剩余容量为 𝑐 的背包中的最大价值,记为 𝑑𝑝[𝑖, 𝑐] 。
待求解的是 𝑑𝑝[𝑛, 𝑐𝑎𝑝] ,因此需要一个尺寸为 (𝑛 + 1) × (𝑐𝑎𝑝 + 1) 的二维 𝑑𝑝 表。
第二步:找出最优子结构,进而推导出状态转移方程
当我们做出物品 𝑖 的决策后,剩余的是前 𝑖 − 1 个物品的决策,可分为以下两种情况。
不放入物品 𝑖 :背包容量不变,状态变化为 [𝑖 − 1, 𝑐] 。
放入物品 𝑖 :背包容量减小 𝑤𝑔𝑡[𝑖 − 1] ,价值增加 𝑣𝑎𝑙[𝑖 − 1] ,状态变化为 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]]
上述分析向我们揭示了本题的最优子结构: 最大价值 𝑑𝑝[𝑖, 𝑐] 等于不放入物品 𝑖 和放入物品 𝑖 两种方案中的 价值更大的那一个 。由此可推出状态转移方程:
                        𝑑𝑝[𝑖, 𝑐] = max(𝑑𝑝[𝑖 − 1, 𝑐], 𝑑𝑝[𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] + 𝑣𝑎𝑙[𝑖 − 1])
需要注意的是,若当前物品重量 𝑤𝑔𝑡[𝑖 − 1] 超出剩余背包容量 𝑐 ,则只能选择不放入背包。
第三步:确定边界条件和状态转移顺序
当无物品或无剩余背包容量时最大价值为 0 ,即首列 𝑑𝑝[𝑖, 0] 和首行 𝑑𝑝[0, 𝑐] 都等于0 。
当前状态 [𝑖, 𝑐] 从上方的状态 [𝑖 − 1, 𝑐] 和左上方的状态 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 转移而来,因此通过两层循环正序遍历整个 𝑑𝑝 表即可。
根据以上分析,我们接下来按顺序实现暴力搜索、记忆化搜索、动态规划解法。

1. 方法一:暴力搜索

搜索代码包含以下要素。
递归参数 :状态 [𝑖, 𝑐] 。
返回值 :子问题的解 𝑑𝑝[𝑖, 𝑐] 。
终止条件 :当物品编号越界 𝑖 = 0 或背包剩余容量为 0 时,终止递归并返回价值 0
剪枝 :若当前物品重量超出背包剩余容量,则只能不放入背包。
/* 0-1 背包:暴力搜索 */
    int knapsackDFS(int[] wgt, int[] val, int i, int c) {
        // 若已选完所有物品或背包无容量,则返回价值 0
        //i 表示当前物品的索引,c 表示当前背包的剩余容量。
        if (i == 0 || c == 0) {
            return 0;
        }

        // 若超过背包容量,则只能不放入背包
        if (wgt[i - 1] > c) {
            //递归调用 knapsackDFS,排除当前物品。
            return knapsackDFS(wgt, val, i - 1, c);
        }

        // 计算不放入和放入物品 i 的最大价值
        int no = knapsackDFS(wgt, val, i - 1, c);// 表示不放入当前物品时的最大价值,即递归调用 knapsackDFS 排除当前物品。
        int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1];//yes 表示放入当前物品时的最大价值,即递归调用 knapsackDFS 并减去当前物品的重量。
        // 返回两种方案中价值更大的那一个
        return Math.max(no, yes);
    }
如图所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 𝑂(2 𝑛 )
观察递归树,容易发现其中存在重叠子问题,例如 𝑑𝑝[1, 10] 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。

2. 方法二:记忆化搜索

为了保证重叠子问题只被计算一次,我们借助记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应 𝑑𝑝[𝑖, 𝑐] 。
引入记忆化之后, 时间复杂度取决于子问题数量 ,也就是 𝑂(𝑛 × 𝑐𝑎𝑝)
    /* 0-1 背包:记忆化搜索 */
    int knapsackDFSMem(int[] wgt, int[] val, int[][] mem, int i, int c) {
        // 若已选完所有物品或背包无容量,则返回价值 0
        if (i == 0 || c == 0) {
            return 0;
        }
        // 若已有记录,则直接返回
        if (mem[i][c] != -1) {
            return mem[i][c];
        }
        // 若超过背包容量,则只能不放入背包
        if (wgt[i - 1] > c) {
            return knapsackDFSMem(wgt, val, mem, i - 1, c);
        }
        // 计算不放入和放入物品 i 的最大价值
        int no = knapsackDFSMem(wgt, val, mem, i - 1, c);
        int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1];
        // 记录并返回两种方案中价值更大的那一个
        mem[i][c] = Math.max(no, yes);
        return mem[i][c];
    }
下图展示了在记忆化递归中被剪掉的搜索分支。

3. 方法三:动态规划

动态规划实质上就是在状态转移中填充 𝑑𝑝 表的过程,代码如下所示。  

 /* 0-1 背包:动态规划 */
    int knapsackDP(int[] wgt, int[] val, int cap) {
        int n = wgt.length;
        // 初始化 dp 表
        //dp[i][c] 表示在前 i 个物品中,背包容量为 c 时的最大总价值。
        //使用一个二维数组 dp 表来存储中间状态,其中 dp[i][c] 是一个状态,表示前 i 个物品放入容量为 c 的背包时的最大总价值。
        int[][] dp = new int[n + 1][cap + 1];

        // 状态转移
        for (int i = 1; i <= n; i++) {//遍历每个物品
            for (int c = 1; c <= cap; c++) {// 遍历每个可能的背包容量
                if (wgt[i - 1] > c) {
                    // 若超过背包容量,则不选物品 i
                    dp[i][c] = dp[i - 1][c];
                } else {
                    // 不选和选物品 i 这两种方案的较大值
                    dp[i][c] = Math.max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
                }
            }
        }
        //最终结果存储在 dp[n][cap] 中,即前 n 个物品,背包容量为 cap 时的最大总价值。
        return dp[n][cap];
    }
如图所示,时间复杂度和空间复杂度都由数组 dp 大小决定,即 𝑂(𝑛 × 𝑐𝑎𝑝)

4. 空间优化
由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从
𝑂(𝑛 2 ) 将低至 𝑂(𝑛) 。
进一步思考,我们是否可以仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格 子转移过来的。假设只有一个数组,当开始遍历第 𝑖 行时,该数组存储的仍然是第 𝑖 − 1 行的状态。
‧ 如果采取正序遍历,那么遍历到 𝑑𝑝[𝑖, 𝑗] 时,左上方 𝑑𝑝[𝑖 − 1, 1] ~ 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 值可能已经被覆
盖,此时就无法得到正确的状态转移结果。
‧ 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。 图 14‑21 展示了在单个数组下从第 𝑖 = 1 行转换至第 𝑖 = 2 行的过程。请思考正序遍历和倒序遍历的区别。

 

在代码实现中,我们仅需将数组 dp 的第一维 𝑖 直接删除,并且把内循环更改为倒序遍历即可 

    /* 0-1 背包:空间优化后的动态规划 */
    int knapsackDPComp(int[] wgt, int[] val, int cap) {
        int n = wgt.length;
        // 初始化 dp 表
        int[] dp = new int[cap + 1];
        // 状态转移
        for (int i = 1; i <= n; i++) {
            // 倒序遍历
            for (int c = cap; c >= 1; c--) {
                if (wgt[i - 1] <= c) {
                    // 不选和选物品 i 这两种方案的较大值
                    dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
                }
            }
        }
        return dp[cap];
    }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值