最近在看一些react里面的Virtual DOM的相关文章,看到了看到了 livoras 的这篇文章,其中讲到了在比较两棵虚拟DOM树的差异的时候会用到字符串最小编辑距离的算法,因为那篇文章主要讲述的点并不在此,所以对于这个算法着墨不多,于是就认真去研究了下这个算法,在此处做个记录吧。
问题描述:
给定两个字符串m和n,只允许进行如下三种操作:
- 插入,例如:ab -> abc
- 删除,例如:abc -> ab
- 替换,例如:abc -> abd
那么请求出将m变成n的最小操作次数(也就是最小编辑距离)
求解这个问题,一般有两种思路:递归和动态规划。
递归:
首先假设字符串m有j位,字符串n有k位,此时我们将m -> n的最小编辑距离记为d[j][k],此时我们能总结出如下的规律:
- 当m[j] === n[k](m[j]和n[k]为字符串的最后一位)时,例如:asd -> jkl时候,很明显此时最后一位是不需要做变动的,因此最小编辑距离等同于:as -> jk,那么我们可以确定d[j][k] === d[j - 1][k - 1];
- 当 m[j] !== n[k] 时,字符串asd到字符串jkl的d[j][k] 又可以分为如下三种情况:
- asd -> jkl的最小编辑距离 = as -> jkl的最小编辑距离+1(加一个插入d的操作),对此可以描述为:d[j][k] === d[j - 1][k] + 1;
- asd -> jkl的最小编辑距离 = asdl -> jkl的最小编辑距离 + 1(加一个删除l的操作),此时可以看到asdl与jkl的最后一位相等,因此可以再次简化为:asd -> jk的最小编辑距离 + 1,对此可以描述为:d[j][k] = d[j][k - 1] + 1;
- asd -> jkl的最小编辑距离 = asl -> jkl的最下编辑距离 + 1(加上一个将d替换成l的操作),此时asl与jkl的最后一位再次相等,因此同样可以进行简化为:as -> jk的最下编辑距离 + 1,对此可以描述为:d[j][k] = d[j - 1][k - 1] + 1;
- 如果m的长度为 0,那么 m -> n 的最小编辑距离为n的长度;反过来,如果n的长度为 0,那么 m -> n 的最小编辑距离为m的长度(全部执行删除操作),可以描述为:d[j][0] = j,d[0][k] = k;
那么我们可以开始按照上述思路写代码了:
/**
* 递归算法
* @param {string} m
* @param {string} n
* @param {number} j 字符串m的长度
* @param {number} k 字符串n的长度
* @returns {number} 从 m -> n 的最小编辑距离
*/
function editDistance(m, n, j, k) {
// 触碰到边界条件
if (k === 0) {
return j;
} else if (j === 0) {
return k;
} else if (m[j - 1] === n[k - 1]) {
// 当最后一位相等的时候
return editDistance(m, n, j - 1, k - 1);
} else {
// 当最后一位不相等的时候,取最小值
const d1 = editDistance(m, n, j - 1, k) + 1;
const d2 = editDistance(m, n, j, k - 1) + 1;
const d3 = editDistance(m, n, j - 1, k - 1) + 1;
return Math.min(d1, d2, d3);
}
}
这个代码虽然能实现,但是有个严重的问题,就是代码的性能很低下,时间复杂度是指数增长的,因此可以考虑另外一种实现。
动态规划
动态规划看起来跟递归很像,不过推理逻辑正好是反过来的。递归的逻辑是:“要求得 d[j][k],先要求得 d[j-1][k-1]……”,动态规划的逻辑是:“先求得 d[j-1][k-1],再求 d[j][k]……”这是它们的主要区别。
同样先举个例子,有两个字符串分别是m = ‘asdfgh’ 和 n = ‘zscv’,我们一步步的来进行如下处理:
1:首先将我们的两个字符串放入如下的矩阵中去:
空 | 0 | a | s | d | f | g | h |
---|---|---|---|---|---|---|---|
0 | |||||||
z | |||||||
s | |||||||
c | |||||||
v |
2: 此时我们可以将d[0][0]、d[0][1] … d[0][4]等等的最小编辑距离填入此矩阵:
空 | 0 | a | s | d | f | g | h |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
z | 1 | ||||||
s | 2 | ||||||
c | 3 | ||||||
v | 4 |
3: 这个时候我们可以去计算d[1][1]了,上面在讲述递归的方法的时候我们已经说过计算d[j][k]的时候,当m[j] !== n[k]时会有三种方法,此时 d[0][1] + 1 = 2、 d[1][0] + 1 = 2、d[0][0] + 1 = 1,因此可以得知d[1][1]的最小编辑距离就是1,然后这一行后面的我们都可以直接进行插入操作一次递增即可,由此得出下面的矩阵:
空 | 0 | a | s | d | f | g | h |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
z | 1 | 1 | 2 | 3 | 4 | 5 | 6 |
s | 2 | ||||||
c | 3 | ||||||
v | 4 |
4:这个时候我们开始去计算d[2][2],首先我们可以按照之前的方式计算出d[1][2] = 2,填入矩阵,这个时候再看d[2][2],此时我们可以发现矩阵正好满足条件m[j] === n[k],因此此时d[j][k] === d[j - 1][k - 1] = 1,填入矩阵如下:
空 | 0 | a | s | d | f | g | h |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
z | 1 | 1 | 2 | 3 | 4 | 5 | 6 |
s | 2 | 2 | 1 | 2 | 3 | 4 | 5 |
c | 3 | ||||||
v | 4 |
5:不断重复上述步骤直到完成矩阵:
空 | 0 | a | s | d | f | g | h |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
z | 1 | 1 | 2 | 3 | 4 | 5 | 6 |
s | 2 | 2 | 1 | 2 | 3 | 4 | 5 |
c | 3 | 3 | 3 | 2 | 3 | 4 | 5 |
v | 4 | 4 | 4 | 4 | 3 | 4 | 5 |
此时我们自然可以看到d[j][k] = 5;
按照思路写下代码:
/**
* 动态规划算法
* @param {string} m
* @param {string} n
* @return {number} 从 m → n 的最小编辑距离
*/
function dynamicPlanning(m, n) {
const lenM = m.length;
const lenN = n.length;
const d = [];
for (let i = 0; i <= lenM; i++) {
d[i] = [];
d[i][0] = i;
}
for (let j = 0; j <= lenN; j++) {
d[0].push(j);
}
for (let i = 1; i <= lenM; i++) {
for (let j = 1; j <= lenN; j++) {
if (m[i - 1] === n[j - 1]) {
d[i][j] = d[i - 1][j - 1];
} else {
const d1 = d[i - 1][j] + 1;
const d2 = d[i][j - 1] + 1;
const d3 = d[i - 1][j - 1] + 1;
d[i][j] = Math.min(d1, d2, d3);
}
}
}
return d[lenM][lenN];
}
这次的算法复杂度就为线性了