最小表示法

1 一句话算法

最小表示法让两个起点互相比赛,一旦第 kk 位分出胜负,就一次淘汰输家起点后面连续 k+1k+1 个不可能答案。

2 问题模型

给定一个长度为 nn 的字符串 ss,把它首尾相接看成环。每个起点都能得到一个循环同构串:

s = abc
起点 0: abc
起点 1: bca
起点 2: cab

目标是找到字典序最小的循环同构串,并返回它在原串中的起点。

例如:

s = bba
循环同构: bba, bab, abb
最小表示: abb
起点: 2

3 核心直觉

暴力做法会枚举所有起点,两两比较,最坏 O(n2)O(n^2)。

最小表示法的核心是“比较一次,淘汰一段”。

设两个候选起点为 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 算法步骤

  1. 令 i = 0,j = 1,分别表示两个候选起点。
  2. 令 k = 0,表示当前两个候选已经匹配的长度。
  3. 比较 s[(i+k)%n] 和 s[(j+k)%n]:
    • 若相等,令 k++;
    • 若左边更大,i 这一段失败,令 i += k + 1;
    • 若右边更大,j 这一段失败,令 j += k + 1。
  4. 每次淘汰后重置 k = 0。
  5. 若 i == j,把其中一个指针再向后移动一位。
  6. 当某个指针达到 n 或已经比较了 n 个字符,答案是 min(i,j)。

5 算法证明

关键引理:若 S_i 和 S_j 前 k 个字符相同,并且第 k 个字符处 S_i > S_j,那么 i, i+1, ..., i+k 都不可能是答案。

证明:

取任意偏移 pp,其中 0≤p≤k0 \le p \le k。

由于前 kk 个字符相同,S_{i+p} 与 S_{j+p} 在遇到第一个不同字符前,看到的是同一段对应内容。到比较到原来的失配点时:

Si[k]>Sj[k] S_i[k] > S_j[k]

因此:

Si+p>Sj+p 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 复杂度分析

设字符串长度为 nn。

  • 时间复杂度:O(n)O(n)。每轮相等比较累计的长度为 kk,一旦失配,就让某个候选前进 k+1k+1 步,可把这些比较计入这次前进。两个指针总共前进 O(n)O(n) 步;如果最后因为相等比较达到 nn 而停止,也只额外比较 nn 次。
  • 算法额外空间复杂度:O(1)O(1),只维护 i,j,k 等常数变量;输入串、输出缓冲另计。Python 完整程序拼出结果字符串时会分配 O(n)O(n) 空间。

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 相关题解

暂无相关题解