动态规划进阶:LCS、LIS与矩阵连乘

动态规划这东西,说实话,入门容易进阶难。很多朋友学完背包问题就觉得DP不过如此,结果一碰到字符串匹配、序列分析就懵了。今天咱们就来啃这三块硬骨头——最长公共子序列、最长递增子序列、矩阵连乘。我当年在做一个基因序列比对工具时,就是靠LCS算法撑起来的,那会儿踩的坑,现在想想还挺有意思。

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

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

比如"ABCBDAB"和"BDCABA",它们的LCS是"BCBA"或"BDAB",长度是4。怎么算?暴力枚举肯定不行,2的n次方级别的复杂度,字符串稍微长点就炸了。

状态定义与转移方程

我个人习惯用二维数组dp[i][j]表示:字符串A的前i个字符和字符串B的前j个字符的LCS长度。那么状态转移怎么推?

  • 如果A[i] == B[j],那这个字符肯定在LCS里,dp[i][j] = dp[i-1][j-1] + 1
  • 如果不等,那就取max(dp[i-1][j], dp[i][j-1]),说白了就是跳过其中一个字符

嗯,这里要注意:边界条件dp[0][j]和dp[i][0]都是0,因为空串跟谁都没公共子序列。

int lcs(char *a, char *b) {
    int m = strlen(a), n = strlen(b);
    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 (a[i-1] == b[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];
}
我的小技巧:如果只需要长度,可以只用两行数组滚动更新,空间从O(mn)降到O(n)。我在嵌入式项目里经常这么干,内存能省则省。

回溯构造LCS

光知道长度不够,有时候你得把具体的子序列拿出来。怎么做?从dp[m][n]往回走:

  • 如果a[i-1]==b[j-1],这个字符就是LCS的一部分,然后往左上角走
  • 如果不相等,看dp[i-1][j]和dp[i][j-1]谁大,往大的方向走
  • 如果相等,随便选一个方向(但可能得到不同的LCS)
我曾经踩过的坑:回溯时如果dp[i-1][j]==dp[i][j-1],两条路径都可能产生不同的LCS。如果你只想要其中一条,没问题;但如果你要所有可能的LCS,那就得用DFS了,复杂度会飙升。

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

LIS这个问题,你想想看,给一个数组,找出最长的严格递增的子序列。比如[10,9,2,5,3,7,101,18],LIS是[2,5,7,101]或[2,3,7,101],长度4。

最直观的做法是O(n²)的DP,但说实话,在工程中我更常用O(n log n)的贪心+二分法。为什么?因为数据量一大,平方级就扛不住了。

O(n²)的DP解法

定义dp[i]为以第i个元素结尾的LIS长度。转移方程:

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;
}

最后取所有dp[i]的最大值。简单吧?但n=10000时,这个循环就要跑5000万次,有点慢。

O(n log n)的贪心+二分

这个思路很有意思。我维护一个数组tails,tails[k]表示长度为k+1的递增子序列中,最小的末尾元素。你想想看,末尾元素越小,后面越容易接上新的数,对吧?

int lengthOfLIS(int* nums, int numsSize) {
    int tails[numsSize];
    int len = 0;
    
    for (int i = 0; i < numsSize; i++) {
        // 二分查找第一个大于等于nums[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;
}
核心思想:tails数组不记录具体的LIS序列,只记录每个长度下的最小末尾值。这样每次新元素来的时候,用二分找到它该放的位置,要么替换掉一个更大的末尾,要么扩展长度。

我在做股票交易系统的时候,就用这个算法分析价格序列的上升趋势。嗯,效果还不错,比单纯看均线灵敏多了。

三、矩阵连乘问题

这个问题,说白了就是:给你一堆矩阵,怎么加括号让乘法次数最少?注意,矩阵乘法满足结合律,不满足交换律,所以括号位置很关键。

比如三个矩阵A(10×100)、B(100×5)、C(5×50):

  • (AB)C:10×100×5 + 10×5×50 = 5000 + 2500 = 7500次乘法
  • A(BC):100×5×50 + 10×100×50 = 25000 + 50000 = 75000次乘法

你看,差了整整10倍!

状态定义

定义dp[i][j]为从第i个矩阵到第j个矩阵的最小乘法次数。p数组存储维度:矩阵i的维度是p[i-1]×p[i]。

转移方程:

dp[i][j] = min(dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j])
// 其中 i ≤ k < j

什么意思?就是在k处断开,左边的最小代价加上右边的最小代价,再加上合并左右结果的代价。

int matrixChain(int p[], int n) {
    int dp[n][n];
    memset(dp, 0, sizeof(dp));
    
    // len是子问题的矩阵个数,从2开始
    for (int len = 2; len < n; len++) {
        for (int i = 1; i < n - len + 1; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            for (int k = i; k < j; k++) {
                int cost = dp[i][k] + dp[k+1][j] + 
                           p[i-1] * p[k] * p[j];
                if (cost < dp[i][j])
                    dp[i][j] = cost;
            }
        }
    }
    return dp[1][n-1];
}
注意:这里n是矩阵个数+1,因为p数组有n+1个元素。我刚开始学的时候老搞混下标,后来干脆画个表格,把p[i-1]、p[k]、p[j]对应到矩阵维度上,就清晰多了。

四、知识体系总览

这三个问题虽然看起来不一样,但核心套路是相通的。我画了个图,帮你理清思路:

动态规划进阶三大经典问题 LCS 最长公共子序列 LIS 最长递增子序列 矩阵连乘 最优括号化 共同特征:最优子结构 + 重叠子问题 二维DP / 一维DP / 区间DP DP解题四步法 ① 定义状态(dp[i][j]表示什么) ② 推导转移方程(如何从子问题得到当前) ③ 确定边界条件(初始化dp数组) ④ 选择遍历顺序(自底向上/区间长度)

五、避坑指南与实战心得

做了这么多年嵌入式开发,我总结了几条关于DP的实战经验:

  • 先画表格再写代码:遇到二维DP,先在纸上画个表格,手动填几个值,规律就出来了。我每次带新人都是这么教的。
  • 注意下标偏移:LCS里dp[i][j]对应a[i-1]和b[j-1],这个偏移搞错了,整个程序就废了。
  • 空间优化要谨慎:滚动数组虽然省内存,但如果你需要回溯构造具体解,那就不能省,得保留完整dp表。
  • 测试用例要覆盖边界:空串、单元素、全部相等、全部不等——这些情况都得测一遍。

我曾经犯过的错:有一次做LIS的O(n log n)版本,二分查找写成了找第一个大于nums[i]的位置,结果tails数组里存的值全乱了。后来才发现,应该是找第一个大于等于的位置。就这一个等号的区别,debug了一整个下午。

好了,这三个问题就聊到这儿。你想想看,它们本质上都是在做同一件事——把大问题拆成小问题,用小问题的答案拼出大问题的答案。只不过LCS和LIS是序列上的DP,矩阵连乘是区间上的DP。套路学会了,以后遇到新问题,先想想能不能用DP,怎么定义状态,怎么转移——思路就打开了。


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