从 "字符拼图" 到 "文字魔术":动态规划玩转字符串变形术

"动态规划像养猫,刚开始抓不住,熟了之后,喵喵喵全是套路!"

1. 判断子序列:DP 的敲门砖

问题描述

给定字符串 s 和 t,判断 s 是否为 t 的子序列。

基础解法:双指针

js 复制代码
var isSubsequence = function(s, t) {
    let index = 0;
    for (let i = 0; i < t.length; i++) {
        if (s[index] === t[i]) index++;
    }
    return index === s.length;
};

思维可视化

想象你在看一场电影(t),而你的小伙伴(s)只关心某些镜头。你只要顺着电影播放,帮他数他关心的镜头出现了几次,全部出现就 OK!


动态规划解法

状态定义

  • dp[i][j]:s 前 i 个字符和 t 前 j 个字符的最长公共子序列长度

状态转移

  • 如果 s[i-1] === t[j-1],则 dp[i][j] = dp[i-1][j-1] + 1
  • 否则 dp[i][j] = dp[i][j-1]

代码实现

js 复制代码
var isSubsequence = function(s, t) {
    let dp = Array.from({length: s.length + 1}, () => new Array(t.length + 1).fill(0));
    for (let i = 1; i <= s.length; i++) {
        for (let j = 1; j <= t.length; j++) {
            if (s[i-1] === t[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
            else dp[i][j] = dp[i][j-1];
        }
    }
    return dp[s.length][t.length] === s.length;
};

空间优化

js 复制代码
var isSubsequence = function(s, t) {
    let dp = new Array(t.length + 1).fill(0);
    for (let i = 1; i <= s.length; i++) {
        let pre = dp[0];
        for (let j = 1; j <= t.length; j++) {
            let temp = dp[j];
            if (s[i-1] === t[j-1]) dp[j] = pre + 1;
            else dp[j] = dp[j-1];
            pre = temp;
        }
    }
    return dp[t.length] === s.length;
};

动态规划解法可视化

以 s = "abc",t = "ahbgdc" 为例,上述代码执行过程中 DP 表格的变化如下:

a h b g d c
0 0 0 0 0 0 0
a 0 1 1 1 1 1 1
b 0 1 1 2 2 2 2
c 0 1 1 2 2 2 3

表格说明:

  • 浅蓝色单元格:初始值为 0
  • 绿色单元格:字符匹配时,值为左上角单元格值 + 1,比如当 i=1、j=1 时,s [0] 与 t [0] 都是 'a',所以 dp [1][1] = dp [0][0] + 1 = 1
  • 黄色单元格:字符不匹配时,值继承自左侧单元格,例如 i=1、j=2 时,s [0] 是 'a',t [1] 是 'h',不匹配,所以 dp [1][2] = dp [1][1] = 1

最终 dp[3][6] = 3 等于 s 的长度,因此 s 是 t 的子序列。


思考时刻

如果把 "子序列" 换成 "子串",你觉得状态转移会发生什么变化?(提示:子串要求连续)


2. 不同的子序列:计数型 DP

问题描述

给定 s 和 t,问 t 在 s 中作为子序列出现了多少次?

动态规划解法

状态定义

  • dp[i][j]:s 前 i 个字符中出现 t 前 j 个字符的子序列个数

状态转移

  • 如果 s[i-1] === t[j-1],dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
  • 否则 dp[i][j] = dp[i-1][j]

代码实现

js 复制代码
var numDistinct = function(s, t) {
    const m = s.length, n = t.length;
    let dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
    for (let i = 0; i <= m; i++) dp[i][0] = 1;
    for (let j = 1; j <= n; j++) dp[0][j] = 0;
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (s[i-1] === t[j-1]) dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
            else dp[i][j] = dp[i-1][j];
        }
    }
    return dp[m][n];
};

空间优化

js 复制代码
var numDistinct = function(s, t) {
    const m = s.length, n = t.length;
    let dp = new Array(n + 1).fill(0);
    dp[0] = 1;
    for (let i = 1; i <= m; i++) {
        for (let j = n; j >= 1; j--) {
            if (s[i-1] === t[j-1]) dp[j] = dp[j-1] + dp[j];
        }
    }
    return dp[n];
};

动态规划解法可视化

以 s = "babgbag",t = "bag" 为例,DP 表格的填充过程如下:

b a g
1 1 1 1
b 1 2 1 1
a 1 2 3 1
b 1 3 3 1
g 1 3 3 4
b 1 4 3 4
a 1 4 7 4
g 1 4 7 11

表格说明:

  • 浅蓝色单元格:初始值,第一行始终为 1,因为空字符串在任何字符串中作为子序列出现的次数都是 1
  • 绿色单元格:字符匹配时,dp[i][j] = dp[i-1][j-1] + dp[i-1][j],比如 i=2、j=2 时,s [1] 与 t [1] 都是 'a',所以 dp [2][2] = dp [1][1] + dp [1][2] = 2 + 1 = 3
  • 黄色单元格:字符不匹配时,dp[i][j] = dp[i-1][j],例如 i=3、j=2 时,s [2] 是 'b',t [1] 是 'a',不匹配,所以 dp [3][2] = dp [2][2] = 3

最终结果为 11,表示 t 在 s 中作为子序列出现了 11 次。


思考时刻

如果 s = "babgbag",t = "bag",你能手动画出 DP 表的变化过程吗?(建议尝试!)


3. 两个字符串的删除操作:LCS 的妙用

问题描述

给定 word1 和 word2,每次可以删除任意一个字符,问最少几步能让两个字符串相同?

解法一:最长公共子序列(LCS)

思路

  • 最长公共子序列 LCS 部分保留,其余都删掉
  • 答案 = word1.length + word2.length - 2 * LCS长度

代码实现

js 复制代码
var minDistance = function(word1, word2) {
    const m = word1.length, n = word2.length;
    let dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
    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] + 1;
            else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
        }
    }
    return m + n - 2 * dp[m][n];
};

解法一:最长公共子序列(LCS)可视化

以 word1 = "sea",word2 = "eat" 为例,LCS 的 DP 表格如下:

e a t
0 0 0 0
s 0 0 0 0
e 0 1 1 1
a 0 1 2 2

从表格中可知 LCS 长度为 2("ea"),因此最少删除次数为 3 + 3 - 2*2 = 2,这与代码计算结果一致。


解法二:直接 DP

状态定义

  • dp[i][j]:word1 前 i 个字符和 word2 前 j 个字符变成相同所需的最小删除次数

状态转移

  • 如果 word1[i-1] === word2[j-1],dp[i][j] = dp[i-1][j-1]
  • 否则 dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + 1

代码实现

js 复制代码
var minDistance = function(word1, word2) {
    const m = word1.length, n = word2.length;
    let dp = Array.from({length: m + 1}, () => new 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;
    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], dp[i][j-1]) + 1;
        }
    }
    return dp[m][n];
};

空间优化

js 复制代码
var minDistance = function(word1, word2) {
    const m = word1.length, n = word2.length;
    let dp = new Array(n + 1).fill(0);
    for (let j = 0; j <= n; j++) dp[j] = j;
    for (let i = 1; i <= m; i++) {
        let pre = dp[0];
        dp[0] = i;
        for (let j = 1; j <= n; j++) {
            let temp = dp[j];
            if (word1[i-1] === word2[j-1]) dp[j] = pre;
            else dp[j] = Math.min(dp[j], dp[j-1]) + 1;
            pre = temp;
        }
    }
    return dp[n];
};

解法二:直接 DP 可视化

同样以 word1 = "sea",word2 = "eat" 为例,直接 DP 的表格如下:

e a t
0 1 2 3
s 1 2 3 4
e 2 1 2 3
a 3 2 1 2

表格说明:

  • 第一行和第一列:初始化为字符串长度,因为将一个空字符串和一个长度为 k 的字符串变成相同,需要删除 k 个字符
  • 绿色单元格:字符匹配时,值等于左上角单元格值,比如 i=3、j=2 时,word1 [2] 与 word2 [1] 都是 'a',所以 dp [3][2] = dp [2][1] = 1
  • 黄色单元格:字符不匹配时,值为上方或左侧单元格最小值 + 1,例如 i=2、j=1 时,word1 [1] 是 'e',word2 [0] 是 'e',匹配,所以 dp [2][1] = dp [1][0] = 1

最终结果为 2,与 LCS 方法得到的答案一致。


思考时刻

你能用自己的话解释 "LCS 法" 和 "直接 DP 法" 在本题的本质区别吗?哪个更容易理解?


4. 编辑距离:动态规划的巅峰对决

问题描述

给定 word1 和 word2,可以插入、删除、替换字符,问最少几步能将 word1 变成 word2?

状态定义

  • dp[i][j]:word1 前 i 个字符变成 word2 前 j 个字符的最小编辑次数

状态转移

  • 如果 word1[i-1] === word2[j-1],dp[i][j] = dp[i-1][j-1]
  • 否则 dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
    • dp[i-1][j]:删除
    • dp[i][j-1]:插入
    • dp[i-1][j-1]:替换

代码实现

js 复制代码
var minDistance = function(word1, word2) {
    let m = word1.length, n = word2.length;
    let dp = Array.from({length: m + 1}, () => new 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;
    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], dp[i][j-1], dp[i-1][j-1]) + 1;
        }
    }
    return dp[m][n];
};

空间优化

js 复制代码
var minDistance = function(word1, word2) {
    let m = word1.length, n = word2.length;
    let dp = new Array(n + 1).fill(0);
    for (let j = 0; j <= n; j++) dp[j] = j;
    for (let i = 1; i <= m; i++) {
        let pre = dp[0];
        dp[0] = i;
        for (let j = 1; j <= n; j++) {
            let temp = dp[j];
            if (word1[i-1] === word2[j-1]) dp[j] = pre;
            else dp[j] = Math.min(dp[j], dp[j-1], pre) + 1;
            pre = temp;
        }
    }
    return dp[n];
};

动态规划解法可视化

以 word1 = "horse",word2 = "ros" 为例,编辑距离的 DP 表格如下:

r o s
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

表格说明:

  • 第一行和第一列:初始化为字符串长度,因为将一个空字符串编辑成一个长度为 k 的字符串需要 k 次插入操作
  • 绿色单元格:字符匹配时,值等于左上角单元格值,比如 i=3、j=1 时,word1 [2] 与 word2 [0] 都是 'r',所以 dp [3][1] = dp [2][0] = 2
  • 黄色单元格:字符不匹配时,值为上方、左侧或左上角单元格最小值 + 1,例如 i=2、j=2 时,word1 [1] 是 'o',word2 [1] 是 'o',匹配,所以 dp [2][2] = dp [1][1] = 1

最终结果为 3,表示将 "horse" 转换为 "ros" 最少需要 3 步操作:

  1. 将 'h' 替换为 'r'(1 步)
  1. 删除 'r'(2 步)
  1. 将 'e' 替换为's'(3 步)

思考时刻

如果编辑距离只允许 "插入" 和 "删除",还能用上面的 DP 吗?你会怎么修改状态转移?


总结与彩蛋

  • 子序列问题是 DP 的入门,编辑距离是 DP 的高阶。
  • 只要掌握了 "状态定义 + 状态转移 + 初始化 + 遍历顺序",DP 就不再是洪水猛兽。
  • 动态规划的世界,套路虽多,但本质不变:拆解问题,复用子问题结果,勇敢画表格!

"DP 的路上,愿你不再迷路,遇到难题,先画表格!"

------ 你的 DP 小助手

这些图表直观展示了 DP 数组的填充过程,帮助你理解每个状态值的来源和状态转移的逻辑。实际解题时,建议自己动手绘制这些表格,这是掌握动态规划思想的有效方法!

相关推荐
Eloudy1 小时前
简明量子态密度矩阵理论知识点总结
算法·量子力学
点云SLAM1 小时前
Eigen 中矩阵的拼接(Concatenation)与 分块(Block Access)操作使用详解和示例演示
人工智能·线性代数·算法·矩阵·eigen数学工具库·矩阵分块操作·矩阵拼接操作
伍哥的传说2 小时前
Radash.js 现代化JavaScript实用工具库详解 – 轻量级Lodash替代方案
开发语言·javascript·ecmascript·tree-shaking·radash.js·debounce·throttle
算法_小学生2 小时前
支持向量机(SVM)完整解析:原理 + 推导 + 核方法 + 实战
算法·机器学习·支持向量机
前端程序媛-Tian3 小时前
【dropdown组件填坑指南】—怎么实现下拉框的位置计算
前端·javascript·vue
iamlujingtao3 小时前
js多边形算法:获取多边形中心点,且必定在多边形内部
javascript·算法
算法_小学生3 小时前
逻辑回归(Logistic Regression)详解:从原理到实战一站式掌握
算法·机器学习·逻辑回归
嘉琪0013 小时前
实现视频实时马赛克
linux·前端·javascript
DebugKitty4 小时前
C语言14-指针4-二维数组传参、指针数组传参、viod*指针
c语言·开发语言·算法·指针传参·void指针·数组指针传参
qystca4 小时前
MC0241防火墙
算法