最长上升子序列
引入
以下记 \(s\) 的长度为 \(n\),\(t\) 的长度为 \(m\)。用 \(s[l\dots r]\) 表示 \(s\) 的 \(l\) 到 \(r\) 的子段。子段即在原序列头尾删去一些(可以什么都不删)后得到的序列。
一些问题:
-
什么是子序列?
称 \(t\) 是 \(s\) 的子序列,即是 \(s\) 删掉一些元素(可以什么都不删)后可以得到 \(t\)。
-
什么是上升子序列?
称 \(t\) 是上升子序列,仅当 \(s\) 的子序列 \(t\) 满足 \(\forall i\in[1,m),t_i<t_{i+1}\)(即单调上升)。
-
什么是最长上升子序列?
是上升子序列中长度最长(可能多个)的。
(最长不降子序列同理。)
以下将最长上升子序列简写为 LIS。
注意:LIS 可能有多个。
dp
\(O(n^2)\) 求 LIS 及其长度
做法
记 \(dp_i\) 表示 \(s\) 的前缀 \(s[1\dots i]\) 的以 \(s_i\) 结尾的 LIS 的长度。
可以发现,因为我们一定要选 \(s_i\),我们可以枚举上一个选的 \(s_j\),并用 \(dp_j\) 更新 \(dp_i\):
很好理解,因为一定选 \(s_i\),所以长度要加 \(1\)。
答案是 \(\max\{dp_i\},1\le i\le n\),因为每一个 \(1\le i\le n\) 都可能当 LIS 的结尾。假设 \(s\) 的 LIS 的结尾是 \(s_e\),那么 \(dp_e\) 即为答案。
若想求 LIS,则记录前驱即可。
代码
(以前写的,码风丑,见谅。)
#include <iostream> using namespace std; int main() { int n; scanf("%d",&n); int a[n+1]; for(int i=1;i<=n;i++) { scanf("%d",a+i); } int dp[n+1]; fill(dp,dp+n+1,0); dp[1]=1; for(int i=2;i<=n;i++) { for(int j=1;j<i;j++) { if(a[j]<a[i]) { dp[i]=max(dp[i],dp[j]+1); } else { dp[i]=max(dp[i],1); } } } int ans=-1; for(int i=1;i<=n;i++) { ans=max(ans,dp[i]); } printf("%d",ans); return 0; }
这样做是 \(O(n^2)\) 的。能否优化?
\(O(n\log n)\) 求 LIS 及其长度
做法
考虑对 dp 进行优化。
可以发现后面的两个不等式是一个二维偏序(类似逆序对)。由于对于 LIS,原序列的顺序是重要的,故不对 \(a\) 进行排序。直接从小到大遍历 \(i\),这样就满足了第一个条件。
对 \(a\) 离散化,使 \(1\le a_i\le n\)。以 \(a_i\) 为下标,对 \(dp_i\) 使用一种权值 RMQ,每次对于 \(\forall 1\le j<i,a_j\in[1,a_i)\) 查询 \(\max\{dp_j\}+1\),即为 \(dp_i\)。树状数组(BIT)可以胜任。
具体(人话)的,离散化后遍历每个 \(1\le i\le n\)。设 BIT 维护的数组为 \(t\),则
然后修改
这样就同时满足了两个不等式,并计算出了 \(dp_i\)。
同样地,若想求 LIS,则在 BIT 中额外增加第二个比较关键字,记录想要的前驱即可。
代码
#include <iostream> #include <algorithm> using std::cin; typedef long long ll; constexpr int N=214514; int n; int a[N],b[N],dp[N]; struct bit { int t[N]; #define lb (x&(-x)) inline void clr(){for(int i=0;i<=n;i++)t[i]=0;} inline void mdf(int x,int y){for(;x<=n;x+=lb)t[x]=std::max(t[x],y);} inline int qry(int x){int res=0;for(;x;x-=lb)res=std::max(res,t[x]);return res;} }; void Main() { cin>>n; t.clr(); for(int i=1;i<=n;i++)cin>>a[i]; std::copy(a+1,a+n+1,b+1); std::sort(b+1,b+n+1); int tot=std::unique(b+1,b+n+1)-b-1; for(int i=1;i<=tot;i++)pos[i].clear(); for(int i=1;i<=n;i++)a[i]=std::lower_bound(b+1,b+tot+1,a[i])-b; int len=0; for(int i=1;i<=n;i++) { dp[i]=t.qry(a[i]-1)+1; t.mdf(a[i],dp[i]); len=std::max(len,dp[i]); } printf("%d\n",len); } int main() { std::ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); int T; cin>>T; while(T--)Main(); return 0; }
一些奇怪的做法
\(O(n\log n)\) 求 LIS 长度
做法
发现当 \(s[1\dots i-1]\) 的 LIS 中最后一个数小于 \(s_i\) 时,\(s[1\dots i]\) 的 LIS 可以照抄 \(s[1\dots i-1]\) 的,并在最后加上 \(s_i\)。
原因:(为方便,暂时记 \(s[1\dots i-1]\) 的 LIS 为 \(t\))
依然单调上升。
我们当然希望每次都满足这个条件,但不满足怎么办?
臆想:直接将 \(s_i\) 替换进 \(t\)。
其实有一定合理性。我们将设法用 \(s_i\) 替换 \(t\) 中某个位置,并让 \(t\) 依然单调递增。容易发现这个位置是第一个大于等于 \(s_i\) 的数。原因:
大括号处不等式理由:\(t_{pos}\) 第一个大于等于 \(s_i\),所以 \(t_{pos-1}\not\ge s_i\) 即 \(t_{pos-1}<s_i\)。
\(pos\) 可以用 lower_bound()
二分确定(因为 \(t\) 单调递增)。(如果是最长不降,用 upper_bound()
,原因可类比。)
但 \(t\) 就没有了顺序性:后来的 \(s_i\) 反而跑到了前面,所以这个操作使 \(t\) 不合法。
其实这个操作并不影响 \(t\) 的长度,所以答案不会变劣。但可能变好:如果 \(s_i\) 刚好是答案序列的一部分,那么它使 \(t\) 的字典序变小,递增长度可能更长,答案可能会变得更大。(其实挺玄学的。引用物理老师的一句话:“这个说不清楚,自己悟吧。”)
最后,我们得到了一个 \(O(n\log n)\) 求 LIS 长度的算法。
代码
#include <iostream> #include <vector> #include <algorithm> using namespace std; const int N=114514; vector<int> v; int a[N]; int main() { int n; scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d",a+i); } for(int i=1;i<=n;i++) { int pos=lower_bound(v.begin(),v.end(),a[i])-v.begin(); if(pos==v.size()) { v.push_back(a[i]);// 1 } else { v[pos]=a[i]; } } printf("%d",v.size()); return 0; }
如果求字典序最小的 LIS 呢?
\(O(n\log n)\) 求字典序最小的 LIS
复杂度未知。
考虑使上面的算法可以保存答案。显然,算法结束后的 \(t\) 序列不一定是答案序列(可能不合法)。上面的算法只是尽可能地让 \(t\) 变长。
为什么说只是“可能不合法”?显然如果从开始到结束全部都是将 \(s_i\) 添加到 \(t\) 末尾(即上面代码中每次都执行 // 1
处操作),结束时 \(t\) 合法且字典序最小。因为只要添加到最后,就既不破坏 \(t\) 的递增性,也不破坏 \(t\) 的顺序性。
那么如果恰好中间有一个操作替换到了序列 \(t\) 中呢?(我们设替换到了 \(t_{pos}\) 的位置)的确,整个序列 \(t\) 变得不合法了,但 \(t[1\dots pos]\) 依然合法,且比原来的字典序小了。
自然地想到:可以维护 \(ans_i\) 数组表示 \(t[1\dots i]\)(即 \(s\) 的长度为 \(i\) 的字典序最小的上升子序列)。\(ans_i\) 初始时均为空。注意 \(ans_i\) 时一个序列而非数,\(ans\) 中存了很多序列。
但替换进 \(t_{pos}\) 时,可以维护 \(ans_{pos}=ans_{pos-1}\cup s_i\)。(\(\cup\) 表示在末尾加上。)
你可能会疑惑:如果以后 \(ans_{pos-1}\) 更新了,不用更新 \(ans_{pos}\) 吗?不用的。因为如果 \(ans_{pos}\) 更新,必然意味着 \(ans_{pos-1}\) 中出现(替换进)了一个 \(s_j\),且 \(j>i\)(我们按顺序遍历 \(s_i\))。如果此时更新 \(ans_{pos}\),意味着在更新后的 \(ans_{pos}\) 中,有 \(s_j\) 排在 \(s_i\) 前面。这不符合顺序性。而第一次更新时 \(ans_{pos-1}\) 是最优的,相信就好了。
最后的答案是 \(ans_m\)。\(m\) 需要在不断添加 \(s_i\) 中维护。\(ans_i\) 可以用 vector
实现。
这样算法是 \(O(n\log n)\) 的,空间复杂度 \(O(mn)\),最坏时 \(m=n\),空间复杂度 \(O(n^2)\),属于空间换时间、答案了。一般不会达到最差。(其实 \(O(n^2)\) 求字典序最小的 LIS 似乎更复杂。)
代码
#include <iostream> #include <vector> #include <algorithm> using namespace std; const int N=114514; vector<int> v,ans[N]; int a[N]; int main() { int n; scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d",a+i); } for(int i=1;i<=n;i++) { int pos=lower_bound(v.begin(),v.end(),a[i])-v.begin(); if(pos==v.size()) { v.push_back(a[i]); } else { v[pos]=a[i]; } ans[pos]=ans[pos-1]; ans[pos].push_back(a[i]); } for(int t:ans[v.size()-1]) { printf("%d ",t); } return 0; }
拓展
例题:LuoguRMJ (AtCoder) [ABC354F] Useless for LIS
题意简述:给定长度为 \(n\) 的序列 \(a\),求所有的 \(i\),使 \(a_i\) 在任意 LIS 中。
解
钦定 \(a_i\) 在 LIS 中,即计算出以 \(a_i\) 结尾和以 \(a_i\) 开头的 LIS,两者长度之和减 \(1\) 即为 必须包含 \(a_i\) 的 LIS 的长度。若该长度等于全局 LIS 长度,则输出 \(i\)。
EOF
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具