最长上升子序列
引入
以下记 \(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