0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P4391,日期见页头。两边不一致时信原站。
题目描述
一家无线电台需要向多位接收者发送一条信息。为了确保所有听众都能接收到, 该信息在一个连续的循环中被一遍又一遍地播放。
你将得到其中一位接收者收到的一段字符序列。已知该序列的长度至少与原信息的长度一样长。
你的任务是编写一个程序,提取出电台发送的原信息。更形式化地说,你的程序需要找到输入序列 S 的
最短子序列 S′,使得 S 本身又是(足够长的)重复序列 S′+S′+···+S′ 的子串。
输入格式
第一行包含一个整数 L,即序列 S 的长度。第二行包含恰好 L 个字符,即序列 S 本身。
该序列由小写字母组成。
输出格式
一行,一个整数:信息 S′ 的长度 L′。请注意,L′ 必须是尽可能小的值。
规模与约定
对于全部的测试点,保证 1 ≤ L ≤ 10⁶。时限 1 秒,内存 128000 KB(125 MB)。
输入输出样例
输入
8 cabcabca
输出
3
abc 不断自我连接得到 abcabcabcabc,读入的 cabcabca 是它的子串 ⇒ 答案 3。
⚠⚠ 注意这一组:n = 8,答案 3,而 3 并不整除 8 —— 第 ③ 步整节都在说这件事。
★ 另外注意 S′ = abc 不是 S 的前缀(S 以 c 开头):题面要的是「子串」,不是「前缀」。
1★ 一句话:答案 = n − 最长 border
printf("%d\n", n - nxt[n - 1]);这一行成立,靠的是两句话,每一句都得单独站住:
- 「
S是某个长度d的串重复无限次的子串」⟺「d是S的一个周期」 (周期的定义:对所有i ≥ d有s[i] == s[i-d])。- ⇐ 有周期
d⇒s[i] = s[i mod d]⇒S就是「前d个字符」重复串的前缀,当然是子串; - ⇒
S′^∞整体有周期d,而子串继承周期 ⇒S有周期d。 - ⚠ 题面要的是子串不是前缀(样例里
S′ = abc就不是S的前缀), 但上面这两半只谈长度,所以不影响答案。
- ⇐ 有周期
- 最短周期 =
n− 最长 border(border 和周期是同一件事的两种说法: 长度b的 border ⟺ 长度n − b的周期)。
⇒ 第 ② 步把这两句话按字面全枚举验了一遍,因为「一行代码 + 两层推理」正是最该验的形状。
// P4391 无线传输 —— 正解:答案 = n − nxt[n-1],一行//// ★★ 两步就到:// ① 题面那句「S 是 S'+S'+… 的**子串**」⟺ **|S'| 是 S 的一个周期**// (周期 d 的定义:对所有 i ≥ d 有 s[i] == s[i−d])。// · ⇐ 有周期 d ⇒ s[i] = s[i mod d] ⇒ S 就是 (前 d 个字符)^∞ 的前缀,当然是子串;// · ⇒ S'^∞ 整体有周期 d,而**子串继承周期** ⇒ S 有周期 d。// ⚠ 注意题面要的是「子串」不是「前缀」—— S' 不一定是 S 的前缀(`cabcabca` 的 S' 可以是 `abc`),// 但**最短的那个长度是同一个**,所以不影响答案(p4391All.cpp 里全枚举验过)。// ② 最短周期 = **n − 最长 border**(border 和周期是一一对应的两种说法)。//// ⚠⚠ 而这道题最容易挂的地方,是把另一道经典题的写法搬过来:// 「最小**整**周期」那类题(要求 d 整除 n)会多写一句 `if (n % d) d = n;`// —— 这道题**不要求整除**(样例 `cabcabca` 的答案就是 3,而 3 不整除 8)⇒ 那一句一加就错(见 p4391Div.cpp)。#include <cstdio>#include <cstring>using namespace std;
static const int MAXN = 1000006;static char s[MAXN];static int nxt[MAXN];
int main() { int n; if (scanf("%d", &n) != 1) return 0; if (scanf("%s", s) != 1) return 0; 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; } printf("%d\n", n - nxt[n - 1]); return 0;}点「运行 ▶」看结果
题面只有一句「1 ≤ L ≤ 10⁶」,没有任何子任务。
⇒ 上面那份 O(n²) 的参照物在真题上是 10¹² 次比较,一分不给
(和第 39 章 P1531 / P1198 那两道一样 ——
⚠ 而同一张题单里的 P3375 的暴力稳拿 70 分:「暴力值多少分」是「暴力 × 那道题分档」的属性)。
★ 正解那一侧的账:顶格 10⁶ 本机 不到 0.01 秒(读入 1 MB + 一趟 next),余量 100 倍以上。
2★★★ 把题面那句话按字面全枚举验一遍
// P4391 —— 把题面那句「是 S'+S'+… 的**子串**」按字面全枚举验一遍。// ./p4391All 人话版// ./p4391All csv 给 check:viz 用//// ★★★ 为什么要有这一份:正解只有一行(`n − nxt[n-1]`),而它压着**两层推理**:// ① 「S 是某个长度 d 的串重复无限次的子串」⟺「d 是 S 的一个周期」;// ② 「最短周期」= 「n − 最长 border」。// ⇒ 两层都对才轮得到那一行。而[「验算要走一条和算法完全无关的路」](/sol/p1332/)——// 这里走的是**最笨的那条**:把长度 d 的**候选串全枚举一遍**,// 真的去查「S 是不是它重复若干次之后的子串」。//// ⚠ 顺带数一个决定这一页对拍档位的数:**有多少个串会让「整除版」出错**// (也就是 n 不被答案整除的那些)—— 那正是 p4391Div.cpp 的触发条件。#include <bits/stdc++.h>using namespace std;
static const int ALPHA = 2; // 字母表 {a, b}static const int LMAX = 10; // 串长 1..10
static int byKmp(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; } return n - nxt[n - 1];}static int byPeriod(const string& s) { // 按「周期」的定义 int n = (int)s.size(); for (int d = 1; d <= n; d++) { bool ok = true; for (int i = d; i < n && ok; i++) if (s[i] != s[i - d]) ok = false; if (ok) return d; } return n;}static int byLiteral(const string& s) { // ★ 按题面**字面**:枚举候选 S',查 S 是不是它的重复串的子串 int n = (int)s.size(); for (int d = 1; d <= n; d++) { int total = 1; for (int i = 0; i < d; i++) total *= ALPHA; for (int code = 0; code < total; code++) { string t; int c = code; for (int i = 0; i < d; i++) { t.push_back((char)('a' + c % ALPHA)); c /= ALPHA; } string rep; while ((int)rep.size() < n + d) rep += t; // 重复到够长 if (rep.find(s) != string::npos) return d; } } return n;}
int main(int argc, char** argv) { bool csv = argc > 1 && string(argv[1]) == "csv";
long long total = 0, bad12 = 0, bad13 = 0, notDivide = 0, halfBorder = 0; for (int len = 1; len <= LMAX; len++) { long long cnt = 1; for (int i = 0; i < len; i++) cnt *= ALPHA; for (long long code = 0; code < cnt; code++) { string s; long long c = code; for (int i = 0; i < len; i++) { s.push_back((char)('a' + c % ALPHA)); c /= ALPHA; } total++; int a = byKmp(s), b = byPeriod(s), d = byLiteral(s); if (a != b) bad12++; if (a != d) bad13++; if (len % a) notDivide++; // ⇒ 「整除版」在这个串上会答错 int nb = len - a; if (nb * 2 == len) halfBorder++; // ⇒ 「只打 border」那版恰好蒙对 } }
if (csv) { printf("total,%lld\nbadKmpPeriod,%lld\nbadKmpLiteral,%lld\n", total, bad12, bad13); printf("notDivide,%lld\nhalfBorder,%lld\n", notDivide, halfBorder); printf("pctNotDivide,%.2f\npctHalf,%.2f\n", 100.0 * (double)notDivide / (double)total, 100.0 * (double)halfBorder / (double)total); return 0; } printf("① 字母表 {a,b}、长度 1~%d 的全部 %lld 个串,三种算法两两一致:\n", LMAX, total); printf(" n − 最长 border ↔ 按「周期」定义暴力 对不上 %lld 个\n", bad12); printf(" n − 最长 border ↔ ★ 按题面**字面**全枚举候选 S' 对不上 %lld 个\n", bad13); printf("② 其中 n **不被**答案整除的有 %lld 个(%.2f%%)—— 「整除版」正是在这些串上答错\n", notDivide, 100.0 * (double)notDivide / (double)total); printf("③ 其中最长 border 恰好是一半的有 %lld 个(%.2f%%)—— 「只打 border」那版只在这些串上蒙对\n", halfBorder, 100.0 * (double)halfBorder / (double)total); return 0;}点「运行 ▶」看结果
n − nxt[n-1] 和「按周期定义暴力」都还在同一套语言里(周期 / border)。
⇒ 要验的其实是上一层:题面说的是「S 是 S′^∞ 的子串」,这跟周期是一回事吗?
★ 所以第三条路干脆不讲道理:把长度 d 的候选串全枚举一遍
(字母表 {a,b} ⇒ 2^d 个),真的去查「S 是不是它重复若干次之后的子串」。
字母表 {a,b}、长度 1~10 的全部 2046 个串:
| 两两比较 | 对不上 |
|---|---|
n − 最长 border ↔ 按周期定义暴力 |
★ 0 个 |
n − 最长 border ↔ ★ 按题面字面枚举候选 S′ |
★ 0 个 |
⇒ 「验算要走一条和算法完全无关的路」的又一次, ★ 而这一次那条路验的不是算法,是读题 —— 它证明「子串」和「周期」在这道题上真的是一回事。
同一次全枚举还顺手数出了两个决定对拍的数:
| 个数 / 2046 | 占比 | |
|---|---|---|
n 不被答案整除的串 |
1374 | ★ 67.16% |
| 最长 border 恰好是一半的串 | 52 | 2.54% |
★★ 第一行就是下一步那个错法的触发面:三分之二的串会让「整除版」答错。
3★★★ 错法一:多写了一句「d 必须整除 n」—— 那是另一道题的正确写法
求「最小整周期」(把 S 恰好切成若干个完全相同的段)时,标准写法是:
int d = n - nxt[n-1];
if (n % d) d = n; // ★ 那道题里这一句是**必须**的⇒ 而这道题不要求切得整齐 —— 只要 S 是 S′^∞ 的子串,
S′ 可以在中间开始、也可以在中间结束。那一句一加就错。
★ 官方样例一测就死:cabcabca 的 n = 8、d = 3,3 不整除 8 ⇒ 它打出 8。
★ 触发条件:n 不被答案整除(上一步数过:随便抓一个串,67.16% 会中)。
⇒ ★★★ 这是第 52 章立的那条「上一章的正确写法可能就是这一章的 bug」 在同一张题单内的现场 —— ⚠ 而更狠的还在后面: 同一张题单里的 P3435 要的是「最短非零 border」, 和这道题的「最长 border」正好相反。三道题,三个方向。
// P4391 ✗ 错法一:多写了一句「d 必须整除 n」//// ⚠⚠ 这是**另一道题的正确写法**:求「最小**整**周期」(把 S 恰好切成若干个相同的段)时,// `d = n − nxt[n-1]` 只有在 `n % d == 0` 时才是答案,否则整个串没有整周期、答案是 n。// 那条判断在那类题上是必须的 —— ★ 而这道题**只要求 S 是 S'^∞ 的子串,不要求切得整齐**。// ⇒ 官方样例一测就死:`cabcabca` 的 n = 8、d = 3,**3 不整除 8**,它会打出 8。// ★ 触发条件:**n 不被 (n − nxt[n-1]) 整除**。#include <cstdio>#include <cstring>using namespace std;
static const int MAXN = 1000006;static char s[MAXN];static int nxt[MAXN];
int main() { int n; if (scanf("%d", &n) != 1) return 0; if (scanf("%s", s) != 1) return 0; 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; } int d = n - nxt[n - 1]; if (n % d) d = n; // ⚠ 这一句是「最小整周期」那道题的 printf("%d\n", d); return 0;}点「运行 ▶」看结果
4⚠ 错法二:把 border 本身当成了答案
「答案和 next 有关」是对的,可有关的是 n 减去它。
直接打 nxt[n-1] 等于在答「最长 border 有多长」——那是另一个问题。
★ 它只在「最长 border 恰好是一半」时蒙对(abab 那种)——
上一步全枚举数过:2046 个串里只有 52 个(2.54%)。
5★★★ 对拍:十格「触发 ≡ 抓获」,而一个对照档同时把两个错法打成能证的 0
// P4391 的生成器:./p4391Gen 种子 [档位]//// ★ 两个错法各靠什么现形:// · p4391Div(多写了「d 必须整除 n」)→ **n 不被答案整除**;// · p4391Nxt(直接打 border) → **2 × nxt[n-1] ≠ n**(border 不是正好一半)。//// 档位:// 0 ★ 顺手写的:26 个小写字母随机,n = 10~40(⚠ 几乎必然没有 border ⇒ 答案就是 n)// 1 ⚠ 字母表压到 2 个(a / b)⇒ border 开始有了// 2 ⚠⚠ **整周期串**:一小段重复整数次(n = d × k)// ⇒ ★ 那一档 `n % d == 0`,「整除版」那一句**根本不生效** ⇒ 它是**能证的精确的 0**// 3 ★ 最终档:周期串 + **随机截断**(n 不再整除 d)⇒ 「整除版」当场现形// 4 ⚠⚠ 对照档:一段**正好重复两次**(`XX` 这种形状)⇒ 最长 border 恰好是一半// ⇒ ★★ 「整除版」和「只打 border」**同时**变成能证的精确的 0(两个 0 各有一行证明)//// ⚠ 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() % 5u; int d = 2 + (int)rd; // 一段的长度 2~6 unsigned rk = rng() % 5u; int k = 3 + (int)rk; // 重复 3~7 次 if (mode == 4) k = 2; // ⚠ 对照档:正好两次 ⇒ border = n/2 string unit; for (;;) { // 摇一段出来 unit.clear(); for (int i = 0; i < d; i++) unit.push_back(pick()); if (mode != 4) break; /* ⚠ 档 4 要的是「border 恰好一半」,那要求这一段**自己没有更短的周期** (比如 unit = "aaa" 时,"aaaaaa" 的最长 border 是 5 而不是 3)*/ bool primitive = true; for (int q = 1; q < d && primitive; q++) { if (d % q) continue; bool per = true; for (int i = q; i < d && per; i++) if (unit[(size_t)i] != unit[(size_t)(i - q)]) per = false; if (per) primitive = false; } if (primitive) break; } for (int i = 0; i < k; i++) s += unit; if (mode == 3) { // ⚠ 随机截断:n 不再是 d 的倍数 unsigned rc = rng() % (unsigned)d; int cut = (int)rc; if (cut) s.resize(s.size() - (size_t)cut); } } 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 周期定义暴力:1500 轮 0 组不一致):
| 档位 | 答案 = n 的轮数 |
✗ 必须整除 | ✗ 打 border |
|---|---|---|---|
| 0 ★ 顺手写的(26 个小写字母随机) | ⚠ 284 | 16 | 300 |
| 1 ⚠ 字母表压到 2 个 | 88 | 212 | 300 |
| 2 ⚠ 整周期串(一段重复 3~7 次) | 0 | ★ 0 | 300 |
| 3 ★ 最终档(周期串 + 随机截断) | 0 | 175 | 300 |
| 4 ⚠⚠ 对照档:一段正好重复两次 | 0 | ★ 0 | ★ 0 |
-
★★★ 档 4 把两个错法同时打成 0,而两个 0 都能不跑程序地证明: 那一档的串形如
XX(X自己没有更短的周期)⇒n = 2d、最长 border 正好是d。错法 为什么在 XX上必然对必须整除 n = 2d⇒n % d == 0⇒ 那一句根本不生效打 border border = d,而答案= n − d = d⇒ 两个数恰好相等⇒ 第 33 章 P1266、第 47 章 P1200 / P1598 那条的又一次: 一个对照档可以同时给两个 0 交代清楚。 ⚠⚠ 而这一档是被实测逼出来的:第一版只写「一段重复两次」,忘了要求那一段自己是本原的 ——
unit = "aaa"时"aaaaaa"的最长 border 是 5 不是 3, 于是「打 border」那一列量出来是 74 / 300 而不是 0。 ⇒ ★★ 「结构性的 0」要把那个结构写全了才成立(第 42 章 P1965 那条)。 -
★★ 顺手写的那一档几乎在验零 —— 26 个小写字母随机,300 轮里有 284 轮答案就是
n(随机串几乎没有周期)⇒ 「整除版」只抓到 16 次。 ⇒ 而把字母表压到 2 个,同一列立刻变成 212 —— 第 22 章 P1020 那条「生成器该照抄题面的比值」在字符串题上的老形态。 -
★ 十格「触发 ≡ 抓获」一个不差:
n % 答案 ≠ 0/2 × nxt[n-1] ≠ n。
6★ 哪一版就已经能过了
| 版本 | 结果 | 说明 |
|---|---|---|
★ p4391.cpp |
AC | 顶格 10⁶ 不到 0.01 秒 |
⚠ p4391Brute.cpp |
0 分 | 没有任何部分分档,O(n²) = 10¹² |
| ✗ 必须整除 | WA | 样例就死;随便抓个串 67% 会错 |
| ✗ 打 border | WA | 每一组都错(除非 border 恰好一半,2.54%) |
⇒ ★★ 一句话带走:这道题的代码是全书最短的之一,而它值得单独占一页的理由有两个 ——
① 那一行压着两层推理,而第二层(题面「子串」⟺ 周期)只能靠按字面全枚举去验;
② 它和「最小整周期」那道经典题只差一句 if (n % d) d = n;,
而那一句在这里是错的 —— ⇒ 背下来的模板,要连它的前提一起背
(第 43 章、第 44 章 P2142 那条,换到字符串上又成立一次)。