0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 B3637,日期见页头。两边不一致时信原站。
题目描述
这是一个简单的动规板子题。
给出一个由 n (n ≤ 5000) 个不超过 10⁶ 的正整数组成的序列。
请输出这个序列的最长上升子序列的长度。
最长上升子序列是指,从原序列中按顺序尽可能多取出一些数字排在一起,这些数字是逐渐增大的。
输入格式
第一行,一个整数 n,表示序列长度。第二行有 n 个整数,表示这个序列。
输出格式
一个整数表示答案。
输入输出样例
输入
6 1 2 4 1 3 4
输出
4
分别取出 1、2、3、4 即可。
★★ 而这一组样例本身就值得盯一眼:它有重复元素(1 和 4 各出现两次),
可它放过了本页那个错法 —— 第 ④ 步整节都在讲这件事。
第 22 章把这道题的两种正解都讲透了(O(n²) 的 DP、O(n log n) 的 tails)。
⇒ 这一页不重复,它讲三件正文顺带提过、但没有量的事:
- ★★★ 正文第 ⑩ 步说「两种写法在没有重复元素的序列上答案完全一样」—— 这句话是对的,可它极容易被读成「有重复就会不一样」。 照题面随机 300 轮:有重复 300 轮,答案不同只有 2 轮(第 ④ 步)。
- ★★ 题单说「先交
O(n²)再交O(n log n),对比一下用时」—— 量出来这道题上分不出高下(20 毫秒 vs 0 毫秒),真正的分界线在别的题上(第 ⑤ 步)。 - ★
tails数组不是那个最长上升子序列本身 —— 给一个具体的反例(第 ⑥ 步)。
1两版正解,都能过
// B3637 最长上升子序列 —— ★ 这一版就能 AC(O(n log n))//// 这是[第 22 章](/ch/22-lis/)的模板题,正解正文第 ⑥ 步已经讲透了:// 维护一个 `tails` 数组,`tails[k]` = 所有长度为 k+1 的上升子序列里**最小的那个结尾**。// 来一个新数 x:在 tails 里找**第一个 ≥ x 的位置**(`lower_bound`),// 找到就把它替换掉,找不到(x 比所有的都大)就 push_back。// ⇒ `tails` 的**长度**就是答案。//// ⚠⚠ **`tails` 里存的不是那个最长上升子序列本身**(正文第 ⑧ 步专门说过这件事)——// 它只是「每个长度的最优结尾」,中途的内容常常是一串不连贯的数。**只有长度是可信的。**//// ★ 题面要的是**严格上升**(「逐渐增大」),所以这里是 `lower_bound`。// 要是题目说「不下降」,就得换成 `upper_bound` —— 一个字母的差别,// 而它**只有在序列里有相等的数时才会现形**(页面第 ④ 步把这条量成了两层触发条件)。//// 复杂度 `O(n log n)`。★ 而题面 `n ≤ 5000` —— `O(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<int> tails; for (int i = 0; i < n; i++) { int x; cin >> x; auto it = lower_bound(tails.begin(), tails.end(), x); // ★ 严格上升 ⇒ lower_bound if (it == tails.end()) tails.push_back(x); else *it = x; } cout << tails.size() << "\n"; return 0;}点「运行 ▶」看结果
// B3637 的另一版正解:`O(n²)` 的经典 DP —— ★ 这一版**在这道题上也能 AC**//// `f[i]` = 以第 i 个数结尾的最长上升子序列长度;`f[i] = max(f[j]) + 1`(j < i 且 a[j] < a[i])。//// ★ 题单里给这道题写了一句:「先交 `O(n²)` 再交 `O(n log n)`,**对比一下用时**」——// 页面第 ③ 步把这句话量了出来:题面顶格 `n = 5000` 时它只要几毫秒,// ⚠ 而**真正的分界线不在这道题上** —— 要 n 大到 10⁵ 才轮得到 `O(n log n)` 出场// ([P1020 导弹拦截](/sol/p1020/)就是那样一道题)。//// ⇒ 「哪一版该交」不是「哪一版更优」,是**题面的 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<int> a(n), f(n, 1); for (int& x : a) cin >> x;
int ans = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1); // ★ 严格上升 ⇒ 这里是 < ans = max(ans, f[i]); } cout << (n ? ans : 0) << "\n"; return 0;}点「运行 ▶」看结果
2参照物:2ⁿ 枚举子集
// B3637 的参照物:`2ⁿ` 枚举子集//// ★ 它把「最长上升子序列」这句话照抄一遍:枚举每个子集,检查是不是严格递增,取最长的。// 不需要「状态 / 转移」,也不需要 `tails` 那个巧办法 —— 这正是它当参照物的价值。// ⚠ 只跑得动 `n ≤ 22` 左右。
#include <bits/stdc++.h>using namespace std;
int main() { int n; if (!(cin >> n)) return 0; vector<int> a(n); for (int& x : a) cin >> x;
int best = 0; for (int mask = 0; mask < (1 << n); mask++) { int last = INT_MIN, len = 0; bool ok = true; for (int i = 0; i < n && ok; i++) if (mask >> i & 1) { if (a[i] <= last) ok = false; // ★ 严格上升 else { last = a[i]; len++; } } if (ok) best = max(best, len); } cout << best << "\n"; return 0;}点「运行 ▶」看结果
它把「最长上升子序列」这句话照抄一遍,不需要「状态 / 转移」,也不需要 tails 那个巧办法。
300 组(n ≤ 14) |
|
|---|---|
tails 版 vs O(n²) DP |
不一致 0 组 |
tails 版 vs 2ⁿ 暴力 |
不一致 0 组 |
3那个一字之差的错法
// B3637 错法:把 `lower_bound` 写成 `upper_bound`//// 一个字母的差别 —— 它求的是「**最长不下降**子序列」,而题面要的是**严格上升**。//// ★★ 而这个 bug 有**两层**触发条件(页面第 ④ 步量了):// ① 序列里**得有相等的数**(没有重复元素时两种写法答案完全一样 —— 正文第 ⑩ 步说过);// ② 那些相等的数还得**真的落在最优解的路上**。// ⇒ 照题面随机(`n ≤ 5000`、值 ≤ 10⁶)时第 ① 层几乎必然成立,第 ② 层却很少成立;// 把值域压到几十,两层就一起成立了。
#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> tails; for (int i = 0; i < n; i++) { int x; cin >> x; 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;}点「运行 ▶」看结果
lower_bound 求的是严格上升,upper_bound 求的是不下降 —— 一个字母。
正文第 ⑩ 步给过判据:
两种写法在没有重复元素的序列上给出的答案完全一样。
这句话是对的。 而下面这一步要说的是:它只是必要条件。
4★★★ 两层触发条件 —— 而第一层完全没有区分度
n 固定在题面顶格 5000,只拧值域:
| 值域上限 | 4 | 40 | 1 000 | 10⁶(题面顶格) |
|---|---|---|---|---|
| 序列里有重复元素的轮数 | 300 | 300 | 300 | 300 |
upper_bound 那版真被抓的轮数 |
300 | 300 | 300 | ★ 2 |
n = 5000 的序列在 10⁶ 的值域里随机取,按生日悖论期望有 12 对重复
—— 所以「有没有重复元素」这一栏在四个档位上全是 300,一点区分度都没有。
而真正决定成败的是第二层:那些相等的数还得恰好落在最优解的路上。 照题面随机时它只有 2 / 300(0.67%)。
⇒ ⚠ 正文那句话不能反过来读:
「没有重复 ⇒ 一定相同」是对的,「有重复 ⇒ 会不同」是错的。
★ 顺带:本页第 ⓪ 步那组官方样例就是现成的例子 —— 1 2 4 1 3 4 有两对重复,
而严格上升和不下降都是 4。
⇒ 这也说明正文那个生成器为什么要把值域压到 1~4:
它压的不是「有没有重复」(怎么都有),是「重复的密度」 ——
密度够大,第二层才有机会成立。
本书这一轮反复量同一件事:「满足触发条件」和「真被抓」差多少。
| 第一层 | 真被抓 | 比 | |
|---|---|---|---|
| P2240(最后一刀除不尽) | 239 | 239 | 1.0 |
| P1094(剩奇数件) | 191 | 191 | 1.0 |
| P1223(有并列) | 104 | 56 | 1.9 |
| P1803(端点重合) | 191 | 3 | 60 |
| 这道题(有重复元素) | 300 | 2 | ★ 150 |
⇒ 一头是「一个不差」,另一头是「差 150 倍」。 这两个数的关系只能量,不能推 —— 而且第一层写得越「显然」,越要提防它没有区分度。
5★★ 「先交 O(n²) 再交 O(n log n)」—— 量出来这题分不出高下
题单里给这道题写的是「先交 O(n²) 再交 O(n log n),对比一下用时」。本机实测:
n |
5 000(题面顶格) | 20 000 | 100 000 |
|---|---|---|---|
O(n²) |
20 毫秒 | 410 毫秒 | ★ 10 813 毫秒 |
O(n log n) |
0 毫秒 | 0 毫秒 | 3 毫秒 |
O(n²) 的内层比较次数 |
1.25 × 10⁷ | 2.0 × 10⁸ | 5.0 × 10⁹ |
这道题顶格 n = 5000 —— O(n²) 只要 20 毫秒,时限 1 秒,余量 50 倍。
两版在这道题上根本分不出高下(一个 20 毫秒,一个量不出来)。
真正的分界线在下一道题上:P1020 导弹拦截 的 n ≤ 10⁵ ——
同一份 O(n²) 要 10.8 秒,而 O(n log n) 是 3 毫秒。
⇒ 所以题单那句话的价值不在「比出谁快」,在于让你亲手把这条线跨过去一次:
同一个算法、同一台机器,n 从 5000 走到 10⁵,O(n²) 从「随便过」变成「超时 10 倍」。
6★ tails 数组里存的不是那个子序列
正文第 ⑧ 步提过这件事,这里给个能自己跑的反例。序列 3 1 4 1 5 9 2 6:
答案(最长上升子序列的长度) : 4 比如 1 4 5 9
跑完之后 tails 里剩下的 : 1 2 5 6
而 1 2 5 6 根本不是原序列的一个子序列 —— 原序列里 5 出现在 2 的前面。
tails[k] 的定义是「所有长度为 k+1 的上升子序列里,最小的那个结尾」——
它是一堆不同子序列各自的结尾拼在一起的,当然不必是同一条路。
⇒ 只有 tails.size() 是可信的。 要真的把那条子序列打印出来,
得另外记一个「前驱」数组(正文第 ⑦ 步那个动画演示的就是这件事)。
7度量程序和生成器
// B3637 的度量程序 —— 这一页所有数字都出自这一份。//// `./b3637Count` 人看的版本// `./b3637Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用//// 四段:// ① ★ 三条路一致:tails 版 / `O(n²)` DP / `2ⁿ` 枚举子集;// ② ★★★ 「`lower_bound` 写成 `upper_bound`」那个 bug 的**两层**触发条件 ——// 「序列里有相等的数」和「真被抓」差多少(拧值域);// ③ ★★ 题单说「先交 `O(n²)` 再交 `O(n log n)`,对比一下用时」—— 量出来,// 并且指出**这道题上分不出高下**,真正的分界线在别的题上;// ④ ★ `tails` 数组**不是**那个最长上升子序列本身 —— 举一个具体的反例。
#include <bits/stdc++.h>#include <chrono>using namespace std;using namespace std::chrono;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");}
/** 正解:tails + lower_bound(严格上升)。cnt 记比较次数 */static int lisStrict(const vector<int>& a, ll* cnt = nullptr) { vector<int> t; for (int x : a) { auto it = lower_bound(t.begin(), t.end(), x); if (cnt) *cnt += (ll)(t.empty() ? 1 : 1 + (ll)(64 - __builtin_clzll(t.size()))); if (it == t.end()) t.push_back(x); else *it = x; } return (int)t.size();}/** 错法:upper_bound(不下降) */static int lisNonDec(const vector<int>& a) { vector<int> t; for (int x : a) { auto it = upper_bound(t.begin(), t.end(), x); if (it == t.end()) t.push_back(x); else *it = x; } return (int)t.size();}/** O(n^2) DP(严格上升)。cnt 记内层比较次数 */static int lisN2(const vector<int>& a, ll* cnt = nullptr) { int n = a.size(), ans = 0; vector<int> f(n, 1); for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { if (cnt) (*cnt)++; if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1); } ans = max(ans, f[i]); } return n ? ans : 0;}/** 2^n 枚举子集 */static int lisBrute(const vector<int>& a) { int n = a.size(), best = 0; for (int mask = 0; mask < (1 << n); mask++) { int last = INT_MIN, len = 0; bool ok = true; for (int i = 0; i < n && ok; i++) if (mask >> i & 1) { if (a[i] <= last) ok = false; else { last = a[i]; len++; } } if (ok) best = max(best, len); } return best;}static bool hasDup(vector<int> a) { sort(a.begin(), a.end()); return adjacent_find(a.begin(), a.end()) != a.end();}static vector<int> gen(mt19937& rng, int n, int hi) { vector<int> a(n); for (int i = 0; i < n; i++) a[i] = (int)(rng() % (unsigned)hi) + 1; return a;}
int main(int argc, char** argv) { CSV = argc > 1 && string(argv[1]) == "csv";
/* ① 三条路一致 */ { mt19937 rng(20260829u); int groups = 0, badN2 = 0, badBrute = 0; for (int rep = 0; rep < 300; rep++, groups++) { vector<int> a = gen(rng, (int)(rng() % 14) + 1, 1000000); int ok = lisStrict(a); if (lisN2(a) != ok) badN2++; if (lisBrute(a) != ok) badBrute++; } if (!CSV) printf("① %d 组(n ≤ 14):tails 版 vs O(n²) 不一致 %d 组;vs 2^n 暴力不一致 %d 组\n", groups, badN2, badBrute); row("three", {groups, badN2, badBrute}); }
/* ② ★★★ 两层触发条件:有重复 vs 真被抓 */ { const int HIS[] = {4, 40, 1000, 1000000}; vector<ll> out; for (int hi : HIS) { mt19937 rng(hi * 7919u + 37u); int dup = 0, caught = 0; for (int r = 0; r < 300; r++) { vector<int> a = gen(rng, 5000, hi); // ★ n 照题面顶格 if (hasDup(a)) dup++; if (lisNonDec(a) != lisStrict(a)) caught++; } out.push_back(dup); out.push_back(caught); if (!CSV) printf("② n = 5000,值域 ≤ %7d(300 轮):有重复元素 %d 轮," "而 upper_bound 那版真被抓 %d 轮\n", hi, dup, caught); } row("layers", out); }
/* ③ O(n²) vs O(n log n):题面顶格分不出高下,真正的线在别处 */ { const int NS[] = {5000, 20000, 100000}; vector<ll> out; for (int n : NS) { mt19937 rng(n * 40503u + 7u); vector<int> a = gen(rng, n, 1000000); ll c1 = 0, c2 = 0; auto t0 = steady_clock::now(); int r1 = lisN2(a, &c1); ll ms1 = duration_cast<milliseconds>(steady_clock::now() - t0).count(); t0 = steady_clock::now(); int r2 = lisStrict(a, &c2); ll ms2 = duration_cast<milliseconds>(steady_clock::now() - t0).count(); out.push_back(ms1); out.push_back(ms2); out.push_back(r1 == r2 ? 1 : 0); if (!CSV) printf("③ n = %6d:O(n²) %5lld 毫秒(内层比较 %lld 次)," "O(n log n) %4lld 毫秒(约 %lld 次),答案%s\n", n, ms1, c1, ms2, c2, r1 == r2 ? "相同" : "**不同**"); } row("timing", out); }
/* ④ tails 不是 LIS 本身 */ { vector<int> a = {3, 1, 4, 1, 5, 9, 2, 6}; vector<int> t; for (int x : a) { auto it = lower_bound(t.begin(), t.end(), x); if (it == t.end()) t.push_back(x); else *it = x; } if (!CSV) { printf("④ 序列 3 1 4 1 5 9 2 6:最长上升子序列长度 %d,而 tails 里最后剩的是", (int)t.size()); for (int x : t) printf(" %d", x); printf("(★ 它本身**不是**一个上升子序列的原样 —— 只有长度可信)\n"); } vector<ll> o = {(ll)t.size()}; for (int x : t) o.push_back(x); row("tails", o); } return 0;}点「运行 ▶」看结果
// B3637 对拍生成器:`./b3637Gen <seed> [n 上限] [值域上限]`// 默认 `n ≤ 16`、值 ≤ 10⁶ —— n 压在 16 是为了让 `2ⁿ` 暴力当得了参照物,// **而值域照题面顶格**(题面:`n ≤ 5000`,数 ≤ 10⁶)。//// ★★ 这一页要拧的旋钮是**值域**,而不是 n:// 「`lower_bound` 写成 `upper_bound`」那个 bug 的第一层触发条件是「序列里有相等的数」,// 而值域一大就几乎没有重复 —— 这正是正文第 ⑩ 步把生成器值域压到 `1~4` 的原因。// ⚠ 而这一页量出来的是:**第一层成立远远不等于被抓**(页面第 ④ 步那张表)。
#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]) : 16; int vHi = argc > 3 ? atoi(argv[3]) : 1000000; nHi = max(1, min(5000, nHi)); vHi = max(1, min(1000000, vHi));
mt19937 rng(seed * 2654435761u + 3637u); int n = (int)(rng() % (unsigned)nHi) + 1; printf("%d\n", n); for (int i = 0; i < n; i++) printf("%u%c", (unsigned)(rng() % (unsigned)vHi) + 1, i + 1 == n ? '\n' : ' '); return 0;}点「运行 ▶」看结果
8一页纸
| 关键的一步 | tails[k] = 长度 k+1 的上升子序列里最小的结尾;lower_bound 找位置 |
| 哪一版能 AC | 两版都能(O(n log n) 和 O(n²))—— 题面 n ≤ 5000 |
| ★★★ 这一页的主线 | 正文那句「没有重复元素时两种写法一样」只是必要条件: 照题面随机 300 轮,有重复 300 轮、答案不同只有 2 轮(差 150 倍) |
| ★ 第一层的陷阱 | 「有没有重复元素」在四个值域档位上全是 300 —— 一点区分度都没有; 生成器压值域压的是重复的密度,不是「有没有重复」 |
| ★★ 该交哪一版 | 由题面的 n 说了算:n = 5000 时 20 毫秒 vs 0 毫秒(分不出),到 P1020 的 n = 10⁵ 就是 10.8 秒 vs 3 毫秒 |
| ★ 一个常见误解 | tails 里剩的不是那条子序列(3 1 4 1 5 9 2 6 ⇒ 1 2 5 6,不是子序列) |
| 参照物 | 2ⁿ 枚举子集 —— 300 组不一致 0 组 |
| 样例的表现 | ★ 有重复却放过了错法 —— 它自己就是「第一层成立、第二层不成立」的例子 |