好的,我来为你讲解一个线性动态规划领域中尚未出现在你列表里的经典题目。
最长公共上升子序列 (Longest Common Increasing Subsequence, LCIS)
题目描述:
给定两个整数序列 A 和 B(长度分别为 n 和 m),请你求出这两个序列的 最长公共上升子序列 的长度。
一个序列 S 如果同时满足以下两个条件,则称为 A 和 B 的公共上升子序列:
- 公共性:
S是A的子序列,也是B的子序列(即从A和B中按顺序取出一些元素,可以不连续,构成的相同序列)。 - 严格递增性:
S中的元素是严格单调递增的(即对于序列中的任意相邻元素S[i]和S[i+1],有S[i] < S[i+1])。
我们的目标是找到满足以上条件的最长序列的长度。
示例:
- A =
[1, 3, 5, 7, 9] - B =
[2, 3, 4, 7, 8, 9] - 最长公共上升子序列之一是
[3, 7, 9],长度为3。 - 另一个例子
[3, 9]也满足,但长度不是最长的。
解题过程(循序渐进)
这个问题本质上是 最长公共子序列 (LCS) 和 最长递增子序列 (LIS) 的结合体。我们需要找到一个序列,它同时是两个给定序列的子序列,并且自身是严格递增的。
第一步:状态定义
动态规划的核心是定义状态。我们如何用一个状态来描述问题的某个“中间情况”呢?
一个自然的想法是模仿经典的 LCS 问题,定义 dp[i][j] 为:考虑 A 的前 i 个元素和 B 的前 j 个元素时,所能得到的最长公共上升子序列的长度。
但是,这个定义存在一个关键缺陷:我们不知道这个最长公共上升子序列的最后一个元素是什么。而“递增”这个性质强烈依赖于序列的最后一个元素(即最大值)。没有最后一个元素的信息,我们无法判断能否把当前考察的元素安全地添加到子序列末尾。
因此,我们需要强化我们的状态定义,强制记录与“递增”相关的关键信息。一个更强大的定义是:
dp[i][j] 表示:
- 考虑
A的前i个元素(A[1...i])和B的前j个元素(B[1...j])。 - 并且,所构成的公共上升子序列必须以
B[j]这个元素作为结尾。 - 在这个条件下的最长长度。
为什么以 B[j] 结尾?
因为在处理公共子序列时,我们通常同时遍历两个序列。固定公共子序列的结尾元素是 B[j](它也必须是 A 中某个匹配的元素),我们就知道了这个子序列的“当前最大值”,从而能够方便地判断后续元素能否接上,以满足递增性。
第二步:状态转移方程
现在我们要推导 dp[i][j] 如何从其他状态计算得来。
根据 A[i] 和 B[j] 是否相等,我们分两种情况讨论:
情况1:A[i] != B[j]
如果 A[i] 不等于 B[j],那么 B[j] 这个元素就不可能作为公共子序列的结尾被包含在考虑了 A[i] 的情况下吗?不一定。
我们的状态 dp[i][j] 定义的是“考虑 A 的前 i 个元素”,而非“必须使用 A[i]”。如果 A[i] 和 B[j] 不匹配,那么我们完全可以不使用 A[i],直接从前 i-1 个 A 的元素中去寻找与 B[j] 匹配的公共上升子序列。
因此,当 A[i] != B[j] 时:
dp[i][j] = dp[i-1][j]
情况2:A[i] == B[j]
这是关键情况。当 A[i] 等于 B[j] 时,意味着我们找到了一个公共元素 val = A[i] = B[j],它可以作为我们公共上升子序列的结尾。
那么,以 val 结尾的、最长的公共上升子序列是怎么形成的呢?
它必然是由一个更短的、以某个小于 val 的元素结尾的公共上升子序列,后面接上这个 val 构成的。
这个“更短的子序列”从哪里来?它必须同时是 A 和 B 的子序列,并且以某个小于 val 的元素结尾。
- 因为是
A的子序列,并且我们已经考虑了A的前i个元素,所以这个短序列只能来自A[1...i-1]。 - 因为是
B的子序列,并且必须以某个小于val的元素结尾,假设这个元素是B[k](其中k < j),那么它就来自B[1...k]。
因此,我们需要在 B 的前 j-1 个元素中,寻找所有满足 B[k] < val 的位置 k,然后看看以这些 B[k] 结尾的、考虑 A 的前 i-1 个元素的最长公共上升子序列长度是多少,即 dp[i-1][k]。取其中的最大值,然后加 1(代表接上当前的 val)。
但是,注意:这里 A 的索引是 i-1,因为我们决定使用 A[i],所以短序列只能从 A 的前 i-1 个里选。B 的索引是 k,因为短序列的结尾是 B[k]。
所以,转移方程为:
dp[i][j] = max(dp[i-1][k]) + 1,其中 1 <= k < j,且 B[k] < B[j]。
如果找不到任何一个 k 满足 B[k] < B[j],那么这个 val 自己就构成一个长度为 1 的子序列。
因此,我们可以将 max 操作的初始值设为 0,最后再加1。
综合两种情况:
- 若
A[i] != B[j]:dp[i][j] = dp[i-1][j] - 若
A[i] == B[j]:dp[i][j] = max(0, max_{1<=k<j, B[k]<B[j]} { dp[i-1][k] } ) + 1
第三步:初始化与最终答案
初始化:
我们可以将 dp 数组(维度 (n+1) x (m+1))全部初始化为 0。dp[0][j] 表示 A 的前 0 个元素(空序列)与 B 的前 j 个元素的最长公共上升子序列长度,显然是 0。同理 dp[i][0] 也为 0。
最终答案:
我们的状态 dp[i][j] 定义是“必须以 B[j] 结尾”。
但题目要求的最长公共上升子序列,其结尾可以是 B 中的任意一个元素,或者 A 中的任意一个元素。实际上,由于公共性,它必然同时是两者中的某个元素。
因此,最终的答案应该是所有 dp[i][j] 中的最大值,即:
ans = max_{1<=i<=n, 1<=j<=m} dp[i][j]
第四步:复杂度分析与优化
直接按照上述方程实现,时间复杂度为 O(n * m²),因为对于每个 (i, j),当 A[i]==B[j] 时,我们需要遍历所有 k (1 <= k < j) 来寻找最大值。
我们可以进行优化。观察内层循环 max_{k<j, B[k]<B[j]} dp[i-1][k]。
对于固定的 i,当我们在外层循环 j 从 1 到 m 时,我们实际上是在为每个 j,寻找它前面所有比 B[j] 小的元素对应的 dp[i-1][k] 的最大值。
这是一个经典的 在序列中,维护前缀中满足某种条件(值小于当前值)的状态最大值 的问题。
我们可以在遍历 j 的过程中,动态维护一个变量 maxv。这个 maxv 表示:在已经遍历过的 B[1...j-1] 中,所有 B[k] 值小于 当前待比较的数值 C 的 dp[i-1][k] 的最大值。
在当前问题中,C 是什么?它就是 A[i](因为只有 A[i]==B[j] 时我们才需要这个最大值)。
所以,算法可以优化为:
- 外层循环
i从1到n。 - 内层循环
j从1到m。 - 对于每个
(i, j):- 首先,处理
A[i] != B[j]的情况:dp[i][j] = dp[i-1][j]。 - 然后,为 下一个
j+1做准备(或者说为所有A[i] == B[j]的情况做准备),我们需要更新maxv。如果B[j] < A[i],那么B[j]就满足“小于A[i]”这个条件,它的状态dp[i-1][j]可能成为未来的最大值候选。所以我们令maxv = max(maxv, dp[i-1][j])。 - 最后,如果
A[i] == B[j],这正是我们需要用到maxv的时刻!此时maxv已经记录了所有k<j且B[k] < A[i] (=B[j])的dp[i-1][k]的最大值。所以dp[i][j] = maxv + 1。
- 首先,处理
这样,我们通过一个变量 maxv,将内层的 k 循环优化掉了。总时间复杂度降至 O(n * m)。
第五步:示例演算(以优化后的算法为例)
令 A = [1, 3, 2], B = [3, 1, 2]。我们的目标是求 LCIS 长度。
初始化 dp 为全0,n=3, m=3。
i=1 (A[1]=1):
maxv = 0(初始)- j=1 (B[1]=3):
A[1] != B[1]->dp[1][1] = dp[0][1] = 0B[1]=3与A[1]=1比较:B[j] < A[i]? 3<1? False。maxv不变。
- j=2 (B[2]=1):
A[1] == B[2]!所以dp[1][2] = maxv + 1 = 0 + 1 = 1(序列[1])- 然后判断
B[2]=1与A[1]=1:B[j] < A[i]? 1<1? False (严格小于)。maxv不变。
- j=3 (B[3]=2):
A[1] != B[3]->dp[1][3] = dp[0][3] = 0B[3]=2与A[1]=1:B[j] < A[i]? 2<1? False。maxv不变。
此时dp[1]行:[0, 1, 0]
i=2 (A[2]=3):
maxv = 0(重新初始化)- j=1 (B[1]=3):
A[2] == B[1]!此时maxv为初始值0 ->dp[2][1] = 0 + 1 = 1(序列[3])B[1]=3与A[2]=3:B[j] < A[i]? 3<3? False。maxv不变。
- j=2 (B[2]=1):
A[2] != B[2]->dp[2][2] = dp[1][2] = 1B[2]=1与A[2]=3:B[j] < A[i]? 1<3? True。maxv = max(0, dp[1][2]=1) = 1。
- j=3 (B[3]=2):
A[2] != B[3]->dp[2][3] = dp[1][3] = 0B[3]=2与A[2]=3:B[j] < A[i]? 2<3? True。maxv = max(1, dp[1][3]=0) = 1。
此时dp[2]行:[1, 1, 0]
i=3 (A[3]=2):
maxv = 0- j=1 (B[1]=3):
A[3] != B[1]->dp[3][1] = dp[2][1] = 1B[1]=3与A[3]=2:B[j] < A[i]? 3<2? False。
- j=2 (B[2]=1):
A[3] != B[2]->dp[3][2] = dp[2][2] = 1B[2]=1与A[3]=2:B[j] < A[i]? 1<2? True。maxv = max(0, dp[2][2]=1) = 1。
- j=3 (B[3]=2):
A[3] == B[3]!此时maxv = 1(来自B[2]=1,且dp[2][2]=1) ->dp[3][3] = maxv + 1 = 1 + 1 = 2(序列[1, 2],它来自A[1],A[3]和B[2],B[3])B[3]=2与A[3]=2:B[j] < A[i]? 2<2? False。
此时dp[3]行:[1, 1, 2]
最终答案 ans = max(dp[i][j]) = 2。最长公共上升子序列为 [1, 2],长度是 2。
第六步:核心代码框架(优化后)
int lengthOfLCIS(vector<int>& A, vector<int>& B) {
int n = A.size(), m = B.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
int ans = 0;
for (int i = 1; i <= n; i++) {
int maxv = 0; // 维护当前行(i)下,B[1..j-1]中,所有B[k]<A[i]的dp[i-1][k]的最大值
for (int j = 1; j <= m; j++) {
// 先继承“不选A[i]”的情况
dp[i][j] = dp[i-1][j];
// 如果找到一个公共元素,并且可以接在某个递增序列后面
if (A[i-1] == B[j-1]) { // 注意代码中索引从0开始,所以用i-1, j-1
dp[i][j] = max(dp[i][j], maxv + 1);
ans = max(ans, dp[i][j]);
}
// 更新maxv:为下一个j(或者当前i,下一个相等的B[j])做准备
if (B[j-1] < A[i-1]) {
maxv = max(maxv, dp[i-1][j]);
}
}
}
return ans;
}
总结:
最长公共上升子序列 问题的精髓在于 状态定义的强化。通过将状态定义为“以特定元素结尾”,我们成功地将“公共”和“递增”两个约束条件融合到了一个状态里。而利用变量 maxv 进行优化,则是将寻找“前驱最佳状态”的过程从 O(m) 降到了 O(1),是动态规划中一种常见的 决策集合只增不减时的最大值维护 技巧。