leetcode 72 编辑距离 这是一道比较经典的动态规划算法题。
简单记录下该题的思路和代码。该题是要求算出最小编辑距离,也就是从字符串1到字符串2,所需要的步数。
在这里也会做一点扩展,给出从字符串1到字符串2的编辑操作路径的解法。
第一部分 求最小值
思路如下:
- 用两个指针i, j分别指向word1,word2两个字符串的最后,一步步往前,缩小问题规模。
- base case: 当i走完word1或者j走完word2,则可返回另一个字符串剩下的长度。
- 操作类型:对于字符串比对的操作有四种,跳过 删除 替换 插入。对比的字符相同则跳过,不相同则执行其他三种操作。
- dp定义:返回word1[0...i]和word2[0...j]的最小编辑距离。
先来看看最直接的暴力解法:
var minDistance = function(word1, word2) {
let m = word1.length, n = word2.length
// 初始值指向最后一个索引
return dp(m-1, n-1)
function dp(i, j) {
if (i===-1) return j+1
if (j===-1) return i+1
if (word1[i]===word2[j]) {
return dp(i-1, j-1)
} else {
// 别忘了在每步操作后加1
return Math.min(
dp(i-1, j) + 1, // 删除
dp(i, j-1) + 1, // 插入
dp(i-1, j-1) + 1 // 替换
)
}
}
};
当然,暴力解法存在重叠子问题,可能会超过时间限制。
- 利用备忘录优化下:
var minDistance = function(word1, word2) {
let m = word1.length, n = word2.length
// fill不能直接传入数组,会指向同一个引用
let memo = new Array(m).fill().map(() => new Array(n).fill(-1))
return dp(m - 1, n - 1)
function dp(i, j) {
if (i === -1) return j + 1
if (j === -1) return i + 1
if (memo[i][j] !== -1) return memo[i][j]
if (word1[i] === word2[j]) {
memo[i][j] = dp(i - 1, j - 1)
} else {
memo[i][j] = Math.min(
dp(i - 1, j) + 1,
dp(i, j - 1) + 1,
dp(i - 1, j - 1) + 1
)
}
return memo[i][j]
}
};
- 如果利用dp数组优化,则是这样:
var minDistance = function(word1, word2) {
let m = word1.length, n = word2.length
// 考虑到一方可能为空字符串的情况,数组长度分别为m+1、n+1
let dp = new Array(m+1).fill().map(() => new Array(n+1).fill())
for (let i = 0;i <= m; i++) {
dp[i][0] = i
}
for (let j = 0; j <= n;j++) {
dp[0][j] = j
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n;j++) {
// 注意此处的i-1、j-1, 确保数组索引是从0开始。
if (word1[i-1] === word2[j-1]) {
dp[i][j] = dp[i-1][j-1]
} else {
dp[i][j] = Math.min(
dp[i-1][j] +1,
dp[i][j-1] + 1,
dp[i-1][j-1] + 1
)
}
}
}
return dp[m][n]
};
第二部分 具体操作路径
第一部分已给出求最小编辑距离的解法,那如果想知道word1是如何一步步变成word2,也就是如何得到具体的操作路径呢?
可以给dp数组增加额外的信息,dp数组除了保存之前的距离值外,可以多保存一个操作值,即在计算最短距离的同时把操作值记录下来,这样就可以从最终的结果dp[m][n]反向推导出具体的操作路径。
如下,声明一个节点,记录距离值和操作值。
class Node {
constructor(val, choice) {
this.val = val
this.choice = choice
}
val = -1
choice // 记录操作 0-跳过 1-删除 2-插入 3-替换
}
完整代码如下:
代码中getPath(dp, m, n)所得的值即为所求操作路径。
class Node {
constructor(val, choice) {
this.val = val
this.choice = choice
}
val = -1
choice // 记录操作 0-跳过 1-删除 2-插入 3-替换
}
var minDistance = function(word1, word2) {
let m = word1.length, n = word2.length
// 考虑到一方可能为空字符串的情况,数组长度分别为m+1、n+1
let dp = new Array(m+1).fill().map(() => new Array(n+1).fill().map(()=> new Node()))
for (let i = 0;i <= m; i++) {
// 若j指针走完,i没走完,则剩下的操作就是删除掉word1剩下的字符即可
dp[i][0] = new Node(i, 1)
}
for (let j = 0; j <= n;j++) {
// 若i指针走完,j没走完,则剩下的操作就是插入word2剩下的字符即可
dp[0][j] = new Node(j, 2)
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n;j++) {
// 注意此处的i-1、j-1, 确保数组索引是从0开始。
if (word1[i-1] === word2[j-1]) {
dp[i][j].val = dp[i-1][j-1].val
dp[i][j].choice = 0 // 跳过
} else {
const [min, index] = findMin(
dp[i-1][j],
dp[i][j-1],
dp[i-1][j-1]
)
dp[i][j].val = min.val + 1
dp[i][j].choice = [1, 2, 3][index]
}
}
}
console.log(getPath(dp, m, n))
return dp[m][n].val
};
function findMin(a, b, c) {
let min = [a, b, c].sort((a, b) => {
return a.val - b.val
})[0]
let index = [a, b, c].findIndex(i => i.val === min.val)
return [min, index]
}
// 获取操作路径
function getPath(dp, m, n) {
let position = [m, n]
let endPoint = dp[m][n]
const textMap = ['跳过', '删除', '插入', '替换']
const res = []
while (endPoint.val) {
if (endPoint.choice === undefined) break
res.unshift(textMap[endPoint.choice])
const [i, j] = getPrev(endPoint.choice, position)
position = [i, j]
endPoint = dp[i][j]
}
return res
}
// 获取上一个节点的位置
function getPrev(choice, [m, n]) {
return posMap = [ [m-1, n-1],
[m-1, n],
[m, n-1],
[m-1, n-1]
][choice]
}