UVa 11732 strcmp() Anyone

题目描述

我们需要计算在使用 C/C++\texttt{C/C++}C/C++ 标准库函数 strcmp() 比较所有字符串对时,总共需要执行多少次字符比较操作。

给定 NNN 个字符串,我们需要计算所有 N(N−1)2\frac{N(N-1)}{2}2N(N1) 对字符串比较时,strcmp() 函数内部执行的字符比较次数总和。

输入格式

  • 输入包含最多 101010 组测试数据
  • 每组数据以整数 NNN (0<N<40010 < N < 40010<N<4001) 开始,表示字符串数量
  • 接下来 NNN 行,每行一个字符串(只包含字母和数字,长度在 111100010001000 之间)
  • 输入以 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];
}

比较过程如下:

  1. 逐个比较两个字符串的对应字符
  2. 如果字符相同,继续比较下一个字符
  3. 如果遇到字符不同,立即返回差值
  4. 如果遇到 \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 次比较
  • 如果两个字符串完全相同(长度 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 次比较

暴力解法的问题

直接两两比较所有字符串对的时间复杂度为 O(N2×L)O(N^2 \times L)O(N2×L),其中:

  • N≤4000N \leq 4000N4000L≤1000L \leq 1000L1000
  • 最坏情况下操作次数约为 8×1098 \times 10^98×109,会超时

方法一:Trie\texttt{Trie}Trie 树解法

算法思路

利用 Trie\texttt{Trie}Trie(字典树)来高效统计所有字符串对的比较次数:

  1. 将所有字符串插入 Trie\texttt{Trie}Trie 树中
  2. 每个 Trie\texttt{Trie}Trie 节点记录:
    • cnt:经过该节点的字符串数量
    • endCnt:在该节点结束的字符串数量
  3. 使用 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=pairschildPairs
  • 其中在当前节点结束的对数: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 的高效解法:

  1. 字符串排序:将字符串按字典序排序,使具有公共前缀的字符串相邻
  2. 计算相邻 LCP\texttt{LCP}LCP:计算排序后相邻字符串的最长公共前缀
  3. 利用 LCP\texttt{LCP}LCP 性质:对于任意两个字符串 strings[i]strings[j]i < j),它们的 LCP\texttt{LCP}LCP 等于区间 [i+1, j] 内所有相邻 LCP\texttt{LCP}LCP 的最小值
  4. 比较次数计算:根据 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⋅Llog⁡N)O(N \cdot L \log N)O(NLlogN),其中 LLL 是字符串平均长度
  • LCP\texttt{LCP}LCP 计算O(N⋅L)O(N \cdot L)O(NL),计算所有相邻字符串对的 LCP\texttt{LCP}LCP
  • 比较次数统计O(N2)O(N^2)O(N2),遍历所有字符串对
  • 总复杂度O(N⋅Llog⁡N+N2)O(N \cdot L \log N + N^2)O(NLlogN+N2)

虽然理论复杂度为 O(N2)O(N^2)O(N2),但由于实际数据中字符串通常有较长的公共前缀,且 N≤4000N \leq 4000N4000,在实践中表现良好。

两种方法对比

方面Trie 树解法LCP 解法
时间复杂度O(N⋅L)O(N \cdot L)O(NL)O(N⋅Llog⁡N+N2)O(N \cdot L \log N + N^2)O(NLlogN+N2)
空间复杂度O(N⋅L)O(N \cdot L)O(NL)O(N)O(N)O(N)
实现难度中等简单
适用场景字符串长度差异大字符串有较多公共前缀

总结

两种方法各有优势:

  • Trie\texttt{Trie}Trie 树方法 理论复杂度更低,适合字符串长度较大的情况
  • LCP\texttt{LCP}LCP 方法 实现简单,代码清晰,在实际竞赛中更易于编写和调试

根据具体问题和数据特点选择合适的方法,LCP\texttt{LCP}LCP 方法在大多数情况下能够提供良好的性能表现。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值