最小表示法

给定一个字符串 \(S_{0 \sim n-1}\),如果不断把它的最后一个字符放到开头,最终会得到 \(n\) 个字符串,称这 \(n\) 个字符串是循环同构的。这些字符串中字典序最小的一个,称为字符串 \(S\) 的最小表示。

例如 \(S\)"abca",那么它的 4 个循环同构字符串为 "abca", "aabc", "caab", "bcaa"\(S\) 的最小表示为 "aabc"。与 \(S\) 循环同构的字符串可以用该字符串在 \(S\) 中的起始下标表示,用 \(B_i\) 来表示从 \(i\) 开始的循环同构字符串,即 \(S_{i \sim n-1} + S_{0 \sim i-1}\)

如何求出一个字符串的最小表示?最朴素的方法是:按照定义,依次比较这 \(n\) 个循环同构的字符串,找到其中字典序最小的一个。比较两个循环同构字符串 \(B_i\)\(B_j\) 时,也采用直接向后扫描的方式,依次取 \(k=0,1,2,\dots\),比较 \(B_{i+k}\)\(B_{j+k}\) 是否相等,直至找到一个不相等的位置,从而确定 \(B_i\)\(B_j\) 的大小关系。

实际上,一个字符串的最小表示可以在 \(O(n)\) 的线性时间内求出。

对于任意的 \(i,j\),仔细观察 \(B_i\)\(B_j\) 的比较过程:

image

如果在 \(i+k\)\(j+k\) 处发现不相等,假设 \(S_{i+k} \gt S_{j+k}\)(这里下标实际上要对 \(n\) 取模),那么当然可以得知 \(B_i\) 不是 \(S\) 的最小表示(因为存在一个更小的循环同构串 \(B_j\))。除此之外,还可以得知 \(B_{i+1}, B_{i+2}, \dots, B_{i+k}\) 也都不是 \(S\) 的最小表示。这是因为对于 \(1 \le p \le k\),存在一个比 \(B_{i+p}\) 更小的循环同构串 \(B_{j+p}\)(从 \(i+p\)\(j+p\) 开始向后扫描,同样会在 \(p=k\) 时发现不相等,并且 \(S_{i+k} \gt S_{j+k}\))。

同理,如图 \(S_{i+k} \lt S_{j+k}\),那么 \(B_j, B_{j+1}, \dots, B_{j+k}\) 都不是 \(S\) 的最小表示,直接跳过这些位置,一定不会遗漏最小表示。

算法流程

初始化 \(i=0, \ j = 1\)

通过直接向后扫描的方法,比较 \(B_i\)\(B_j\) 两个循环同构串。

  • 如果扫描了 \(n\) 个字符后仍然相等,说明 \(S\) 有更小的循环元(例如 catcat 有循环元 cat),并且该循环元已经扫描完成,\(B_{\min(i,j)}\) 即为最小表示。
  • 如果在 \(i+k\)\(j+k\)(需要对 \(n\) 取模)处发现不相等:若 \(S_{i+k} \gt S_{j+k}\),令 \(i \leftarrow i+k+1\);若 \(S_{i+k} \lt S_{j+k}\),令 \(j \leftarrow j+k+1\)。不管是 \(i\) 还是 \(j\) 如果移动后重叠了,令其中一个加一。

\(i\)\(j\) 都小于 \(n\),则重复上面的过程,否则 \(B_{\min(i,j)}\) 为最小表示。

该算法通过两个指针不断向后移动的形式,尝试比较每两个循环同构串的大小。利用前面分析的性质,及时排除掉不可能的那个选项。当其中一个移动到结尾时,就考虑过了所有可能的二元组 \((B_i,B_j)\),从而得到了最小表示。

如果每次比较向后扫描了 \(k\) 的长度,则 \(i\)\(j\) 二者之一会向后移动 \(k\),而 \(i\)\(j\) 合计一共最多向后移动了 \(2n\) 的长度,因此该算法的时间复杂度为 \(O(n)\)

例题:P13270 【模板】最小表示法

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;

const int N = 1e7 + 5;
char s[N];

int main()
{
    int n; 
    // 读取字符串长度和字符串内容
    scanf("%d%s", &n, s);

    // i, j 是两个候选的起始位置指针,k 是当前匹配的长度偏移量
    int i = 0, j = 1, k = 0;
    
    // 最小表示法的双指针核心逻辑
    while (i < n && j < n && k < n) {
        // 比较从 i 和 j 开始,偏移量为 k 的字符(取模以处理循环同构)
        char a = s[(i + k) % n];
        char b = s[(j + k) % n];

        if (a == b) {
            // 如果对应字符相等,则增加偏移量继续向后比对
            k++;
        } else {
            // 如果不相等,则字典序较大的那个指针可以向后“跳跃”
            if (a > b) {
                // 以 i 开头的字符串字典序更大,且 i 到 i+k 之间的位置都不可能成为起点
                i = i + k + 1;
            } else {
                // 以 j 开头的字符串字典序更大
                j = j + k + 1;
            }
            // 确保两个指针不重合
            if (i == j) j++;
            // 重置偏移量,开始新一轮比对
            k = 0;
        }
    }

    // 最终最小表示的起始位置是 i 和 j 中的较小值
    int start = min(i, j);

    // 从找到的最小起点开始,循环输出 n 个字符
    for (int p = 0; p < n; p++) {
        printf("%c", s[(start + p) % n]);
    }
    printf("\n");

    return 0;
}

习题:P1368 工艺

解题思路

将最左边的方块放到最右边实际上就是循环移位操作,所以原问题就是求最小表示。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;

const int N = 3e5 + 5;
int a[N];

int main()
{
    int n; 
    scanf("%d", &n);
    // 读取每个方块的瑕疵度
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);

    // i, j 分别为两个候选的起始位置指针,k 为当前匹配的长度偏移量
    int i = 0, j = 1, k = 0;
    
    // 最小表示法的双指针核心逻辑:在 O(n) 时间内找到字典序最小的循环同构起始点
    while (i < n && j < n && k < n) {
        // 比较从 i 和 j 开始,偏移量为 k 的元素(取模以处理循环移动)
        int vi = a[(i + k) % n];
        int vj = a[(j + k) % n];

        if (vi == vj) {
            // 如果对应元素相等,则增加偏移量继续向后比对
            k++;
        } else { 
            // 如果不相等,则字典序较大的那个指针可以向后“跳跃”
            if (vi > vj) {
                // 以 i 开头的序列字典序更大,跳过不可能作为起点的区间
                i = i + k + 1;
            } else {
                // 以 j 开头的序列字典序更大
                j = j + k + 1;
            }
            
            // 确保两个候选起始指针不重合
            if (i == j) j++;
            // 重置偏移量,重新开始新一轮比对
            k = 0;
        }
    }
    
    // 最终最小表示的起始位置是 i 和 j 中的较小值
    int start = min(i, j);

    // 从找到的最小起点开始,循环输出 n 个整数
    for (int p = 0; p < n; p++) {
        printf("%d%c", a[(start + p) % n], (p == n - 1 ? '\n' : ' '));
    }
    
    return 0;
}

习题:P10476 Necklace

解题思路

项链可以从任意位置开始读取,因此一个长度为 \(n\) 的字符串有 \(n\) 种可能的表现形式。只要两个字符串属于同一组循环同构,它们就代表同一个项链。

为了判断两个字符串是否循环同构,最直观的方法是将它们都转换为各自的“最小表示”。如果两个字符串的最小表示完全相同,则它们同构。

参考代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int N = 1000005;
int n;
char a[N], b[N];

// 最小表示法函数:寻找字符串 s 的最小循环同构起始下标
int get(char s[]) {
    int i = 0, j = 1, k = 0; // i, j 是候选起点,k 是匹配偏移量
    while (i < n && j < n && k < n) {
        // 比较以 i 和 j 为起点的循环后缀字符
        char vi = s[(i + k) % n];
        char vj = s[(j + k) % n];
        
        if (vi == vj) {
            k++; // 字符相同,继续比对下一位
        } else {
            // 发现不同字符,根据字典序大小跳过不可能的起点
            if (vi > vj) {
                // 以 i 为起点的字符串字典序更大,i 及其之前的 k 个位置都不可能成为起点
                i += (k + 1);
            } else {
                // 以 j 为起点的字符串字典序更大
                j += (k + 1);
            }

            // 确保两个候选指针不重合
            if (i == j) j++;
            // 重置匹配步长
            k = 0;
        }
    }
    // 返回 i 和 j 中更小的有效下标
    return min(i, j);
}

int main()
{
    // 读取两条项链的描述字符串
    scanf("%s%s", a, b);
    n = strlen(a);

    // 获取两条项链各自的最小表示起始位置
    int sa = get(a), sb = get(b);

    // 比较两者的最小表示是否完全相同
    bool equal = true;
    for (int i = 0; i < n; i++) {
        if (a[(sa + i) % n] != b[(sb + i) % n]) {
            equal = false;
            break;
        }
    }

    // 根据比较结果输出
    if (equal) {
        printf("Yes\n");
        // 如果同构,输出其字典序最小的表示
        for (int i = 0; i < n; i++) printf("%c", a[(sa + i) % n]);
        printf("\n");
    } else {
        printf("No\n");
    }

    return 0;
}
posted @ 2026-02-03 20:02  RonChen  阅读(8)  评论(0)    收藏  举报