数据结构(邓俊辉)学习笔记】串 17——Karp-Rabin算法:散列
文章目录
- 1.数位溢出
- 2.散列压缩
- 3.应对冲突
- 4.指纹更新
1.数位溢出
在前一节中,已经成功地完成了一次视角转换,了解到应该如何从数学上将每一个串视作为一个自然数。以下我们就来将这一构思具体的兑现为一个算法。很有意思的是,我们在此需要用到第9章的关键技术散列。
我们将每一个串所对应的自然数称作为它的指纹 fingerprint。因这个数相对于串,就像指纹相对于人一样,可以用来甄别其身份。然而我们注意到,这样一个自然数是以字符集的规模作为进制的,因此字符集只要不是很小,这类指纹的数值就会变得很大,这不能不说是个坏消息。
比如,我们知道对于 ASCII 字符集来说,它的规模为128,对于这类字符,即便模式串的长度不是特别的长,它对应的指纹也会长的令我们吃不消。我们可以来做一个快速的封底估算,128 是 2 的7 次方,因此即便是长度为 10 的模式串,它所对应的指纹也至少需要70个比特方能表示。
~
这就意味着,即便在64位的计算平台上,长度不小于10的字符串将无法直接表示。而更糟糕的是,我们因此而遇到的麻烦还不止这些。
实际上,在整数的字宽已经不能继续视作为常数之后,整数之间的运算,也不能继续保证可在常数时间内完成。尽管 RAM 模型曾经的确做过这样一个不切实际的假设。实际上就渐进复难度的意义而言,此时,每次指纹比对所需要的时间将仍然线性正比于串的长度,也就是说,我们计算效率将重新退化到蛮力算法的水准。
那么,为了破解这一难题,你又能想到哪些高招呢?
2.散列压缩
既然以上问题的根源在于数位的溢出,那么我们很自然的就会应该想到通过压缩来解决它。没错,将一个硕大的取值空间压缩到一个可存储、可计算的更小的空间。从方法论上讲,这也不正是散列吗?
没错,我们需要对指纹来做散列压缩。具体地,我们将借助合适的散列函数将字符串的指纹压缩到存储器可支持的范围。
这里我们不妨就以模余法为例,模余的基底取作素数 97。接下来,假设我们需要在这样一个文本串中寻找模式串 82818, 以下的主体流程与蛮力算法一样:
-
我们也是从首个对齐位置开始,逐次地去尝试各个位置,在每一个位置我们都将局部的子串与模式串进行比对。
而在这里最为本质的不同在于,我们将不再是逐个地去比较每个字符,而是直接在两个串的指纹之间进行比对。
-
为此我们首先要计算出模式串的指纹,如果没有算错,应该是77。在文本串中,我们首先尝试的是子串 27182。也不难验证,它所对应的指纹为22,与目标指纹77不符,因此我们可以随即将其排除并接着转向下一个字串,也就是71828。
-
同样地,也不难验证,它的指纹为48。与77不符,所以也同样被排除掉。
-
再接下来对应的字串为 18281,它所对应的指纹也不难验算为45,同样与77不符,因而可以排除掉。
-
不难看出,整个算法将在下一个对齐位置发现匹配。是的,这个子串与模式串完全一致,所以它所对应的指纹也应该为77,而实际上这也是算法所发现的。
于是通过这种方法,我们也同样地完成了一次模式匹配。需要提醒你再次注意的是,在整个这样的计算过程中,我们分别只需要常数的时间就可以排除或者确认一个对齐位置,而这一点正是我们设计这种算法的初衷。
当然,算法至此,依然没有尽善尽美,你能看出其中的问题吗?
3.应对冲突
没错,冲突。既然是散列,冲突就必然不可避免。
不过好在,我们这里只用到了指纹相等置于匹配的必要性,这与散列中的试探过程完全同理。
你应该记得在试探查找的过程中,即便我们发现对应的桶非空,我们也不会贸然地认为它必定就是我们要找的元素。是的,为了最终确定它的身份,我们还需要做一次严格的比对。而在这里,我们也不妨沿用这种方法。
来看这样一个实例:依然是刚才我们已经熟知的那个文本串,只不过这里将模式串替换为18284。
-
首先确认,模式串的指纹为48。
以下,我们依然是去逐个尝试每一个对齐位置。
-
在第一个对齐位置,我们得到的是指纹 22,既然它与目标的 48 不等,我们也可自然地将这一对齐位置排除掉。
-
接下来的第二个对齐位置,所对应的局部子串是 71828。很显然,它并不是我们要找的模式串。然而很不幸,它所对应的指纹却是 48,与模式串的指纹一模一样。当然,这也没有什么奇怪的,两个不同的元素在经过散列变换之后,有可能会被映射到同一个散列码,这种现象正是我们所谓的冲突。
好在,我们沿用了散列表的策略,我们在这种情况下还会对这两个字符串做逐位的比对,以最终确定它的确是匹配。当然经过这样的严格比对之后,我们也的确可以排除掉这个对齐位置。
事实上,这个算法会如此不断地继续运行下去,直到最终抵达真正的匹配穿,也就是18284。当然,我们算法在此也不会遗漏掉这次匹配,因为显然这个局部子串所对应的散列码也应该是48。
这样我们又再次向前迈出了坚实的一步,至此,我们距离 Karp-Rabin 算法只差最终的一小步了。
4.指纹更新
就目前的模式而言,为了计算出文本串中每一个子串所对应的指纹,我们所需要花费的时间似乎都需线性正比于子串的长度。
当然也就是模式串的长度,如果考虑到有多达 O(n) 个这样的潜在子串,那么你或许会沮丧的发现,最终的整体时间复杂度又再一次回到了O(n) * m,难道,我们此前的心血都是白费的吗?这也是我们在最后这一小步中所要解决的一个关键问题。
我们思考的方向和目标是,将每一指纹的计算成本从 O(m) 降低到常数。如果你还没有想出有效的方法,不妨首先温习一下此前的进制转换算法,或许你能从中得到一些启示。
没错,在那个算法中,我们很好地利用了相邻数位之间的相关性。其实在这里,也存在着类似的相关性。在文本串中,任何两个相邻子串之间都存在着紧密的相关性。具体来说,二者的指纹之间存在的相关性,而这两个指纹的计算过程以及结果之间也存在着紧密的相关性。
从这幅图中可以看出,相邻的子串几乎一样,二者唯一的区别只在于前者的首字符以及后者的末字符。只要能够悟到这一点,相信你就不难设计出一种新的方法,使得我们可以在常数的时间内由前一个指纹得到后一个指纹。