0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P3435,日期见页头。两边不一致时信原站。
题目描述
一个字符串是由小写英文字母组成的有限序列。特别地,它也可以是空序列(即长度为 0 的序列)。
如果字符串 A 是通过字符串 B 和 C 按顺序连接得到的,我们表示为 A = BC。
如果存在一个字符串 B 使得 A = PB,那么字符串 P 是字符串 A 的前缀。
此外,如果 P ≠ A 且 P 不是空字符串,我们称 P 是 A 的真前缀。
如果 Q 是 A 的真前缀,并且 A 是字符串 QQ 的前缀(不一定是真前缀),那么字符串 Q 是 A 的周期。
例如,字符串 abab 和 ababab 都是 abababa 的周期。
字符串 A 的最大周期是其最长的周期,如果 A 没有周期,则为空字符串。
例如,ababab 的最大周期是 abab;abc 的最大周期是空字符串。
任务:编写一个程序,计算该字符串所有前缀的最大周期长度之和。
输入格式
第一行包含一个整数 k,表示字符串的长度。接下来的一行包含一个由 k 个小写英文字母组成的字符串。
输出格式
单独一行输出一个整数,表示输入字符串所有前缀的最大周期长度之和。
数据范围
对于所有数据,1 ≤ k ≤ 10⁶。时限 1 秒,内存 131072 KB(128 MB)。
输入输出样例
输入
8 babababa
输出
24
babababa 的八个前缀里,前两个(b、ba)没有周期,各贡献 0;
后六个分别贡献 2 / 2 / 4 / 4 / 6 / 6 ⇒ 合计 24。
1★★ 第一步是翻译:题面那个「周期」,比常说的周期多一个条件
设 |A| = L、|Q| = q。题面对 Q 的要求是两条:
Q是A的真前缀 ⇒0 < q < L;A是QQ的前缀 ⇒ ①L ≤ 2q(QQ得够长);②A[i] = A[i-q](也就是通常说的「q是周期」)。
而「周期 q」⟺「border b = L − q」,把三条翻译过去就是:0 < b ≤ L/2。
⇒ 要最大的 q,就是要最小的非零 b。
★ 而那个 b ≤ L/2 是白送的,不用管:
若最短的非零 border b > L/2,那条 border 自己会重叠,于是 2b − L 也是一个更短的非零 border
—— 和「它最短」矛盾。⇒ 最短非零 border 一定 ≤ L/2。
⇒ 于是答案 = Σ (L − 最短非零 border),没有非零 border 的前缀贡献 0。
| 题 | 要的是 | 一行公式 |
|---|---|---|
| P4391 无线传输 | 最短周期 | n − ★ 最长 border |
| ★ 本题 | 最大周期 | L − ★ 最短非零 border |
⇒ 同一张题单、同一个 next 数组,两道题往相反的方向用。
⚠ 把这道题写成隔壁那道的样子(ans += (i+1) - nxt[i]),算出来的是
每个前缀的最小周期之和 —— 官方样例上 24 变成 12(第 ③ 步)。
★★ 加上 P4391 那一页量到的「多写一句 if (n % d) d = n; 就变成另一道题」,
这一章一口气给了三个方向:
⇒ 第 52 章那条「上一章的正确写法可能就是这一章的 bug」,
在同一张题单内连着成立两次。
2★ 正解:一句递推替掉「沿链跳到底」
设 f[i] = s[0..i] 的最短非零 border 长度(没有就是 0)。
nxt[i] 本身是一个 border,而比它更短的 border,正好就是「nxt[i] 那个前缀」的 border:
f[i] = nxt[i] == 0 ? 0
: f[nxt[i]-1] != 0 ? f[nxt[i]-1] // border 的 border 更短
: nxt[i]; // nxt[i] 自己就是最短的⇒ 一趟 O(n),每个下标只碰一次。 ⚠ 而所有人的第一版都是「每个前缀沿 border 链一路跳到底」—— 那是 O(n²),第 ⑤ 步量它。
// P3435 OKR-Periods of Words —— 正解:每个前缀的最大周期 = 长度 − **最短非零 border**//// ★★ 先把题面那个「周期」翻译清楚(它和常说的「周期」差一个条件):// 题面:Q 是 A 的**真前缀**,且 A 是 **QQ** 的前缀。设 |A| = L、|Q| = q,那么// · 「A 是 QQ 的前缀」⇒ ① L ≤ 2q;② A 有通常意义的周期 q(即 A[i] = A[i−q])。// · 「真前缀」⇒ 0 < q < L。// 而周期 q ⟺ border b = L − q ⇒ 三个条件合起来就是:**0 < b ≤ L/2**。// ⇒ 要最大的 q,就是要**最小的非零 border**。// ★ 而「最短非零 border 一定 ≤ L/2」是白送的:若 b > L/2,那条 border 自己重叠,// 于是 2b − L 也是一个更短的非零 border ⇒ 最短的那个不可能 > L/2。//// ⚠⚠ 这里和[同一张题单的 P4391](/sol/p4391/) **正好相反**:那道题要的是 **n − 最长 border**。// ⇒ 同一个 next 数组,两道题往两个方向用(见 p3435Long.cpp —— 那一版就是把这道题当成了那道题)。//// ★ 求「最短非零 border」不用跳链,一条递推就够(O(n)):// f[i] = s[0..i] 的最短非零 border 长度(没有就是 0)// = nxt[i] == 0 ? 0// : f[nxt[i]-1] != 0 ? f[nxt[i]-1] // border 的 border 更短// : nxt[i]; // nxt[i] 自己就是最短的那个// ⚠ 答案顶格 ≈ n²/2 = 5×10¹¹ ⇒ **必须 long long**(见 p3435Int.cpp,那条线正好在 n = 65537)。#include <cstdio>#include <cstring>using namespace std;
static const int MAXN = 1000006;static char s[MAXN];static int nxt[MAXN], f[MAXN];
int main() { int k; if (scanf("%d", &k) != 1) return 0; if (scanf("%s", s) != 1) return 0; int n = (int)strlen(s); nxt[0] = 0; for (int i = 1, j = 0; i < n; i++) { while (j > 0 && s[i] != s[j]) j = nxt[j - 1]; if (s[i] == s[j]) j++; nxt[i] = j; } long long ans = 0; for (int i = 0; i < n; i++) { int b = nxt[i]; if (b == 0) f[i] = 0; else f[i] = f[b - 1] ? f[b - 1] : b; if (f[i]) ans += (long long)(i + 1) - f[i]; // 没有非零 border ⇒ 周期是空串,加 0 } printf("%lld\n", ans); return 0;}点「运行 ▶」看结果
上面那份参照物不碰 next、不提 border,直接按题面问:
「Q = A[0..q-1] 是真前缀吗?A 是 QQ 的前缀吗?」——第一个成立的 q 就是最大周期。
⇒ 四档 1200 轮,和正解 0 组不一致。 ★ 它验的不是算法,是读题(「验算要走一条和算法完全无关的路」)。
3⚠ 错法一:用了最长 border —— 那是隔壁 P4391 的写法
「最长 border」和「最短非零 border」只有在 border 链只有一节时才是同一个东西。
⇒ 触发条件就是它的反面:存在某个前缀,nxt[nxt[i]-1] 也非零。
⚠ 而这件事在顺手写的随机数据上几乎不发生 ——
26 个小写字母随机造的串,nxt 几乎全是 0,300 轮里只有 1 轮能抓到它(第 ⑤ 步那张表)。
★ 官方样例倒是一测就死(24 → 12)。
// P3435 ✗ 错法一:用了**最长** border —— 那是隔壁 P4391 的写法//// ⚠⚠ 同一个 next 数组,两道题往**相反**的方向用:// · [P4391](/sol/p4391/):要**最短周期** = n − **最长** border;// · 本题: 要**最大周期** = L − **最短非零** border。// ⇒ 顺手写成 `ans += (i+1) - nxt[i]`,算出来的是**每个前缀的最小周期之和**。// ★ 触发条件:**存在某个前缀,它的最长 border 和最短非零 border 不是同一个**// (也就是 border 链长度 ≥ 2)。⇒ 官方样例 `babababa` 一测就死(24 → 20)。#include <cstdio>#include <cstring>using namespace std;
static const int MAXN = 1000006;static char s[MAXN];static int nxt[MAXN];
int main() { int k; if (scanf("%d", &k) != 1) return 0; if (scanf("%s", s) != 1) return 0; int n = (int)strlen(s); nxt[0] = 0; for (int i = 1, j = 0; i < n; i++) { while (j > 0 && s[i] != s[j]) j = nxt[j - 1]; if (s[i] == s[j]) j++; nxt[i] = j; } long long ans = 0; for (int i = 0; i < n; i++) if (nxt[i]) ans += (long long)(i + 1) - nxt[i]; // ⚠ 最长而不是最短 printf("%lld\n", ans); return 0;}点「运行 ▶」看结果
4⚠ 错法二:答案用 int —— 而那条线是一个眼熟的整数
// P3435 解析页上那几个「对拍看不见」的数字的出处。// ./p3435Count 人话版// ./p3435Count csv 给 check:viz 用//// 它回答三件事:// ① ★ **`int` 那条线在哪** —— 全是同一个字母时答案是 n(n−1)/2,第一次越过 2³¹−1 的 n 是多少。// ⚠ 这里**只负责算**;确认那一步交给 `check:viz`:拿真程序在 n = 65536 / 65537 上// 各跑一遍,看 int 版是不是**正好**在 65537 开始和正解不一样。// (拿同一个公式再算一遍不叫自检 —— [第 19 章 P2240](/sol/p2240/) 那条。)// ② 顶格(n = 10⁶)的答案有多大、是 `int` 上限的多少倍;// ③ ★★★ 「沿 border 链一路跳到底」那一版**跳了多少步** ——// 它的答案永远对,对拍看不见,**只能换尺子数次数**// ([第 20 章 P5019](/sol/p5019/) 那条:n 每翻 4 倍,O(n²) 的签名是次数 ×16)。#include <bits/stdc++.h>using namespace std;
/** 全 a 串上,「一路跳到底」要跳多少步 */static long long jumpSteps(const string& s) { int n = (int)s.size(); vector<int> nxt(n, 0); for (int i = 1, j = 0; i < n; i++) { while (j > 0 && s[i] != s[j]) j = nxt[j - 1]; if (s[i] == s[j]) j++; nxt[i] = j; } long long steps = 0; for (int i = 0; i < n; i++) { int j = nxt[i]; if (!j) continue; while (nxt[j - 1]) { j = nxt[j - 1]; steps++; } } return steps;}/** 正解那一版(一句递推)只花几步 —— 每个下标恰好一次 */static long long recurSteps(const string& s) { return (long long)s.size(); }
int main(int argc, char** argv) { bool csv = argc > 1 && string(argv[1]) == "csv";
/* ① int 那条线:算出来 + 穷举确认 */ long long INTMAX = 2147483647LL; int lineCalc = 0; for (long long n = 1; n <= 200000; n++) if (n * (n - 1) / 2 > INTMAX) { lineCalc = (int)n; break; } long long atLine = (long long)lineCalc * (lineCalc - 1) / 2; long long belowLine = (long long)(lineCalc - 1) * (lineCalc - 2) / 2;
/* ② 顶格 */ long long big = 1000000LL * (1000000LL - 1) / 2;
/* ③ 跳链步数:全 a 串 vs 随机串 */ mt19937 rng(20260908u); vector<int> NS = {1000, 4000, 16000}; vector<long long> stepA, stepR; for (int n : NS) { stepA.push_back(jumpSteps(string((size_t)n, 'a'))); string r; for (int i = 0; i < n; i++) { unsigned c = rng() % 26u; r.push_back((char)('a' + c)); } stepR.push_back(jumpSteps(r)); }
if (csv) { printf("lineCalc,%d\natLine,%lld\nbelowLine,%lld\n", lineCalc, atLine, belowLine); printf("big,%lld\novInt,%.0f\n", big, (double)big / (double)INTMAX); for (size_t i = 0; i < NS.size(); i++) printf("stepA%d,%lld\n", NS[(int)i], stepA[i]); for (size_t i = 0; i < NS.size(); i++) printf("stepR%d,%lld\n", NS[(int)i], stepR[i]); printf("ratioA1,%.1f\nratioA2,%.1f\n", (double)stepA[1] / (double)stepA[0], (double)stepA[2] / (double)stepA[1]); printf("recur,%lld\n", recurSteps(string(16000, 'a'))); return 0; }
printf("① int 那条线:全 a 串的答案 n(n−1)/2 第一次越过 2³¹−1 是在 n = %d\n", lineCalc); printf(" (n = %d 时 %lld ≤ 2147483647,n = %d 时 %lld > 它)\n", lineCalc - 1, belowLine, lineCalc, atLine); printf(" ⚠ 这只是算出来的;确认那一步在 check:viz 里:拿真程序在这两个 n 上各跑一遍\n"); printf(" ⇒ 和第 11 章 P1908、第 35 章 P2866 的那条线一模一样 —— 它是 int 的性质\n"); printf("② 顶格 n = 10⁶:答案 %lld,是 int 上限的 %.0f 倍\n", big, (double)big / (double)INTMAX); printf("③ 「一路跳到底」跳了多少步(全 a 串 / 随机串):\n"); for (size_t i = 0; i < NS.size(); i++) printf(" n = %5d:全 a %12lld 步 随机 %6lld 步\n", NS[(int)i], stepA[i], stepR[i]); printf(" n 每翻 4 倍,全 a 那一列 ×%.1f、×%.1f —— 这就是 O(n²) 的签名\n", (double)stepA[1] / (double)stepA[0], (double)stepA[2] / (double)stepA[1]); printf(" ★ 而正解那一句递推:每个下标恰好走一次,n = 16000 就是 %lld 步\n", recurSteps(string(16000, 'a'))); return 0;}点「运行 ▶」看结果
全是同一个字母时,长度 L 的前缀贡献 L − 1 ⇒ 答案 = Σ (L−1) = n(n−1)/2。
n(n−1)/2 第一次越过 2³¹−1 |
★ n = 65537 |
(n = 65536 时 2 147 450 880 ≤ 2 147 483 647;n = 65537 时 2 147 516 416 > 它) |
|
顶格 n = 10⁶ 的答案 |
499 999 500 000,是 int 上限的 233 倍 |
★★★ 而 65537 这个数,第 11 章 P1908(逆序对)和
第 35 章 P2866(能看见几头牛)量到的是同一个 ——
三道题毫无关系,可它们的上界都是 n(n−1)/2。
⇒ 它是 int 的性质,不是那道题的性质(和 46341 那个数一样)。
⚠⚠ 而「算出来的那条线」不能拿同一个公式再算一遍来自检(第 19 章 P2240 那条)——
所以确认那一步是拿真程序跑的:全 a 串,n = 65536 两版都打 2 147 450 880,
n = 65537 正解打 2 147 516 416、int 版打 −2 147 450 880。线正好在那儿。
5★★★ 慢法:沿 border 链一路跳到底 —— 答案永远对,只能数次数
「一路跳到底」和正解的答案逐字节相同,对拍三万轮也抓不到。 ⇒ 换尺子,数「一共跳了多少步」:
n |
全是 a |
随机 26 个字母 |
|---|---|---|
| 1 000 | 498 501 | ★ 0 |
| 4 000 | 7 994 001 | ★ 0 |
| 16 000 | 127 976 001 | ★ 0 |
| 倍数(n 每翻 4 倍) | ★ ×16.0、×16.0 | — |
★★★ ×16 就是 O(n²) 的签名(第 20 章 P5019 那条)——
顶格 n = 10⁶ 要跳约 5 × 10¹¹ 步,一秒钟想都别想。
⚠⚠ 而随机串那一列是精确的 0:随机 26 个字母几乎没有 border,链一节都没有
⇒ 顺手造一组「顶格随机」跑一遍,这个坑一步都看不见。
★ 对照:正解那一句递推在 n = 16000 上只走 16 000 步 —— 差 7998 倍。
⇒ ★★ 一句记忆化,复杂度从 O(n²) 掉到 O(n),而代码只长了一行。
6★★★ 对拍:四个待测版本里,两个对拍永远抓不到 —— 而理由完全不同
// P3435 的生成器:./p3435Gen 种子 [档位]//// ★ 四个待测版本里,**只有一个是对拍抓得到的**:// · p3435Long(用了最长 border)→ **存在某个前缀,它的 border 链长度 ≥ 2**// (也就是「最长 border」和「最短非零 border」不是同一个);// · p3435Int(答案用 int)→ ⚠ 要答案越过 2³¹,那条线在 **n = 65537**// ⇒ **小数据结构上到不了** ⇒ 对拍四档全是 0;// · p3435Jump(沿链一路跳到底)→ ⚠ **答案永远对** ⇒ 对拍原理上看不见。// ⇒ ★★ 两种「对拍是聋的」,理由完全不同:一个是「答案永远对」,一个是「档位到不了那条线」。//// 档位:// 0 ★ 顺手写的:26 个小写字母随机,n = 10~40(⚠ border 链几乎全是空的 ⇒ Long 版蒙对)// 1 ⚠ 字母表压到 2 个(a / b)// 2 ⚠ 周期串:一小段重复若干次 ⇒ border 链一下子变长// 3 ★ 最终档:周期串 + 随机尾巴//// ⚠ rng() 一律先落到具名变量再传参([第 24 章 P1776](/sol/p1776/) 那一跤)。#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1; int mode = argc > 2 ? atoi(argv[2]) : 0; mt19937 rng(seed * 1000003u + 20260908u);
int alpha = (mode >= 1) ? 2 : 26; auto pick = [&]() { unsigned r = rng() % (unsigned)alpha; return (char)('a' + r); };
string s; if (mode >= 2) { unsigned rd = rng() % 3u; int d = 1 + (int)rd; // 一段 1~3 个字符 ⇒ border 链长 unsigned rk = rng() % 8u; int k = 4 + (int)rk; string unit; for (int i = 0; i < d; i++) unit.push_back(pick()); for (int i = 0; i < k; i++) s += unit; if (mode == 3) { // 随机尾巴 unsigned rt = rng() % 6u; int t = (int)rt; for (int i = 0; i < t; i++) s.push_back(pick()); } } else { unsigned r = rng() % 31u; int n = 10 + (int)r; for (int i = 0; i < n; i++) s.push_back(pick()); } printf("%d\n%s\n", (int)s.size(), s.c_str()); return 0;}点「运行 ▶」看结果
四档 × 300 轮(正解 vs 按定义暴力:1200 轮 0 组不一致):
| 档位 | ✗ 最长 border | ✗ int |
⚠ 跳到底 |
|---|---|---|---|
| 0 ★ 顺手写的(26 个字母随机) | ⚠ 1 | ★ 0 | ★ 0 |
| 1 ⚠ 字母表压到 2 个 | 262 | ★ 0 | ★ 0 |
| 2 ⚠ 周期串(一段重复 4~11 次) | 300 | ★ 0 | ★ 0 |
| 3 ★ 最终档(周期串 + 随机尾巴) | 300 | ★ 0 | ★ 0 |
-
★★★ 两列全 0,而那两个 0 完全不是一回事:
为什么对拍抓不到 那要怎么抓 ⚠ 跳到底 答案永远对 —— 它只是慢 换尺子数次数(第 ⑤ 步) ✗ int档位到不了那条线 —— 要 n ≥ 65537才溢出算一句,再拿真程序在线两侧各跑一遍(第 ④ 步) ⇒ ★★ 本书两类「对拍是聋的」,在同一页上并排出现: 一类是「只影响复杂度、不影响答案」, 一类是「小数据结构上碰不到」。 ⚠ 看到一列全是 0,先问是哪一类 —— 加轮数对两者都没用,而救法完全不同。
-
★★ 顺手写的那一档几乎抓不到唯一那个抓得到的 —— 26 个字母随机,300 轮只有 1 轮;字母表压到 2 个就是 262。 ⇒ 第 22 章 P1020 那条「生成器该照抄题面的比值」在字符串题上的老形态: 拧的不是长度,是字母表。
-
★ 四格「触发 ≡ 抓获」一个不差:触发条件是「存在某个前缀的 border 链长度 ≥ 2」。
7★ 哪一版就已经能过了
| 版本 | 结果 | 说明 |
|---|---|---|
★ p3435.cpp |
AC | 一趟 next + 一句递推,O(n) |
| ✗ 最长 border | WA | 样例就死(24 → 12) |
✗ int |
WA | ⚠ 对拍四档全 0,n ≥ 65537 才现形 |
| ⚠ 跳到底 | TLE | ⚠ 答案全对;顶格全 a 要跳 5 × 10¹¹ 步 |
⇒ ★★ 一句话带走:这道题最难的一步在写代码之前 ——
把题面那句「A 是 QQ 的前缀」翻成「0 < b ≤ L/2」,
再看出「要最大周期 = 要最短非零 border」。
⇒ 而翻错的代价不是编译错误,是一个看起来很合理的错答案
(隔壁那道题的公式,在这道题上照样跑得飞快)。