「一本通 2.1 练习 1」Power Strings

GitHub跳转原题关系图返回列表

把 s 接成 s+s 后从下标 1 找 s 的首次再现,该位置就是整除 n 的最小周期 d,答案 n//d;CPython 的 find 用 Two-Way 算法线性完成,与 KMP 同阶。

OJ: roj

题目 ID: 1457

难度:普及+/提高-

标签:字符串KMP周期python

创建: 2026-09-30 11:53

更新: 2026-10-07 11:01

形式化题目

给定若干个仅含英文字母的非空字符串,对每个字符串 ss 求最大的正整数 kk, 使得存在某个字符串 tt 满足 s=t+t+⋯+t⏟k 个 ts = \underbrace{t + t + \cdots + t}_{k \text{ 个 } t} (把 tt 重复连接 kk 次恰好得到 ss)。

例如 ababab = (ab)^3,k=3k = 3;abcd 无法由更短的串重复得到,k=1k = 1。

正解

思路

朴素做法与其瓶颈。 设 ∣s∣=n|s| = n。若 s=tks = t^k,则周期 d=∣t∣=n/kd = |t| = n/k 一定是 nn 的约数。 于是可以枚举 nn 的每个约数 dd,验证 s[:d] * (n // d) == s,取最小的可行 dd,答案即 n/dn/d。 单次验证要做一遍全长比较,代价 O(n)O(n);而 n⩽106n \leqslant 10^6 的正整数最多有 240 个约数, 单串最坏 O(240n)O(240n),输入又是"若干行"串,累加起来无法接受。 瓶颈在于:对每个约数都要独立做一次全文比较,彼此之间没有任何信息复用。 理想目标是一次线性扫描就把最短周期求出来。

关键转化:把串看成环,用"循环移位"刻画周期。 把 ss 首尾相接围成一个环,定义 “左移 ii 位不变”:把环整体转 ii 格后读出的串仍是 ss。两个方向:

  • 若 s=tks = t^k,d=∣t∣d = |t|:环上每隔 dd 格的内容完全相同,左移 dd 位后逐位对齐,ss 不变。
  • 若左移 ii 位不变:从环上任一位置出发,沿环每走 ii 步字符都相同(走 −i-i 步同理)。 由裴蜀定理,存在整数 a,ba, b 使 a⋅i+b⋅n=gcd⁡(i,n)a \cdot i + b \cdot n = \gcd(i, n),即模 nn 意义下 走 aa 个 ±i\pm i 步等价于走 g=gcd⁡(i,n)g = \gcd(i, n) 步,所以左移 gg 位也不变。

于是"不变移位"集合对加法封闭,构成模 nn 的子群。设最小的正不变移位为 dd,则:

  1. dd 整除所有不变移位;对 dd 自己用一次上面的推理得 gcd⁡(d,n)\gcd(d, n) 也是不变移位且 ⩽d\leqslant d,由最小性 gcd⁡(d,n)=d\gcd(d, n) = d,即 d∣nd \mid n。
  2. 左移 dd 位不变说明环转 dd 格后逐位相同,而 d∣nd \mid n,所以把环剪开恰好得到 n/dn/d 段相同的 s[:d]s[:d],即 s=(s[:d])n/ds = (s[:d])^{n/d}。
  3. 不存在更短的 tt 使 s=tks = t^k,否则 ∣t∣|t| 是比 dd 更小的正不变移位。

所以答案 =n/d= n/d,其中 dd 是最小正不变移位(最小整除周期)。

线性求 dd:在 s+ss+s 里找 ss 的第一次再现。 把 ss 接在自己后面得 s+ss+s(长 2n2n)。 ss 在 s+ss+s 的位置 ii(1⩽i⩽n1 \leqslant i \leqslant n)出现,意味着 (s+s)[i+k]=s[k](s+s)[i+k] = s[k] 对所有 kk 成立,而 (s+s)[i+k]=s[(i+k) mod n](s+s)[i+k] = s[(i+k) \bmod n], 这恰好就是"左移 ii 位不变";反之亦然。因此:

d=(s+s).find(s, 1)d = (s+s).\text{find}(s,\ 1)

位置 00 是平凡出现要跳过;位置 nn 处 ss 总是出现——那是平凡的移位 n≡0(modn)n \equiv 0 \pmod n, 对应答案 1——所以 find 一定有返回,无需判 −1-1。

用样例 ababab(n=6n = 6,s+s = abababababab)逐个起点检查 ss 是否再现:

出现起点 ii 从该位起取 6 个字符 是否等于 ababab
0 ababab 是(平凡:自己,跳过)
1 bababa 否
2 ababab 是(第一次再现)
3 bababa 否
4 ababab 是
5 bababa 否
6 ababab 是(平凡:第二个 ss)

观察重点:再现位置恰好是 {2,4,6}\{2, 4, 6\}——全是最小周期 22 的倍数, 这正是"不变移位构成子群"的直观体现;第一个再现位置 22 就是 dd,答案 6/2=36/2 = 3。 而左移 11 位得到 bababa ≠\neq ababab,与表中 i=1i=1 不匹配一致。 另外两组样例同样吻合:aaaa 的再现位置是 {1,2,3,4}\{1, 2, 3, 4\},d=1d = 1,答案 44; abcd 除位置 00 和 44 外没有再现,d=4d = 4,答案 11。

这个 find 并不比 KMP 慢:经典 KMP 做法先求 fail 数组,令 p=n−fail[n−1]p = n - \text{fail}[n-1] (补上最长公共前后缀),pp 是最小周期,n mod p=0n \bmod p = 0 时答案 n/pn/p,否则 11; 而 CPython 的 str/bytes.find 底层采用 Two-Way(Crochemore–Perrin)算法, 最坏情况同样是线性 O(n)O(n)——与手写 KMP 同阶,却以 C 速度运行,还省掉了 O(n)O(n) 的 fail 数组。本文的 main.py 就采用 find 写法。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 11:53
# update_at: 2026-09-30 11:53

import sys


def max_repeats(s: bytes) -> int:
    """返回 s 最多由多少个相同子串重复连接而成。"""
    # s 在 s+s 中首次再现的位置(从 1 起)= 能整除 len(s) 的最小周期,
    # CPython 的 find 用 Two-Way 算法,最坏也是线性时间。
    period = (s + s).find(s, 1)
    return len(s) // period


def solve() -> None:
    # 每行一个仅含字母的串,读到 "." 结束;按空白切分即可逐串处理
    for s in sys.stdin.buffer.read().split():
        if s == b".":
            break
        print(max_repeats(s))


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-09-30 12:00
 * update_at: 2026-10-05 02:26
 */
// KMP 求 fail 数组:fail[n-1] 是整个串的最长公共前后缀长度,
// 则 p = n - fail[n-1] 是最小周期;p 整除 n 时答案是 n/p,否则串不是幂串,答案 1。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;

typedef long long ll;

int len;            // 当前串长
char s[MAXN];       // 当前字符串,下标从 0 开始
int fail[MAXN];     // fail[i] = s[0..i] 的最长相等前后缀长度(不含整个串)

// 对当前串求 fail 数组(KMP 失配函数)。
void get_fail() {
    fail[0] = 0;
    // j 表示 s[0..i-1] 匹配到的前缀长度,尝试把 s[i] 接在 s[j] 后面
    for (int i = 1, j = 0; i < len; i++) {
        while (j > 0 && s[i] != s[j]) j = fail[j - 1]; // 失配则跳到更短的前后缀
        if (s[i] == s[j]) j++;
        fail[i] = j;
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    while (cin >> s) {
        len = strlen(s); // 声明 len 为 int 即可,无强制转换
        if (len == 1 && s[0] == '.') break; // 句号结束输入

        get_fail();
        int p = len - fail[len - 1]; // 最小周期
        if (len % p == 0) {
            cout << len / p << "\n"; // 整周期,串是幂串
        } else {
            cout << 1 << "\n"; // 最小周期不整除 n,无法由重复子串构成
        }
    }

    return 0;
}

正文与代码的对应关系:

  • max_repeats(s) 中的 period = (s+s).find(s, 1) 就是上文的最小整除周期 dd;
  • len(s) // period 即答案 n/dn/d(d∣nd \mid n 由子群性质保证,整除必成立);
  • solve() 里 sys.stdin.buffer.read().split() 把输入按空白切成一个个串, 读到 b"." 提前结束,与"每行一个串、. 停止"的格式等价(串只含字母)。

复杂度

设一次输入的所有串长总和为 LL(单串 ⩽106\leqslant 10^6)。

  • 时间:每串拼 s+ss+s 与一次 find 都是 O(n)O(n),总计 O(L)O(L),与 KMP 正解同阶。 本地真实数据 10 组全部通过,最慢单测约 0.04s(OJ 时限 1s),无超时风险。
  • 空间:单串峰值 O(n)O(n)(s+s 长 2n2n,n=106n = 10^6 时约 3MB,远低于 128MB 限制)。

总结

  • 幂串问题 s=tks = t^k 的核心是求最小整除周期 dd,答案 n/dn/d。
  • 把串看成环后,“ss 是幂串"等价于"存在非平凡循环移位使 ss 不变”;由裴蜀定理, 不变移位构成子群,最小正元自动整除 nn——这一步把"逐个验证约数"变成了"找第一个再现位置"。
  • ss 在 s+ss+s 中(位置 ⩾1\geqslant 1)第一次再现的位置就是 dd,任何线性字符串匹配 (KMP、Z 函数、Two-Way find)都能完成这最后一步。
  • Python 实现要点:bytes 读入免解码,内建 find 以 C 速度做线性匹配, 全程没有 Python 层的逐字符循环。