最长上升子序列(LIS)全解析:从基础到进阶(基础讲解篇)
最长上升子序列(LIS)全解析:从基础到进阶(基础讲解篇)
0. 引言
0.1 本文概述
本文主要讲解 最长上升子序列LIS 的三种 O(n2)O(n^2)O(n2) 解法,两种 O(nlogn)O(n \log n)O(nlogn) 基础优化思路,以及 LIS 的扩展运用。附有模板和详细讲解。你可以按照需求自行跳过一些章节。
这篇文章是基础讲解篇,主要讲解 LIS 的基础解法和 O(nlogn)O(n \log n)O(nlogn) 优化,后面我将会更新扩展部分。
说明:作者写这篇文章,主要是发现目前的 LIS 教程大多都比较简短,新手看不太懂。这篇文章适合新手以及进阶选手,甚至能让更高层级的选手进行复习。另外,本篇文章的下标若无特殊说明均从 111 开始。习题部分的题目并没有完整,如果有人能补充,可以私信我,我会及时补充。
鸣谢:若有人指出我的不足或错误,或补充题目和内容,均可以在评论区或者私信我。我将十分感谢大家。
提问:如果你对某些地方有疑问,可以私信或在发在评论区,我有时间的话基本都会回答的。
申明:转载请标明原作者及原文链接。
0.2 目录
文章目录
- 最长上升子序列(LIS)全解析:从基础到进阶(基础讲解篇)
- 0. 引言
- 0.1 本文概述
- 0.2 目录
- 1. LIS 基础认知:从定义到核心思路
- 1.1 什么是最长上升子序列(LIS)
- 1.1.1 严格上升与非严格上升的定义
- 1.1.2 LIS 的问题模型
- 1.2 LIS 的核心解题思路:三种基础方向
- 1.2.1 搜索
- 1.2.2 贪心
- 1.2.3 动态规划
- 1.2.4 三种方法对比
- 1.3 从 O(n^2) 到 O(n \log n) 的优化
- 1.3.1 优化思路
- 1.3.2 贪心
- 1.3.3 动态规划
- 1.3.4 优化方法对比
1. LIS 基础认知:从定义到核心思路
1.1 什么是最长上升子序列(LIS)
1.1.1 严格上升与非严格上升的定义
上升子序列的定义是求出从原序列中按从前到后的顺序取出一些数字排在一起,这些数字是逐渐增大的。
最长上升子序列就是长度最长的上升子序列。
通常来说,上升子序列分为严格上升和非严格上升(即单调不降) 两种。两者的区别在于数字逐渐增大是不含等号和含等号的。严格上升的大小比较是 >>>,而不严格则是 >=>=>=。
简单举个例子,序列 [3,2,4,3,6,7,9][3, 2, 4, 3, 6, 7, 9][3,2,4,3,6,7,9],其中 [3,3,6,7,9][3, 3, 6, 7, 9][3,3,6,7,9] 是非严格上升序列,而不是严格上升序列。
正常情况下,我们认为 LIS 是严格上升的,除非有特殊说明。本篇文章也遵循这样的原则。
1.1.2 LIS 的问题模型
其实前文已经说到了 LIS 的定义,一般的问题就是求递增或递减序列长度,还可能问一些方案数,构造序列,可经点,必经点等等,但是这里补充一点,数字可以被替换为其它可以比较大小的任何东西,比如 pair 或你定义的结构体(但是你的比较函数不要出现漏洞)。
1.2 LIS 的核心解题思路:三种基础方向
针对一个最基础的问题:求 LIS 的长度。(序列长度 ≤104\le 10^4≤104)。
1.2.1 搜索
看见这种题,最简单的想法就是搜索。我们枚举整个序列的所有子序列,然后暴力判断子序列是否上升,如果上升,就更新答案。这样时间复杂度为 O(2n)O(2^n)O(2n),很明显过不了此题。
int q[30];
bool check(int len){//判断选择序列合法性for(int i = 2; i <= len; ++ i){if(a[i - 1] >= a[i]) return 0;}return 1;
}
void dfs(int x, int l){//x 表示搜到的位置,l 表示选择的序列的长度if(x > n){if(check(l)){ans = max(ans, l);}return;}dfs(x + 1, l);q[l + 1] = a[x];dfs(x + 1, l + 1);
}
int main(){//读入scanf("%d", &n);for(int i = 1; i <= n; ++ i){scanf("%d", &a[i]);}dfs(1, 0);printf("%d", ans);return 0;
}
接下来我们考虑进行剪枝。基础的剪枝主要分为两种:可行性剪枝和最优性剪枝。
可行性就是针对整个序列是否满足上升序列进行剪枝。由于搜出来的序列单调递增,因此我们可以考虑记录上一个元素的值(如果没有上一个元素就设为极小值,因为每个数都可能成为第一个元素),只有在当前元素大于上一个元素时,才尝试添加当前元素进行搜索。但是这样还是需要搜索每一个元素。
其实,我们只关心上升序列的位置,不关心其他位置的数是多少。因此,我们只需搜索上升序列的位置,记录长度和最后一个元素的值即可。
最优性是对当前答案是否比已知答案更优秀。我们可以记忆化,把选当前元素的序列最长长度记录下来,因为后面选的元素只与前一个元素有关。如果当前搜到的结果不比记忆化的结果好,那么就返回。
这个复杂度不好分析,我也不会严谨证明。复杂度是均摊 O(n2)O(n^2)O(n2) 的。在这里简单解释一下均摊,均摊表示有一些地方复杂度会高一些,有一些地方复杂度会低一些,平均下来就是均摊。
void dfs(int x, int k){//x 表示当前搜到的位置,k 表示当前搜的序列长度if(k <= f[x]) return;//最优性剪枝f[x] = k;ans = max(ans, k);for(int i = x + 1; i <= n; ++ i){if(a[x] < a[i]){//可行性剪枝dfs(i, k + 1);}}
}
int main(){//读入scanf("%d", &n);for(int i = 1; i <= n; ++ i){scanf("%d", &a[i]);}for(int i = 1; i <= n; ++ i){//枚举每一个起始点dfs(i, 1);}return 0;
}
其实,在这份代码的基础上,我们还可以添加 A* 的那种预估剪枝。其实预估剪枝就是预估出当前可能的花费或价值等,与其它情况进行比较,进而加快搜索。虽然可能对代码的理论时间复杂度并无太大影响,但会显著提升速度。
我们可以预估出当前位置到终点的最大可能长度(即 k+n−xk + n - xk+n−x,n−xn - xn−x 为对后面的最大预估长度),与当前的答案进行比较,若并不会更优,那么就没必要搜了,直接返回。
预估函数对已经记忆化的搜索优化并不是很大,但是如果加到第一个暴力的代码加上针对没有改变状态的可行性剪枝,加入预估剪枝效果就比较明显,除非出题人卡数据。
void dfs(int x, int k){//x 表示当前搜到的位置,k 表示当前搜的序列长度if(k <= f[x]) return;//最优性剪枝if(k + n - x <= ans) return;//预估性剪枝f[x] = k;ans = max(ans, k);for(int i = x + 1; i <= n; ++ i){if(a[x] < a[i]){//可行性剪枝dfs(i, k + 1);}}
}
int main(){//读入scanf("%d", &n);for(int i = 1; i <= n; ++ i){scanf("%d", &a[i]);}for(int i = 1; i <= n; ++ i){//枚举每一个起始点dfs(i, 1);}return 0;
}
然后这就是搜索的方法,时间复杂度为均摊 O(n2)O(n ^ 2)O(n2)。这种复杂度并不稳定,最优理论复杂度是 O(n)O(n)O(n)(序列单调递增,第一个元素开始的就是最长的,后面的元素只要一调用 dfs 就会遇到边界(k<=fik<=f_ik<=fi,而 fi=if_i=ifi=i,k=1k=1k=1)返回,最坏是 O(n2)O(n^2)O(n2)(序列单调不增,每个点判断后面的点都要花费 O(n)O(n)O(n)),但是因为各种剪枝速度稍微会快一些。
附上对比剪枝的时间复杂度的表格:
剪枝方法 | 时间复杂度和常数(这里指搜索状态数) |
---|---|
不加剪枝 | 时间复杂度 O(2n)O(2^n)O(2n),常数较大(虽然比起其它复杂算法来说常数已经很小了) |
可行性剪枝/改变搜索状态 | 时间复杂度 O(2n)O(2^n)O(2n),常数稍大 |
记忆化搜索 | 时间复杂度 O(n2)O(n^2)O(n2),常数较小 |
预估函数 | 时间复杂度 O(n2)O(n^2)O(n2),常数更小 |
在随机数据下,搜索往往更快。
1.2.2 贪心
现在我们考虑另外一种思路,思考一件事情,选的序列如何尽可能更长。
比较显然的结论是,如果我们有一个数 xxx 比序列结尾元素大,我们就将它加入序列 bbb,使得序列尽可能长。那 xxx 这个数如果不能增加序列长度,还有别的用吗?由于我们选的序列单调递增,为了让后面的元素更好拼接上前面的元素,我们就可以找到序列中满足 bi<xb_i < xbi<x 的最大的 iii 的位置,然后更新 bi+1b_{i + 1}bi+1 元素的值。
至于这个策略为什么是对的,我们可以用分类讨论来粗略地说明一下:
若当前的最优序列为 bbb,长度为 lll,现在考虑向其中加入一个 ≤bl\le b_l≤bl 的元素。
若不按照上面的策略进行更新,若继续来了一个 ≤al\le a_l≤al 的元素,且在当前加入元素的后面,也不进行更新,最后会导致最后一个元素无法更新,导致整个序列的长度无法进行更新。
若按照上面的策略进行更新,我们假设更新了 kkk 处的 bkb_kbk,再来一个元素 x(ak<x≤al)x(a_k < x \le a_l)x(ak<x≤al),那么就可以向后推进,最终更新到 lll 处的 blb_lbl,从而更容易使整个序列增长。
以上内容是感性理解,我们严谨的反证一下:
反证目标:假设存在一个序列,贪心算法构造的数组 bbb 的长度严格小于实际LIS的长度,通过推导矛盾证明该假设不成立。
核心性质:贪心算法维护的 bbb 数组具有两个关键性质:
- 单调性:bbb 数组严格递增(b1<b2<⋯<blb_1 < b_2 < \dots < b_lb1<b2<⋯<bl,即 ∀i>1,bi<bi+1\forall i>1, b_i < b_{i + 1}∀i>1,bi<bi+1);
- 最小结尾性:bib_ibi 是所有长度为 iii 的LIS的最小可能结尾元素。
反证过程:
假设存在序列 AAA,贪心算法得到的 bbb 数组长度为 kkk,而实际LIS的长度为 m>km > km>k。设实际LIS为 S=[s1,s2,…,sm]S = [s_1, s_2, \dots, s_m]S=[s1,s2,…,sm],其中 sk+1s_{k+1}sk+1 是第 k+1k+1k+1 个元素,根据LIS定义有 sk+1>sks_{k+1} > s_ksk+1>sk。
- 若 sk+1>bks_{k+1} > b_ksk+1>bk:根据贪心策略,会将 sk+1s_{k+1}sk+1 加入 bbb 数组,使 bbb 的长度变为 k+1k+1k+1,与假设的 k<mk < mk<m 矛盾。
- 若 sk+1≤bks_{k+1} \le b_ksk+1≤bk:由于 sks_ksk 是长度为 kkk 的LIS的结尾元素,根据 bbb 的最小结尾性,sk≥bks_k \ge b_ksk≥bk(否则 bkb_kbk 应被 sks_ksk 替换)。因此 sk+1>sk≥bks_{k+1} > s_k \ge b_ksk+1>sk≥bk,即 sk+1>bks_{k+1} > b_ksk+1>bk,与假设的 sk+1≤bks_{k+1} \le b_ksk+1≤bk 矛盾。
两种情况均推出矛盾,故假设不成立,贪心算法构造的 bbb 数组长度必然等于实际LIS的长度。
如果你实在无法理解,我举个例子,序列为 [4,2,5,3,3,4,5][4, 2, 5, 3, 3, 4, 5][4,2,5,3,3,4,5]。
枚举第一个位置,由于选择序列为空,加入选择序列 bbb,此时 b=4b = {4}b=4。
枚举第二个位置,无法更新序列长度,更新 b[1]b[1]b[1] 处的 444,此时 b=2b = {2}b=2。
枚举第三个位置,可以更新序列长度,bbb 中加入 555,此时 b=2,5b = {2, 5}b=2,5。
枚举第四个位置,无法更新长度,更新 b[2]b[2]b[2] 处的 555,此时 b=2,3b = {2, 3}b=2,3。
枚举第五个位置,无法更新长度,也无法更新 b[2]b[2]b[2],此时 b=2,3b = {2, 3}b=2,3。
枚举第六个位置,可以更新序列长度,bbb 中加入 444,此时 b=2,3,4b = {2, 3, 4}b=2,3,4。
枚举第七个位置,可以更新序列长度,bbb 中加入 555,此时 b=2,3,4,5b = {2, 3, 4, 5}b=2,3,4,5。
最终,我们的 LIS 长度为 444,并得出了一组可行方案 [2,3,4,5][2, 3, 4, 5][2,3,4,5]。
这种做法的时间复杂度为 O(n2)O(n^2)O(n2),因为每个元素花了 O(l)O(l)O(l) 时间寻找 aia_iai 在 bbb 中位置,也就是 O(n)O(n)O(n)(因为 lll 和 nnn 同级,最坏情况, l=nl = nl=n),但实际会快一点。
这个东西似乎叫做最优子结构,感兴趣的同学可以去查一查。
int main(){scanf("%d", &n);for(int i = 1; i <= n; ++ i){scanf("%d", &a[i]);}int l = 0;b[0] = -inf;//避免第一个元素是负数,加入不进去for(int i = 1; i <= n; ++ i){if(a[i] > b[l]){b[++ l] = a[i];//更新序列长度}else{int t = 0;for(int j = 1; j < l; ++ j){if(b[j] < a[i]){t = j;}else{break;}}//如果找不到后面的元素 bj<ai(t==0),那就更新第一个元素b[t + 1] = min(b[t + 1], a[i]);}}printf("%d", l);return 0;
}
1.2.3 动态规划
观察到这道题求的是最长的上升子序列,首先上升子序列是单调递增的,只有最后一个元素才会对接下来的选择产生影响,因此我们可以考虑到了 xxx 位置后,对后面元素的影响,这就是题目产生的后效性,需要用设出状态(除了可以从位置设,还可以从值域设,这是两重条件,但是从值域设不好做)。
- 正推:考虑 iii 处向后面进行扩展,接下来我们可以向后面任意一个 jjj 满足 aj>aia_j > a_iaj>ai 的地方转移,更新 jjj 处的答案。初始化每个位置的答案为 111。
更规范的写法:设 fif_ifi 表示 iii 位置的 LIS 长度,考虑向后进行转移,fj=fi+1(j>i,aj>ai)f_j = f_i + 1(j > i, a_j > a_i)fj=fi+1(j>i,aj>ai),初始化 fi=1(1≤i≤n)f_i = 1(1 \le i \le n)fi=1(1≤i≤n),答案为 max{fi}(1≤i≤n)\max\{f_i\}(1 \le i \le n)max{fi}(1≤i≤n)。时间复杂度为 O(n2)O(n^2)O(n2),实际转移数量与正序对数量有关。
//这里我们就省略读入等操作了,和之前一样
//初始化
for(int i = 1; i <= n; ++ i){f[i] = 1;
}
for(int i = 1; i <= n; ++ i){for(int j = i + 1; j <= n; ++ j){if(a[j] > a[i]){//向后转移f[j] = max(f[j], f[i] + 1);}}
}
int ans = 0;
for(int i = 1; i <= n; ++ i){ans = max(ans, f[i]);
}
printf("%d", ans);
- 反推:考虑 iii 从哪里来,iii 可以从前面任意一个位置的 aaa 小于 aia_iai 的地方转移过来,初始化 000 位置为 000,也可以直接初始化每个位置为 111。
更正规的写法:设 fif_ifi 表示 iii 位置的 LIS 长度,考虑从前进行转移,fi=fj+1(j<i,aj<ai)f_i = f_j + 1(j < i, a_j < a_i)fi=fj+1(j<i,aj<ai),初始化 f0=0f_0 = 0f0=0,答案为 max{fi}(1≤i≤n)\max\{f_i\}(1 \le i \le n)max{fi}(1≤i≤n)。时间复杂度为 O(n2)O(n^2)O(n2),实际转移数量与正序对数量有关。
f[0] = 0;
a[0] = -inf;//这样写要保证长度为1(第一个元素)能转移过来
for(int i = 1; i <= n; ++ i){for(int j = 0; j < i; ++ j){//注意边界if(a[j] < a[i]){//从前转移f[i] = max(f[i], f[j] + 1);}}
}
int ans = 0;
for(int i = 1; i <= n; ++ i){ans = max(ans, f[i]);
}
printf("%d", ans);
然后这就是两种 dp 做法。虽然平常我常用逆推,但实际上,这两种做法并无优劣之分,本质上是相同的,在一些场景下正推好用,在一些场景下逆推好用,有条件的还可以写成记忆化搜索做,搜索因为有剪枝所以往往比 dp 快,但注意栈空间。
我们还是来对比一下两种做法,两者时间复杂度相同,速度也差不多:
状态设计 | 转移方程 | 初始化 | 结果 | |
---|---|---|---|---|
正推 | fif_ifi 表示 iii 位置的 LIS 长度 | fj=fi+1(j>i,aj>ai)f_j = f_i + 1(j > i, a_j > a_i)fj=fi+1(j>i,aj>ai) | a0=−inf,f0=0a_0 = -inf, f_0 = 0a0=−inf,f0=0 或 fi=1(1≤i≤n)f_i = 1(1 \le i \le n)fi=1(1≤i≤n) | max{fi}(1≤i≤n)\max\{f_i\}(1 \le i \le n)max{fi}(1≤i≤n) |
逆推 | fif_ifi 表示 iii 位置的 LIS 长度 | fi=fj+1(j<i,aj<ai)f_i = f_j + 1(j < i, a_j < a_i)fi=fj+1(j<i,aj<ai) | a0=−inf,f0=0a_0 = -inf, f_0 = 0a0=−inf,f0=0 或 fi=1(1≤i≤n)f_i = 1(1 \le i \le n)fi=1(1≤i≤n) | max{fi}(1≤i≤n)\max\{f_i\}(1 \le i \le n)max{fi}(1≤i≤n) |
1.2.4 三种方法对比
这就是三种基本的 O(n2)O(n^2)O(n2) 做法,实际上有些做法分了类,这里我们把它们的优劣列举出来。
-
搜索。时间复杂度均摊 O(n2)O(n^2)O(n2),速度不稳定,在随机数据下比较快,最优 O(n)O(n)O(n),最坏 O(n2)O(n^2)O(n2)。很难进行深入的扩展(虽然带权是可以的),且均摊时间复杂度不稳定,想在上面扩展可能会破坏均摊结果。但是可以用搜索来骗分。
-
贪心。时间复杂度 O(n2)O(n^2)O(n2),速度较稳定,如果数据水的话时间复杂度是 O(n)O(n)O(n),扩展性稍弱,无法解决带权问题,如果带权会带来贪心无法解决的后效性。速度略比动态规划快。
-
动态规划。时间复杂度 O(n2)O(n^2)O(n2),速度非常稳定,不管怎样复杂度都不会变。扩展性很强,能解决很多问题。如果你要用它来骗分的话(想要骗水的一些更大范围的数据),估计效果不是很好。
1.3 从 O(n^2) 到 O(n \log n) 的优化
1.3.1 优化思路
先看我们搜索的 O(n2)O(n^2)O(n2) 做法,发现很难避免不枚举所有元素及其后面的元素,除非你的剪枝足够好,用了玄学做法,否则无法优化到 O(nlogn)O(n \log n)O(nlogn),尽管 O(n2)O(n^2)O(n2) 的搜索在随机数据下已经足够优异。
再来看贪心,可以发现 bbb 数组单调递增,我们查找最后一个 x>bix > b_ix>bi 的元素,可以考虑用二分进行优化,时间复杂度 O(nlogn)O(n \log n)O(nlogn)。
最后看动态规划,先看正推,正推向后转移,需要将区间 >ai>a_i>ai 的值全部更新上 fi+1f_i + 1fi+1,可以用数据结构维护,反推类似,区间 <ai<a_i<ai 的值用来更新 fif_ifi。时间复杂度为 O(nlogn)O(n \log n)O(nlogn)。
1.3.2 贪心
具体的来说一下如何优化,在查找 x>bix > b_ix>bi 的最大的 iii 时,可以二分查找,这里可以用系统自带的 lower_bound
,查找小于等于 xxx 的最大位置,更新查找位置加 111的位置,或者用 upper_bound
,查找第一个 x≤bix \le b_ix≤bi 的位置,就可以直接修改查找位置了。当然,你也可以手写二分查找,听说比系统自带的二分要快一些,我自己也试了试。
如果你不会手写二分,或想偷懒,写系统自带的函数注意带不带等号。lower_bound
查找的小于等于 xxx 的最大位置,upper_bound
查找的是大于 xxx 的最小位置。
int l = 0;
b[0] = -inf;
for(int i = 1; i <= n; ++ i){if(a[i] > b[l]){b[++ l] = a[i];//更新序列长度}else{//以下的t1,t2,t3都表示更新位置,值都相同,只是求解方法不同,我一般会用 lower_bound 的方法int t1 = lower_bound(b, b + l + 1, a[i] - 1) - b + 1;int t2 = upper_bound(b, b + l + 1, a[i] - 1) - b;int pl = 1, pr = l, t3 = 0;//以lower_bound为例while(pl <= pr){int mid = (pl + pr) >> 1;if(b[mid] < a[i]){t3 = mid;pl = mid + 1;}else{pr = mid - 1;}}++ t3;b[t1] = min(b[t1], a[i]);}
}
printf("%d", l);
1.3.3 动态规划
前置知识:离散化。
离散化是将一些分散的数字映射到连续的一段数字的一种算法。根据数值等于的数算成编号相等和算成编号不等两种情况:
- 相等,也是这个题目需要的。首先有一个暴力的方法,用系统自带的
map
或unordered_map
,但是map
是红黑树,常数很大,unordered_map
基于哈希,虽然速度快,但有错误的可能性,经常会被出题人卡。因此,你除了可以多个哈希值进行哈希,达到较快的速度以及较高的正确性以外(其实多次的模运算已经让它的速度和 O(nlogn)O(n \log n)O(nlogn) 的算法差不多了),还可以另外开一个数组,将所有要离散的值存进去,排个序,去重(因为相等元素编号相等),然后借助数组的下标将这些值映射回去,用二分查找即可。
for(int i = 1; i <= n; ++ i){b[i] = a[i];
}
sort(b + 1, b + n + 1);
N = unique(b + 1, b + n + 1) - b - 1;//注意这里返回的是第一个重复位置的指针,所以要-b-1
for(int i = 1; i <= n; ++ i){a[i] = lower_bound(b + 1, b + N + 1, a[i]) - b;
}
//如果要访问原来的a值,可以直接b[a[i]],也可以另开一个数组存储原值
- 不相等,上面的思路可以实现,但是较复杂。这里有一种简单的方法:开一个
struct
结构体存储数值和编号,然后按数值排序,把新排序后的下标和存储的编号对应起来。
struct node{int x, y;
}a[P];
bool cmp(node p, node q){if(p.x == q.x) return p.y < q.y;//这里建议大家写上,否则可能因为系统无法确定两个x相同的优先级从而出现速度变慢或答案错误return p.x < q.x;
}
for(int i = 1; i <= n; ++ i){scanf("%d", &a[i].x);a[i].y = i;
}
sort(a + 1, a + n + 1, cmp);
for(int i = 1; i <= n; ++ i){b[a[i].y] = i;//映射回排名
}
两种方法需要根据题目需求自行决定,比如 LIS 的值域相等的算一样的,而不是算成不同的数。
正推优化:
观察方程 fj=fi+1(aj>ai,j>i)f_j = f_i + 1(a_j > a_i, j > i)fj=fi+1(aj>ai,j>i),我们可以考虑从小到大转移,然后更新 [ai+1,maxn][a_i + 1, maxn][ai+1,maxn] 的部分的 fff 值,这样如果再查询 aka_kak 的值时,能够保证前面所有元素都对后面做了贡献。这里我们可以用值域树状数组或线段树解决,注意离散化。
树状数组最大的难点在于如何修改 >ai> a_i>ai 的部分,我们可以将大于转化为 <N−ai+1< N - a_i + 1<N−ai+1,这样就可以了。
void add(int x, int k){while(x <= N){tr[x] = max(tr[x], k);x += x & -x;}
}
int ask(int x){int ans = 0;while(x){ans = max(ans, tr[x]);x -= x & -x;}return ans;
}
int main(){//读入及离散化部分省略for(int i = 1; i <= N; ++ i){tr[i] = 1;}for(int i = 1; i <= n; ++ i){f[i] = ask(N - a[i] + 1);//查询a[i]add(N - a[i], f[i] + 1);//更新a[i]+1, N-(a[i]+1)+1=N-a[i]}int ans = 0;for(int i = 1; i <= n; ++ i){ans = max(ans, f[i]);}printf("%d", ans);
}
然后线段树我就不想写了,线段树的操作和逆推类似,如果在实现上遇到问题可以来询问我。
逆推优化:
观察我们的转移方程 fi=fj+1(aj<ai)f_i = f_j + 1(a_j < a_i)fi=fj+1(aj<ai),我们求的是满足条件下的最大的 fjf_jfj,这样 fif_ifi 才会最大。因此,我们可以考虑用一个东西来维护小于某个数的最大值映射的值,实际上就是需要能够快速求出值域小于 xxx 对应的值的 max\maxmax。这个我们可以用很多数据结构维护,比较简单的有值域树状数组和线段树。
树状数组:
void add(int x, int k){while(x <= N){//注意,这里N表示值域范围tr[x] = max(tr[x], k);x += x & -x;}
}
int ask(int x){int ans = 0;while(x){ans = max(ans, tr[x]);x -= x & -x;}return ans;
}
int main(){scanf("%d", &n);for(int i = 1; i <= n; ++ i){scanf("%d", &a[i]);}for(int i = 1; i <= n; ++ i){b[i] = a[i];}sort(b + 1, b + n + 1);N = unique(b + 1, b + n + 1) - b - 1;for(int i = 1; i <= n; ++ i){a[i] = lower_bound(b + 1, b + N + 1, a[i]) - b;}for(int i = 0; i <= N; ++ i){f[i] = -inf;}for(int i = 1; i <= n; ++ i){int t = ask(a[i] - 1);f[i] = t + 1;add(a[i], f[i]);}int ans = 0;for(int i = 1; i <= n; ++ i){ans = max(ans, f[i]);}printf("%d", ans);return 0;
}
线段树:
struct node{int l, r, maxn;
}tr[4 * P];
void build(int bh, int l, int r){tr[bh].l = l, tr[bh].r = r, tr[bh].maxn = -inf;if(l == r) return;int mid = (l + r) >> 1;build(bh << 1, l, mid);build(bh << 1 | 1, mid + 1, r);
}
void modify(int bh, int p, int k){//单点修改if(tr[bh].l == tr[bh].r){tr[bh].maxn = max(tr[bh].maxn, k);return;}int mid = (tr[bh].l + tr[bh].r) >> 1;if(p <= mid){modify(bh << 1, p, k);}else{modify(bh << 1 | 1, p, k);}tr[bh].maxn = max(tr[bh << 1].maxn, tr[bh << 1 | 1].maxn);
}
int query(int bh, int l, int r){//区间查询if(r < tr[bh].l || l > tr[bh].r) return -inf;if(tr[bh].l >= l && tr[bh].r <= r){return tr[bh].maxn;}return max(query(bh << 1, l, r), query(bh << 1 | 1, l, r));
}
int main(){scanf("%d", &n);for(int i = 1; i <= n; ++ i){scanf("%d", &a[i]);}for(int i = 1; i <= n; ++ i){b[i] = a[i];}sort(b + 1, b + n + 1);N = unique(b + 1, b + n + 1) - b - 1;for(int i = 1; i <= n; ++ i){a[i] = lower_bound(b + 1, b + N + 1, a[i]) - b;}for(int i = 0; i <= N; ++ i){f[i] = -inf;}build(1, 1, N);for(int i = 1; i <= n; ++ i){int t = query(1, 1, a[i] - 1);f[i] = t + 1;modify(1, a[i], f[i]);}int ans = 0;for(int i = 1; i <= n; ++ i){ans = max(ans, f[i]);}printf("%d", ans);return 0;
}
当值域范围超出了一定限度,或者想追求速度,都可以加上离散化使得代码更快,但是注意如果你想使用离散化之前的数组计算,那么记得修改。
1.3.4 优化方法对比
贪心的做法速度快,但是扩展性较弱。动态规划的两种方法,树状数组常数小,大约比贪心做法慢一倍,扩展性比线段树弱,而线段树常数大,至少比树状数组慢一倍,扩展性较强,当然如果你用什么平衡树之类的我就不说了,太过于大材小用了。
做法 | 时间复杂度和常数 | 额外空间复杂度(不计入离散化数组) | 扩展性 |
---|---|---|---|
贪心+二分 | O(nlogn)O(n \log n)O(nlogn),常数很小 | O(n)O(n)O(n) | 较弱 |
动态规划+树状数组 | O(NlogN)O(N \log N)O(NlogN),常数较小 | O(N)O(N)O(N) | 较强 |
动态规划+线段树 | O(NlogN)O(N \log N)O(NlogN),常数较大 | O(4×N)O(4 \times N)O(4×N),有时可通过动态开点优化时空复杂度 | 更强 |
以上是关于最长上升子序列(LIS)的基础内容,接下来下一篇文章将对LIS进行扩展,敬请期待。
习题:
洛谷B3637 最长上升子序列
洛谷P1091 合唱队形
洛谷P1233 木棍加工
洛谷P2782 友好城市
洛谷P1020 [NOIP 1999 提高组] 导弹拦截
洛谷P1103 书本整理
以下题目虽然和LIS关系不大,但是值得一练:
洛谷P1908 逆序对
洛谷P1637 三元上升子序列
题外话:之前写过一篇动态规划的文章,最主要的难点是推式子,由于篇幅很长可能会出错,欢迎各位找出漏洞和询问不懂的地方。
也欢迎各位点赞收藏,我们还会继续更新扩展篇。