题单 · 习题解析

洛谷 P1439 两个排列的最长公共子序列

★★★ 那个「一字之差」的坑在这道题上是题面保证的精确 0;⚠⚠ 而「造一档违反题面的数据」这一个动作,同时是那个 0 的自检和「命门」的称重

原题:洛谷 P1439出自 第 22 章 线性 DP:最长上升子序列 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1439,日期见页头。两边不一致时信原站。

题目描述

给出 1, 2, …, n两个排列 P₁P₂,求它们的最长公共子序列。

输入格式

第一行是一个数 n。接下来两行,每行为 n 个数,为自然数 1, 2, …, n 的一个排列。

输出格式

一个数,即最长公共子序列的长度。

数据规模与约定

  • 对于 50% 的数据,n ≤ 10³
  • 对于 100% 的数据,n ≤ 10⁵

输入输出样例

输入

5
3 2 1 4 5
1 2 3 4 5

输出

3

3 2 1 4 51 2 3 4 5 的最长公共子序列长度是 3(比如 3 4 52 4 51 4 5)。

★ 这一页是那个「一字之差」的坑的第三个点

同一轮的 B3637P1020 量的是同一个坑 (lower_bound 还是 upper_bound),结论是「抓获率由 n / 值域 的比值决定」:

比值 那个坑被抓
B3637(照题面随机) 0.005 2 / 300
P1020(题面顶格) 2.0 几乎必抓
这道题 —— 精确的 0

而这一页那个 0 不是「数据碰巧」,是题面保证的 —— 第 ④ 步会说明为什么, 以及怎么证明它不是空壳

1第一反应:照 LCS 的定义写 DP

p1439Lcs.cpp第一版:O(n²) 的 LCS DP
// P1439 的第一反应:**照 LCS 的定义写 O(n²) DP** —— 对,但这道题上根本开不下
//
// `f[i][j]` = P1 前 i 个和 P2 前 j 个的 LCS 长度,
// `f[i][j] = (a[i] == b[j]) ? f[i-1][j-1] + 1 : max(f[i-1][j], f[i][j-1])`。
//
// ⚠⚠ 题面 `n ≤ 10⁵`:
// · 时间 `10¹⁰` —— 一秒钟的时限下差四个数量级;
// · **空间更早爆**:`10⁵ × 10⁵` 个 int = **40 GB**(滚动数组能压到 800 KB,但时间还是 10¹⁰)。
// ⇒ **这一版在这道题上不是「慢一点」,是连数组都开不出来。**
//
// ★ 但它是这一页最可靠的**参照物**(小 n 上用),因为它是把题意照抄一遍,
// 一点「转成 LIS」的巧劲都不用。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> a(n), b(n);
for (int& x : a) cin >> x;
for (int& x : b) cin >> x;
vector<vector<int>> f(n + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
f[i][j] = (a[i - 1] == b[j - 1]) ? f[i - 1][j - 1] + 1
: max(f[i - 1][j], f[i][j - 1]);
cout << f[n][n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

f[i][j] = (a[i] == b[j]) ? f[i-1][j-1] + 1 : max(f[i-1][j], f[i][j-1]) —— 教科书写法,绝对不会错。

⚠ 而在这道题上,先撑不住的是空间

题面 n ≤ 10⁵

时间 10¹⁰ 次 —— 一秒的时限下差四个数量级
整张 f 表(int) 37 GB
就算滚动成两行 781 KB(空间过了)—— 但时间那一关还是过不去

⇒ 这一版不是「慢一点」,是连数组都开不出来。 ★ 但它是这一页最可靠的参照物(小 n 上用):它把题意照抄一遍,一点巧劲都不用。

2★ 关键一步:把 LCS 转成 LIS

转化只有两行

pos[v] = 数 vP₁ 里的下标。把 P₂ 里的每个数换成它的 pos,得到序列 b

P₁P₂ 的公共子序列 ⇔ b 的上升子序列。

  • :一个公共子序列在 P₂ 里按顺序出现,而它在 P₁ 里的下标也必须递增 ⇒ 换成 b 之后是上升的;
  • b 的一个上升子序列,对应的那些数在两个排列里都是按顺序出现的 ⇒ 它是公共子序列。

⇒ 答案 = b 的最长上升子序列长度O(n log n)

拿样例走一遍(P₁ = 3 2 1 4 5P₂ = 1 2 3 4 5):

    pos:  3->0   2->1   1->2   4->3   5->4
    P2 = 1 2 3 4 5  换成  b = 2 1 0 3 4
    b 的最长上升子序列: 2 3 4  (长度 3)   <- 就是答案
p1439.cpp★ 这一版就能 AC
// P1439 两个排列的最长公共子序列 —— ★ 这一版就能 AC
//
// 题意:给出 1..n 的**两个排列** P1、P2,求它们的最长公共子序列长度(`n ≤ 10⁵`)。
//
// ★ 关键一步:**把 LCS 转成 LIS**。
// 记 `pos[v]` = 数 v 在 P1 里的下标;把 P2 里的每个数换成它的 `pos`,得到序列 b。
// 那么「P1 和 P2 的公共子序列」⇔「b 的上升子序列」:
// · 一个公共子序列,在 P2 里按顺序出现,在 P1 里的下标也必须递增 ⇒ b 上升;
// · 反过来,b 的一个上升子序列,对应的那些数在两个排列里都是按顺序出现的。
// ⇒ 答案 = **b 的最长上升子序列长度**,`O(n log n)`。
//
// ⚠⚠ 这一步**完全依赖「两个都是排列」**:`pos` 要有定义,每个数得只出现一次。
// 题面那句「1, 2, …, n 的两个排列」是**命门**,不是背景(页面第 ⑤ 步把它称了重量)。
//
// ★★★ 而这道题上有一件事很值得注意:转化出来的 b **一定是 0..n-1 的一个排列** ——
// **没有任何重复元素**。⇒ [第 22 章第 ⑩ 步](/ch/22-lis/)那个「`lower_bound` 还是
// `upper_bound`」的坑,在这道题上是**结构性的 0**:两种写法答案永远相同。
// ⇒ 和同一轮的 [B3637](/sol/b3637/)(比值 0.005,300 轮抓 2 次)、
// [P1020](/sol/p1020/)(比值 2.0,几乎必抓)连成三个点,而这一页这个 0 是**题面保证的**。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> pos(n + 1, 0);
for (int i = 0; i < n; i++) { int v; cin >> v; pos[v] = i; } // v 在 P1 里的下标
vector<int> tails;
for (int i = 0; i < n; i++) {
int v;
cin >> v;
int x = pos[v]; // 换成下标
auto it = lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 组(两个排列,n ≤ 60
「转成 LIS」 vs O(n²) LCS DP 不一致 0 组

3★ 顺带一条:反过来映射也是对的

p1439Rev.cpp另一种写法:反过来映射

P₂ 的位置去换 P₁ 里的数,答案一样 —— 因为「最长公共子序列」这件事对两个序列是对称的

300 组
反过来映射 vs 正着映射 不一致 0 组

★ 这是「写反了不一定是 bug」的又一次: 有些「方向」是这道题的对称轴,反过来什么也不会发生。 ⚠ 而这仍然要跑一遍才敢说 —— 上一道 P1020 的两问, 方向写反就是实打实的丢分。

4★★★ 那个「一字之差」的坑,在这道题上是结构性的 0

p1439Upper.cpp把 lower_bound 换成 upper_bound
// P1439 「错法」:把 `lower_bound` 写成 `upper_bound`
//
// ★★★ 而在这道题上它**根本不是错法** —— 两种写法的答案**永远相同**。
// 原因是结构性的:转化出来的序列 b 是 `pos` 的一个排列,**每个下标只出现一次**,
// 而[第 22 章第 ⑩ 步](/ch/22-lis/)那个判据说得很清楚:
// **没有重复元素时,严格上升和不下降是同一件事。**
//
// ⇒ 这一份留在这儿,是为了把那个坑的抓获率量成**精确的 0**(页面第 ④ 步),
// 而且这个 0 **是题面保证的,不是数据碰巧** —— 和
// [B3637](/sol/b3637/)(300 轮抓 2 次)、[P1020](/sol/p1020/)(几乎必抓)凑成三个点。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> pos(n + 1, 0);
for (int i = 0; i < n; i++) { int v; cin >> v; pos[v] = i; }
vector<int> tails;
for (int i = 0; i < n; i++) {
int v;
cin >> v;
int x = pos[v];
auto it = upper_bound(tails.begin(), tails.end(), x); // ← 一字之差
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 组(两个排列)
upper_bound 版 vs lower_bound 不同 0 组
★ 为什么必然是 0 —— 一句话就能证

转化出来的 bpos 的一个排列P₂ 里每个数只出现一次,pos 又是单射 ⇒ b 里没有任何重复元素

第 22 章第 ⑩ 步那个判据说得很清楚: 没有重复元素时,严格上升和不下降是同一件事。

⇒ 这个 0 是题面那句「两个排列」保证出来的,加多少轮、换什么随机种子都不会变。

5⚠⚠ 而「精确的 0」要配一道自检 —— 这次自检和「命门判据」是同一个动作

⚠ 「一个反例都没有」和「这段代码根本没在跑」,输出上一模一样

本书这一轮已经为两个「精确的 0」补过自检 (P2240>= 数出 52160、P1094 拿已知错法验搜索)。 这一页的自检更省事 —— 把题面那句约束放开就行

把「两个排列」放开,改成「两个可能有重复的序列」,同一批代码再跑 300 轮:

放开「都是排列」之后(300 轮)
upper_boundlower_bound 不同的轮数 205
「转成 LIS」和真正的 LCS 不同的轮数 194
★★ 一个动作,两个结论
  • 上面那一栏是自检:同一段代码在别的输入上当场就分得出来 ⇒ 第 ④ 步那个 0 不是空壳,是真的;
  • 下面那一栏是「命门」的重量:一旦不是排列,pos[v] 就没有定义了(同一个数出现两次, 只能记住一个位置)—— 「把 LCS 转成 LIS」这一步整个塌掉,194 / 300 轮直接算错。

⇒ 题面那句「1, 2, …, n 的两个排列」不是背景描述,是命门第 12 章 P1226 那套分法;P1094 那页说的 「违反它之后塌掉的不是数据,是算法本身」,这一页是第二次)。

★★ 而这一页多出一层:「造一档违反题面的数据」这一个动作, 同时干了「给精确的 0 做自检」和「称出命门的重量」两件事。

6度量程序和生成器

p1439Count.cpp度量程序
// P1439 的度量程序 —— 这一页所有数字都出自这一份。
//
// `./p1439Count` 人看的版本
// `./p1439Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用
//
// 五段:
// ① ★ 「转成 LIS」≡ 照定义写的 `O(n²)` LCS DP;
// ② ★★★ 那个「一字之差」的坑在这道题上是**精确的 0** —— 而且是**结构性**的
// (转化出来的一定是排列,没有重复元素);
// ③ ⚠⚠ 而这个 0 **配了自检**:把题面那句「两个排列」放开(允许重复),
// 同一个 `upper_bound` 当场就和 `lower_bound` 不同 ⇒ 这段代码是活的;
// ④ ★★ 顺手称一称那句约束的重量:不是排列时,「转成 LIS」这一步**整个塌掉**;
// ⑤ ★ 反过来映射也是对的(LCS 对两个序列是对称的);
// ⑥ ★ `O(n²)` LCS 在这道题上是什么概念(时间和**空间**各算一遍)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
static bool CSV = false;
static void row(const char* key, const vector<ll>& v) {
if (!CSV) return;
printf("%s", key);
for (ll x : v) printf(",%lld", x);
printf("\n");
}
/** 转成 LIS。loose=true 时用 upper_bound(那个「一字之差」的写法) */
static int byLis(const vector<int>& a, const vector<int>& b, bool loose = false) {
int n = a.size();
int mx = 0;
for (int v : a) mx = max(mx, v);
for (int v : b) mx = max(mx, v);
vector<int> pos(mx + 1, -1);
for (int i = 0; i < n; i++) pos[a[i]] = i; // ⚠ 有重复时这里只留最后一次
vector<int> t;
for (int v : b) {
if (pos[v] < 0) continue;
int x = pos[v];
auto it = loose ? upper_bound(t.begin(), t.end(), x) : lower_bound(t.begin(), t.end(), x);
if (it == t.end()) t.push_back(x);
else *it = x;
}
return (int)t.size();
}
/** 反过来映射(拿 b 的位置去查 a) */
static int byLisRev(const vector<int>& a, const vector<int>& b) {
return byLis(b, a, false);
}
/** 参照物:照定义写的 O(n²) LCS */
static int byLcs(const vector<int>& a, const vector<int>& b) {
int n = a.size(), m = b.size();
vector<vector<int>> f(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
f[i][j] = (a[i - 1] == b[j - 1]) ? f[i - 1][j - 1] + 1
: max(f[i - 1][j], f[i][j - 1]);
return f[n][m];
}
static void genPerm(mt19937& rng, int n, vector<int>& a, vector<int>& b) {
a.resize(n); b.resize(n);
iota(a.begin(), a.end(), 1);
iota(b.begin(), b.end(), 1);
shuffle(a.begin(), a.end(), rng);
shuffle(b.begin(), b.end(), rng);
}
static void genDup(mt19937& rng, int n, vector<int>& a, vector<int>& b) {
a.resize(n); b.resize(n);
for (int i = 0; i < n; i++) a[i] = (int)(rng() % (unsigned)n) + 1;
for (int i = 0; i < n; i++) b[i] = (int)(rng() % (unsigned)n) + 1;
}
int main(int argc, char** argv) {
CSV = argc > 1 && string(argv[1]) == "csv";
/* ① 转成 LIS ≡ O(n²) LCS,② 那个坑是精确的 0,⑤ 反过来映射也对 */
{
mt19937 rng(20260829u);
int groups = 0, badLcs = 0, badUpper = 0, badRev = 0;
for (int rep = 0; rep < 300; rep++, groups++) {
vector<int> a, b;
genPerm(rng, (int)(rng() % 60) + 1, a, b);
int ok = byLis(a, b);
if (byLcs(a, b) != ok) badLcs++;
if (byLis(a, b, true) != ok) badUpper++;
if (byLisRev(a, b) != ok) badRev++;
}
if (!CSV) printf("① %d 组(两个排列,n ≤ 60):转成 LIS vs O(n²) LCS 不一致 %d 组\n"
"② 那个「一字之差」的坑(upper_bound):不同 %d 组 —— **精确的 0**\n"
"⑤ 反过来映射:不同 %d 组(LCS 对两个序列是对称的)\n",
groups, badLcs, badUpper, badRev);
row("perm", {groups, badLcs, badUpper, badRev});
}
/* ③ ⚠⚠ 自检:把那个 0 造回来 —— 放开「都是排列」,upper_bound 当场就不同了 */
{
mt19937 rng(1439u);
int rounds = 300, diffUpper = 0, badTrans = 0;
for (int r = 0; r < rounds; r++) {
vector<int> a, b;
genDup(rng, (int)(rng() % 40) + 2, a, b); // ← 违反题面:允许重复
if (byLis(a, b, true) != byLis(a, b)) diffUpper++;
if (byLis(a, b) != byLcs(a, b)) badTrans++; // ④ 转化本身塌了没有
}
if (!CSV) printf("③ ⚠ 自检(放开「都是排列」,%d 轮):upper_bound 和 lower_bound 不同 %d 轮\n"
" ⇒ 上面那个 0 不是空壳,是**题面保证**出来的\n"
"④ 同一档:「转成 LIS」和真正的 LCS 不同 %d 轮 —— "
"**不是排列,这个转化整个塌掉**\n", rounds, diffUpper, badTrans);
row("selfcheck", {rounds, diffUpper, badTrans});
}
/* ⑥ O(n²) LCS 在这道题上是什么概念 */
{
ll n = 100000;
ll ops = n * n; // 10^10
ll bytesFull = n * n * 4; // int 表
ll gb = bytesFull / 1024 / 1024 / 1024;
ll rollingKB = 2 * (n + 1) * 4 / 1024; // 滚动数组
if (!CSV) printf("⑥ 顶格 n = 10^5:O(n²) LCS 要算 %lld 次(10^10 量级),"
"整表 %lld GB;就算滚动成两行也只有 %lld KB —— **时间那一关还是过不去**\n",
ops, gb, rollingKB);
row("scale", {ops, gb, rollingKB});
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1439Gen.cpp数据生成器
// P1439 对拍生成器:`./p1439Gen <seed> [n 上限] [是否允许重复]`
// 默认 `n ≤ 60`、**严格照题面造两个排列**。
//
// ★ 第三个参数(默认 0)是给页面第 ⑤ 步用的**违反题面**的一档:
// 传 1 时不再造排列,而是随机造两个可能有重复的序列 ——
// 用来称一称题面那句「1, 2, …, n 的两个**排列**」的重量
// (⇒ 一旦不是排列,「把 LCS 转成 LIS」这一步整个塌掉)。
//
// ⚠ 默认 n 压在 60,是因为参照物是 `O(n²)` 的 LCS DP(它要开 n² 的表)。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1;
int nHi = argc > 2 ? atoi(argv[2]) : 60;
int dup = argc > 3 ? atoi(argv[3]) : 0;
nHi = max(1, min(2000, nHi));
mt19937 rng(seed * 2654435761u + 1439u);
int n = (int)(rng() % (unsigned)nHi) + 1;
printf("%d\n", n);
if (!dup) {
vector<int> a(n), b(n);
iota(a.begin(), a.end(), 1);
iota(b.begin(), b.end(), 1);
shuffle(a.begin(), a.end(), rng);
shuffle(b.begin(), b.end(), rng);
for (int i = 0; i < n; i++) printf("%d%c", a[i], i + 1 == n ? '\n' : ' ');
for (int i = 0; i < n; i++) printf("%d%c", b[i], i + 1 == n ? '\n' : ' ');
} else {
for (int t = 0; t < 2; t++)
for (int i = 0; i < n; i++)
printf("%u%c", (unsigned)(rng() % (unsigned)n) + 1, i + 1 == n ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 生成器的第三个参数就是那个「违反题面」的开关(默认 0 = 严格照题面造两个排列)。

7一页纸

关键的一步 把 LCS 转成 LISP₂ 里每个数换成它在 P₁ 里的下标,求最长上升子序列
哪一版能 AC p1439.cppO(n log n)
第一反应为什么不行 O(n²) LCS 在 n = 10⁵ 上要 10¹⁰ 次,整表 37 GB(滚动能压到 781 KB,但时间过不去)
★★★ 这一页的位置 那个「一字之差」的坑的第三个点
B3637 比值 0.005 抓 2/300 → P1020 比值 2.0 几乎必抓 → 这里是精确的 0
⇒ 而这个 0 是题面保证的(转化出来一定是排列,没有重复元素)
⚠⚠ 自检 放开「都是排列」,upperlower 当场不同 205 / 300 ⇒ 那个 0 不是空壳
★★ 一个动作两个结论 同一档「违反题面」的数据,既是自检,也称出了命门的重量:
不是排列时「转成 LIS」整个塌掉(194 / 300 轮算错)
★ 顺带 反过来映射也对(LCS 对两个序列对称,300 组不一致 0 组)—— 写反不一定是 bug