UVa 10829 L-Gap Substrings

题目描述

如果一个字符串的形式为 UVU\texttt{UVU}UVU ,其中 U\texttt{U}U 非空,并且 V\texttt{V}V 恰好包含 LLL 个字符,那么我们称 UVU\texttt{UVU}UVU 是一个 LLL -间隔字符串。例如, abcbabc\texttt{abcbabc}abcbabc 是一个 111 -间隔字符串。 xyxyyxyxy\texttt{xyxyyxyxy}xyxyyxyxy 既是一个 222 -间隔字符串,也是一个 666 -间隔字符串,但不是 101010 -间隔字符串(因为 U\texttt{U}U 非空)。

给定一个字符串 sss 和一个正整数 ggg ,你需要找出 sss 中所有 ggg -间隔子串的数量。 sss 只包含小写字母,长度最多为 50,00050,00050,000 个字符。

输入格式
第一行包含一个整数 ttt1≤t≤101 \leq t \leq 101t10 ),表示测试用例的数量。接下来的 ttt 行中,每行包含一个整数 ggg1≤g≤101 \leq g \leq 101g10 )和一个字符串 sss

输出格式
对于每个测试用例,输出用例编号和 ggg -间隔子串的数量。

样例输入

2
1 bbaabaaaaa
5 abxxxxxab

样例输出

Case 1: 7
Case 2: 1

题目分析

问题理解

我们需要在字符串 sss 中找出所有满足以下条件的子串:

  1. 子串形式为 UVU\texttt{UVU}UVU ,即由三个部分组成。
  2. U\texttt{U}U 是非空字符串。
  3. V\texttt{V}V 的长度恰好为 ggg
  4. 第一个 U\texttt{U}U 和第二个 U\texttt{U}U 完全相同。

换句话说,我们需要找到所有长度相等、内容相同且相隔 ggg 个字符的两个子串 U\texttt{U}U

形式化定义

设字符串 sss 的长度为 nnn ,下标从 000 开始。我们需要统计所有满足以下条件的四元组 (i,j,len)(i, j, len)(i,j,len) 的数量:

  • iii 是第一个 U\texttt{U}U 的起始位置。
  • jjj 是第二个 U\texttt{U}U 的起始位置。
  • lenlenlenU\texttt{U}U 的长度( len>0len > 0len>0 )。
  • j=i+len+gj = i + len + gj=i+len+g
  • 对于所有 0≤k<len0 \leq k < len0k<len ,有 s[i+k]=s[j+k]s[i + k] = s[j + k]s[i+k]=s[j+k]
  • 子串的结束位置不超过 n−1n - 1n1 ,即 i+2×len+g−1<ni + 2 \times len + g - 1 < ni+2×len+g1<n

数据范围分析

  • n≤50000n \leq 50000n50000 ,因此 O(n2)O(n^2)O(n2) 的算法可能勉强通过(如果常数较小),但 O(n3)O(n^3)O(n3) 的算法肯定超时。
  • g≤10g \leq 10g10 ,这是一个重要的限制条件,意味着中间间隔很小。
  • t≤10t \leq 10t10 ,所以需要每个测试用例都高效处理。

解题思路

暴力枚举(不可行)

最直接的想法是枚举所有可能的 U\texttt{U}U 长度 lenlenlen 和起始位置 iii ,然后比较两个 U\texttt{U}U 是否相同。这样需要三层循环:

  1. 枚举 lenlenlen :从 111(n−g)/2(n - g) / 2(ng)/2
  2. 枚举 iii :从 000n−2×len−gn - 2 \times len - gn2×leng
  3. 比较两个长度为 lenlenlen 的子串是否相等。

时间复杂度为 O(n3)O(n^3)O(n3) ,在 n=50000n = 50000n=50000 时完全不可行。

哈希优化

我们可以使用字符串哈希Rabin-Karp\texttt{Rabin-Karp}Rabin-Karp 哈希)来在 O(1)O(1)O(1) 时间内比较两个子串是否相等。这样可以将时间复杂度降低到 O(n2)O(n^2)O(n2)

哈希原理

  • 预处理字符串的前缀哈希值: hash[i]=s[0]×basei−1+s[1]×basei−2+⋯+s[i−1]×base0hash[i] = s[0] \times base^{i-1} + s[1] \times base^{i-2} + \dots + s[i-1] \times base^0hash[i]=s[0]×basei1+s[1]×basei2++s[i1]×base0
  • 计算子串 s[l..r]s[l..r]s[l..r] 的哈希值: hash(l,r)=hash[r+1]−hash[l]×baser−l+1hash(l, r) = hash[r+1] - hash[l] \times base^{r-l+1}hash(l,r)=hash[r+1]hash[l]×baserl+1
  • 如果两个子串的哈希值相同,我们可以认为它们相等(在合适的模数下碰撞概率极低)。

算法步骤

  1. 预处理字符串的哈希数组和 basebasebase 的幂次数组。
  2. 枚举 lenlenlen111(n−g)/2(n - g) / 2(ng)/2
  3. 对于每个 lenlenlen ,枚举 iii000n−2×len−gn - 2 \times len - gn2×leng
  4. 计算第一个 U\texttt{U}U 的哈希值 hash(i,i+len−1)hash(i, i+len-1)hash(i,i+len1) 和第二个 U\text{U}U 的哈希值 hash(i+len+g,i+2×len+g−1)hash(i+len+g, i+2 \times len+g-1)hash(i+len+g,i+2×len+g1)
  5. 如果两个哈希值相等,则答案加 111

时间复杂度O(n2)O(n^2)O(n2)
空间复杂度O(n)O(n)O(n)

为什么能通过?

虽然 O(n2)O(n^2)O(n2) 对于 n=50000n = 50000n=50000 来说理论计算量很大(约 2.5×1092.5 \times 10^92.5×109 次操作),但实际上:

  1. ggg 很小( ≤10\leq 1010 ),所以内层循环的 iii 的范围随着 lenlenlen 增大而快速减小。
  2. 哈希比较是 O(1)O(1)O(1) 的,常数很小。
  3. 现代 CPU\texttt{CPU}CPU 速度较快,且题目时间限制可能较宽松。
  4. 实际测试中该算法可以在 222 秒内通过。

更优解法(后缀数组)

如果需要处理更大的 nnn 或更严格的时限,可以使用后缀数组 + RMQ\texttt{RMQ}RMQ 的方法:

  1. 构建字符串的后缀数组和高度数组(LCP\texttt{LCP}LCP 数组)。
  2. 使用 RMQ\texttt{RMQ}RMQ(稀疏表)预处理 LCP\texttt{LCP}LCP 数组,以便 O(1)O(1)O(1) 查询任意两个后缀的最长公共前缀。
  3. 枚举 lenlenleniii ,通过查询 LCP(i,i+len+g)\texttt{LCP}(i, i+len+g)LCP(i,i+len+g) 判断是否满足条件。
  4. 还可以进一步优化:对于每个偏移量 d=len+gd = len + gd=len+g ,计算所有 iiiLCP(i,i+d)\texttt{LCP}(i, i+d)LCP(i,i+d) ,然后统计贡献。

这种方法的时间复杂度为 O(nlog⁡n)O(n \log n)O(nlogn) ,可以处理更大的数据范围。但由于 ggg 很小,哈希法已经足够。


代码实现(字符串哈希)

// L-Gap Substrings
// UVa ID: 10829
// Verdict: Accepted
// Submission Date: 2025-12-05
// UVa Run Time: 1.740s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net

#include <bits/stdc++.h>
using namespace std;
using ull = unsigned long long;

const int MAXN = 50005;
const ull BASE = 131;

ull powBase[MAXN];
ull preHash[MAXN];

void initHash(const string &s) {
    int n = s.length();
    powBase[0] = 1;
    for (int i = 1; i <= n; ++i) powBase[i] = powBase[i-1] * BASE;
    preHash[0] = 0;
    for (int i = 1; i <= n; ++i) preHash[i] = preHash[i-1] * BASE + (s[i-1] - 'a' + 1);
}

ull getHash(int l, int r) { // 闭区间 [l, r],0-based
    return preHash[r+1] - preHash[l] * powBase[r-l+1];
}

int countGapSubstrings(int g, const string &s) {
    int n = s.length();
    initHash(s);
    int ans = 0;
    for (int lenU = 1; lenU <= (n - g) / 2; ++lenU) {
        for (int i = 0; i + 2 * lenU + g <= n; ++i) {
            int j = i + lenU + g;
            if (getHash(i, i + lenU - 1) == getHash(j, j + lenU - 1)) ++ans;
        }
    }
    return ans;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int t;
    cin >> t;
    for (int caseNo = 1; caseNo <= t; ++caseNo) {
        int g;
        string s;
        cin >> g >> s;
        int ans = countGapSubstrings(g, s);
        cout << "Case " << caseNo << ": " << ans << "\n";
    }
    return 0;
}

代码实现(后缀数组)

// L-Gap Substrings
// UVa ID: 10829
// Verdict: Accepted
// Submission Date: 2025-12-05
// UVa Run Time: 0.040s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net

#include <bits/stdc++.h>

using namespace std;

const int N = 500050;

char s[N];

// 后缀数组结构体,用于构建后缀数组、高度数组和RMQ查询
struct SuffixArray {
    int sa[N];       // 后缀数组,sa[i]表示排第i名的后缀的起始下标
    int t1[N], t2[N], c[N];  // 临时数组和计数数组,用于基数排序
    int height[N];   // 高度数组,height[i] = LCP(sa[i-1], sa[i])
    int rnk[N];      // 排名数组,rnk[i]表示起始位置为i的后缀的排名
    int lg[N], st[30][N]; // RMQ所需:lg数组用于快速计算log,st为稀疏表

    // 构建后缀数组,n为字符串长度+1(包含末尾的0),m为字符集大小
    void buildSA(int n, int m) {
        int i, *x = t1, *y = t2;
        // 第一轮基数排序:按第一个字符排序
        for (i = 0; i < m; i++) c[i] = 0;
        for (i = 0; i < n; i++) c[x[i] = s[i]]++;
        for (i = 1; i < m; i++) c[i] += c[i - 1];
        for (i = n - 1; i >= 0; i--) sa[--c[x[i]]] = i;
        
        // 倍增算法,k为当前比较的步长
        for (int k = 1; k <= n; k <<= 1) {
            int p = 0;
            // 对第二关键字排序:没有第二关键字的排在最前面
            for (i = n - k; i < n; i++) y[p++] = i;
            // 根据上一轮的sa数组确定第二关键字顺序
            for (i = 0; i < n; i++)
                if (sa[i] >= k) y[p++] = sa[i] - k;
            
            // 对第一关键字排序
            for (i = 0; i < m; i++) c[i] = 0;
            for (i = 0; i < n; i++) c[x[y[i]]]++;
            for (i = 1; i < m; i++) c[i] += c[i - 1];
            for (i = n - 1; i >= 0; i--) sa[--c[x[y[i]]]] = y[i];
            
            // 生成新的rank数组
            swap(x, y);
            p = 1;
            x[sa[0]] = 0;
            for (i = 1; i < n; i++)
                // 如果当前后缀和前一个后缀的两个关键字都相同,则排名相同
                x[sa[i]] = (y[sa[i - 1]] == y[sa[i]] && y[sa[i - 1] + k] == y[sa[i] + k]) ? p - 1 : p++;
            
            // 如果所有后缀的排名都已不同,则提前结束
            if (p >= n) break;
            m = p; // 更新字符集大小
        }
    }
    
    // 计算高度数组(LCP数组)
    void getHeight(int n) {
        int k = 0;
        // 计算rank数组
        for (int i = 1; i <= n; i++) rnk[sa[i]] = i;
        
        // 计算每个后缀与它前一名的后缀的LCP
        for (int i = 0; i < n; i++) {
            if (k) k--; // height[rank[i]] >= height[rank[i-1]] - 1
            int j = sa[rnk[i] - 1]; // 排名在i之前一位的后缀起始位置
            while (s[i + k] == s[j + k]) k++; // 扩展匹配
            height[rnk[i]] = k;
        }
    }
    
    // 初始化RMQ稀疏表,用于快速查询任意两个后缀的LCP
    void initilizeRMQ(int n) {
        // 预处理log数组,用于RMQ查询时快速计算分段长度
        lg[0] = -1;
        for (int i = 1; i <= n; i++)
            lg[i] = lg[i >> 1] + 1;
        
        // 初始化ST表第一层
        for (int i = 1; i <= n; i++)
            st[0][i] = height[i];
        
        // 构建ST表,st[j][i]表示区间[i, i+2^j-1]的最小值
        for (int j = 1; (1 << j) <= n; j++)
            for (int i = 1; i + (1 << j) - 1 <= n; i++)
                st[j][i] = min(st[j - 1][i], st[j - 1][i + (1 << (j - 1))]);
    }
    
    // 查询两个后缀的最长公共前缀(LCP)
    int RMQ(int L, int R) {
        // 将字符串位置转换为排名
        L = rnk[L]; R = rnk[R];
        if (L > R) swap(L, R); // 确保L <= R
        L++; // height数组从1开始,表示排名相邻后缀的LCP
        // 使用RMQ查询区间[L, R]的最小值
        int k = lg[R - L + 1];
        return min(st[k][L], st[k][R - (1 << k) + 1]);
    }
};

SuffixArray suf, rev; // 正向和反向后缀数组

int main() {
    int T, cases = 0;
    scanf("%d", &T);
    while (T--) {
        int g;
        scanf("%d%s", &g, s);
        int n = strlen(s);
        
        // 构建正向后缀数组(用于计算两个子串的最长公共前缀LCP)
        suf.buildSA(n + 1, 128); // n+1是为了包含字符串结束符
        suf.getHeight(n);
        suf.initilizeRMQ(n);
        
        // 构建反向后缀数组(用于计算两个子串的最长公共后缀LCS)
        // 通过反转字符串,将LCS问题转化为LCP问题
        for (int i = 0; i < n / 2; i++) swap(s[i], s[n - 1 - i]);
        rev.buildSA(n + 1, 128);
        rev.getHeight(n);
        rev.initilizeRMQ(n);
        
        long long sum = 0;
        
        // 枚举U的长度len
        for (int len = 1; len <= n / 2; len++) {
            // 枚举第一个U的起始位置i,这里使用了跳跃枚举优化
            // 由于我们只关心相距len+g的位置对,所以可以按len步长跳跃
            for (int i = 0; i < n; i += len) {
                int L = i, R = i + g + len; // 第二个U的起始位置
                if (R >= n) break; // 第二个U超出字符串范围
                
                // 计算LCP:从L和R开始的最长公共前缀
                // 限制不超过U的长度len
                int lcp = min(len, suf.RMQ(L, R));
                
                // 计算LCS:到L-1和R-1结束的最长公共后缀
                // 通过反向字符串的LCP计算
                L = n - L - 1; // 转换为反向字符串中的位置
                R = n - R - 1;
                int lcs = min(len, rev.RMQ(L, R));
                
                // 有效的匹配长度计算:
                // 考虑重叠情况:总匹配长度 = lcp + lcs - len
                // 这是因为两个U的长度为len,而lcp和lcs可能重叠
                int cnt = lcp + lcs - len;
                sum += max(cnt, 0); // 只累加非负值
            }
        }
        
        printf("Case %d: %lld\n", ++cases, sum);
    }
    return 0;
}

总结

本题的关键在于利用字符串哈希将子串比较从 O(n)O(n)O(n) 降低到 O(1)O(1)O(1) ,从而将总时间复杂度从 O(n3)O(n^3)O(n3) 降低到 O(n2)O(n^2)O(n2) 。虽然 O(n2)O(n^2)O(n2) 对于 n=50000n = 50000n=50000 来说看起来很大,但由于 ggg 很小且实际循环次数少于最坏情况,该算法可以在时限内通过。

如果题目数据范围更大(例如 n≤106n \leq 10^6n106 ),则需要使用后缀数组等 O(nlog⁡n)O(n \log n)O(nlogn) 的算法。但对于本题的数据范围,字符串哈希是一种简洁高效的解法。

时间复杂度O(n2)O(n^2)O(n2)
空间复杂度O(n)O(n)O(n)

在 Java 或类似的编程语言中,`substring()` 方法用于从字符串中提取子字符串。我们来分析一下字符串 `"06-18"` 调用 `substring(0, 2)` 和 `substring(3)` 的结果。 --- ### 原始字符串: ```java String str = "06-18"; ``` #### 1. `str.substring(0, 2)` - 参数含义:`substring(startIndex, endIndex)` - `startIndex`: 起始索引(包含) - `endIndex`: 结束索引(不包含) - 索引从 0 开始: - `'0'` → index 0 - `'6'` → index 1 - `'-'` → index 2 - `'1'` → index 3 - `'8'` → index 4 所以 `substring(0, 2)` 取的是索引 0 到 1 的字符(不包括 2): ```java "06" ``` #### 2. `str.substring(3)` - 参数含义:`substring(startIndex)` —— 从指定位置开始到字符串末尾 - 所以 `substring(3)` 取的是从索引 3 到结尾的字符: ```java "18" ``` --- ### 完整 Java 示例代码: ```java public class SubstringExample { public static void main(String[] args) { String str = "06-18"; String part1 = str.substring(0, 2); // [0, 2) String part2 = str.substring(3); // [3, end] System.out.println("前两字符: " + part1); // 输出: 06 System.out.println("从第4个字符开始: " + part2); // 输出: 18 } } ``` ### 解释: - `substring(0, 2)` 提取了月份部分(假设是 MM-dd 格式中的 MM)。 - `substring(3)` 提取了日期部分(dd),跳过了中间的 `'-'` 分隔符。 这种技巧常用于简单解析固定格式的字符串(如日期、编号等),但建议对格式不确定的情况使用更健壮的方式(如 `SimpleDateFormat` 或正则表达式)。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值