题目简述:
给你一个字符串
s,考虑其所有 重复子串 :即s的(连续)子串,在s中出现 2 次或更多次。这些出现之间可能存在重叠。返回 任意一个 可能具有最长长度的重复子串。如果
s不含重复子串,那么答案为""。
题目链接:1044. 最长重复子串
这是我最推荐的方案,性能很好的同时代码也不会弯弯绕绕。本文的其他算法已经在事实上远不是开发岗位需要掌握的程度了,理论和代码上复杂度都很高。
考虑 Rabin–Karp 算法编码,对每个固定长度 1_000_000_007L 与 1_000_000_009L,应满足
如果希望降低哈希碰撞的概率,可用考虑双模哈希校验,选择两对
对于一个确定的
为了以
如果存在一个长度为
算法的时间复杂度为
可以看到,针对本问题字符串哈希 + 二分查找的表现已经十分亮眼了。后文的方案性能上均十分强劲,但都没有本方案简单好写。
算法中因为利用了哈希判等,因此不属于完全确定性的算法——至少,是否产生哈希碰撞与
class Solution {
private static final Random rand = new Random("Hoshino Ichika".hashCode());
public static final long M1 = 1_000_000_007L;
public static final long M2 = 1_000_000_009L;
public static final long B1 = rand.nextLong(2, M1);
public static final long B2 = rand.nextLong(2, M2);
public long[] POW_B1;
public long[] POW_B2;
public String longestDupSubstring(String s) {
int n = s.length();
POW_B1 = new long[n + 1];
POW_B2 = new long[n + 1];
POW_B1[0] = 1;
POW_B2[0] = 1;
for (int i = 1; i <= n; i++) {
POW_B1[i] = mod((POW_B1[i - 1] * B1), M1);
POW_B2[i] = mod((POW_B2[i - 1] * B2), M2);
}
long[] preHash_1 = buildPrefixHash(s, M1, B1);
long[] preHash_2 = buildPrefixHash(s, M2, B2);
/* 二分搜索子串长度 L */
int lo = 1;
int hi = s.length() - 1;
int loc = -1;
while (lo <= hi) {
int L = lo + (hi - lo) / 2;
int tmp = findDupSubstring(s, preHash_1, preHash_2, L);
if (tmp == -1) {
hi = L - 1;
} else {
lo = L + 1;
loc = tmp;
}
}
if (loc == -1) return "";
return s.substring(loc, loc + hi);
}
/* 1-based */
public long[] buildPrefixHash(String s, long M, long B) {
long[] prefixHash = new long[s.length() + 1];
for (int i = 1; i <= s.length(); i++) {
prefixHash[i] = mod(prefixHash[i - 1] * B + s.charAt(i - 1), M);
}
return prefixHash;
}
/* 利用前缀哈希数组,查询 [i, i + L) 上子串的哈希值 */
public long hash(long[] prefixHash, long M, long[] powB, int i, int L) {
return mod(prefixHash[i + L] - prefixHash[i] * powB[L], M);
}
/* 返回重复子串的起始索引,如果不存在重复子串则约定返回 -1 */
public int findDupSubstring(String s, long[] preHash_1, long[] preHash_2, int L) {
Set<Long> hashes = new HashSet<>((int) ((s.length() - L + 1) / 0.75f) + 1);
for (int i = 0; i <= s.length() - L; i++) {
long h1 = hash(preHash_1, M1, POW_B1, i, L);
long h2 = hash(preHash_2, M2, POW_B2, i, L);
long key = (h1 << 32) | (h2 & 0xffffffffL);
if (hashes.contains(key)) return i;
hashes.add(key);
}
return -1;
}
private long mod(long x, long m) {
return (x % m + m) % m;
}
}补充阅读:二分搜索 + 哈希(Rabin-Karp) ❌ 后缀数组 ✔
首先得到所有后缀,记后缀数组为 SA,对 SA 按字典序排序。
定义 LCP 数组为排序后 SA 中相邻后缀的最长公共前缀长度,例如 lcp[i] 表示排序后 sa[i] 与 sa[i+1] 的最长公共前缀长度,最后 LCP 中的最大值即为最长字符串的长度,对应的 SA 中的元素即为最长重复子串。
仔细想想,为什么这样能够保证得到最长重复子串呢?设某个重复子串在 s 中出现了至少两次,那么它必然分别是至少两个后缀的公共前缀。将所有后缀按字典序排序后,拥有该公共前缀的一段后缀会在后缀数组中形成一个连续区间,而这段区间内必然存在某对相邻后缀,它们的 LCP 至少等于这个重复子串的长度。因此,只需要考察排序后相邻后缀的最长公共前缀长度,取其中最大值,就能得到全局最长重复子串。
朴素的做法是真的存储所有后缀再排序,接着再对每两个相邻的排序后后缀双指针寻找最长公共前缀的长度,但这样做的话时间复杂度为
要避免超时,务必解决下面三个关键问题:
-
对于 SA,我们不必真的存储所有的后缀,存储所有后缀的索引即可,于是空间开销降至
$O(n)$ ; -
对 SA 排序时不要像这样重写比较器:
Integer[] sa = new Integer[n]; for (int i = n - 1; i >= 0; i--) sa[i] = i; Arrays.sort(sa, (a, b) -> { while (a < n && b < n) { if (s.charAt(a) > s.charAt(b)) { return 1; } else if (s.charAt(a) < s.charAt(b)) { return -1; } else { a++; b++; } } return a == n ? -1 : 1; });
这必然导致排序需要付出
$O(n^2\log n)$ 的时间开销代价,必然超时。应考虑基数排序,比较容易实现的方案是考虑倍增法,步骤为:
- 初始化秩数组
rank、计数桶尺寸size与计数桶数组cnt,rank可以用后缀首字符值或递增序列初始化,size初始化为rank值域宽度加一; - 在倍增主循环
k = 1, 2, 4, ...中维护每轮循环的二元键序,定义key[i] = (rank[i], rank[i+k]),如果i + k索引越界则视rank[i+k] = -1,含义为小于任何合法秩; - 对二元键序中的第二个值进行排序,
- 首先遍历所有二元键序的第二个值,用计数桶
cnt统计值出现的频数; - 计算
cnt的前缀和数组,可以就地直接修改cnt,即cnt[q] += cnt[q-1]; - 根据
cnt倒着放置元素,索引i从n - 1遍历至0,令idx = sa[i],取第二关键字sec = (idx + k < n) ? (rank[idx + k] + 1) : 0,执行tmpSA[--cnt[sec]] = idx;该过程为稳定放置(后出现者先占位)。
- 首先遍历所有二元键序的第二个值,用计数桶
- 对二元键序中的第一个值进行排序(同样使用稳定计数排序),
- 将计数桶清零,遍历
tmpSA,统计第一关键字频数:fir = rank[idx] + 1,做cnt[fir]++; - 对
cnt做前缀和; - 倒序稳定放置回
sa:从i = n - 1至0,idx = tmpSA[i],fir = rank[idx] + 1,执行sa[--cnt[fir]] = idx。
- 将计数桶清零,遍历
- 依据新顺序重标秩,
- 设
tmpRank[sa[0]] = 0,classes = 1; - 对
i = 1..n-1:令a = sa[i-1],b = sa[i],若(rank[a] != rank[b]) || ( (a + k < n ? rank[a + k] : -1) != (b + k < n ? rank[b + k] : -1) ),则classes++;随后令tmpRank[b] = classes - 1; - 交换引用:
rank ←→ tmpRank。
- 设
- 维护桶尺寸并判断是否提前结束,
- 若
classes == n,说明所有后缀秩已互异,排序完成,跳出主循环; - 否则更新计数桶尺寸
size = classes + 1,并将cnt[0..size-1]清零,为下一轮计数排序做准备。
- 若
- 将窗口翻倍,令
k <<= 1,回到步骤 3–6 继续下一轮; - 主循环结束后,
sa即为目标后缀数组;如需LCP,可在O(n)时间内用 Kasai 算法计算:构造pos[sa[i]] = i,用复用指针h作为下界,依次填充lcp[pos[i]],每轮结束若h > 0则令h--。
- 初始化秩数组
-
对于 LCP,可以用严格
$O(n)$ 时间开销的 Kasai 算法代替最坏$O(n^2)$ 时间复杂度的朴素双指针:- 按原字符串的索引进行遍历,假设对原字符串中以
s[i]开头的后缀排序后在 SA 中的顺序是k,初始化已知公共前缀长度m = 0; - 如果
k是最大的字典序,即不存在右邻居,则重设m = 0,并且跳过本轮循环; - 否则,取右邻居后缀的起点
j = sa[k + 1],现在可以肯定这两个相邻字典序的后缀数组已经拥有长为m的公共前缀,于是从两个后缀的第m + 1位开始比较s[i+m]与s[j+m],如果匹配则m++并继续比较,直到指针越界或不再匹配; - 更新
lcp[k] = m,如果m > 0则令m--(意味着去掉首字符后公共前缀至多减少 1),然后为下一轮计算提供已知公共前缀长度的下界。
- 按原字符串的索引进行遍历,假设对原字符串中以
可以看到,本方案异常繁琐,仅是以上三点就非常复杂了。因此我最推荐字符串哈希 + 二分查找的方案,性能优秀的同时代码也不难编写。
我们所实现的算法时间复杂度为
但即便是
如果将排序算法改为 DC3 等算法,则时间复杂度能够像 SAM 一样达到线性复杂度
class Solution {
public String longestDupSubstring(String s) {
int n = s.length();
int[] sa = new int[n];
sortSA(s, sa);
int[] mapCharToLexiOrder = new int[n]; // 将 s[i] 映射至对应后缀的字典序
for (int i = 0; i < n; i++) {
mapCharToLexiOrder[sa[i]] = i;
}
int[] lcp = new int[n];
String res = kasai(s, lcp, sa, mapCharToLexiOrder);
return res;
}
/* 倍增法基数排序,可以用朴素双指针重写 Integer[] 的比较器,但这样最坏时间复杂度将达到 O(n²log n) */
/* 可以替换为更高级的 DC3 等算法,能够做到线性时间复杂度 O(n) */
public void sortSA(String s, int[] sa) {
int n = s.length();
if (n == 0) return;
for (int i = 0; i < n; i++) sa[i] = i;
int[] rank = new int[n];
int maxChar = 0;
for (int i = 0; i < n; i++) {
rank[i] = s.charAt(i);
if (rank[i] > maxChar) maxChar = rank[i];
}
int[] tmpSA = new int[n];
int[] tmpRank = new int[n];
final int MAX = Math.max(n, maxChar + 1) + 2;
int[] cnt = new int[MAX];
int classes = maxChar + 1;
for (int k = 1; k < n; k <<= 1) {
int K = classes + 1;
Arrays.fill(cnt, 0, K, 0);
for (int i = 0; i < n; i++) {
int idx = sa[i];
int sec = (idx + k < n) ? (rank[idx + k] + 1) : 0;
cnt[sec]++;
}
for (int i = 1; i < K; i++) cnt[i] += cnt[i - 1];
for (int i = n - 1; i >= 0; i--) {
int idx = sa[i];
int sec = (idx + k < n) ? (rank[idx + k] + 1) : 0;
tmpSA[--cnt[sec]] = idx;
}
java.util.Arrays.fill(cnt, 0, K, 0);
for (int i = 0; i < n; i++) cnt[rank[i] + 1]++;
for (int i = 1; i < K; i++) cnt[i] += cnt[i - 1];
for (int i = n - 1; i >= 0; i--) {
int idx = tmpSA[i];
sa[--cnt[rank[idx] + 1]] = idx;
}
tmpRank[sa[0]] = 0;
int newClasses = 1;
for (int i = 1; i < n; i++) {
int a = sa[i - 1], b = sa[i];
int ra1 = rank[a], rb1 = rank[b];
int ra2 = (a + k < n) ? rank[a + k] : -1;
int rb2 = (b + k < n) ? rank[b + k] : -1;
if (ra1 != rb1 || ra2 != rb2) newClasses++;
tmpRank[b] = newClasses - 1;
}
int[] t = rank; rank = tmpRank; tmpRank = t;
classes = newClasses;
if (classes == n) break;
}
}
/* 也可以替换为逻辑更简单的朴素双指针,但这样最坏时间复杂度将达到 O(n²) */
public String kasai(String s, int[] lcp, int[] sa, int[] mapCharToLexiOrder) {
int n = s.length();
int m = 0; // 已知公共前缀长度
int longestLen = 0;
int longestStart = -1;
for (int i = 0; i < n; i++) {
int k = mapCharToLexiOrder[i];
if (k == n - 1) {
m = 0;
continue;
}
int j = sa[k + 1];
while (i + m < n && j + m < n) {
if (s.charAt(i + m) == s.charAt(j + m)) m++;
else break;
}
lcp[k] = m;
if (m > longestLen) {
longestLen = m;
longestStart = i;
}
if (m > 0) m--;
}
if (longestLen == 0) return "";
return s.substring(longestStart, longestStart + longestLen);
}
}主要理论参考:后缀自动机 (SAM)
一些观点补充:后缀自动机 学习笔记
在 SAM 的 DAG 模型中出现过两次以上的子串即为重复子串,取其中最长的即为问题的解。
class SAM {
static final int ALPHA = 26;
int[][] next; // 转移
int[] link; // 后缀链接
int[] len; // 到该状态的最长串长度
int[] firstpos; // 该状态覆盖的最长串的一个右端点位置
long[] occ; // endpos 集合大小(出现次数)
int last; // 表示整个串的“末尾状态”
int sz; // 状态数
public SAM(int n) {
int cap = 2 * n + 5;
next = new int[cap][ALPHA];
for (int i = 0; i < cap; i++) Arrays.fill(next[i], -1);
link = new int[cap];
Arrays.fill(link, -1);
len = new int[cap];
firstpos = new int[cap];
Arrays.fill(firstpos, -1);
occ = new long[cap];
sz = 1; // 初始化状态 0
last = 0;
}
public void extend(int c, int pos) {
int cur = sz++;
len[cur] = len[last] + 1;
firstpos[cur] = pos;
occ[cur] = 1; // 新增结尾
int p = last;
while (p != -1 && next[p][c] == -1) {
next[p][c] = cur;
p = link[p];
}
if (p == -1) {
link[cur] = 0;
} else {
int q = next[p][c];
if (len[p] + 1 == len[q]) {
link[cur] = q;
} else {
int clone = sz++;
// 克隆 q
System.arraycopy(next[q], 0, next[clone], 0, ALPHA);
len[clone] = len[p] + 1;
link[clone] = link[q];
firstpos[clone] = firstpos[q];
occ[clone] = 0; // 克隆不新增结尾
while (p != -1 && next[p][c] == q) {
next[p][c] = clone;
p = link[p];
}
link[q] = link[cur] = clone;
}
}
last = cur;
}
// 计数排序:按 len 从小到大顺序
public int[] orderByLen() {
int maxLen = 0;
for (int i = 0; i < sz; i++) maxLen = Math.max(maxLen, len[i]);
int[] cnt = new int[maxLen + 1];
for (int i = 0; i < sz; i++) cnt[len[i]]++;
for (int i = 1; i <= maxLen; i++) cnt[i] += cnt[i - 1];
int[] ord = new int[sz];
for (int i = sz - 1; i >= 0; i--) ord[--cnt[len[i]]] = i;
return ord;
}
// occ 沿 suffix link 从子到父汇总
public void accumulateOcc() {
int[] ord = orderByLen();
for (int i = sz - 1; i >= 0; i--) {
int v = ord[i];
if (link[v] != -1) occ[link[v]] += occ[v];
}
}
}
class Solution {
public String longestDupSubstring(String s) {
int n = s.length();
if (n <= 1) return "";
SAM sam = new SAM(n);
for (int i = 0; i < n; i++) {
sam.extend(s.charAt(i) - 'a', i);
}
sam.accumulateOcc();
int bestLen = 0;
int bestPos = -1;
for (int v = 0; v < sam.sz; v++) {
if (sam.occ[v] >= 2 && sam.len[v] > bestLen) {
bestLen = sam.len[v];
bestPos = sam.firstpos[v];
}
}
if (bestLen == 0) return "";
return s.substring(bestPos - bestLen + 1, bestPos + 1);
}
}难度太大,这里就不考虑了。

