最长上升子序列 LIS
O(n²) 与 O(n log n)·导弹拦截
本课摘要
最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。
- 判断最长上升子序列 LIS的适用条件与状态边界
- 围绕“O(n²) 与 O(n log n)·导弹拦截”推导转移与计算顺序
- 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节
正在整理目录…
什么是「上升子序列」
给一串数 ,子序列是从中挑出若干个数、保持原来的先后次序(但不必相邻)得到的序列; 若挑出的这串数严格递增,就是一条上升子序列。我们要找的,是其中最长的一条——它的长度就是 LIS (Longest Increasing Subsequence)。
盯住上图: 这五个数在原序列里的下标是 (递增,说明保持了原次序), 对应的数值 也递增——两个条件都满足,是合法的上升子序列。能不能更长?试遍所有挑法,答案是不能,所以这题的 LIS 长度是 5。
那能不能贪心,从左到右「能接就接」?看这条链就会翻车:从 2 起步,遇到 5 接上(2,5),再遇 6 接上(2,5,6), 往后 8、9 也接,得到 2,5,6,8,9——长度也是 5,碰巧不差。但若序列是 ,贪心从 1 接了 100 就卡死(后面再没有比 100 大的),只得长度 2; 而正解是 长度 4。此刻接哪个数最好,取决于后面还有什么——这正是需要 DP 的信号。
要枚举所有子序列?那是 种挑法, 就已无从枚举。下面用 DP 把它压成 ,再进一步压到 。
状态与转移:以某个数「结尾」
难点在于「子序列可以在任意位置结尾」,直接对整体设状态很滑。换个抓手:强制枚举它以哪个数结尾。 设 表示:以 为最后一个元素的最长上升子序列的长度。这样每条上升子序列都被它的结尾唯一「认领」,不重不漏。
怎么算 ?既然它以 结尾,那 前面那个数 必须满足两件事:下标更靠前()、数值更小(,才「上升」)。 在所有这样的 里,谁结尾的子序列最长( 最大),就把 接到它后面,长度 :
如果左边没有任何更小的数可接,这个 为空, 就取初值 1( 自己单独成一条长度 1 的串)。 最终答案不是 ——因为 LIS 可以在任何位置收尾——而是整个 dp 数组的最大值:
本质
「以 结尾」这个限定,把「求全局最长」拆成了 个彼此独立、可按下标顺序递推的小问题。 每个 只依赖它左边已算好的 ,于是 种挑法被 次比较装下。★答案取全行最大,别顺手写成 。
跟着算一遍
用序列 走几步(下标从 1 记),把方程跑起来:
看它一格一格长出来
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
深化:O(n log n) 贪心 + 二分
应付 绰绰有余,但 就会超时。瓶颈在那句「向左扫所有 」。能不能不扫? 关键洞察是一个贪心:要让子序列有机会更长,同样长度的上升子序列,它的结尾越小越好——结尾越小,后面越容易接上更多数。
于是维护一个数组 : 表示所有长度为 的上升子序列中,最小的那个结尾。 它有个漂亮性质—— 本身严格递增(长度越长,最小结尾必然越大)。逐个处理 :
动作一 · 追加。若 比 当前末尾还大,它能接在最长那条的后面,于是把它追加到 末尾——LIS 长度增长 1。
动作二 · 替换。否则,用二分在 里找第一个 的位置(),把那一格替换成 。 含义是:某个长度的子序列,如今找到了一个更小的结尾,长度没变,但为后面接续腾出了更多空间。
因为 始终有序,二分只需 ,总复杂度降到 。最终 的长度就是 LIS。要当心一个常见误解: 的内容不一定是某条真实存在的上升子序列(它是被反复替换出来的), 但它的长度恒等于 LIS,这才是我们要的答案。
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
常见陷阱 · 严格上升 vs 不降
求严格上升子序列,二分用 (第一个 ,相等也替换);若改求不降(允许相等)子序列,则要换成 (第一个 )。 一字之差,结果就差一截——下一节 LCS 里 P1439 排列 LCS 正是靠把问题转成 LIS、再用这套二分做到 的。
例题
#include <algorithm>
#include <iostream>
using namespace std;
int n, ans, a[5005], dp[5005];
int main()
{
cin >> n;
for (int i = 1; i <= n; i++)
{
cin >> a[i];
}
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]); // ★LIS 可在任意位置结尾,取全局最大
}
cout << ans << endl;
return 0;
}
// TAG: 线性DP LIS 最长上升子序列#include <algorithm>
#include <iostream>
using namespace std;
int n, a[100005];
int g1[100005], len1; // 第一问:最长不升子序列的「结尾」栈
int g2[100005], len2; // 第二问:最长上升子序列的「结尾」栈
int main()
{
while (cin >> a[++n]); // 读到文件尾,n 会多算 1
n--;
for (int i = 1; i <= n; i++)
{
// 第一问:一套系统能拦的最多导弹 = 最长「不升」子序列长度。
// 维护一个「各长度的最大结尾」序列 g1(单调不升),二分找第一个 < a[i] 的位置替换。
if (len1 == 0 || g1[len1] >= a[i])
{
g1[++len1] = a[i];
}
else
{
int l = 1, r = len1;
while (l <= r) // 找第一个 g1[p] < a[i]
{
int mid = (l + r) >> 1;
g1[mid] < a[i] ? r = mid - 1 : l = mid + 1;
}
g1[l] = a[i];
}
// 第二问(Dilworth):最少拦截系统数 = 最长「上升」子序列长度。
// g2 单调上升,二分找第一个 >= a[i] 的位置替换(lower_bound)。
if (len2 == 0 || g2[len2] < a[i])
{
g2[++len2] = a[i];
}
else
{
int l = 1, r = len2;
while (l <= r) // 找第一个 g2[p] >= a[i]
{
int mid = (l + r) >> 1;
g2[mid] >= a[i] ? r = mid - 1 : l = mid + 1;
}
g2[l] = a[i];
}
}
cout << len1 << endl << len2 << endl;
return 0;
}
// TAG: LIS 最长不升 Dilworth 二分 O(nlogn)#include <algorithm>
#include <iostream>
using namespace std;
int n, ans, a[105];
int up[105], down[105]; // up[i]:以 i 结尾的最长上升;down[i]:从 i 起的最长下降
int main()
{
cin >> n;
for (int i = 1; i <= n; i++)
{
cin >> a[i];
}
for (int i = 1; i <= n; i++) // 正向:每人左侧的最长上升
{
up[i] = 1;
for (int j = 1; j < i; j++)
{
if (a[j] < a[i])
{
up[i] = max(up[i], up[j] + 1);
}
}
}
for (int i = n; i >= 1; i--) // 反向:每人右侧的最长下降
{
down[i] = 1;
for (int j = n; j > i; j--)
{
if (a[j] < a[i])
{
down[i] = max(down[i], down[j] + 1);
}
}
}
for (int i = 1; i <= n; i++) // 枚举峰顶 i,合唱队形长 up[i]+down[i]-1
{
ans = max(ans, up[i] + down[i] - 1);
}
cout << n - ans << endl; // 最少出列 = 总人数 − 最长合唱队形
return 0;
}
// TAG: 线性DP 双向LIS 枚举峰顶
