- gf25051 的博客
《咸鱼概要 · C++》动态规划
- @ 2026-6-5 20:45:27
动态规划经典——最长上升/不下降子序列问题(O(n2) 与 O(n log n) 详解)
子序列问题是算法竞赛中的常客,其中 最长上升子序列(LIS) 以及它的各种变体(不下降、下降、不上升)更是基础中的基础。本文将从零开始,用最直白的语言详细讲解 的朴素动态规划和 的贪心+二分优化,并手把手带你写出一份自己也能看懂的代码。
阅读完本文,你将能够:
- 彻底理解两种写法的状态定义和转移过程
- 清晰区分“严格上升”与“不下降”写法上的细微差别
- 学会输出最长子序列的具体方案
- 轻松应对各种变体(最长下降、最长不上升、合唱队形等)
一、问题引入
给定一个长度为 的整数序列 ,求最长的子序列(不一定连续),使得序列中的元素满足某种单调性,例如:
- 严格递增:
- 不下降(非严格递增):
- 严格递减:
- 不上升(非严格递减):
下文中我们将以最经典的两种——最长严格上升子序列和最长不下降子序列为主线进行讲解,并扩展到其他类型。
二、 朴素动态规划
可能要有一定动态规划基础
2.1 状态定义
设 表示 以第 个元素结尾 的最长上升(或不下降)子序列的长度。
为什么这样定义?
因为子序列的最后一个元素决定了后续元素能否接上,所以将“结尾位置”作为状态是非常自然的。
2.2 转移方程
对于当前位置 ,我们向前扫描所有 :
-
若求 严格上升,需满足 :
$$dp[i] = \max_{j < i,\ a[j] < a[i]} \{ dp[j] + 1 \}$$ -
若求 不下降,需满足 :
$$dp[i] = \max_{j < i,\ a[j] \le a[i]} \{ dp[j] + 1 \}$$
如果找不到任何合法的 ,则 (自己单独成为一个长度为 1 的子序列)。
最终答案即为 。
2.3 代码实现
以求 最长不下降子序列 为例:
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int a[N], dp[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int ans = 0;
for (int i = 1; i <= n; i++) {
dp[i] = 1; // 初始化为1
for (int j = 1; j < i; j++) {
if (a[j] <= a[i]) // 不下降条件
dp[i] = max(dp[i], dp[j] + 1);
}
ans = max(ans, dp[i]);
}
cout << ans << endl;
return 0;
}
若要求 严格上升,只需将13行条件改为 a[j] < a[i] 即可。
2.4 复杂度分析
两层循环使得时间复杂度为 ,对于 的数据完全足够,但当 达到 以上时就必须优化了。
三、O(n log n) 贪心思想 + 二分查找优化
3.1 核心思想
我们不再直接存储以每个元素结尾的长度,而是维护一个数组 ,其中 表示当前找到的长度为 的子序列中,末尾元素的最小可能值。
为什么记录最小值?
因为末尾元素越小,后面的元素就越容易接上去,从而得到更长的子序列。这是一种典型的贪心思想。
数组有一个非常重要的性质:它是单调递增(或非严格递增)的。这一点保证了我们可以使用二分查找来快速更新。
以严格上升子序列为例, 严格递增:
若 ,则 。
因为如果 ,我们可以用更短序列的末尾元素去替换更长的末尾,这与“最优”矛盾。
3.2 算法流程(以严格上升为例)
从头到尾遍历原序列的每个元素 :
- 如果 (当前最长长度 的末尾),说明 可以接在长度为 的子序列后面,形成一个长度为 的新子序列,于是
d[++len] = a[i]。 - 否则,在 中找到第一个 大于等于 的位置 ,并用 替换 。这一步意味着:我们找到了一个长度与 相同、但末尾元素更小(或相等)的子序列,使该长度的末尾值变得更优。
遍历结束后, 即为答案。
3.3 两种变体:lower_bound 还是 upper_bound?
这是初学者最容易混淆的地方。我们用一张表来彻底理清:
| 子序列类型 | 条件 | 数组的单调性 | 新元素直接扩展条件 | 二分查找函数 |
|---|---|---|---|---|
| 严格上升 | 严格递增 | a[i] > d[len] |
lower_bound(找第一个 的位置,替换掉大于等于 的值) |
|
| 不下降 | 非严格递增(单调不减) | a[i] >= d[len] |
upper_bound(找第一个 的位置,替换掉比它大的值) |
为什么会有这样的区别?
- 严格上升时,子序列不允许相等,所以 中不能出现相等的元素。用
lower_bound找到第一个 的位置,如果找到的刚好等于 ,替换它不会产生相等的元素(因为原本就是相等的,替换后长度不变,末尾值也不变),实际上是保持严格性。 - 不下降时,允许相等,因此 中可能出现相等元素。为了在相等时能够扩展长度,我们只替换 严格大于 的元素,保留相等的,所以用
upper_bound查找第一个 的位置。
3.4 代码实现
求最长不下降子序列长度:
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int a[N], d[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int len = 1;
d[1] = a[1];
for (int i = 2; i <= n; i++) {
if (a[i] >= d[len]) { // 不下降:允许相等
d[++len] = a[i];
} else {
int pos = upper_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
}
}
cout << len << endl;
return 0;
}
求最长严格上升子序列长度:
// 只改两个地方:
if (a[i] > d[len]) { // 严格大于
d[++len] = a[i];
} else {
int pos = lower_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
}
3.5 模拟演示
以序列 1 3 2 8 5 6,求最长不下降子序列为例:
- 初始:
d[1] = 1,len = 1 - i=2, a[2]=3:3 >= 1,扩展 →
d[2]=3,len=2 - i=3, a[3]=2:2 < 3,二分
upper_bound找到第一个 >2 的位置是 d[2]=3,替换 →d[2]=2,d=[1,2] - i=4, a[4]=8:8 >= 2,扩展 →
d[3]=8,d=[1,2,8] - i=5, a[5]=5:5 < 8,替换 d[3] →
d[3]=5,d=[1,2,5] - i=6, a[6]=6:6 >= 5,扩展 →
d[4]=6,d=[1,2,5,6],len=4
最终答案 4,与题意一致。
四、输出最长子序列的明细
很多题目不仅要求长度,还要输出具体的子序列。在 算法中,由于 数组只存储末尾元素,丢失了中间信息,我们需要额外维护 前驱指针。
4.1 思路
设 表示以 结尾的子序列中, 的前一个元素在原数组中的下标。
在更新 数组的同时记录 :
- 当 直接扩展长度时,它的前驱就是 所对应的元素下标。
- 当 替换 时,它的前驱就是 对应的元素下标。
为了能够通过 数组找到对应元素的下标,我们让 不再只存储值,而是存储一个结构体,包含值 x 和它在原数组中的位置 id。
最后,从 的 id 出发,沿着 数组回溯,即可得到子序列(逆序),用递归或栈正序输出。
4.2 参考代码
以最长不下降子序列为例:
#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;
int a[N], fa[N], n, len;
struct Node {
int x; // 元素的值
int id; // 在原序列中的下标
} d[N];
// 递归输出序列
void print(int i) {
if (fa[i] == -1) {
cout << a[i] << " ";
return;
}
print(fa[i]);
cout << a[i] << " ";
}
// upper_bound 的自定义比较函数
bool cmp(int val, Node node) {
return val < node.x;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
memset(fa, -1, sizeof(fa));
len = 1;
d[1].x = a[1];
d[1].id = 1;
for (int i = 2; i <= n; i++) {
if (a[i] >= d[len].x) {
d[++len].x = a[i];
d[len].id = i;
fa[i] = d[len - 1].id;
} else {
int pos = upper_bound(d + 1, d + len + 1, a[i], cmp) - d;
d[pos].x = a[i];
d[pos].id = i;
fa[i] = d[pos - 1].id;
}
}
cout << len << "\n";
print(d[len].id);
return 0;
}
如果要求的是严格上升,只需将条件改为
a[i] > d[len].x,并将upper_bound换成lower_bound(注意自定义比较函数可能需要调整,也可以直接传a[i]并利用Node的operator<重载,但为了简洁可直接用lower_bound(d+1, d+len+1, a[i], [](Node node, int val){ return node.x < val; })等方式)。
五、全类型变体及技巧总结
掌握了严格上升和不下降,其他变体只需做简单变换即可。
| 要求 | 正向处理方案 | 另一种方案 |
|---|---|---|
| 最长严格上升(LIS) | lower_bound,条件 a[i] > d[len] |
- |
| 最长不下降 | upper_bound,条件 a[i] >= d[len] |
|
| 最长严格下降 | 将数组反转后求最长严格上升,或从右向左求最长严格上升 | 使用 lower_bound 配合 greater<int>() |
| 最长不上升 | 将数组反转后求最长不下降,或从右向左求最长不下降 | 使用 upper_bound 配合 greater<int>() |
注意:
lower_bound和upper_bound默认要求数组升序。如果数组是降序排列(处理下降/不上升时),需要传入greater<int>()作为比较函数,且此时逻辑会反转:
lower_bound(begin, end, val, greater<>())找到第一个 小于等于 val 的位置(因为数组是降序,“不小于”即“小于等于”)
建议初学者统一使用“反转数组”或“反向遍历”的方法,不易出错。
六、经典练习题与题解
下面给出四道覆盖不同变体的经典题目,附详细题解和代码。
题2:最长严格上升子序列
题目描述:
输入 和序列,求最长严格上升子序列长度。。
思路:将条件改为 >,使用 lower_bound。
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int a[N], d[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int len = 1;
d[1] = a[1];
for (int i = 2; i <= n; i++) {
if (a[i] > d[len]) {
d[++len] = a[i];
} else {
int pos = lower_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
}
}
cout << len << endl;
return 0;
}
题3:合唱队形(NOIP2004 提高组)
题目描述:
位同学站成一排,音乐老师要请其中的 位同学出列,使得剩下的 位同学排成合唱队形。合唱队形是指这样的一种队形:设 位同学从左到右依次编号为 ,他们的身高分别为 ,则要求 (即先严格上升后严格下降)。求最少需要几位同学出列(即 )。
输入:第一行整数 ,第二行 个整数。
输出:一个整数,表示最少出列人数。
思路:
正向求以每个位置 结尾的最长严格上升子序列长度 ;反向求以每个位置 开头的最长严格下降子序列长度 (等价于从右向左求最长严格上升)。
枚举中间最高的同学位置 ,队伍长度为 ,取最大值。答案为 。
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int a[N], f[N], g[N], d[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
// 正向 LIS
int len = 1;
d[1] = a[1];
f[1] = 1;
for (int i = 2; i <= n; i++) {
if (a[i] > d[len]) {
d[++len] = a[i];
} else {
int pos = lower_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
}
f[i] = len; // 注意:这里存储的是以a[i]结尾的LIS长度,但严格来说O(n log n)维护的len是全局最长,无法直接得到以每个i结尾的精确长度。因此通常本题的正解是O(n^2) DP,或者在O(n log n)时额外记录每个i插入时的长度(可通过记录插入位置获取)。为便于演示,这里使用O(n^2) DP更加稳妥。但考虑到n可能较大,可用下面技巧:在二分插入时记录pos作为以i结尾的长度。
}
// 更精确的O(n log n)写f[i]:每次都记录插入/扩展时的长度
// 重写一遍正向:
len = 0;
for (int i = 1; i <= n; i++) {
int pos = lower_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
if (pos > len) len = pos;
f[i] = pos; // 此时以a[i]结尾的最长上升长度就是pos
}
// 反向 LIS(从右向左做严格上升,即原序列的下降)
memset(d, 0, sizeof(d));
len = 0;
for (int i = n; i >= 1; i--) {
int pos = lower_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
if (pos > len) len = pos;
g[i] = pos;
}
int ans = 0;
for (int i = 1; i <= n; i++) {
ans = max(ans, f[i] + g[i] - 1);
}
cout << n - ans << endl;
return 0;
}
解释:
lower_bound查找d中第一个 的位置pos,pos就是 能够接上的最长长度。这正是以 结尾的 LIS 长度。
题4:导弹拦截(NOIP1999 普及组)
题目描述:
某国开发出一种导弹拦截系统,但有一个缺陷:第一发炮弹可以打到任意高度,但以后的每一发炮弹都不能高于前一发的高度(即高度不上升)。给定一段时间内来袭导弹的高度序列,求:
- 一套系统最多能拦截多少导弹?(最长不上升子序列长度)
- 要拦截所有导弹最少需要配备多少套拦截系统?(贪心/最长上升子序列长度)
思路:
第一问:求最长不上升子序列。可以反向求最长不下降,或者用 upper_bound + greater<int>()。
第二问:根据 Dilworth 定理,对于一个偏序集,最长链长度等于最小反链划分数。这里最少系统数等于最长严格上升子序列长度。
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int a[N], d[N], n;
int main() {
// 读入直到文件尾,或者给定个数,这里假设先输入n
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
// 第一问:最长不上升子序列
// 方法:将数组反转求最长不下降,或直接用d维护降序
int len = 0;
for (int i = 1; i <= n; i++) {
// 在降序数组中找第一个小于a[i]的位置(即允许相等时不替换相等的)
// 由于是不上升,允许相等,d降序排列
// 使用 upper_bound 配合 greater<int>(),找到第一个小于a[i]的位置
int pos = upper_bound(d + 1, d + len + 1, a[i], greater<int>()) - d;
d[pos] = a[i];
if (pos > len) len = pos;
}
cout << len << endl;
// 第二问:最少系统数 = 最长严格上升子序列长度
memset(d, 0, sizeof(d));
len = 0;
for (int i = 1; i <= n; i++) {
int pos = lower_bound(d + 1, d + len + 1, a[i]) - d;
d[pos] = a[i];
if (pos > len) len = pos;
}
cout << len << endl;
return 0;
}
七、总结
- 子序列问题的核心是 DP, 的思路最简单,适合小数据。
- 的写法通过维护各长度末尾最小值来贪心扩展,借助二分将转移优化到 。
- 二分函数的选择是易错点:
- 严格上升 →
lower_bound - 不下降 →
upper_bound - 严格下降 / 不上升 → 反转数组或使用
greater<>()的二分。
- 严格上升 →
- 若需输出具体方案,额外维护前驱数组,回溯即可。
- 常见的合唱队形、导弹拦截等问题,本质是多个方向子序列的组合应用。
希望这篇博客能帮助你彻底攻克最长上升/不下降子序列及其变体。如果有任何疑问,欢迎在评论区交流讨论!
附:全类型对比速查表
| 类型 | 条件 | 二分查找函数(升序维护) | 直接扩展条件 |
|---|---|---|---|
| 最长严格上升 LIS | < | lower_bound |
a[i] > d[len] |
| 最长不下降 LNDS | <= | upper_bound |
a[i] >= d[len] |
| 最长严格下降 | > | 反转数组 + LIS | 或降序 lower_bound |
| 最长不上升 | >= | 反转数组 + LNDS | 或降序 upper_bound |
附:dxd上课笔记

熟练掌握这些,你就能应对绝大部分子序列相关的考题了。