最小表示法
摘要
最小表示法旨在解决与字符串循环同构相关的问题。对于一个给定的字符串,其所有循环同构串(通过将前缀字符移到末尾形成的新字符串)中,字典序最小的那一个被称为该字符串的“最小表示”。本文将探讨如何高效地找到这个最小表示。
一句话算法
通过双指针竞争,不断淘汰掉“不可能成为最小表示”的候选字符串,从而在线性时间内找到最优解。
问题定义
“循环同构 (Cyclic Shift)”
一个字符串 S 的所有循环同构串,是指通过将 S 的前 k 个字符(0 <= k < n,n 为字符串长度)依次移动到字符串末尾而得到的所有字符串的集合。
例如,字符串 s = "abc" 的循环同构串有三个:
"abc"(k=0)"bca"(k=1)"cab"(k=2)
最小表示法 (Minimal Representation) 的任务就是,从一个字符串的所有循环同构串中,找出字典序最小的一个。我们通常返回其在原字符串中的起始下标。
关键思路
暴力解法
最直观的思路是枚举所有可能的起始位置(从 0 到 n-1),生成对应的循环同构串,然后两两比较,找出字典序最小的那个。
这种方法需要两层循环:外层循环枚举挑战者,内层循环进行字符串比较。在最坏情况下(例如字符串为 aaaa...aab),每次比较都需要接近 n 次操作,总时间复杂度会退化到
优化思路:双指针法
暴力解法的瓶颈在于做了大量冗余的比较。很多字符串在比较的初期就已经“败下阵来”,但我们没有有效利用这个信息。
优化的核心思想类似于 KMP 算法:当比较出现不一致时,利用已知信息,尽可能多地排除不可能是答案的候选位置。
我们使用两个指针 i 和 j,分别指向两个候选的起始位置。k 用来记录 s[i...] 和 s[j...] 两个子串匹配的长度。
- 初始化:
i = 0,j = 1,k = 0。 - 比较:比较
s[(i+k)%n]和s[(j+k)%n]。- 相等:如果字符相同,说明
i和j开头的字符串在前k+1个字符上仍然是“势均力敌”的。我们增加k,继续向后比较。 - 不相等:假设
s[(i+k)%n] > s[(j+k)%n],这意味着以i开头的字符串在前k个字符与j相同,但在第k个字符处字典序更大。因此,i所代表的字符串“落败”。- 此时,不仅
i被排除了,所有以i到i+k开头的字符串都可以被排除。因为对于任何0 <= p <= k,s[j+p]都等于s[i+p],而s[j+p]对应的候选串s[j...]已经证明比s[i...]更优,所以s[i+p...]也没有机会。 - 因此,我们可以安全地将
i指针移动到i+k+1的位置,然后从头开始比较(k=0)。
- 此时,不仅
- 同理,如果
s[(i+k)%n] < s[(j+k)%n],则j落败,我们将j移动到j+k+1。
- 相等:如果字符相同,说明
为了避免 i 和 j 指向同一个位置,如果 i 在更新后等于 j,则让 i(或j)再前进一位。
这个过程不断进行,i 和 j 像两名选手在比赛,输的一方会大幅前进,跳过大量不可能的位置。当其中一个指针超过 n 时,比赛结束,未出局的指针就是最小表示的起始位置。
算法步骤
- 初始化指针
i = 0,j = 1,k = 0。 - 当
i < n,j < n,k < n时,循环执行以下步骤: a. 计算当前要比较的字符:char_i = s[(i + k) % n]和char_j = s[(j + k) % n]。 b. 如果char_i == char_j,则k自增 1,继续比较下一个字符。 c. 如果char_i > char_j,说明以i开头的字符串不是最优的。将i更新为i + k + 1。 d. 如果char_i < char_j,说明以j开头的字符串不是最优的。将j更新为j + k + 1。 e. 在 c 或 d 之后,重置k = 0。 f. 如果更新后i == j,则将j自增 1。 - 循环结束后,
min(i, j)即为最小表示的起始下标。
算法证明
该算法的关键在于一次失配可以排除 k+1 个候选位置的正确性。
假设在比较 s[i...] 和 s[j...] 时,它们的前 k 个字符相同,即 s[i..i+k-1] == s[j..j+k-1],但在第 k 个字符处 s[i+k] > s[j+k]。
这意味着字符串 s[i...] 比 s[j...] 要差。
现在我们考虑任何一个在 [i, i+k] 区间内的起始位置 i+p(0 <= p <= k)。对应的竞争者是 s[j+p...]。由于 s[i..i+k-1] == s[j..j+k-1],我们有 s[i+p..i+k-1] == s[j+p..j+k-1]。
而 s[i+k] > s[j+k]。
这意味着 s[i+p...] 和 s[j+p...] 的第一个不同点,就是 s[i+k] 和 s[j+k] 的比较,且前者更大。所以 s[j+p...] 总是优于 s[i+p...]。
因此,i 到 i+k 之间的所有位置都不可能是最小表示的起点,我们可以安全地将 i 移动到 i+k+1。
复杂度分析
- 时间复杂度:
。虽然代码看起来有多重循环,但我们可以分析指针移动的总次数。 i和j两个指针都只会单调递增。在每次比较中,要么k增加,要么i或j中的一个增加k+1。i+j+k的值在每次迭代中都是增加的,其最大值不超过3n。因此,总的比较次数是线性的。 - 空间复杂度:
。我们只使用了常数个额外变量。
代码实现
下面是根据上述思想实现的C++代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 2025-11-23 代码 我虽然凭直觉写了出来
// 但是我不能进行数学证明,我无法理解这个代码
// 我甚至无法证明这个算法是O(n)的
#include <bits/stdc++.h>
using namespace std;
const int maxn = 2e7+5;
int n;;
char a[maxn];
int get_min(){
int min_pos = 0;
int i = 1;
//用来比较的串
while(i < n)
{
int k;
for(k = 0;k < n;k++) {
if( a[min_pos+k] != a[i+k]) break;
}
// if( k == n) break; // 周期串
if( a[min_pos+k] < a[i+k]) { // min_pos 更小
i = i+k+1; // 排除不可能的位置
}
else { // i 更小
// 计算下一个可能的位置
int next_pos_1 = min_pos +k+1;
int next_pos_2 = i+1;
// 更新min_pos
min_pos = i;
i = std::max(next_pos_1,next_pos_2);
}
}
return min_pos;
}
int main (int argc, char *argv[]) {
std::cin >> n;
std::cin >> a;
//复制字符串
for(int i = 0;i < n ;++i ) // i: 0->n
a[i+n] = a[i];
int p = get_min();
for(int i = 1;i <= n ;++i ) // i: 1->n
{
int tp = p+i-1;
cout << a[tp % n] ;
}
return 0;
}
测试用例
让我们用字符串 s = "abacaba" (n=7) 来手动模拟算法。
- 初始:
i = 0(abacaba),j = 1(bacabaa),k = 0。 s[0](‘a’) <s[1](‘b’)。j落败。j = j + k + 1 = 1 + 0 + 1 = 2。k = 0。i = 0(abacaba),j = 2(acabaab)。
s[0](‘a’) ==s[2](‘a’)。k = 1。s[1](‘b’) <s[3](‘c’)。j落败。j = j + k + 1 = 2 + 1 + 1 = 4。k = 0。i = 0(abacaba),j = 4(abaabca)。
s[0..2](aba) ==s[4..6](aba)。k增加到 3。s[3](‘c’) >s[(4+3)%7=0](‘a’)。i落败。i = i + k + 1 = 0 + 3 + 1 = 4。k = 0。i = 4(abaabca),j = 4。
i == j,j变为 5。i = 4(abaabca),j = 5(baabaca)。
s[4](‘a’) <s[5](‘b’)。j落败。j = j + k + 1 = 5 + 0 + 1 = 6。k = 0。i = 4(abaabca),j = 6(aabacab)。
s[4](‘a’) ==s[6](‘a’)。k = 1。s[5](‘b’) >s[(6+1)%7=0](‘a’)。i落败。i = i + k + 1 = 4 + 1 + 1 = 6。k = 0。i = 6(aabacab),j = 6。
i == j,j变为 7。j到达n,循环结束。
最终 min(i, j) = min(6, 7) = 6。
所以,最小表示是从下标 6 开始的字符串 aabacab。
实践思考与扩展
- 非循环字符串: 这种双指针竞争的思想也可以用于非循环字符串的问题。例如,LeetCode 1044. 最长重复子串 和 LeetCode 1163. 按字典序排在最后的子串 都可以借鉴类似的思想来优化。
- 多个最小表示: 如果一个字符串有多个相同的最小表示(例如
s = "ababab"),该算法会返回这些表示中的第一个。