📏 子序列DP — 最长公共子序列与最长递增子序列

子序列问题是字符串DP的灵魂——删除不改变顺序,插入填补空隙

📖 子序列DP概览

子序列(Subsequence)是从原序列中删除若干元素(不改变顺序)得到的新序列。子序列DP的核心是:对于每个元素,考虑"选"还是"不选"。

子序列问题分类: 子序列DP +-- 最长公共子序列 (LCS) --- 两个序列的公共部分 | +-- dp[i][j] = text1前i个与text2前j个的LCS长度 +-- 最长递增子序列 (LIS) --- 单调递增的最长子序列 | +-- dp[i] = 以nums[i]结尾的LIS长度 +-- 最长回文子序列 (LPS) --- 正反读一样的子序列 | +-- dp[i][j] = s[i..j]的LPS长度 核心模式: LCS: 二维DP, 双指针分别前进 LIS: 一维DP + 二分优化 LPS: 区间DP, 两端向内收缩

1. 子序列 vs 子串 vs 子数组

概念要求连续性
子序列 Subsequence顺序不变不要求连续
子串 Substring顺序不变必须连续
子数组 Subarray顺序不变必须连续(数组版)

🔬 题目一:最长公共子序列(LC 1143)

给定两个字符串,返回最长公共子序列的长度。

思路分析

LCS状态转移: text1 = "abcde", text2 = "ace" "" a c e "" 0 0 0 0 a 0 1 1 1 b 0 1 1 1 c 0 1 2 2 d 0 1 2 2 e 0 1 2 3 转移: text1[i-1] == text2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 text1[i-1] != text2[j-1]: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

代码实现

class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                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])
        return dp[m][n]

复杂度分析

时间: O(m * n) 空间: O(m * n)

🔬 题目二:最长递增子序列(LC 300)

给定整数数组,返回最长严格递增子序列的长度。

思路分析

LIS两种解法: 方法一: O(n^2) DP dp[i] = 以nums[i]结尾的LIS长度 dp[i] = max(dp[j] + 1) 对所有 j < i 且 nums[j] < nums[i] nums = [10, 9, 2, 5, 3, 7, 101, 18] dp = [ 1, 1, 1, 2, 2, 3, 4, 4] 方法二: O(n log n) 贪心+二分 维护一个tails数组,tails[i]表示长度为i+1的递增子序列的末尾最小值 tails = [2, 3, 7, 18] 长度=4

代码实现(O(n^2) DP版)

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        if not nums:
            return 0
        dp = [1] * len(nums)
        for i in range(1, len(nums)):
            for j in range(i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i], dp[j] + 1)
        return max(dp)

代码实现(O(n log n) 贪心+二分版)

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        tails = []
        for num in nums:
            lo, hi = 0, len(tails)
            while lo < hi:
                mid = (lo + hi) // 2
                if tails[mid] < num:
                    lo = mid + 1
                else:
                    hi = mid
            if lo == len(tails):
                tails.append(num)
            else:
                tails[lo] = num
        return len(tails)

复杂度分析

方法时间空间特点
DPO(n^2)O(n)能求具体LIS
贪心+二分O(n log n)O(n)只能求长度

🔬 题目三:最长回文子序列(LC 516)

给定字符串s,返回最长回文子序列的长度。

思路分析

区间DP:
1. 状态:dp[i][j] = s[i..j]的最长回文子序列长度
2. 转移:
若 s[i] == s[j]:dp[i][j] = dp[i+1][j-1] + 2
若 s[i] != s[j]:dp[i][j] = max(dp[i+1][j], dp[i][j-1])
3. 初始:dp[i][i] = 1(单个字符是回文)
4. 遍历:按区间长度从小到大

代码实现

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n = len(s)
        dp = [[0] * n for _ in range(n)]
        for i in range(n):
            dp[i][i] = 1
        for length in range(2, n + 1):
            for i in range(n - length + 1):
                j = i + length - 1
                if s[i] == s[j]:
                    dp[i][j] = dp[i+1][j-1] + 2 if length > 2 else 2
                else:
                    dp[i][j] = max(dp[i+1][j], dp[i][j-1])
        return dp[0][n-1]

复杂度分析

时间: O(n^2) 空间: O(n^2)

LPS有一个巧妙的转化:LPS(s) = LCS(s, reverse(s))。即最长回文子序列 = 字符串与其翻转的最长公共子序列。但这增加了空间开销,直接用区间DP更高效。

面试实战技巧

在实际面试中,算法题不仅仅是写代码,更考察沟通、分析和解决问题的能力。以下是一些实战技巧:

1. 解题步骤模板

5分钟分析:理解题意 - 确认边界 - 想暴力解 - 优化 - 写代码

1. 先复述题目,确认理解无误
2. 问清边界条件:空输入?负数?溢出?
3. 先说暴力解法(证明你理解问题)
4. 分析瓶颈,提出优化思路
5. 边写代码边解释思路

2. 常见陷阱

通用陷阱:
1. 索引越界:特别注意循环范围和数组边界
2. 初始化遗漏:DP的初始条件是否正确?
3. 遍历方向:依赖关系决定遍历顺序
4. 整数溢出:Python不溢出但其他语言注意
5. 边界条件:空数组、单元素、全相同

3. 代码模板

# 通用解题模板
class Solution:
    def solve(self, input_data):
        # 1. 边界检查
        if not input_data:
            return default_value
        # 2. 初始化
        result = initial_value
        state = initial_state
        # 3. 主循环
        for item in input_data:
            state = transition(state, item)
            result = max/min/update(result, state)
        # 4. 返回结果
        return result
面试中的代码不是越短越好,而是越清晰越好。适当的注释、合理的变量名、分步骤的实现,比一行式解法更能展示工程素养。
成就解锁:子序列猎手 — 掌握LCS与LIS两大核心模型及其变体
LeetCode AC验证:LC 1143 Longest Common Subsequence ✅ | LC 300 Longest Increasing Subsequence ✅ | LC 516 Longest Palindromic Subsequence ✅

📝 课后练习

  1. LC 392 判断子序列
  2. LC 115 不同的子序列
  3. LC 673 最长递增子序列的个数
  4. LC 646 最长数对链

🔑 本课要点回顾

📚 扩展阅读

思考题:LIS的O(n log n)解法中,tails数组存储的不是真正的LIS,为什么tails的长度等于LIS的长度?

⚡ 进阶专题:常见变体与扩展

每道经典题目都有丰富的变体。掌握核心模板后,灵活应对变体才是面试的真正挑战。以下列举本课相关的高频变体和扩展思路。

1. 常见题目变体

条件变化型:在原题基础上增加约束条件,如:数组中有负数、元素有上限、需要考虑溢出等。核心思路:不改变DP框架,只在转移时增加条件判断。

维度扩展型:从一维DP扩展到二维或三维。如:从线性到环形(LC 213)、从单串到双串(LCS)、从无约束到有约束。核心思路:增加状态维度来编码额外信息。

目标变化型:从求最值变为求方案数、求具体方案、判定可行性。核心思路:dp存的值从max/min变为count/boolean,转移从取最值变为累加。

2. 面试中的沟通技巧

# 面试沟通模板
# 1. 先说思路(1-2分钟)
"这道题我想到两种解法,先说更直观的一种..."

# 2. 分析复杂度(30秒)
"时间复杂度O(n^2),空间可以优化到O(n)"

# 3. 边写边说
"这里我定义dp[i]表示...,转移方程是..."

# 4. 主动验证
"我用示例走一遍:dp[0]=..., dp[1]=..."

# 5. 讨论优化
"空间还可以进一步优化,因为dp[i]只依赖..."

3. 代码调试检查清单

检查项常见错误
初始条件dp[0]是否正确?空输入是否处理?
遍历范围循环从0还是1开始?是否越界?
状态转移依赖的状态是否已计算?
返回值是dp[n]还是max(dp)?
边界情况单元素、全相同、空数组
面试中主动验证是最被低估的技巧。用示例手动走一遍DP表,既能发现bug,又能展示你的严谨性。很多面试官就是看你能否主动发现和修正错误。

⚡ 子序列DP速查与对比

子序列问题有三个核心模型,它们的DP定义和遍历方式各不相同。

模型状态定义遍历方式时间空间
LCSdp[i][j]=前i与前j的LCS长双重正序O(mn)O(mn)
LIS(DP)dp[i]=以i结尾的LIS长i正序,j逆序检查O(n^2)O(n)
LIS(二分)tails[i]=长度i+1的最小尾单次遍历+二分O(nlogn)O(n)
LPSdp[i][j]=s[i..j]的LPS长区间长度递增O(n^2)O(n^2)

子序列问题的通用技巧

1. 公共子序列问题:用二维DP,两个指针分别前进。核心:匹配则+1,不匹配则跳过某个字符取max。

2. 递增子序列问题:关注"以谁结尾"。暴力O(n^2)检查前面所有更小元素;二分O(nlogn)维护有序序列。

3. 回文子序列问题:区间DP,从短区间到长区间。两端匹配+2,不匹配取max(左缩,右缩)。