直接枚举所有子串是平方级候选,关键转折是把最优化问题拆成判定问题:
这个判定具有单调性:长度 length 可行,则所有更短长度都可行(取两次出现的相同前缀即可);长度 length 不可行,则更长长度也不可行。因此可以二分最大可行长度。
对每个固定长度,用滚动哈希在线性时间内扫描全部定长窗口;哈希相同后再逐字符比较,排除哈希碰撞。
一句话记忆:二分长度,滚动窗口算哈希,哈希相等后校验真实字符。
给定字符串 s,找出任意一个最长重复子串。重复子串必须是连续的,并且在 s 中至少出现两次;两次出现可以重叠。
示例 1:
"ana" 分别从下标 1 和 3 开始,两次出现有重叠,这是合法的。
示例 2:
题目约束:
2 <= s.length <= 3 × 10^4。s 只包含小写英文字母。定义判定命题:
它具有前缀单调性:
length 前缀会构成反例。所以要找的是最后一个使 P(length) 为真的长度,这正是二分答案的标准形态。
注意本题不是"左右指针按窗口合法性伸缩"的典型滑动窗口。固定长度后,候选确实是依次右移一位的定长窗口,但让搜索范围减半的是答案长度的单调性,而不是窗口伸缩。
使用闭区间 [left, right]:
left / right:尚未排除的最小、最大候选长度。bestLength / bestStart:已确认存在重复子串的最大长度及某次出现的起点。最长重复子串不可能等于整个字符串(长度 n 只有一个起点),所以初始区间为 [1, n - 1];长度 0 对应默认答案 "",不参与搜索。
每轮检查 mid:
bestLength = mid、bestStart = 当前重复窗口起点,然后 left = mid + 1,继续找更长的。right = mid - 1,由单调性排除 mid 及所有更长长度。二分结束后返回 s.slice(bestStart, bestStart + bestLength);若没有任何非空重复子串,bestLength 保持 0,自然返回 ""。
findDuplicateStart(length) 要回答"长度为 length 的子串是否出现两次"。朴素做法是把每个窗口字符串存入集合,但窗口内容比较和存储都是 O(length)。滚动哈希把窗口压成一个整数,每次右移只做常数次算术。
字符编码为 1~26,以 BASE = 27 构造长度为 length 的窗口哈希:
所有运算对 MOD 取模。窗口从起点 start - 1 右移到 start 时:
BASE^(length - 1)(最高位贡献)。BASE,相当于整体左移一位。内容相同的窗口哈希一定相同,但哈希相同不代表内容相同——取模必然存在碰撞。因此哈希表不能只记录"这个哈希出现过",而要保存该哈希对应的所有历史起点:
只有精确比较通过后才能更新 bestStart / bestLength,否则可能把碰撞当成答案。这一步使结果不依赖"哈希绝不碰撞"的假设。
以 s = "banana" 为例,n = 6:
| 二分区间 | mid | 长度为 mid 的窗口 | 判定 | 更新 |
|---|---|---|---|---|
[1, 5] | 3 | "ban"、"ana"、"nan"、"ana" | "ana" 重复 | 记录长度 3,搜索 [4, 5] |
[4, 5] | 4 | "bana"、"anan"、"nana" | 无重复 | 搜索区间缩为 [4, 3] |
搜索结束,最长重复子串长度为 3,返回 "ana"。
思路参考:JoshCrozier/leetcode-javascript。本文重新整理了二分状态和滚动窗口,并保留哈希碰撞后的精确比较;原项目采用 MIT License。
MOD 以内的哈希值与 BASE = 27 相乘后仍远小于 JavaScript 的最大安全整数 2^53 - 1,所以上述乘法不会先发生整数精度丢失。
| 代码 | 作用 |
|---|---|
left = 1, right = n - 1 | 二分所有可能的非空答案长度 |
findDuplicateStart(mid) | 判定问题:长度 mid 是否可行 |
highestPower | 移出窗口字符的最高位权重 BASE^(length - 1) |
startsByHash | 按哈希分组保存历史窗口起点 |
sameSubstring(...) | 逐字符校验,排除哈希碰撞 |
bestStart, bestLength | 保存当前已确认的最长答案 |
findDuplicateStart(length) 按起点顺序扫描每个长度为 length 的窗口。滚动公式与完整多项式哈希等价,所以同一内容的窗口必然进入同一个哈希桶。
当当前窗口与桶内某个历史窗口逐字符相等时,它们起点不同且内容完全相同,确实找到了重复子串,不会误报。反过来,任意出现至少两次的长度 length 子串,其后一次出现被扫描时,一定能在对应哈希桶中找到前一次起点并通过逐字符比较,不会漏报。
由前缀单调性,可行长度构成连续前缀区间。二分在可行时向右搜索、不可行时向左搜索,最终记录的 bestLength 就是最大可行长度,返回的子串即最长重复子串。
"abcd" 应返回 ""。"aaaaa" 的答案是 "aaaa",两次出现允许重叠。"aa" 返回 "a","ab" 返回 ""。n - 1。left = mid + 1。BASE^(length - 1),不是 BASE^length。% 可能保留负号,减去旧字符后要先加 MOD 再取模。Set 保存哈希并直接返回;必须校验真实内容,或者明确接受概率正确的双哈希方案。设 n = s.length。
在没有意外哈希碰撞,或者把哈希操作视为期望常数时间时:
O(n) 个窗口。O(log n) 次检查。O(n log n)。O(n),用于字符编码、哈希表和窗口起点。精确字符比较保证了答案一定正确,但也意味着最坏时间不能无条件写成 O(n log n)。对某个长度 length,设相同哈希桶内实际执行的字符比较总成本为 C_length,则总时间更准确地写成:
如果人为构造大量哈希碰撞,同一轮可能比较平方级窗口对,每次又最多比较 O(n) 个字符,保守的确定性最坏上界会明显高于 O(n log n)。实际题目中单个大质数哈希通常可以通过;若需要更稳定的性能,可以使用双哈希降低碰撞概率,或改用确定性的后缀数组。
同时维护两组不同模数的哈希,只有两个哈希都相同时才认为是候选。碰撞概率会大幅降低,通常可以省略逐字符比较并获得期望 O(n log n) 时间,但理论上仍然是概率算法。
把所有后缀排序后,任意重复子串一定是某两个后缀的公共前缀;最长重复子串就是相邻后缀的最大最长公共前缀。
O(n log² n)。O(n log n)。O(n) 时间计算 LCP 数组。这种方案确定性更强,但实现长度和调试成本都高于二分加滚动哈希。
后缀自动机也能在线性状态规模内统计子串出现次数,并找出最长重复子串。渐进复杂度优秀,但状态构建、出现次数传播和答案恢复更复杂,通常不作为面试中的第一实现。
因为“存在长度为 length 的重复子串”具有前缀单调性:可行长度的所有更短长度都可行,不可行长度的所有更长长度都不可行。
n - 1?长度为 n 的子串只有整个字符串一个起点,不可能出现两次;长度 n - 1 则可能在全相同字符串中从起点 0 和 1 重叠出现。
新旧窗口的大部分字符相同。减去旧首字符的最高位贡献,将剩余部分乘以基数,再加入新尾字符,就能从旧哈希得到新哈希。
取模会把大量不同字符串映射到有限的哈希值,理论上必然存在碰撞。真实字符比较可以排除碰撞,使算法不会返回内容不同的两个窗口。
某个历史起点可能只是与当前窗口发生哈希碰撞。如果比较失败就覆盖或丢弃其他起点,之后可能漏掉同一哈希桶中真正相同的窗口。
不会。题目允许重叠,算法只要求两个起点不同,不限制它们的距离。例如 "aaaaa" 中起点 0 和 1 的 "aaaa" 就是合法答案。
O(n log n)?滚动计算本身是线性的,但碰撞桶中的逐字符校验可能产生额外成本。正常哈希分布下碰撞很少,时间接近 O(n log n);极端碰撞下会退化。
当题目要求确定性的复杂度保证、需要回答多次后缀或公共前缀查询,或者输入可能针对固定哈希构造时,后缀数组更合适;代价是实现明显更复杂。
BASE^(length - 1)。"aaaaa" 的答案 "aaaa"。O(log n),或者忽略碰撞校验直接宣称确定性的 O(n log n)。先只回答第 1 题,再展开后续问题:
5 可行时,长度 1~4 一定都可行?"aaaaa" 中,为什么长度 4 的答案不违反“两次出现”的要求?length 时,从窗口起点 i - 1 滚动到 i 的哈希公式。