阶段 5 · 动态规划 · 第 22 章

线性 DP:最长上升子序列

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

例题:最长上升子序列 建议用时:110 分钟
这一章练的是「状态怎么设」

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

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

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

1 一句话问题

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

输入 5
     2 5 3 7 1
输出 3
⚠ 子序列 ≠ 子串

子串必须连着(5 3 7),子序列只要保持先后顺序就行(2 5 72 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ⁿ 枚举子集
输入(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 1f1 2 2 3 1 —— 最后一格是 1,答案却是 3。 这是这题排第二的坑,动画里专门并排显示了这两个数。

5 O(n²) 的写法

dp2.cppO(n²) DP
输入(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):手写二分
输入(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 的每一步
输入(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连方案一起还原
输入(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)

本机实测:

nO(n²)O(n log n)
5 0000.042 秒0.007 秒
20 0000.65 秒0.009 秒
50 0004.24 秒0.015 秒
100 00016.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两种要求并排跑
输入(stdin)
输出
点「运行 ▶」看结果

1 3 3 3 5严格上升最长 31 3 5),非降最长 51 3 3 3 5)。

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

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

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

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

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

11 ★ 对拍

对拍器
生成器造四种数据:值域只有 1~4(重复满地,专打「严格 vs 非降」)、正常随机、已经递增(答案 = n)、严格递减(答案 = 1)。

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

  • 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
配套练习
  • 洛谷 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 的重头戏。

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

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