第 21 章立了 DP 三件套:状态、转移、边界和顺序。后面三件都是机械活, 真正难的永远是第一件 —— 状态是什么。
这一章就练这个。而且它给出的答案是整个 DP 里复用率最高的一句: 「以 i 结尾」。
它还顺带把第 8 章的二分请回来用一次 —— 你会看到一个很漂亮的事实: 那个用来二分的数组,天然就是单调的,不需要你做任何事。
1 一句话问题
给一个长度为 n 的序列,从中按原顺序挑出若干个数(可以不连着),
要求挑出来的这些数严格递增。最多能挑几个?
输入 5
2 5 3 7 1
输出 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ⁿ 种组合全试一遍(第 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。
这是这题排第二的坑,动画里专门并排显示了这两个数。
5 O(n²) 的写法
点「运行 ▶」看结果
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)。
点「运行 ▶」看结果
7 动画:看着 tails 被一格一格顶掉
只有两种动作:绿色 = 接到末尾(长度 +1),红色 = 顶掉某一格(长度不变,结尾变小)。
8 ★ 这一章最容易产生的误解
把动画播到最后,或者跑一下下面这份 trace:
点「运行 ▶」看结果
2 5 3 7 1 跑完,tails = [1, 3, 7]。
可是 1 在原序列里排在 7 的后面 —— [1, 3, 7] 根本不是这个序列的子序列!
但它的长度 3 是对的。真正的最长上升子序列是 2 5 7 或者 2 3 7。
tails 是「每种长度的最好结尾」的记录板,不是答案序列本身。
它中途会被改得面目全非,只有「长度」这一个数字是有意义的。
那要输出那条序列怎么办?回到 O(n²),顺手记前驱:
点「运行 ▶」看结果
- 转移时顺手记
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 非降
点「运行 ▶」看结果
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 大。
这也是第 20 章那条规矩的又一次应用: 随机的必须是「算法依赖的那个东西」 —— 这里依赖的是「有没有相等的数」。
11 ★ 对拍
值得故意写错、看对拍怎么抓的:
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 的重头戏。
状态要多一维「还剩多少容量」,而 ★ 关键一步是一个所有人都会踩的坑: 滚动成一维之后,循环为什么必须倒着写。
正着写不会报错、不会崩,只会让同一件物品被拿两次 —— 又一个「安静地给你错答案」的例子。这次我们会把它画出来。