最长公共子序列
最长公共子序列
题意
给定长度分别为 、 的两个整数序列 、,求它们的最长公共子序列的长度。
与上一题不同,子序列可以不连续,但元素的先后顺序不能改变。
例如:
a:1 2 3
b:1 9 2 9 3
最长公共子序列为 1 2 3,长度为 ;而最长相同子段的长度只有 。
状态与转移
因为允许跳过元素,不必要求选出的序列以当前位置结尾。这次改用两个前缀中的最优值定义状态。
设 表示 的前 个数与 的前 个数的最长公共子序列长度。
考虑两个前缀的最后一个元素:
- 若 ,可以将它们作为最后一对匹配,接在前面的最长公共子序列后面,得到 。
- 若 ,这两个位置不可能同时被选中,至少需要跳过一个:跳过 ,得到 ;跳过 ,得到 ,取较大值。
初始化 ,因为其中一个序列为空时,公共子序列长度为 。答案直接取 。
参考代码
输入第一行为 ,接下来两行分别为序列 、;输出最长公共子序列的长度。约定 ,。
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 2005;
int n, m, a[N], b[N], dp[N][N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; ++i) cin >> a[i];
for (int j = 1; j <= m; ++j) cin >> b[j];
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (a[i] == b[j]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
cout << dp[n][m] << '\n';
return 0;
}
全局数组默认初始化为 。时间复杂度 ,空间复杂度 。
位运算优化
位序约定:图中按二进制顺序书写
为了避免在序列下标和二进制位之间反复转换,从这里开始,所有位集和差分的图都按二进制的书写顺序展示:
左边是高位 右边是低位
a[n] a[n-1] ... a[2] a[1]
也就是说,图中的序列是倒着写的,最右边的有效位对应 a[1]。代码中第 j 位对应 a[j],第 0 位空着不用。后面的图省略这个恒为 0 的空位,因此:
d << 1把a[j]的位移到a[j+1],图上向左移动;- “最早出现的位置”是下标最小的位置,在图上表现为候选中的最右侧位置;
- 二进制减法保留的是每段最低位的
1,正好就是每段最早出现的位置。
本节的 DP 行、差分和位运算示例均按这个方向展示。
1. 优化目标:从逐个状态转移到整行转移
为与代码一致,本节固定序列 ,依次处理 。记 为 的前 个元素与 的前 个元素的 LCS 长度。
普通做法枚举两个序列的前缀长度 ,逐个计算 ,时间复杂度为 。当 都接近 时,需要计算约 亿个状态,无法满足本题时限。
固定 ,观察同一行相邻两列: 相比 只多考虑了 一个元素,答案不会减小,且最多增加 。因此:
这使我们可以用一个 数组保存当前行的差分:
接下来仍依次枚举 ,但只维护当前行的差分数组 。我们的目标是:已知第 行的差分,在处理 时,通过位运算批量算出第 行的差分。
第 i 行的差分 d + b[i+1] 在 a 中的所有匹配位置
↓ 位运算批量更新
第 i+1 行的差分
初始 ,差分全为 。依次处理 ,每轮更新一次差分。
由于 ,差分之和就是这一行最后一列的值。因此,处理完 后, 中 的个数就是答案。
2. 用同一个例子观察相邻两行
两个序列为(这里仅列出原始序列,后面的图统一倒序展示):
a:3 2 3 3 5 9 3 3
b:2 5 3
假设已经处理完 的前两个元素 2 5,也就是 。当前 DP 行及其差分为:
a 的位置(高位到低位): 8 7 6 5 4 3 2 1
a[j]: 3 3 9 5 3 3 2 3
dp[2][j]: 2 2 2 2 1 1 1 0
差分 d: 0 0 0 1 0 0 1 0
这里,位置 的 表示 LCS 长度第一次达到 ;位置 的 表示 LCS 长度第一次达到 。
下一轮处理 ,计算考虑前缀 2 5 3 的第 行:
dp[3][j]: 3 3 2 2 2 2 1 1
新的差分: 0 1 0 0 0 1 0 1
这一轮发生了三处变化:
- 长度 :原来最早在位置 出现,现在位置 的
3就能与 匹配,第一个 从位置 移到位置 。 - 长度 :原来最早在位置 出现,现在前缀
3 2 3已经能匹配2 3,第二个 从位置 移到位置 。 - 长度 :现在能匹配
2 5 3,在位置 新增一个 ,答案由 增加到 。
下面研究怎样直接更新差分。
3. 差分的变化:每段只保留最右侧的候选
按下标从小到大看,旧差分中的每个 是一段的末端,最后一个 后面的 构成最后一段。为了遵守位序约定,下面把这些段整体倒序显示:
位置(高位到低位): 8 7 6 | 5 4 3 | 2 1
旧差分 d: 0 0 0 | 1 0 0 | 1 0
这里的分段仍按下标区间理解,只是图中倒序书写。因此,旧差分中的 出现在每段的左端;它表示某种长度最早出现的位置。处理当前元素后,如果本段有更小下标的匹配位置,就把这个 移到候选中最右侧的位置;否则保留原来的 。
本例处理 :
- 左侧全 段
[6,8]在图中写成8 7 6:位置 都匹配,选择下标最小的 ,将000改为010。因此最长长度增加 。 - 中间段
[3,5]在图中写成5 4 3:位置 都匹配,选择下标最小的 ,将100改为001。 - 右侧段
[1,2]在图中写成2 1:位置 匹配,将10改为01。
第二段为什么这样变化?旧行中,长度 最早在位置 出现。把当前的 接在旧方案 2 后面,从位置 起就能匹配 2 3,无需等到旧方案 2 5 的结束位置 。
如果处理 后,最长公共子序列的长度从 增加到 ,那么旧差分最后一个 后面的连续 中,一定恰有一个变成 。 这里“后面”指下标更大的方向,在倒序图中位于最后一个 的左侧。新的匹配必须出现在这个连续 段中,才能得到长度 ;选择其中下标最小、也就是图中最右侧的匹配位置,作为新增的 。如果没有匹配,最长长度就不会增加。
按下标顺序看,区间 [1,2] 和 [3,5] 分别调整长度 和长度 最早出现的位置,末尾的全 段 [6,8] 负责尝试得到长度 。这些变化都根据旧行计算;本轮前移的 不能再次用于匹配同一个 。
因此,只需把“旧差分中的 ”和“当前元素的匹配位置”都标成候选,然后每段保留下标最小、也就是图中最右侧的候选。
预处理位集 pos[v],用 标记 中所有等于 的位置。本例为:
a(高位到低位): 3 3 9 | 5 3 3 | 2 3
位置: 8 7 6 | 5 4 3 | 2 1
旧差分 d: 0 0 0 | 1 0 0 | 1 0
pos[3]: 1 1 0 | 0 1 1 | 1 0
候选 x: 1 1 0 | 1 1 1 | 1 1
新差分: 0 1 0 | 0 0 1 | 0 1
这里的“候选”指这一段的新 可以出现的位置。原有的 也要保留为候选,因为新增元素后,旧方案仍然有效;找不到更早的匹配位置时,就保留原来的 。
候选位集通过按位或得到:
x = d | pos[b[i+1]]
pos 只需遍历一次 来建立。之后每轮直接取出当前元素的匹配位集,无需再逐个比较所有 。
4. 用二进制减法找到每段最低位的 1
按照前面的约定,所有二进制行都从高位到低位书写,右侧对应较小的下标。下面不再把同一行翻来翻去。
先看单独一段。一个非零二进制数减去 时,最低的 变为 ,它右边的 都变为 ,更高位不变:
x: 11100
x-1: 11011
x ^ (x-1): 00111
x & (x^(x-1)): 00100
先异或标出发生变化的位置,再与上原来的 x,就只保留了最低的那个 。在图中它是候选中的最右侧位置,也就是下标最小的候选。
现在有多段,就在每段起点都放一个 ,作为减数。起点是位置 ,以及旧差分中每个 的后一位:
位置(高位到低位): 8 7 6 | 5 4 3 | 2 1
旧差分 d: 0 0 0 | 1 0 0 | 1 0
起点 y: 0 0 1 | 0 0 1 | 0 1
把旧差分左移一位,再将第 位设为 (第 位空着不用),即可得到 y。在图中,左移就是向左移动:从 移到 。
接着计算 z=x-y,相当于每段都减去一个 。除图中最左侧的全 段外,每段最高位都有旧差分留下的 ,足以承担借位,所以不会跨段借位;全 段若没有候选,最终也不会选出任何位置。
本例的完整运算为:
a 的位置(高位到低位): 8 7 6 | 5 4 3 | 2 1
x: 1 1 0 | 1 1 1 | 1 1
y: 0 0 1 | 0 0 1 | 0 1
z=x-y: 1 0 1 | 1 1 0 | 1 0
x ^ z: 0 1 1 | 0 0 1 | 0 1
x & (x^z): 0 1 0 | 0 0 1 | 0 1
得到的新差分为 0 1 0 | 0 0 1 | 0 1,与前面的手算结果一致。
于是,整行更新可以写成下面的位集运算。这里是算法表达式,具体的跨块移位和减法由后面的代码实现:
x = d | pos[b[i+1]]
y = (d << 1) | 2
z = x - y
d = x & (x ^ z)
其中 x & (x ^ z) 也等于 x & ~z,下面代码使用前一种写法。
5. 用 64 位整数分块实现
这里用 unsigned long long 保存位集,每个整数保存 位,直接完成移位、按位或及减法。
第 位对应 a[j],第 位空着不用。块编号从 开始:
第 0 块:第 0~ 63 位
第 1 块:第 64~127 位
第 2 块:第 128~191 位
……
所以预处理匹配位置可以写成:
pos[a[i]][i / 64] |= 1ULL << (i % 64);
i / 64 表示块编号,i % 64 表示块内位置;1ULL << k 生成只有第 位为 的数,|= 将这一位标记为 ,保留已有标记。
代码中的三个数组对应前面的公式:
| 数组 | 含义 |
|---|---|
f |
起初保存旧差分,合并匹配位置后暂存 x,最后更新为新差分 |
g |
每段起点的标记,对应 y |
h |
分块减法的结果,对应 x-y |
每轮依次完成四步:
-
构造
g:将旧f左移。当前块最高位移出后,补到下一块最低位:g[j] |= f[j] << 1; g[j + 1] |= f[j] >> 63;然后
g[0] |= 2,把第 位设为 ,标记第一段的起点。因为第 位不用,这里应写2。 -
构造候选集合:
f[j] |= pos[b[i]][j],把旧差分和匹配位置合并,此时f暂存x。 -
计算减法:从低位块到高位块计算
h[j] = f[j] - g[j] - borrow,其中borrow是上一块传来的借位。减去借位时不够减,或接着减g[j]时不够减,都需要向下一块借位。 -
得到新差分:
f[j] &= f[j] ^ h[j],每段只留下最低位的 。
这些操作按块批量完成,不需要枚举每一段。按实际长度处理时,每轮需要 块;下面代码为了统一边界,固定处理 B = MAXN / 64 + 1 块,转移时间复杂度为 。当 时,每轮 块,共约 万次块更新,每次更新包含固定数量的操作。
6. 参考代码
下面按 编写。pos 记录 中的匹配位置,外层依次处理 b[i],与前面的推导一致。g 每轮清零,因为移位结果使用 |= 合并;h 每个块都会重新赋值,无需清零。
#include <cstdio>
#include <cstring>
using namespace std;
const int MAXN = 70000;
const int B = MAXN / 64 + 1;
int n, m;
int a[MAXN + 5], b[MAXN + 5];
unsigned long long pos[MAXN + 5][B];
unsigned long long f[B + 1], g[B + 1], h[B + 1];
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
for (int i = 1; i <= m; ++i) scanf("%d", &b[i]);
// 第 i 位对应 a[i],第 0 位空着不用
for (int i = 1; i <= n; ++i) pos[a[i]][i / 64] |= 1ULL << (i % 64);
for (int i = 1; i <= m; ++i) {
memset(g, 0, sizeof(g));
// g 保存每段起点:旧差分左移一位,再标记第 1 位
for (int j = 0; j < B; ++j) {
g[j] |= f[j] << 1;
g[j + 1] |= f[j] >> 63;
}
g[0] |= 2;
// f 暂存候选集合 x
for (int j = 0; j < B; ++j) f[j] |= pos[b[i]][j];
// h = x - g,从低位块到高位块处理借位
unsigned long long borrow = 0;
for (int j = 0; j < B; ++j) {
h[j] = f[j] - g[j] - borrow;
borrow = (borrow && f[j] == 0)
|| (f[j] - borrow < g[j]);
}
// 每段只保留最低位的 1,得到新差分
for (int j = 0; j < B; ++j) f[j] &= f[j] ^ h[j];
}
int ans = 0;
for (int j = 0; j < B; ++j) ans += __builtin_popcountll(f[j]);
printf("%d\n", ans);
return 0;
}
与最大相同子段的区别
| 比较项 | 最大相同子段 | 最长公共子序列 |
|---|---|---|
| 连续性 | 必须连续 | 可以不连续 |
| 状态含义 | 以 、 结尾的最优值 | 两个前缀中的最优值 |
| 不匹配时 | 归零 | 跳过一个元素,取较大值 |
| 最终答案 | 所有 的最大值 |
两个排列的最长公共子序列
题目描述
给定两个长度均为 的排列 和 ,每个排列都恰好包含 中的每个整数一次。求它们的最长公共子序列的长度。
子序列是从原序列中删除若干元素(也可以不删除)后得到的序列,保留的元素相对顺序不变,位置可以不连续。公共子序列指同时是 和 的子序列。
输入格式
第一行一个整数 。
第二行 个整数,表示排列 。
第三行 个整数,表示排列 。
输出格式
输出一个整数,表示最长公共子序列的长度。
数据范围
,保证 和 均为 的排列。
样例输入
5
3 2 1 4 5
2 4 1 5 3
样例输出
3
样例说明
2 4 5 是两个排列的一个最长公共子序列,长度为 。
思路:转化为最长上升子序列
普通 LCS 的 做法无法通过。由于每个数只出现一次,可以用 pos[x] 记录 在 中的位置,再将 替换成 pos[a[i]]:
b: 2 4 1 5 3
位置: 1 2 3 4 5
a: 3 2 1 4 5
替换后: 5 1 3 2 4
按 的顺序选择元素,要使它们在 中也保持原顺序,对应的位置就必须严格递增。 因此,替换后序列的最长上升子序列长度,就是原问题的答案。
例如,位置序列中的 1 2 4 对应原数值 2 4 5,答案为 。
沿用二分做法:dp[k] 表示长度为 的上升子序列的最小结尾。每次用 lower_bound 找到第一个 的位置并替换,超出当前长度则令 len++。
时间复杂度 ,空间复杂度 。
参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 100005;
int n, p1[N], p2[N], pos[N], dp[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; ++i) cin >> p1[i];
for (int i = 1; i <= n; ++i) {
cin >> p2[i];
pos[p2[i]] = i;
}
int len = 0;
for (int i = 1; i <= n; ++i) {
int x = pos[p1[i]];
int k = lower_bound(dp + 1, dp + len + 1, x) - dp;
dp[k] = x;
if (k > len) len = k;
}
cout << len << '\n';
return 0;
}