题目描述

通常两个押韵的单词末尾字符序列相同。我们利用这一特性定义了"反押韵"的概念。反押韵是指一对单词具有相似的开头。反押韵的程度定义为最长字符串 SSS 的长度,使得两个字符串都以 SSS 开头。

例如:

  • arborealarcturus 是一个程度为 222 的反押韵对
  • chalkboardoverboard 是一个程度为 000 的反押韵对

给定一个单词列表和若干查询 (i,j)(i, j)(i,j),要求输出第 iii 个和第 jjj 个单词的反押韵程度。

输入格式

  • 第一行:测试用例数 TTT (T≤35T \leq 35T35)
  • 每个测试用例:
    • 第一行:字符串数量 NNN (1≤N≤1051 \leq N \leq 10^51N105)
    • 接下来 NNN 行:每个字符串(仅小写字母,长度 L≤104L \leq 10^4L104
    • 下一行:查询数 QQQ (1≤Q≤1061 \leq Q \leq 10^61Q106)
    • 接下来 QQQ 行:每行两个整数 iiijjj (1≤i,j≤N1 \leq i, j \leq N1i,jN)

重要约束N×L≤106N \times L \leq 10^6N×L106

输出格式

对于每个测试用例:

  • 第一行:Case X:XXX 为用例编号)
  • 接下来 QQQ 行:每个查询的答案

题目分析

问题本质

我们需要高效地解决多个查询:对于任意两个字符串,找出它们的最长公共前缀LCP\texttt{LCP}LCP)的长度。

约束条件分析

  • NNN 最多 10510^5105QQQ 最多 10610^6106LLL 最多 10410^4104
  • 关键约束:N×L≤106N \times L \leq 10^6N×L106(总字符数有限)
  • 如果对每个查询都直接比较两个字符串,最坏情况时间复杂度为 O(Q×L)O(Q \times L)O(Q×L),可能达到 101010^{10}1010 次操作,显然不可行

核心挑战

如何在预处理后,用接近 O(1)O(1)O(1) 的时间回答每个查询?

解题思路

关键观察

两个字符串的最长公共前缀长度等于它们在 Trie\texttt{Trie}Trie 中对应节点的最近公共祖先LCA\texttt{LCA}LCA)的深度。

算法设计

  1. 构建 Trie\texttt{Trie}Trie

    • 将所有字符串插入到 Trie 中
    • 每个节点记录深度和父节点信息
    • 记录每个字符串在 Trie 中的结束节点
  2. LCA\texttt{LCA}LCA 预处理

    • 使用 DFS\texttt{DFS}DFS 生成 Trie\texttt{Trie}Trie 的欧拉序
    • 记录每个节点的首次出现位置
    • 使用 RMQ\texttt{RMQ}RMQ(范围最小值查询)技术预处理欧拉序,支持 O(1)O(1)O(1)LCA\texttt{LCA}LCA 查询
  3. 查询处理

    • 对于查询 (i,j)(i, j)(i,j),找到两个字符串在 Trie\texttt{Trie}Trie 中的结束节点
    • 查询这两个节点的 LCA\texttt{LCA}LCA
    • LCA\texttt{LCA}LCA 的深度即为最长公共前缀长度

复杂度分析

  • 构建 TrieO(总字符数)=O(106)O(\text{总字符数}) = O(10^6)O(总字符数)=O(106)
  • DFS 欧拉序O(Trie 节点数)O(\text{Trie 节点数})O(Trie 节点数)
  • RMQ 预处理O(nlog⁡n)O(n \log n)O(nlogn),其中 nnn 为欧拉序列长度
  • 每个查询O(1)O(1)O(1)

总复杂度:O(总字符数+Q)O(\text{总字符数} + Q)O(总字符数+Q),在约束范围内可行。

算法细节

Trie\texttt{Trie}Trie 树结构

struct TrieNode {
    int depth;        // 节点深度(根节点深度为0)
    int parent;       // 父节点索引
    int children[26]; // 子节点指针
};

LCA\texttt{LCA}LCARMQ\texttt{RMQ}RMQ

  • 欧拉序DFS\texttt{DFS}DFS 遍历 Trie\texttt{Trie}Trie 时记录访问的节点序列
  • 首次出现位置:记录每个节点在欧拉序中第一次出现的位置
  • RMQ\texttt{RMQ}RMQ:在欧拉序上,两个节点的 LCA\texttt{LCA}LCA 就是它们首次出现位置之间深度最小的节点

查询过程

对于字符串 iiijjj

  1. 获取它们在 Trie\texttt{Trie}Trie 中的结束节点 uuuvvv
  2. 查询 uuuvvvLCA\texttt{LCA}LCA 节点 lll
  3. 输出 depth[l]depth[l]depth[l] 作为答案

代码实现

// Anti-Rhyme Pairs
// UVa ID: 12338
// Verdict: Accepted
// Submission Date: 2025-11-08
// UVa Run Time: 0.160s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cmath>
#include <cstring>

using namespace std;

const int MAXN = 100005;
const int MAXL = 10005;
const int MAX_NODES = 1000005; // 因为 N * L <= 1e6

struct TrieNode {
    int depth;
    int parent;
    int children[26];
    TrieNode() : depth(0), parent(-1) {
        memset(children, -1, sizeof(children));
    }
};

vector<TrieNode> trie;
int wordEnd[MAXN]; // 记录每个单词在 Trie 中的结束节点

int addNode(int parent) {
    int idx = trie.size();
    trie.emplace_back();
    trie[idx].parent = parent;
    trie[idx].depth = (parent == -1) ? 0 : trie[parent].depth + 1;
    return idx;
}

void insert(const string& s, int wordIndex) {
    int node = 0; // root
    for (char ch : s) {
        int c = ch - 'a';
        if (trie[node].children[c] == -1) {
            int newNode = addNode(node);
            trie[node].children[c] = newNode;
        }
        node = trie[node].children[c];
    }
    wordEnd[wordIndex] = node;
}

// LCA 相关
vector<int> euler;
int firstOccurrence[MAX_NODES];
int depth[MAX_NODES];
int eulerIndex;

void dfs(int u) {
    firstOccurrence[u] = eulerIndex;
    euler.push_back(u);
    eulerIndex++;
    for (int i = 0; i < 26; i++) {
        int v = trie[u].children[i];
        if (v != -1) {
            depth[v] = depth[u] + 1;
            dfs(v);
            euler.push_back(u);
            eulerIndex++;
        }
    }
}

vector<vector<int>> st;
vector<int> logTable;

void buildRMQ() {
    int n = euler.size();
    logTable.resize(n + 1);
    logTable[1] = 0;
    for (int i = 2; i <= n; i++) {
        logTable[i] = logTable[i / 2] + 1;
    }
    int k = logTable[n] + 1;
    st.assign(n, vector<int>(k));
    for (int i = 0; i < n; i++) {
        st[i][0] = euler[i];
    }
    for (int j = 1; j < k; j++) {
        for (int i = 0; i + (1 << j) <= n; i++) {
            int left = st[i][j - 1];
            int right = st[i + (1 << (j - 1))][j - 1];
            st[i][j] = (depth[left] < depth[right]) ? left : right;
        }
    }
}

int queryLCA(int u, int v) {
    int l = firstOccurrence[u];
    int r = firstOccurrence[v];
    if (l > r) swap(l, r);
    int j = logTable[r - l + 1];
    int leftNode = st[l][j];
    int rightNode = st[r - (1 << j) + 1][j];
    return (depth[leftNode] < depth[rightNode]) ? leftNode : rightNode;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int T;
    cin >> T;
    for (int caseNum = 1; caseNum <= T; caseNum++) {
        int n;
        cin >> n;
        trie.clear();
        addNode(-1); // root

        for (int i = 0; i < n; i++) {
            string s;
            cin >> s;
            insert(s, i);
        }

        // 准备 LCA
        euler.clear();
        eulerIndex = 0;
        depth[0] = 0;
        dfs(0);
        buildRMQ();

        int q;
        cin >> q;
        cout << "Case " << caseNum << ":\n";
        while (q--) {
            int i, j;
            cin >> i >> j;
            i--; j--;
            int u = wordEnd[i];
            int v = wordEnd[j];
            int lca = queryLCA(u, v);
            cout << trie[lca].depth << "\n";
        }
    }

    return 0;
}

总结

本题通过将最长公共前缀问题转化为 Trie\texttt{Trie}Trie 树上的 LCA\texttt{LCA}LCA 问题,利用欧拉序和 RMQ\texttt{RMQ}RMQ 技术实现了 O(1)O(1)O(1) 的查询响应。这种"问题转化+数据结构优化"的思路在字符串处理问题中非常常见,值得学习和掌握。

关键技巧:

  • Trie\texttt{Trie}Trie 树用于处理前缀相关问题
  • 欧拉序 + RMQ\texttt{RMQ}RMQ 用于高效 LCA\texttt{LCA}LCA 查询
  • 充分利用题目约束条件进行优化
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐