动态规划(下):最长公共子序列、最长递增子序列、编辑距离

好,咱们接着聊动态规划。上一章我们把 DP 的套路捋了一遍,今天来啃三个硬骨头——最长公共子序列(LCS)、最长递增子序列(LIS)和编辑距离。这三个问题,说白了就是字符串和序列处理里的“三座大山”。我在做嵌入式协议解析时,没少跟它们打交道。

一、最长公共子序列(LCS)

先问个问题:给你两个字符串,怎么找出它们都包含的最长顺序子序列?注意,是子序列,不是子串。子序列可以不连续,但顺序不能乱。

举个例子,"abcde" 和 "ace" 的 LCS 是 "ace",长度 3。这个在基因序列比对、文件 diff 里都用得上。我当年做嵌入式 OTA 差分升级时,就用 LCS 来算新旧固件的公共部分,减少传输量。

状态定义

定义 dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的 LCS 长度。为什么用 i-1 和 j-1?为了方便处理空串,dp[0][j] 和 dp[i][0] 都是 0。

状态转移方程

if (text1[i-1] == text2[j-1])
    dp[i][j] = dp[i-1][j-1] + 1;
else
    dp[i][j] = max(dp[i-1][j], dp[i][j-1]);

逻辑很直观:字符相等,长度加一;不等,取左边或上边的最大值。嗯,这里要注意,很多人会写成 dp[i][j] = max(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]),其实没必要,因为 dp[i-1][j-1] 一定小于等于另外两个。

代码实现

int longestCommonSubsequence(char* text1, char* text2) {
    int m = strlen(text1), n = strlen(text2);
    int dp[m+1][n+1];
    memset(dp, 0, sizeof(dp));
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (text1[i-1] == text2[j-1])
                dp[i][j] = dp[i-1][j-1] + 1;
            else
                dp[i][j] = dp[i-1][j] > dp[i][j-1] ? dp[i-1][j] : dp[i][j-1];
        }
    }
    return dp[m][n];
}
空间优化小技巧: 观察发现,dp[i][j] 只依赖 dp[i-1][j-1]、dp[i-1][j] 和 dp[i][j-1]。所以可以用两行滚动数组,甚至一行加一个左上角变量。我在内存受限的 MCU 上就是这么干的。

二、最长递增子序列(LIS)

LIS 的问题描述很简单:给定一个无序数组,找出最长的严格递增子序列长度。比如 [10,9,2,5,3,7,101,18] 的 LIS 是 [2,5,7,101],长度 4。

这个问题的经典解法有两种:O(n²) 的 DP 和 O(n log n) 的贪心+二分。我个人更推荐后者,尤其是处理大数据时。

O(n²) 解法

定义 dp[i] 为以 nums[i] 结尾的 LIS 长度。对于每个 i,遍历 j < i,如果 nums[j] < nums[i],就更新 dp[i] = max(dp[i], dp[j] + 1)。

int lengthOfLIS(int* nums, int numsSize) {
    int dp[numsSize], maxLen = 1;
    for (int i = 0; i < numsSize; i++) {
        dp[i] = 1;
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i] && dp[j] + 1 > dp[i])
                dp[i] = dp[j] + 1;
        }
        if (dp[i] > maxLen) maxLen = dp[i];
    }
    return maxLen;
}

O(n log n) 解法——贪心+二分

这个思路很巧妙:维护一个数组 tails,tails[k] 表示长度为 k+1 的递增子序列的末尾元素最小值。遍历每个数,用二分查找找到它在 tails 中的位置,然后替换。

int lengthOfLIS(int* nums, int numsSize) {
    int tails[numsSize], len = 0;
    for (int i = 0; i < numsSize; i++) {
        int left = 0, right = len;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (tails[mid] < nums[i])
                left = mid + 1;
            else
                right = mid;
        }
        tails[left] = nums[i];
        if (left == len) len++;
    }
    return len;
}
我曾经踩过的坑: 二分查找的边界条件一定要搞清楚。我一开始写的是 while (left <= right),结果死循环了半小时。记住,找的是第一个大于等于 target 的位置,用左闭右开区间最稳。

三、编辑距离(Levenshtein Distance)

编辑距离,就是将一个字符串变成另一个字符串所需的最少操作次数。操作有三种:插入、删除、替换。这个在拼写纠错、DNA 序列比对里太常见了。

我记得有一次做嵌入式语音识别后处理,用户说“打电话给张三”,识别成了“打电话给张山”,编辑距离一算,距离为 1,直接自动纠正了。

状态定义

dp[i][j] 表示 word1[0..i-1] 转换成 word2[0..j-1] 的最小编辑距离。

状态转移方程

if (word1[i-1] == word2[j-1])
    dp[i][j] = dp[i-1][j-1];
else
    dp[i][j] = min(
        dp[i-1][j] + 1,    // 删除 word1[i-1]
        dp[i][j-1] + 1,    // 插入 word2[j-1]
        dp[i-1][j-1] + 1   // 替换
    );

完整代码

int minDistance(char* word1, char* word2) {
    int m = strlen(word1), n = strlen(word2);
    int dp[m+1][n+1];
    
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1[i-1] == word2[j-1])
                dp[i][j] = dp[i-1][j-1];
            else {
                int del = dp[i-1][j] + 1;
                int ins = dp[i][j-1] + 1;
                int rep = dp[i-1][j-1] + 1;
                dp[i][j] = del < ins ? (del < rep ? del : rep) : (ins < rep ? ins : rep);
            }
        }
    }
    return dp[m][n];
}
核心要点: 编辑距离的初始化很关键。dp[i][0] = i 表示把 word1 前 i 个字符全部删除;dp[0][j] = j 表示把空串插入 j 次变成 word2 前 j 个字符。

四、三种 DP 的对比与总结

问题 状态定义 时间复杂度 空间复杂度 典型应用
LCS dp[i][j]:两串前缀的公共子序列长度 O(mn) O(mn) 可优化为 O(n) 文件 diff、基因比对
LIS dp[i]:以 nums[i] 结尾的 LIS 长度 O(n²) 或 O(n log n) O(n) 任务调度、最长上升趋势
编辑距离 dp[i][j]:两串前缀的编辑距离 O(mn) O(mn) 可优化为 O(n) 拼写纠错、语音识别后处理

你想想看,这三个问题其实都有一个共同点:它们都是二维 DP 的典型代表。LCS 和编辑距离是二维表格填表,LIS 虽然是一维数组,但优化后的二分思想也值得反复品味。

我的个人习惯: 遇到字符串或序列的 DP 问题,先画表格。把 i 和 j 的对应关系画出来,状态转移方程自然就出来了。纸上谈兵比直接写代码快得多。

五、知识体系图

下面这张图把三个问题的核心逻辑串起来了,方便你对比记忆:

动态规划三大经典问题 最长公共子序列 状态:dp[i][j] 字符相等:左上+1 不等:max(左, 上) 应用:文件 diff 应用:基因序列比对 应用:OTA差分升级 时间复杂度:O(mn) 空间可优化为 O(n) 最长递增子序列 状态:dp[i] 或 tails O(n²):遍历 j < i O(n log n):贪心+二分 应用:任务调度 应用:最长上升趋势 应用:股票最大收益 O(n²) 适合小数据 O(n log n) 适合大数据 编辑距离 状态:dp[i][j] 字符相等:左上 不等:min(删, 插, 替) 应用:拼写纠错 应用:语音识别后处理 应用:DNA序列比对 时间复杂度:O(mn) 三种操作:插入/删除/替换 共同点:二维DP表格填表 | 区别:转移方程不同 | 优化方向:空间压缩

这三个问题,你只要把状态定义和转移方程吃透了,以后遇到类似的 DP 题,基本都能套上。说白了,动态规划就是“把大问题拆成小问题,用小问题的答案拼出大问题的答案”。

好了,这一章的内容就到这。代码多敲几遍,表格多画几次,你自然就找到感觉了。

公众号:蓝海资料掘金营,微信 deep3321