【LeetCode 动态规划专项】编辑距离(72)

1. 题目

给你两个单词 word1word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:

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

1.1 示例

  • 示例 1 1 1
  • 输入: word1 = "horse"word2 = "ros"
  • 输出: 3 3 3
  • 解释:
    • horse -> rorse (将 'h' 替换为 'r')
    • rorse -> rose (删除 'r')
    • rose -> ros (删除 'e')
  • 示例 2 2 2
  • 输入: word1 = "intention"word2 = "execution"
  • 输出: 5 5 5
  • 解释:
    • intention -> inention (删除 't')
    • inention -> enention (将 'i' 替换为 'e')
    • enention -> exention (将 'n' 替换为 'x')
    • exention -> exection (将 'n' 替换为 'c')
    • exection -> execution (插入 'u')

1.2 说明

1.3 提示

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

1.4 进阶

你能进一步输出一组最少操作次数中每一次的具体操作么?

2. 解法一(朴素递归)

2.1 分析

对于该题目,乍一看似乎无从下手,因为看起来好像除了尝试所有种可能的操作序列以外别无他法。通常在这个时候递归就是一个用来遍历所有可能情况的一种可能的方式。

需要注意的是,对于递归的实现,首先需要考虑的就是递归的出口,否则就会因无限递归而导致栈溢出。对于字符串,很自然可以考虑使用其中的字符索引来表示每一次递归前后的状态,然后考虑哪些状态代表着递归的出口。

对此,由于题目给定两个字符串,故这里需要两个索引。据此,下面分别定义递归的出口和递归调用的各种情况:

2.1.1 递归出口

很显然,当 word1word2 为空字符串 ""的时候,此时可以直接得到最少的操作次数,即此时二者中非空字符串的长度。例如:当 word1 = "horse"word2 = "" ,此时需要 word1 需要进行最少 5 5 5 次删除操作才能得到 word2

2.1.2 递归调用

word1word2 均不为空时又可以进一步分为以下两种情况,具体地:

  1. word1[i] == word2[j],如下图所示,此时无需做插入、删除或替换中的任何操作,即问题等价于考虑如何将 word1[1:] 通过最少的操作次数转换成 word2[1:]
    在这里插入图片描述

  2. word1[i] != word2[j] ,此时根据接下来选择字符的插入、删除还是替换操作,又可以进一步分为 3 3 3 种情况:

  • 插入: 此时,问题的求解等价于考虑在使用 1 1 1 次插入操作后,如何再通过最少的操作次数将 word1 转换成 word2[1:]

在这里插入图片描述

  • 删除: 此时,问题的求解等价于考虑在使用 1 1 1 次删除后,如何再通过最少的操作次数将 word1[1:] 转换成 word2

在这里插入图片描述

  • 替换: 此时,问题的求解等价于考虑在使用 1 1 1 次替换操作后,如何再通过最少的操作次数将 word1[1:] 转换成 word2[1:]

在这里插入图片描述

2.2 解答

根据上述分析,我们可以很简单得到下列基于朴素递归的代码实现:

class Solution:
    def min_distance(self, word1: str, word2: str) -> int:
        if len(word1) == 0:
            return len(word2)
        if len(word2) == 0:
            return len(word1)
        if word1[0] == word2[0]:
            return self.min_distance(word1[1:], word2[1:])
        else:
            return min(1 + self.min_distance(word1, word2[1:]),
                       1 + self.min_distance(word1[1:], word2),
                       1 + self.min_distance(word1[1:], word2[1:]))


def main():
    word1 = "intention"
    word2 = "execution"
    sln = Solution()
    print(sln.min_distance(word1, word2))  # 5


if __name__ == '__main__':
    main()

2.3 复杂度

上述朴素递归的解法虽然非常简洁优雅,然而其时间复杂度却高达 O ( 3 min(len(word1), len(word2)) ) O(3^{\text{min(len(word1), len(word2))}}) O(3min(len(word1), len(word2))) ,,如果直接进行提交的话,会收到平台的提示 超出时间限制

3. 解法二(自顶向下缓存递归)

3.1 分析

上述朴素递归的解法之所以低效,原因在于其对于很多子问题都进行了重复的计算,例如:当word1 = "horse"word2 = "hello" 且递归调用深度为 3 3 3 时,子问题的求解如下:

md("horse", "hello")
	md("orse", "ello")
		md("orse", "llo")
			md("orse", "lo")
			md("rse", "llo") <- 
			md("rse", "lo")
		md("rse", "ello")
			md("rse", "llo") <-
			md("se", "ello")
			md("se", "llo") <<-
		md("rse", "llo")
			md("rse", "llo") <-
			md("se", "llo") <<-
			md("se", "lo")

解决重复子问题的第一个方式便是采用额外的容器对其进行存储,如此,在第一次计算出子问题结果之后,在下次再次需要求解子问题时就可以直接从存储的结果中获取而无需重复计算了:

3.2 解答

from typing import Dict


class Solution:
    def memoized_min_distance(self, word1: str, word2: str, i: int, j: int, memo: Dict) -> int:
        if i == len(word1):
            return len(word2) - j
        if j == len(word2):
            return len(word1) - i
        if (i, j) not in memo.keys():
            if word1[i] == word2[j]:
                num = self.memoized_min_distance(word1, word2, i + 1, j + 1, memo)
            else:
                num = min(1 + self.memoized_min_distance(word1, word2, i, j + 1, memo),
                          1 + self.memoized_min_distance(word1, word2, i + 1, j, memo),
                          1 + self.memoized_min_distance(word1, word2, i + 1, j + 1, memo))
            memo[(i, j)] = num
        return memo[(i, j)]


def main():
    word1 = "dinitrophenylhydrazine"
    word2 = "acetylphenylhydrazine"
    sln = Solution()
    memo = dict()
    print(sln.memoized_min_distance(word1, word2, 0, 0, memo))  # 6


if __name__ == '__main__':
    main()

  • 执行用时: 104 ms , 在所有 Python3 提交中击败了 94.65% 的用户;
  • 内存消耗: 17.3 MB , 在所有 Python3 提交中击败了 93.47% 的用户。

3.3 复杂度

  • 时间复杂度: O ( m n ) O(mn) O(mn)
  • 空间复杂度: O ( m n ) O(mn) O(mn)

4. 解法三(自底向上动态规划迭代)

4.1 分析

以上自顶向下缓存递归的解法虽然已大幅提高了解答的效率,但是不仅每一次递归的资源消耗较大,而且递归还是最大深度的限制,因此进一步考虑迭代的解法。

这里,采用动态规划的迭代解法同样使用一个辅助的存储容器,此处使用了一个二维的列表 dp,列表中的每个元素 dp[i][j] 表示将由 word1i 个字符串组成的子串转换成由 word2j 个字符组成子串所需的最小操作数量。

4.2 解答

from typing import Dict


class Solution:
    def iterative_min_distance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(m + 1):
            dp[i][0] = i
        for j in range(n + 1):
            dp[0][j] = j
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
        return dp[-1][-1]


def main():
    word1 = "dinitrophenylhydrazine"
    word2 = "acetylphenylhydrazine"
    sln = Solution()
    print(sln.iterative_min_distance(word1, word2))  # 6


if __name__ == '__main__':
    main()

  • 执行用时: 120 ms , 在所有 Python3 提交中击败了 90.81% 的用户;
  • 内存消耗: 18.3 MB , 在所有 Python3 提交中击败了 88.78% 的用户

4.3 复杂度

  • 时间复杂度: O ( m n ) O(mn) O(mn)
  • 空间复杂度: O ( m n ) O(mn) O(mn)

5. 重建最短编辑路径

对于本题而言,只需要我们求出最少编辑次数即可,但是实际中更具有意义的是知道在整个编辑过程中每一步应该做什么,下面给出了重建最短编辑路径的代码,具体的分析类似于【LeetCode 动态规划专项】最长公共子序列(1143)

from typing import Any, List


class Node:
    def __init__(self, distance: int, choice: Any):
        self.distance = distance
        self.choice = choice


class Solution:
    def _min_node(self, delete: Node, replace: Node, insert: Node) -> Node:
        node = Node(delete.distance, 'DELETE')
        if node.distance > replace.distance:
            node.distance = replace.distance
            node.choice = 'REPLACE'
        if node.distance > insert.distance:
            node.distance = insert.distance
            node.choice = 'INSERT'
        return node

    def _print_edit_path(self, dp: List[List[Node]], word1: str, word2: str):
        rows, cols = len(dp), len(dp[0])
        i = rows - 1
        j = cols - 1
        while i > 0 and j > 0:
            if dp[i][j].choice is None:
                print('Skip' + ' ' + word1[i - 1])
                i -= 1
                j -= 1
            elif dp[i][j].choice == 'DELETE':
                print('Delete' + ' ' + word1[i - 1])
                i -= 1
            elif dp[i][j].choice == 'INSERT':
                print('Insert' + ' ' + word2[j - 1])
                j -= 1
            else:
                print('Replace' + ' ' + word1[i - 1] + ' ' + 'with' + ' ' + word2[j - 1])
                i -= 1
                j -= 1
        while i > 0:
            print('Delete' + ' ' + word1[i - 1])
            i -= 1
        while j > 0:
            print('Insert' + ' ' + word2[j - 1])
            j -= 1

    def reconstruct_edit_distance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[Node(0, None) for _ in range(n + 1)] for _ in range(m + 1)]
        for i in range(m + 1):
            dp[i][0].distance = i
            dp[i][0].choice = 'DELETE'
        for j in range(n + 1):
            dp[0][j].distance = j
            dp[0][j].choice = 'INSERT'
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j].distance = dp[i - 1][j - 1].distance
                else:
                    dp[i][j] = self._min_node(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1])
                    dp[i][j].distance += 1
        self._print_edit_path(dp, word1, word2)
        return dp[-1][-1].distance

def main():
    word1 = "dinitrophenylhydrazine"
    word2 = "acetylphenylhydrazine"
    sln = Solution()
    print(sln.reconstruct_edit_distance(word1, word2))


if __name__ == '__main__':
    main()

对于上述代码,需要特别注意的是 dp 的行列数量 rowscols 和 表示 word1word2 字符索引 ij 之间的数量关系。

猜你喜欢

转载自blog.csdn.net/weixin_37780776/article/details/120392512