学习笔记二

给你两个单词 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 

关键点解析:

  1. 动态规划定义
    dp[i][j] 表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符所需的最少操作数 。

  2. 边界条件

    • dp[i][0] = i:将 word1 的前 i 个字符全部删除。
    • dp[0][j] = j:向空字符串插入 word2 的前 j 个字符 。
  3. 状态转移

    • 字符相同 :直接继承左上角的值(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])。
  4. 复杂度

    • 时间复杂度: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)

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]]
  • 斐波那契数列:仅需存储前两个状态。

🧩 五、动态规划的通用解题框架

按四步法系统化求解:

  1. 状态定义 :明确dp[i][j]dp[i]的含义。
  2. 边界条件 :初始化最小子问题的解(如dp[0]=0)。
  3. 状态转移:用数学公式覆盖所有决策。
  4. 计算顺序:按依赖顺序填表(自底向上)或递归+记忆化(自顶向下)。

自顶向下 vs 自底向上

方式 优点 缺点
自顶向下(记忆化) 代码直观,按需计算子问题 递归栈溢出风险
自底向上(迭代) 无递归开销,空间优化更灵活 可能计算无用子问题

💎 六、总结:吃透动态规划的关键点

  1. 本质理解:动态规划是"聪明地穷举",核心是复用子问题解。
  2. 状态设计:从问题的最后一步逆向定义状态,确保无后效性。
  3. 转移方程 :分情况讨论(匹配/不匹配),用min/max覆盖决策。
  4. 边界驱动:边界是递推的锚点,需特殊处理(空串、零值等)。
  5. 优化方向:滚动数组、状态压缩是高频优化手段。

终极检验:尝试手推背包问题(0-1背包)的状态转移表,并写出空间优化版代码。

相关推荐
再学一点就睡3 小时前
手写 Promise 静态方法:从原理到实现
前端·javascript·面试
再学一点就睡4 小时前
前端必会:Promise 全解析,从原理到实战
前端·javascript·面试
前端工作日常4 小时前
我理解的eslint配置
前端·eslint
前端工作日常5 小时前
项目价值判断的核心标准
前端·程序员
90后的晨仔5 小时前
理解 Vue 的列表渲染:从传统 DOM 到响应式世界的演进
前端·vue.js
OEC小胖胖6 小时前
性能优化(一):时间分片(Time Slicing):让你的应用在高负载下“永不卡顿”的秘密
前端·javascript·性能优化·web
烛阴6 小时前
ABS - Rhomb
前端·webgl
植物系青年6 小时前
10+核心功能点!低代码平台实现不完全指南 🧭(下)
前端·低代码
植物系青年6 小时前
10+核心功能点!低代码平台实现不完全指南 🧭(上)
前端·低代码
桑晒.6 小时前
CSRF漏洞原理及利用
前端·web安全·网络安全·csrf