原题链接:1044.最长重复子串
出现形式:原题重现
原解
宫水三叶的题解
1、通过字符串哈希建立子串的唯一标识;
2、尝试一下以len为最大长度的子串是否重复出现
3、如果len为最大长度可以,那么增加最大长度,继续尝试,否则减小最大长度继续尝试。
概念: 字符串哈希相当于用一个数字去代表一个字符串,并且唯一对应。
字符串哈希的详细介绍
构造方法:
思路:模拟十进制。
例如知道十位是2,个位是3,我们可以通过2 × 10 + 3 = 23,知道构成的值是23。类似的,如果我们知道字符串第一位(从左向右数)是a,第二位b,我们也可以使用同样的思路去构建字符串ab的值。
如果用这种思路,那么有两个关键的值我们需要知道,一个是底数,一个是字母所在的位数。比如十进制中的底数我们知道要用10,位数有十位、百位,底数基本上确定了就不动了,所以只需要确认位数。在10进制中,通过乘以10的次方来判断,比如个位是10的0次,十位是10的1次,以此类推。所以这里也模仿就行。通过乘以底数的次方来确定位置,类似于10进制,左为高位,右为低位。最右边的位数的底数次方为0,从右到左依次递增。那么下面就看下两个关键值都是如何确定的。
在教程中说的是,底数我们一般 用素数 ,至于取哪个素数自己看情况就行,先随便取一个。
比如我在原题解的代码中看到的底数是1313131,我一开始就不知道怎么就突然来一个1313131了,我换成别的是不是也可以,比如24(一开始还不知道只能是素数),看了一下作者在另一篇的解释才知道,他怎么知道是1313131呢?就是试一下,因为取小了没法通过所有用例,至于为什么呢?我还没有弄太清楚。后来我自己试了一下小一点的素数,确实是没有问题的,但是再小一点,确实就出现无法全部通过的情况。
实现方法: 用数组p[i],保存是底数的i次方,根据前面的解释,最低(右)位p[0] = 1,即底数Base的0次方
例子: 对于字符串abc,取Base为5,则:
p[0] = 50 = 1;
p[1] = 51 = p[0] ×5 = 5;
p[2] = 52 = p[1] ×5 = 25;
p[3] = 53 = p[2] ×5 = 125;
关键点:
关键点:
这里也是跟数组p一样,需要直到数组长度和元素值的计算,因为我们直到h[0]的值,所以也是用递推去计算。
前面已经解决了从0到i位置上的哈希值的构建,但是子串的开始不都是从0开始,我们更想要实现的是从任意位置开始,所以关键的问题是我怎么计算从任意位置i到任意位置j的哈希值。
还是用前面的例子, 对于字符串abc,如果取Base为5,h数组的结果如下
h[0] = 0;
h[1] = h[0]×5 + a = 0×5 + a = a;
h[2] = h[1] ×5 + b = a×51 + b ;
h[3] = h[2]× 5 +c = ( a×5 + b)×5 + b = a×52 + b×5 + c;
如果我现在需要计算2到3位置的字符串的哈希值,也就是计算bc的哈希值,那么应该怎么办呢?根据我们对h数组的构造,如果我们另外对bc字符串计算,那就是:
hbc[0] = 0;
hbc[1] = hbc[0]×5 + b = b;
hbc[2] = hbc[1] ×5 + c = b×5 + c ;
通过对比数组hbc和h,我们就可以发现,我们需要的结果hbc[2]其实就是h[3]的含有字母b和c后半段。那我们只需要消掉h[3]中a字母的项就可以了,而不是去构建hbc。
要达到这样的目的,只需要通过消元法,就可以借助h[1] 消掉a的项,通过h[3] - h[1] × 52 就可以得到。
对于其他任意的位置,我们也可以通过的计算实现,那么我们只需要通过这里确定以下几个关键的值是如何计算,就可以知道一般情况的计算公式。
怎么知道用h数组中的哪两个相减
比如例子中,我们要求的开始位置为2,终止位置为3,那么被减数就是终止位置的h,即h[j]。因为最终的结果就是这里的一部分,那减数怎么判断呢,从例子中我们可以到,我们的开始是2,但是用的是1,从式子去看是比较明显的,因为我要之去掉a,所以用到的就是只有a的部分,但是h[2]不仅有a还有我需要的b,所以需要在开始位置的上一个,只能才能保证到我们开始的字母是被保留的。推广到一般的情况,则因为h[i]表示的是从0到i位置的字符,但是i是子串开始的地方,需要消掉的是0到i - 1位置的字符,所以用到h[i - 1];
如何确认要乘以Base的几次方才能消掉无关字母
从我们的例子来看,我们用到了h[j]和h[i - 1],直接用他们相减就可以了也就是,j - (i - 1) = j - i + 1
i到j位置的子串哈希值为
h[j] - h[i - 1] × p[j - i + 1]
最大长度的选取
作者在这里使用的是二分法的思想,首次选取整个字符串长度的一半作为最大长度进行尝试。根据当前最大长度的尝试结果对下一次尝试的长度进行调整。
长度调整
如果上一次尝试可行,那么修改左边界,达到增加最大尝试长度的效果,如果上一次没有不可行,修改右边界达到减小最大尝试长度的效果。
class Solution {
long[] h, p;
//h:哈希数组;p:次方数组,字符串哈希:一个数字和一个字符串之间的一一对应
public String longestDupSubstring(String s) {
int P = 83, n = s.length();
//P:一般要求是素数,至于要取多少,其实是不要求的
h = new long[n + 1];
p = new long[n + 1];
//这里作者原来用的是 + 10,我自己没弄明白,所以改成了自己理解的
//h[i]:0~i对应字符串哈希值,p[i]:底数的i次方的值
h[0] = 0;//一个字符都没有时的哈希值
p[0] = 1;//底数的0次方
for (int i = 0; i < n; i++) {//预处理P和H数组
p[i + 1] = p[i] * P;
//递推的时候我们是用i位置和i - 1位置的关系,这里用的是第i+1和i项的关系式,因为字符串的下标是从0开始,我们需要转换成个数,比如说第一个字符下标是0,但它是有1个字符,应该对应p[1],h[1],一开始我还想那把i的开始位置改成1不就好了,但是这样就遗漏了一个字符。
//自然溢出法构建哈希公式
h[i + 1] = h[i] * P + s.charAt(i);//s.charAt(i)相当于用这个位置的ASCII码进行计算
}
String ans = "";
int l = 0, r = n;
//尝试最大长度子串是否重复
while (l < r) {
int mid = (l + r + 1 ) / 2;//也可以通过右移:int mid = (l + r + 1 ) >> 1来实现
//检查某个长度为最大长度时,是不是有方案
String t = check(s, mid);
//二分思想
if (t.length() != 0) l = mid;
else r = mid - 1;
ans = t.length() > ans.length() ? t : ans;//取重复多次中长度最长的子串
}
return ans;
}
String check(String s, int len) {
int n = s.length();
Set<Long> set = new HashSet<>();//记录已经被处理过的子串
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;//从i开始,向后长度为len的子串
long cur = h[j] - h[i - 1] * p[j - i + 1];
//计算i - 1到j位置构成的子串的哈希值
if (set.contains(cur)) return s.substring(i - 1, j);
//如果已经出现过1次了,说明这是第二次出现,满足题意.通过字符串哈希让判断变得简单
set.add(cur);//第一次遇到这个字符串就加到set中
}
return "";//如果没有重复那就返回空字符串
}
}