题解 CF594E 【Cutting the Line】

· · 题解

部分几乎全部内容摘抄自 zzy 的解题报告,orz zzy。

题目来源

IOI2020 集训队作业中 AC 人数最少的一道,毒瘤字符串。

截至 2020 年 2 月 17 日的统计:

注:一共有 70 个人,所有作业题平均 AC 数量在 40 以上。

题目大意

给定一个长度为 n 的字符串 S,你需要把它划分成至多 k 段,也就是使得 S=s_1s_2\dots s_k,然后决定每一段翻转或者不翻转,最后再顺次拼接得到 T,求字典序最小的 T

数据范围

1\le |S|\le 5\times 10^6,1\le k\le S

预知识

符号含义:

一、\text{Lyndon} 分解

我们定义一个串是 \text{Lyndon} 串,当且仅当这个串的最小后缀就是这个串本身。

该命题等价于这个串是它的所有循环表示中字典序最小的。

引理 1:如果 uv 都是 \text{Lyndon} 串并且 u<v,则 uv 也是 \text{Lyndon} 串。

证明:

1、若 \operatorname{len}(u)\ge\operatorname{len}(v)

这时,uv 这两个串在 \operatorname{len}(v) 之前就出现了不同的字符,所以有 v>uv,又因为 v\text{Lyndon} 串,所以 v 的所有后缀都大于 v,所以 uv 的所有后缀都大于 uv,故 uv\text{Lyndon} 串。

2、若 \operatorname{len}(u)<\operatorname{len}(v)

u 不是 v 的前缀,那么有 v>uv,得证。

uv 的前缀,那么如果 v<uv,则必有 v[\operatorname{len}(u)+1:]<v(也就是各自去掉了前 |u| 个字符),矛盾。

我们定义一个串 S\text{Lyndon} 分解为一个字符串序列 A_1,A_2,\dots,A_m,满足:

可以证明这种划分存在且唯一。

存在性证明

初始令 m=|S| 并且 A_i=S[i],然后每次不断找到 A_i<A_{i+1} 并且合并为一个串。最后一定能使得所有的 A_i\ge A_{i+1}

唯一性证明

假设对于字符串 S 存在两个不同的 \text{Lyndon} 分解:

S=A_1A_2\cdots A_iA_{i+1}A_{i+2}\cdots A_{m_1} S=A_1A_2\cdots A_iA^\prime_{i+1}A^\prime_{i+2}\cdots A^\prime_{m_1}

不妨设 \operatorname{len}(A_{i+1})>\operatorname{len}(A^\prime_{i+1})

观察 A_{i+1} 在第二种分解中的对应情况。假设 A_{i+1}=A^\prime_{i+1}A^\prime_{i+2}\cdots A^\prime_{k}+(A^\prime_{k+1}[:l])

那么由 \text{Lyndon} 串的性质可知:

A_{i+1}<A^\prime_{k+1}[:l]\le A^\prime_{k+1}\le A^\prime_{i+1}<A_{i+1}

矛盾。

引理2:若字符串 v 和字符 c 满足 vc 是某个 \text{Lyndon} 串的前缀,则对于字符 d>cvd\text{Lyndon} 串。

证明:

设该 \text{Lyndon} 串为 v+c+t

\forall i \in [2,|v|],v[i:]+c+t>v+c+t,也就是说 v[i:]+c\ge v

所以 v[i:]+d>v[i:]+c\ge v

同时因为 c>v[1],我们有 d>c>v[1]>v

v+d 是一个 \text{Lyndon} 串。

\text{Duval} 算法

这个算法可以在 O(n) 时间复杂度,O(1) 空间复杂度内求出一个串的 \text{Lyndon} 分解。

该算法中我们仅需维护三个变量 i,j,k

维持一个循环不变式:

如下图:

当前读入的字符是 s[k],令 j=k-|t|

分三种情况讨论:

复杂度分析:i 只会单调往右移动,同时 k 每次移动的距离不会超过 i 移动的距离,所以时间复杂度是 O(n) 的。

代码如下(刚贡献的P6129):

#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
char s[5000005];
int n,ans;
int main()
{
    scanf("%s",s+1);
    n=(int)strlen(s+1);
    for(int i=1;i<=n;)
    {
        int j=i,k=i+1;//初始化
        while(k<=n&&s[j]<=s[k])
        {
            if(s[j]<s[k])j=i;//合并为一整个
            else j++;//保持循环不变式
            k++;
        }
        while(i<=j)//从v的开头继续
        {
            ans^=i+k-j-1;
            i+=k-j;
        }
    }
    printf("%d\n",ans);
    return 0;
}

二、扩展 KMP / Z-algorithm

这个算法可以在 O(n) 的时间复杂度内求出一个串的所有后缀和这个串的LCP,在思路上和 manacher 有想通之处。

我们设 z_i 表示 s[i:]s 的 LCP 长度。注意在这里要满足 i\ge 2

我们设 [i,i+z_i-1] 为第 i 个位置的 Z-box。我们记录当前右端点最靠右的那个 Z-box 为 [l,r]

假设现在要求出 z_i。分两种情况讨论:

由于 r 只会单调往右,所以时间复杂度也是 O(n)

再附一张图:

代码:

int l=0,r=0;
for(int i=2;i<=n;i++)
{
    if(i>r)
    {
        l=r=i;
        while(r<=n&&s[r-l+1]==s[r])r++;
        z[i]=r-l,r--;
    }
    else
    {
        int k=i-l;
        if(z[k]<r-i+1)z[i]=z[k];
        else
        {
            l=i;
            while(r<=n&&s[r-l+1]==s[r])r++;
            z[i]=r-l,r--;
        }
    }
}

解题过程

n=|S|

Part 1:k=n

k=n 的时候,我们注意到不需要考虑分割数量的限制。

注意到,如果其中有一个子串没有翻转,那么我们可以把这个子串进一步分割成单个字符,并且每一个字符都翻转(因为单个字符所以是否翻转无影响),也就是说,一定存在一个最优解,满足划分出来的每一段都进行了翻转。

我们用 S^r 表示 S 的反串,则问题等价于每次从 S^r 中截取一个后缀并且拼接在当前字符串之后,直到 S^r 为空。

引理 3:一个字符串的最小后缀在这个字符串中出现的位置不交

证明:设最小后缀为 A,如果 A 出现的位置有交,则 A 必定有一个前缀等于后缀,那么 A 的那个后缀的字典序就要小于 A,我们就找到了一个更小的后缀,矛盾。

我们先找到 S^r 的最小后缀,假设它为 A,那么为了让最后得到的串字典序尽可能小,我们必然想使得 T 的开头出现尽可能多的 A

可以发现,我们当前操作把后缀 A 拿出来一定不会让答案变劣。

至此,对于 k=n 的情况,我们已经有了思路:只需要每次取 S^r 的字典序最小的后缀,得到的 T 就是字典序最小的。

考虑 \text{Lyndon} 分解的性质。由于 s_m\text{Lyndon} 串,并且对于任意 i<ms_m\le s_i,所以 s_m 就是字典序最小的后缀。

做一遍 \text{Lyndon} 分解,时间复杂度 O(n)

Part 2: k\neq n

k 变小的时候,我们不能随心所欲地进行分割,需要尽可能节约操作次数。

注意到,某些操作是可以合并的:

第一条很简单,关于第二条,别忘了在原问题中我们是可以对字符串进行翻转的。

T 的开头出现尽可能多的 A,这个原则没变。我们把 S^r 中所有的 A 提出来并把 S^r 写成下面这种形式:

S^r=s_0+c_1A+s_1+c_2A+\cdots+s_{m-1}+c_mA

其中 s_0,s_1,\dots,s_{m-1} 是字符串序列,其中 s_1,s_2,\dots,s_{m-1} 不能为空串,c_1,c_2,\dots,c_m 是正整数序列。

case 1:k\ge 3

注意到,如果这个时候我们取的是 c_mA,那么至少可以使得开头出现 c_m+\max_{i=1}^{m-1}c_iA,而如果在别的位置分割,无论如何不可能出现这些个 A

所以此时就是取最后的一段 c_mA

但是我们很快找到了一组反例:S^r=\text{efcdbbbaaa},k=3

此时,A=\text{``a''},S=\text{``efcdbbb''}+3A

如果我们第一次把 3A 分割出来了,那么问题变成了 S=\text{``efcdbbb''},k=2,最终得到的 T 最优是 \text{``aaabbbdcef''}

而如果我们第一次截取的是 \text{``bbbaaa''} 并且将它翻转,那么可以得到 T=\text{``aaabbbcdef''},显然后者才是最优解。

究其原因,我们发现此时 \text{``aaa''}\text{``bbb''} 都是回文串,而两次都是回文串的话可以合并为一次。

由于一个串的最小后缀除非长度为 1 否则不可能是回文(要不然直接拿最后一个字符当后缀就更优了),所以发生上面这种情况当且仅当 A 和截取后的 S^r 的最小后缀长度都是 1。特判这种情况。

在具体实现上,在 \text{Lyndon} 分解的过程中,先把 S^r 末尾所有相等的 \text{Lyndon} 串(设它们是 s_k,s_{k+1},\cdots,s_m)合并并且将其添加到 T 的末尾,然后如果 s_ms_{k-1} 长度都是 1 就不消耗步数,否则让 k-1

case 2:k\le 2

可以用诸如后缀自动机之类的算法实现 $O(1)$ 求任意两个子串的 LCP。 至此,我们已经有了一个时间复杂度为 $O(n)$ 的 **算法 $1$** 了: 首先特判 $k=1$,将 $S$ 翻转得到 $S^r$,对 $S^r$ 进行一次 $\text{Lyndon}$ 分解,并且将 $S$ 和 $S^r$ 拿出来建后缀自动机。 接下来不断重复如下过程: - 如果 $k\ge 3$,就截取末尾所有相等的 $\text{Lyndon}$ 串,如果 $s_m$ 和 $s_{k-1}$ 长度都是 $1$ 就不消耗步数,否则让 $k-1$。 - 如果 $k=2$,就暴力枚举断点,然后用后缀自动机实现 $O(1)$ 查询 LCP。 ## Part 3 上述做法当 $k=2$ 的时候太过暴力了,实现也不够优美。它并没有充分利用字符串的相关性质。我们尝试发掘性质优化算法。 再重复一遍当前的问题:你有一个 $S^r$,你需要先截取 $S^r$ 的一个后缀,选择翻转或者不翻转得到 $t_1$,然后选择把 $S^r$ 剩下的部分翻转或者不翻转得到 $t_2$,要求 $t_1+t_2$ 的字典序最小。 让我们进行分类讨论: ### case 1:两次都翻转 答案就是 $S$。注意这里的翻转是指对反串进行翻转。 ### case 2:两次都不翻转 相当于要求 $S^r$ 的最小循环表示。 最小循环表示可以用如下算法来求(如果会请跳过): --- 首先令 $s=s+s$。 维护三个变量 $i,j,k$,表示 $s[i:]$ 和 $s[j:]$ 的前 $k$ 个字符相同。 接下来分情况: - $i=j$:令 $j+1$。 - $s[i+k]=s[j+k]$:令 $k+1$。 - $s[i+k]<s[j+k]$:此时对于任意一个 $x\in [j,j+k]$,一定都能在 $[i,i+k]$ 中找到一个对应的 $y$,使得 $s[y:]<s[x:]$,所以直接令 $j=j+k+1$,同时令 $k=0$。 - $s[i+k]>s[j+k]$:同理,令 $i=i+k+1,k=0$。 如果 $i,j$ 其中有一个大于 $n$ 或者 $k\ge n$,就返回 $i,j$ 中较小的一个。 --- 时间复杂度 $O(n)$,因为 $i+j+k$ 是单调上升的。 ### case 3:第一次翻转,第二次不翻转 从后到前枚举断点 $i$,假设当前的最优解是 $j$: ![](https://cdn.luogu.com.cn/upload/image_hosting/qvl7rb2p.png) 相当于要求 $\left(S^r[i,j-1]\right)^r+S[:i-1]$ 和 $S^r$ 的字典序。 可以转化为两次询问: - $(S^r[i,j-1])^r$ 和 $S^r$ 的 LCP - $S^r[j-i+1,j-1]$ 和 $S^r$ 的 LCP 由于所有的询问都是关于整个 $S^r$ 的,因此可以使用扩展 KMP 算法来求解。具体地,令 $S'=S^r+S$,则我们只需要求出这个串的所有后缀和这个串的 LCP。 时间复杂度 $O(n)$。 ### case 4:第一次不翻转,第二次翻转 重点来了。 我们重新设 $S^r$ 的 $\text{Lyndon}$ 分解结果如下: $$S^r=A_1^{k_1}A_2^{k_2}\cdots A_m^{k_m}$$ $$S^r=B_1B_2\cdots B_m$$ 其中满足 $A_1>A_2>\cdots>A_m,B_i=A_i^{k_i}$。 为了方便,我们再设 $SB_i=B_iB_{i+1}\cdots B_{m}$。 --- #### 定理 1:最终答案一定满足存在一个 $i$,使得 $t_1=SB_i$。 也就是说我们一定会在 $B$ 的分界线上进行截取。 证明:若最优答案不在 $B$ 的分界线上,分情况讨论: - 若截取的位置位于 $A$ 的分界线上,那么分界线左右一定都是一样的 $\text{Lyndon}$ 串 $A_i$,那么 $t_1=SB_{i}$ 和 $t_1=SB_{i+1}$ 这两种方案中一定有一种更优。 - 若截取的位置不在 $A$ 的分界线上,那么一定在某一个 $A_i$ 的内部。由于 $A_i$ 是 $\text{Lyndon}$ 串,所以把分界线向前移动到 $A_i$ 的分界线上一定会更优。 --- #### 引理 4:若 $s_1$ 是 $\text{Lyndon}$ 串并且满足 $s_1>s_2\ge s_3$,则 $s_1>s_2+s_2\ge s_2+s_3$。 证明:分两种情况讨论。 - 若 $s_2$ 不是 $s_1$ 的前缀,则 $s_1$ 和 $s_2$ 在比较的时候直接产生不同。 - 若 $s_2$ 是 $s_1$ 的前缀,则 $s_3\le s_2<s_1<s_1[|s_2|:]$,故成立。 --- #### 定理 2:$SB_i>SB_{i+1}$。 证明:取 $SB_i$ 的第一个 $\text{Lyndon}$ 串 $A_i$,再将 $SB_{i+1}$ 写成 $\text{Lyndon}$ 分解的形式,由**引理 4** 可以得出 $SB_i>A_i>SB_{i+1}$。 --- 由**定理 2** 可知,除非 $SB_{i+1}$ 是 $SB_i$ 的前缀,否则 $t_1=SB_{i+1}$ 一定会被 $t_1=SB_i$ 更优。 我们取最后一个满足 $SB_{p}$ 不是 $SB_{p-1}$ 的前缀的 $p$,那么由刚刚的讨论可知 $t_1=SB_p$ 一定优于前面的任意一个 $SB_i$,也就是说如果最优答案中 $t_1=SB_i$,则一定满足 $i\ge p$。 --- #### 定理 3:若 $SB_{i+1}$ 是 $SB_i$ 的前缀,则一定满足 $\operatorname{len}(SB_{i+1})\le \dfrac 12\operatorname{len}(SB_i)

证明:如下图:

我们发现 SB_i 前面出现了两个 t,而 t=B_i=A_i^{k_i},这显然矛盾。

定理 3 告诉我们,SB_p,SB_{p+1},\dots,SB_m 这些串一定满足后一个长度不大于前一个的 1\over 2,所以至多只会有 O(\log n) 个。

枚举 O(\log n) 个断点,然后二分+哈希比较大小,这一部分的时间复杂度可以做到 O(\log^2n)

继续分析性质

首先假设 SB_iSB_{i-1} 更优秀。

再来一张大图:

直线表示平移,曲线表示翻转。

看下面两个,由于 SB_iSB_{i-1} 优秀,所以 X^r<Y

再看上面两个,由于 X^r<Y,所以我们就可以直接得出 SB_iSB_{i-2} 优秀。

换句话说,我们只需要找到最大的 q,满足 SB_qSB_{q-1} 优秀,那么令 t_1=SB_q 就是答案。

由于一次比较是 O(\operatorname{len}(SB_i)) 的,所以我们从 SB_m 往前一个串一个串暴力比较,时间复杂度就是 O(n)因此暴力算几项就完事了!!1

至此,我们就得到一个非常优美的算法 2了:

步骤 1:特判 k=1,并且将 S 翻转得到 S^r,对 S^r 进行 \text{Lyndon} 分解。

步骤 2:如果 k\ge 3,则每次截取最后一段 B_m,如果 A_mS_{m-1} 长度都是 1 则不消耗步数。

步骤 3:如果 k=2,则进行分类讨论:

时间复杂度 O(n) 甚至 O(n\log n)

代码

#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
char s[10000005],t[5000005],t1[5000005],t2[5000005],t3[5000005],t4[5000005];
int n,k,m,nowl,z[10000005],A[5000005],l[5000005];
void extend(char*s,int l)//在t串之后新加入s[:l-1]
{
    for(int i=0;i<l;i++)t[++nowl]=s[i];
}
void solve1(int n)//两段都翻转,直接就是原串
{
    for(int i=1;i<=n;i++)t1[i]=s[n-i+1];
}
void solve2(int n)//两段都不翻转,最小表示法
{
    for(int i=n+1;i<=2*n;i++)s[i]=s[i-n];
    int i=1,j=2,k=0;
    while(i<=n&&j<=n&&k<n)
    {
        if(s[i+k]==s[j+k])k++;
        else if(s[i+k]<s[j+k])j+=k+1,k=0;
        else i+=k+1,k=0;
        if(i==j)j++;
    }
    for(int x=0;x<n;x++)t2[x+1]=s[min(i,j)+x];
}
void solve3(int n)//第一段翻转第二段不翻转,扩展 KMP
{
    for(int i=n+1;i<=2*n;i++)s[i]=s[n-(i-n)+1];
    n*=2;
    z[1]=n;
    int l=1,r=1;
    for(int i=2;i<=n;i++)
    {
        if(r<i)
        {
            l=r=i;
            while(r<=n&&s[r]==s[r-l+1])r++;
            z[i]=r-l;
            r--;
        }
        else
        {
            if(z[i-l+1]<r-i+1)z[i]=z[i-l+1];
            else
            {
                l=i;
                while(r<=n&&s[r]==s[r-l+1])r++;
                z[i]=r-l;
                r--;
            }
        }
    }//以上为扩展KMP算法
    n/=2;
    int j=n+1;
    for(int i=n;i>=1;i--)
    {
        int l=i,r=j-1;
        l=n-l+1,r=n-r+1;
        int len=z[r+n];
        if(len<l-r+1)
        {
            if(s[len+1]>s[j-len-1])j=i;
        }
        else
        {
            len=j-i;
            l=len+1,r=j-1;
            if(s[z[l]+1]<s[l+z[l]])j=i;
        }
    }
    int tot=0;
    for(int i=n;i>=j;i--)t3[++tot]=s[i];
    for(int i=1;i<j;i++)t3[++tot]=s[i];
}
void solve4(int n)//第一次不翻转,第二次翻转,暴力比较
{
    int p=m;
    while((A[p+1]-A[p])*2<=(A[p]-A[p-1]))
    {
        bool flag=0;
        for(int i=A[p]-1;i>=A[p-1];i--)
        {
            if(s[i]<s[n-i+A[p-1]])
            {
                flag=1;
                break;
            }
            if(s[i]>s[n-i+A[p-1]])break;
        }
        if(flag)break;
        p--;
    }
    p=A[p];
    int tot=0;
    for(int i=p;i<=n;i++)t4[++tot]=s[i];
    for(int i=p-1;i>=1;i--)t4[++tot]=s[i];
}
void update(char*t1,char*t2,int n)//取字典序最小
{
    for(int i=1;i<=n;i++)
    {
        if(t1[i]<t2[i])return;
        if(t2[i]<t1[i])break;
    }
    for(int i=1;i<=n;i++)t1[i]=t2[i];
}
void solve(int n)
{
    solve1(n);
    solve2(n);
    solve3(n);
    solve4(n);
    update(t1,t2,n);
    update(t1,t3,n);
    update(t1,t4,n);
    extend(t1+1,n);
}
int main()
{
    scanf("%s",s+1);
    n=(int)strlen(s+1);
    scanf("%d",&k);
    for(int i=1;i*2<=n;i++)swap(s[i],s[n-i+1]);//取反串
    if(k==1)//特判k=1
    {
        for(int i=1;i<=n;i++)t[i]=s[n-i+1];
        for(int i=1;i<=n;i++)
        {
            if(s[i]<t[i])
            {
                printf("%s\n",s+1);
                return 0;
            }
            if(s[i]>t[i])
            {
                printf("%s\n",t+1);
                return 0;
            }
        }
        printf("%s\n",s+1);
        return 0;
    }
    for(int i=1;i<=n;)//Lyndon分解
    {
        int j=i,k=i+1;
        while(s[j]<=s[k])
        {
            if(s[j]<s[k])j=i;
            else j++;
            k++;
        }
        A[++m]=i;
        l[m]=k-j;
        while(i<=j)i+=k-j;
    }
    A[m+1]=n+1;
    while(m&&k>=3)//当k>=3时
    {
        extend(s+A[m],A[m+1]-A[m]);
        if(l[m]!=1||l[m-1]!=1)k--;
        m--;
    }
    if(!m)
    {
        printf("%s\n",t+1);
        return 0;
    }
    solve(A[m+1]-1);//当k=2时
    printf("%s\n",t+1);
    return 0;
}

总结

本道题涉及到了后缀自动机,\text{Lyndon} 分解,扩展 KMP,最小表示法等等多种字符串冷门算法,综合考察了选手对前沿字符串算法的掌握能力、调试代码能力和肝脏承受能力,难度适中,覆盖知识点广,题目又着切合实际的背景,解法比较自然。给出题人点赞!