你有没有发现,刷题路上总有那么几道题,像项链里的珍珠,串起来才闪闪发光?
今天,我们不聊枯燥的定义,也不讲无聊的套路。我们要用最生活化的比喻、最实用的技巧,带你拆解那些让人又爱又恨的“子序列”动态规划问题。
别眨眼,接下来你会发现,原来刷题也能像串项链一样有趣!
🏗️ 什么是子序列?
想象一下你有一串珍珠项链,子序列就是从中挑出一些珍珠,保持原有的顺序,但不一定是连续的。就像从"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要注意双重循环的顺序
🎯 实战练习建议
新手起步 🐣
- 最长公共子序列 - 理解二维DP的基础
- 最长递增子序列 - 掌握一维DP的精髓
进阶挑战 🏃♂️
- 编辑距离 - 经典的字符串DP问题
- 最长回文子序列 - 结合回文特性
- 不同的子序列 - 计数类DP问题
高手进阶 🦅
- K个不同字符的最长子串 - 滑动窗口+DP
- 正则表达式匹配 - 复杂的字符串DP
💡 解题小贴士
记忆口诀:
- "子序列,顺序留,跳过元素不犯愁"
- "字符配,长度收,字符不配取最优"
- "空间省,滚动走,一维数组解千愁"
调试技巧:
- 先用小例子手算一遍
- 画出DP表格,观察填充过程
- 打印中间状态,验证思路
🏁 总结
子序列类动态规划问题的核心是:
- 状态定义要清晰(dp数组的含义)
- 状态转移要准确(递推关系)
- 边界条件要处理好(初始值)
记住,每个子序列问题都是在问:
"在前i个元素中,以某种条件约束的最优子序列是什么?"
掌握了这三板斧,你就能在子序列的世界里横着走啦!🦀