最长公共子串的不同子串个数(允许重叠的统计)
字数 4661 2025-12-13 00:12:27

最长公共子串的不同子串个数(允许重叠的统计)


题目描述

给定一个字符串 \(S\),求它的所有子串中,有多少个是至少出现两次的(子串可以重叠出现)。
换句话说,你需要统计 \(S\)所有重复出现的子串不同子串个数。

示例
输入:\(S = "ababa"\)
输出:6
解释:
重复子串有:

  • "a" 出现在位置 0,2,4
  • "b" 出现在位置 1,3
  • "ab" 出现在位置 0,2
  • "ba" 出现在位置 1,3
  • "aba" 出现在位置 0,2
  • "bab" 出现在位置 1,3
    总共 6 个不同的子串。

解题过程

第一步:理解问题

  • 子串是连续的。
  • 如果同一个子串在字符串中至少出现两次(可以重叠,比如 "aaa" 中 "aa" 在位置 0-1 和 1-2 出现两次),就计入答案。
  • 要求不同的子串,即只要内容不同就算不同的子串,不管出现次数多少。
  • 目标:统计所有这样的不同子串的个数。

第二步:直接枚举法的局限性

  • 子串总数是 \(O(n^2)\),检查每个子串是否出现两次需要 \(O(n)\) 或借助数据结构,总体 \(O(n^3)\) 会超时。
  • 我们需要更高效的方法,最好在 \(O(n^2)\) 或更低复杂度完成。

第三步:转化为公共前缀问题

一个经典观察:
如果某个长度为 \(L\) 的子串在 \(S\) 中至少出现两次,那么它在后缀数组中,两个后缀的最长公共前缀(LCP) 长度至少为 \(L\)

另一种更直观的动态规划思路:
定义 \(dp[i][j]\) 为以 \(S[i]\)\(S[j]\) 结尾的相同子串的长度(即 \(S[i-dp[i][j]+1 .. i]\)\(S[j-dp[i][j]+1 .. j]\) 相同,且子串结束于 i 和 j)。

转移方程
如果 \(S[i] = S[j]\),则 \(dp[i][j] = dp[i-1][j-1] + 1\)(当 \(i>0, j>0\));
否则 \(dp[i][j] = 0\)

注意:为了避免 i-1 或 j-1 越界,我们可以从 i=1 到 n-1, j=i+1 到 n-1 遍历,但这样是 \(O(n^2)\) 时间和空间,对于 n 很大可能内存过大。我们稍后会优化。


第四步:从 dp 得到答案

对于每一对 (i,j),如果 \(dp[i][j] = L > 0\),说明有一个长度为 L 的子串在 i 和 j 处结束。
这个子串是 \(S[i-L+1 .. i]\)
我们需要统计不同的子串,所以不能简单地把所有 L>0 的情况计数(会重复统计同一个子串在不同结束位置)。

去重方法
对于每个可能的起始位置 \(p\) 和长度 \(L\),子串是唯一的。我们可以在发现 \(dp[i][j] = L\) 时,得到它的起始位置 \(start = i-L+1\),子串是 \(S[start .. start+L-1]\),但可能有多个 (i,j) 对应同一个子串。

一个巧妙的方法:
用集合存储子串的哈希值(如滚动哈希)来去重。
但我们也可以利用后缀自动机或后缀数组,这里我们用 DP 配合哈希来去重。


第五步:优化思路

直接 \(O(n^2)\) 的二维 dp 在 n=5000 时内存就 25M*4 字节可能过大,但通常题目 n 在 2000 左右可以接受,如果更大则需要更优结构。

我们换一种定义:
\(dp[i][j]\) 表示以 i 和 j 开始的相同子串长度。
则转移:若 \(S[i] = S[j]\),则 \(dp[i][j] = dp[i+1][j+1] + 1\),否则 0。
这样可以从后往前递推,但一样是 \(O(n^2)\) 空间。

为了节省空间,可以只保留一维数组,但统计不同子串时需要记录子串本身。
常用做法是:
遍历所有起始位置对 (i,j) 且 i<j,计算它们的最长公共前缀长度 L = lcp(i,j),那么对于长度 1 到 L 的子串 \(S[i..i+len-1]\) 都是重复的(因为它也出现在 j 开头)。把这些子串加入集合。

但这样还是 \(O(n^3)\) 如果我们枚举每个 len,实际上我们可以只记录:
对于每对 (i,j),得到 L 后,我们记录最长的一个重复子串,但更短的也需要记录,所以用后缀数组 + LCP 数组更方便。


第六步:后缀数组解法(高效标准解法)

  1. 构建后缀数组 \(sa\) 和 LCP 数组。

  2. 对于后缀数组中相邻的两个后缀 \(sa[k-1]\)\(sa[k]\),它们的 LCP 值记为 \(L\)
    那么它们共享长度为 1 到 L 的前缀,这些前缀都是重复出现的子串。
    但要注意:不同的相邻对可能对应相同的子串,需要去重。
    去重方法是:
    对于后缀 \(sa[k-1]\)\(sa[k]\),它们贡献的新的重复子串是那些长度大于 \(max(LCP(k-1), LCP(k+1))\) 的子串(这里 LCP(k) 是后缀 k-1 和 k 的 LCP 值,边界为 0)。
    更简单的方法:
    把所有的相邻 LCP 值看作一个区间,长度从 1 到 LCP[k] 的子串可能被前面或后面覆盖,实际上去重可以用“每个子串只在它第一次出现时被统计”的思路,但这里我们直接用一个集合记录所有子串哈希值,但可能内存大。

    不过已知公式:
    不同重复子串数 = 所有 (LCP[i] 对答案的贡献) 的总和,其中 LCP[i] 是后缀数组中相邻后缀的 LCP 长度。
    贡献是 LCP[i] 减去与上一个 LCP 覆盖重叠的部分,但计算较复杂。

    一个简单正确的方法:
    用后缀自动机统计所有出现次数 >1 的子串个数,这是 O(n) 的。但这里我们限制在“线性 DP”范围,所以我们用 DP 方法。


第七步:DP 去重技巧

我们用滚动哈希(Rabin-Karp)枚举所有子串,记录出现次数,出现次数>1 则计入答案。
枚举所有长度 L,用哈希表记录哈希值出现次数,再对每个长度去重。
复杂度 O(n^2) 时间,O(n^2) 哈希存储,在 n<=2000 时可行。

步骤

  1. 初始化一个全局集合 repeated_substrings 用于存子串的哈希值(或直接存子串)。

  2. 对于每个起始位置 i 从 0 到 n-1:

    • 维护当前子串的哈希值 h=0
    • 对于每个结束位置 j 从 i 到 n-1:
      • 更新哈希值 h = h * base + S[j]
      • 如果哈希值 h 在之前从某个起始位置 i'<i 开始时已经生成过,那么当前 (i,j) 是重复出现的,但注意:
        我们要确保这是不同的子串,所以用 (h, j-i+1) 作为键存入集合,最后集合大小就是答案。
        但这样会漏掉一些重复子串,因为相同子串可能被多个起始位置发现,但集合保证唯一。

    但更稳妥的方法:
    先枚举所有子串,用哈希表记录每个子串出现的起始位置列表,遍历哈希表,如果某个子串起始位置列表长度>=2,则它是重复子串,计数+1。
    实现时,可以用哈希值+长度作为键,但需解决哈希冲突(可用双哈希)。


第八步:线性 DP 解法(另一种定义)

定义 \(f[i]\) 为以 i 结尾的所有重复子串的新出现的重复子串个数,不太好推。
我们回到二维 DP 的统计:
\(dp[i][j]\) 为以 i 和 j 结尾的相同子串长度(i<j),若 \(dp[i][j] = L > 0\),则子串 S[i-L+1..i] 是重复的。
我们把这些子串的 (起始位置, 长度) 存入集合。
最后集合的大小就是答案。

实现时,用滚动数组优化空间:
因为 dp[i][j] 只依赖于 dp[i-1][j-1],我们可以只保留上一行,但要注意遍历顺序。

伪代码

集合 set
初始化 dp[0..n-1] = 0
for i from 0 to n-1:
  for j from i+1 to n-1:
    if S[i] == S[j]:
      if i>0 and j>0:
        dp[j] = dp_prev[j-1] + 1
      else:
        dp[j] = 1
      如果 dp[j] > 0:
        起始位置 start = i - dp[j] + 1
        长度 len = dp[j]
        把 (start, len) 加入集合
    else:
      dp[j] = 0
  更新 dp_prev = dp 的拷贝(只保留上一行的值)

但这里 dp[j] 是二维压缩成一维后的混淆,实际需用二维滚动,细节略。


第九步:最终简洁的 DP 统计

我们换用:
dp[l][i] 表示从 i 开始长度为 l 的子串是否重复出现。
转移:若 S[i..i+l-1] == S[j..j+l-1] 则重复。
但这样还是 O(n^3)。

实际上,这个问题常用后缀自动机解决,但题目标签是线性 DP,我们给出一个 O(n^2) 的 DP 解法:

定义 len[i][j] 表示以 i 和 j 开头的最长公共前缀长度,从后往前推:

for i = n-1 down to 0:
  for j = n-1 down to 0:
    if S[i] == S[j]:
      len[i][j] = 1 + len[i+1][j+1]  (边界处理为 0)
    else:
      len[i][j] = 0

然后,对于每对 (i,j) 且 i<j,如果 len[i][j] = L > 0,则对于每个长度 1..L,子串 S[i..i+k-1] 至少出现在 i 和 j 两个位置,把这些子串加入集合。

去重:用哈希集合存储子串的 (起始位置 i, 长度 k),但注意不同 (i,k) 可能对应相同子串,所以必须用字符串哈希来唯一标识。我们直接用字符串本身存入集合(如果 n 较小,如 <=2000,子串总数 O(n^2),每个平均 O(n),总体 O(n^3) 内存会大,但 n=1000 可行)。

为了优化,我们可以在发现 L>0 时,直接把对应子串的哈希值存入集合。


第十步:实例验证

S = "ababa"
计算 len[i][j] 矩阵(只考虑 i<j):

i=0, j=2: S[0]=a, S[2]=a, len=1+len[1][3]=1+?
先算 len[1][3]: S[1]=b, S[3]=b, len=1+len[2][4]
len[2][4]: S[2]=a, S[4]=a, len=1+len[3][5]=1+0=1
所以 len[1][3]=1+1=2
所以 len[0][2]=1+2=3

这样得到子串:
(0,2) 长度 1..3: "a","ab","aba"
(1,3) 长度 1..2: "b","ba"
(0,3) 长度 1..1: "a"(已有)
(1,4) 长度 1..2: "b","ba"(已有)
(0,4) 长度 1..1: "a"(已有)
(2,4) 长度 1..3: "a","ab","aba"(但注意 (2,4) 的 "ab" 是从 S[2..3] 吗?检查 S[2..4]="aba",长度 1..3 是 "a","ab","aba",其中 "ab" 是 S[2..3],但 S[0..1] 也是 "ab",所以是重复。

把所有不重复的收集:
长度1: "a","b"
长度2: "ab","ba"
长度3: "aba","bab"
共 6 个,符合示例。


第十一步:算法复杂度

  • 时间 O(n^2) 计算 len 数组,统计子串时避免重复可用哈希,总 O(n^2)。
  • 空间 O(n^2) 或优化为 O(n) 如果只保留一行,但统计子串需要存储结果,最坏 O(n^2) 子串。

总结

本题虽然可以用后缀自动机高效解决,但“线性 DP” 范畴下,我们通过二维 LCP 的 DP 计算,得到所有重复子串,并用哈希集合去重,最终得到不同重复子串的个数。核心是 len[i][j] 的递推与子串提取去重。

最长公共子串的不同子串个数(允许重叠的统计) 题目描述 给定一个字符串 \(S\),求它的 所有子串 中,有多少个是 至少出现两次的 (子串可以重叠出现)。 换句话说,你需要统计 \(S\) 中 所有重复出现的子串 的 不同 子串个数。 示例 : 输入:\(S = "ababa"\) 输出:6 解释: 重复子串有: "a" 出现在位置 0,2,4 "b" 出现在位置 1,3 "ab" 出现在位置 0,2 "ba" 出现在位置 1,3 "aba" 出现在位置 0,2 "bab" 出现在位置 1,3 总共 6 个不同的子串。 解题过程 第一步:理解问题 子串是连续的。 如果同一个子串在字符串中至少出现两次(可以重叠,比如 "aaa" 中 "aa" 在位置 0-1 和 1-2 出现两次),就计入答案。 要求 不同 的子串,即只要内容不同就算不同的子串,不管出现次数多少。 目标:统计所有这样的不同子串的个数。 第二步:直接枚举法的局限性 子串总数是 \(O(n^2)\),检查每个子串是否出现两次需要 \(O(n)\) 或借助数据结构,总体 \(O(n^3)\) 会超时。 我们需要更高效的方法,最好在 \(O(n^2)\) 或更低复杂度完成。 第三步:转化为公共前缀问题 一个经典观察: 如果某个长度为 \(L\) 的子串在 \(S\) 中至少出现两次,那么它在 后缀数组 中,两个后缀的 最长公共前缀(LCP) 长度至少为 \(L\)。 另一种更直观的动态规划思路: 定义 \(dp[ i][ j]\) 为以 \(S[ i]\) 和 \(S[ j]\) 结尾的 相同子串的长度 (即 \(S[ i-dp[ i][ j]+1 .. i]\) 与 \(S[ j-dp[ i][ j]+1 .. j ]\) 相同,且子串结束于 i 和 j)。 转移方程 : 如果 \(S[ i] = S[ j]\),则 \(dp[ i][ j] = dp[ i-1][ j-1 ] + 1\)(当 \(i>0, j>0\)); 否则 \(dp[ i][ j ] = 0\)。 注意 :为了避免 i-1 或 j-1 越界,我们可以从 i=1 到 n-1, j=i+1 到 n-1 遍历,但这样是 \(O(n^2)\) 时间和空间,对于 n 很大可能内存过大。我们稍后会优化。 第四步:从 dp 得到答案 对于每一对 (i,j),如果 \(dp[ i][ j ] = L > 0\),说明有一个长度为 L 的子串在 i 和 j 处结束。 这个子串是 \(S[ i-L+1 .. i ]\)。 我们需要统计不同的子串,所以不能简单地把所有 L>0 的情况计数(会重复统计同一个子串在不同结束位置)。 去重方法 : 对于每个可能的起始位置 \(p\) 和长度 \(L\),子串是唯一的。我们可以在发现 \(dp[ i][ j] = L\) 时,得到它的起始位置 \(start = i-L+1\),子串是 \(S[ start .. start+L-1 ]\),但可能有多个 (i,j) 对应同一个子串。 一个巧妙的方法: 用集合存储子串的哈希值(如滚动哈希)来去重。 但我们也可以利用后缀自动机或后缀数组,这里我们用 DP 配合哈希来去重。 第五步:优化思路 直接 \(O(n^2)\) 的二维 dp 在 n=5000 时内存就 25M* 4 字节可能过大,但通常题目 n 在 2000 左右可以接受,如果更大则需要更优结构。 我们换一种定义: 设 \(dp[ i][ j ]\) 表示以 i 和 j 开始的相同子串长度。 则转移:若 \(S[ i] = S[ j]\),则 \(dp[ i][ j] = dp[ i+1][ j+1 ] + 1\),否则 0。 这样可以从后往前递推,但一样是 \(O(n^2)\) 空间。 为了节省空间,可以只保留一维数组,但统计不同子串时需要记录子串本身。 常用做法是: 遍历所有起始位置对 (i,j) 且 i<j,计算它们的最长公共前缀长度 L = lcp(i,j),那么对于长度 1 到 L 的子串 \(S[ i..i+len-1 ]\) 都是重复的(因为它也出现在 j 开头)。把这些子串加入集合。 但这样还是 \(O(n^3)\) 如果我们枚举每个 len,实际上我们可以只记录: 对于每对 (i,j),得到 L 后,我们记录 最长 的一个重复子串,但更短的也需要记录,所以用 后缀数组 + LCP 数组 更方便。 第六步:后缀数组解法(高效标准解法) 构建后缀数组 \(sa\) 和 LCP 数组。 对于后缀数组中相邻的两个后缀 \(sa[ k-1]\) 和 \(sa[ k ]\),它们的 LCP 值记为 \(L\)。 那么它们共享长度为 1 到 L 的前缀,这些前缀都是重复出现的子串。 但要注意:不同的相邻对可能对应相同的子串,需要去重。 去重方法是: 对于后缀 \(sa[ k-1]\) 和 \(sa[ k]\),它们贡献的 新的 重复子串是那些长度大于 \(max(LCP(k-1), LCP(k+1))\) 的子串(这里 LCP(k) 是后缀 k-1 和 k 的 LCP 值,边界为 0)。 更简单的方法: 把所有的相邻 LCP 值看作一个区间,长度从 1 到 LCP[ k ] 的子串可能被前面或后面覆盖,实际上去重可以用“每个子串只在它第一次出现时被统计”的思路,但这里我们直接用一个集合记录所有子串哈希值,但可能内存大。 不过已知公式: 不同重复子串数 = 所有 (LCP[ i] 对答案的贡献) 的总和,其中 LCP[ i ] 是后缀数组中相邻后缀的 LCP 长度。 贡献是 LCP[ i ] 减去与上一个 LCP 覆盖重叠的部分,但计算较复杂。 一个简单正确的方法: 用后缀自动机统计所有出现次数 >1 的子串个数,这是 O(n) 的。但这里我们限制在“线性 DP”范围,所以我们用 DP 方法。 第七步:DP 去重技巧 我们用滚动哈希(Rabin-Karp)枚举所有子串,记录出现次数,出现次数>1 则计入答案。 枚举所有长度 L,用哈希表记录哈希值出现次数,再对每个长度去重。 复杂度 O(n^2) 时间,O(n^2) 哈希存储,在 n <=2000 时可行。 步骤 : 初始化一个全局集合 repeated_substrings 用于存子串的哈希值(或直接存子串)。 对于每个起始位置 i 从 0 到 n-1: 维护当前子串的哈希值 h=0 对于每个结束位置 j 从 i 到 n-1: 更新哈希值 h = h * base + S[ j ] 如果哈希值 h 在之前从某个起始位置 i' <i 开始时已经生成过,那么当前 (i,j) 是重复出现的,但注意: 我们要确保这是 不同 的子串,所以用 (h, j-i+1) 作为键存入集合,最后集合大小就是答案。 但这样会漏掉一些重复子串,因为相同子串可能被多个起始位置发现,但集合保证唯一。 但更稳妥的方法: 先枚举所有子串,用哈希表记录每个子串出现的起始位置列表,遍历哈希表,如果某个子串起始位置列表长度>=2,则它是重复子串,计数+1。 实现时,可以用哈希值+长度作为键,但需解决哈希冲突(可用双哈希)。 第八步:线性 DP 解法(另一种定义) 定义 \(f[ i]\) 为以 i 结尾的所有重复子串的 新出现的重复子串个数 ,不太好推。 我们回到二维 DP 的统计: 设 \(dp[ i][ j]\) 为以 i 和 j 结尾的相同子串长度(i<j),若 \(dp[ i][ j] = L > 0\),则子串 S[ i-L+1..i ] 是重复的。 我们把这些子串的 (起始位置, 长度) 存入集合。 最后集合的大小就是答案。 实现时,用滚动数组优化空间: 因为 dp[ i][ j] 只依赖于 dp[ i-1][ j-1 ],我们可以只保留上一行,但要注意遍历顺序。 伪代码 : 但这里 dp[ j ] 是二维压缩成一维后的混淆,实际需用二维滚动,细节略。 第九步:最终简洁的 DP 统计 我们换用: dp[l][i] 表示从 i 开始长度为 l 的子串是否重复出现。 转移:若 S[i..i+l-1] == S[j..j+l-1] 则重复。 但这样还是 O(n^3)。 实际上,这个问题常用后缀自动机解决,但题目标签是线性 DP,我们给出一个 O(n^2) 的 DP 解法: 定义 len[i][j] 表示以 i 和 j 开头的最长公共前缀长度,从后往前推: 然后,对于每对 (i,j) 且 i<j,如果 len[i][j] = L > 0 ,则对于每个长度 1..L,子串 S[ i..i+k-1 ] 至少出现在 i 和 j 两个位置,把这些子串加入集合。 去重 :用哈希集合存储子串的 (起始位置 i, 长度 k),但注意不同 (i,k) 可能对应相同子串,所以必须用字符串哈希来唯一标识。我们直接用字符串本身存入集合(如果 n 较小,如 <=2000,子串总数 O(n^2),每个平均 O(n),总体 O(n^3) 内存会大,但 n=1000 可行)。 为了优化,我们可以在发现 L>0 时,直接把对应子串的哈希值存入集合。 第十步:实例验证 S = "ababa" 计算 len[ i][ j] 矩阵(只考虑 i <j): i=0, j=2: S[ 0]=a, S[ 2]=a, len=1+len[ 1][ 3 ]=1+? 先算 len[ 1][ 3]: S[ 1]=b, S[ 3]=b, len=1+len[ 2][ 4 ] len[ 2][ 4]: S[ 2]=a, S[ 4]=a, len=1+len[ 3][ 5 ]=1+0=1 所以 len[ 1][ 3 ]=1+1=2 所以 len[ 0][ 2 ]=1+2=3 这样得到子串: (0,2) 长度 1..3: "a","ab","aba" (1,3) 长度 1..2: "b","ba" (0,3) 长度 1..1: "a"(已有) (1,4) 长度 1..2: "b","ba"(已有) (0,4) 长度 1..1: "a"(已有) (2,4) 长度 1..3: "a","ab","aba"(但注意 (2,4) 的 "ab" 是从 S[ 2..3] 吗?检查 S[ 2..4]="aba",长度 1..3 是 "a","ab","aba",其中 "ab" 是 S[ 2..3],但 S[ 0..1 ] 也是 "ab",所以是重复。 把所有不重复的收集: 长度1: "a","b" 长度2: "ab","ba" 长度3: "aba","bab" 共 6 个,符合示例。 第十一步:算法复杂度 时间 O(n^2) 计算 len 数组,统计子串时避免重复可用哈希,总 O(n^2)。 空间 O(n^2) 或优化为 O(n) 如果只保留一行,但统计子串需要存储结果,最坏 O(n^2) 子串。 总结 本题虽然可以用后缀自动机高效解决,但“线性 DP” 范畴下,我们通过 二维 LCP 的 DP 计算 ,得到所有重复子串,并用哈希集合去重,最终得到不同重复子串的个数。核心是 len[i][j] 的递推与子串提取去重。