1 一句话算法
最小表示法让两个起点互相比赛,一旦第
2 问题模型
给定一个长度为
s = abc
起点 0: abc
起点 1: bca
起点 2: cab
目标是找到字典序最小的循环同构串,并返回它在原串中的起点。
例如:
s = bba
循环同构: bba, bab, abb
最小表示: abb
起点: 2
3 核心直觉
暴力做法会枚举所有起点,两两比较,最坏
最小表示法的核心是“比较一次,淘汰一段”。
设两个候选起点为 i 和 j,它们前 k 个字符都相同,但第 k 个字符不同:
s[i + 0] == s[j + 0]
s[i + 1] == s[j + 1]
...
s[i + k - 1] == s[j + k - 1]
s[i + k] > s[j + k]
此时从 i 开始的循环串输给了从 j 开始的循环串。不只 i 会输,i, i+1, ..., i+k 这些起点都不可能成为最小表示。
原因是每个 i+p 都能找到对应的 j+p 作为更优竞争者。
4 算法步骤
- 令
i = 0,j = 1,分别表示两个候选起点。 - 令
k = 0,表示当前两个候选已经匹配的长度。 - 比较
s[(i+k)%n]和s[(j+k)%n]:- 若相等,令
k++; - 若左边更大,
i这一段失败,令i += k + 1; - 若右边更大,
j这一段失败,令j += k + 1。
- 若相等,令
- 每次淘汰后重置
k = 0。 - 若
i == j,把其中一个指针再向后移动一位。 - 当某个指针达到
n或已经比较了n个字符,答案是min(i,j)。
5 算法证明
关键引理:若 S_i 和 S_j 前 k 个字符相同,并且第 k 个字符处 S_i > S_j,那么 i, i+1, ..., i+k 都不可能是答案。
证明:
取任意偏移
由于前 S_{i+p} 与 S_{j+p} 在遇到第一个不同字符前,看到的是同一段对应内容。到比较到原来的失配点时:
因此:
也就是说,起点 i+p 至少输给了起点 j+p,所以 i+p 不可能是全局最小。
于是当 S_i > S_j 时,可以安全跳过 [i,i+k];反过来当 S_j > S_i 时,可以安全跳过 [j,j+k]。
指针始终只向右移动,每次淘汰的起点都已证明不可能成为答案,所以最后留下的候选就是最小表示起点。
6 复杂度分析
设字符串长度为
- 时间复杂度:
。每轮相等比较累计的长度为 ,一旦失配,就让某个候选前进 步,可把这些比较计入这次前进。两个指针总共前进 步;如果最后因为相等比较达到 而停止,也只额外比较 次。 - 算法额外空间复杂度:
,只维护 i,j,k等常数变量;输入串、输出缓冲另计。Python 完整程序拼出结果字符串时会分配空间。
7 代码实现
模板输入一个字符串,输出它的最小表示。
#include <bits/stdc++.h>
using namespace std;
int minimal_rotation_pos(const string &s) {
int n = (int)s.size();
int i = 0;
int j = 1;
int k = 0;
while (i < n && j < n && k < n) {
char a = s[(i + k) % n];
char b = s[(j + k) % n];
if (a == b) {
k++;
} else if (a > b) {
i += k + 1;
if (i == j) i++;
k = 0;
} else {
j += k + 1;
if (i == j) j++;
k = 0;
}
}
return min(i, j);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> s;
int pos = minimal_rotation_pos(s);
for (int i = 0; i < (int)s.size(); i++) {
cout << s[(pos + i) % (int)s.size()];
}
cout << '\n';
return 0;
}
# 最小表示法:返回字典序最小的循环同构串的起始下标(0 <= pos < n)。
# 双指针 i、j 加偏移 k 比较,失配时一次淘汰 k+1 个起点,均摊 O(n)。
# 空串返回 0(初始 i=0, j=1 取 min);调用方自行拼出 s[pos:] + s[:pos]。
def minimal_rotation_pos(s: str) -> int:
"""返回最小循环同构串的起点下标。"""
n = len(s)
i = 0
j = 1
k = 0
while i < n and j < n and k < n:
# (i + k) % n:循环同构,越过末尾就绕回开头。
a = s[(i + k) % n]
b = s[(j + k) % n]
if a == b:
k += 1
elif a > b:
# s[i..i+k] 这一段字典序更大,i 到 i+k 都不可能是答案,整体跳过。
i += k + 1
if i == j: # 两指针重合会让比较失去意义,错开一格
i += 1
k = 0
else:
j += k + 1
if i == j:
j += 1
k = 0
return min(i, j)
if __name__ == "__main__":
import sys
data = sys.stdin.read().split()
if data:
s = data[0]
pos = minimal_rotation_pos(s)
print(s[pos:] + s[:pos])
8 测试用例
输入:
bba
输出:
abb
再看一个有重复周期的例子:
ababab
最小表示仍然是:
ababab
它有多个等价最小起点,模板返回其中下标最小的一个;本页采用 0 起始下标。
9 应用分类详解
最小表示法的本质是“在所有循环同构串中找字典序最小代表”。看到字符串可以旋转、项链、环形序列时,就应该想到它。
一、 循环同构规范化
典型模式: 判断两个环形字符串是否本质相同,或需要给环形字符串一个唯一代表。
识别信号: 出现“旋转后相同”“循环移位”“环形字符串”。
核心建模: 对每个字符串求最小表示,把环形对象变成普通字符串后比较。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 判断旋转等价 | 环形字符串判等 | 两个串的最小表示相同则等价 |
| 项链表示 | 项链去重 | 每条项链用最小表示作为规范形 |
二、 环形序列最小字典序输出
典型模式: 给定一个环形排列,要求从某处断开,使输出字典序最小。
识别信号: 出现“选择一个起点输出”“环形数组”“字典序最小”。
核心建模: 把序列转成字符串或可比较数组,使用最小表示法找断点。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 模板题 | luogu P13270 | 直接求最小表示起点 |
| 最小循环移位 | AcWing 136 | 双指针淘汰候选起点 |
三、 作为字符串比较组件
典型模式: 更大问题中需要快速比较环形状态。
识别信号: 状态有环形对称性,直接比较会产生很多重复状态。
核心建模: 先把每个环形状态规范化,再放入集合、排序或哈希表。
10 经典例题
1. luogu P13270
最小表示法模板题。重点练习双指针淘汰区间的写法。
2. AcWing 136
求字符串的最小循环移位。适合作为最小表示法入门题。
3. LeetCode 1163(按字典序排在最后的子串)
不是环形字符串,但也体现了“多个起点竞争,失败者批量淘汰”的思想。可以用来理解同类双指针比较模型。
11 参考
12 草稿归档
旧稿维护说明
12.1 草稿与正式教程的关系
旧稿中的双指针淘汰思路、重叠区间证明和题目线索已经合并进本页的正式教程。两个候选区间即使重叠,偏移后的两段在遇到失配点前依然逐位相等,所以淘汰论证不要求它们互不相交。
后续维护正文 index.md 以及本目录 code/minimal-string.cpp、code/minimal-string.py,不再维护第二套算法实现。旧目录中的两幅 Excalidraw 图片也保留在 images/ 下。
13 相关题解
暂无相关题解