刷过 OJ 算法题的人,对“字符串乘方”这四个字应该不陌生。尤其 POJ 2406 Power Strings 这道经典题,几乎每个学 KMP 的人都被它虐过一遍。它的核心问题一句话就能讲明白:给定一个字符串,判断它能不能由某个更短的字符串重复若干次得到,如果能,最大重复次数是多少。
乍一听这事特别简单,不就是找循环节吗?可一旦真上手,你会发现里面有好多坑。比如我当年用暴力枚举因子写了个一看就对的版本,结果交上去直接超时;后来换了 KMP 的 Next 数组,又因为循环节判定条件写反,WA 到怀疑人生。这篇文章就围绕“字符串乘方”这个主题,把背后的数学模型、KMP 解法原理、哈希方案、多语言实现和常见坑位一次讲清楚,希望能帮你少走弯路。
这篇内容适合三类人看:正在刷算法题的学生、准备面试的开发者、以及工作中需要对字符串做模式分析的工程师。前面部分从数学定义讲起,中间是多语言可执行的代码,后面是调试技巧和真实场景应用,你可以按需跳到对应部分。
1. 字符串乘方到底在问什么
1.1 一眼看穿题面
先看一个最简单的形式:给定字符串s = "ababab",它能被"ab"重复 3 次得到,所以答案是 3。再比如s = "abcabcabc",答案是 3,因为它是"abc"的 3 次乘方。但如果给你s = "abababa",你会发现它没法由某个短串完整重复得到,此时答案就是 1,或者说它只能看作自身的一次乘方。
这类问题的输入通常只给一个字符串,有的版本会附带一个值 m,问从字符串中划分出多少个长度不小于 m 的乘方子串,这种属于进阶变体。更经典的形式是 POJ 2406 这样的多测输入,字符串以英文句号.单独一行表示结束。不管题面怎么包装,核心命题只有一个:找出给定字符串的最小循环节长度,然后让总长度除以它,得到最大幂次。
在刷题网站上,这类题目的数据规模通常是 10 的 5 次方到 10 的 6 次方级别。这就意味着 O(n^2) 的做法基本没戏,你需要的是线性复杂度或者接近线性的复杂度。
1.2 数学建模:从乘方到循环节
把问题抽象成数学语言:设字符串 S 的长度为 n,如果能找到一个长度为 d 的字符串 T,使得 S 等于 T 重复 k 次,那么必然有 n = d * k,且对于 S 中的任意位置 i,满足 S[i] = S[i + d](这里的下标从 0 开始,且 i + d < n)。
这个等式是判断“是否存在周期 d”的原始定义。注意这里我用的是“周期”,而不是“循环节”。严谨地说,如果存在某个正整数 p,使得对所有合法的 i 都有 S[i] = S[i + p],那么 p 叫做 S 的一个周期。而循环节要求 p 必须整除 n,也就是整段字符串正好由若干完整的周期拼成。
我们可以枚举这个 d:先找出 n 的所有因子,从小到大逐个验证。一旦某个 d 满足“以 d 为间隔的所有对应位置字符相等”,它就是一个可行循环节,此时最大幂次就是 n / d。因为我们在从小到大枚举因子,所以第一个验证成功的 d 就是最小循环节长度,对应的 n / d 就是最大幂次。
这种暴力验证的思路,时间复杂度是 O(n * τ(n)),其中 τ(n) 是 n 的因子个数。n 为 1e6 时,因子个数最多也就二百多个,遍历一遍字符串也完全吃得消,所以它其实并不是一无是处。很多新手不知道这一点,一上来就追求 KMP,反而把更简单的实现方式忽略了。
1.3 周期与循环节的区别
这里必须单独拉出来讲,因为这是最容易踩坑的地方。看字符串"abcab",长度 n = 5,它的前缀"abc"重新出现时,我们可以说 3 是它的一个周期吗?验证一下:S[0] = 'a',S[3] = 'a',相等;S[1] = 'b',S[4] = 'b',相等。确实,p = 3 是一个周期。但 3 不整除 5,所以"abcab"不能由某个短串重复得到,它自己的乘方幂次就是 1。
用 KMP 算出来的 Next 数组,其实可以快速得到字符串的最小周期,但这个“最小周期”不一定能做“最小循环节”。很多人在这里翻车,就是因为只算了n - next[n-1],没判断是否能整除,结果把"abcab"这种字符串错误地当成了周期串。
判断逻辑必须是两层的:第一,n % (n - next[n-1]) == 0;第二,n / (n - next[n-1])才是最大幂次 k。如果不满足整除条件,答案直接是 1。
2. 三种主流解法与选型底层逻辑
2.1 暴力枚举因子
暴力法的思路很直接:对 n 的每个因子 d,逐个检查 S[i] 是否等于 S[i + d]。写起来大概长这样:
def max_power_brutal(s: str) -> int: n = len(s) for d in range(1, n + 1): if n % d != 0: continue ok = True for i in range(n - d): if s[i] != s[i + d]: ok = False break if ok: return n // d return 1这个实现里,外层循环是 d,从 1 试到 n,只关心能整除的 d。内层循环比较所有相隔 d 的位置是否相等。如果全部相等,说明 d 是循环节,直接返回 n // d。
它的优点是完全不需要任何算法基础,甚至可以手工推算。缺点是当数据量大且字符串字符分布非常均匀时,例如全 a 串"aaaaaaaaaa",每个内层循环都要走到最后才退出,整体耗时偏高。但在面试中,如果你先写出暴力版,再和面试官讨论如何优化到 KMP,反而是一种稳妥的沟通策略。
2.2 KMP 前缀函数:真正的正解
KMP 的 Next 数组,在字符串周期问题里是核心。这里我不打算展开 KMP 匹配的全过程,只讲它和周期的关系。
定义前缀函数 pi[i] 表示字符串 S 的子串 S[0..i] 的最长相等真前缀和真后缀的长度。比如s = "ababab",它的 pi 数组为[0, 0, 1, 2, 3, 4]。整个字符串的最后一个 pi 值是 4,那么最长 border 的长度就是 4,而n - pi[n-1] = 6 - 4 = 2,这个 2 就是最小周期。又因为 6 能被 2 整除,所以最小循环节就是长度 2 的"ab",幂次为 3。
为什么n - pi[n-1]会等于最小周期?原理其实不复杂。如果串的前缀和后缀有长度为 b 的公共部分,那么把开头 b 个字符和结尾 b 个字符对齐后,中间就空出了一段长度为 n - b 的区域,这段区域就是周期。你可以想象成军队绕操场跑步,排头跑到某个位置,队尾刚好补上,两个位置之间的间距就是一个周期的步长。
计算前缀函数的递推也不难:pi[0] = 0,从 i = 1 开始,每次取 j = pi[i-1],当 S[i] != S[j] 时,把 j 回退到 pi[j-1],直到 j 为 0 或 S[i] == S[j]。如果 S[i] == S[j],则 j 加一,pi[i] = j。这个递推过程其实就是 KMP 失配时的跳转逻辑,理解了这一点,你就不会再把 next 数组背混了。
2.3 滚动哈希:另一种优雅的路线
如果你把字符串看成一个大整数,那比较两个子串是否相等,可以用哈希 O(1) 完成。滚动哈希的做法是:预处理出整个字符串的前缀哈希和一个对应的幂次数组,然后枚举因子 d,用哈希快速判断第 i 段和第 i + d 段是否相同。
以一个质数 base 对整个串做多项式哈希,哈希公式为:
hash[i] = (hash[i-1] * base + code(S[i])) % mod
判断 S 是否由长度为 d 的子串重复得到,只需检查整个串的哈希是否等于把长度为 d 的前缀哈希按重复的方式拼接后的哈希。拼接一次的公式是:
repeat_hash = hash[d] * (base^(n-d) + base^(n-2d) + ... + 1) % mod
这个公式里的几何级数求和,可以预处理幂次数组后 O(1) 算出。实际比较时,甚至可以直接对每个分段哈希逐个比较,复杂度同样是 O(n * τ(n)),但因为每次比较是 O(1),常数很小。
不过哈希最大的问题是有碰撞风险。虽然选一个大质数比如 1e9+7 或 1e9+9,再搭配 64 位无符号整数溢出取模,碰撞概率已经低到可以忽略,但竞赛中如果数据被特殊构造,单哈希仍可能被卡。稳妥做法是双哈希,用两个不同的 mod 和 base,两个哈希都必须匹配才认定相等。
2.4 不同方法怎么选
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举因子 | O(n * τ(n)) | O(1) | 教学演示、小数据规模 |
| KMP 前缀函数 | O(n) | O(n) | 绝大多数竞赛和面试题 |
| 滚动哈希 | O(n * τ(n)) | O(n) | 需要同时判断多个子串时 |
| 后缀数组 | O(n log n) | O(n) | 需要做更复杂的周期分析 |
实际做题时,我优先推荐 KMP 前缀函数,因为它稳定、好解释、不会碰撞。只有当你需要在一个长字符串里同时查多个子串的幂次关系时,滚动哈希才会体现出明显优势,因为哈希值可以 O(1) 拿到任意子串的比较结果。
3. 多语言代码落地实录
3.1 C 语言版:POJ 2406 的完整 AC 代码
先看最经典的 C 语言实现。POJ 2406 的输入以单独一行.终止,每组数据是一个不含空格的字符串,你需要在输出时打印最大幂次数。完整代码如下:
#include <stdio.h> #include <string.h> #define MAXN 1000005 char s[MAXN]; int pi[MAXN]; int main(void) { while (scanf("%s", s) != EOF) { if (s[0] == '.' && s[1] == '\0') break; int n = (int)strlen(s); pi[0] = 0; for (int i = 1; i < n; i++) { int j = pi[i - 1]; while (j > 0 && s[i] != s[j]) { j = pi[j - 1]; } if (s[i] == s[j]) j++; pi[i] = j; } int cycle = n - pi[n - 1]; if (n % cycle == 0) { printf("%d\n", n / cycle); } else { printf("1\n"); } } return 0; }这段代码里,scanf("%s", s)能自动跳过空白字符,包括换行和空格,所以多测输入处理起来非常省心。判断结束条件时,我特意加了s[1] == '\0',确保真的只有一个点才结束,防止把"." + 其他字符这种输入误判。
cycle的计算是整个程序的核心。注意我优先判断了整除性,再输出幂次。如果你把整除判断漏掉,直接输出n / cycle,遇到"abcab"这类字符串就会得到错误答案,输出一个不存在的幂次。
3.2 C++ 版:用 vector 和 string 简化
C++ 的代码基本就是把 C 版本的数组换成vector<int>,字符串换成std::string,更方便处理动态长度。核心求解函数可以单独封装:
#include <iostream> #include <vector> #include <string> using namespace std; int max_power(const string& s) { int n = (int)s.size(); vector<int> pi(n, 0); for (int i = 1; i < n; i++) { int j = pi[i - 1]; while (j > 0 && s[i] != s[j]) { j = pi[j - 1]; } if (s[i] == s[j]) j++; pi[i] = j; } int cycle = n - pi[n - 1]; if (n % cycle == 0) return n / cycle; return 1; } int main() { string s; while (cin >> s) { if (s == ".") break; cout << max_power(s) << '\n'; } return 0; }这里我使用的pi[i - 1]表示前缀函数,也就是最长 border 长度,而不是 KMP 匹配时常用的失配指针。两者的区别在于,失配指针有时会保存pi[i]对应的下标,这个下标等于pi[i]的值;而前缀函数保存的是长度。只要你在比较时用的是长度而非下标,代码就不会出问题。
3.3 Python 版:写起来最短,但要注意性能
Python 实现和 C++ 在逻辑上完全一致,只是要注意防止在大数据量下超时。用列表存储 pi 数组,循环内部尽量减少属性访问,能提升不少速度:
def max_power(s: str) -> int: n = len(s) pi = [0] * n for i in range(1, n): j = pi[i - 1] while j > 0 and s[i] != s[j]: j = pi[j - 1] if s[i] == s[j]: j += 1 pi[i] = j cycle = n - pi[-1] if n % cycle == 0: return n // cycle return 1在 OJ 上跑 Python 版时,如果遇到 n 为 1e6 级别的输入,虽然 KMP 本身是线性的,但 Python 的大循环还是会有常数开销。一个常见的优化是用 PyPy 提交,或者把读入从input()改成sys.stdin.buffer.read().split(),一次性读入所有字符串再逐条处理,能显著减少 I/O 时间。
3.4 Java / C# / JavaScript 的要点
Java 的String.charAt()在循环里反复调用会有一些性能损耗,可以先转成字符数组再处理,计算前缀函数时用char[] arr = s.toCharArray(),之后arr[i]的访问比s.charAt(i)更快。C# 则要注意字符串是不可变类型,频繁做Substring是反面教材,这里只用索引访问字符,不需要拼接,所以问题不大。
JavaScript 版跑在 Node.js 上时,要注意s[i]在字符串上是可用的,但s.length很大时递归不可取,用普通for循环即可。函数式写法或者正则匹配在这种问题上虽然简短,但性能远不如显式循环。
3.5 输入解析与多测用例处理
字符串乘方题目中,输入格式常见两种:第一种是像 POJ 2406 那样,每行一个字符串,单独一行.表示结束;第二种是像 HDU 1358 那样,给一个 n 和一个字符串,让找出所有前缀中能由某个循环节重复得到的位置。
第二种情况需要你在构造 pi 数组的同时,检查每个位置的前缀。核心判断是(i + 1) % cycle == 0,其中i + 1是当前前缀长度。一旦满足条件,说明前缀 S[0..i] 是一个完整的乘方串,循环节长度是 cycle,幂次是(i + 1) / cycle。把这一判断放在求 pi 的循环里同步做,就不会额外增加时间复杂度。
4. 沉迷踩坑:常见问题与排查思路
4.1 next 数组定义搞混
这是新手最容易懵的地方。同样是“next 数组”,有的教程用next[0] = -1表示失配指针的起点,有的直接用前缀函数。如果把这两种实现混着用,最典型的问题就是计算cycle时多 1 或者少 1。
我自己的习惯是统一用前缀函数的定义:pi[0] = 0,计算出的pi[i]永远是长度。这样cycle = n - pi[n-1]的语义非常清楚:它是整个字符串的最小周期长度。如果你用的是next[0] = -1的版本,那么对应关系会变成cycle = n - (next[n] + 1),这个next[n]往往存储在数组末尾的下一个位置。别去硬背哪种写法更合理,挑一种顺手的,然后每次都用它,代码就不容易错。
4.2 边界条件:空串、单字符、全相同字符
边界条件绝对是判题机最爱的陷阱。先看空串:有些题目的输入可能包含空行,如果你直接读入后调用求 pi 的逻辑,n - pi[-1]会访问不存在的元素。所以进入主逻辑前,必须判断n == 0,此时没有幂次可谈,题目通常也不会给出这种数据,但健壮性处理不能少。
再看单字符"a":它的 n = 1,pi[0] = 0,cycle = 1,1 % 1 == 0,输出 1 或者 1 都没问题。但有的同学会在n == 1时产生疑惑:它算不算最小周期串?严格来说,任何字符串都可以看作自身的一次乘方,所以答案是 1。
全相同字符"aaaa"是最好的测试数据:pi 数组是[0, 1, 2, 3],cycle = 1,整除成立,答案是 4。如果这个数据你的代码输出不了 4,说明 pi 递推里的小等号可能写漏了。
4.3 大小写、空白与隐藏字符
有些题目会要求忽略大小写后判断字符串是否为乘方串,比如给你"AbAbAb",让你大小写不敏感地返回 3。解决思路很简单:先对字符串做一次统一大小写处理,变成小写再跑 KMP。这里必须明确一点,算法层面不能依赖运行环境的默认配置,也不能指望数据库的排序规则来帮忙。你必须在自己的代码里显式调用tolower或toLowerCase。
空白字符也是隐藏大坑。如果字符串是从文件或者网络中读取的,可能带换行符\n或回车符\r。C 语言里scanf("%s", s)会自动处理这部分,但如果你用fgets,记得用strcspn把末尾换行去掉。Python 的input()会自动去掉末尾换行,但不会去掉中间的空格。
4.4 哈希冲突与取模选质数
用哈希法时,质数的选择经常被忽视。base 可以取一个奇数,比如 13331、131 等,mod 最好取一个大质数,比如 1e9+7、1e9+9。如果你的字符串只包含小写字母,base 选择 131 这类数值后,碰撞概率很小;但如果包含任意 ASCII 字符,建议把 base 取大一些,或者直接用无符号 64 位整数自然溢出取模。
自然溢出取模的本质是 mod = 2^64,这在实际中运行极快,但理论上它是合数。在竞赛中被精心构造的数据确实可能卡掉单哈希,所以稳妥的工程方案是双哈希:准备两组 base 和 mod,只有两个哈希都匹配时才认为子串相等。代价是计算量翻倍,但在当前算力下完全可接受。
4.5 大字符串场景的内存与 IO
当字符串长度达到 1e6 或 1e7 级别,内存和 IO 就成了主要瓶颈。C 语言的全局数组char s[MAXN]能轻松容纳 1e6 个字符,但如果长度来到 1e7,就要考虑malloc动态分配。pi 数组的int类型在 4 字节下,1e7 大约占 40MB,内存紧张时可以把int换成short,但要注意 n 不能太大。
IO 方面,C 语言可以用scanf,C++ 建议关闭同步,ios::sync_with_stdio(false),Python 一定要用sys.stdin.buffer.read()。这些 IO 优化在你只需要跑一次算法时效果不明显,但在多测用例中能差出几倍时间。
5. 字符串乘方在真实场景里的影子
5.1 文本压缩与字典构造
字符串乘方问题和文本压缩之间有直接关系。如果一段文本由多段完全相同的子串拼接而成,那我们可以只存储一份子串和重复次数,这就是最简单的字典压缩思想。实际工程中的 LZW 压缩、zip 压缩算法里,会大量用到字符串匹配和周期检测,只不过实现细节更复杂。
在数据备份和日志去重领域,这种思路更实用。比如若干台设备上报的日志,每条日志开头都包含相同的设备标识和时间戳,通过检测这段公共前缀的“循环节”,可以显著减少存储量。我之前在做一个日志采集平台时,就遇到过磁盘写满的问题,后来对日志按前缀做了乘方聚合,存储量直接降了一个数量级。
5.2 生物信息学中的串联重复检测
DNA 和蛋白质序列里经常出现串联重复结构,比如"ATATAT"这样由短片段 repeated 的序列。这种重复与许多遗传疾病相关,所以检测一个长序列中是否存在某个片段的多次串联,是基因数据分析的常见需求。
生物信息学里的序列长度动辄上亿个碱基,KMP 前缀函数这种 O(n) 算法在这里就很有优势。更进阶的题目还会结合滑动窗口,对每个长度为 m 的子序列判断它是否为乘方串,这就完全是把字符串乘方问题放到大数据场景里重新包装了。
5.3 代码审计:重复代码块挖掘
在工程代码里,如果一段代码被原样复制粘贴了很多次,我们可以把它看作某个“代码块”的乘方结构。对代码文本做预处理,去掉空格和注释后,再检测乘方关系,能快速找到重复度最高的模块。这类工具在很多大厂内部的代码质量平台里都有实现,底层核心就是字符串周期检测。
从复杂度上分析,一个包含 n 个字符的代码文件,跑一遍 KMP 前缀函数 O(n) 就能找到所有能被短块重复覆盖的连续区间。相比之下,用暴力比较两两代码块的做法是 O(n^2),在大型代码仓库上根本跑不动。
5.4 变体题:带权字符、多次询问、逆序性质
刷题时你会碰到很多字符串乘方的变体。比如题目给一个长度为 n 的字符串,其中只包含'r'、'g'、'b'三种字符,再给一个值 m,让你统计有多少个长度不小于 m 的子串是乘方串。这类题目通常要结合滑窗和哈希:先枚举循环节长度 d,用滚动哈希快速比较每段是否相等,再统计满足长度条件的数量。
还有一种常见变体是结合回文。因为如果 S = T^k,那么 S 的逆序reverse(S)等于reverse(T)^k,乘方关系在反转操作下保持成立。利用这个性质,可以快速判断一个字符串和它的逆序是否存在相同的循环节,进而解决一些复杂的双串匹配问题。
6. 踩完这些坑之后我的实操建议
6.1 不要死记模板,理解 border 才是关键
以前我备考时也背过 KMP 模板,但一到变体题还是会卡住。后来我把 Next 数组彻底理解成“最长 border 长度”之后,很多题就突然通了。字符串的 border 就是前缀和后缀相同的部分,周期问题的本质就是利用 border 来说事。你只要记住:最小周期等于 n 减去整个字符串的 border 长度,然后判断是否能整除。这一条规则能覆盖八成字符串周期题。
6.2 遇到类似题目先写暴力,再优化
竞赛里有句经验:先写个正确的暴力程序,再拿它和数据生成器对拍,用来验证高效算法的正确性。字符串乘方问题也适合这么做。先写 O(n * τ(n)) 的因子枚举版本,再写 KMP 版本,然后构造随机小串和大串对比输出。这个方法能帮你快速发现整除判断、下标偏移这类隐蔽 bug。
6.3 最小循环节提取的一个调试技巧
如果你需要用循环节去还原最小重复子串,可以拿到cycle长度后,直接取原串前cycle个字符作为 T。但这只能在你已经确认整除条件成立时才有效,否则前 cycle 个字符并不是真正的循环节。我在调试时经常用一个工具函数,专门打印出 pi 数组和推导出的周期值,这样能直观看到算法走到了哪一步,也方便对比不同字符串下 border 长度的变化。
字符串乘方看似是个小题,但背后牵连着字符串哈希、前缀函数、周期定理这些核心知识点。把它彻底吃透,你在处理字符串匹配、压缩算法、甚至代码分析任务时都会比别人多一层底层理解。我自己在这道题上踩过的坑,就是最好的教学案例:先搞清楚数学定义,再选择合适的算法实现,最后用边界数据验证,这条路径几乎适用于所有算法题。