最长上升子序列

引入

以下记 \(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\)

\[dp_i=\max\{dp_j+1\},1\le j<i\le n,a_j<a_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 进行优化。

\[dp_i=\max\{dp_j+1\}=\max\{dp_j\}+1,1\le j<i\le n,a_j<a_i \]

可以发现后面的两个不等式是一个二维偏序(类似逆序对)。由于对于 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=\max_{j\in[1,a_i)}\{t_j\}+1 \]

然后修改

\[t_{a(i)}\gets \max\{t_{a(i)},dp_i\} \]

这样就同时满足了两个不等式,并计算出了 \(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\)

\[\overbrace{t_1<t_2<\cdots<t_m}^{\text{LIS 原有条件}}<s_i \]

依然单调上升。

我们当然希望每次都满足这个条件,但不满足怎么办?

臆想:直接将 \(s_i\) 替换进 \(t\)

其实有一定合理性。我们将设法用 \(s_i\) 替换 \(t\) 中某个位置,并让 \(t\) 依然单调递增。容易发现这个位置是第一个大于等于 \(s_i\) 的数。原因:

\[t_1<t_2<\dots<\overbrace{t_{pos-1}<s_i}\le t_{pos}<t_{pos+1}<\dots<t_m \]

大括号处不等式理由:\(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

posted @ 2023-10-17 22:34  Po7ed  阅读(21)  评论(0编辑  收藏  举报