UVa 12338 Anti-Rhyme Pairs
本文提出了一种基于Trie树和最近公共祖先(LCA)的高效算法,用于解决大规模反押韵对查询问题。算法首先将所有字符串构建为Trie树,记录每个单词的结束节点;然后通过DFS生成欧拉序,预处理RMQ数据结构以支持O(1)的LCA查询;最后对于每个查询(i,j),在Trie树中找到对应节点的LCA,其深度即为最长公共前缀长度。该算法在预处理阶段时间O(N×L),查询阶段O(Q),能高效处理最多10^5
题目描述
通常两个押韵的单词末尾字符序列相同。我们利用这一特性定义了"反押韵"的概念。反押韵是指一对单词具有相似的开头。反押韵的程度定义为最长字符串 SSS 的长度,使得两个字符串都以 SSS 开头。
例如:
arboreal和arcturus是一个程度为 222 的反押韵对chalkboard和overboard是一个程度为 000 的反押韵对
给定一个单词列表和若干查询 (i,j)(i, j)(i,j),要求输出第 iii 个和第 jjj 个单词的反押韵程度。
输入格式
- 第一行:测试用例数 TTT (T≤35T \leq 35T≤35)
- 每个测试用例:
- 第一行:字符串数量 NNN (1≤N≤1051 \leq N \leq 10^51≤N≤105)
- 接下来 NNN 行:每个字符串(仅小写字母,长度 L≤104L \leq 10^4L≤104)
- 下一行:查询数 QQQ (1≤Q≤1061 \leq Q \leq 10^61≤Q≤106)
- 接下来 QQQ 行:每行两个整数 iii 和 jjj (1≤i,j≤N1 \leq i, j \leq N1≤i,j≤N)
重要约束:N×L≤106N \times L \leq 10^6N×L≤106
输出格式
对于每个测试用例:
- 第一行:
Case X:(XXX 为用例编号) - 接下来 QQQ 行:每个查询的答案
题目分析
问题本质
我们需要高效地解决多个查询:对于任意两个字符串,找出它们的最长公共前缀(LCP\texttt{LCP}LCP)的长度。
约束条件分析
- NNN 最多 10510^5105,QQQ 最多 10610^6106,LLL 最多 10410^4104
- 关键约束:N×L≤106N \times L \leq 10^6N×L≤106(总字符数有限)
- 如果对每个查询都直接比较两个字符串,最坏情况时间复杂度为 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)的深度。
算法设计
-
构建 Trie\texttt{Trie}Trie 树
- 将所有字符串插入到 Trie 中
- 每个节点记录深度和父节点信息
- 记录每个字符串在 Trie 中的结束节点
-
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 查询
-
查询处理
- 对于查询 (i,j)(i, j)(i,j),找到两个字符串在 Trie\texttt{Trie}Trie 中的结束节点
- 查询这两个节点的 LCA\texttt{LCA}LCA
- LCA\texttt{LCA}LCA 的深度即为最长公共前缀长度
复杂度分析
- 构建 Trie:O(总字符数)=O(106)O(\text{总字符数}) = O(10^6)O(总字符数)=O(106)
- DFS 欧拉序:O(Trie 节点数)O(\text{Trie 节点数})O(Trie 节点数)
- RMQ 预处理:O(nlogn)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}LCA 与 RMQ\texttt{RMQ}RMQ
- 欧拉序:DFS\texttt{DFS}DFS 遍历 Trie\texttt{Trie}Trie 时记录访问的节点序列
- 首次出现位置:记录每个节点在欧拉序中第一次出现的位置
- RMQ\texttt{RMQ}RMQ:在欧拉序上,两个节点的 LCA\texttt{LCA}LCA 就是它们首次出现位置之间深度最小的节点
查询过程
对于字符串 iii 和 jjj:
- 获取它们在 Trie\texttt{Trie}Trie 中的结束节点 uuu 和 vvv
- 查询 uuu 和 vvv 的 LCA\texttt{LCA}LCA 节点 lll
- 输出 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 查询
- 充分利用题目约束条件进行优化
更多推荐


所有评论(0)