题目描述
我们需要计算在使用 C/C++\texttt{C/C++}C/C++ 标准库函数 strcmp() 比较所有字符串对时,总共需要执行多少次字符比较操作。
给定 NNN 个字符串,我们需要计算所有 N(N−1)2\frac{N(N-1)}{2}2N(N−1) 对字符串比较时,strcmp() 函数内部执行的字符比较次数总和。
输入格式
- 输入包含最多 101010 组测试数据
- 每组数据以整数 NNN (0<N<40010 < N < 40010<N<4001) 开始,表示字符串数量
- 接下来 NNN 行,每行一个字符串(只包含字母和数字,长度在 111 到 100010001000 之间)
- 输入以 000 结束
输出格式
- 对于每组数据,输出
"Case X: T",其中 XXX 是测试用例编号,TTT 是总比较次数
题目分析
strcmp()\texttt{strcmp()}strcmp() 比较机制分析
根据题目提供的 strcmp() 代码:
int strcmp(char *s, char *t) {
int i;
for (i = 0; s[i] == t[i]; i++)
if (s[i] == '\0')
return 0;
return s[i] - t[i];
}
比较过程如下:
- 逐个比较两个字符串的对应字符
- 如果字符相同,继续比较下一个字符
- 如果遇到字符不同,立即返回差值
- 如果遇到
\0字符(字符串结束),返回 000
比较次数计算规则
对于两个字符串的比较:
- 设它们的最长公共前缀(LCP\texttt{LCP}LCP)长度为 kkk
- 如果两个字符串不完全相同:比较次数 = 2k+12k + 12k+1
- kkk 次循环中,每次比较
s[i] == t[i]和s[i] == '\0',共 2k2k2k 次比较 - 第 k+1k+1k+1 次比较
s[k] == t[k],发现不同,111 次比较
- kkk 次循环中,每次比较
- 如果两个字符串完全相同(长度 LLL):比较次数 = 2L+22L + 22L+2
- LLL 次循环中,每次比较
s[i] == t[i]和s[i] == '\0',共 2L2L2L 次比较 - 第 L+1L+1L+1 次比较
s[L] == t[L](都是\0),111 次比较 - 再比较
s[L] == '\0',111 次比较
- LLL 次循环中,每次比较
暴力解法的问题
直接两两比较所有字符串对的时间复杂度为 O(N2×L)O(N^2 \times L)O(N2×L),其中:
- N≤4000N \leq 4000N≤4000,L≤1000L \leq 1000L≤1000
- 最坏情况下操作次数约为 8×1098 \times 10^98×109,会超时
方法一:Trie\texttt{Trie}Trie 树解法
算法思路
利用 Trie\texttt{Trie}Trie 树(字典树)来高效统计所有字符串对的比较次数:
- 将所有字符串插入 Trie\texttt{Trie}Trie 树中
- 每个 Trie\texttt{Trie}Trie 节点记录:
cnt:经过该节点的字符串数量endCnt:在该节点结束的字符串数量
- 使用 BFS\texttt{BFS}BFS 遍历 Trie\texttt{Trie}Trie 树,对于每个节点(深度 ddd):
- 计算在该节点分叉的字符串对数
- 根据是否在节点结束,应用不同的比较次数公式
关键公式
对于深度为 ddd 的节点:
- 总经过字符串对数:pairs=C(cnt,2)pairs = C(cnt, 2)pairs=C(cnt,2)
- 子节点字符串对数之和:childPairs=∑C(child.cnt,2)childPairs = \sum C(child.cnt, 2)childPairs=∑C(child.cnt,2)
- 在当前节点分叉的对数:pairsLCP=pairs−childPairspairsLCP = pairs - childPairspairsLCP=pairs−childPairs
- 其中在当前节点结束的对数:endPairs=C(endCnt,2)endPairs = C(endCnt, 2)endPairs=C(endCnt,2)
比较次数计算:
- 在节点结束的字符串对:比较次数 = 2d+22d + 22d+2
- 不在节点结束的字符串对:比较次数 = 2d+12d + 12d+1
算法实现
// "strcmp()" Anyone?
// UVa ID: 11732
// Verdict: Accepted
// Submission Date: 2025-10-17
// UVa Run Time: 1.940s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net
#include <iostream>
#include <cstring>
using namespace std;
// 常量定义
const int MAX_NODES = 4000000; // 最大节点数,根据题目数据范围设定
const int ALPHABET = 62; // 字符集大小:10个数字 + 26个大写字母 + 26个小写字母
// Trie树节点数据(使用分离的数组提高缓存命中率)
int nodeCnt[MAX_NODES]; // 经过该节点的字符串数量
int nodeEndCnt[MAX_NODES]; // 在该节点结束的字符串数量
int nodeChild[MAX_NODES][ALPHABET]; // 子节点指针数组
int nodeCount; // 当前已使用的节点数量
inline int charIndex(char c) {
if (c <= '9') return c - '0'; // 数字字符
if (c <= 'Z') return c - 'A' + 10; // 大写字母
return c - 'a' + 36; // 小写字母
}
void initTrie() {
nodeCnt[0] = nodeEndCnt[0] = 0; // 根节点计数器清零
memset(nodeChild[0], -1, ALPHABET * sizeof(int)); // 清空根节点的子节点指针
nodeCount = 1; // 节点计数从1开始(0为根节点)
}
inline void insert(const char* s) {
int cur = 0; // 从根节点开始
for (; *s; s++) { // 遍历字符串中的每个字符(直到遇到结束符\0)
nodeCnt[cur]++; // 当前节点经过的字符串数量加1
int idx = charIndex(*s); // 获取当前字符的索引
if (nodeChild[cur][idx] == -1) { // 如果子节点不存在
// 创建新节点
nodeCnt[nodeCount] = nodeEndCnt[nodeCount] = 0; // 新节点计数器清零
memset(nodeChild[nodeCount], -1, ALPHABET * sizeof(int)); // 清空子节点指针
nodeChild[cur][idx] = nodeCount++; // 设置子节点指针并增加节点计数
}
cur = nodeChild[cur][idx]; // 移动到子节点
}
// 字符串插入完成,更新结束节点计数器
nodeCnt[cur]++; // 经过该结束节点的字符串数量加1
nodeEndCnt[cur]++; // 在该节点结束的字符串数量加1
}
long long solve() {
long long total = 0; // 总比较次数
// 使用静态数组作为队列,避免动态内存分配
static int queue[MAX_NODES]; // 节点队列
static int depth[MAX_NODES]; // 节点深度队列
// BFS初始化:从根节点开始
queue[0] = 0; // 根节点入队
depth[0] = 0; // 根节点深度为0
int front = 0, rear = 1; // 队列指针
// BFS遍历Trie树
while (front < rear) {
int cur = queue[front]; // 取出当前节点
int d = depth[front]; // 当前节点深度
front++; // 队首指针后移
// 计算当前节点的字符串对数
long long cnt = nodeCnt[cur]; // 经过该节点的字符串数量
long long endCnt = nodeEndCnt[cur]; // 在该节点结束的字符串数量
long long pairsHere = cnt * (cnt - 1) >> 1; // C(cnt, 2) 组合数计算
long long endPairsHere = endCnt * (endCnt - 1) >> 1; // C(endCnt, 2) 组合数计算
// 计算所有子节点的字符串对数之和
long long childPairs = 0;
for (int i = 0; i < ALPHABET; i++) {
int child = nodeChild[cur][i]; // 获取子节点
if (child != -1) { // 如果子节点存在
long long childCnt = nodeCnt[child];
childPairs += childCnt * (childCnt - 1) >> 1; // 累加子节点的组合数
// 子节点入队,准备后续处理
queue[rear] = child;
depth[rear] = d + 1; // 子节点深度为当前深度+1
rear++;
}
}
// 计算在当前节点分叉的字符串对数
// pairsAtNode = 在当前节点有公共前缀但在子节点分叉的字符串对数
long long pairsAtNode = pairsHere - childPairs;
// 根据比较次数公式累加总比较次数:
// - 不在该节点结束的字符串对:比较次数 = 2*d + 1
// - 在该节点结束的字符串对:比较次数 = 2*d + 2
total += (pairsAtNode - endPairsHere) * (2LL * d + 1); // 非结束对的贡献
total += endPairsHere * (2LL * d + 2); // 结束对的贡献
}
return total;
}
int main() {
ios::sync_with_stdio(false); // 关闭C++与C的输入输出同步,提高速度
cin.tie(0); // 解除cin与cout的绑定,提高速度
int N, caseNo = 1; // N: 字符串数量, caseNo: 测试用例编号
char s[1005]; // 字符串缓冲区
// 处理多组测试数据,直到遇到0
while (cin >> N && N) {
initTrie(); // 初始化Trie树
// 读取并插入所有字符串
for (int i = 0; i < N; i++) {
cin >> s;
insert(s);
}
// 计算并输出结果
cout << "Case " << caseNo++ << ": " << solve() << '\n';
}
return 0;
}
方法二:LCP\texttt{LCP}LCP(最长公共前缀)解法
算法思路
基于排序和 LCP\texttt{LCP}LCP 的高效解法:
- 字符串排序:将字符串按字典序排序,使具有公共前缀的字符串相邻
- 计算相邻 LCP\texttt{LCP}LCP:计算排序后相邻字符串的最长公共前缀
- 利用 LCP\texttt{LCP}LCP 性质:对于任意两个字符串
strings[i]和strings[j](i < j),它们的 LCP\texttt{LCP}LCP 等于区间[i+1, j]内所有相邻 LCP\texttt{LCP}LCP 的最小值 - 比较次数计算:根据 LCP\texttt{LCP}LCP 长度和字符串是否相同,应用对应的比较次数公式
关键性质
对于排序后的字符串数组,LCP\texttt{LCP}LCP 具有区间最小值性质:
LCP(strings[i], strings[j]) = min(lcp[i+1], lcp[i+2], ..., lcp[j])
算法实现
// "strcmp()" Anyone?
// UVa ID: 11732
// Verdict: Accepted
// Submission Date: 2025-10-17
// UVa Run Time: 0.690s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <climits>
using namespace std;
// 计算两个字符串的最长公共前缀长度
int computeLCP(const string &a, const string &b) {
int len = 0;
int min_len = min(a.length(), b.length()); // 取两字符串长度的最小值
while (len < min_len && a[len] == b[len]) // 逐个字符比较直到不同或结束
len++;
return len;
}
int main() {
ios::sync_with_stdio(false); // 关闭同步,提高I/O速度
cin.tie(0); // 解除cin与cout的绑定
int N, caseNo = 1; // N: 字符串数量, caseNo: 测试用例编号
while (cin >> N && N) { // 读取N直到遇到0
vector<string> strings(N);
for (int i = 0; i < N; i++)
cin >> strings[i]; // 读取所有字符串
// 对字符串进行字典序排序,使具有公共前缀的字符串相邻
sort(strings.begin(), strings.end());
// lcp[i] 存储 strings[i-1] 和 strings[i] 的最长公共前缀长度
// lcp[0] 未使用,从 lcp[1] 开始有效
vector<int> lcp(N, 0);
for (int i = 1; i < N; i++) lcp[i] = computeLCP(strings[i-1], strings[i]);
long long totalComparisons = 0; // 总比较次数
// 遍历所有字符串对 (i, j),其中 i < j
for (int i = 0; i < N; i++) {
int min_lcp = INT_MAX; // 初始化最小LCP为最大值
for (int j = i + 1; j < N; j++) {
// 更新从 i+1 到 j 的最小LCP
// 根据LCP区间最小值性质:LCP(i,j) = min(lcp[i+1], ..., lcp[j])
if (j == i + 1) {
min_lcp = lcp[j]; // 第一个相邻对,直接取lcp[j]
} else {
min_lcp = min(min_lcp, lcp[j]); // 更新区间最小值
}
// 确定实际的LCP长度
// 如果字符串完全相同,实际LCP应该是字符串长度,而不是min_lcp
int actual_lcp = min_lcp;
if (strings[i] == strings[j]) {
actual_lcp = strings[i].length(); // 相同字符串的LCP等于自身长度
}
// 根据字符串是否相同计算比较次数
if (strings[i] == strings[j]) {
// 完全相同的情况:比较次数 = 2 * 字符串长度 + 2
totalComparisons += 2 * actual_lcp + 2;
} else {
// 不同字符串:比较次数 = 2 * LCP长度 + 1
totalComparisons += 2 * actual_lcp + 1;
}
}
}
// 输出结果
cout << "Case " << caseNo++ << ": " << totalComparisons << "\n";
}
return 0;
}
LCP\texttt{LCP}LCP 方法复杂度分析
- 排序:O(N⋅LlogN)O(N \cdot L \log N)O(N⋅LlogN),其中 LLL 是字符串平均长度
- LCP\texttt{LCP}LCP 计算:O(N⋅L)O(N \cdot L)O(N⋅L),计算所有相邻字符串对的 LCP\texttt{LCP}LCP
- 比较次数统计:O(N2)O(N^2)O(N2),遍历所有字符串对
- 总复杂度:O(N⋅LlogN+N2)O(N \cdot L \log N + N^2)O(N⋅LlogN+N2)
虽然理论复杂度为 O(N2)O(N^2)O(N2),但由于实际数据中字符串通常有较长的公共前缀,且 N≤4000N \leq 4000N≤4000,在实践中表现良好。
两种方法对比
| 方面 | Trie 树解法 | LCP 解法 |
|---|---|---|
| 时间复杂度 | O(N⋅L)O(N \cdot L)O(N⋅L) | O(N⋅LlogN+N2)O(N \cdot L \log N + N^2)O(N⋅LlogN+N2) |
| 空间复杂度 | O(N⋅L)O(N \cdot L)O(N⋅L) | O(N)O(N)O(N) |
| 实现难度 | 中等 | 简单 |
| 适用场景 | 字符串长度差异大 | 字符串有较多公共前缀 |
总结
两种方法各有优势:
- Trie\texttt{Trie}Trie 树方法 理论复杂度更低,适合字符串长度较大的情况
- LCP\texttt{LCP}LCP 方法 实现简单,代码清晰,在实际竞赛中更易于编写和调试
根据具体问题和数据特点选择合适的方法,LCP\texttt{LCP}LCP 方法在大多数情况下能够提供良好的性能表现。
202

被折叠的 条评论
为什么被折叠?



