查找字符串子串——逐步理解字符串Hash算法

引出

字符串hash一般应用在字符串匹配问题:给定一个长度为n的s串,以及一个长度为m的t串,求t串在s串出现的次数或者位置。

因为查询整数有很多技巧,比字符串要快和丰富(比如二分),所以字符串hash的思路就是利用特殊的公式将一个字符串与一个整数对应,并且尽量保证互相唯一对应,这样只要查到对应的整数出现了,就表明字符串出现了,这对应整数叫字符串的hash值

有许多种构建字符串和整数对应公式的办法,通常情况下字符串很容易只对应一个整数,但是反过来很可能有一个整数对应了多个字符串的情况,这种不唯一对应的情况称为hash冲突,我们构建的公式要尽量避免这种冲突,简单的a-1,b-2这种思路是很容易冲突的,所以我们站在巨人的肩膀上,有前人已经总结出的较好的避免冲突的构建办法,本着实用主义介绍两种:

各取所需,建议都看
单hash法/自然溢出法:简单,有一定冲突概率,时间较短,没有刻意刁难的题目一般用这个就够了
双hash法:冲突概率几乎可以不计,但是时间消耗比较长
此外还有冲突概率低且用时短的拉链法等,但是代码构造比较复杂,这里不展开介绍

单hash法&自然溢出法

基本思路:

在数数时,超过9之后,我们采用1和0的组合来表示比9大1的数,依此类推,实现了只用0-9这10个数字来表示所有的数,这种表达办法的核心是,进制

那在表达字母的时候,字母有26个,转化成数字,可不可以近似理解成对应26进制,比如a是1,b是2,依此类推到z是26,然后aa是27,ab就是28。这种思路其实也是hash的基础思路,也就是进制hash。

展开来看就是:假设把a-z与1-26对应,那么按照我们进制转换的习惯,以10进制为例,121=1 * 101+2 * 101+1 * 100,同理aba=1 * 262+2 * 261+ 1 * 260,这样看是不是每个字符串都能对应出一个整数了?但是会产生很多问题:

问题1:过长的字符串在计算机里表示成整数存不下。因为字符串可能才几位,这个对应的整数就已经很大很大了。那么当计算出的整数可能超过上限时,我们对其取余一个较大的数h进行处理,也就是mod一个h,能将其从上限变回h以内的一个数。

问题2:进制刚好26,则取模回来必然导致重复。虽然取模解决了计算机内存储的问题,但是如果是以例子中的26进制,因为字母刚好26个,也就是每一位进制在利用的时候必然是存满的,到了上限再取模回来,必然和前面的数有同样的hash值。这个时候如果把进制变大,那么两个不同位数的字符串之间的空出的位置就会变大,比如进制取27时,z=26 * 270=26,aa=1 * 271+1 * 270=28,那么原先26进制中的27的位置存aa,现在就被空出来了,进制再取大则空的位置更多,那么当我们的字符串位数超过存储上限的时候,取模回来有一定概率存到这些空中,那么这个长字符串和短字符串冲突的概率就变小了,当进制和取模的数调整的比较好的时候,这种概率就会变得很低。

同时我们希望依托字符串的子串来获得更长一位的hash值,而不是遍历每一位算出它的值(也就是利用前缀和减少遍历时间):
本身进制计算的时候,就是第x位上的数乘以进制b的x-1次方,然后把所有位的值相加,例如十进制的121=1 * 101+2 * 101+1 * 100。但其实还有一种办法也是可以的,就是假设我知道了12的值,我想算出121,只要把12 * 10+1,同理1234,其实就是123 * 10+4。那么同样的思路,我要存cd,我可以存c的值,然后让c*进制+d,这个时候如果再往后读了一位,字符串变成了cdb,那么我就可以用cd的值乘以进制+b得到cdb的hash值,这样就依托了字符串的子串。

要说明的是,我只需要保证我已经存的子串有确切对应的数就行了,而不需要浪费其他的数来存我不需要的。虽然上限定了之后,存的数的总量没变,也就是表达不同字符的空间没变多,但是我们没有必要让字符串排列的每一种可能都呈现。

假设3位字符串,我没必要让a-zzz的所有情况都用一个数来表示,我只要在我已经得到的字符串的基础上,不让多存一位的字符串和已经存过的子串的hash值一样即可。例如我得到anakj,我只要保证,a,an,ana,anak,anakj都是不同整数表示就行,我甚至可以让他们分别等于1,2,3,4,5,但是这样找不出普遍存储的规律,所以才有了上述进制hash的思路,可以让更长位数的字符串保存在我调高进制时空出来的部分。

同时虽然这些空的部分按照进制运算应该对应某个字符串a,但是空位被长的b存进去了,只要在更高位b的子串中没有这个被替代的a本身,那么这个重复其实本质上没有影响到我后续的判定;以26进制为例,假设mod的h=27,然后26进制下按照规律应该是a=1,b=2这样对应过来,但我存ac的时候,其实前面只存了a的hash值对应的是1,ac=29mod27=2就没有重复,虽然按规律b应该对应2,但是我并不需要找一个数对应b,而是需要找一个数对应ac,所以我并不介意;但是存ab时ab=28mod27=1,就和1重复了,这就是冲突。

所以常见的取进制是131,233,13331,mod要大于进制数(不然进制的意义何在),一般不用人为取mod值,unsigned long long的最大值2的64次-1,在计算机里超过这个值会自动取模,这就是自然溢出法,如果人为规定一个mod值,那就是单hash,至于为什么进制取131这种就需要更深入的展开,这里就记结论就行

总结:

构建:假设进制为base,取模的值是h,原字符串s,到第i位也就是s[i]的hash值则可以表示为

Hash(i)= [ Hash(i-1) * base + s(i) ] mod h;

取出子串:如果想取出某个中间的子串,比如取第k到第k+j位,那么只要用Hash(k+j) - Hash(k) * basek,同时别忘了再取模就行了。例如3415,想取出41,是不是只要341的hash值减去3 * base^2就行

Hash(s’)= [ Hash(k+j)-Hash(k) * base^k ] mod h;

代码实现:

因为自然溢出法比人为定义一个mod的单hash要优秀很多,单hash在人为规定mod值之后会比自然溢出法要多出一些时间,所以这里主要展开注释自然溢出法

//自然溢出法
//本身公式是递推公式,所以代码实现思路是递归
int base = 131;//进制一般取131,13331,233
unsigned long long hashN(char s[])
{
	unsigned long long hash = 0;
	int len = strlen(s);
	for (long long i = 0; i < len; i++)
	{
		hash = base * hash + (unsigned long long)s[i];
		//利用unsigned long long 的范围自动溢出,而不用取mod
	}
	return hash;
}

双hash法

基本思路:

了解了单hash的原理后,双hash的目的就是降低冲突的概率,那么我用两种不同的取模值,取出来的值都对应一个字符串,那么另一个字符串想要和之前存好的字符串有冲突,它在两个取模值下的hash值就必须都和前一个一样,这个冲突的概率就远远低于了只有一个取模时的难度。

那么只要一个字符串对应两个hash值,然后我们让单hash执行两遍,两遍的mod值取的不一样就可以了,其中一个字符串对应两个hash值可以建立一个结构体,里面存它的两个对应的hash值,比如结构体下arr[i].x存第一个hash值,arr[i].y存第二个,当然用pair之类的也可以,只要能一个下标对应存下两个值就行。

代码实现:

这里可以直接上代码了,有了前面的基础,代码看一遍基本就明白了:

//双hash法
const int maxn = 1005;//maxn根据字符串最大长度定
int base = 131;

//用来存一个字符串在不同取模下的两个hash值
struct node
{
	unsigned long long x, y;
}arr[maxn];

//定义两个不同的取模值
unsigned long long mod1 = 1e9 + 7;//大质数
unsigned long long mod2 = 1e9 + 9;//大质数

unsigned long long hash1(char s[])//其实这就是单hash,作为双hash的一部分
{
	int len = strlen(s);
	unsigned long long hash = 0;
	for (int i = 0; i < len; i++)
	{
		hash = (hash * base + (unsigned long long)s[i]) % mod1;
	}
	return hash;
}

unsigned long long hash2(char s[])//其实这就是单hash,作为双hash的一部分
{
	int len = strlen(s);
	unsigned long long hash = 0;
	for (int i = 0; i < len; i++)
	{
		hash = (hash * base + (unsigned long long)s[i]) % mod2;
	}
	return hash;
}

//给到第i位的字符串赋值
arr[i].x = hash1(s);
arr[i].y = hash2(s);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值