第 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✓ 长度 32 3 7✓ 长度 32 5 7 1? 不行,1 比 7 小- 有长度 4 的吗?序列里比
7大的数一个都没有,1在最后又最小 —— 没有。
所以答案是 3。
3暴力:2ⁿ 枚举子集
// 最长上升子序列 —— 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;}点「运行 ▶」看结果
每个数「选或不选」,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[i] 是「以 a[i] 结尾」的长度,而最长的那条不一定以最后一个数结尾。
答案是 max(f[0..n-1])。
2 5 3 7 1 里 f 是 1 2 2 3 1 —— 最后一格是 1,答案却是 3。
这是这题排第二的坑,动画里专门并排显示了这两个数。
5O(n²) 的写法
// 最长上升子序列 —— 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;}点「运行 ▶」看结果
n = 5000 完全够用(洛谷 B3637 就是这个数据范围)。真正需要更快的是 n = 10⁵ 那一档。
橙色格子就是在枚举「倒数第二个数」。看两遍就会发现: 大部分时间都花在「扫左边所有的 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)。
// 最长上升子序列 —— 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;}点「运行 ▶」看结果
7动画:看着 tails 被一格一格顶掉
只有两种动作:绿色 = 接到末尾(长度 +1),红色 = 顶掉某一格(长度不变,结尾变小)。
8★ 这一章最容易产生的误解
把动画播到最后,或者跑一下下面这份 trace:
// 最长上升子序列 —— 把 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;}点「运行 ▶」看结果
2 5 3 7 1 跑完,tails = [1, 3, 7]。
可是 1 在原序列里排在 7 的后面 —— [1, 3, 7] 根本不是这个序列的子序列!
但它的长度 3 是对的。真正的最长上升子序列是 2 5 7 或者 2 3 7。
tails 是「每种长度的最好结尾」的记录板,不是答案序列本身。
它中途会被改得面目全非,只有「长度」这一个数字是有意义的。
那要输出那条序列怎么办?回到 O(n²),顺手记前驱:
// 最长上升子序列 —— 不只要长度,还要**把那条序列本身还原出来**//// 为什么要有这份代码:// 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;}点「运行 ▶」看结果
- 转移时顺手记
pre[i] = 从哪个 j 转移过来的; - 从最优的那个下标出发,顺着
pre一路往回走; - 走出来是倒着的,
reverse一下。
背包(第 23 章)、区间 DP(第 26 章)要输出方案时,用的是同一套动作。
顺带一提:check:viz 对这份代码做的是硬验证 ——
它会检查输出的序列真的严格递增、真的是原序列的子序列、长度真的等于答案。
只对长度不对方案的错误,普通对拍是抓不出来的。
9实测: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²) 那份有它不可替代的地方:它能还原方案,也更容易改。
题目一变(比如「最长不下降子序列的方案」「二维偏序」),O(n²) 改两个字就行,
而 tails 那套要重新想。先写 O(n²),卡了再上二分 —— 这是考场上的顺序。
10⚠ 一个字母的坑:严格上升 vs 非降
// 最长上升子序列 —— 「严格上升」和「非降」只差一个字母,答案差很多//// 这是 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;}点「运行 ▶」看结果
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★ 对拍
// 最长上升子序列 —— 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自测
- 洛谷 B3637 最长上升子序列解析 → —— 模板题,n ≤ 5000,O(n²) 就能过。先交 O(n²) 再交 O(n log n),对比一下用时
- 洛谷 P1020 导弹拦截解析 → —— NOIP1999。必须用 O(n log n)。第二问要用 Dilworth 定理(最少的不升子序列个数 = 最长上升子序列长度),而且两问一个用 lower_bound 一个用 upper_bound —— 本章第 10 步那个坑的实战版
- 洛谷 P1439 最长公共子序列解析 → —— 两个排列的 LCS 可以转成 LIS 来做(把第二个序列按第一个的位置重新编号)。转化很巧,值得专门想明白
- 洛谷 P1091 合唱队形解析 → —— NOIP2004。正着做一遍 LIS、倒着做一遍,然后枚举中间那个人 —— 「跑两遍 DP」的经典入门题
- 洛谷 P2782 友好城市解析 → —— 排序之后就是 LIS。难点在看出「排完序之后这题就是 LIS」,这一步才是 DP 题的真正门槛
第 23 章:01 背包 —— 阶段 5 的重头戏。
状态要多一维「还剩多少容量」,而 ★ 关键一步是一个所有人都会踩的坑: 滚动成一维之后,循环为什么必须倒着写。
正着写不会报错、不会崩,只会让同一件物品被拿两次 —— 又一个「安静地给你错答案」的例子。这次我们会把它画出来。