【问题描述】 [72. 编辑距离]
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:插入一个字符
删除一个字符
替换一个字符示例 1:输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
【解答思路】
1.动态规划
第 1 步:定义状态
状态:dp[i][j] 表示将 word1[0, i) 转换成为 word2[0, j) 的方案数。
思考状态的方法:1、题目问什么就将什么定义为状态;2、「状态转移方程」怎么好推导,就怎么定义状态;3、根据经验和问题的特点(只有多做题了)。
说明:由于要考虑空字符,这里的下标 i 不包括 word[i],同理下标 j 不包括 word[j],从行数和列数多设置一行、一列也可以来理解这一点,也就是状态的下标 i 和 j 和字符的下标 i、j 有一个位置的偏差。
第 2 步:思考状态转移方程
状态转移方程通常是在做分类讨论,而分类讨论的过程,常常利用了这个问题的「最优子结构」。
情况 1:word1[i] == word2[j]
如果 word1[i] == word2[j] 成立,则将 word1[0, i) 转换成为 word2[0, j) 的方案数就等于 将 word1[0, i - 1) 转换成为 word2[0, j - 1) 的方案数,即:
dp[i + 1][j + 1] = dp[i][j];
注意:这种情况,方案数最少,后面三种情况可以不用讨论,在这里暂时忽略。
情况 2:word1[i] != word2[j]
如果 word1[i] != word2[j] ,则将 word1[0, i) 转换成为 word2[0, j) 的方案数就等于下面 3 种情况的最少操作数(「最优子结构」):
考虑修改 word1[i] 成为 word2[j];
此时 dp[i + 1][j + 1] = dp[i][j] + 1,这里的 1 代表了将 word1[i] 替换成为 word2[j] 这一步操作。
考虑将 word1[0, i] 的最后一个字符删除;
此时 word1[0, i - 1] 到 word2[0, j] 的最少操作数 + 1+1,就是这种方案数的最少操作数,即: dp[i + 1][j + 1] = dp[i][j + 1] + 1,这里的 1 代表了 word1[0, i] 的最后一个字符删除这一步操作。
考虑将 word1[0, i] 的末尾添加一个字符使得 word1[i + 1] == word2[j];
此时考虑方案的时候,由于 word1[i + 1] == word2[j],状态转移就不应该考虑 word2[j],因此 word1[0, i] 到 word2[0, j - 1] 的最少操作数 + 1+1,就是这种方案数的最少操作数,即: dp[i + 1][j + 1] = dp[i + 1][j] + 1,这里的 1 代表了将 word1[0, i] 的末尾添加一个字符使得 word1[i + 1] == word2[j]。(注意:可以考虑一下为什么得先讨论 word1[i] == word2[j] 的情况。)
在这 3 种操作中取最小值。
dp[i + 1][j + 1] = min(dp[i][j], dp[i][j + 1], dp[i + 1][j]) + 1
第 3 步:初始化
从一个字符串变成空字符串,非空字符串的长度就是编辑距离;
以下代码其实就是在填表格的第 00 行、第 00 列。
for (int i = 0; i <= len1; i++) {dp[i][0] = i;
}for (int j = 0; j <= len2; j++) {dp[0][j] = j;
}
第 4 步: 思考输出
输出:dp[len1][len2] 符合语义,即 word1[0, len) 转换成 word2[0, len2) 的最小操作数。(这里 ) 表示开区间。)
第 5 步: 思考状态压缩
我们看一下「状态转移方程」:
- 如果末尾字符相等,就「抄」左上角单元格的值;
- 如果末尾字符不相等,就从「正上方」、「左边」、「左上角」三个单元格的值中选出最小的 + 1。
因此,初看可以使用「滚动数组」,更极端一点,用 2 \times 22×2 表格就可以完成操作。但是真正去做「状态压缩」的时候,由于初始化的原因,发现没有那么容易,在这里不做「状态压缩」。(事实上可以压缩,但是只要是压缩状态,必然给编码造成一定困难,并且破坏代码可读性,根据情况做吧,个人觉得在空间紧张的情况下必须压缩空间,其余不必。)
时间复杂度:O(N^2) 空间复杂度:O(N)
import java.util.Arrays;public class Solution {public int minDistance(String word1, String word2) {// 由于 word1.charAt(i) 操作会去检查下标是否越界,因此// 在 Java 里,将字符串转换成字符数组是常见额操作char[] word1Array = word1.toCharArray();char[] word2Array = word2.toCharArray();int len1 = word1Array.length;int len2 = word2Array.length;// 多开一行一列是为了保存边界条件,即字符长度为 0 的情况,这一点在字符串的动态规划问题中比较常见int[][] dp = new int[len1 + 1][len2 + 1];// 初始化:当 word 2 长度为 0 时,将 word1 的全部删除for (int i = 1; i <= len1; i++) {dp[i][0] = i;}// 当 word1 长度为 0 时,就插入所有 word2 的字符for (int j = 1; j <= len2; j++) {dp[0][j] = j;}// 注意:填写 dp 数组的时候,由于初始化多设置了一行一列,横、纵坐标有个偏移for (int i = 0; i < len1; i++) {for (int j = 0; j < len2; j++) {// 这是最佳情况if (word1Array[i] == word2Array[j]) {dp[i + 1][j + 1] = dp[i][j];continue;}// 否则在以下三种情况中选出步骤最少的,这是「动态规划」的「最优子结构」// 1、在下标 i 处插入一个字符int insert = dp[i + 1][j] + 1;// 2、替换一个字符int replace = dp[i][j] + 1;// 3、删除一个字符int delete = dp[i][j + 1] + 1;dp[i + 1][j + 1] = Math.min(Math.min(insert, replace), delete);}}// 打印状态表格进行调试
// for (int i = 0; i <=len1; i++) {
// System.out.println(Arrays.toString(dp[i]));
// }return dp[len1][len2];}public static void main(String[] args) {String word1 = "horse";String word2 = "ros";Solution solution = new Solution();int res = solution.minDistance(word1, word2);System.out.println(res);}
}作者:liweiwei1419
链接:https://leetcode-cn.com/problems/edit-distance/solution/dong-tai-gui-hua-java-by-liweiwei1419/
【总结】
- 动态规划
- 「自底向上」去考虑一个问题
思考的时候可以先将大区间拆分成小区间,求解的时候由小区间的解得到大区间的解。
- 「动态规划」求解的问题的三个特征:
- 重叠子问题
- 最优子结构
- 无后效性
- 动态规划解题步骤
第 1 步:定义状态
第 2 步:思考状态转移方程
第 3 步:初始化
第 4 步: 思考输出
第 5 步: 思考状态压缩