Power Strings(信息学奥赛一本通- P1457)

【题目描述】

原题来自:POJ 2406

给定若干个长度 ≤10^6 的字符串,询问每个字符串最多是由多少个相同的子字符串重复连接而成的。如:ababab 则最多有 3 个 ab 连接而成。

【输入】

输入若干行,每行有一个字符串,字符串仅含英语字母。特别的,字符串可能为 . 即一个半角句号,此时输入结束。

【输出】

【输入样例】

复制代码
abcd
aaaa
ababab
.

【输出样例】

复制代码
1
4
3

今天来盘一道极其经典的字符串老题------POJ 2406 (Power Strings)。这道题是字符串周期、循环节问题的"试金石"。

题目要求很简单:给一个长度最大为106的字符串,问它最多能由多少个相同的子串重复拼接而成。 比如ababab,可以看作3个ab拼成,答案就是3。

这道题的标准解法其实是KMP算法的next数组定理,但如果对KMP原理理解不够透彻,很容易写挂。相比之下,字符串哈希的思维负担极小,且通过合理的预处理,依然能轻松跑进1s。

下面记录一下用字符串哈希解决这道题的思考过程,以及代码是如何一步步进化的。


一、 题目分析与解题思路

数据范围预警:字符串长度 L≤10^6。 这意味着任何 O(N^2) 的算法都会被无情地判定为超时。

核心思路

  1. 枚举长度 :既然是由同一子串重复构成的,那么子串的长度len一定能被总长度N整除。我们只需要从小到大枚举可能存在的子串长度len

  2. 校验合法性 :假设当前枚举的子串长度是len,我们就把原串按照len切成若干块。如果每一块的字符串都和第一块一模一样,那拼凑就成立。

  3. 哈希降维 :怎么快速判断每一块是否和第一块一样?用字符串哈希 把字符串压缩成 unsigned long long数字。比较数字是否相等,就是O(1)的操作。


二、 代码演进:

版本一:暴力哈希(能过纯属评测机数据太水)

最开始的思路非常直白:外层循环枚举长度len,内层循环挨个切块。每次切出一块,就当场从头算一遍这块的哈希值,看看跟第一块一不一样。

这种写法虽然把"字符串比较"优化成了"算数字",但内层循环反复去乘 131,最坏情况下(比如给了个极其长的aaaa...aaa,而在枚举较大的len时才break),时间复杂度会退化到近似O(N^2)。

V1代码展示:

cpp 复制代码
//这个代码能过 但纯粹是评测机不严格 有很大的优化空间
//思想:求出不同长度子串的哈希值 去看给出的父字符串是否每一段和子串长度相同的
//窗口的哈希值都和子串相同
#include <iostream>
using namespace std;
typedef unsigned long long ull;
string s;

int main(){
    while(cin>>s){
        if(s[0]=='.') return 0;
        //遍历可能的子字符串长度
        //从1到s.size()
        for(int len=1;len<=s.size();len++){
            //当前子字符串长度为len
            //flag标记是否可以拼凑成功
            bool flag=1;
            //子字符串长度一定能被给出的字符串整除
            if(s.size()%len!=0) continue;
            ull ans=0;//存储子字符串哈希值
            //131进制,进制要大于ascii最大值
            for(int i=0;i<len;i++){
                ans=ans*131+s[i];
            }
            //检查给定字符串是否由子字符串拼凑而成
            for(int i=len;i<s.size();i=i+len){
                //存储给定字符串每一段和子字符串长度相等
                //的字符串的哈希值
                ull ans2=0;
                for(int j=i;j<i+len;j++){
                    ans2=ans2*131+s[j];
                }
                //如果给定字符串存在一段和子字符串哈希值不同
                //代表不可以拼凑成功
                if(ans2!=ans){
                    flag=0;
                    break;
                }
            }
            //如果标记为1 代表给定字符串每一段哈希值都和
            //子字符串相同,说明可以拼凑成功 同时第一次找到的
            //一定是长度最小即切分最多的 输出段数
            if(flag==1){
                cout<<s.size()/len<<"\n";
                break;
            }
        }
    }
    return 0;
}
版本二:前缀哈希+砍半优化

为了干掉V1中最耗时的内层O(len)计算,我们引入字符串哈希的终极奥义:前缀哈希预处理 。 如果我们提前花O(N)的时间把前缀哈希数组ans2[]和131的次幂数组p[]算出来,那么任意区间 [L,R]的哈希值就可以用O(1)的时间求出:

Hash=ans2[R]−ans2[L−1]×p[len]

此外,外层枚举也没必要循环到s.size()。一个字符串如果要被分割重复,至少得重复2次。所以len最多只可能到s.size()/2

V2 代码展示(优化版):

cpp 复制代码
//在上个版本基础上就行优化
//第一个优化:不用判断到s.size() 判断到s.size()/2即可
//第二个优化:预处理s数组的哈希前缀和 最后求窗口值直接O(1)查询
//不用每次都反复计算
//等学习了kmp 同学们再用kmp来补一下这道题
#include <iostream>
using namespace std;
//利用unsigned long long的自然溢出机制,代替取模运算
typedef unsigned long long ull;
string s;
ull ans2[1000010];//预处理父字符串的前缀哈希
ull p[1000010];//存131的i次方


int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    //预处理131的i次方
    p[0]=1;
    for(int i=1;i<=1000005;i++)
        p[i]=p[i-1]*131;
    while(cin>>s){
        //如果是.代表输入结束
        if(s[0]=='.') return 0;
        //标记是否找到非本身的子串
        bool flag2=0;
        //第二个优化:预处理父字符串的前缀哈希
        ans2[0]=s[0];
        for(int i=1;i<s.size();i++){
            ans2[i]=ans2[i-1]*131+s[i];
        }
        //第一个优化:遍历可能的子字符串长度
        //不需要遍历到s.size() 最多遍历到s.size()/2即可
        //如果s.size()/2都不行 那么只能是自己本身作为子串
        for(int len=1;len<=s.size()/2;len++){
            //当前子字符串长度为len
            //flag标记是否可以拼凑成功
            bool flag=1;
            //子字符串长度一定能被给出的字符串整除
            if(s.size()%len!=0) continue;
            ull ans=0;//存储子字符串哈希值
            //取第一块子串的哈希值
            ans=ans2[len-1];
            //检查给定字符串是否由子字符串拼凑而成
            for(int i=len;i<s.size();i=i+len){
                //ans3存储给定字符串每一段和子字符串长度相等
                //的字符串的哈希值
                ull ans3=0;
                ans3=ans2[i+len-1]-ans2[i-1]*p[len];
                //如果给定字符串存在一段和子字符串哈希值不同
                //代表不可以拼凑成功
                if(ans3!=ans){
                    flag=0;
                    break;
                }
            }
            //如果标记为1 代表给定字符串每一段哈希值都和
            //子字符串相同,说明可以拼凑成功 同时第一次找到的
            //一定是长度最短的即切分最多的 输出段数 并标记找到非本身子串
            if(flag==1){
                cout<<s.size()/len<<"\n";
                flag2=1;
                break;
            }
        }
        if(flag2==0) cout<<1<<"\n";  
    }
    return 0;
}

三、 时空复杂度分析 (以V2为准)

  • 时间复杂度

    • 预处理前缀哈希:O(N)。

    • 枚举长度:外层循环执行次数看似是N/2,但因为有N%len==0 的剪枝,实际上只有N的约数才会进入内层循环。内层按len步进,单次查询是O(1)。

    • 整体时间复杂度远低于O(NlogN),逼近O(N),对于10^6的数据量轻松秒杀。

  • 空间复杂度 : 使用了两个大小为10^6的unsigned long long数组,占用大约16MB内存,完全符合题目要求。空间复杂度O(N)。


四、 易错点总结

  1. IO 读写瓶颈 : 对于10^6级别的字符串读入,建议不要裸奔使用 cin/cout,可以加上 ios::sync_with_stdio(false); cin.tie(0);否则会因为读写太慢容易卡常数。

  2. 0-based索引的区间减法 : 这道题采用了以0为起点的下标(ans2[0]=s[0])。在做区间减法ans2[i+len-1]-ans2[i-1]*p[len]时,必须保证i-1>=0。因为我们的内层循环是从i=len开始的,所以完美避开了越界。但在写别的题时,一定要高度警惕下标为负的越界问题。

写在最后 :从暴力的两层for循环,到最后用前缀哈希将查询压榨到O(1),对时间复杂度的敏感度就是这样一步步练出来的,建议同学们每道题ac之后一定不要就over了,还是要去想想能不能优化。这道题用KMP算法的Next数组循环节定理甚至可以一行代码出结果,下节课学习了kmp,同学们回头用kmp再来补一下。

相关推荐
MIngYaaa5202 小时前
The 2025 Sichuan Provincial Collegiate Programming Contest 复盘
算法
网域小星球2 小时前
C 语言从 0 入门(二十一)|typedef 类型重定义:简化复杂类型,代码更清爽
c语言·算法·类型重定义·结构体简化·函数指针简化
XWalnut2 小时前
LeetCode刷题 day10
数据结构·算法·leetcode
programhelp_3 小时前
Amazon OA 2026 高频题型拆解 + 速通攻略
数据结构·算法
moonsea02033 小时前
2026.4.14
数据结构·算法·图论
x_xbx3 小时前
LeetCode:42. 接雨水
算法·leetcode·职场和发展
lixinnnn.3 小时前
01BFS:小明的游戏
算法
falldeep3 小时前
Claude Code源码分析
人工智能·算法·机器学习·强化学习
sheeta19983 小时前
LeetCode 每日一题笔记 日期:2026.04.14 题目:2463.最小移动距离
笔记·算法·leetcode