Technocup 2019 - Final D - Compress String 解题报告

Technocup 2019 - Final D - Compress String 解题报告

Description

给你一个字符串 \(S\),现在有另一个字符串 \(T\),初始为空串,每次可以按照字符在 \(S\) 中的顺序往它后面加上一段字符串或一个字符并支付一定代价,你需要最小化使 \(T\) 变为 \(S\) 的代价。代价的计算方式如下:

  • 若往 \(T\) 后加一个字符,则支付 \(a\) 的代价,若该字符为前面已加入字符串的子串,则可以选择支付 \(b\) 的代价而不用支付 \(a\) 的代价

  • 若往 \(T\) 后加一个字符串,则该字符串必须满足为前面已加入字符串的子串,这种情况下,需要支付 \(b\) 的代价

Hint

\[n,a,b\le 5000 \]

注意,数据不一定满足 \(b<a\)

Solution

考虑DP,显然有以下方程:

\[dp[i+1]=min(dp[i+1],dp[i]+a)\\ dp[j]=min(dp[j],dp[i]+b) \quad i+1\le j\le n\&\&substring(i+1,j)\supset substring(1,i) \]

其中 \(substring(i,j)\) 表示原串下标为 \(i...j\) 这一段的字符串

那么现在问题就转化成了如何判断子串关系

观察这个式子,我们发现只需要判断从当前位置向后延伸的一段是否是 \(1\) 到当前位置的子串即可,如果到某个位置后不满足了,那么再往后也不可能再满足了。考虑构造 \([1,i]\) 的后缀自动机,这样我们就可以把后边的串拿过来在后缀自动机上走,一旦没有转移边就 \(break\) 掉。而又由于后缀自动机是增量构造,所以维护起来也非常方便

当然,查询是否是子串也可以 Hash 加上 unordered_map 存储一下就行了

以上两种做法复杂度都是 \(O(n^2)​\),可以通过本题 \(5000​\) 的数据

另提一句,对于数据范围较大的随机数据,使用后缀自动机还是可以在较短的时间内输出结果,因为随机数据下向后找子串很快就会 \(break\) 退出了,但是如果用 Hash 的话空间可能会炸掉

Addition_1

如果 \(n\le 5\times 10^5\) 怎么做?

注意到我们原来的方程每次必定会在后缀自动机上一直走到不能走为止,那么对于一个位置 \(i\),它有一个最右端点 \(pos\),我们现在考虑将当前位置 \(i\) 移动到下一个位置 \(i+1\) 会对这个 \(pos\) 有什么影响

显然这是具备单调性的,即当 \(i\) 向右移动时,\(pos\) 这个位置将会单调不减,因为当 \(substring(i+1,pos)\) 的第一个字符被删去时,由于子串长度缩短,所以其 \(endpos\) 集合必定不增,所以当前 \(pos\) 位置往前的所有位置都还是 \(substring(1,i)\) 的子串

既然如此,我们用 \(two-pointers\) 的思想,对于所有的 \(i\),在总复杂度为 \(O(n)\) 的情况下找出它们所对应的最右端点的 \(pos\),然后对 \([i+1,pos]\) 这段区间用线段树实现 \(O(\log n)\) 的区间取 \(\min\) 操作,便可以在 \(O(n\log n)\) 的时间内得到答案

Addition_2

如果 \(n\le 5\times 10^6\) 并保证字符集 \(\alpha \le 7\),时间限制 \(3\text{s}\),空间限制 \(512\text{MB}\),怎么做?

根据数据范围,这个做法应该是 \(O(n\times \alpha)\) 的,那么我们尝试把上面的线段树查询与修改的 \(\log\) 删掉

考虑使用单调队列,记录两个变量 \(x\)\(val\),表示这个 \(val\) 值最多只能更新到 \(x\) 这个位置,由于 \(x\) 是单调不减的,所以就可以用普通单调队列优化方式进行优化了

时间复杂度 \(O(n\times \alpha)\),空间复杂度 \(O(n\times \alpha)\)

Supplement

之前我们提到了判断子串这一操作,那么在头尾都要移动的情况下应该如何实现这一操作呢?

首先非常简单的是向后加字符,这种情况我们只要走转移边就可以了

所以现在让我们来思考一下前面删字符怎么办

其实这也不难,考虑到后缀自动机的 \(link\) 指向的节点所包含的字符串都是当前节点字符串的后缀,所以我们只需要往上跳 \(link\) 边即可。具体来说,我们记录当前在后缀自动机上的位置 \(k\) 与已经匹配的子串长度 \(nowlen\),如果要在前面删字符,我们就让 \(nowlen\) 减一并查看是否 \(nowlen\) 已经小于了当前节点所包含的最短后缀的长度,如果小于了,就跳 \(link\)

但是这样做是错的

因为后缀自动机是增量构造,随着 \(i\) 的增加逐渐往后添加字符,所以在这过程中由于节点分裂的原因 \(link\) 边可能会被更改。举例来说,当前已经匹配到了节点 \(k\),长度为该节点所能接受的最短长度,这时我们分裂了这个节点,导致当前字符串所能匹配到的节点变成了分裂过后的 \(k\)\(link\) 到的节点,那么在这种情况下,我们需要跳两次 \(link\) 边而不只是一次

其实为了避免这种情况出现,我们也可以直接将后缀自动机先全部建好,然后用线段树合并处理好每一个节点的 \(endpos​\) 集合,往后走查询是否可以继续扩展的时候就可以直接查询所在节点的 \(endpos​\) 集合是否有一些部分在 \([1,i]​\) 中就可以了(虽然这样做就是 \(O(n\log n)\)了...)

另外还有一种填表式的DP方程可以在后缀自动机与线段树合并的基础上再加上单调队列优化强行提高题目难度,我在这就不多说了,复杂度也是 \(O(n\log n)\) 的,可能常数略大,空间开销也比较大,代码放在下面

那么这样这道题就解决了

Code

做法 \(1\):填表式DP+动态开点线段树合并+单调队列优化,常数较大

#include<cstdio>
#include<iostream>
using namespace std;
const int N=5e5+10;
const int LOG=20;
const int INF=2e9;
int n,A,B,tot,las,rt[N<<1],cnt,b[N<<1],c[N],k,nowlen,q[N],head,tail,dp[N];char s[N];
struct Suffix_Automaton{int ch[26],len,siz,link,pos;}SAM[N<<1];
struct Dynamic_Segment_Tree{int lc,rc,val;}t[N*LOG*2];
inline void update(int &o,int L,int R,int x){
	o=++cnt;t[o].val++;if(L==R)return;
	int M=(L+R)>>1;
	if(x<=M)update(t[o].lc,L,M,x);
	else update(t[o].rc,M+1,R,x);
}
inline int Merge(int a,int b){
	if(!a||!b)return a^b;
	int z=++cnt;
	t[z].val=t[a].val+t[b].val;
	t[z].lc=Merge(t[a].lc,t[b].lc);
	t[z].rc=Merge(t[a].rc,t[b].rc);
	return z;
}
inline int query(int o,int L,int R,int l,int r){
	if(!o||!t[o].val||l>r)return 0;
	if(l<=L&&R<=r)return t[o].val;
	int M=(L+R)>>1,sum=0;
	if(l<=M)sum+=query(t[o].lc,L,M,l,r);
	if(r>M)sum+=query(t[o].rc,M+1,R,l,r);
	return sum;
}
inline void RadixSort(){
	for(register int i=1;i<=tot;i++)c[SAM[i].len]++;
	for(register int i=1;i<=n;i++)c[i]+=c[i-1];
	for(register int i=tot;i;i--)b[c[SAM[i].len]--]=i;
}
inline void PreMerge(){
	for(register int i=tot;i;i--)
		rt[SAM[b[i]].link]=Merge(rt[SAM[b[i]].link],rt[b[i]]);
}
inline void Insert(int x){
	int cur=++tot;SAM[cur].len=SAM[las].len+1;SAM[cur].siz=1;SAM[cur].pos=x;
	update(rt[cur],1,n,SAM[cur].pos);int p=las;
	while(p!=-1&&!SAM[p].ch[s[x]-'a'])SAM[p].ch[s[x]-'a']=cur,p=SAM[p].link;
	if(p==-1){SAM[cur].link=0;las=cur;return;}
	int q=SAM[p].ch[s[x]-'a'];
	if(SAM[p].len+1==SAM[q].len){SAM[cur].link=q;las=cur;return;}
	int nq=++tot;SAM[nq].pos=SAM[q].pos;
	SAM[nq].link=SAM[q].link;SAM[nq].len=SAM[p].len+1;
	for(register int i=0;i<26;i++)SAM[nq].ch[i]=SAM[q].ch[i];
	SAM[q].link=SAM[cur].link=nq;
	while(p!=-1&&SAM[p].ch[s[x]-'a']==q)SAM[p].ch[s[x]-'a']=nq,p=SAM[p].link;
	las=cur;
}
int main(){
	scanf("%d%d%d",&n,&A,&B);scanf("%s",s+1);
	SAM[0].link=-1;
	for(register int i=1;i<=n;i++)Insert(i);
	RadixSort();PreMerge();
	for(register int i=1;i<=n;i++)dp[i]=INF;
	dp[1]=A;q[head=tail=1]=1;
	for(register int i=2;i<=n;i++){
		dp[i]=min(dp[i-1]+A,dp[i]);
		k=SAM[k].ch[s[i]-'a'];nowlen++;
		while(head<=tail&&!query(rt[k],1,n,1,q[head])){
			head++;
			if(head>tail)nowlen=0;else nowlen=i-q[head];
			while(k&&nowlen<=SAM[SAM[k].link].len)k=SAM[k].link;
		}
		if(head<=tail)dp[i]=min(dp[i],dp[q[head]]+B);
		while(head<=tail&&dp[q[tail]]>=dp[i])tail--;
		q[++tail]=i;
	}
	printf("%d\n",dp[n]);
	return 0;
}

做法 \(2\):刷表式DP+线段树区间取 \(\min\),常数较为优秀

#include<cstdio>
#include<iostream>
using namespace std;
typedef long long ll;
const int N=5e5+10;
const int INF=2e9;
int n,A,B,tot,las,dp[N],minv[N<<2],k,nowlen,now;char s[N];
struct Suffix_Automaton{int ch[26],len,siz,link;}SAM[N<<1];
inline void Insert(int x){
	int cur=++tot;SAM[cur].len=SAM[las].len+1;SAM[cur].siz=1;
	int p=las;
	while(p!=-1&&!SAM[p].ch[s[x]-'a'])SAM[p].ch[s[x]-'a']=cur,p=SAM[p].link;
	if(p==-1){SAM[cur].link=0;las=cur;return;}
	int q=SAM[p].ch[s[x]-'a'];
	if(SAM[p].len+1==SAM[q].len){SAM[cur].link=q;las=cur;return;}
	int nq=++tot;
	SAM[nq].link=SAM[q].link;SAM[nq].len=SAM[p].len+1;
	for(register int i=0;i<26;i++)SAM[nq].ch[i]=SAM[q].ch[i];
	SAM[q].link=SAM[cur].link=nq;
	while(p!=-1&&SAM[p].ch[s[x]-'a']==q)SAM[p].ch[s[x]-'a']=nq,p=SAM[p].link;
	las=cur;
}
inline void build(int o,int L,int R){
	minv[o]=INF;if(L==R)return;
	int M=(L+R)>>1,lc=o<<1,rc=(o<<1)+1;
	build(lc,L,M);build(rc,M+1,R);
}
inline void update(int o,int L,int R,int l,int r,int v){
	if(l>n||r>n||l>r)return;
	if(l<=L&&R<=r){minv[o]=min(minv[o],v);return;}
	int M=(L+R)>>1,lc=o<<1,rc=(o<<1)+1;
	if(l<=M)update(lc,L,M,l,r,v);
	if(r>M)update(rc,M+1,R,l,r,v);
}
inline int query(int o,int L,int R,int x,int val){
	if(L==R)return min(minv[o],val);
	int M=(L+R)>>1,lc=o<<1,rc=(o<<1)+1;
	if(x<=M)return query(lc,L,M,x,min(val,minv[o]));
	else return query(rc,M+1,R,x,min(val,minv[o]));
}
int main(){
	scanf("%d%d%d",&n,&A,&B);scanf("%s",s+1);
	SAM[0].link=-1;
	for(register int i=1;i<=n;i++)dp[i]=INF;
	build(1,1,n);update(1,1,n,1,1,dp[1]=A);nowlen=k=1;
	for(register int i=1;i<n;i++){
		dp[i]=query(1,1,n,i,INF);
		if(dp[i+1]>dp[i]+A)dp[i+1]=dp[i]+A,update(1,1,n,i+1,i+1,dp[i+1]);
		Insert(i);nowlen--;if(now<i){now=i;k=0;nowlen=0;}
		while(k&&SAM[SAM[k].link].len+1>nowlen)k=SAM[k].link;
		while(now<n){
			if(SAM[k].ch[s[now+1]-'a']){now++;k=SAM[k].ch[s[now]-'a'];nowlen++;}
			else break;
		}
		update(1,1,n,i+1,now,dp[i]+B);
	}
	printf("%d\n",query(1,1,n,n,INF));
	return 0;
}

做法 \(3\):刷表式DP+单调队列维护修改

#include<cstdio>
#include<iostream>
using namespace std;
typedef long long ll;
const int N=5e6+10;
const ll INF_ll=4611686018427387903;
int n,A,B,tot,las,k,nowlen,now,head,tail;char s[N];ll dp[N];
struct Suffix_Automaton{int ch[7],len,siz,link;}SAM[N<<1];
struct node{int x;ll val;node(int xx=0,ll vall=0){x=xx;val=vall;}}q[N];
inline void Insert(int x){
	int cur=++tot;SAM[cur].len=SAM[las].len+1;SAM[cur].siz=1;
	int p=las;
	while(p!=-1&&!SAM[p].ch[s[x]-'a'])SAM[p].ch[s[x]-'a']=cur,p=SAM[p].link;
	if(p==-1){SAM[cur].link=0;las=cur;return;}
	int q=SAM[p].ch[s[x]-'a'];
	if(SAM[p].len+1==SAM[q].len){SAM[cur].link=q;las=cur;return;}
	int nq=++tot;
	SAM[nq].link=SAM[q].link;SAM[nq].len=SAM[p].len+1;
	for(register int i=0;i<7;i++)SAM[nq].ch[i]=SAM[q].ch[i];
	SAM[q].link=SAM[cur].link=nq;
	while(p!=-1&&SAM[p].ch[s[x]-'a']==q)SAM[p].ch[s[x]-'a']=nq,p=SAM[p].link;
	las=cur;
}
int main(){
	scanf("%d%d%d",&n,&A,&B);scanf("%s",s+1);
	SAM[0].link=-1;
	for(register int i=1;i<=n;i++)dp[i]=INF_ll;
	dp[1]=A;nowlen=k=1;head=1;
	for(register int i=1;i<=n;i++){
		while(head<=tail&&q[head].x<i)head++;
		if(head<=tail)dp[i]=min(dp[i],q[head].val);
		dp[i+1]=min(dp[i+1],dp[i]+A);
		Insert(i);nowlen--;if(now<i){now=i;k=0;nowlen=0;}
		while(k&&SAM[SAM[k].link].len+1>nowlen)k=SAM[k].link;
		while(now<n){
			if(SAM[k].ch[s[now+1]-'a']){now++;k=SAM[k].ch[s[now]-'a'];nowlen++;}
			else break;
		}
		while(head<=tail&&q[tail].val>=dp[i]+B)tail--;
		q[++tail]=node(now,dp[i]+B);
	}
	printf("%lld\n",dp[n]);
	return 0;
}

对比一下三种做法的差异:(测试用的数据范围为 \(5\times 10^5\) 级别的)

\[做法 1:8694\,ms\quad 347224\,kb\\ 做法 2:3064\,ms\quad 116188\,kb\\ 做法 3:799\,ms\quad 115988\,kb \]

数据由 \(hkk\) 线下制作

Thanks

感谢 \(yy\)\(hkk\) 同我一起讨论激发了我们的灵感

感谢 \(hkk\) 的数据制作

感谢 \(Markdown\)\(\LaTeX\) 能让我完成这篇文章

posted @ 2019-03-14 13:03  ForwardFuture  阅读(219)  评论(0编辑  收藏  举报