题单 · 习题解析

洛谷 B3637 最长上升子序列

★★★ 正文那句「没有重复元素时两种写法一样」只是必要条件 —— 照题面随机 300 轮:有重复 300 轮,答案不同只有 2 轮(第一层完全没有区分度)

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

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

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

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

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

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

题目描述

这是一个简单的动规板子题。

给出一个由 n (n ≤ 5000) 个不超过 10⁶ 的正整数组成的序列。 请输出这个序列的最长上升子序列的长度。

最长上升子序列是指,从原序列中按顺序尽可能多取出一些数字排在一起,这些数字是逐渐增大的。

输入格式

第一行,一个整数 n,表示序列长度。第二行有 n 个整数,表示这个序列。

输出格式

一个整数表示答案。

输入输出样例

输入

6
1 2 4 1 3 4

输出

4

分别取出 1、2、3、4 即可。 ★★ 而这一组样例本身就值得盯一眼:它有重复元素14 各出现两次), 可它放过了本页那个错法 —— 第 ④ 步整节都在讲这件事。

★ 板子题的解析页要讲什么

第 22 章把这道题的两种正解都讲透了(O(n²) 的 DP、O(n log n)tails)。 ⇒ 这一页不重复,它讲三件正文顺带提过、但没有量的事:

  1. ★★★ 正文第 ⑩ 步说「两种写法在没有重复元素的序列上答案完全一样」—— 这句话是对的,可它极容易被读成「有重复就会不一样」。 照题面随机 300 轮:有重复 300 轮,答案不同只有 2 轮(第 ④ 步)。
  2. ★★ 题单说「先交 O(n²) 再交 O(n log n)对比一下用时」—— 量出来这道题上分不出高下(20 毫秒 vs 0 毫秒),真正的分界线在别的题上(第 ⑤ 步)。
  3. tails 数组不是那个最长上升子序列本身 —— 给一个具体的反例(第 ⑥ 步)。

1两版正解,都能过

b3637.cpp★ O(n log n):tails + lower_bound
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
b3637N2.cpp★ O(n²):经典 DP —— 这题也能过
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2参照物:2ⁿ 枚举子集

b3637Brute.cpp参照物:枚举所有子集
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它把「最长上升子序列」这句话照抄一遍,不需要「状态 / 转移」,也不需要 tails 那个巧办法。

300 组(n ≤ 14
tails 版 vs O(n²) DP 不一致 0 组
tails 版 vs 2ⁿ 暴力 不一致 0 组

3那个一字之差的错法

b3637Upper.cpp错法:lower_bound 写成 upper_bound
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

lower_bound 求的是严格上升upper_bound 求的是不下降 —— 一个字母。 正文第 ⑩ 步给过判据:

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

这句话是对的。 而下面这一步要说的是:它只是必要条件

4★★★ 两层触发条件 —— 而第一层完全没有区分度

n 固定在题面顶格 5000,只拧值域

值域上限 4 40 1 000 10⁶(题面顶格)
序列里有重复元素的轮数 300 300 300 300
upper_bound 那版真被抓的轮数 300 300 300 2
★★★ 第一层 300 / 300,第二层 2 / 300 —— 差 150 倍

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 说了算

这道题顶格 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度量程序和生成器

b3637Count.cpp度量程序
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
b3637Gen.cpp数据生成器
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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 毫秒(分不出),
P1020n = 10⁵ 就是 10.8 秒 vs 3 毫秒
★ 一个常见误解 tails 里剩的不是那条子序列(3 1 4 1 5 9 2 61 2 5 6,不是子序列)
参照物 2ⁿ 枚举子集 —— 300 组不一致 0 组
样例的表现 有重复却放过了错法 —— 它自己就是「第一层成立、第二层不成立」的例子