阶段 5 · 动态规划 · 第 22 章普及组 J

线性 DP:最长上升子序列

状态里那五个字「以 i 结尾」是这一章的全部。顺带把第 8 章的二分请回来,把 O(n²) 砍成 O(n log n)。

需要先学:第 21 章 DP 入门:从记忆化到递推例题:最长上升子序列建议用时:110 分钟
这一章练的是「状态怎么设」

第 21 章立了 DP 三件套:状态、转移、边界和顺序。后面三件都是机械活, 真正难的永远是第一件 —— 状态是什么。

这一章就练这个。而且它给出的答案是整个 DP 里复用率最高的一句: 「以 i 结尾」。

它还顺带把第 8 章的二分请回来用一次 —— 你会看到一个很漂亮的事实: 那个用来二分的数组,天然就是单调的,不需要你做任何事。

1一句话问题

给一个长度为 n 的序列,从中按原顺序挑出若干个数(可以不连着), 要求挑出来的这些数严格递增。最多能挑几个?

输入

5
2 5 3 7 1

输出

3

第一行是 n,第二行是那 n 个数。最长的上升子序列是 2 3 7(长度 3)。 ⚠ 程序只输出长度,不输出是哪一条。

⚠ 子序列 ≠ 子串

子串必须连着(5 3 7),子序列只要保持先后顺序就行(2 5 7、2 3 7 都算)。

这两个字是这题的第一个坑,而且中英文都容易混:subsequence(子序列)vs substring(子串)。

2先用手算一遍

2 5 3 7 1 有哪些上升子序列?

  • 2 5 7 ✓ 长度 3
  • 2 3 7 ✓ 长度 3
  • 2 5 7 1? 不行,1 比 7 小
  • 有长度 4 的吗?序列里比 7 大的数一个都没有,1 在最后又最小 —— 没有。

所以答案是 3。

3暴力:2ⁿ 枚举子集

brute.cpp2ⁿ 枚举子集
// 最长上升子序列 —— 2ⁿ 枚举子集(对拍的标准答案)
//
// 题意:给一个长度为 n 的序列,从中**按原顺序**挑出若干个数(可以不连续),
// 要求挑出来的这些数**严格递增**。最多能挑几个?
//
// 例:2 5 3 7 1 → 最多挑 3 个(2 5 7,或者 2 3 7)
//
// ⚠ 「子序列」不是「子串」:子串必须连着,子序列只要保持先后顺序就行。
// 这两个字是很多人做这题的第一个坑。
//
// 这份代码没有任何想法:n 个数每个「选或不选」,2ⁿ 种组合全试一遍
// (二进制枚举,接第 3 章),检查选出来的是不是严格递增,取最长的。
//
// 它慢得离谱(n = 25 就要跑几秒),但它**绝对不会错** ——
// 后面两种 DP 写法都要拿它来验。
//
// 输入:第一行 n,第二行 n 个整数
// 输出:最长上升子序列的长度
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
int best = 0;
for (int mask = 0; mask < (1 << n); mask++) {
int cnt = 0;
long long last = LLONG_MIN;
bool ok = true;
for (int i = 0; i < n && ok; i++) {
if (!(mask >> i & 1)) continue;
if (a[i] <= last) ok = false; // 严格递增:等于也不行
else { last = a[i]; cnt++; }
}
if (ok) best = max(best, cnt);
}
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

每个数「选或不选」,2ⁿ 种组合全试一遍(第 3 章的二进制枚举), 检查是不是严格递增,取最长的。没有任何想法,但绝对不会错 —— 后面三种写法都要拿它验。

4★ 关键一步(一):状态里那五个字

★ 关键的一步

先试个「自然」的状态:

f[i] = 前 i 个数里最长上升子序列的长度

写转移的时候你会立刻卡住:a[i] 能不能接到那条子序列后面? 不知道 —— 因为你不知道那条子序列的结尾是多少。

状态里缺了「接下来还能不能接」所需要的信息。补救办法就是把它塞进状态里:

f[i] = 以 a[i] 结尾的最长上升子序列长度

加了「以 a[i] 结尾」这五个字,结尾是谁就确定了(就是 a[i]),转移立刻能写:

f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] }

万能问法还是那句(第 21 章):倒数第二个数是谁? 枚举它就行。

★ 这个补救办法叫无后效性:状态必须包含「后面做决定时要用到的全部信息」。 「以 i 结尾」是子序列 / 子段类问题的第一反应,遇到就先试它。

⚠ 答案不是 f[n-1]

f[i] 是「以 a[i] 结尾」的长度,而最长的那条不一定以最后一个数结尾。

答案是 max(f[0..n-1])。

2 5 3 7 1 里 f 是 1 2 2 3 1 —— 最后一格是 1,答案却是 3。 这是这题排第二的坑,动画里专门并排显示了这两个数。

5O(n²) 的写法

dp2.cppO(n²) DP
// 最长上升子序列 —— O(n²) DP:这一章真正要学的状态设法
//
// ============ DP 三件套(第 21 章立的规矩) ============
//
// 【状态】f[i] = **以 a[i] 结尾**的最长上升子序列长度
//
// ★ 为什么要加「以 a[i] 结尾」这五个字?
// 如果状态设成「前 i 个数里的最长上升子序列长度」,你会发现转移写不出来 ——
// 因为你不知道那个最长子序列的**结尾是多少**,也就没法判断 a[i] 能不能接上去。
// 状态里必须留下「接下来能不能接」所需要的信息,这叫**无后效性**。
// 「以 i 结尾」是 DP 里最常用的一种补救办法,见到「子序列 / 子段」类题目先试它。
//
// 【转移】f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] }(没有这样的 j 就是 1)
// —— 还是那句万能问法:**倒数第二个数是谁?** 枚举它就行。
//
// 【边界和顺序】每个 f[i] 至少是 1(就它自己);i 从小到大填,
// 因为 f[i] 只依赖下标更小的 f[j]。**依赖谁,就先填谁。**
//
// 【答案】max(f[0..n-1])。
// ⚠ 不是 f[n-1]!最长的那条不一定以最后一个数结尾。这是这题第二个坑。
//
// 复杂度 O(n²):n = 5000 完全没问题(洛谷 B3637 就是这个数据范围)。
// n 到 10⁵ 才需要下一份 fast.cpp 的 O(n log n)。
//
// 输入输出和 brute.cpp 一样。
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<int> f(n, 1); // 边界:每个数自己就是一个长度为 1 的上升子序列
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++)
if (a[j] < a[i]) // 严格上升,写 <= 就变成「非降」了(见 dup.cpp)
f[i] = max(f[i], f[j] + 1);
ans = max(ans, f[i]); // 答案是所有 f[i] 里最大的
}
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

n = 5000 完全够用(洛谷 B3637 就是这个数据范围)。真正需要更快的是 n = 10⁵ 那一档。

以 i 结尾:O(n²) 是在枚举「倒数第二个数」
答案 4 · 46 帧
第 1 / 46 步
a[i]
2
5
3
7
1
6
4
8
f[i](以 a[i] 结尾的最长长度)
·
·
·
·
·
·
·
·
目前最大的 f
0
最后一格 f[7]
…
还原出的那一条
…
蓝色 = 正在填的 f[i],橙色 = 正在检查的 a[j](填实心表示它比 a[i] 小、可以接), 绿色 = 最终选中的那个来源。注意中间那两栏:答案是最大的 f, 不是最后一格的 f —— 默认数据里这两个数就不一样。
状态:f[i] = 以 a[i] 结尾的最长上升子序列长度。每填一格,就把它左边所有比它小的数扫一遍,挑 f 最大的那个接上去。

橙色格子就是在枚举「倒数第二个数」。看两遍就会发现: 大部分时间都花在「扫左边所有的 j」上,而其中绝大多数是白扫的。

6★ 关键一步(二):tails 数组

★ 关键的一步

换一个角度记录信息:

tails[k] = 所有长度为 k+1 的上升子序列里,结尾最小的那个结尾值

为什么是「结尾最小」:结尾越小,后面越容易接上新的数。 同样长度的子序列我们只关心最好接的那一条,别的都可以扔掉。

★ 而它有个天生的性质:tails 一定是严格递增的。

反证:若 tails[k] >= tails[k+1],那条长度 k+2、结尾是 tails[k+1] 的子序列, 去掉最后一个数,就得到一条长度 k+1、结尾比 tails[k+1] 还小的子序列 —— 比 tails[k] 更小,和「tails[k] 是最小结尾」矛盾。∎

递增 ⇒ 可以二分(第 8 章的 lower_bound 终于派上用场了)。于是扫每个 a[i]:

  • 在 tails 里找第一个 >= a[i] 的位置 p;
  • 找不到(a[i] 比所有都大)→ 接到末尾,最长长度 +1;
  • 找到了 → 用 a[i] 顶掉 tails[p](长度不变,但结尾更小,以后更好接)。

答案就是 tails 的长度。每个元素一次二分,总共 O(n log n)。

fast.cppO(n log n):手写二分
// 最长上升子序列 —— O(n log n):tails 数组 + 二分
//
// ★ 关键的一步,一句话:
//
// tails[k] = **所有长度为 k+1 的上升子序列里,结尾最小的那个结尾值**
//
// 为什么要「结尾最小」:结尾越小,后面越容易接上新的数。
// 同样长度的子序列,我们只关心最好接的那一个 —— 别的都可以扔掉。
//
// ★ 第二步(这才是能二分的理由):**tails 一定是严格递增的。**
//
// 反证一下:如果 tails[k] >= tails[k+1],那么那个长度为 k+2、结尾是 tails[k+1] 的子序列,
// 把它的最后一个数去掉,就得到一个长度为 k+1、结尾 < tails[k+1] <= tails[k] 的子序列 ——
// 比 tails[k] 还小,和「tails[k] 是最小结尾」矛盾。
//
// 所以 tails 天然单调,**可以二分**(第 8 章的 lower_bound 在这里派上用场)。
//
// 于是扫描每个 a[i],在 tails 里找第一个 >= a[i] 的位置 p:
// - 找不到(a[i] 比所有都大)→ 直接接到末尾,最长长度 +1
// - 找到了 → 用 a[i] 把 tails[p] 换掉(同样长度,换一个更小的结尾,以后更好接)
// 答案就是 tails 的长度。
//
// ⚠⚠ 最容易产生的误解,这一章会反复强调:
// **tails 数组不是那条最长上升子序列本身!**
// 它只是「每种长度的最好结尾」的记录板,中途会被改得面目全非。
// 序列 2 5 3 7 1 跑完之后 tails = [1, 3, 7],而 1 在原序列里排在 7 的**后面** ——
// [1,3,7] 根本不是原序列的子序列。但它的**长度 3 是对的**。
// 真要输出那条序列,得像 path.cpp 那样另外记前驱。
//
// 这里手写二分(接第 8 章),stl.cpp 里有用 std::lower_bound 的等价写法。
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<long long> tails; // tails[k]:长度 k+1 的上升子序列的最小结尾
for (int i = 0; i < n; i++) {
// 手写 lower_bound:找第一个 >= a[i] 的位置(第 8 章的模板)
int l = 0, r = (int)tails.size();
while (l < r) {
int mid = l + (r - l) / 2;
if (tails[mid] >= a[i]) r = mid;
else l = mid + 1;
}
if (l == (int)tails.size()) tails.push_back(a[i]); // 比所有都大,接到末尾
else tails[l] = a[i]; // 否则换掉那一格
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
stl.cpp同一份,用 STL

7动画:看着 tails 被一格一格顶掉

tails:每种长度的「最好结尾」
答案 4
第 1 / 10 步
原序列
2
5
3
7
1
6
4
8
tails(长度就是答案)
(空)
当前最长长度
0
已经扫过
0 / 8
真正的一条 LIS
…
绿色 = 这一步接到了末尾(最长长度 +1),红色 = 这一步顶掉了原来那一格(长度不变,结尾变小)。 播到最后对比一下最后两栏:tails 的长度是对的,但 tails 本身通常不是一条真的子序列。
tails[k] = 所有长度为 k+1 的上升子序列里,最小的那个结尾值。它一定是递增的,所以可以二分。

只有两种动作:绿色 = 接到末尾(长度 +1),红色 = 顶掉某一格(长度不变,结尾变小)。

8★ 这一章最容易产生的误解

★ tails 不是那条最长上升子序列

把动画播到最后,或者跑一下下面这份 trace:

trace.cpp打印 tails 的每一步
// 最长上升子序列 —— 把 tails 数组每一步的变化打出来
//
// 为什么要有这份代码:
// tails 那套「二分 + 替换」看起来像魔法,而且**最后那个数组常常不是任何一条真实的子序列**。
// 光看代码想不明白,打出来看一遍就懂了。
//
// 默认数据 2 5 3 7 1 是精心挑的,跑完你会看到:
// tails = [1, 3, 7]
// 而原序列里 1 排在 7 的**后面** —— [1,3,7] 根本不是这个序列的子序列。
// 但它的**长度 3 是对的**(真正的最长上升子序列是 2 5 7 或者 2 3 7)。
//
// ★ 记住这句话:**tails 记的是「每种长度的最好结尾」,不是答案序列本身。**
// 要输出那条序列,见 path.cpp。
//
// 输入输出格式和 brute.cpp 一样,只是多打了过程。
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
auto show = [](const vector<long long>& v) {
string s = "[";
for (size_t i = 0; i < v.size(); i++) { if (i) s += ", "; s += to_string(v[i]); }
return s + "]";
};
vector<long long> tails;
cout << "序列:";
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i == n - 1];
cout << "\n";
for (int i = 0; i < n; i++) {
auto it = lower_bound(tails.begin(), tails.end(), a[i]);
int p = (int)(it - tails.begin());
if (it == tails.end()) {
tails.push_back(a[i]);
cout << "读到 " << setw(4) << a[i] << ":比 tails 里所有数都大 → 接到末尾,"
<< "最长长度变成 " << tails.size() << "  tails = " << show(tails) << "\n";
} else {
long long old = *it;
*it = a[i];
cout << "读到 " << setw(4) << a[i] << ":换掉 tails[" << p << "] 的 " << old
<< "(同样长度 " << p + 1 << ",但结尾更小,以后更好接)"
<< " tails = " << show(tails) << "\n";
}
}
cout << "\n答案(tails 的长度):" << tails.size() << "\n";
cout << "最终 tails = " << show(tails) << "\n";
cout << "⚠ 别把它当成答案序列 —— 它只是每种长度的「最好结尾」记录板。\n";
cout << " 拿 2 5 3 7 1 试试:最终 tails 是 [1, 3, 7],可 1 在原序列里排在 7 后面,\n";
cout << " [1,3,7] 压根不是这个序列的子序列。要输出真正的那一条,用 path.cpp。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2 5 3 7 1 跑完,tails = [1, 3, 7]。

可是 1 在原序列里排在 7 的后面 —— [1, 3, 7] 根本不是这个序列的子序列!

但它的长度 3 是对的。真正的最长上升子序列是 2 5 7 或者 2 3 7。

tails 是「每种长度的最好结尾」的记录板,不是答案序列本身。 它中途会被改得面目全非,只有「长度」这一个数字是有意义的。

那要输出那条序列怎么办?回到 O(n²),顺手记前驱:

path.cpp连方案一起还原
// 最长上升子序列 —— 不只要长度,还要**把那条序列本身还原出来**
//
// 为什么要有这份代码:
// tails 那套写法只能给你长度(原因见 fast.cpp 和 trace.cpp 里的警告)。
// 题目一旦要求输出方案,就得回到 O(n²) 的 f[i],并且**记住每一步是从谁转移过来的**。
//
// ★ 还原方案的通用套路(所有 DP 都一样,不止这题):
// 转移的时候顺手记一个 pre[i] = 「f[i] 是从哪个 j 转移来的」;
// 最后从最优的那个下标出发,顺着 pre 一路往回走,走出来的就是方案(记得倒过来)。
//
// 这个套路在背包(第 23 章)、区间 DP(第 26 章)里会原样再用一遍。
//
// 输出:第一行长度,第二行那条上升子序列(长度相同的答案可能有多条,这里给字典序最靠前找到的那条)
//
// 顺带一提:这份代码同时也是 check-viz 的一个「硬验证」——
// 脚本会检查它输出的序列**真的是原序列的子序列、真的严格递增、长度真的等于答案**。
// 只对长度不对方案的话,这种错误对拍是抓不出来的。
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
if (n == 0) { cout << 0 << "\n\n"; return 0; }
vector<int> f(n, 1), pre(n, -1);
int best = 1, bestAt = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i] && f[j] + 1 > f[i]) {
f[i] = f[j] + 1;
pre[i] = j; // ★ 记住是从谁接过来的
}
}
if (f[i] > best) { best = f[i]; bestAt = i; }
}
vector<long long> path;
for (int i = bestAt; i != -1; i = pre[i]) path.push_back(a[i]);
reverse(path.begin(), path.end()); // 顺着 pre 走出来是倒的
cout << best << "\n";
for (size_t i = 0; i < path.size(); i++) cout << path[i] << " \n"[i + 1 == path.size()];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 还原方案的通用套路(所有 DP 都一样)
  1. 转移时顺手记 pre[i] = 从哪个 j 转移过来的;
  2. 从最优的那个下标出发,顺着 pre 一路往回走;
  3. 走出来是倒着的,reverse 一下。

背包(第 23 章)、区间 DP(第 26 章)要输出方案时,用的是同一套动作。

顺带一提:check:viz 对这份代码做的是硬验证 —— 它会检查输出的序列真的严格递增、真的是原序列的子序列、长度真的等于答案。 只对长度不对方案的错误,普通对拍是抓不出来的。

9实测:O(n²) 和 O(n log n) 差多少

同题对比:O(n²) vs O(n log n)
先跑 20000,再改成 50000(O(n²) 那份要 4 秒多)。100000 要十六秒,别在网页里等。
O(n²)
O(n log n)

本机实测:

n O(n²) O(n log n)
5 000 0.042 秒 0.007 秒
20 000 0.65 秒 0.009 秒
50 000 4.24 秒 0.015 秒
100 000 16.7 秒 0.020 秒
1 000 000 想都别想 0.134 秒

n 翻倍,O(n²) 的耗时翻四倍(0.65 → 4.24 → 16.7),而 O(n log n) 几乎是直线。

⚠ 别急着永远只写 O(n log n)

O(n²) 那份有它不可替代的地方:它能还原方案,也更容易改。

题目一变(比如「最长不下降子序列的方案」「二维偏序」),O(n²) 改两个字就行, 而 tails 那套要重新想。先写 O(n²),卡了再上二分 —— 这是考场上的顺序。

10⚠ 一个字母的坑:严格上升 vs 非降

dup.cpp两种要求并排跑
// 最长上升子序列 —— 「严格上升」和「非降」只差一个字母,答案差很多
//
// 这是 LIS 这题最经典的坑,而且**只有在有重复元素的数据上才会暴露**:
//
// 严格上升(strictly increasing):a < b,相等不行 → 用 lower_bound
// 非降 (non-decreasing) :a <= b,相等可以 → 用 upper_bound
//
// 一个字母的差别。而题面里那句话可能是「严格递增」「单调不减」「不下降」——
// **先把题读准,再决定用哪个**。
//
// 这份代码把两种都跑一遍,并排打给你看。默认数据 1 3 3 3 5 里:
// 严格上升最长 3(1 3 5),非降最长 5(1 3 3 3 5)。
//
// 顺便再说一个第 8 章的老坑:lower_bound / upper_bound 的名字很容易记反。
// lower_bound(x) = 第一个 **>= x** 的位置
// upper_bound(x) = 第一个 **> x** 的位置
// 记不住就现场造一个「有重复元素」的小例子试一下 —— 三十秒的事,比背可靠。
//
// 输入输出格式和 brute.cpp 一样,输出是两行对比。
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<long long> t1; // 严格上升
for (long long x : a) {
auto it = lower_bound(t1.begin(), t1.end(), x);
if (it == t1.end()) t1.push_back(x); else *it = x;
}
vector<long long> t2; // 非降
for (long long x : a) {
auto it = upper_bound(t2.begin(), t2.end(), x);
if (it == t2.end()) t2.push_back(x); else *it = x;
}
cout << "严格上升(lower_bound):" << t1.size() << "\n";
cout << "非降 (upper_bound):" << t2.size() << "\n";
cout << (t1.size() == t2.size()
? "这组数据上两者相等 —— 说明它里面没有「能派上用场的重复元素」,换一组带重复的再试。\n"
: "两者不同。差别全部来自重复元素:非降允许把相等的数接在一起。\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

1 3 3 3 5:严格上升最长 3(1 3 5),非降最长 5(1 3 3 3 5)。

题目要求 二分用 O(n²) 里的判断
严格递增(a < b) lower_bound(第一个 >= x) a[j] < a[i]
非降 / 不下降(a <= b) upper_bound(第一个 > x) a[j] <= a[i]
★ 这个坑只有「有重复元素」的数据才抓得出来

两种写法在没有重复元素的序列上给出的答案完全一样。

所以随机造几组大数据测一测 —— 全过。交上去 —— WA。

本章的生成器把值域压到 1~4(重复满地都是), 300 组数据里有 158 组两种写法答案不同。值域小才是这个生成器的灵魂,不是 n 大。


⚠⚠ 但上面那句话不能反过来读。「没有重复 ⇒ 两版一定相同」是对的, 「有重复 ⇒ 两版会不同」是错的 —— 它只是必要条件。 B3637 那一页照题面随机量了一遍(n = 5000、值 ≤ 10⁶): 300 轮全都有重复元素,而两版答案不同的只有 2 轮。 ⇒ 所以这个生成器压值域,压的不是「有没有重复」(值域 10⁶ 时怎么都有), 而是重复的密度 —— 密度够大,那些相等的数才有机会落在最优解的路上。

这也是第 20 章那条规矩的又一次应用: 随机的必须是「算法依赖的那个东西」 —— 这里依赖的是「有没有相等的数」。

11★ 对拍

对拍器
生成器造四种数据:值域只有 1~4(重复满地,专打「严格 vs 非降」)、正常随机、已经递增(答案 = n)、严格递减(答案 = 1)。
// 最长上升子序列 —— O(n log n):tails 数组 + 二分
//
// ★ 关键的一步,一句话:
//
// tails[k] = **所有长度为 k+1 的上升子序列里,结尾最小的那个结尾值**
//
// 为什么要「结尾最小」:结尾越小,后面越容易接上新的数。
// 同样长度的子序列,我们只关心最好接的那一个 —— 别的都可以扔掉。
//
// ★ 第二步(这才是能二分的理由):**tails 一定是严格递增的。**
//
// 反证一下:如果 tails[k] >= tails[k+1],那么那个长度为 k+2、结尾是 tails[k+1] 的子序列,
// 把它的最后一个数去掉,就得到一个长度为 k+1、结尾 < tails[k+1] <= tails[k] 的子序列 ——
// 比 tails[k] 还小,和「tails[k] 是最小结尾」矛盾。
//
// 所以 tails 天然单调,**可以二分**(第 8 章的 lower_bound 在这里派上用场)。
//
// 于是扫描每个 a[i],在 tails 里找第一个 >= a[i] 的位置 p:
// - 找不到(a[i] 比所有都大)→ 直接接到末尾,最长长度 +1
// - 找到了 → 用 a[i] 把 tails[p] 换掉(同样长度,换一个更小的结尾,以后更好接)
// 答案就是 tails 的长度。
//
// ⚠⚠ 最容易产生的误解,这一章会反复强调:
// **tails 数组不是那条最长上升子序列本身!**
// 它只是「每种长度的最好结尾」的记录板,中途会被改得面目全非。
// 序列 2 5 3 7 1 跑完之后 tails = [1, 3, 7],而 1 在原序列里排在 7 的**后面** ——
// [1,3,7] 根本不是原序列的子序列。但它的**长度 3 是对的**。
// 真要输出那条序列,得像 path.cpp 那样另外记前驱。
//
// 这里手写二分(接第 8 章),stl.cpp 里有用 std::lower_bound 的等价写法。
#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<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
vector<long long> tails; // tails[k]:长度 k+1 的上升子序列的最小结尾
for (int i = 0; i < n; i++) {
// 手写 lower_bound:找第一个 >= a[i] 的位置(第 8 章的模板)
int l = 0, r = (int)tails.size();
while (l < r) {
int mid = l + (r - l) / 2;
if (tails[mid] >= a[i]) r = mid;
else l = mid + 1;
}
if (l == (int)tails.size()) tails.push_back(a[i]); // 比所有都大,接到末尾
else tails[l] = a[i]; // 否则换掉那一格
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑

值得故意写错、看对拍怎么抓的:

  • a[j] < a[i] 写成 <= → 第 1 轮就被抓(因为生成器专造重复元素)
  • lower_bound 写成 upper_bound → 第 1 轮被抓
  • 答案输出 f[n-1] 而不是 max(f) → 第 3 轮被抓
  • f[i] 初值设成 0 → 立刻被抓(每个数自己就是长度 1)
  • tails 用 >= 二分写成 > → 被抓(这就是上一条的手写版)

12这一章可以带走的三样东西

★ 关键的一步

【1】「以 i 结尾」是子序列类问题的默认状态。 状态里必须留下「后面还能不能接」所需要的信息 —— 这就是无后效性。 卡住的时候先问:我的状态漏了什么信息?

【2】「保留最优的那个代表」是一种通用的压缩手段。 tails 的本质是:同样长度的子序列有很多条,我只留结尾最小的那条当代表。 这个思路在很多优化里会再见到(第 35 章的单调队列就是同一个味道)。

【3】能二分的前提永远是单调,而这里的单调是「白送的」。 第 9 章的二分答案要你自己去证可行性单调;这一章的 tails 天生递增, 证明只有三行。看到一个天然有序的数组,就该想到二分。

13自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 23 章:01 背包 —— 阶段 5 的重头戏。

状态要多一维「还剩多少容量」,而 ★ 关键一步是一个所有人都会踩的坑: 滚动成一维之后,循环为什么必须倒着写。

正着写不会报错、不会崩,只会让同一件物品被拿两次 —— 又一个「安静地给你错答案」的例子。这次我们会把它画出来。