0. 前言
LIS(Longest Increasing Subsequence)
最长上升子序列 。
一个数的序列 bi
,当 b1 < b2 < … < bS
的时候,我们称这个序列是上升的。对于给定的一个序列 (a1, a2, …, aN)
,我们可以得到一些上升的子序列 (ai1, ai2, …, aiK)
,这里 1 <= i1 < i2 < … < iK <= N
。
比如,对于序列 (1, 7, 3, 5, 9, 4, 8)
,有它的一些上升子序列,如(1, 7), (3, 4, 8)
等等。这些子序列中最长的长度是 4,比如子序列(1, 3, 5, 8)
要求: 对于给定的序列,求出最长上升子序列的长度。
密切相关:[线性dp] aw895最长上升子序列(知识理解+重要模板题+最长上升子序列模型+LCS转化LIS) 详看下方的知识理解,有用!
1. LIS 模板题
重点: 线性 dp
、LIS 问题及优化
思路:
- 状态定义:
f[i]
所有以a[i]
结尾的上升子序列长度的最大值
- 状态转移:
- 分类依据:倒数第二个数是哪个数,可将状态分类为:
- 当
a[i] > a[j]
时,有f[i] = max(f[i], f[j] + 1)
,j = 0, 1, 2,...,i-1
- 当
- 分类依据:倒数第二个数是哪个数,可将状态分类为:
- 边界处理及初始化
- 每次将
f[i]
首先初始化为 1,因为前段若小于i
的数,则i
自己当做子序列开头
- 每次将
- 时间复杂度:
- O ( n 2 ) O(n^2) O(n2),状态 n n n × \times × 转移数量 n n n
dp O ( n 2 ) O(n^2) O(n2) 代码:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1005;
int n;
int a[N], f[N];
int main() {
cin >> n;
for (int i = 0; i < n; ++i) cin >> a[i];
int ans = 1;
for (int i = 0; i < n; ++i) {
f[i] = 1;
for (int j = 0; j < i; ++j) {
if (a[i] > a[j]) f[i] = max(f[i], f[j] + 1);
}
ans = max(ans, f[i]);
}
cout <<ans << endl;;
return 0;
}
顺便可以输出转移路径,即输出最长上升子序列的数值。
这也是很经典的记录 dp
中间转移过程并输出的方法,很有利于理解中间转移的方式和方法。再进行逆向回推,写代码就要写的通透。
逆序输出最长上升子序列的数值代码:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1005;
int n;
int a[N], f[N], g[N];
int main() {
cin >> n;
for (int i = 0; i < n; ++i) cin >> a[i];
int ans = 1;
for (int i = 0; i < n; ++i) {
f[i] = 1;
g[i] = 0; // 表示它只有一个数
for (int j = 0; j < i; ++j) {
if (a[i] > a[j])
if (f[i] < f[j] + 1) {
f[i] = max(f[i], f[j] + 1);
g[i] = j; //记录下是从哪里转移过来的
}
}
}
int k = 1;
for (int i = 1; i <= n; ++i) // 找到f[i]数组最大值,即最长上升子序列长度
if (f[k] < f[i])
k = i; // k保存f[i]最大值的下标
cout << f[k] << endl; // f[k]即为最长上升子序列的最大长度
for (int i = 0, len = f[k]; i < len; ++i) { // 逆序输出方案
cout << a[k] << ' ';
k = g[k];
}
return 0;
}
2. LIS 贪心 O ( n l o g n ) O(nlogn) O(nlogn) 求解
熟悉 LIS
问题的,其实还应该知道本题还有 dp + 二分的优化。即当数据量大的时候,
O
(
n
2
)
O(n^2)
O(n2) 的时间复杂度是不太行的…可进行优化到
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)
思路:
- 状态定义:
f[i]
表示长度为i
的最长上升子序列,末尾最小的数字,即长度为i
的子序列末尾最小元素。- 这样相当于处理得到了长度为 1、长度为 2、长度为 3…的最长上升子序列,且
f[i]
均保存它们的最小值,那么当枚举到下一个数t
的时候,如果t>f[2]
且t<f[3]
,那么t
一定可以可以与长度为 2 的子序列构成一个新的长度为 3 的上升子序列。同时,它也必定可以与长度为 1 的f[1]
构成一个新的长度为 2 的上升子序列。显然,与长度为 2 的子序列构成一个长度为 3 的子序列是满足最长子序列定义的。故f[i]
数组是具有单调性的(如果不具备单调性,反证法简单可证伪)。那么现在我们针对一个新的数字,去寻找它所能构成的最长子序列的时候,就可以利用f
数组的单调性进行二分查找到对应的i
位置,但是一定需要记得更新f[i+1]
保存的最小值,将其更新为当前值。因为这个数字与i
位置的几个数字将构成一个i+1
长度的最长上升子序列,且二分查找时,f[i+1]
肯定大于当前数的,所以需要更新。(比较白话,顺着思路写了自己的理解)
- 状态转移:
- 当
a[i] > f[cnt -1]
时,这里的下标从 0 开始,cnt
指的是cnt
长度的最长上升子序列。f[cnt-1]
就是下标从 0 开始,cnt
长度的最长上升子序列末尾最小的数字。如果当前数比它大,那么就cnt + 1
,即最长上升子序列长度 +1,更新末尾最小元素为w[i]
- 如果
a[i]<=f[cnt-1]
时,说明不会更新当前长度,但之前末尾的最小元素需要变化,找到第一个大于等于a[i]
,用于更新末尾最小元素
- 当
- 边界处理及初始化
- 每次将
f[i]
首先初始化为 1,因为前段若小于i
的数,则i
自己当做子序列开头
- 每次将
- 时间复杂度:
- O ( n l o g n ) O(nlogn) O(nlogn)
这里的贪心就是将所有长度为 k
的上升子序列拿出来,看看 x
能不能更新最短的 f[k]
若不能更新,则 x
无法接到任意一个长度为 k
的子序列后面。如果能更新,则让 x
更新所有长度为 k
的子序列中最短的哪一个,这就是贪心的来更新。
3. LCS 转 LIS 条件及分析
保留上述思路,虽然看着很绕… 以下思路为 2021 年 5 月 15 日更新。
当 LCS
求最长公共子序列中有一个序列元素是不重复的时候, LCS
问题就可以转化为 LIS
问题,进而使用
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn) 的做法进行求解。
具体转化就是将 b
数组的各个元素搞一个对应的 c
数组,a[c[i]] = b[i]
,即 c
数组存对应位置上 b
数组在 a
数组出现的下标。在此要求 a
数组是元素各不重复的数组,这样才能保证映射的单一性。
这样,在 c
数组存的所有下标中的 LIS
,就是对应的 a b
数组的 LCS
。可从任意一个上升子序列都是公共子序列,任意一个公共子序列都是上升子序列这两方面进行证明。 也比较容易证明,注意下标自然而然是递增的这个条件!
这样,做了映射之后,就是贪心求 LIS
问题的范畴了。
上部分状态定义是没有问题的,但是状态转移中讲的不够清楚,为了做对比,就不删除了。
状态转移:
- 考虑一个新数
x
是怎么更新f[i]
数组的。 f[i]
表示长度为i
的LIS
中结尾的最小值,我们可以找到f[k]
,其是严格小于x
中最大的一个,且由于f
数组的严格单调递增性,f[k+1]
一定大于等于x
。- 那么从
f[k+1]
开始至以后,x
都不能作为它们的结尾,因为它们自身的结尾最小值都已经大于x
了,若将x
作为它们的结尾则破坏了单调性。 - 故
x
可以做k
之前的任意长度的结尾,但是实际上如果x
做了f[1~k-1]
之间的结尾,还不如让f[k]
去做f[1~k-1]
之间的结尾,这样还能保证f[1~k-1]
在长度不变的情况下结尾最小。 - 所以
x
就只能做f[k]
的结尾,实际上就是将x
接到长度为k
的LIS
后面,实际上就是将f[k+1]=x
作为状态更新。 - 在此要明确,原有的
f[k+1]
一定是大于等于x
的,这也是b
数组可以重复的原因,它并不影响正确答案。
边界及技巧:
- 因为我们每次都要二分一个比
x
小的最大的一个元素,若x
本身是最小元素的话,就需要特殊处理一个边界,比较麻烦,故我们可以特殊插入一个比所有元素都小的元素。 - 但其实在此根本不需要设立设个边界值,因为
f[0]
根本没有被用到,甚至这个f[0]
设置为任意值均可ac
。找到了比x
最大的一个元素则为正常情况,若所有的f[i]
都小于x
的话,二分最后停留位置就是l=r=0
,改变的是f[r+1]=f[1]=x
,跟f[0]
没有关系。甚至由f[0]
改变了整个的严格单调递增性也无所谓,因为二分停留位置的下一个一定是第一个大于等于x
的位置,或者说是第一个空闲位置。 也可以将f[0]
改变单调性的情况分类讨论。 - 但是给二分问题加边界确是很常见的一种方式,尤其在使用
set<>
的时候,很有用。
练手题:
终极 O ( n l o g n ) O(nlogn) O(nlogn) 代码:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1e5+5;
int n;
int a[N];
int q[N];
int main() {
cin >> n;
for (int i = 0; i < n; ++i) cin >> a[i];
int cnt = 0;
q[0] = -2e9; // 可由可无
for (int i = 0; i < n; ++i) {
int l = 0, r = cnt;
while (l < r) {
int mid = l + r + 1>> 1;
if (q[mid] < a[i]) l = mid;
else r = mid - 1;
}
cnt = max(cnt, r + 1);
q[r + 1] = a[i];
}
cout << cnt << endl;
return 0;
}