给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数
以下是使用 JavaScript 实现的编辑距离(Levenshtein 距离)动态规划解法,用于计算将 word1
转换成 word2
的最少操作数(插入、删除、替换):
javascript
function minDistance(word1, word2) {
const m = word1.length, n = word2.length;
// 创建 (m+1) x (n+1) 的 DP 矩阵
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
// 初始化边界条件
for (let i = 0; i <= m; i++) dp[i][0] = i; // 删除所有字符
for (let j = 0; j <= n; j++) dp[0][j] = j; // 插入所有字符
// 填充 DP 表
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 {
// 取三种操作的最小值 +1
dp[i][j] = 1 + Math.min(
dp[i][j - 1], // 插入操作
dp[i - 1][j], // 删除操作
dp[i - 1][j - 1] // 替换操作
);
}
}
}
return dp[m][n]; // 返回最终结果
}
// 测试用例
console.log(minDistance("horse", "ros")); // 输出 3
console.log(minDistance("intention", "execution")); // 输出 5
关键点解析:
-
动态规划定义
dp[i][j]
表示将word1
的前i
个字符转换为word2
的前j
个字符所需的最少操作数 。 -
边界条件:
dp[i][0] = i
:将word1
的前i
个字符全部删除。dp[0][j] = j
:向空字符串插入word2
的前j
个字符 。
-
状态转移:
- 字符相同 :直接继承左上角的值(
dp[i][j] = dp[i-1][j-1]
)。 - 字符不同 :取以下三种操作的最小值加 1:
- 插入 :
dp[i][j-1] + 1
(在word1
中插入word2[j]
)。 - 删除 :
dp[i-1][j] + 1
(删除word1[i]
)。 - 替换 :
dp[i-1][j-1] + 1
(将word1[i]
替换为word2[j]
)。
- 插入 :
- 字符相同 :直接继承左上角的值(
-
复杂度:
- 时间复杂度:O(mn),需遍历整个 DP 表。
- 空间复杂度:O(mn),需存储二维数组 。
优化建议:
- 空间优化:可使用滚动数组(两个一维数组)将空间复杂度降至 O(min(m,n)) 。
- 实际应用:此算法广泛用于拼写纠错、生物信息学(DNA序列比对)等场景 。
以下我将从动态规划的本质、核心要素、设计步骤到经典案例,结合逻辑推演与可视化分析,围绕状态定义→边界条件→状态转移→初始值设计的主线展开,辅以编辑距离(Levenshtein距离)的全程推演。
🧠 一、动态规划的本质与核心思想
1. 定义与核心思想
动态规划(Dynamic Programming, DP)是一种通过将复杂问题分解为重叠子问题 ,并存储子问题的解以避免重复计算,从而高效求解最优解的方法。其核心是 "记录并复用"子问题的解,本质是空间换时间。
类比:登山者记录每个休息站的最佳路径,避免重复探索(无后效性)。
2. 适用问题的三大特征
- 最优子结构:问题的最优解包含子问题的最优解(如最短路径的子路径必最短)。
- 重叠子问题 :子问题被重复计算(如斐波那契数列中
F(3)
被多次调用)。 - 无后效性:当前状态一旦确定,后续决策不受之前状态影响(如背包问题中当前容量下的选择与之前物品选择无关)。
3. 与分治法的关键区别
- 分治法:子问题独立(如归并排序)。
- 动态规划:子问题重叠且依赖(如斐波那契数列)。
⚙️ 二、动态规划的四大基石
1. 状态定义:问题的数学建模
状态是子问题的抽象表示,需满足两个条件:
- 完整描述 :涵盖子问题的所有关键变量(如编辑距离中的
dp[i][j]
表示字符串A前i
个字符转成B前j
个字符的最小操作数)。 - 无后效性 :状态仅由当前决策决定,与历史路径无关(如背包问题中
dp[i][j]
只依赖前一行状态)。
设计技巧:从问题的最后一步逆向思考(如编辑距离的最后一次操作是插入、删除或替换)。
2. 边界条件:递推的起点
边界是状态转移的初始值,通常对应最小子问题的解:
- 空字符串处理 :若A为空,转成B需插入所有字符(
dp[0][j] = j
)。 - 零值初始化 :
dp[0][0] = 0
(空字符串转空字符串无需操作)。
常见边界:
- 序列问题:空序列长度为0。
- 路径问题:起点到起点距离为0。
3. 状态转移方程:决策逻辑的数学化
方程描述状态间的递推关系,需覆盖所有决策可能:
math
dp[i][j] =
\begin{cases}
dp[i-1][j-1] & \text{if } A[i] = B[j] \\
\min \begin{cases}
dp[i-1][j] + 1 & \text{(删除A[i])} \\
dp[i][j-1] + 1 & \text{(插入B[j])} \\
dp[i-1][j-1] + 1 & \text{(替换字符)}
\end{cases} & \text{otherwise}
\end{cases}
逻辑解析:
- 字符匹配:无需操作,继承左上状态。
- 字符不匹配 :取三种操作的最小代价:
- 删除A[i] → 状态回退到
(i-1, j)
。 - 插入B[j] → 状态推进到
(i, j-1)
。 - 替换字符 → 状态回退到
(i-1, j-1)
。
- 删除A[i] → 状态回退到
4. 计算顺序:确保子问题已求解
必须按依赖顺序计算(如编辑距离中需先计算dp[i-1][j-1]
再算dp[i][j]
),通常使用自底向上填表法 (从i=0,j=0
逐步迭代)。
📊 三、案例全程推演:编辑距离算法(Levenshtein距离)
以A="horse"
转B="ros"
为例,展示动态规划的全过程:
1. 初始化边界条件
0 | r | o | s | |
---|---|---|---|---|
0 | 0 | 1 | 2 | 3 |
h | 1 | |||
o | 2 | |||
r | 3 | |||
s | 4 | |||
e | 5 |
2. 状态转移推演(关键步骤)
i=1, j=1
(h→r) :字符不匹配,取左、上、左上最小值min(1+1, 1+1, 0+1)=1
(替换操作)。i=2, j=1
(o→r) :不匹配,min(2, 1+1, 1+1)=2
(删除'o')。i=2, j=2
(o→o) :匹配,继承左上值dp[1][1]=1
。
3. 最终DP表
0 | r | o | s | |
---|---|---|---|---|
0 | 0 | 1 | 2 | 3 |
h | 1 | 1 | 2 | 3 |
o | 2 | 2 | 1 | 2 |
r | 3 | 2 | 2 | 2 |
s | 4 | 3 | 3 | 2 |
e | 5 | 4 | 4 | 3 |
结果 :
dp[5][3]=3
(删除'h'、删除'o'、替换'e'为's')。
⚡️ 四、优化技巧:降低空间复杂度
1. 滚动数组法
若状态仅依赖上一行(如编辑距离),可将二维DP压缩为两个一维数组:
python
prev = [0, 1, 2, 3] # 上一行
curr = [1, 0, 0, 0] # 当前行
for i in range(1, m+1):
curr[0] = i # 每行首元素初始化
for j in range(1, n+1):
curr[j] = prev[j-1] if A[i-1]==B[j-1] else min(prev[j], curr[j-1], prev[j-1]) + 1
prev = curr.copy() # 滚动更新
空间复杂度 :从O(mn)
降至O(n)
。
2. 状态压缩的适用场景
- 背包问题:仅依赖前一行的
dp[j]
和dp[j-w[i]]
。 - 斐波那契数列:仅需存储前两个状态。
🧩 五、动态规划的通用解题框架
按四步法系统化求解:
- 状态定义 :明确
dp[i][j]
或dp[i]
的含义。 - 边界条件 :初始化最小子问题的解(如
dp[0]=0
)。 - 状态转移:用数学公式覆盖所有决策。
- 计算顺序:按依赖顺序填表(自底向上)或递归+记忆化(自顶向下)。
自顶向下 vs 自底向上:
方式 优点 缺点 自顶向下(记忆化) 代码直观,按需计算子问题 递归栈溢出风险 自底向上(迭代) 无递归开销,空间优化更灵活 可能计算无用子问题
💎 六、总结:吃透动态规划的关键点
- 本质理解:动态规划是"聪明地穷举",核心是复用子问题解。
- 状态设计:从问题的最后一步逆向定义状态,确保无后效性。
- 转移方程 :分情况讨论(匹配/不匹配),用
min/max
覆盖决策。 - 边界驱动:边界是递推的锚点,需特殊处理(空串、零值等)。
- 优化方向:滚动数组、状态压缩是高频优化手段。
终极检验:尝试手推背包问题(0-1背包)的状态转移表,并写出空间优化版代码。