Hash
什么是hash
哈希就是将一个字符串映射到一个值域比较小的范围内,这样可以方便一些操作,比如计数,比较等
可以将hash看成一个函数hash(s),一个字符串对应一个hash值,一个hash值有可能对应多个字符串
hash的实现
首先,我们可以考虑到一个数字10,用十进制数表示是10,二进制是1010,也就是说我们可以修改权值来使得同样的数字以不同形式来表示,那我们可以对字符串做同样的操作,将字符串看成一个b进制数,用数字表示字符串
h a s h ( s ) = ∑ i = 1 l e n s [ i ] ∗ b l e n − i hash(s)=\sum_{i=1}^{len}s[i]* b^{len-i} hash(s)=∑i=1lens[i]∗blen−i
按照这样的话如果权值b合理,hash函数显然是严格单调的
但是因为数字大小有限,一旦遇到过长的字符串就会超出上限,因此我们可以考虑给字符串取模
h a s h ( s ) = ∑ i = 1 l e n s [ i ] ∗ b l e n − i % M O D hash(s)=\sum_{i=1}^{len}s[i]* b^{len-i}\% MOD hash(s)=∑i=1lens[i]∗blen−i%MOD
这样就使得其值域在0~MOD-1
接下来就需要考虑b和MOD的取值了,如果a和b互质,那么a*k%b在k从1取到b是刚好能将0~b-1全部走一遍,所以将b与MOD取互质会比较好,这样保证了hash函数是均匀的使得一次比较冲突的概率将会是1/MOD,因此为了尽量提高准确率,我们也可以直接选用大素数作为MOD
证明:
假设 gcd ( a , b ) = 1 \gcd (a,b)=1 gcd(a,b)=1
假设 k ∗ a % b = p ∗ a % b k*a\%b=p*a\%b k∗a%b=p∗a%b
那么 k ∗ a = p ∗ a + g ∗ b k*a=p*a+g*b k∗a=p∗a+g∗b
由于 g ∗ b = ( k − p ) ∗ a g*b=(k-p)*a g∗b=(k−p)∗a
又因为gcd(a,b)=1
所以(k-p)应为0或者b的倍数,当k-p为0时k==p,否则 ∣ k − p ∣ m i n = b |k-p|_{min}=b ∣k−p∣min=b
因此可以得证
b的取值应在字符串中最大字符的基数之上,否则带来问题,假设a的基数是16,b的基数是4,c为0的话,权b为4的话如果按照上述计算 h a s h ( b c ) = 4 ∗ 4 + 0 = 16 = h a s h ( a ) hash(bc)=4*4+0=16=hash(a) hash(bc)=4∗4+0=16=hash(a)
以上就是关于简单hash的实现
当然我们可以看到错误率是仍然存在的如果进行了n次比较那错误率会达到 1 − ( 1 − 1 / M O D ) n 1-(1-1/MOD)^n 1−(1−1/MOD)n
为了得到更高的准确率,可以有两个策略,1是让其自然溢出,也就是不做MOD处理,这样的好处是容易写,而且在一定范围内应当是不可能出错的,缺点是它仍然是有一个上限的,如果使用ll那最多判断 2 64 2^{64} 264种不同的字符。当然这已经相当多了,不过由于实际字符串不会是按照顺序来的,很难判掉这么多字符,甚至降低很多倍也是有可能出错的,2是可以采用多重hash,也就是将一个字符串对多个不同的MOD进行hash,判断几次的结果,这会使得单次错误率变为 1 / ∏ M O D i 1/\prod MOD_i 1/∏MODi优点是只要你不断增加MOD就可以达到更高比较次数下的hash要求,缺点是代码相对较多,而且常数比较大。
另外似乎双hash的错误率就可以判断为0,也没有必要做更多重
hash应用
字符串匹配
首先我们有一个模式串p,我们先求出hash§和p的长度,再对着文本串s按照p的长度进行hash比较即可
假设p的长度为m,s的长度为n
最暴力情况下会是n*m的复杂度
但是我们可以考虑到我们是 h a s h ( s ) = ∑ i = 1 l e n s [ i ] ∗ b l e n − i % M O D hash(s)=\sum_{i=1}^{len}s[i]* b^{len-i}\% MOD hash(s)=∑i=1lens[i]∗blen−i%MOD这样进行hash的
这就像是一个数字一样,我们在考虑[100]1->1[001]的时候我们可以将原结果减掉 1 ∗ 1 0 2 1*10^2 1∗102再乘以10再加上1,这样我们就可以O(1)转变,使得最终复杂度为O(n)
当然也可以通过其他角度考虑,我们会想到 h a s h ( s [ l … r ] ) = h a s h ( s [ 1 … r ] ) − h a s h ( s [ 1 … l − 1 ] ) ∗ b r − l + 1 hash(s[l…r])=hash(s[1…r])-hash(s[1…l-1])*b^{r-l+1} hash(s[l…r])=hash(s[1…r])−hash(s[1…l−1])∗br−l+1
所以也可以通过预处理前缀hash值然后进行比较,同样是O(n)的复杂度
判断最长回文串
回文串简而言之就是将原串反过来仍然不变的字符串,我们只需要正反做两次hash,如果相同那么就是回文
然后要考虑的是怎么找到最长的,这个可以考虑二分,可以想到如果存在长回文,那么以这个长回文串的中心向两边走比长回文串的长度短的都一定是回文,那么我们只要做正反两次hash枚举中心点二分判断最长就可以得到最长的回文串了
next数组和KMP
在一个字符串里找另一个字符串出现的次数这样的问题可以怎么解决?
当然hash也可以O(n)解决,不过现在我们不考虑hash
那我们会考虑到朴素算法,暴力找,将每一种可能都比较了,但你会发现复杂度为O(n*m),这是不够优秀的
我们可以想到在暴力的做法里,我们其实有很多不必要的比较
假设文本串为aaab,匹配串为aab
那么第一次比较了aaa发现不对后移一位这个时候我们又依次aab三个都进行比较,但其实我们的前两个比较似乎是可以不进行的,考虑到这个,我们来优化暴力做法,假设我们知道如果匹配串在任何位置失配应该跳到哪进行新的匹配或者说可以跳过多少个字符重新开始匹配那我们就能在O(n)的时间内得到结果
跳过多少个字符我们可以想到,只有匹配串到当前位置的真前缀与真后缀一样才能直接跳转,因此我们利用这个性质来构建这个跳转数组
这个数组我们称为next数组,也有叫前缀函数的
next数组的构筑
我们假设知道前i-1个的next值我们现在考虑第i个next值
那么有两种情况,第一种s[i]==s[next[i-1]]那么next[i]就等于next[i-1]+1
第二种情况,s[i]!=s[next[i-1]],这个时候就又考虑到跳转的问题了,但这个时候我们知道前面所有的next值的,因此我们可以接下去比较s[i]与s[next[next[i-1]-1]],通过跳转,最终可以确定下next[i]的值
由于是真前缀与真后缀的比较,所以第一个的next值必然是0,同时我们也可以考虑到,如果第二个位置失配了,那要改变情况就必须向后移动至少一位,也就是第一位一定会被盖掉,而实际上我们得借用next[0]来跳转,这样考虑next[0]也必须是0
next数组的应用
kmp
有多种写法,第一种我们可以想构筑next数组这样,与文本串比较,这个时候我们将next数组的意义考虑为当下一个字符失配,应当跳转到的位置,具体实现与next构筑相似,只不过变成了在文本串上比较
第二种方法,我们将next数组考虑为真前缀与真后缀的最长相同长度,这样,我们可以令匹配串变成文本串的前缀做成p+不存在的字符+s的形式,然后对新的文本串计算next值,由于中间会有不存在的字符因此,next的计算,不会超过匹配串,我们只需要记录匹配串部分的next然后考虑在后边的部分里出现的next=n的数量即可
这两种复杂度都是O(n+m)
字符串的周期
假设存在周期,我们将一个周期看做一个字符a来理解这个问题
那一个文本串可以表示为aaaaaa
这样我们得到的next数组应该是0,1,2,3,4,5
而实际上的周期可以是a,aa,aaa,aaaaaa
我们可以考虑到如果存在循环节,那么next[n-1]按照定义一定会等于开头到上一个循环节末尾的长度,而距离最后刚好就剩下一个循环节,而且这个循环节一定是最短的,因此n-next[n-1]就是这个最短的循环节长度也就是最短周期。
至于更大的循环节,肯定是由最小循环构成的,因此如果需要,另外计算即可
各个前缀出现的次数
当某个位置出现了next=10的话,我们可以知道在这里至少是长度小于等于10的前缀都出现了一次,由于实际上next数组有next[k]<=next[k-1]+1,因此如果出现next[k]=10,那在之前一定会在1-9全部出现一次,所以我们单独统计就可以防止重复计算,然后考虑到实际上字符串在某些位置是有可能出现重复的,例如之前aaaaaa的例子,next[n-1]=5,那我们只将前缀长度为5的+1的话我们其实会漏掉很多,例如最后这个a相当于前缀长度为1的也出现了一次,那我们要将这部分计算进去,所以我们从大到小将使用next数组跳转,再过程中对对应长度的前缀统计数组进行增加即可,最后由于前缀本身没有被统计所以全部再增加1即可
如果统计匹配串的各个前缀在文本串里出现的次数的话,我们同样可以先得到p+不存在的字符+s然后来得出next数组,将s串出现的next统计后,与刚才一样,从大到小跳转并增加即可
本质不同子串数
假设文本串为s,长度是n,假设我们知道从 k 到 n k到n k到n这一段的本质不同子串数,那我们考虑 k − 1 到 n k-1到n k−1到n就只需要考虑以 k − 1 k-1 k−1为开头是否有出现新的子串,如果都是新的那会增加n-k+2个子串,那我们只需要再计算一遍next数组,看next不为0的有多少即可,用n-k+2减掉即可
组合而成的长文本内查找匹配数
对于特别长的文本而言,我们甚至无法写出来,但是如果存在一定规律,我们仍然可以通过计算得知匹配串p在该文本s内出现的次数
我们在kmp的写法里就讲到过可以通过p+不存在的字符+s来解决匹配问题,仔细考虑后可以发现,其实在这样一个不存在的字符的隔离下,我们在计算过程中有一个下标其实是一直p里跑的,在数值上反映为next的值绝不超过p的长度。
于是我们考虑创建一个自动机,可以理解为,我们考虑求出当目前文本匹配到next为任意值时,再添加任意字符后的next为多少。因为这些转变的关系是有限的可计算的。当然是在p为正常大小时。
这里介绍一下这个自动机的构建:
我们先构建一个p+不存在的字符这样的字符串并且先计算出这个字符串的next,然后从头到尾将整个字符串遍历,在每次遍历中我们假设遇到的新字符,这个字符也要遍历所有字符,然后剩下的计算与next数组的计算类似只不过要将结果存到另外的数组中如 a u t [ i ] [ c h a r j ] aut[i][char_j] aut[i][charj]
另外,我们还可以通过 a u t [ n e x t [ i − 1 ] ] [ c h a r j ] aut[next[i-1]][char_j] aut[next[i−1]][charj]转移到 a u t [ i ] [ c h a r j ] aut[i][char_j] aut[i][charj],这样可以将计算next数组时的while给去掉,可以将复杂度控制在p的长度乘以字符的种类数
我们得到这个后我们就可以很容易的计算出某个串处理后最后的next值,以及期间出现的p的次数,然后我们向后添加别的文本串就可以通过当前的最后next值快速求出新的末尾next值,以及期间出现的p的次数,类似这样操作,最后就能解决一个由组合而来的超长文本串s内出现了多少次p的问题。
简单讲述一个较为简单的例子
g
1
=
a
,
g
2
=
a
b
a
,
g
3
=
a
b
a
c
a
b
a
,
g
4
=
a
b
a
c
a
b
a
d
a
b
a
c
a
b
a
,
.
.
.
g_1=a, g_2=aba, g_3=abacaba, g_4=abacabadabacaba,...
g1=a,g2=aba,g3=abacaba,g4=abacabadabacaba,...
给定一个数
k
<
=
1
0
5
k<=10^5
k<=105以及一个长度的较短的字符串s,问在
g
k
g_k
gk中出现的次数
我们除了用到aut数组之外,还需要另外的两个数组, G [ i ] [ j ] 和 K [ i ] [ j ] G[i][j]和K[i][j] G[i][j]和K[i][j]第一个的意义是在自动机为j状态下处理掉 g i g_i gi串后自动机的状态。这里的自动机的状态可以理解为当前计算的串末尾的next值是什么。第二个的意义是匹配串p在从自动机状态为j开始处理 g i g_i gi出现的p次数。这样最后的答案就会是 K [ k ] [ 0 ] K[k][0] K[k][0]
这样我们可以通过这样 G [ i ] [ j ] = G [ i − 1 ] [ a u t [ G [ i − 1 ] [ j ] ] [ i ] ] G[i][j]=G[i-1][aut[G[i-1][j]][i]] G[i][j]=G[i−1][aut[G[i−1][j]][i]]利用 G [ i − 1 ] G[i-1] G[i−1]来得到 G [ i ] G[i] G[i]
然后 K [ i ] [ j ] = K [ i − 1 ] [ j ] + [ a u t [ G [ i − 1 ] [ j ] ] [ i ] = = p 的 长 度 ] + K [ i − 1 ] [ a u t [ G [ i − 1 ] [ j ] ] [ i ] ] K[i][j]=K[i-1][j]+[aut[G[i-1][j]][i]==p的长度]+K[i-1][aut[G[i-1][j]][i]] K[i][j]=K[i−1][j]+[aut[G[i−1][j]][i]==p的长度]+K[i−1][aut[G[i−1][j]][i]]来计算
三个组成分别是 g i − 1 , c h a r i , g i − 1 g_{i-1},char_i,g_{i-1} gi−1,chari,gi−1
更加普遍地讲对于另外的组合得到的长字符串也可以通过这样的方式计算