经典动态规划:编辑距离

本期例题:LeetCode 72. Edit Distance 编辑距离(Hard)

给定字符串 st,将 s 转换成 t。你可以进行三种操作:插入一个字符、删除一个字符、替换一个字符。计算将 s 将转换成 t 的最小操作数。

示例:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
    horse -> rorse (将 'h' 替换为 'r')
    rorse -> rose (删除 'r')
    rose -> ros (删除 'e')

今天我们要讨论一道经典的二维动态规划问题:编辑距离(Edit Distance)。

你可能在各种地方都听说过「编辑距离」。编辑距离又叫莱文斯坦距离(Levenshtein distance),用于评估两个字符串的差异程度,在拼写检查、文本对比(diff)等场景中都有应用。编辑距离问题的实际意义使得它非常受面试官青睐。

编辑距离问题是 LeetCode Hard 题目,很多同学做这道题的时候会觉得很难,总是找不到思路。这道题的难点主要有两个:

  • 第一,如何在拿到题目的时候想到可以用动态规划来解。这一般需要你做过二维动态规划的经典例题,然后联想到本题的思路。

  • 第二,如何从题目描述中拆解出动态规划的具体思路。这需要你有一些拆解动态规划题目的技巧。

今天的文章会以编辑距离问题为例,讨论如何攻克动态规划问题的这两个难点。我们先讲讲如何从复杂的问题中拆解出动态规划的思路,再讲讲如何在动态规划题目之间进行联想

编辑距离的拆解思路

假设你已经知道了编辑距离这道题是用动态规划来做,那怎么能进一步得出具体的思路呢?

动态规划题目的解题步骤我们都知道,要先定义子问题,再写出子问题的递推关系。道理我们都懂,但是对于具体的题目,如何定义子问题和子问题的递推关系还是很需要技巧的。

如果直接从整体看编辑距离,其实很难下手。究竟怎样组合插入、删除、替换操作,才能得到最少的编辑次数呢?这些操作之间又满足怎样的性质呢?

在做动态规划问题的时候,如果你觉得从全局考虑很困难,就试试先不考虑全局,从局部入手。我们可以只考虑其中的「一步」,至于剩下的步骤,就交给其他子问题完成就行。对于编辑距离来说,这「一步」就是指「单次的编辑操作」。

我们以 "horse" 和 "ros" 为例。我们考虑这「一步」做的是插入、删除还是替换操作。

  • 如果做插入操作,那么插入的是 "ros" 末尾的 's',我们还需要计算 "horse" 到 "ro" 的编辑距离;

  • 如果做删除操作,那么删除的是 "horse" 末尾的 'e',我们还需要计算 "hors" 到 "ros" 的编辑距离;

  • 如果做替换操作,那么是把 "horse" 末尾的 'e' 替换为 "ros" 末尾的 's',我们还需要计算 "hors" 到 "ro" 的编辑距离。

三种不同的编辑操作

为什么要关注「一步」呢?因为动态规划最关键的地方在于寻找「子问题的递推关系」,或者叫「状态转移方程」。所谓「递推关系」,就是一个子问题经过怎样的「一步」操作可以转换为另一个子问题。

例如,之前讲过的打家劫舍问题,计算子问题 的时候,我们可以有两种选择: 计算而来,或者 计算而来。也就是说,子问题 可以转换为

这有点类似递归的思路。我只需要把当前这一步计算做好,然后相信递归函数能帮我做好剩下的计算。动态规划其实很像递归,只不过动态规划一般是自底向上计算,保存每个子问题。

对于编辑距离问题,我们的「一步」有三种「选择」。选择插入、删除或者替换操作,我们可以将当前子问题转换为不同的子问题:

将当前子问题转换为不同的子问题

这样,编辑距离的子问题如何定义其实就相当清楚了。不同的子问题其实就是 st 有不同的长度。我们可以定义子问题 为「s[0..i)t[0..j) 的编辑距离」。子问题的递推关系就有三个分支,分别对应插入、删除、替换三种操作。

这样,我们就有了动态规划解法的基本思路,接下来,我们继续套用动态规划的「解题四步骤」,来给出完整的题解吧。

套用解题四步骤

老规矩,我们使用动态规划的解题四步骤来写出题解代码。

步骤一:定义子问题

上文我们已经讨论了两个字符串的动态规划问题的子问题套路。和最长公共子序列问题一样,我们可以定义子问题 为「s[0..i)t[0..j) 的编辑距离」。那么,这是一道二维动态规划问题。

子问题的定义

步骤二:写出子问题的递推关系

子问题的递推关系就和三种编辑操作相对应。对于 s[0..i)t[0..j) 的编辑距离,我们有三种方案:

  • 删除 s[i-1],然后计算 s[0..i-1)t[0..j) 的编辑距离

  • 插入 t[j-1],然后计算 s[0..i)t[0..j-1) 的编辑距离

  • s[i-1] 替换为 t[j-1],然后计算 s[0..i-1)t[0..j-1) 的编辑距离

对于这三种方案,我们需要找出其中操作最少的方案。用公式写出来的话,就是:

子问题的递推关系,情况一

需要注意的是,上面的「替换」方案,如果 s[i-1]t[j-1] 本来就相等的话,是不需要这一步替换的,编辑操作可以减少一次。那么,我们要比较 s[0..i)t[0..j) 的最后一个字符,即 s[i-1]t[j-1]。这可能会有两种情况:

第一种情况:如果 s[i-1] != t[j-1],我们可以做「插入」、「删除」或者「替换」操作。

第二种情况:如果 s[i-1] == t[j-1],那么这最后一个字符不需要进行任何编辑操作,我们可以去掉最后一个字符,然后计算 s[0..i-1)t[0..j-1) 的编辑距离。用公式写出来的话,就是:

子问题的递推关系,情况二

这样,我们得到最终的子问题递推关系为:

另外,别忘了递推关系的 base case。

  • 时,s[0..i) 为空,还剩 t[0..j) 个字符,我们需要做 次插入操作,因此

  • 时,类似地,有

步骤三:确定 DP 数组的计算顺序

对于二维动态规划问题,我们需要定义二维的 DP 数组,其中 dp[i][j] 对应子问题 ,即 s[0..i)t[0..j) 的编辑距离。

s 的长度为 t 的长度为 ,则 的取值范围分别为: 。DP 数组是一个 的矩形。

为了确定 DP 数组的计算顺序,我们需要知道子问题的依赖方向。观察子问题的递推关系, 依赖于 ,我们在 DP 数组中画出依赖方向的箭头:

DP 数组中子问题的依赖方向

我们发现,编辑距离问题的子问题依赖方向和最长公共子序列问题一模一样!这是因为,在两道题目中, 都依赖于 。虽然具体的递推关系不一样,但是只要是这样的依赖关系,DP 数组的计算顺序就是一样的。

那么,编辑距离问题,DP 数组的计算顺序同样是从左到右、从上到下。我们应该以这样的顺序遍历 DP 数组:

for (int i = 0; i <= m; i++) {
    for (int j = 0; j <= n; j++) {
        // 计算 dp[i][j] ...
    }
}

最终我们得到的题解代码为:

public int minDistance(String s, String t) {
    // 子问题:
    // f(i, j) = s[0..i) 和 t[0..j) 的编辑距离

    // f(0, j) = j
    // f(i, 0) = i
    // f(i, j) = f(i-1, j-1), if s[i-1] == t[j-1]
    //           max: f(i-1, j) + 1
    //                f(i, j-1) + 1
    //                f(i-1, j-1) + 1, if s[i-1] != t[j-1]

    int m = s.length();
    int n = t.length();
    int[][] dp = new int[m+1][n+1];
    for (int i = 0; i <= m; i++) {
        for (int j = 0; j <= n; j++) {
            if (i == 0) {
                dp[i][j] = j;
            } else if (j == 0) {
                dp[i][j] = i;
            } else {
                if (s.charAt(i-1) == t.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1];
                } else {
                    dp[i][j] = 1 + min3(
                        dp[i-1][j],  // 删除操作
                        dp[i][j-1],  // 插入操作
                        dp[i-1][j-1] // 替换操作
                    );
                }
            }
        }
    }
    return dp[m][n];
}

private int min3(int x, int y, int z) {
    return Math.min(x, Math.min(y, z));
}

这个算法的时间、空间复杂度均为 ,其中 分别表示 st 的长度。

步骤四:空间优化(可选)

编辑距离问题本身属于较难的题目,所以我们写出基本的解法就可以,一般面试中不会追问空间优化的方法。

实际上,编辑距离、最长公共子序列这类问题有一种共同的空间优化方法。后面我会专门写一篇文章,给大家讲述动态规划的各种空间优化套路。

从 LCS 问题联想出思路

以上我们讲解了在「知道这道题是动态规划」的情况下该怎么拆解子问题。那么你可能要问了,在刚拿到这道题的时候,怎么才能想到它是一道动态规划题目呢?

答案很简单,从做过的题目进行联想。如果你觉得这道题和某个动态规划题目很像,那它多半可以用动态规划来做。

这道编辑距离问题,其实和我们讲过的最长公共子序列(LCS)问题非常相似。这两个题目都是涉及到两个字符串比较的「双串」题目。很容易联想到。

如果你对最长公共子序列(LCS)题目不太了解,可以回顾之前的文章:

LeetCode 例题精讲 | 15 最长公共子序列:二维动态规划的解法

我用 LCS 作为二维动态规划的例题,就是因为它的方法非常经典,很多其他题目中都有它的影子。

LCS 问题中其实蕴含了两个解题的小套路:

  • 一般来说,涉及到「子序列」的问题,都是用动态规划来做。

  • 一般来说,涉及两个字符串的动态规划问题,都可以使用 ij 两个指针分别指向两个字符串的尾部,然后向前移动。也就是说,定义子问题 表示子串 s[0..i)t[0..j)

LCS 问题是关于求公共的子序列。编辑距离问题乍一看没有什么子序列,但联想起来,其实非常相似。

仔细看编辑距离问题的「插入」、「删除」、「替换」三个操作。其中「插入」和「删除」是相反的操作,从 s 转换到 t 插入的字符,相当于从 t 转换到 s 删除的字符。

那么,我们其实可以把所有的操作归类调整,分为三个阶段:

  • 首先,做「删除」操作,从 s 转换到 s 的一个子序列 s’

  • 接着,做「替换」操作,从 s’ 转换到 t 的一个子序列 t’

  • 最后,做「插入」操作,从 t’ 转换到 t

例如,下图中是 "intention" 转换为 "execution" 的实际例子:

将插入、删除、替换操作进行归类调整

这样就可以看出编辑距离问题也和「子序列」类问题非常相似。那么我们从这一点想到用动态规划的解法,也就很正常了。

在想到可以用动态规划解题之后,我们再根据具体的题目描述拆解子问题,前面的部分我们已经讲解了具体的操作了。

总结

本文用编辑距离问题展示了从实际问题中分析动态规划思路的技巧。很多动态规划的难题都是需要一定的拆解技巧。例如比较著名的两道:

  • LeetCode 312. Burst Balloons

  • LeetCode 887. Super Egg Drop

(这两道题目都比较难,初学者不要轻易尝试)

同时,本文还展示了编辑距离问题与 LCS 的相似之处。两道题因为子问题的依赖关系是相同的,所以 DP 数组的计算顺序也完全一致。其实,很多动态规划题目都是可以「触类旁通」的。在刷题的时候,把做过的题目理解、吃透,能够让你在做新的题目的时候有更多的思路。

往期文章

我是 nettee,致力于分享面试算法的解题套路,让你真正掌握解题技巧,做到举一反三。我的《LeetCode 例题精讲》系列文章正在写作中,关注我的公众号,获取最新文章。

<think>好的,我现在需要帮助用户找到关于使用C语言实现动态规划解决编辑距离问题的资料或代码示例。用户之前已经提供了一些引用内容,包括代码和解释,我需要仔细分析这些内容并给出清晰的回答。 首先,我需要确认编辑距离问题的基本概念。编辑距离是指将一个字符串转换为另一个字符串所需的最少操作次数,操作包括插入、删除和替换字符。动态规划是解决这个问题的常用方法,因为它可以分解问题为重叠的子问题,并利用之前的计算结果来优化效率。 接下来,我需要查看用户提供的引用内容,尤其是引用[1]中的C语言代码。这段代码定义了一个min函数来取三个数的最小值,然后实现了myCharDistance函数来计算编辑距离。代码中使用了二维数组dist来存储子问题的结果,初始化了边界条件,然后通过双重循环填充整个数组。如果当前字符相同,则直接继承左上角的值;否则,计算插入、删除、替换三种操作的最小值并加1。 用户还提到了其他引用,比如引用[3]中的示例,展示了从"intention"到"execution"的转换步骤,这有助于理解编辑距离的实际应用。引用[4]则来自LeetCode,明确了允许的三种操作,这与我之前的理解一致。 我需要确保回答的结构清晰,符合用户的要求。首先解释动态规划解决编辑距离的原理,然后展示代码示例,并详细分析代码结构。同时,按照用户的指示,行内数学公式用$...$,独立公式用$$...$$,但根据用户提供的示例,可能不需要太多公式,但如果有的话需要正确应用。 另外,用户要求生成相关问题,我需要根据内容提出几个相关问题,比如动态规划的原理、优化方法、空间复杂度优化等。这些问题需要与编辑距离和C语言实现相关,帮助用户进一步学习。 需要注意代码中的一些关键点,比如dist数组的索引与字符串的对应关系,因为字符串是从0开始,而dist数组从1开始,所以在比较字符时使用的是str1[i-1]和str2[j-1]。这一点需要特别说明,避免读者混淆。 最后,确保引用正确,用户提供的引用[1]到[4]需要在回答中适当标注,例如在解释动态规划方程时引用相关示例或代码部分。同时,回答要使用中文,保持口语化,避免使用专业术语过多,但必要的术语需要准确。 总结步骤: 1. 介绍编辑距离的基本概念和动态规划的应用。 2. 分析用户提供的代码示例,解释关键部分。 3. 分步说明动态规划的实现步骤,包括初始化、状态转移方程。 4. 提供完整的代码示例并注释。 5. 生成相关问题,促进深入学习。 6. 确保引用正确,格式符合要求。 现在需要检查是否有遗漏点,比如空间复杂度优化方法,或者不同情况的处理(如空字符串)。用户的代码中已经处理了空字符串的情况,通过初始化dist[i][0]和dist[0][j]。另外,替换操作的代价是否为1,根据问题定义是正确的,所有操作都是单位成本。 可能还需要提到测试用例,比如用户提供的示例输入输出,以验证代码的正确性。例如,输入"intention"和"execution"应返回5,如引用[3]所示。 确保代码部分正确使用高亮,并且解释清晰,没有错误。同时,注意用户提到的系统级指令,如数学公式的格式和引用标识的添加。</think>### 动态规划解决编辑距离问题的C语言实现 编辑距离(Levenshtein距离)是衡量两个字符串之间差异的经典算法,通过动态规划可高效求解。其核心思想是构建状态转移表,记录将字符串$str1[0..i]$转换为$str2[0..j]$所需的最小操作次数[^4]。 --- #### 实现步骤 1. **定义状态** 用二维数组$dist[i][j]$表示将$str1$前$i$个字符转换为$str2$前$j$个字符的最小操作次数。 2. **初始化边界条件** - 当$str2$为空时:$dist[i][0] = i$(需删除$i$次) - 当$str1$为空时:$dist[0][j] = j$(需插入$j$次) 3. **状态转移方程** $$dist[i][j] = \begin{cases} dist[i-1][j-1] & \text{当 } str1[i-1] = str2[j-1] \\ \min(dist[i-1][j], dist[i][j-1], dist[i-1][j-1]) + 1 & \text{否则} \end{cases}$$ 其中三种操作对应:删除、插入、替换[^2][^4]。 --- #### 完整代码示例 ```c #include<stdio.h> #include<string.h> // 取三个数中的最小值 int min(int a, int b, int c) { int temp = a < b ? a : b; return temp < c ? temp : c; } int editDistance(char* str1, char* str2) { int len1 = strlen(str1), len2 = strlen(str2); int dist[len1+1][len2+1]; // 多一行一列存储边界条件 // 初始化边界 for (int i = 0; i <= len1; i++) dist[i][0] = i; for (int j = 0; j <= len2; j++) dist[0][j] = j; // 动态规划填表 for (int i = 1; i <= len1; i++) { for (int j = 1; j <= len2; j++) { if (str1[i-1] == str2[j-1]) { dist[i][j] = dist[i-1][j-1]; } else { dist[i][j] = min( dist[i-1][j], // 删除操作 dist[i][j-1], // 插入操作 dist[i-1][j-1] // 替换操作 ) + 1; } } } return dist[len1][len2]; } int main() { char str1[] = "intention"; char str2[] = "execution"; printf("编辑距离:%d\n", editDistance(str1, str2)); // 输出5[^3] return 0; } ``` --- #### 代码分析 1. **索引对齐** 数组$dist$的行列索引从1开始,而字符串索引从0开始,故比较字符时需使用$str1[i-1]$和$str2[j-1]$[^1]。 2. **时间复杂度** 双重循环导致时间复杂度为$O(nm)$,其中$n$和$m$为两字符串长度。 3. **空间优化方向** 可通过滚动数组将空间复杂度从$O(nm)$优化到$O(n)$,仅保留当前行和上一行数据。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值