珍珠项链的秘密:如何用动态规划串起刷题人生 💡

123 阅读6分钟

你有没有发现,刷题路上总有那么几道题,像项链里的珍珠,串起来才闪闪发光?

今天,我们不聊枯燥的定义,也不讲无聊的套路。我们要用最生活化的比喻、最实用的技巧,带你拆解那些让人又爱又恨的“子序列”动态规划问题。

别眨眼,接下来你会发现,原来刷题也能像串项链一样有趣!

🏗️ 什么是子序列?

想象一下你有一串珍珠项链,子序列就是从中挑出一些珍珠,保持原有的顺序,但不一定是连续的。就像从"HELLO"中挑出"HLO",这就是个子序列!

子序列问题的核心特点:

  • 顺序不能变(不能从"HELLO"中挑出"OLH")
  • 可以不连续(不像子串必须连续)
  • 长度不限(可以是1个字符,也可以是整个字符串)

🎯 三大经典子序列问题

1. 最长公共子序列 (LCS) - 寻找DNA的相似性 🧬

问题描述:给定两个字符串,找出它们共有的最长子序列的长度。

现实场景

  • DNA序列比对("ATCG"和"ACG"的最长公共部分是"ACG")
  • 文件差异比较(git diff的核心算法)
  • 拼写纠错("hellp"和"hello"的相似度)

解题思路

我们用dp[i][j]表示第一个字符串前i个字符和第二个字符串前j个字符的最长公共子序列长度。

状态转移方程

if (text1[i-1] === text2[j-1]) {
    dp[i][j] = dp[i-1][j-1] + 1  // 字符匹配,长度+1
} else {
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])  // 字符不匹配,看谁更长
}

代码实现

// 最长公共子序列 - 标准解法
var longestCommonSubsequence = function(text1, text2) {
    let n = text1.length, m = text2.length
    
    // dp[i][j]:text1前i个字符和text2前j个字符的LCS长度
    let dp = new Array(n + 1).fill().map(() => new Array(m + 1).fill(0))
    
    for (let i = 1; i <= n; i++) {
        for (let j = 1; j <= m; j++) {
            if(text1[i - 1] === text2[j - 1]) {
                // 🎯 找到共同字符,长度+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 dp[n][m]
};

空间优化技巧 💡

二维数组太占空间?我们可以用滚动数组优化到一维!

// 空间优化版本 - 只用一维数组
var longestCommonSubsequence = function(text1, text2) {
    const n = text1.length, m = text2.length
    let dp = new Array(m + 1).fill(0)
    
    for (let i = 1; i <= n; i++) {
        let prev = 0  // 保存左上角的值
        for (let j = 1; j <= m; j++) {
            let temp = dp[j]  // 保存当前值
            if(text1[i - 1] === text2[j - 1]) {
                dp[j] = prev + 1  // 使用左上角的值+1
            } else {
                dp[j] = Math.max(dp[j], dp[j - 1])  // 取上方或左方的最大值
            }
            prev = temp  // 更新prev供下一轮使用
        }
    }
    return dp[m]
};

2. 不相交的线问题 - LCS的变形记 🎭

问题描述:给定两个数组,可以连接相同值的元素形成线,但不能让线交叉,最多能连多少条线?

核心洞察:这其实就是最长公共子序列的换皮题!

为什么?

  • 不相交的线 ⇨ 保持相对顺序
  • 连最多线 ⇨ 最长子序列

所以解法跟LCS一模一样,只是把字符串换成了数字数组:

// 不相交的线 = 最长公共子序列
var maxUncrossedLines = function(nums1, nums2) {
    return longestCommonSubsequence(nums1, nums2)
};

3. 最长递增子序列 (LIS) - 寻找上升趋势 📈

问题描述:给定一个数组,找出最长的严格递增的子序列长度。

示例

  • 输入:[10,9,2,5,3,7,101,18]
  • 输出:4 (子序列[2,3,7,101])

解法一:动态规划(O(n²))

// 动态规划解法 - 时间复杂度O(n²)
var lengthOfLIS = function(nums) {
    if (nums.length === 0) return 0
    
    // dp[i]:以nums[i]结尾的最长递增子序列长度
    let dp = new Array(nums.length).fill(1)
    
    for (let i = 1; i < nums.length; i++) {
        for (let j = 0; j < i; j++) {
            if (nums[i] > nums[j]) {
                // 🚀 如果当前数比前面的大,可以接在后面
                dp[i] = Math.max(dp[i], dp[j] + 1)
            }
        }
    }
    
    return Math.max(...dp)
};

解法二:贪心+二分查找(O(n log n))🚀

这个解法更巧妙!我们维护一个tails数组:

  • tails[i]表示长度为i+1的递增子序列的最小末尾元素
  • 这个数组始终保持递增
// 贪心+二分查找 - 时间复杂度O(n log n)
var lengthOfLIS = function(nums) {
    if (!nums || nums.length === 0) return 0;
    
    let tails = [];  // tails[i]:长度为i+1的递增子序列的最小末尾
    
    for (let num of nums) {
        if (tails.length === 0 || num > tails[tails.length - 1]) {
            // 🎯 可以扩展最长子序列
            tails.push(num);
        } else {
            // 🔍 用二分查找找到替换位置
            let left = 0, right = tails.length - 1;
            while (left < right) {
                let mid = Math.floor((left + right) / 2);
                if (tails[mid] < num) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
            tails[left] = num;  // 替换为更小的值
        }
    }
    
    return tails.length;  // 长度就是最长递增子序列的长度
};

🧩 子序列问题的通用解题模板

第一步:定义状态

问自己:

  • dp[i]dp[i][j]到底表示什么?
  • 是"以i结尾的..."还是"前i个元素的..."?

第二步:状态转移

核心公式:

if (字符匹配/满足条件) {
    dp[i][j] = dp[i-1][j-1] + 1  // 包含当前元素
} else {
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])  // 不包含当前元素
}

第三步:初始化

  • 空字符串/空数组的情况
  • 单个元素的情况

第四步:遍历顺序

  • 通常是从前往后
  • 二维DP要注意双重循环的顺序

🎯 实战练习建议

新手起步 🐣

  1. 最长公共子序列 - 理解二维DP的基础
  2. 最长递增子序列 - 掌握一维DP的精髓

进阶挑战 🏃‍♂️

  1. 编辑距离 - 经典的字符串DP问题
  2. 最长回文子序列 - 结合回文特性
  3. 不同的子序列 - 计数类DP问题

高手进阶 🦅

  1. K个不同字符的最长子串 - 滑动窗口+DP
  2. 正则表达式匹配 - 复杂的字符串DP

💡 解题小贴士

记忆口诀

  • "子序列,顺序留,跳过元素不犯愁"
  • "字符配,长度收,字符不配取最优"
  • "空间省,滚动走,一维数组解千愁"

调试技巧

  1. 先用小例子手算一遍
  2. 画出DP表格,观察填充过程
  3. 打印中间状态,验证思路

🏁 总结

子序列类动态规划问题的核心是:

  • 状态定义要清晰(dp数组的含义)
  • 状态转移要准确(递推关系)
  • 边界条件要处理好(初始值)

记住,每个子序列问题都是在问:

"在前i个元素中,以某种条件约束的最优子序列是什么?"

掌握了这三板斧,你就能在子序列的世界里横着走啦!🦀