「Wallace 笔记」快速上手回文自动机(PAM)

Content

  • 简述
  • 概念
  • 结构
  • 构建
  • 实现
  • 正确性
  • 后记

简述

回文树(EER Tree)是可以存储一个字符串的 所有回文字串 的一种实用数据结构。

回文自动机(PAM)是存储了这个字符串的所有回文串信息的自动机。

事实上这两个是一个东西,是一个高效处理回文相关问题的利器

概念

回文自动机是一类可以接受字符串的所有回文子串的自动机。

结构

1.结构特点

我们知道回文串大多分奇偶讨论

有一种被称为 manacher 的算法,被广泛用于解决回文问题。它是通过在字符间插入不属于字符集的分隔符(如 #$ 等)处理的。

但 PAM 一般不这么干——我们一般 直接按奇偶分为两个部分

那么就会有两个根节点:奇根和偶根。

OI-wiki 上的演示图片:

2. 状态

既然可以被称为“自动机”,那么一定有状态。

PAM 的一个状态表示 原串的一个回文字串

状态中除了转移函数的值,还存储了最基础的两个字段:faillen。分别表示 后缀链接,以及 当前状态的结点所代表的最长回文字串的长

后缀链接具体见下文。

奇根和偶根并不代表任何串,仅作为初始状态存在。

特别的,我们令奇根的 ( ext{len} = -1),偶根的 ( ext{len} = 0)

所有的状态都代表一个本质不同的回文子串,PAM 中又存储了所有回文子串的信息,那么 PAM 的状态数减一(除去奇根)等于原串本质不同的回文子串个数。

3. 转移

PAM 的转移函数比较特殊。

如果结点 (x) 走了一条字符 (c) 的转移,那么表示在 (x)当前回文子串前后各加一个字符 (c)

这个并不难理解——因为必须满足回文性质。

4. fail 指针

即后缀链接。

对于一个状态 (x),其 ( ext{fail}) 指针所指的状态 (y),代表的最长回文串满足,后者为前者的最长回文真后缀

特别的,偶根的 $ ext{fail} = $ 奇根,至于奇根的 ( ext{fail}) 我们不需要管,因为长度为 1 的字串必定为回文串,不可能失配。

建出 PAM 后,fail 指针将结点连成了一颗树形结构—— fail 树。

构建

和 SAM 一样,我们用 增量法 构造 PAM,即在线算法。

我们假设对 (S) 构造 PAM,当前位置为 (p)。现在将 (S_p) 插入 PAM。

首先找到上一次插入 (S_{p - 1}) 的结点 (x),判断是否满足 (S_{p} = S_{p - ext{len}(x) - 1}),即待添加的字符与当前回文子串 的前一个字符 是否相同。

若相同那么直接找转移边,否则就需要跳 fail 指针。一直跳,直至条件 (S_{p} = S_{p - ext{len}(x) - 1}) 被满足。(跳到最后可能会跳到根,但此时条件成了 (S_{p} = S_{p}),而这显然成立不可能继续回跳,因此不需要过于在意边界处理)

于是现在我们将 (x) 跳转到了正确的位置,可以进行插入了!

如果发现转移 (S_p) 字符的转移已存在,那么就基本完成了,反之则需新建结点(以下称 (y))。

上文提到 PAM 的结点中至少维护两个字段——lenfail

前者不难得到,回顾转移的定义,是在首位两端加入字符,于是有 ( ext{len}(y) = ext{len}(x) + 2)

但如何求 ( ext{fail})?我们从 ( ext{fail}(x)) 开始跳 ( ext{fail}) 指针,从 (x) 代表的最长回文后缀起步,直至满足前后匹配然后取转移即可。顺带一提,这里的跳 ( ext{fail}) 与上面的其实非常相似。

如果没得匹配,和上面一样会得到 ( ext{fail}(y) = 0) 的结果,不过不会有任何不妥——空串为任何串的后缀。

以上,我们完成了一个字符的插入。

附上图示:

UztmWR.png

实现

1. 基础设置

struct Node { // 结点(状态)结构体
    int ch[S], fail; // 转移,失配指针
    int len; // 当前状态可以表示的最长回文子串的长
} t[N];
int total, last;
// 总点数,上一次更新的点的标号
void init() { // 初始化
    t[0].fail = 1, t[1].fail = 0;
    t[0].len = 0, t[1].len = -1;
    total = 1, last = 0;
}
int create(int l) { // 创建 len = l 的新结点
    int x = ++total;
    t[x].len = l, t[x].fail = 0;
    return x;
}

2. 跳转 fail 函数

// 跳转 fail : 从结点 x 起,跳转以配对 s[p]
int getFail(int x, int p) {
    while (s[p] != s[p - t[x].len - 1])
        x = t[x].fail; // 不匹配则一直跳
    return x;
}

3. 构造算法主体 extend

// 增量构造 : 添加字符 s[p](主串为 s[1, n])
void extend(int p) {
    int x = getFail(last, p); // 从上一次添加的位置起,跳 fail 到可添加的位置
    if (t[x].ch[s[p] - 'a'] == 0) { // 跳完了,如果没有转移,那么新建
        int y = create(t[x].len + 2); // 新建结点 y : 注意 len 是前面与后面各一个字符,因此 +2
        t[y].fail = t[getFail(t[x].fail, p)].ch[s[p] - 'a']; // 得到 y 的 fail 指针,取转移
        t[x].ch[s[p] - 'a'] = y; // y 为 x 的某一个转移的值,更新转移函数值
    }
    last = t[x].ch[s[p] - 'a']; // 更新 last
}

正确性

进行了我们将对 PAM 的构建算法进行正确性(复杂度)证明。

命题 1. 一个字符串 (S) 的本质不同的回文子串个数不超过 (|S|) 个。

证明: 数学归纳法。(部分参考自 OI-wiki)

  • (|S| = 1) 时,显然。
  • (|S| ge 2) 时,假设 (S) 尾部加上某一个字符 (c)(T),并且 (S) 满足此命题。
    • 我们设 (T) 的所有回文真后缀为 (T[l_1, |T|], T[l_2, |T|], cdots , T[l_k, |T|]),其中 (l_i < l_{i + 1})。因为 (T[l_1, |T|]) 是回文串,于是所有 (in [l_1, |T|]) 的位置 (p) 满足 (T[p, |T|] = T[l_1, l_1 + |T| - p])
    • 那么对于任意一个 (i in (1, k]),由于 (T[l_i, |T|])(T[l_1, |T|]) 的一个子串,于是 (T[l_i, |T|]) 的反串 一定在 (T[1, |T| - 1]) 中出现过。然而 (T[l_i, |T|]) 也是回文的,正串就是反串,所以得出结论:只有 (T[l_1, |T|]) 可能被作为新的本质不同的回文子串
    • 一个字符最多新增一个本质不同的回文子串,所以该命题得证。

命题 2. PAM 上的状态数为 (O(|S|))

证明: 因为 PAM 上的状态数等于本质不同的回文子串个数,结合命题 1 即可得证。

或者说,由 PAM 构造算法,可得一次 extend 至多增加一个结点。那么 (|S|) 次添加显然有 (O(|S|)) 个结点。

命题 3. PAM 构造算法的时间复杂度为 (O(|S|))

证明: 观察上述构造算法,发现仅有 getFail 函数的复杂度难以证明。跳 fail 指针,连 fail 可以视作在 fail 树上跳祖先,建边。

fail 树上,每次跳 fail 时深度 (-1),每次建 fail 边时深度 (+1)

而在建 fail 指针时,我们发现是从 ( ext{fail}(x)) 开始跳的,因此深度 (-1),而最终深度只会 (+1)。于是整棵 fail 树深度 (le |S|)

那么易知跳的次数不超过 (O(|S|)),于是构建算法的时间复杂度也为 (O(|S|))

后记

原文地址:https://www.cnblogs.com/-Wallace-/p/13375265.html