文章目录
- 1044. 最长重复子串
- 前言
- 思路
- Version 1:暴力
- Version 2:引入二分,优化 O ( n 2 ) O(n^2) O(n2)
- Version 3:引入自定义哈希,优化字符串比较
- Version 4:计算所有字符串的哈希值
- Version 5:引入无符号长整型,自动取模
- Version 6:引入双哈希,大大降低哈希碰撞
- 总结
- 代码
- 主要参考资料
1044. 最长重复子串
困难
给你一个字符串 s ,考虑其所有 重复子串 :即 s 的(连续)子串,在 s 中出现 2 次或更多次。这些出现之间可能存在重叠。
返回 任意一个 可能具有最长长度的重复子串。如果 s 不含重复子串,那么答案为 “” 。
示例 1:
输入:s = “banana”
输出:“ana”
示例 2:
输入:s = “abcd”
输出:“”
题目 End…
前言
这个解法我是学习了其他大佬的题解后,我自己琢磨出的一个比较好理解的版本,但是效率并没有那么高,并且这个解法有一定误判的可能(并且官网题解也可能误判:官网使用随机数来取模 + 随机数作为进制,并且保证这两随机数的大小,所以保证了误判的概率会非常非常小,但是如果对这个随机数予以定值,仍然有可能误判,大家可以试试。个人浅见,如果有错误欢迎指出问题)
所以我写的这个解法主要有两个意义:
- 可以以较低的成本加深大家对字符串哈希算法的理解,并且可以 完成并通过 这个题目
- 看了这篇文章之后,再去看官网的或者其他大佬写的更高效的版本可能会有帮助
思路
Version 1:暴力
首先这个是最容易想到的版本,枚举所有的子字符串,并加入哈希表中,进行判重
class Solution {
public:
string longestDupSubstring(string s) {
unordered_map<string, int> mp;
string res;
for (int i = 0; i < s.size(); i ++) {
for (int len = 1; i + len <= s.size(); len ++) {
string sub = s.substr(i, len);
if (++ mp[sub] >= 2 && sub.size() > res.size()) {
res = sub;
}
}
}
return res;
}
};
但是很明显会超时,第 19 个用例就无法通过。这里有几个问题可以尝试优化
- 是不是不需要遍历所有长度的子字符串?来优化掉这个 O ( n 2 ) O(n^2) O(n2)?
- 在数据量非常庞大的情况下,
unordered_map
可能存在性能退化的问题,在哈希冲突的情况下,时间复杂度可能会退化为 l o g 2 n log_2n log2n - 其次,如果发生了哈希冲突,又需要针对哈希表红黑树结点内存值来比较 K e y Key Key 是否相等 —— 存在字符串比较,如果一个字符串长度是 3 ∗ 1 0 4 3 * 10^4 3∗104,那其实性能开销还是不小的
Version 2:引入二分,优化 O ( n 2 ) O(n^2) O(n2)
这个其实不难理解:
- 如果存在一个字符串长度为 n,并且重复了,那么一定存在重复的、长度为 n - 1 的字符串
- 如果不存在一个字符串长度为 n 的重复字符串,那么一定不存在重复的、长度为 n + 1 的字符串
举个例子
b
a
n
a
n
a
banana
banana,明显最长重复子字符串为
a
n
a
ana
ana
- 存在 a n a ana ana 长度为 3 的重复字符串,那么一定存在 a n an an 长度为 2 的子字符串(或者 n a na na)
- 如果不存在长度为 4 的重复字符串,那么一定不存在长度为 5 的重复子字符串
所以这里就可以通过二分查找的方式 ′ 猜答 案 ′ '猜答案' ′猜答案′,思路有点像二分答案。如果猜大了,并且不存在这个长度的重复字符串,那么说明一定没有更大的重复字符串。
Version 3:引入自定义哈希,优化字符串比较
首先明确一点:如果有两个字符串 a a a, b b b,那么就算不讨论哈希带来的消耗,在我们将 a a a 放入哈希表后,再尝试插入 b b b,如果发生哈希冲突了怎么办?
会遍历哈希桶链表(甚至红黑树),逐个结点判断是否存在重复 K e y Key Key 值,即就是 a a a 会和 b b b 发生逐字节比较。
- 如果 a a a 和 b b b 长度很长呢?
- 如果发生哈希冲突的不止一个结点呢?还有 c c c, d d d, e e e 字符串,并且他们长度都很长呢?
所以我们需要针对这里的字符串制定更高效的哈希方式:
假设现在有两个字符串, " a b c " "abc" "abc" (称为 A), " b c d " "bcd" "bcd"(称为 B)
- 如果我们把他们想象成一个整数?假设
a = 1, b = 2, c = 3, d = 4
- 那么 A 字符串,就可以是: 1 ∗ 100 + 2 ∗ 10 + 3 = 123 1 * 100 + 2 * 10 + 3 = 123 1∗100+2∗10+3=123
- 那么 B 字符串,就可以是: 2 ∗ 100 + 3 ∗ 10 + 4 = 234 2 * 100 + 3 * 10 + 4 = 234 2∗100+3∗10+4=234
- 那么这两个字符串的比较是否就变成了整数的比较?成百上千个字节的比较可以直接优化成 4或8 个字节的比较
但是显然小写字母有 26 个,所以这里的进制不能是 10,可以定成 31 进制,32 进制等,但是最好定成 31、67 这些质数(有利于降低哈希冲突,提高性能,具体设为多少更合适那就是专业人员做的事了,我不知道)
那么如果不考虑数据范围,那么我们是不是就根本不用考虑哈希冲突了?没错,因为字符串越长,这个哈希值一直都是递增的,只要数据范围装得下,除非字符串相等,否则永远都不会碰撞,效率嘎嘎高。可惜的是我们需要考虑数据范围,但是这个问题等会再说
Version 4:计算所有字符串的哈希值
先解决一个问题,如果我们要计算长度为 3 的字符串哈希值,比如上面的 a b c abc abc,那还是很好操作的,那么如果现在有字符串 a b c d e f g abcdefg abcdefg,我们怎么高效地计算所有长度为 3 的字符串哈希值呢?
- 上面说了 a b c abc abc = 1 ∗ 100 + 2 ∗ 10 + 3 = 123 1 * 100 + 2 * 10 + 3 = 123 1∗100+2∗10+3=123
- 那么如果我们要计算 b c d bcd bcd 呢?我们是不是要舍弃 a a a 字母,也就是减少 a ∗ 100 a * 100 a∗100 呢?
- 好,现在减少了 a ∗ 100 a * 100 a∗100,但是现在 b b b 变成了百位, c c c 变成了个位,是不是需要 ( 2 ∗ 10 + 3 ) ∗ 10 (2 * 10 + 3) * 10 (2∗10+3)∗10 呢?也就是 【乘上进制】
- 最后加上 d d d
OK,于是就可以通过这种滑动窗口的方式, O ( n ) O(n) O(n) 复杂度计算所有长度为 3 的子字符串
Version 5:引入无符号长整型,自动取模
为了防止数据溢出,我们就可以引入取模,来防止数据溢出,但是为了简化程序,简化思路,这里可以使用 unsigned long long
,无符号长整形,自带取模效果。
Version 6:引入双哈希,大大降低哈希碰撞
但是这样问题又来了,如果取模了,那么原本递增、很大的数据有可能突然特别小,以至于和前面已经哈希的值发生碰撞,那是否重复就不好说了
于是引入二次哈希,我们使用另一个不同的进制来计算该字符串的哈希值。如果字符串 A A A,字符串 B B B 两次哈希值都一样,那么我们认为字符串相等,所以可能存在误判,但是概率极小。
总结
- 二分答案,猜最长重复子串的长度,并验证
- 用数字表示字符串,高效进行哈希
- 防止溢出,自动取模,使用
unsigned long long
- 取模后导致哈希冲突,使用双哈希降低冲突概率
- (ps:
pair<ULL, ULL>
不支持哈希,故使用set
)
代码
class Solution {
public:
constexpr static int P1 = 31; // 31 进制
constexpr static int P2 = 67; // 67 进制
// 最长长度的重复子串
string longestDupSubstring(string s) {
string res ;
// 二分答案
int left = 0, right = s.size() - 1;
while (left <= right) {
// +1 即向上取整,向上取整可以防止 guess_len = 0,比如 left = 0, right = 1
int guess_len = left + ((right - left + 1) >> 1);
string search = find(s, guess_len); // 开始找有没有这个长度的重复子字符串
if (search.size() == 0) { // 没找到捏
right = guess_len - 1;
}
else { // 找到了
left = guess_len + 1;
}
if (search.size() > res.size()) res = search;
}
return res;
}
using ULL = unsigned long long;
using PULL = pair<ULL, ULL>; // pair<ULL, ULL> 用来存储两次哈希值
string find(const string& s, int len) {
// 通过滑动窗口的方式来计算所有子串的 哈希值,ULL 会自动帮我们取模
ULL hash1 = 0, hash2 = 0;
ULL base1 = 1, base2 = 1; // 当前 len 的最大进制,用于窗口右移的删减
set<PULL> vis; // 判重
for (int i = 0; i < len; i ++) {
hash1 = (hash1 * P1) + s[i];
hash2 = (hash2 * P2) + s[i];
base1 *= P1; // 计算最高位,比如上面 abc 中的 100,自己模拟一次就明白了
base2 *= P2;
}
vis.insert({hash1, hash2}); // 插入两次哈希值
for (int i = len; i < s.size(); i ++) {
hash1 = (hash1 * P1 + s[i]) - base1 * s[i - len];
hash2 = (hash2 * P2 + s[i]) - base2 * s[i - len];
if (vis.count({hash1, hash2})) {
return s.substr(i - len + 1, len);
}
vis.insert({hash1, hash2});
}
return "";
}
};
主要参考资料
【字符串哈希】字符串哈希入门(宫水三叶)
【微扰理论】Rabin-Karp + 二分搜索