1.Manacher算法

题目描述

给出一个只由小写英文字符 $\texttt a,\texttt b,\texttt c,\ldots\texttt y,\texttt z$ 组成的字符串 SS ,求 SS 中最长回文串的长度 。

字符串长度为 nn。

输入格式

一行小写英文字符 $\texttt a,\texttt b,\texttt c,\cdots,\texttt y,\texttt z$ 组成的字符串 SS。

输出格式

一个整数表示答案。

输入输出样例 #1

输入 #1

aaa

输出 #1

3

说明/提示

1≤n≤1.1×1071\le n\le 1.1\times 10^7。

Manacher 算法(马拉车算法),用于在线性时间 O (n) 内求解一个字符串的最长回文子串长度。

1.1 核心思路是:

  1. 字符串预处理:在原字符串的每个字符两侧插入分隔符(如#),消除奇偶长度回文的差异,统一处理;

  2. 回文半径数组 d:d[i]表示以预处理后字符串的第i位为中心的最长回文子串的半径(包含中心本身);

  3. 利用回文对称性优化:通过维护当前已知的最右回文边界[l, r],减少重复匹配,将时间复杂度降至 O (n);

  4. 结果转换:预处理后的回文半径减 1,即为原字符串的最长回文子串长度。

1.2 字符串预处理的作用:

  • 原字符串abba → 预处理后#a#b#b#a#,最长回文半径为 5,5-1=4(原长度);

  • 原字符串abcba → 预处理后#a#b#c#b#a#,最长回文半径为 6,6-1=5(原长度);

  • 统一奇偶长度回文的处理逻辑,无需分情况讨论。

1.3 回文半径数组 d 的含义:

  • 对于预处理后的字符串s,d[i]表示以s[i]为中心的最长回文子串的半径(包含中心);

  • 例如s[i] = #,d[i] = 3,则回文子串为s[i-2..i+2],长度为2*3-1=5,对应原字符串长度为3-1=2。

1.4 最右回文边界 [l, r] 的优化逻辑:

  • 当i在[l, r]内时,利用回文的对称性,直接复用对称位置的d值,避免重复匹配;

  • r-i+1是i到r的距离,确保d[i]不会超出当前最右边界,否则需要暴力扩展。

1.5 哨兵字符s[0] = '$':

  • 避免在while循环中访问s[-1]等越界位置,因为s[0]是特殊字符,永远不会和其他字符相等,循环会自动终止。

1.6 时间复杂度:

  • 每个字符最多被扩展一次(暴力扩展的总次数为 O (n)),因此整体时间复杂度为 O (n)。

参考代码

// Manacher算法:线性时间求解最长回文子串长度 O(n)
#include<bits/stdc++.h>
using namespace std;

// 常量定义:N需足够大,覆盖预处理后的字符串长度(原字符串长度*2+2)
const int N = 3e7;
char a[N];   // 存储原始输入字符串(下标从1开始)
char s[N];   // 存储预处理后的字符串(插入分隔符#,消除奇偶回文差异)
int d[N];    // 回文半径数组:d[i]表示以s[i]为中心的最长回文子串的半径(包含中心)

/*
 * 计算预处理后字符串s的回文半径数组d
 * s 预处理后的字符串(下标从1开始)
 * n 预处理后字符串的长度
 */
void get_d(char *s, int n)
{
    // 初始化:预处理后字符串第一个有效位置(s[1]是#)的回文半径为1(仅自身)
    d[1] = 1;
    
    // i:当前遍历的中心位置;l/r:当前已知的最右回文子串的左/右边界(初始r=1)
    for (int i = 2, l, r = 1; i <= n; i++)
    {
        // 第一步:核心优化:利用回文对称性,减少重复匹配
        if (i <= r)  // 当前中心i在最右回文边界r内,可复用对称位置的回文半径
        {
            // r-i+l:i关于当前回文中心((l+r)/2)的对称位置
            // min(d[对称位置], r-i+1):取较小值,避免超出当前最右边界r
            d[i] = min(d[r - i + l], r - i + 1);
        }
        // 第二步:暴力扩展:以i为中心,向左右匹配,直到字符不相等
        // s[i-d[i]] 是当前左边界,s[i+d[i]] 是当前右边界
        while (s[i - d[i]] == s[i + d[i]])
            d[i]++;  // 匹配成功,回文半径+1
        
        // 第三步:更新最右回文边界:如果当前回文的右边界超出r,则更新l和r
        if (i + d[i] - 1 > r)
        {
            l = i - d[i] + 1;  // 新回文的左边界
            r = i + d[i] - 1;  // 新回文的右边界
        }
    }
}

int main()
{
    // 读取原始字符串,存储到a[1]开始的位置(方便后续预处理)
    scanf("%s", a + 1);
    // n:原始字符串长度;k:预处理后字符串的下标指针(初始0)
    int n = strlen(a + 1), k = 0;
    
    // 预处理字符串:消除奇偶长度回文的差异
    s[0] = '$';  // 哨兵字符(避免越界,s[0]永远不会和其他字符匹配)
    s[++k] = '#';  // 第一个分隔符
    // 遍历原始字符串,插入字符+分隔符
    for (int i = 1; i <= n; i++)
    {
        s[++k] = a[i];  // 插入原始字符
        s[++k] = '#';   // 插入分隔符
    }
    n = k;  // 更新n为预处理后字符串的长度
    
    // 计算回文半径数组d
    get_d(s, n);
    
    // 遍历d数组,找到最大的回文半径
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, d[i]);
    
    // 结果转换:预处理后的回文半径-1 = 原字符串的最长回文子串长度
    // 例如:预处理后半径为4(#a#b#a#),对应原字符串abba,长度3(4-1)
    printf("%d\n", ans - 1);
    return 0;
}

2.后缀数组

题目描述

读入一个长度为 nn 的由大小写英文字母或数字组成的字符串,请把这个字符串的所有非空后缀按字典序(用 ASCII 数值比较)从小到大排序,然后按顺序输出后缀的第一个字符在原串中的位置。位置编号为 11 到 nn。

输入格式

一行一个长度为 nn 的仅包含大小写英文字母或数字的字符串。

输出格式

一行,共 nn 个整数,表示答案。

输入输出样例 #1

输入 #1

ababa

输出 #1

5 3 1 4 2

说明/提示

1≤n≤1061\le n \le 10^6。

倍增法构造后缀数组(SA) 并求解height数组,核心功能是在 O (n log n) 时间复杂度内生成字符串的后缀数组,是处理字符串后缀相关问题(如最长重复子串、最长公共前缀等)的基础算法。

2.1 核心概念定义

  • 后缀数组(sa):sa[i] 表示排名为i的后缀的起始位置(字符串下标从 1 开始);

  • 排名数组(rk):rk[i] 表示起始位置为i的后缀的排名;

  • height 数组:height[i] 表示排名为i的后缀与排名为i-1的后缀的最长公共前缀(LCP) 长度。

2.2 算法核心流程

  1. get_sa 函数:采用「倍增法 + 基数排序」构造后缀数组。先按后缀的前 1 个字符排序,再依次按前 2、4、8...2^k 个字符排序(倍增),每次排序通过基数排序保证效率,直到所有后缀排名唯一;

  2. get_height 函数:利用height数组的性质(height[rk[i]] ≥ height[rk[i-1]] - 1),线性时间求解height数组;

  3. main 函数:输入字符串、初始化参数、调用上述函数,最终输出后缀数组。

2.3 基数排序的稳定性

  • 基数排序采用逆序遍历将元素放入 sa 数组,保证相同关键字的元素相对顺序不变(稳定性),这是倍增法正确的关键。

  • 双关键字排序时,先按第二关键字排序,再按第一关键字排序,符合基数排序 “低位优先” 的逻辑。

2.4 倍增法的核心优化

  • 每次排序仅基于前 2^k 个字符,无需显式存储后缀的所有字符,通过 “排名合并” 实现高效排序;

  • 当m == n时,所有后缀的排名唯一,提前退出循环,减少不必要的计算。

2.5 height 数组的核心性质

  • 对于后缀i(起始位置),设rk[i] = r,则height[r] ≥ height[r-1] - 1;

  • 该性质的意义:无需从 0 开始比较公共前缀,而是从height[rk[i-1]] - 1开始,将时间复杂度从 O (n²) 降至 O (n)。

2.6 数组下标从 1 开始的原因

  • 避免后缀起始位置 + k 时出现 0 的情况(如sa[i]+k),简化越界判断(超出 n 的部分视为排名 0,无需额外处理);

  • 基数排序的计数数组c从 1 开始,与字符 ASCII 码(≥'a'=97)匹配,避免混淆。

2.7 变量 m 的作用

  • 初始m=122(小写字母 z 的 ASCII 码),覆盖所有小写字母的初始排名;

  • 倍增过程中m动态更新为当前排名的最大值,若m==n说明所有后缀排名唯一,提前终止。

参考代码

#include <bits/stdc++.h>
using namespace std;

// 数组大小:需覆盖字符串最大长度(题目中字符串长度≤2e6)
const int N = 2000010;
char s[N];          // 存储输入字符串,下标从1开始(避免越界,方便后缀操作)
int n, m;           // n:字符串长度(等价于后缀个数);m:基数排序的桶大小(初始为字符ASCII最大值)
// 核心数组说明:
int x[N];           // 第一关键字数组:x[i]表示起始位置为i的后缀的当前排名(按前2^k个字符)
int y[N];           // 辅助数组:①存储按第二关键字排序后的后缀起始位置 ②临时存储旧的x数组(排名)
int c[N];           // 计数数组:基数排序的桶,统计每个排名/字符的出现次数
int sa[N];          // 后缀数组:sa[i]表示排名为i的后缀的起始位置
int rk[N];          // 排名数组:rk[i]表示起始位置为i的后缀的排名
int height[N];      // height数组:height[i] = LCP(sa[i], sa[i-1])(排名i和i-1的后缀的最长公共前缀)

/*
 * 倍增法构造后缀数组(SA)
 * 核心思想:按后缀的前2^k个字符排序,每次基于前k个字符的排名,通过基数排序合并为前2k个字符的排名
 * 时间复杂度:O(n log n)(基数排序O(n),倍增次数O(log n))
 */
void get_sa()
{
    int i, j, k;
    // 第一步:按后缀的前1个字符(即单个字符)进行基数排序(初始化)
    // 1. 统计每个字符的出现次数(x[i] = s[i],即字符的ASCII值作为初始排名)
    for (i = 1; i <= n; i++) c[x[i] = s[i]]++;
    // 2. 前缀和处理:c[i]表示≤i的字符的总个数,确定每个字符的排名范围
    for (i = 1; i <= m; i++) c[i] += c[i - 1];
    // 3. 逆序遍历:将后缀按字符放入sa数组(保证基数排序的稳定性)
    for (i = n; i; i--) sa[c[x[i]]--] = i;

    // 第二步:倍增过程(k为当前排序的字符长度的一半,2^k)
    for (k = 1; k <= n; k <<= 1) // 每次k翻倍,直到2^k ≥ n
    {
        // 阶段1:按第二关键字排序(后缀的后k个字符的排名)
        memset(c, 0, sizeof(c)); // 清空计数数组
        // y数组暂存当前sa数组(按前k个字符排好的后缀),作为第二关键字排序的候选
        for (i = 1; i <= n; i++) y[i] = sa[i];
        // 统计第二关键字的出现次数:x[y[i]+k]是后缀y[i]的后k个字符的排名(第二关键字)
        // 注:若y[i]+k >n,说明该后缀的后k个字符不存在,排名视为0(c[0]初始为0,无需处理)
        for (i = 1; i <= n; i++) c[x[y[i] + k]]++;
        // 前缀和确定第二关键字的排名范围
        for (i = 1; i <= m; i++) c[i] += c[i - 1];
        // 逆序遍历:按第二关键字将后缀放入sa数组(完成第二关键字排序)
        for (i = n; i; i--) sa[c[x[y[i] + k]]--] = y[i];

        // 阶段2:按第一关键字排序(后缀的前k个字符的排名)
        memset(c, 0, sizeof(c)); // 清空计数数组
        // y数组暂存按第二关键字排好的sa数组,作为第一关键字排序的候选
        for (i = 1; i <= n; i++) y[i] = sa[i];
        // 统计第一关键字的出现次数:x[y[i]]是后缀y[i]的前k个字符的排名(第一关键字)
        for (i = 1; i <= n; i++) c[x[y[i]]]++;
        // 前缀和确定第一关键字的排名范围
        for (i = 1; i <= m; i++) c[i] += c[i - 1];
        // 逆序遍历:按第一关键字将后缀放入sa数组(完成双关键字排序)
        for (i = n; i; i--) sa[c[x[y[i]]]--] = y[i];

        // 阶段3:重新计算排名x数组(合并前k和后k的排名,生成前2k个字符的新排名)
        // y数组暂存旧的x数组(前k个字符的排名),用于比较是否相同
        for (i = 1; i <= n; i++) y[i] = x[i];
        m = 0; // 重置新排名的最大值(从0开始)
        for (i = 1; i <= n; i++)
        {
            // 若两个后缀的前k个字符排名(y[sa[i]])和后k个字符排名(y[sa[i]+k])都相同
            // 则它们的前2k个字符排名相同,否则新排名+1
            if (y[sa[i]] == y[sa[i - 1]] && y[sa[i] + k] == y[sa[i - 1] + k])
                x[sa[i]] = m; // 排名不变
            else
                x[sa[i]] = ++m; // 排名递增
        }
        // 优化:若所有后缀排名唯一(m==n),无需继续倍增
        if (m == n) break;
    }
}

/**
 * 求解height数组
 * 核心性质:height[rk[i]] ≥ height[rk[i-1]] - 1,利用该性质避免重复比较,线性时间求解
 * 时间复杂度:O(n)
 */
void get_height()
{
    int i, j, k;
    // 第一步:由sa数组推导rk数组(sa和rk互为逆运算)
    // sa[i]是排名i的后缀位置 → rk[sa[i]] = i
    for (i = 1; i <= n; i++) rk[sa[i]] = i;

    // 第二步:遍历每个后缀i(起始位置),计算height[rk[i]]
    k = 0; // k表示当前公共前缀的长度,初始为0
    for (i = 1; i <= n; i++)
    {
        // 排名为1的后缀没有前驱,height[1] = 0,直接跳过
        if (rk[i] == 1) continue;
        // 核心优化:height[rk[i]] ≥ height[rk[i-1]] - 1,因此k先减1
        if (k) k--;
        // j是排名比i小1的后缀的起始位置(即sa[rk[i]-1])
        j = sa[rk[i] - 1];
        // 暴力扩展公共前缀:直到字符不相等或超出字符串长度
        while (i + k <= n && j + k <= n && s[i + k] == s[j + k])
            k++;
        // height[rk[i]] 是后缀i和其前驱后缀j的最长公共前缀长度
        height[rk[i]] = k;
    }
}

int main()
{
    // 输入字符串,存储到s[1]开始的位置(方便后缀操作)
    scanf("%s", s + 1);
    // n:字符串长度(后缀个数);m初始化为'z'的ASCII码(122),覆盖所有小写字母
    n = strlen(s + 1);
    m = 122;

    // 构造后缀数组
    get_sa();
    // 求解height数组(本示例未输出height,仅演示构造)
    get_height();

    // 输出后缀数组:sa[1]~sa[n] 依次为排名1~n的后缀起始位置
    for (int i = 1; i <= n; i++)
        printf("%d ", sa[i]);
    return 0;
}

3.后缀自动机

题目描述

给定一个只包含小写字母的字符串 SS。

请你求出 SS 的所有出现次数不为 11 的子串的出现次数乘上该子串长度的最大值。

输入格式

一行一个仅包含小写字母的字符串 SS。

输出格式

一个整数,为所求答案。

输入输出样例 #1

输入 #1

abab

输出 #1

4

说明/提示

对于 10%10 \% 的数据,∣S∣≤1000\lvert S \rvert \le 1000。 对于 100%100\% 的数据,1≤∣S∣≤1061 \le \lvert S \rvert \le {10}^6。

后缀自动机(SAM, Suffix Automaton),核心功能是求解字符串中「出现次数≥2 的子串的(长度 × 出现次数)的最大值」。SAM 是处理字符串子串问题的高效数据结构,时间 / 空间复杂度均为 O (n)(n 为字符串长度)。

3.1 核心概念与流程

  1. SAM 的核心结构:

    • 状态(State):每个状态代表一组「endpos 集合相同」的子串(endpos 是子串在原字符串中结束位置的集合);

    • 转移边(ch):ch[u][c]表示状态 u 接受字符 c 后转移到的状态;

    • 后缀链接(fa):fa[u]指向 u 的后缀状态(对应更短的子串);

    • 长度(len):len[u]是状态 u 对应的最长子串的长度。

  2. extend 函数:SAM 的核心构建函数,逐个添加字符,维护状态、转移边和后缀链接,处理「状态分裂」的特殊情况;

  3. 后缀链接树遍历(dfs):SAM 的后缀链接构成一棵树,通过 DFS 累加每个状态的 endpos 大小(即子串出现次数 cnt),并计算目标最大值;

  4. 结果计算:遍历所有状态,找到cnt[u]>1时cnt[u] * len[u]的最大值(出现次数≥2 的子串,长度 × 次数最大)。

3.2 SAM 的状态与 endpos 集合

  • 每个状态对应一组「endpos 集合相同」的子串:例如状态 u 的 endpos={3,5},表示该状态的所有子串都在原字符串的 3、5 位置结束;

  • len[u]是状态 u 最长子串的长度,len[fa[u]]是其最短子串的长度 - 1(状态 u 的子串长度范围:(len[fa[u]], len[u]]);

  • cnt[u]最终表示 endpos 集合的大小(子串出现次数),初始时只有「新建的状态(np)」cnt=1,其余为 0,需通过后缀链接树 DFS 累加。

3.3 extend 函数的三种核心情况

情况 条件 处理方式
新字符无匹配转移 p 回跳到根(p=0) fa[np] = 1(根节点)
转移状态长度匹配 len[q] = len[p]+1 fa[np] = q
转移状态长度过长 len[q] > len[p]+1 分裂 q 为 nq,调整转移和后缀链接

状态分裂的意义:当len[q] > len[p]+1时,q 的最长子串长度超过了「p 添加字符 c」应有的长度,需分裂出 nq 来匹配该长度,保证 SAM 的「每个状态对应唯一 endpos 集合」的性质。

3.4 后缀链接树的作用

  • SAM 的后缀链接(fa)构成一棵树(根为 1),树中父节点的 endpos 集合是子节点的超集;

  • DFS 遍历该树时,子节点的 cnt 会累加到父节点,最终cnt[u]等于状态 u 的 endpos 集合大小(子串出现次数);

  • 例如:若状态 v 是 u 的子节点,则 v 的子串是 u 的子串的后缀,因此 v 的出现次数需计入 u。

3.5 答案计算逻辑

  • 要求子串出现次数≥2(cnt[u]>1),此时cnt[u] * len[u]表示「长度为 len [u] 的子串出现了 cnt [u] 次」,取最大值即为题目所求;

  • 若所有子串都只出现 1 次,ans 保持初始值 0(符合逻辑)。

参考代码

#include <bits/stdc++.h>
using namespace std;

typedef long long LL;
const int N = 2e6 + 10;  // 状态总数上限(SAM的状态数不超过2n)
char str[N];             // 输入的原始字符串
vector<int> e[N];        // 后缀链接树的邻接表(用于DFS遍历)
LL cnt[N];               // cnt[u]:状态u对应的endpos集合大小(子串出现次数)
LL ans;                  // 最终答案:max(cnt[u] * len[u])(cnt[u]>1)
int tot = 1;             // SAM的状态总数,初始根节点为1
int np = 1;              // 「最后状态」指针,初始指向根节点

// SAM核心数组:
int fa[N];             // fa[u]:状态u的后缀链接(指向父状态)
int ch[N][26];          // ch[u][c]:状态u接受字符c(0-25对应a-z)后转移到的状态
int len[N];            // len[u]:状态u对应的最长子串的长度

/**
 * SAM的核心扩展函数:添加字符c(0-25),构建SAM的状态和边
 * c 待添加的字符(已转换为0-25的整数)
 * 核心逻辑:
 * 1. 新建状态np,初始化其长度和出现次数;
 * 2. 从当前最后状态p回跳,为p添加指向np的转移边(若不存在);
 * 3. 处理三种情况:
 *    - p回跳到根(p=0):np的后缀链接指向根;
 *    - 转移状态q的长度符合预期:np的后缀链接指向q;
 *    - 转移状态q的长度不符合:分裂q为nq,调整转移和后缀链接。
 */
void extend(int c)
{
    // p:回跳指针,初始指向「添加字符前的最后状态」np(旧值);np:新建的状态
    int p = np;
    np = ++tot;          // 新建状态,状态总数+1
    len[np] = len[p] + 1;// 新状态的最长子串长度 = 原最后状态长度 + 1(新增一个字符)
    cnt[np] = 1;         // 新状态初始出现次数为1(对应当前字符的结束位置)

    // 阶段1:从p沿后缀链接回跳,为所有无c转移的状态添加指向np的转移边
    for (; p && !ch[p][c]; p = fa[p])
        ch[p][c] = np;

    // 阶段2:处理后缀链接
    if (p == 0)          // 情况1:p回跳到根(无匹配的转移),np的后缀链接指向根(1)
        fa[np] = 1;
    else                 // 情况2:p找到有c转移的状态,q是该转移的目标状态
    {
        int q = ch[p][c];// q:p通过c转移到达的状态
        if (len[q] == len[p] + 1)  // 子情况2.1:q的长度恰好是p的长度+1(符合预期)
            fa[np] = q;           // np的后缀链接直接指向q
        else                      // 子情况2.2:q的长度过长,需要分裂q为新状态nq
        {
            int nq = ++tot;       // 新建分裂状态nq
            len[nq] = len[p] + 1; // nq的最长子串长度 = p的长度+1(匹配当前转移的长度)
            
            // 步骤1:调整后缀链接
            fa[nq] = fa[q];       // nq的后缀链接继承q的后缀链接
            fa[q] = nq;           // q的后缀链接指向nq(q成为nq的子节点)
            fa[np] = nq;          // np的后缀链接指向nq(替代原来的q)
            
            // 步骤2:修正转移边:所有从p回跳路径上指向q的c转移,改为指向nq
            for (; p && ch[p][c] == q; p = fa[p])
                ch[p][c] = nq;
            
            // 步骤3:复制q的转移边到nq(nq和q共享除长度外的转移)
            memcpy(ch[nq], ch[q], sizeof(ch[q]));
        }
    }
}

/**
 * 遍历后缀链接树,累加每个状态的endpos大小(cnt),并计算答案
 * u 当前遍历的状态(从根节点1开始)
 * 核心逻辑:
 * 1. 递归遍历u的所有子节点(后缀链接树的子节点);
 * 2. 累加子节点的cnt到u的cnt(endpos集合大小是子节点的并集);
 * 3. 若u的出现次数>1,更新答案为max(ans, cnt[u]*len[u])。
 */
void dfs(int u)
{
    // 遍历u的所有子节点(后缀链接树中,fa[v]=u则v是u的子节点)
    for (auto v : e[u])
    {
        dfs(v);                // 递归处理子节点
        cnt[u] += cnt[v];      // 累加子节点的出现次数到当前节点
    }
    // 若当前状态的子串出现次数≥2,计算长度×次数,更新最大值
    if (cnt[u] > 1)
        ans = max(ans, cnt[u] * len[u]);
}

int main()
{
    // 输入字符串(存储到str数组)
    scanf("%s", str);
    // 逐个字符扩展SAM:将字符转换为0-25(a-z),调用extend
    for (int i = 0; str[i]; i++)
        extend(str[i] - 'a');
    
    // 构建后缀链接树的邻接表:fa[i]是i的父节点,将i加入fa[i]的子节点列表
    // 状态从2开始(根节点1无父节点)
    for (int i = 2; i <= tot; i++)
        e[fa[i]].push_back(i);
    
    // 从根节点1开始DFS,统计cnt并计算答案
    dfs(1);
    
    // 输出最终答案
    printf("%lld\n", ans);
    return 0;
}