刷LeetCode中等题时,编辑距离绝对是动态规划的经典代表作------它看似复杂,三种操作(插入、删除、替换)让人无从下手,但只要理清状态定义和转移逻辑,就能轻松破解。今天就带大家一步步拆解这道题,从题意分析到代码实现,把每一个细节讲透。
一、题目解读:到底要解决什么问题?
题目很简洁:给定两个单词word1和word2,返回将word1转换成word2所使用的最少操作数。
允许的三种操作(对一个单词进行):
-
插入一个字符:比如word1是"abc",word2是"abdc",可以在word1的b和c之间插入d,完成转换。
-
删除一个字符:比如word1是"abcd",word2是"abc",删除word1的d即可。
-
替换一个字符:比如word1是"abc",word2是"adc",将word1的b替换成d即可。
核心难点:三种操作可以任意组合,如何找到"最少步骤"?------ 动态规划的核心就是"最优子结构",把大问题拆成小问题,逐个解决。
二、动态规划思路拆解(核心部分)
动态规划解题,关键就3步:定义dp数组、确定边界条件、推导状态转移方程。我们一步步来:
1. 定义dp数组
设word1的长度为n,word2的长度为m,定义dp[i][j]:将word1前i个字符(word1[0...i-1])转换成word2前j个字符(word2[0...j-1])所需的最少操作数。
为什么是i-1和j-1?因为dp数组的下标从0开始,而字符串的下标也从0开始,dp[0][0]表示两个空串的转换,dp[1][1]才对应两个单词的第一个字符,这样定义更直观,避免下标混乱。
2. 确定边界条件
边界情况就是"其中一个单词为空串"的场景:
-
如果word1为空(i=0),那么要转换成word2前j个字符,只能不断插入j个字符,所以dp[0][j] = j。
-
如果word2为空(j=0),那么要转换成空串,只能不断删除word1前i个字符,所以dp[i][0] = i。
比如dp[0][3] = 3(空串转成3个字符,插入3次),dp[2][0] = 2(2个字符转成空串,删除2次)。
3. 推导状态转移方程
这是最关键的一步,我们分两种情况讨论:word1的第i个字符(word1[i-1])和word2的第j个字符(word2[j-1])是否相等。
情况1:word1[i-1] == word2[j-1]
此时,两个字符已经匹配,不需要做任何操作,最少操作数就等于"word1前i-1个字符转word2前j-1个字符"的操作数,即:
dp[i][j] = dp[i-1][j-1]
比如word1是"abc",word2是"adc",当i=3、j=3时,word1[2] = c,word2[2] = c,此时dp[3][3] = dp[2][2]。
情况2:word1[i-1] != word2[j-1]
此时需要进行插入、删除、替换三种操作中的一种,取这三种操作的最少步骤即可,对应三个方向的状态:
-
删除操作:删除word1的第i个字符,此时dp[i][j] = dp[i-1][j] + 1(删除1次,加上之前的操作数)。
-
插入操作:在word1的第i个字符后插入一个和word2[j-1]相同的字符,此时dp[i][j] = dp[i][j-1] + 1(插入1次,加上之前的操作数)。
-
替换操作:将word1的第i个字符替换成word2[j-1],此时dp[i][j] = dp[i-1][j-1] + 1(替换1次,加上之前的操作数)。
所以,状态转移方程为:
dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + 1)
三、完整代码实现(TypeScript)
结合上面的思路,直接上代码,每一行都加了详细注释,对应我们拆解的逻辑:
typescript
function minDistance(word1: string, word2: string): number {
let n = word1.length; // word1的长度
let m = word2.length; // word2的长度
// 边界情况:有一个字符串为空串,操作数就是另一个字符串的长度
if (n * m == 0) {
return n + m;
}
// 初始化dp数组:n+1行(word1的0~n个字符),m+1列(word2的0~m个字符)
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1));
// 边界状态初始化:word2为空时,dp[i][0] = i(删除i次)
for (let i = 0; i < n + 1; i++) {
dp[i][0] = i;
}
// 边界状态初始化:word1为空时,dp[0][j] = j(插入j次)
for (let j = 0; j< m + 1; j++) {
dp[0][j] = j;
}
// 填充dp数组:遍历所有i和j,计算每个dp[i][j]的值
for (let i = 1; i < n + 1; i++) {
for (let j = 1; j < m + 1; j++) {
// 左:删除操作,dp[i-1][j] + 1
let left = dp[i - 1][j] + 1;
// 下:插入操作,dp[i][j-1] + 1
let down = dp[i][j - 1] + 1;
// 左下:替换/不操作,先取dp[i-1][j-1]
let left_down = dp[i - 1][j - 1];
// 字符不相等时,替换操作需要+1
if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
left_down += 1;
}
// 取三种操作的最小值,赋值给dp[i][j]
dp[i][j] = Math.min(left, Math.min(down, left_down));
}
}
// dp[n][m]就是word1完整转word2的最少操作数
return dp[n][m];
};
四、代码解析与易错点提醒
1. 易错点1:边界条件的判断
当n*m == 0时,说明其中一个字符串长度为0,此时直接返回n+m即可,不用再初始化dp数组,节省时间。比如word1为空,word2长度为5,返回5(插入5次)。
2. 易错点2:dp数组的初始化
dp数组的长度是n+1和m+1,因为要包含"0个字符"的情况,比如dp[0][0] = 0(两个空串,无需操作)。
3. 易错点3:字符串下标与dp数组下标的对应
dp[i][j]对应word1[0...i-1]和word2[0...j-1],所以取字符时要用word1.charAt(i-1)和word2.charAt(j-1),千万别写成i和j,否则会越界。
五、总结:这道题的核心价值
编辑距离是动态规划的经典应用,它的核心思想是"用子问题的最优解推导原问题的最优解"。这道题的关键不是记住代码,而是理解:
-
如何定义dp数组,让它能表示"子问题的最优解";
-
如何根据题意,找到边界条件(极端情况);
-
如何分析不同场景,推导状态转移方程。
学会这道题后,再遇到类似的"最少操作""最优路径"类动态规划题,思路会清晰很多。比如字符串匹配、最长公共子序列等,都能用到类似的思维。