给定长为 、字符集 的字符串,给定 ,问最少花多少时间能够打字打出这个字符串。关于打字的操作有三种:
- 花费 时间,在末尾打出一个字符。
- 花费 时间,复制已经打出的字符串的一个子串。(剪贴板改变)
- 花费 时间,在末尾粘贴复制的字符串。(剪贴板不变)
动态规划
-
状态表示: 表示打完前 个字符、最后一步复制了长为 的段的最小代价。
- ,否则不如全手打
- ,否则无法复制
- 特别地, 表示最后一个字符是手打出来的。
- 用 表示 取任意合法值时的最小
- 用 表示满足 , 的最大位置,即最后一次不相交的出现位置。如果没有这样的位置, .
-
状态转移:
- ,表示手打一个字符
-
,表示当场复制一段再粘贴。
需要满足 ,即这个子串在之前不相交地出现过一次。 -
,表示使用之前的剪贴板。
因为从上一次复制到这次复制之间剪贴板不会变化,所以中途的字符全部手动打出,最后只粘贴一次即可。
需要满足 ,即这个子串在之前不相交地出现过两次。
-
状态边界: .
-
答案状态:
-
复杂度:
只剩下一个问题,怎么求 ?
后缀自动机,启发式合并,三指针
时刻注意,后缀自动机的每个节点对应一组长度连续且依次为后缀的字符串,它们在原字符串中具有相同的出现位置(endpos集)。
易证一个结论: ,即每个节点的endpos集大小乘以字符串个数就是这个节点所有字符串的所有出现位置,所有节点的总和正好是子串个数。
现在 数组正好有 项,我们可以依次处理每个节点,求得每个endpos与每个字符串长度的 .
为了获得节点的endpos集,可以先建立后缀自动机,然后使用启发式合并的方法依次处理每个节点。
遍历一个endpos集,对于最短的字符串长度,使用双指针求它的lst。每求得一个endpos的答案,就再添加一个指针,依次把字符串长度增大到最长,求得这个endpos的所有答案。
使用上面的结论可以证明,这样的做法复杂度总和是 的。
启发式合并还有 的复杂度,不过加上之后总复杂度还是 。
总结与代码
- 这题多组数据多倍时限,memset没用好可能会超时。
- RE一次,因为dsu的部分忘了开双倍空间。
- 很多较难的题目,都是需要先猜结论的。大胆假设,小心求证。
- 系统总结了sam+dsu的写法,训练场上写了不少时间,这道题可以打印下来当模板备用。
- 双指针法的扩展,三指针法,细节思考了比较久时间。
- 获得了新结论,可以用来证明复杂度,训练场上计算了好长时间的时间复杂度,算不出来不敢写。
- 现场赛只有两个队过,AC的时候挺高兴的,后来看知乎上出题人说这题medium easy,TAT,不过确实不是特别难。
- 使用自动机的字符串题会自带小常数,很难卡掉,不要过度优化,勇敢地交吧!
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
inline int read()
{
int x=0,f=1; char ch=getchar();
while(!isdigit(ch)){if(ch=='-')f=-1;ch=getchar();}
while(isdigit(ch)){x=x*10+ch-'0';ch=getchar();}
return x*f;
}
const int M = 5016, MOD = 1000000007;
namespace sam
{
int sz, lst; map<int, int> ch[M<<1];
int len[M<<1], link[M<<1], pre[M<<1];
int create(int id=0)
{
ch[id].clear();
len[id] = link[id] = pre[id] = 0;
return sz = id;
}
void extend(int n)
{
create(0); lst = 0;
for(int i=0; i<n; ++i)
{
int c = read(), cur = create(sz+1);
len[cur] = len[lst] + 1;
int p = lst;
while(!ch[p][c])
{
ch[p][c] = cur;
p = link[p];
}
if(ch[p][c] != cur)
{
int q = ch[p][c];
if(len[p]+1 == len[q]) link[cur] = q;
else
{
int clone = create(sz+1);
ch[clone] = ch[q];
link[clone] = link[q];
len[clone] = len[p] + 1;
while(ch[p][c]==q)
{
ch[p][c] = clone;
p = link[p];
}
link[q] = link[cur] = clone;
}
}
lst = cur;
pre[cur] = i+1;
}
}
}
int csf[M][M]; //csf[i][j]表示以i为endpos、长为j的子串,最后一个小于等于j-i的endpos
namespace dsu
{
using namespace sam;
vector<int> son[M<<1]; //树
set<int> st[M<<1]; int tar[M<<1];
int tmp[M];
void merge(int a, int b)
{
if(st[tar[a]].size()<st[tar[b]].size()) swap(a,b);
for(auto x:st[tar[b]])
st[tar[a]].insert(x);
st[tar[b]].clear();
tar[b] = tar[a];
}
void dfs(int u)
{
for(auto v:son[u])
dfs(v), merge(u, v);
//下方是启发式合并的实际处理,本题的终极目标是得到csf数组
if(!u || !st[tar[u]].size()) return;
int cnt = 0;
for(auto x : st[tar[u]])
tmp[cnt++] = x;
int l1 = len[link[u]]+1, l2 = len[u];
for(int i=1, j=-1; i<cnt; ++i) //i表示右侧指针,j表示让i,l1合法的最后一个指针
{
while(j+1<i && tmp[i]-tmp[j+1]>=l1) ++j;
for(int k=j, l=l1; ~k && l<=l2; ++l) //k表示让i,l合法的最后一个指针
{
while(~k && tmp[i]-tmp[k]<l) --k;
if(~k) csf[tmp[i]][l] = tmp[k];
}
}
}
void solve()
{
st[tar[0]].clear();
for(int i=0; i<=sz; ++i)
{
son[i].clear();
tar[i] = i;
if(pre[i]) st[i].insert(pre[i]);
}
for(int i=1; i<=sz; ++i)
son[link[i]].push_back(i);
dfs(0);
}
}
ll dp[M][M]; //dp[i][j]表示写完前i个字符的最后一步是粘贴了长为j的段,的最小花费。
ll mi[M]; //mi[i]表示dp[i][]的最小值
int main(void)
{
#ifdef _LITTLEFALL_
freopen("in.txt","r",stdin);
#endif
int T = read();
for(int kase=1; kase<=T; ++kase)
{
int n = read(), x = read(), y = read(), z = read();
memset(csf, 0, (n+1)*sizeof(csf[0]));
memset(dp, 0x3f, (n+1)*sizeof(dp[0]));
memset(mi, 0x3f, (n+1)*sizeof(mi[0]));
sam::extend(n);
dsu::solve(); //求得csf
dp[0][0] = mi[0] = 0;
for(int i=1; i<=n; ++i)
{
dp[i][0] = mi[i-1] + x;
mi[i] = dp[i][0];
for(int j=z/x; j<=i/2; ++j) if(csf[i][j])
{
dp[i][j] = mi[i-j] + y + z;
if(csf[csf[i][j]][j])
dp[i][j] = min(dp[i][j], dp[csf[i][j]][j] + 1ll*x*(i-j-csf[i][j]) + z);
mi[i] = min(mi[i], dp[i][j]);
}
}
printf("Case #%d: %I64d\n", kase, mi[n] );
}
return 0;
}