Skip to content

Palindrome Mouse - 题解

标签与难度

标签: 回文自动机, PAM, Fail树, 树状数组, DFS序, 字符串, 计数 难度: 2500

题目大意喵~

一位博士拿到了一个只包含小写字母的字符串 s。他找到了 s 的所有不同回文子串,构成了集合 S。现在,他想从 S 中选择两个不同的字符串 ab,满足 ab 的子串。请问总共有多少对这样的 (a, b) 呢,喵?

举个栗子,如果 s = "aba",那么不同的回文子串集合 S 就是 {"a", "b", "aba"}。 满足条件的 (a, b) 对有:

  • a = "a", b = "aba"("a" 是 "aba" 的子串)
  • a = "b", b = "aba"("b" 是 "aba" 的子串) 总共有 2 对。

解题思路分析

这道题要求我们统计所有回文子串中,一个作为另一个子串的有序对 (a, b) 的数量。一看到“所有回文子串”这种字眼,聪明的我我呀,第一反应就是使用回文自动机(PAM) 这个强大的工具来解决问题,喵~

为什么是回文自动机 (PAM)?

PAM 能在 O(N)O(N) 的时间内构建一个数据结构,其中每个节点都唯一对应一个回文子串。它主要包含两种结构:

  1. Trie 结构(儿子树)ch[u][c] 指向在节点 u 代表的回文串两边同时加上字符 c 形成的新回文串。这棵树(森林)体现了回文串的包含关系。如果 vu 在儿子树上的后代,那么 u 一定是 v中心子串
  2. Fail 指针(Fail 树)fail[u] 指向节点 u 代表的回文串的最长回文后缀。所有 fail 指针构成了一棵树(森林),它揭示了回文串之间的后缀关系。

转化问题

题目要求统计满足 ab 的子串的 (a, b) 对数。我们可以换个角度,对每个回文串 b,计算它有多少个不同的回文子串 aa \neq b),然后把所有 b 的结果加起来。

总答案 = bS(b 的不同回文子串数量1)\sum_{b \in S} (\text{b 的不同回文子串数量} - 1)

所以,核心问题就变成了:对于 PAM 中的每一个节点 v(代表回文串 P_v),如何计算 P_v 有多少个不同的回文子串?

关键性质与递推

这道题有一个非常关键(但不太直观)的性质,喵~

一个回文串 P_u 的所有回文子串集合,等于其最长回文后缀 P_{fail(u)} 的所有回文子串集合,并上一些只属于 P_u 的新回文子串。

那么,这些“新”的回文子串是哪些呢?它们是那些在 P_u 中出现,但在 P_{fail(u)} 中没有出现过的回文串。 这些新出现的串,一定包含 P_u 的首尾两个字符。由于它们本身也是回文串,所以它们的形式必然是 c...c,其中 cP_u 的首尾字符。

在 PAM 中,所有以 c 开头和结尾的回文串,都可以在儿子树上从代表 "c" 的节点开始,沿着一条路径找到。 所以,对于节点 u,它的新回文子串就是从 u 开始,在儿子树上一直走到根的这条路径上的所有节点所代表的回文串。

Count(u) 为节点 u 代表的回文串的子串中,不同回文串的数量。 我们可以得到一个递推关系: Count(u) = Count(fail(u)) + (u 在儿子树上的深度)

这个递推看起来很美好,但它其实是错误的!因为 Count(fail(u))u 在儿子树上的祖先集合可能有交集,直接相加会重复计算。

正确的解法:分组计算贡献

直接对每个回文串计算其子串数量再求和,会遇到复杂的去重问题。我们不妨再次转换思路,不要孤立地看每个回文串,而是将它们分组处理。

观察参考代码可以发现一个非常巧妙的思路:fail 指针的指向进行分组。我们遍历 PAM 中的每个节点 u(从2号节点开始,代表非空回文串),然后考虑所有以 u 为最长回文后缀的那些回文串。 也就是说,我们建立一个集合 V_u = {u} \cup \{v \mid fail[v] = u\}

V_u 这个集合里的所有回文串 p 有一个共同的性质:它们的最长回文后缀,要么是 u,要么是 u 的前缀(如果 p=u)。

现在,我们考虑 V_u 中的这些回文串,以及它们在儿子树上的所有后代。 令 C_u = \bigcup_{p \in V_u} \text{son_subtree}(p),其中 son_subtree(p) 表示 p 在儿子树中的整个子树(包括 p 自身)。 这个集合 C_u 中的任意两个回文串 xy,如果 xy 在儿子树上的祖先,那么 x 就是 y 的中心子串。

算法的神奇之处在于,它通过一种特殊的方式把所有 (a, b) 对的计数任务,不重不漏地分配到每个 u 的计算中。对每个 u,它计算了一个值并累加到总答案里。这个值是 |C_u| - 1

为什么是 |C_u| - 1 呢?这暗示着,对于每个 u,我们都确定了一个“基准”回文串(很可能就是 u 自身),然后统计了 C_u 中所有其他回文串与这个基准串形成的 (基准, 其他) 或者 (其他, 基准) 的配对。

算法流程

  1. 构建 PAM: 遍历输入字符串 s,构建回文自动机。
  2. 预处理:
    • 构建儿子树的邻接表。
    • 构建Fail 树的邻接表(即 fail_children[fail[v]] 存入 v)。
    • 儿子树进行一次 DFS,计算出每个节点的子树大小 subtree_size,以及它的 DFS 序区间 [dfs_in, dfs_out]
  3. 主循环与计数:
    • 初始化总答案 ans = 0 和一个树状数组 BIT
    • 遍历每个 PAM 节点 u (从 2 到 tot)。
    • 对于当前的 u: a. 获取集合 V_u = \{u\} \cup \{v \mid fail[v] = u\}。 b. 为了处理 son_subtree 的并集,我们将 V_u 中的节点按长度排序。 c. 遍历 V_u 中的每个节点 p。我们用树状数组来判断 p 是否已经被包含在之前处理过的某个 q \in V_u 的儿子子树中了。 d. BIT.query(dfs_in[p]) 可以查询 p 在儿子树上的所有祖先是否被“标记”。如果查询结果为0,说明 pV_u 中一个新的“根”(它不属于 V_u 中任何其他节点的儿子子树)。 e. 如果 p 是一个新的根,我们就将它的整个儿子子树的贡献 subtree_size[p] 加入到当前 u 的临时计数 current_count 中,并在树状数组上标记 p 的子树区间 [dfs_in[p], dfs_out[p]]。 f. 处理完 V_u 中所有节点后,我们将 current_count - 1 加到总答案 ans 中。 g. 清理:为了下一次循环,需要将本次在树状数组上做的修改全部撤销。
  4. 输出最终的 ans

这个算法通过 DFS 序和树状数组,巧妙地计算了 \bigcup_{p \in V_u} \text{son_subtree}(p) 的大小,避免了复杂的集合运算。每次 ans += |C_u| - 1,最终累加起来就是我们想要的答案,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码,变量名和注释都尽量清晰了哦,希望能帮到你,喵~

cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

using namespace std;

const int MAXN = 100005;

// === 树状数组模板 ===
struct FenwickTree {
    vector<int> bit;
    int size;

    FenwickTree(int n) : size(n + 1), bit(n + 1, 0) {}

    void update(int index, int delta) {
        for (; index < size; index += index & -index) {
            bit[index] += delta;
        }
    }

    int query(int index) {
        int sum = 0;
        for (; index > 0; index -= index & -index) {
            sum += bit[index];
        }
        return sum;
    }
    
    void clear() {
        fill(bit.begin(), bit.end(), 0);
    }
};

// === 回文自动机模板 ===
struct PalindromeAutomaton {
    int str[MAXN];
    int ch[MAXN][26];
    int fail[MAXN];
    int len[MAXN];
    int node_count;
    int last_node;
    int str_len;

    void init() {
        // 奇根为1,偶根为0
        fail[0] = 1;
        len[1] = -1;
        node_count = 1;
        last_node = 0;
        str_len = 0;
        // C++11中,未初始化的全局/静态变量默认为0,但显式清空更安全
        for(int i = 0; i < MAXN; ++i) {
            for(int j = 0; j < 26; ++j) ch[i][j] = 0;
        }
    }

    int get_fail(int u, int i) {
        while (str[i - len[u] - 1] != str[i]) {
            u = fail[u];
        }
        return u;
    }

    void insert(int c) {
        str[++str_len] = c;
        int p = get_fail(last_node, str_len);
        if (!ch[p][c]) {
            int q = ++node_count;
            len[q] = len[p] + 2;
            fail[q] = ch[get_fail(fail[p], str_len)][c];
            ch[p][c] = q;
        }
        last_node = ch[p][c];
    }
};

// 全局变量,方便DFS等函数访问
PalindromeAutomaton pam;
vector<int> son_tree[MAXN];
vector<int> fail_children[MAXN];
int dfs_in[MAXN], dfs_out[MAXN], subtree_size[MAXN];
int timer;

void build_son_tree() {
    for (int i = 0; i <= pam.node_count; ++i) {
        son_tree[i].clear();
    }
    // 从1开始,因为0和1是根,0是偶根,1是奇根
    // 儿子关系只在实际回文串节点间有意义
    for (int i = 1; i <= pam.node_count; ++i) {
        for (int j = 0; j < 26; ++j) {
            if (pam.ch[i][j]) {
                son_tree[i].push_back(pam.ch[i][j]);
            }
        }
    }
}

void build_fail_children() {
    for (int i = 0; i <= pam.node_count; ++i) {
        fail_children[i].clear();
    }
    // 从2号节点开始,它们是第一个实际的回文串
    for (int i = 2; i <= pam.node_count; ++i) {
        fail_children[pam.fail[i]].push_back(i);
    }
}

void dfs_son_tree(int u) {
    dfs_in[u] = ++timer;
    subtree_size[u] = 1;
    for (int v : son_tree[u]) {
        dfs_son_tree(v);
        subtree_size[u] += subtree_size[v];
    }
    dfs_out[u] = timer;
}

void solve(int case_num) {
    string s;
    cin >> s;

    pam.init();
    for (char c : s) {
        pam.insert(c - 'a');
    }

    build_son_tree();
    build_fail_children();

    timer = 0;
    // 对儿子树进行DFS,处理奇根和偶根两棵树
    dfs_son_tree(1); 
    dfs_son_tree(0);

    long long total_pairs = 0;
    FenwickTree bit(pam.node_count + 2);
    vector<int> activated_nodes;

    for (int u = 2; u <= pam.node_count; ++u) {
        vector<int> group = fail_children[u];
        group.push_back(u);

        sort(group.begin(), group.end(), [&](int a, int b) {
            return pam.len[a] < pam.len[b];
        });

        long long current_group_palindrome_count = 0;
        activated_nodes.clear();

        for (int p : group) {
            // 查询p的祖先是否已被激活
            if (bit.query(dfs_in[p]) == 0) {
                current_group_palindrome_count += subtree_size[p];
                // 标记p的子树区间
                bit.update(dfs_in[p], 1);
                bit.update(dfs_out[p] + 1, -1);
                activated_nodes.push_back(p);
            }
        }
        
        if (current_group_palindrome_count > 0) {
            total_pairs += current_group_palindrome_count - 1;
        }

        // 清理本次循环在树状数组上的修改
        for (int p : activated_nodes) {
            bit.update(dfs_in[p], -1);
            bit.update(dfs_out[p] + 1, 1);
        }
    }

    cout << "Case #" << case_num << ": " << total_pairs << endl;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    int t;
    cin >> t;
    for (int i = 1; i <= t; ++i) {
        solve(i);
    }
    return 0;
}

复杂度分析

  • 时间复杂度: O(TNlogN)O(T \cdot N \log N)

    • 对于每组测试数据,构建PAM的时间是 O(N)O(N),其中 NN 是字符串长度。
    • 对儿子树进行DFS是 O(N)O(N)
    • 主循环遍历所有PAM节点 u。对于每个 u,我们处理一个集合 V_u。所有 V_u 的大小之和是 O(N)O(N)。在循环内部,排序和树状数组查询/更新操作的复杂度是 O(logN)O(\log N)。因此,这部分的总时间复杂度是 O(NlogN)O(N \log N)
    • 综上,总时间复杂度为 O(NlogN)O(N \log N)
  • 空间复杂度: O(N)O(N)

    • PAM本身需要 O(NΣ)O(N \cdot |\Sigma|) 的空间,但由于转移是稀疏的,实际实现中节点数和边数都是 O(N)O(N) 级别。
    • 儿子树、Fail孩子们的邻接表、DFS序相关数组和树状数组都需要 O(N)O(N) 的空间。

知识点总结

这道题是一道非常好的PAM综合应用题,将字符串算法和数据结构巧妙地结合了起来,喵~

  1. 回文自动机 (PAM): 解决回文串问题的利器,能够高效地表示出所有不同回文子串及其结构关系。
  2. Fail 树与儿子树: 理解PAM的两种核心结构是解题的关键。Fail树体现后缀关系,儿子树体现中心包含关系。
  3. DFS序 + 树状数组: 这是处理树上子树问题的经典技巧。将子树查询转化为区间查询,可以高效地维护和查询子树信息。在这里,它被用来计算一系列子树的并集大小。
  4. 分组贡献/离线思想: 当直接计算每个元素的贡献很困难时,可以尝试将元素分组,然后计算每组的整体贡献,或者将所有查询/操作离线下来统一处理。本题按fail指针分组就是一个绝妙的例子。

希望这篇题解能帮助你更好地理解这个问题,如果还有不明白的地方,随时可以再来问我哦,喵~!