[NOI 2014] 动物园

题目链接:https://www.acwing.com/problem/content/1002/

题面:
近日,园长发现动物园中好吃懒做的动物越来越多了。例如企鹅,只会卖萌向游客要吃的。

为了整治动物园的不良风气,让动物们凭自己的真才实学向游客要吃的,园长决定开设算法班,让动物们学习算法。

某天,园长给动物们讲解KMP算法。

园长:“对于一个字符串S,它的长度为L。我们可以在O(L)的时间内,求出一个名为next的数组。有谁预习了next数组的含义吗?”

熊猫:“对于字符串S的前i个字符构成的子串,既是它的后缀又是它的前缀的字符串中(它本身除外),最长的长度记作next[i]。”

园长:“非常好!那你能举个例子吗?”

熊猫:“例S为abcababc,则next[5]=2。因为S的前5个字符为abcab,ab既是它的后缀又是它的前缀,并且找不到一个更长的字符串满足这个性质。同理,还可得出next[1]=next[2]=next[3]=0,next[4]=next[6]=1,next[7]=2,next[8]=3。”

园长表扬了认真预习的熊猫同学。

随后,他详细讲解了如何在O(L)的时间内求出next数组。

下课前,园长提出了一个问题:“KMP算法只能求出next数组。我现在希望求出一个更强大num数组一一对于字符串S的前i个字符构成的子串,既是它的后缀同时又是它的前缀,并且该后缀与该前缀不重叠,将这种字符串的数量记作num[i]。例如S为aaaaa,则num[4]=2。这是因为S的前4个字符为aaaa,其中a和aa都满足性质‘既是后缀又是前缀’,同时保证这个后缀与这个前缀不重叠。而aaa虽然满足性质‘既是后缀又是前缀’,但遗憾的是这个后缀与这个前缀重叠了,所以不能计算在内。同理,num[1]=0,num[2]=num[3]=1,num[5]=2。”

最后,园长给出了奖励条件,第一个做对的同学奖励巧克力一盒。

听了这句话,睡了一节课的企鹅立刻就醒过来了!

但企鹅并不会做这道题,于是向参观动物园的你寻求帮助。

你能否帮助企鹅写一个程序求出num数组呢?

特别地,为了避免大量的输出,你不需要输出num[i]分别是多少,你只需要输出∏Li=1(num[i]+1)对1,000,000,007取模的结果即可。

∏i=1L(num[i]+1)=(num[1]+1)∗(num[2]+1)∗…∗(num[L]+1)
输入格式
第1行仅包含一个正整数n ,表示测试数据的组数。

随后n行,每行描述一组测试数据。每组测试数据仅含有一个字符串S,S的定义详见题目描述。

数据保证 S 中仅含小写字母。输入文件中不会包含多余的空行,行末不会存在多余的空格。

输出格式
包含 n 行,每行描述一组测试数据的答案,答案的顺序应与输入数据的顺序保持一致。

对于每组测试数据,仅需要输出一个整数,表示这组测试数据的答案对 1,000,000,007 取模的结果。

输出文件中不应包含多余的空行。

输入样例:
3
aaaaa
ab
abcababc
输出样例:
36
1
32

数据范围是 n <= 5, L <= 1000000

题意分析

题目先介绍了一通 kmp 算法,next 数组,可能是提示我们做法跟这个有点关系。然后要我们求的是每个字串对应的不重叠相同前后缀的数量,这就与 ne 数组不一样了, ne 数组求的是最值, 而 num 数组求的是和值。

思路

我自己写的时候思路是这样的:读入一个字符串之后,先处理出它的 ne 数组,然后再开一个循环计算 num 数组,对于整个字符串的某一个前缀,如果这个字串的最大相同前后缀长度的两倍小于等于这个字串的长度,也就是 ne[i]2 <= i, 那么这本身已经有了一个不重叠的相同前后缀了,由于前后缀相同,而前缀作为一个字串本身也有相同前后缀,那么这个前后缀也一定会是整个字符串(也就是长度为 i 的前缀子串)的相同前后缀(下面还会用到这个),我们不断回溯(利用ne[]),若存在长度大于 0 的相同前后缀就计数加一,知道相同前后缀长度为 0 或者回溯到头。 如果一开始 ne[i]2 > i 的话,只要在循环里面计数的地方加上一个if判断,如果ne[j]*2 < j 才计数。简单来说就是统计所有长度小于等于 i/2 的相同前后缀个数。

50分代码(过五个点 TLE 五个点)

#include <iostream>
#include <cstring>

using namespace std;

const int mod = 1e9+7,N = 1e6+10;

char p[N];

int ne[N],num[N];

int main(){
    int n;
    cin>>n;
    long long ans = 1;
    while(n--){
        memset(p,'Z',sizeof p);
        scanf("%s",p+1);
        ans = 1;
        for(int i = 2,j = 0; p[i+1] != 'Z';i++){
            while(j && p[j+1] != p[i]) j = ne[j];
            if(p[j+1] == p[i])j++;
            ne[i] = j;
        }
        for(int i = 1;p[i+1]!='Z';i++){
            int j = i,cnt = 0;
            if(ne[i]*2 <= i){
                while(j > 1 && ne[j]){
                    cnt++;
                    j = ne[j];
                }
                num[i] = cnt;
            }else{
                while(j > 1 && ne[j]){
                    if(ne[j]*2 <= i)cnt++;
                    j = ne[j];
                } 
                num[i] = cnt;
            }
            ans = ans*(num[i]+1)%mod;
            }
            printf("%lld\n",ans);
    }
    
    return 0;
}

上述代码之所以会 TLE,大概是因为碰到像一串很多个相同字母,比如 1e6 个 a 这样的输入,求 num[i] 的时候回溯次数太多了,因为每次回溯只回溯了一格,但是回溯的前面很多次数其实都是没用的,根本不会计数,而且这种一次一次统计的方式也过于低效。

一种可行的思路

思考很久无果之后,我在网上看了几篇博客,才大概了解了一种正确的做法。仔细回想一下,前面在计数的时候,有什么步骤是重复做的,无效的?其实就是每次统计相同前后缀个数,我们都是重新开始统计,然而之前有一些子串的子串,或者子串的子串....它们的相同前后缀个数我们其实已经统计过了,但是在前缀 i 的长度递增过程中,我们可能重复统计了很多次相同的内容。而要利用前面的信息,我们可以采用递推的方法。
我们先不考虑重叠不重叠的要求,考虑每个子前缀的相同前后缀个数(这里我们把整个子串本身也视为一个相同前后缀,原因后面说明),那么我们可以得出公式

\[num[i] = num[ne[i]] + 1 \]

即前缀 i 的相同前后缀的数量,就是它的最长相同前后缀的数量(对应公式中的 1 ),加上它的前缀的相同前后缀的数量(与我上面的朴素法中的思路一个道理,前缀的相同前后缀也是整个串的相同前后缀)(这对应公式中的第一项 num[ne[i]])。而在求答案的时候,我们要将每个 i 对应的真正的 num[i] ,我们下面记作res[i],我们要把 (res[i]+1) 累乘起来。仿照 ne 数组的求解方式,我们也在匹配中求解 res[i]。对于某个前缀 i,假设我们匹配到 i 的最长相等前后缀长度是 j,如果 \(j \times 2 <= i\),那么 num[j] 就是所求的 res[j],因为此时的 j 代表的是前缀 i 的最大相等前后缀,且 j 是满足不重叠要求的,而 num[j] 包含了这个前缀 j 本身,以及它的其他相等前后缀,因此涵盖了前缀 i 的所有的不重叠相等前后缀。如果 \(j \times 2 > i\) 的话,就用 j = ne[j] 回溯,直到 \(j \times 2 <= i\),这时 j 代表队仍然是某个 i 的相等前后缀的长度(因为前缀的相等前后缀也是整体的相等前后缀),而且满足不重叠条件,那么与上面前一种情况同理,此时的 \(res[i] = num[j]\) 。最后解释一下为什么前面算num的时候要把整个子串本身也算作一个相等前后缀:因为算res的时候,我们要先找到不重叠的最大子串,然后用它的相等前后缀个数来推res,如果直接在 num 里面包括整个子串本身,后面用起来比较方便,因为在用num[j]的时候本身就已经把j当作子串了。

可行做法与暴力法差别在哪?

AC 做法在算 num 时利用了递推,这记录了前面的信息,然后在算 res 的时候,虽然也有回溯,但是由于 j 每次最多只会前进一格,如果上一次已经回溯到满足 \(j \times 2 <= i\),然后 i 每次也是前进一格,所以每次要转变到 \(j \times 2 <= i\)这个状态其实需要回溯的次数是比较少的,整个做法匹配一趟下来基本上是 O(L) 的,所有用例加起来也就 O(nL),是 ok 的。

AC 代码

#include <iostream>
#include <cstring>

using namespace std;

const int mod = 1e9+7,N = 1e6+10;

char p[N];

int ne[N],num[N];

int main(){
    int n;
    cin>>n;
    long long ans = 1;
    while(n--){
        memset(p,'Z',sizeof p);
        scanf("%s",p+1);
        ans = 1;
        num[1] = 1;
        for(int i = 2,j = 0; p[i+1] != 'Z';i++){
            while(j && p[j+1] != p[i]) j = ne[j];
            if(p[j+1] == p[i])j++;
            ne[i] = j;
            num[i] = num[ne[i]] + 1;
        }
            for(int i = 2,j = 0; p[i+1]!='Z';i++){
                while(j && p[i]!=p[j+1]) j = ne[j];
                if(p[j+1] == p[i]) j++;
                while((j<<1)>i)j = ne[j];
                ans = (ans*(num[j]+1))%mod;
            }
       printf("%lld\n",ans);
    }
    
    return 0;
}

summary

本题也算是 kmp 算法的一个应用,利用了 next 数组以及字符串匹配过程中利用前面已经得到的信息的思想,有效降低了时间复杂度,感觉kmp算法还是相当精妙的一个算法,要灵活使用还是需要多加练习。

posted @ 2021-05-01 11:22  今天AC了吗  阅读(104)  评论(0)    收藏  举报