数据结构和算法:动态规划

初探动态规划

动态规划(dynamic programming)是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。

例题:爬楼梯
给定一个共有 𝑛 阶的楼梯,每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶?

如图所示,对于一个 3 阶楼梯,共有 3 种方案可以爬到楼顶:
在这里插入图片描述
本题的目标是求解方案数量,可以考虑通过回溯来穷举所有可能性。
具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 1 阶或 2 阶,每当到达楼梯顶部时就将方案数量加 1 ,当越过楼梯顶部时就将其剪枝。

/**
 * File: climbing_stairs_backtrack.cpp
 * Created Time: 2023-06-30
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 回溯 */
void backtrack(vector<int> &choices, int state, int n, vector<int> &res) {
    // 当爬到第 n 阶时,方案数量加 1
    if (state == n)
        res[0]++;
    // 遍历所有选择
    for (auto &choice : choices) {
        // 剪枝:不允许越过第 n 阶
        if (state + choice > n)
            continue;
        // 尝试:做出选择,更新状态
        backtrack(choices, state + choice, n, res);
        // 回退
    }
}

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

/* Driver Code */
int main() {
    int n = 9;

    int res = climbingStairsBacktrack(n);
    cout << "爬 " << n << " 阶楼梯共有 " << res << " 种方案" << endl;

    return 0;
}

方法一:暴力搜索

回溯算法通常并不显式地对问题进行拆解,而是将求解问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。

可以尝试从问题分解的角度分析这道题。设爬到第 𝑖 阶共有 𝑑𝑝[𝑖] 种方案,那么 𝑑𝑝[𝑖] 就是原问题,其子问题包括:
在这里插入图片描述
由于每轮只能上 1 阶或 2 阶,因此当站在第 𝑖 阶楼梯上时,上一轮只可能站在第 𝑖 − 1 阶或第 𝑖 − 2 阶上。换句话说,只能从第 𝑖 − 1 阶或第 𝑖 − 2 阶迈向第 𝑖 阶。

由此便可得出一个重要推论:爬到第 𝑖 − 1 阶的方案数加上爬到第 𝑖 − 2 阶的方案数就等于爬到第 𝑖 阶的方案数。公式如下:
在这里插入图片描述
这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。下图展示了该递推关系:
在这里插入图片描述
可以根据递推公式得到暴力搜索解法。以 𝑑𝑝[𝑛] 为起始点,递归地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 𝑑𝑝[1] 和 𝑑𝑝[2] 时返回。其中,最小子问题的解是已知的,即 𝑑𝑝[1] = 1、𝑑𝑝[2] = 2 ,表示爬到第 1、2 阶分别有 1、2 种方案。

/**
 * File: climbing_stairs_dfs.cpp
 * Created Time: 2023-06-30
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 搜索 */
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);
}

/* Driver Code */
int main() {
    int n = 9;

    int res = climbingStairsDFS(n);
    cout << "爬 " << n << " 阶楼梯共有 " << res << " 种方案" << endl;

    return 0;
}

下图展示了暴力搜索形成的递归树。
对于问题 𝑑𝑝[𝑛] ,其递归树的深度为 𝑛 ,时间复杂度为 𝑂(2^𝑛) 。指数阶属于爆炸式增长,如果输入一个比较大的 𝑛 ,则会陷入漫长的等待之中。
在这里插入图片描述
指数阶的时间复杂度是“重叠子问题”导致的。例如 𝑑𝑝[9] 被分解为 𝑑𝑝[8] 和 𝑑𝑝[7] ,𝑑𝑝[8] 被分解为 𝑑𝑝[7] 和 𝑑𝑝[6] ,两者都包含子问题 𝑑𝑝[7] 。

方法2:记忆化搜索

为了提升算法效率,希望所有的重叠子问题都只被计算一次。
为此,声明一个数组 mem 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
1.当首次计算 𝑑𝑝[𝑖] 时,将其记录至 mem[i] ,以便之后使用。
2.当再次需要计算 𝑑𝑝[𝑖] 时,我们便可直接从 mem[i] 中获取结果,从而避免重复计算该子问题。

/**
 * File: climbing_stairs_dfs_mem.cpp
 * Created Time: 2023-06-30
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 记忆化搜索 */
int dfs(int i, vector<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 代表无记录
    vector<int> mem(n + 1, -1);
    return dfs(n, mem);
}

/* Driver Code */
int main() {
    int n = 9;

    int res = climbingStairsDFSMem(n);
    cout << "爬 " << n << " 阶楼梯共有 " << res << " 种方案" << endl;

    return 0;
}

经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 𝑂(𝑛) 。
在这里插入图片描述

方法三:动态规划

记忆化搜索是一种“从顶至底”的方法:从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。
与之相反,动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。

由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。
在以下代码中,初始化一个数组 dp 来存储子问题的解,它起到了与记忆化搜索中数组 mem 相同的记录作用:

/**
 * File: climbing_stairs_dp.cpp
 * Created Time: 2023-06-30
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 爬楼梯:动态规划 */
int climbingStairsDP(int n) {
    if (n == 1 || n == 2)
        return n;
    // 初始化 dp 表,用于存储子问题的解
    vector<int> dp(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];
}

/* Driver Code */
int main() {
    int n = 9;

    int res = climbingStairsDP(n);
    cout << "爬 " << n << " 阶楼梯共有 " << res << " 种方案" << endl;

    res = climbingStairsDPComp(n);
    cout << "爬 " << n << " 阶楼梯共有 " << res << " 种方案" << endl;

    return 0;
}

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

空间优化

由于 𝑑𝑝[𝑖] 只与 𝑑𝑝[𝑖 − 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) 。
在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时可以只保留必要的状态,通过“降维”来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”。

动态规划问题特性

子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同:
分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,可以将每个决策步骤之前的子序列看作一个子问题。

动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。

最优子结构

例题:爬楼梯最小代价
给定一个楼梯,每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示在该台阶所需要付出的代价。给定一个非负整数数组 𝑐𝑜𝑠𝑡 ,其中 𝑐𝑜𝑠𝑡[𝑖] 表示在第 𝑖 个台阶需要付出的代价,𝑐𝑜𝑠𝑡[0] 为地面(起始点)。请计算最少需要付出多少代价才能到达顶部?

如图所示,若第 1、2、3 阶的代价分别为 1、10、1 ,则从地面爬到第 3 阶的最小代价为 2 。
在这里插入图片描述
设 𝑑𝑝[𝑖] 为爬到第 𝑖 阶累计付出的代价,由于第 𝑖 阶只可能从 𝑖 − 1 阶或 𝑖 − 2 阶走来,因此 𝑑𝑝[𝑖] 只可能等于 𝑑𝑝[𝑖−1]+𝑐𝑜𝑠𝑡[𝑖] 或 𝑑𝑝[𝑖−2]+𝑐𝑜𝑠𝑡[𝑖] 。为了尽可能减少代价,应该选择两者中较小的那一个:
在这里插入图片描述
这便可以引出最优子结构的含义:原问题的最优解是从子问题的最优解构建得来的。
可以从两个子问题最优解 𝑑𝑝[𝑖 − 1] 和 𝑑𝑝[𝑖 − 2] 中挑选出较优的那一个,并用它构建出原问题 𝑑𝑝[𝑖] 的最优解。

爬楼梯题的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。可以发现,虽然题目修改前后是等价的,但最优子结构浮现出来了:第 𝑛 阶最大方案数量等于第 𝑛 − 1 阶和第 𝑛 − 2 阶最大方案数量之和

根据状态转移方程,以及初始状态 𝑑𝑝[1] = 𝑐𝑜𝑠𝑡[1] 和 𝑑𝑝[2] = 𝑐𝑜𝑠𝑡[2] ,我们就可以得到动态规划代码:

/**
 * File: min_cost_climbing_stairs_dp.cpp
 * Created Time: 2023-06-30
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 爬楼梯最小代价:动态规划 */
int minCostClimbingStairsDP(vector<int> &cost) {
    int n = cost.size() - 1;
    if (n == 1 || n == 2)
        return cost[n];
    // 初始化 dp 表,用于存储子问题的解
    vector<int> dp(n + 1);
    // 初始状态:预设最小子问题的解
    dp[1] = cost[1];
    dp[2] = cost[2];
    // 状态转移:从较小子问题逐步求解较大子问题
    for (int i = 3; i <= n; i++) {
        dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
    }
    return dp[n];
}

/* 爬楼梯最小代价:空间优化后的动态规划 */
int minCostClimbingStairsDPComp(vector<int> &cost) {
    int n = cost.size() - 1;
    if (n == 1 || n == 2)
        return cost[n];
    int a = cost[1], b = cost[2];
    for (int i = 3; i <= n; i++) {
        int tmp = b;
        b = min(a, tmp) + cost[i];
        a = tmp;
    }
    return b;
}

/* Driver Code */
int main() {
    vector<int> cost = {0, 1, 10, 1, 1, 1, 10, 1, 1, 10, 1};
    cout << "输入楼梯的代价列表为 ";
    printVector(cost);

    int res = minCostClimbingStairsDP(cost);
    cout << "爬完楼梯的最低代价为 " << res << endl;

    res = minCostClimbingStairsDPComp(cost);
    cout << "爬完楼梯的最低代价为 " << res << endl;

    return 0;
}

以上代码的动态规划过程:
在这里插入图片描述
本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 𝑂(𝑛) 降至 𝑂(1) :

/* 爬楼梯最小代价:空间优化后的动态规划 */
int minCostClimbingStairsDPComp(vector<int> &cost) {
    int n = cost.size() - 1;
    if (n == 1 || n == 2)
        return cost[n];
    int a = cost[1], b = cost[2];
    for (int i = 3; i <= n; i++) {
        int tmp = b;
        b = min(a, tmp) + cost[i];
        a = tmp;
    }
    return b;
}

2. 无后效性

无后效性是动态规划能够有效解决问题的重要特性之一,其定义为:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。

给定状态 𝑖 ,它会发展出状态 𝑖 + 1 和状态 𝑖 + 2 ,分别对应跳 1 步和跳 2 步。在做出这两种选择时,无须考虑状态 𝑖 之前的状态,它们对状态 𝑖 的未来没有影响。

例题:给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,但不能连续两轮跳 1 阶,请问有多少种方案可以爬到楼顶?

爬上第 3 阶仅剩 2 种可行方案,其中连续三次跳 1 阶的方案不满足约束条件,因此被舍弃。
在这里插入图片描述

在该问题中,如果上一轮是跳 1 阶上来的,那么下一轮就必须跳 2 阶。这意味着,下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上一轮所在楼梯阶数)有关。

不难发现,此问题已不满足无后效性,状态转移方程 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2] 也失效了,因为 𝑑𝑝[𝑖 − 1] 代表本轮跳 1 阶,但其中包含了许多“上一轮是跳 1 阶上来的”方案,而为了满足约束,就不能将 𝑑𝑝[𝑖 − 1] 直接计入 𝑑𝑝[𝑖] 中。

为此,需要扩展状态定义:状态 [𝑖, 𝑗] 表示处在第 𝑖 阶并且上一轮跳了 𝑗 阶,其中 𝑗 ∈ {1, 2} 。此状态定义有效地区分了上一轮跳了 1 阶还是 2 阶,可以据此判断当前状态是从何而来的。
1.当上一轮跳了 1 阶时,上上一轮只能选择跳 2 阶,即 𝑑𝑝[𝑖, 1] 只能从 𝑑𝑝[𝑖 − 1, 2] 转移过来。
2.当上一轮跳了 2 阶时,上上一轮可选择跳 1 阶或跳 2 阶,即 𝑑𝑝[𝑖, 2] 可以从 𝑑𝑝[𝑖−2, 1] 或 𝑑𝑝[𝑖−2, 2] 转移过来。

在该定义下,𝑑𝑝[𝑖, 𝑗] 表示状态 [𝑖, 𝑗] 对应的方案数。此时状态转移方程为:
在这里插入图片描述
在这里插入图片描述
最终,返回 𝑑𝑝[𝑛, 1] + 𝑑𝑝[𝑛, 2] 即可,两者之和代表爬到第 𝑛 阶的方案总数:

/**
 * File: climbing_stairs_constraint_dp.cpp
 * Created Time: 2023-07-01
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 带约束爬楼梯:动态规划 */
int climbingStairsConstraintDP(int n) {
    if (n == 1 || n == 2) {
        return 1;
    }
    // 初始化 dp 表,用于存储子问题的解
    vector<vector<int>> dp(n + 1, vector<int>(3, 0));
    // 初始状态:预设最小子问题的解
    dp[1][1] = 1;
    dp[1][2] = 0;
    dp[2][1] = 0;
    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];
}

/* Driver Code */
int main() {
    int n = 9;

    int res = climbingStairsConstraintDP(n);
    cout << "爬 " << n << " 阶楼梯共有 " << res << " 种方案" << endl;

    return 0;
}

由于仅需多考虑前面一个状态,因此仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。

动态规划解题思路

问题判断

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

适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。

换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。

在此基础上,动态规划问题还有一些判断的“加分项”:
1.问题包含最大(小)或最多(少)等最优化描述。
2.问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。

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

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

问题求解步骤

动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 𝑑𝑝 表,推导状态转移方程,确定边界条件等。

例题:给定一个 𝑛 × 𝑚 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。

下图展示了一个例子,给定网格的最小路径和为 13 。
在这里插入图片描述
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
每一轮的决策就是从当前格子向下或向右走一步。设当前格子的行列索引为 [𝑖, 𝑗] ,则向下或向右走一步后,索引变为 [𝑖 + 1, 𝑗] 或 [𝑖, 𝑗 + 1] 。因此,状态应包含行索引和列索引两个变量,记为 [𝑖, 𝑗] 。

状态 [𝑖, 𝑗] 对应的子问题为:从起始点 [0, 0] 走到 [𝑖, 𝑗] 的最小路径和,解记为 𝑑𝑝[𝑖, 𝑗] 。
至此,就得到了下图所示的二维 𝑑𝑝 矩阵,其尺寸与输入网格 𝑔𝑟𝑖𝑑 相同。
在这里插入图片描述
动态规划和回溯过程可以描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
每个状态都对应一个子问题,我们会定义一个 𝑑𝑝 表来存储所有子问题的解,状态的每个独立变量都是 𝑑𝑝 表的一个维度。从本质上看,𝑑𝑝 表是状态和子问题的解之间的映射。

第二步:找出最优子结构,进而推导出状态转移方程
对于状态 [𝑖, 𝑗] ,它只能从上边格子 [𝑖 − 1, 𝑗] 和左边格子 [𝑖, 𝑗 − 1] 转移而来。因此最优子结构为:到达 [𝑖, 𝑗] 的最小路径和由 [𝑖, 𝑗 − 1] 的最小路径和与 [𝑖 − 1, 𝑗] 的最小路径和中较小的那一个决定。

根据以上分析,可推出上图所示的状态转移方程:
在这里插入图片描述
在这里插入图片描述

根据定义好的 𝑑𝑝 表,思考原问题和子问题的关系,找出通过子问题的最优解来构造原问题的最优解的方法,即最优子结构。一旦找到了最优子结构,就可以使用它来构建出状态转移方程。

第三步:确定边界条件和状态转移顺序
处在首行的状态只能从其左边的状态得来,处在首列的状态只能从其上边的状态得来,因此首行 𝑖 = 0 和首列 𝑗 = 0 是边界条件。

如图所示,由于每个格子是由其左方格子和上方格子转移而来,因此使用循环来遍历矩阵,外循环遍历各行,内循环遍历各列。
在这里插入图片描述

边界条件在动态规划中用于初始化 𝑑𝑝 表,在搜索中用于剪枝。状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经被正确地计算出来。

根据以上分析,已经可以直接写出动态规划代码。然而子问题分解是一种从顶至底的思想,因此按照“暴力搜索 → 记忆化搜索 → 动态规划”的顺序实现更加符合思维习惯。

方法一:暴力搜索

从状态 [𝑖, 𝑗] 开始搜索,不断分解为更小的状态 [𝑖 − 1, 𝑗] 和 [𝑖, 𝑗 − 1] ,递归函数包括以下要素。
递归参数:状态 [𝑖, 𝑗] ;
返回值:从 [0, 0] 到 [𝑖, 𝑗] 的最小路径和 𝑑𝑝[𝑖, 𝑗];
终止条件:当 𝑖 = 0 且 𝑗 = 0 时,返回代价 𝑔𝑟𝑖𝑑[0, 0] 。
剪枝:当 𝑖 < 0 时或 𝑗 < 0 时索引越界,此时返回代价 +∞ ,代表不可行

/* 最小路径和:暴力搜索 */
int minPathSumDFS(vector<vector<int>> &grid, int i, int j) {
    // 若为左上角单元格,则终止搜索
    if (i == 0 && j == 0) {
        return grid[0][0];
    }
    // 若行列索引越界,则返回 +∞ 代价
    if (i < 0 || j < 0) {
        return INT_MAX;
    }
    // 计算从左上角到 (i-1, j) 和 (i, j-1) 的最小路径代价
    int up = minPathSumDFS(grid, i - 1, j);
    int left = minPathSumDFS(grid, i, j - 1);
    // 返回从左上角到 (i, j) 的最小路径代价
    return min(left, up) != INT_MAX ? min(left, up) + grid[i][j] : INT_MAX;
}

下图给出了以 𝑑𝑝[2, 1] 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸变大而急剧增多。

从本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格。
在这里插入图片描述
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 𝑚 + 𝑛 − 2 步,所以最差时间复杂度为𝑂(2^(𝑚+𝑛)) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择,因此实际的路径数量会少一些。

方法二:记忆化搜索

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

/* 最小路径和:记忆化搜索 */
int minPathSumDFSMem(vector<vector<int>> &grid, vector<vector<int>> &mem, int i, int j) {
    // 若为左上角单元格,则终止搜索
    if (i == 0 && j == 0) {
        return grid[0][0];
    }
    // 若行列索引越界,则返回 +∞ 代价
    if (i < 0 || j < 0) {
        return INT_MAX;
    }
    // 若已有记录,则直接返回
    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] = min(left, up) != INT_MAX ? min(left, up) + grid[i][j] : INT_MAX;
    return mem[i][j];
}

如图所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 𝑂(𝑛𝑚) 。
在这里插入图片描述

方法三:动态规划

/* 最小路径和:动态规划 */
int minPathSumDP(vector<vector<int>> &grid) {
    int n = grid.size(), m = grid[0].size();
    // 初始化 dp 表
    vector<vector<int>> dp(n, vector<int>(m));
    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] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
        }
    }
    return dp[n - 1][m - 1];
}

下图展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 𝑂(𝑛𝑚) 。
数组 dp 大小为 𝑛 × 𝑚 ,因此空间复杂度为 𝑂(𝑛𝑚) 。

在这里插入图片描述
在这里插入图片描述

4.空间优化

由于每个格子只与其左边和上边的格子有关,因此可以只用一个单行数组来实现 𝑑𝑝 表。
请注意,因为数组 dp 只能表示一行的状态,所以无法提前初始化首列状态,而是在遍历每行时更新它:

/* 最小路径和:空间优化后的动态规划 */
int minPathSumDPComp(vector<vector<int>> &grid) {
    int n = grid.size(), m = grid[0].size();
    // 初始化 dp 表
    vector<int> dp(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] = min(dp[j - 1], dp[j]) + grid[i][j];
        }
    }
    return dp[m - 1];
}

0-1 背包问题

例题:给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的背包。每个物品只能选择一次,问在限定背包容量下能放入物品的最大价值。

由于物品编号 𝑖 从 1 开始计数,数组索引从 0 开始计数,因此物品 𝑖 对应重量 𝑤𝑔𝑡[𝑖 − 1] 和价值 𝑣𝑎𝑙[𝑖 − 1] 。
在这里插入图片描述
可以将 0‑1 背包问题看作一个由 𝑛 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此该问题满足决策树模型。

该问题的目标是求解 “在限定背包容量下能放入物品的最大价值” ,因此较大概率是一个动态规划问题。

第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 𝑖 和剩余背包容量 𝑐 ,记为 [𝑖, 𝑐] 。
状态 [𝑖, 𝑐] 对应的子问题为:前 𝑖 个物品在剩余容量为 𝑐 的背包中的最大价值,记为 𝑑𝑝[𝑖, 𝑐] 。
待求解的是 𝑑𝑝[𝑛, 𝑐𝑎𝑝] ,因此需要一个尺寸为 (𝑛 + 1) × (𝑐𝑎𝑝 + 1) 的二维 𝑑𝑝 表。

第二步:找出最优子结构,进而推导出状态转移方程
做出物品 𝑖 的决策后,剩余的是前 𝑖 − 1 个物品的决策,可分为以下两种情况:
不放入物品 𝑖 :背包容量不变,状态变化为 [𝑖 − 1, 𝑐] 。
放入物品 𝑖 :背包容量减少 𝑤𝑔𝑡[𝑖 − 1] ,价值增加 𝑣𝑎𝑙[𝑖 − 1] ,状态变化为 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 。

本题的最优子结构:最大价值 𝑑𝑝[𝑖, 𝑐] 等于不放入物品 𝑖 和放入物品 𝑖 两种方案中价值更大的那一个。由此可推导出状态转移方程:
在这里插入图片描述
需要注意的是,若当前物品重量 𝑤𝑔𝑡[𝑖 − 1] 超出剩余背包容量 𝑐 ,则只能选择不放入背包。

第三步:确定边界条件和状态转移顺序
当无物品或无剩余背包容量时最大价值为 0 ,即首列 𝑑𝑝[𝑖, 0] 和首行 𝑑𝑝[0, 𝑐] 都等于 0 。
当前状态 [𝑖, 𝑐] 从上方的状态 [𝑖 − 1, 𝑐] 和左上方的状态 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 转移而来,因此通过两层循环正序遍历整个 𝑑𝑝 表即可。

方法一:暴力搜索

搜索代码包含以下要素。
1.递归参数:状态 [𝑖, 𝑐] 。
2.返回值:子问题的解 𝑑𝑝[𝑖, 𝑐] 。
3.终止条件:当物品编号越界 𝑖 = 0 或背包剩余容量为 0 时,终止递归并返回价值 0 。
4.剪枝:若当前物品重量超出背包剩余容量,则只能选择不放入背包。

int knapsackDFS(vector<int> &wgt, vector<int> &val, int i, int c) {
    // 若已选完所有物品或背包无剩余容量,则返回价值 0
    if (i == 0 || c == 0) {
        return 0;
    }
    // 若超过背包容量,则只能选择不放入背包
    if (wgt[i - 1] > c) {
        return knapsackDFS(wgt, val, i - 1, c);
    }
    // 计算不放入和放入物品 i 的最大价值
    int no = knapsackDFS(wgt, val, i - 1, c);
    int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1];
    // 返回两种方案中价值更大的那一个
    return max(no, yes);
}

由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 𝑂(2^𝑛) 。
在这里插入图片描述
观察递归树,容易发现其中存在重叠子问题,例如 𝑑𝑝[1, 10] 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。

方法二:记忆化搜索

为了保证重叠子问题只被计算一次,借助记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应 𝑑𝑝[𝑖, 𝑐]。
引入记忆化之后,时间复杂度取决于子问题数量,也就是 𝑂(𝑛 × 𝑐𝑎𝑝) 。

/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(vector<int> &wgt, vector<int> &val, vector<vector<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] = max(no, yes);
    return mem[i][c];
}

在这里插入图片描述

方法三:动态规划

动态规划实质上就是在状态转移中填充 𝑑𝑝 表的过程:

/* 0-1 背包:动态规划 */
int knapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
    // 状态转移
    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] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[n][cap];
}

时间复杂度和空间复杂度都由数组 dp 大小决定,即 𝑂(𝑛 × 𝑐𝑎𝑝) 。

空间优化

由于每个状态都只与其上一行的状态有关,因此可以使用两个数组滚动前进,将空间复杂度从 𝑂(𝑛^2)降至 𝑂(𝑛) 。

进一步思考,能否仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 𝑖 行时,该数组存储的仍然是第 𝑖 − 1 行的状态。
‧ 如果采取正序遍历,那么遍历到 𝑑𝑝[𝑖, 𝑗] 时,左上方 𝑑𝑝[𝑖 − 1, 1] ~ 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 值可能已经被覆盖,此时就无法得到正确的状态转移结果。
‧ 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
在这里插入图片描述
在这里插入图片描述

/* 0-1 背包:空间优化后的动态规划 */
int knapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<int> dp(cap + 1, 0);
    // 状态转移
    for (int i = 1; i <= n; i++) {
        // 倒序遍历
        for (int c = cap; c >= 1; c--) {
            if (wgt[i - 1] <= c) {
                // 不选和选物品 i 这两种方案的较大值
                dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[cap];
}

完全背包问题

例题:给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的背包。每个物品可以重复选取,问在限定背包容量下能放入物品的最大价值。
示例如图所示:
在这里插入图片描述

1.动态规划思路

完全背包问题和 0‑1 背包问题非常相似,区别仅在于不限制物品的选择次数
在 0‑1 背包问题中,每种物品只有一个,因此将物品 𝑖 放入背包后,只能从前 𝑖 − 1 个物品中选择。
在完全背包问题中,每种物品的数量是无限的,因此将物品 𝑖 放入背包后,仍可以从前 𝑖 个物品中选择。

在完全背包问题的规定下,状态 [𝑖, 𝑐] 的变化分为两种情况:
‧ 不放入物品 𝑖 :与 0‑1 背包问题相同,转移至 [𝑖 − 1, 𝑐] 。
‧ 放入物品 𝑖 :与 0‑1 背包问题不同,转移至 [𝑖, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 。

从而状态转移方程变为:
在这里插入图片描述

2.代码实现

对比两道题目的代码,状态转移中有一处从 𝑖 − 1 变为 𝑖 ,其余完全一致:

/* 完全背包:动态规划 */
int unboundedKnapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
    // 状态转移
    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] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[n][cap];
}

3.空间优化

由于当前状态是从左边和上边的状态转移而来的,因此空间优化后应该对 𝑑𝑝 表中的每一行进行正序遍历。这个遍历顺序与 0‑1 背包正好相反。
在这里插入图片描述
在这里插入图片描述

仅需将数组 dp 的第一维删除:

/* 完全背包:空间优化后的动态规划 */
int unboundedKnapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<int> dp(cap + 1, 0);
    // 状态转移
    for (int i = 1; i <= n; i++) {
        for (int c = 1; c <= cap; c++) {
            if (wgt[i - 1] > c) {
                // 若超过背包容量,则不选物品 i
                dp[c] = dp[c];
            } else {
                // 不选和选物品 i 这两种方案的较大值
                dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[cap];
}

零钱兑换问题Ⅰ

背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。

例题:给定 𝑛 种硬币,第 𝑖 种硬币的面值为 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1] ,目标金额为 𝑎𝑚𝑡 ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 −1 。
示例如图:
在这里插入图片描述

1.动态规划思路

零钱兑换可以看作完全背包问题的一种特殊情况,两者具有以下联系与不同点。
‧ 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
‧ 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
‧ 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。

第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
状态 [𝑖, 𝑎] 对应的子问题为:前 𝑖 种硬币能够凑出金额 𝑎 的最少硬币数量,记为 𝑑𝑝[𝑖, 𝑎] 。
二维 𝑑𝑝 表的尺寸为 (𝑛 + 1) × (𝑎𝑚𝑡 + 1) 。

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

此题与完全背包问题的状态转移方程存在以下两点差异:
本题要求最小值,因此需将运算符 max() 更改为 min() 。
优化主体是硬币数量而非商品价值,因此在选中硬币时执行 +1 即可。
在这里插入图片描述
第三步:确定边界条件和状态转移顺序
当目标金额为 0 时,凑出它的最少硬币数量为 0 ,即首列所有 𝑑𝑝[𝑖, 0] 都等于 0 。

当无硬币时,无法凑出任意 > 0 的目标金额,即是无效解。为使状态转移方程中的 min() 函数能够识别并过滤无效解,考虑使用 +∞ 来表示它们,即令首行所有 𝑑𝑝[0, 𝑎] 都等于 +∞ 。

2.代码实现

大多数编程语言并未提供 +∞ 变量,只能使用整型 int 的最大值来代替。而这又会导致大数越界:状态转移方程中的 +1 操作可能发生溢出。

为此,采用数字 𝑎𝑚𝑡 + 1 来表示无效解,因为凑出 𝑎𝑚𝑡 的硬币数量最多为 𝑎𝑚𝑡 。最后返回前,判断𝑑𝑝[𝑛, 𝑎𝑚𝑡] 是否等于 𝑎𝑚𝑡 + 1 ,若是则返回 −1 ,代表无法凑出目标金额。

/* 零钱兑换:动态规划 */
int coinChangeDP(vector<int> &coins, int amt) {
    int n = coins.size();
    int MAX = amt + 1;
    // 初始化 dp 表
    vector<vector<int>> dp(n + 1, vector<int>(amt + 1, 0));
    // 状态转移:首行首列
    for (int a = 1; a <= amt; a++) {
        dp[0][a] = MAX;
    }
    // 状态转移:其余行和列
    for (int i = 1; i <= n; i++) {
        for (int a = 1; a <= amt; a++) {
            if (coins[i - 1] > a) {
                // 若超过目标金额,则不选硬币 i
                dp[i][a] = dp[i - 1][a];
            } else {
                // 不选和选硬币 i 这两种方案的较小值
                dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1);
            }
        }
    }
    return dp[n][amt] != MAX ? dp[n][amt] : -1;
}

3.空间优化

零钱兑换的空间优化的处理方式和完全背包问题一致:

/* 零钱兑换:空间优化后的动态规划 */
int coinChangeDPComp(vector<int> &coins, int amt) {
    int n = coins.size();
    int MAX = amt + 1;
    // 初始化 dp 表
    vector<int> dp(amt + 1, MAX);
    dp[0] = 0;
    // 状态转移
    for (int i = 1; i <= n; i++) {
        for (int a = 1; a <= amt; a++) {
            if (coins[i - 1] > a) {
                // 若超过目标金额,则不选硬币 i
                dp[a] = dp[a];
            } else {
                // 不选和选硬币 i 这两种方案的较小值
                dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1);
            }
        }
    }
    return dp[amt] != MAX ? dp[amt] : -1;
}

零钱兑换问题Ⅱ

例题:给定 𝑛 种硬币,第 𝑖 种硬币的面值为 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1] ,目标金额为 𝑎𝑚𝑡 ,每种硬币可以重复选取,问凑出目标金额的硬币组合数量。
在这里插入图片描述

1.动态规划思路

相比于上一题,本题目标是求组合数量,因此子问题变为:前 𝑖 种硬币能够凑出金额 𝑎 的组合数量。而 𝑑𝑝 表仍然是尺寸为 (𝑛 + 1) × (𝑎𝑚𝑡 + 1) 的二维矩阵。

当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为:
在这里插入图片描述
当目标金额为 0 时,无须选择任何硬币即可凑出目标金额,因此应将首列所有 𝑑𝑝[𝑖, 0] 都初始化为 1 。当无硬币时,无法凑出任何 > 0 的目标金额,因此首行所有 𝑑𝑝[0, 𝑎] 都等于 0 。

2.代码实现

/* 零钱兑换 II:动态规划 */
int coinChangeIIDP(vector<int> &coins, int amt) {
    int n = coins.size();
    // 初始化 dp 表
    vector<vector<int>> dp(n + 1, vector<int>(amt + 1, 0));
    // 初始化首列
    for (int i = 0; i <= n; i++) {
        dp[i][0] = 1;
    }
    // 状态转移
    for (int i = 1; i <= n; i++) {
        for (int a = 1; a <= amt; a++) {
            if (coins[i - 1] > a) {
                // 若超过目标金额,则不选硬币 i
                dp[i][a] = dp[i - 1][a];
            } else {
                // 不选和选硬币 i 这两种方案之和
                dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]];
            }
        }
    }
    return dp[n][amt];
}

3.空间优化

空间优化处理方式相同,删除硬币维度即可:

/* 零钱兑换 II:空间优化后的动态规划 */
int coinChangeIIDPComp(vector<int> &coins, int amt) {
    int n = coins.size();
    // 初始化 dp 表
    vector<int> dp(amt + 1, 0);
    dp[0] = 1;
    // 状态转移
    for (int i = 1; i <= n; i++) {
        for (int a = 1; a <= amt; a++) {
            if (coins[i - 1] > a) {
                // 若超过目标金额,则不选硬币 i
                dp[a] = dp[a];
            } else {
                // 不选和选硬币 i 这两种方案之和
                dp[a] = dp[a] + dp[a - coins[i - 1]];
            }
        }
    }
    return dp[amt];
}

编辑距离问题

编辑距离,也称 Levenshtein 距离,指两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。

例题:输入两个字符串 𝑠 和 𝑡 ,返回将 𝑠 转换为 𝑡 所需的最少编辑步数。你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、将字符替换为任意一个字符。

将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。
在这里插入图片描述
编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。

在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello 转换到 algo 有许多种可能的路径。
从决策树的角度看,本题的目标是求解节点 hello 和节点 algo 之间的最短路径。
在这里插入图片描述

1.动态规划思路

第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
每一轮的决策是对字符串 𝑠 进行一次编辑操作。

希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 𝑠 和 𝑡 的长度分别为 𝑛 和 𝑚 ,我们先考虑两字符串尾部的字符 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 。

1.若 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 相同,可以跳过它们,直接考虑 𝑠[𝑛 − 2] 和 𝑡[𝑚 − 2]
2.若 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 不同,需要对 𝑠 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。

也就是说,在字符串 𝑠 中进行的每一轮决策(编辑操作),都会使得 𝑠 和 𝑡 中剩余的待匹配字符发生变化。因此,状态为当前在 𝑠 和 𝑡 中考虑的第 𝑖 和第 𝑗 个字符,记为 [𝑖, 𝑗] 。

状态 [𝑖, 𝑗] 对应的子问题:将 𝑠 的前 𝑖 个字符更改为 𝑡 的前 𝑗 个字符所需的最少编辑步数

至此,得到一个尺寸为 (𝑖 + 1) × (𝑗 + 1) 的二维 𝑑𝑝 表。

第二步:找出最优子结构,进而推导出状态转移方程
在这里插入图片描述

考虑子问题 𝑑𝑝[𝑖, 𝑗] ,其对应的两个字符串的尾部字符为 𝑠[𝑖 − 1] 和 𝑡[𝑗 − 1] ,可根据不同编辑操作分为上图所示的三种情况:
1.在 𝑠[𝑖 − 1] 之后添加 𝑡[𝑗 − 1] ,则剩余子问题 𝑑𝑝[𝑖, 𝑗 − 1] 。
2.删除 𝑠[𝑖 − 1] ,则剩余子问题 𝑑𝑝[𝑖 − 1, 𝑗] 。
3.将 𝑠[𝑖 − 1] 替换为 𝑡[𝑗 − 1] ,则剩余子问题 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 。

根据以上分析,可得最优子结构:𝑑𝑝[𝑖, 𝑗] 的最少编辑步数等于 𝑑𝑝[𝑖, 𝑗 − 1]、𝑑𝑝[𝑖 − 1, 𝑗]、𝑑𝑝[𝑖 − 1, 𝑗 − 1] 三者中的最少编辑步数,再加上本次的编辑步数 1 。
对应的状态转移方程为:
在这里插入图片描述请注意,当 𝑠[𝑖 − 1] 和 𝑡[𝑗 − 1] 相同时,无须编辑当前字符,这种情况下的状态转移方程为:
在这里插入图片描述
第三步:确定边界条件和状态转移顺序
当两字符串都为空时,编辑步数为 0 ,即 𝑑𝑝[0, 0] = 0 。当 𝑠 为空但 𝑡 不为空时,最少编辑步数等于 𝑡 的长度,即首行 𝑑𝑝[0, 𝑗] = 𝑗 。当 𝑠 不为空但 𝑡 为空时,最少编辑步数等于 𝑠 的长度,即首列 𝑑𝑝[𝑖, 0] = 𝑖 。

观察状态转移方程,解 𝑑𝑝[𝑖, 𝑗] 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 𝑑𝑝 表即可。

2.代码实现

/* 编辑距离:暴力搜索 */
int editDistanceDFS(string s, string t, int i, int j) {
    // 若 s 和 t 都为空,则返回 0
    if (i == 0 && j == 0)
        return 0;
    // 若 s 为空,则返回 t 长度
    if (i == 0)
        return j;
    // 若 t 为空,则返回 s 长度
    if (j == 0)
        return i;
    // 若两字符相等,则直接跳过此两字符
    if (s[i - 1] == t[j - 1])
        return editDistanceDFS(s, t, i - 1, j - 1);
    // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
    int insert = editDistanceDFS(s, t, i, j - 1);
    int del = editDistanceDFS(s, t, i - 1, j);
    int replace = editDistanceDFS(s, t, i - 1, j - 1);
    // 返回最少编辑步数
    return min(min(insert, del), replace) + 1;
}

/* 编辑距离:记忆化搜索 */
int editDistanceDFSMem(string s, string t, vector<vector<int>> &mem, int i, int j) {
    // 若 s 和 t 都为空,则返回 0
    if (i == 0 && j == 0)
        return 0;
    // 若 s 为空,则返回 t 长度
    if (i == 0)
        return j;
    // 若 t 为空,则返回 s 长度
    if (j == 0)
        return i;
    // 若已有记录,则直接返回之
    if (mem[i][j] != -1)
        return mem[i][j];
    // 若两字符相等,则直接跳过此两字符
    if (s[i - 1] == t[j - 1])
        return editDistanceDFSMem(s, t, mem, i - 1, j - 1);
    // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
    int insert = editDistanceDFSMem(s, t, mem, i, j - 1);
    int del = editDistanceDFSMem(s, t, mem, i - 1, j);
    int replace = editDistanceDFSMem(s, t, mem, i - 1, j - 1);
    // 记录并返回最少编辑步数
    mem[i][j] = min(min(insert, del), replace) + 1;
    return mem[i][j];
}

/* 编辑距离:动态规划 */
int editDistanceDP(string s, string t) {
    int n = s.length(), m = t.length();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    // 状态转移:首行首列
    for (int i = 1; i <= n; i++) {
        dp[i][0] = i;
    }
    for (int j = 1; j <= m; j++) {
        dp[0][j] = j;
    }
    // 状态转移:其余行和列
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) {
                // 若两字符相等,则直接跳过此两字符
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
                dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
            }
        }
    }
    return dp[n][m];
}

3.空间优化

由于 𝑑𝑝[𝑖, 𝑗] 是由上方 𝑑𝑝[𝑖 − 1, 𝑗]、左方 𝑑𝑝[𝑖, 𝑗 − 1]、左上方 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 转移而来的,而正序遍历会丢失左上方 𝑑𝑝[𝑖 − 1, 𝑗 − 1] ,倒序遍历无法提前构建 𝑑𝑝[𝑖, 𝑗 − 1] ,因此两种遍历顺序都不可取。

为此,可以使用一个变量 leftup 来暂存左上方的解 𝑑𝑝[𝑖 − 1, 𝑗 − 1] ,从而只需考虑左方和上方的解。
此时的情况与完全背包问题相同,可使用正序遍历。代码如下所示:

/* 编辑距离:空间优化后的动态规划 */
int editDistanceDPComp(string s, string t) {
    int n = s.length(), m = t.length();
    vector<int> dp(m + 1, 0);
    // 状态转移:首行
    for (int j = 1; j <= m; j++) {
        dp[j] = j;
    }
    // 状态转移:其余行
    for (int i = 1; i <= n; i++) {
        // 状态转移:首列
        int leftup = dp[0]; // 暂存 dp[i-1, j-1]
        dp[0] = i;
        // 状态转移:其余列
        for (int j = 1; j <= m; j++) {
            int temp = dp[j];
            if (s[i - 1] == t[j - 1]) {
                // 若两字符相等,则直接跳过此两字符
                dp[j] = leftup;
            } else {
                // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
                dp[j] = min(min(dp[j - 1], dp[j]), leftup) + 1;
            }
            leftup = temp; // 更新为下一轮的 dp[i-1, j-1]
        }
    }
    return dp[m];
}

学习地址

学习地址:https://github.com/krahets/hello-algo
重新复习数据结构,所有的内容都来自这里。

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

飞大圣

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值