代码随想录算法训练营 Day39 | 动态规划 part12

115. 不同的子序列

给你两个字符串 st ,统计并返回在 s 的 子序列 中 t 出现的个数。

测试用例保证结果在 32 位有符号整数范围内。

cpp 复制代码
class Solution {
public:
    int numDistinct(string s, string t) {
        int n = s.size(), m = t.size();
        // 【防坑神笔】:使用 uint64_t (无符号 64 位整数)
        // 本题测试用例极其变态,组合种数会超出 int 甚至 long long 的范围,用 uint64_t 完美避开溢出变负数的问题
        vector<vector<uint64_t>> dp(n + 1, vector<uint64_t>(m + 1, 0));
    // 初始化:空字符串 t 是任何字符串 s 的子序列,且只有 1 种删法(全删光)
    for(int i = 0; i <= n; i++) dp[i][0] = 1;
    // dp[0][j] (j>0) 默认为 0,因为空串 s 无法包含非空串 t
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            if(s[i-1] == t[j-1]){
                // 匹配成功:两种选择的组合数相加
                // 1. 用 s[i-1] 匹配:继承左上角 dp[i-1][j-1]
                // 2. 不用 s[i-1] 匹配:继承正上方 dp[i-1][j]
                dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
            }
            else {
                // 匹配失败:s[i-1] 毫无用处,直接丢弃,继承正上方
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[n][m];
    }
};

总结

1. 为什么 dp[i][0] 全部是 1
  • t 是空串,s"bag"
  • 问:空串是 "bag" 的子序列吗?有几种?
  • 答:是。只有一种,就是把 "bag" 里的字符全部删掉。所以无论 s 有多长,只要 t 是空的,凑出它的方法数恒定为 1
  • 反之,如果 s 是空的,t 不是空的(dp[0][j]),那连字符都没有,怎么凑?方法数自然是 0
2. 为什么匹配成功时是"相加"而不是"取最大"?
  • 392题(判断子序列):只要你有一种方法能凑出来,结果就是 true,所以用 max。
  • 本题(不同子序列):你要统计所有可能的路径。当 s[i-1] == t[j-1] 时,你面前分岔了:
    • 路线 A:让这两个字符硬碰硬匹配上,去看看前面剩下的字符能凑出多少种(dp[i-1][j-1])。
    • 路线 B:虽然相等,但我偏不让它匹配,我直接把 s[i-1] 当垃圾扔掉,看看剩下的 s 能不能凑出 tdp[i-1][j])。
    • 总方法数 = 路线A的方法数 + 路线B的方法数。这就是加法原理。
3. 为什么没有看"左边" dp[i][j-1]

因为 t 里的字符是必须被按顺序满足的。你只能决定删不删 s 里的字符(控制 i 的指针回退),你绝对没有权利跳过 t 里的字符(不能控制 j 的指针回退)。所以所有的状态转移,一定是 i-1(往上看),绝对不会是 j-1(往左看)。


583. 两个字符串的删除操作

给定两个单词 word1word2 ,返回使得 word1word2相同所需的最小步数。

每步 可以删除任意一个字符串中的一个字符。

cpp 复制代码
class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.size(), m = word2.size();
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        // 【初始化的物理意义】:把非空字符串删成空字符串,需要删多少次?
        // 答案显而易见:有几个字符删几次。
        for(int i = 0; i <= n; i++) dp[i][0] = i;
        for(int j = 0; j <= m; j++) dp[0][j] = j;
        for(int i = 1; i <= n; i++){
            for(int j = 1; j <= m; j++){
                if(word1[i-1] == word2[j-1]){
                    // 天作之合,不用删,直接继承前面的代价
                    dp[i][j] = dp[i-1][j-1];
                }
                else {
                    // 【硬核逻辑拆解】:遇到了不一致的字符,有三种策略:
                    // 策略1:删 word1 的当前字符,代价 +1  -> dp[i-1][j] + 1
                    // 策略2:删 word2 的当前字符,代价 +1  -> dp[i][j-1] + 1
                    // 策略3:两个都不想留,同时删掉,代价 +2 -> dp[i-1][j-1] + 2
                    // (注意:策略3其实被策略1和策略2的递推包含了,但显式写出来逻辑更严密)
                    dp[i][j] = min(min(dp[i-1][j], dp[i][j-1]) + 1, dp[i-1][j-1] + 2);
                }
            }
        }
        return dp[n][m];
    }
};

总结

1. 为什么要写 dp[i-1][j-1] + 2

写这行代码的直觉是:"既然 word1[i-1]word2[j-1] 不相等,那我把这两个碍眼的字符同时删掉,代价是 +2,然后看前面的 dp[i-1][j-1]。"

但在数学上,这个分支是 100% 多余的!

  • 假设我不删 word2[j-1],只删 word1[i-1],代价是 dp[i-1][j] + 1
  • 那么 dp[i-1][j] 是怎么来的呢?它是从 dp[i-1][j-1] 删掉 word2[j-1] 得来的,所以 dp[i-1][j] <= dp[i-1][j-1] + 1 永远成立。
  • 同理,dp[i][j-1] <= dp[i-1][j-1] + 1 也永远成立。
  • 因此,min(dp[i-1][j], dp[i][j-1]) + 1 必然小于等于 dp[i-1][j-1] + 2。那个"同时删掉"的策略,已经被"删一个"的策略在底层悄悄覆盖了。
2. 本题 = 1143题(LCS)的马甲
  • 最终剩下的一定是两个字符串的最长公共子序列 (LCS)。
  • word1 需要删掉的长度 = len(word1) - LCS
  • word2 需要删掉的长度 = len(word2) - LCS
  • 总删除次数 = len(word1) + len(word2) - 2 * LCS

这种方法空间复杂度甚至可以优化到 O(1),是真正的最优解。


72. 编辑距离

给你两个单词 word1word2请返回将 word1 转换成 word2 所使用的最少操作数

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符
cpp 复制代码
class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.size(), m = word2.size();
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        // 初始化边界:一边有字符,一边是空串,只能全插入或全删除
        for(int i = 0; i <= n; i++) dp[i][0] = i; 
        for(int j = 0; j <= m; j++) dp[0][j] = j;
        for(int i = 1; i <= n; i++){
            for(int j = 1; j <= m; j++){
                if(word1[i-1] == word2[j-1]){
                    // 啥也不用做,继承前一个状态
                    dp[i][j] = dp[i-1][j-1];
                }
                else {
                    // 【终极核心】:不相等时,三选一取最小值,代价统一为 +1
                    dp[i][j] = min({dp[i-1][j-1], dp[i-1][j], dp[i][j-1]}) + 1;
                }
            }
        }
        return dp[n][m];
    }
};

总结

word1[i-1] != word2[j-1] 时:

1. dp[i-1][j-1] + 1 → 替换
  • 为什么:想把 word1 变成 word2,当前字符不一样。我把 word1[i-1] 替换成 word2[j-1],这一步花掉 1 的代价。替换完后,两边这层就对齐了,直接去看前面剩下的字符 dp[i-1][j-1]
2. dp[i-1][j] + 1 → 删除 或 插入
  • 为什么是删除:当前字符对不上,我直接把 word1[i-1] 删掉。花掉 1 的代价。删完之后,word1的指针退一格,word2 的指针不动,所以看 dp[i-1][j]
  • 为什么也是插入:当前对不上,我强行在 word1 里插入一个和 word2[j-1] 一样的字符。花掉 1 的代价。插入后,word2[j-1] 算被满足了对齐了,word2 指针进一格(变成 j),而 word1 还在原地没动,所以看 dp[i][j-1]
  • 【高阶认知】:"删 word1 的字符" 和 "给 word1 插入字符",在数学上是完全等价的逆操作!所以 dp[i-1][j]dp[i][j-1] 本质上都在处理一个指针不动、另一个指针后退的情况。
3. 为什么不需要像上一题那样写 +2

上一题(583)只允许"删除",不允许"替换"。如果不相等,只能硬删两个字符(+2)。

而本题允许"替换"(+1),既然花 1 的代价就能解决不匹配的问题,聪明的算法自然不会去选花 2 的代价去删两个字符。"替换"操作彻底干掉了"同时删除"的必要性。


编辑距离问题总结

题号 目标函数 允许的操作 s[i]==t[j] 匹配时 s[i]!=t[j] 不匹配时 初始化边界
392 判断子序列 返回 bool (求最大长度==n) 只能删 t 继承左上 +1 只看左 dp[i][j-1] (只能删t) 0
115 不同子序列 返回 int (求路径总数) 只能删 s 相加 左上 + 正上 (用/不用当前字符) 只看上 dp[i-1][j] (只能删s) dp[i][0]=1 (空串是任何串子序列)
583 删除操作 返回 int (求最小步数) 能删 st 继承左上 看左和上取Min +1 (挑一个删) dp[i][0]=i dp[0][j]=j *(全删光的代价)*
72 编辑距离 返回 int (求最小步数) s、删 t、替换 继承左上 看左、上、左上取Min +1 (删s/删t/替换) dp[i][0]=i dp[0][j]=j *(全删光/全插入)*
相关推荐
阿Y加油吧2 小时前
动态规划经典题解:最长递增子序列 & 乘积最大子数组
算法·动态规划·代理模式
f3iiish2 小时前
3783. 整数的镜像距离 力扣
算法·leetcode
Not Dr.Wang4222 小时前
基于matlab的控制系统奈氏图及其稳定性分析
数据结构·算法·matlab
闻缺陷则喜何志丹2 小时前
【排序 离散化 二维前缀和】 P7149 [USACO20DEC] Rectangular Pasture S|普及+
c++·算法·排序·离散化·二维前缀和
rainbow7242442 小时前
AI学习路线分享:通用型认证与算法认证学习体验对比
人工智能·学习·算法
君义_noip2 小时前
信息学奥赛一本通 4163:【GESP2512七级】城市规划 | 洛谷 P14921 [GESP202512 七级] 城市规划
c++·算法·图论·gesp·信息学奥赛
Simon_lca2 小时前
验厂不翻车!Acushnet 11 项核心政策 + 自查要点,一文搞定
大数据·人工智能·经验分享·算法·制造
智者知已应修善业3 小时前
【51单片机按键控制流水灯+数码管显示按键次数】2023-6-15
c++·经验分享·笔记·算法·51单片机
汉克老师3 小时前
GESP2023年12月认证C++三级( 第三部分编程题(1、小猫分鱼))
c++·算法·模拟算法·枚举算法·gesp三级·gesp3级