📅  最后修改于: 2023-12-03 15:26:28.341000             🧑  作者: Mango
最长公共递增子序列(Longest Common Increasing Subsequence,简称LCIS)是一类动态规划问题,它是最长公共子序列(LCS)和最长递增子序列(LIS)的结合体。
在两个序列中,同一个元素可以在结果序列中出现多次,只要在原序列中它们的出现顺序相同即可。例如,对于序列 $A={3, 1, 4, 2, 8, 5, 6}$ 和 $B={6, 5, 8, 3, 4, 1, 2}$,它们的最长公共递增子序列是 ${3, 4, 5, 6}$(或者 ${1, 4, 5, 6}$、${1, 4, 5, 2}$ 等等)。
LCIS 问题较为复杂,其求解方法也非常多样。本文主要介绍两种动态规划的解法:时间复杂度为 $O(n^2)$ 的朴素算法和时间复杂度为 $O(n \log n)$ 的线段树算法。
朴素算法的思路比较简单,类似于 LCS 的动态规划方式,但是需要考虑到在计算过程中需要记录每个数在两个序列中出现的位置,以便在最终生成的 LCIS 中确定每个数的位置。
具体来说,我们可以定义 $f_{i,j}$ 表示 $A_{1...i}$ 和 $B_{1...j}$ 的最长公共递增子序列长度,$p_{i,j}$ 表示 $f_{i,j}$ 最后一个数在 $A_{1...i}$ 中的位置。则状态转移方程为:
$$ f_{i,j} = \begin{cases} 0, &i=0 \text{ or } j=0; \ f_{i-1,j-1}+1, &A_i = B_j, p_{i,j}=p_{i-1,j-1}; \ \max(f_{i,j-1}, f_{i-1,j}), &A_i\ne B_j. \end{cases} $$
这里值得注意的是,如果有多个 $f_{i,j}$ 取到最大值,则可以任选一个进行连续求解并记录答案。
实现细节:
时间复杂度为 $O(n^2)$。
基于线段树的 LCIS 算法在时间复杂度上实现了较大的优化,能够达到 $O(n\log n)$ 的级别。以下的讲解会涉及到一些基本的线段树操作,如果对线段树还不熟悉的读者可以参考其他资料进行学习。
首先,定义 Logest Common Increasing Subsequence LCS$(B,A)$ 表示 $B$ 中的一个公共子序列 $C$,使得 $A$ 中的 $C$ 是递增的。即:
$$ \mathrm{LCS}(B,A) = {\mathbf{c}1,\mathbf{c}2,\cdots,\mathbf{c}{\mathrm{len}(C)}}, B{\mathbf{c}1}<B{\mathbf{c}2}<\cdots<B{\mathbf{c}{\mathrm{len}(C)}}; A{\mathbf{c}1}<A{\mathbf{c}2}<\cdots<A{\mathbf{c}_{\mathrm{len}(C)}}. $$
其次,对于 $B$ 的每个元素,它可能出现在 $C$ 中,也可能不出现。我们用 $C[i]$ 表示到目前为止子序列中已经选择的 $C$ 中长度的最大值。
然后,我们就可以将计算 LCIS 的问题转换为了对 $C$ 序列的维护。对于每个 $B[j]$,我们可以在 $C[1..j-1]$ 的前缀中查询不大于 $B[j]$ 的最大值 $k$,然后将 $k+1$ 作为 $C[j]$ 的值。这个查询可以使用线段树优化掉,进而实现 $O(n \log n)$ 的复杂度。具体来说,我们使用一棵线段树维护 $C$ 序列,每个节点维护的是前缀的最值和其出现位置。在插入某个新的 $B[j]$ 时,对对应的区间 $[1,j-1]$ 进行查询,找到最大的 $i$ 使得 $B[i]\le B[j]$,然后使用 $C[i]$ 更新 $C[j]$ 的值。注意到线段树是维护的前缀最值,因此需在查询时从右向左遍历,直到找到第一个不小于 $B[j]$ 的位置为止。
实现细节:
时间复杂度为 $O(n \log n)$。
下面是基于朴素算法的代码实现:
const int MAXN = 1e3 + 5;
int a[MAXN], b[MAXN];
int f[MAXN][MAXN], p[MAXN][MAXN];
vector<int> ans;
void lcis(int n, int m) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (a[i] == b[j]) {
f[i][j] = f[i-1][j-1] + 1;
p[i][j] = p[i-1][j-1];
} else if (f[i-1][j] >= f[i][j-1]) {
f[i][j] = f[i-1][j];
p[i][j] = p[i-1][j];
} else {
f[i][j] = f[i][j-1];
p[i][j] = p[i][j-1];
}
}
}
int i = n, j = m;
while (i > 0 && j > 0) {
if (a[i] == b[j] && p[i][j] == p[i-1][j-1]) ans.push_back(a[i]);
if (f[i][j] == f[i-1][j]) i--;
else if (f[i][j] == f[i][j-1]) j--;
else {i--; j--;}
}
reverse(ans.begin(), ans.end());
}
int main() {
int n, m; cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) cin >> b[i];
lcis(n, m);
for (int x: ans) cout << x << " ";
cout << endl;
return 0;
}
下面是基于线段树的代码实现:
const int MAXN = 1e3 + 5;
const int INF = 0x3f3f3f3f;
int a[MAXN], b[MAXN];
int C[MAXN];
struct Node { int v, p; } tr[MAXN << 2];
void pushup(int u) {
if (tr[u << 1].v >= tr[u << 1 | 1].v) tr[u] = tr[u << 1];
else tr[u] = tr[u << 1 | 1];
}
void modify(int u, int l, int r, int p, Node v) {
if (l == r) {
tr[u] = max(tr[u], v, [](const Node &x, const Node &y) {return x.v < y.v;});
return;
}
int mid = (l + r) >> 1;
if (p <= mid) modify(u << 1, l, mid, p, v);
else modify(u << 1 | 1, mid + 1, r, p, v);
pushup(u);
}
Node query(int u, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return tr[u];
Node res = {0, INF};
int mid = (l + r) >> 1;
if (ql <= mid) res = query(u << 1, l, mid, ql, qr);
if (qr > mid) res = max(res, query(u << 1 | 1, mid + 1, r, ql, qr), [](const Node &x, const Node &y) {return x.v < y.v;});
return res;
}
int lcis(int n, int m) {
memset(tr, 0xcf, sizeof tr);
memset(C, 0, sizeof C);
C[1] = 1;
modify(1, 1, m, 1, {1, 0});
for (int i = 2; i <= m; i++) {
Node t = query(1, 1, m, 1, i-1);
int j = t.p;
if (b[j] <= b[i]) C[i] = t.v + 1;
modify(1, 1, m, i, {C[i], i});
}
int ans = 0;
for (int i = 1; i <= m; i++) ans = max(ans, C[i]);
return ans;
}
int main() {
int n, m; cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) cin >> b[i];
cout << lcis(n, m) << endl;
return 0;
}