「前端刷题」72. 编辑距离

88 阅读3分钟

「这是我参与11月更文挑战的第10天,活动详情查看:2021最后一次更文挑战」。

题目

链接:leetcode-cn.com/problems/ed…

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

 

示例 1:

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

示例 2:

输入: word1 = "intention", word2 = "execution" 输出: 5 解释: intention -> inention (删除 't') inention -> enention (将 'i' 替换为 'e') enention -> exention (将 'n' 替换为 'x') exention -> exection (将 'n' 替换为 'c') exection -> execution (插入 'u')

 

提示:

  • 0 <= word1.length, word2.length <= 500
  • word1word2 由小写英文字母组成

解题思路

思路1

采用自顶向下的递归方式,从右向左来对比两个字符串。

如果字符相同,则不需要操作,直接跳过;另外在其他三个操作方法(删除,替换,插入)中选择操作步数最少的方法。(备注:不要陷入递归的细节中去,我们的脑子可不像电脑那样一直可以进行压栈出栈操作)

递归解法存在许多的重叠子问题,采用备忘录的方式来记录对应长度的操作步数。

我们从后往前遍历字符串word1,word2,当两个字符串相等时,我们不做修改,继续往前遍历,当不相等时,可以做如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

如果字符串word1遍历结束,则将word2剩下的字符添加到word1首部,操作次数为word2剩下的字符数。
如果字符串word2遍历结束,则将word1剩下的字符删除,操作次数为word1剩下的字符数。

/**
 * @param {string} word1
 * @param {string} word2
 * @return {number}
 */
var minDistance = function(word1, word2) {

  const len1 = word1.length, len2 = word2.length
  // 初始化备忘录二维数组
  let memo = Array(len1+1)
  for(let i=0; i<len1+1; i++) {
    memo[i] = Array(len2+1).fill(-1)
  }

  /**
  *@param {number} i word1.length-1
  *@param {number} j word2.length-2
  *@return {number} 编辑步数
   */
  function dp(i, j) {
    // base case
    // 如果word1串读完了,则直接插入剩余的word2串
    if(i == -1) return j+1
    // 如果word2串读完了,则直接删除剩余的word1串
    if(j == -1) return i+1

    // 对应长度的步数已在备忘录中,直接返回步数
    if(memo[i][j] != -1) {
      return memo[i][j]
    }

    // 两个字符相同,不需要任何操作,直接跳过
    if (word1.charAt(i) == word2.charAt(j)) {
      memo[i][j] = dp(i-1, j-1)
    } 
    // 不同则选择步数最少的操作
    else {
      memo[i][j] = min(
        dp(i-1, j-1)+1, //替换操作,两个串的长度均减一
        dp(i-1, j)+1,   //word1串的删除操作,word1串长度减一
        dp(i, j-1)+1    //word1串的插入操作,word1不变,word2减一
      )
    }

    // 返回需要操作的步数
    return memo[i][j]
  }

  // 在三个值中选最小值
  function min(a, b, c) {
    return Math.min(a, Math.min(b, c))
  }

  // 调用dp函数,返回结果
  return dp(len1-1, len2-1)
};

思路2

暴力穷举

我们根据上面的思路写出如下代码

var minDistance = function(word1, word2) {
    const dp = (i, j) => {
        // 因为递归传入的是上一次的(i -1, j - 1),所以这里都需要 + 1
        if (i === -1) return j + 1  // s1走完了,将s2剩下的插入s1,需要j + 1步
        if (j === -1) return i + 1 // s2走完了,删除s1剩下的,需要i + 1步
        if (word1[i] === word2[j]) {
            // 什么都不做,i,j向前移动一位
            return dp(i - 1, j - 1)
        } else {
            // 找出最小的
            return Math.min(
                dp(i, j - 1) + 1, // 插入,在word1[i]中插入和word2[j]一样的字符,相当于把word2向前移动1位,word1不动
                dp(i - 1, j) + 1, // 删除,把word1[i]删除,相当于word1向前移动1位,word2不动
                dp(i - 1, j - 1) + 1 // 替换操作,都向前移动1位
            )
        }
    }
    // 从后往前遍历,i, j 初始化指向最后一个索引
    return dp(word1.length - 1, word2.length - 1)
};

上面我们用暴力递归穷举出了所有方法,找出其中步骤最小的。我们想办法来对上面的代码做些优化。

思路3

记忆化搜索

我们添加一个字典来存储已经计算过的项。

var minDistance = function(word1, word2) {
    // 建一个字典
    const memo = new Map()
    const dp = (i, j) => {
        if (memo.has(i + '' + j)) return memo.get((i + '' + j))
        if (i === -1) return j + 1  // s1走完了,将s2剩下的插入s1,需要j + 1步
        if (j === -1) return i + 1 // s2走完了,删除s1剩下的,需要i + 1步
        if (word1[i] === word2[j]) {
            // 什么都不做,i,j向前移动一位
            memo.set(i + '' + j, dp(i - 1, j - 1))
        } else {
            memo.set(i + '' + j, Math.min(
                dp(i, j - 1) + 1, // 插入,在word1[i]中插入和word2[j]一样的字符,相当于把word2向前移动1位,word1不动
                dp(i - 1, j) + 1, // 删除,把word1[i]删除,相当于word1向前移动1位,word2不动
                dp(i - 1, j - 1) + 1 // 替换操作,都向前移动1位
            ))
        }
        return memo.get(i + '' + j)
    }
    // 从后往前遍历,i, j 初始化指向最后一个索引
    return dp(word1.length - 1, word2.length - 1)
};

思路4

我们使用DP来解这道题。详细看代码

function minDistance(word1: string, word2: string): number {
    const m = word1.length, n = word2.length;
    // 我们要多添加一行一列,用来做base case
    const dp = Array.from(Array(word1.length + 1), () => Array(word2.length+1).fill(0));
    // 添加一列,base case
    for (let i = 1; i <= m; i++) {
        dp[i][0] = i;
    }
    // 添加一行,base case
    for (let i = 1; i <= n; i++) {
        dp[0][i] = i;
    }
    // 因为我们补了一行/列base case,这里都从1开始
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            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, // s1删除操作
                    dp[i][j - 1] + 1, // s1插入操作
                    dp[i -1][j - 1] + 1 // 替换
                )
            }
        }
    }
    return dp[m][n]
};

复杂度分析

暴力穷举时间复杂度为指数级,加字典后为O(mn), DP时间复杂度O(mn),空间复杂度O(mn)