CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/32 | 字符串匹配基础(上):如何借助哈希算法实现高效字符串匹配?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

104 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="32 | 字符串匹配基础(上):如何借助哈希算法实现高效字符串匹配?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/4d/b2933f7399d06d65e3a88208934bc04d.mp3"></audio>
从今天开始我们来学习字符串匹配算法。字符串匹配这样一个功能我想对于任何一个开发工程师来说应该都不会陌生。我们用的最多的就是编程语言提供的字符串查找函数比如Java中的indexOf()Python中的find()函数等,它们底层就是依赖接下来要讲的字符串匹配算法。
字符串匹配算法很多我会分四节来讲解。今天我会讲两种比较简单的、好理解的它们分别是BF算法和RK算法。下一节我会讲两种比较难理解、但更加高效的它们是BM算法和KMP算法。
这两节讲的都是单模式串匹配的算法也就是一个串跟一个串进行匹配。第三节、第四节我会讲两种多模式串匹配算法也就是在一个串中同时查找多个串它们分别是Trie树和AC自动机。
今天讲的两个算法中RK算法是BF算法的改进它巧妙借助了我们前面讲过的哈希算法让匹配的效率有了很大的提升。那**RK算法是如何借助哈希算法来实现高效字符串匹配的呢**?你可以带着这个问题,来学习今天的内容。
## BF算法
BF算法中的BF是Brute Force的缩写中文叫作暴力匹配算法也叫朴素匹配算法。从名字可以看出这种算法的字符串匹配方式很“暴力”当然也就会比较简单、好懂但相应的性能也不高。
在开始讲解这个算法之前,我先定义两个概念,方便我后面讲解。它们分别是**主串**和**模式串**。这俩概念很好理解,我举个例子你就懂了。
比方说我们在字符串A中查找字符串B那字符串A就是主串字符串B就是模式串。我们把主串的长度记作n模式串的长度记作m。因为我们是在主串中查找模式串所以n&gt;m。
作为最简单、最暴力的字符串匹配算法BF算法的思想可以用一句话来概括那就是**我们在主串中检查起始位置分别是0、1、2....n-m且长度为m的n-m+1个子串看有没有跟模式串匹配的**。我举一个例子给你看看,你应该可以理解得更清楚。
<img src="https://static001.geekbang.org/resource/image/f3/a2/f36fed972a5bdc75331d59c36eb15aa2.jpg" alt="">
从上面的算法思想和例子我们可以看出在极端情况下比如主串是“aaaaa....aaaaaa”省略号表示有很多重复的字符a模式串是“aaaaab”。我们每次都比对m个字符要比对n-m+1次所以这种算法的最坏情况时间复杂度是O(n*m)。
尽管理论上BF算法的时间复杂度很高是O(n*m),但在实际的开发中,它却是一个比较常用的字符串匹配算法。为什么这么说呢?原因有两点。
第一实际的软件开发中大部分情况下模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候当中途遇到不能匹配的字符的时候就可以就停止了不需要把m个字符都比对一下。所以尽管理论上的最坏情况时间复杂度是O(n*m),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多。
第二朴素字符串匹配算法思想简单代码实现也非常简单。简单意味着不容易出错如果有bug也容易暴露和修复。在工程中在满足性能要求的前提下简单是首选。这也是我们常说的[KISSKeep it Simple and Stupid设计原则](https://zh.wikipedia.org/wiki/KISS%E5%8E%9F%E5%88%99)。
所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。
## RK算法
RK算法的全称叫Rabin-Karp算法是由它的两位发明者Rabin和Karp的名字来命名的。这个算法理解起来也不是很难。我个人觉得它其实就是刚刚讲的BF算法的升级版。
我在讲BF算法的时候讲过如果模式串长度为m主串长度为n那在主串中就会有n-m+1个长度为m的子串我们只需要暴力地对比这n-m+1个子串与模式串就可以找出主串与模式串匹配的子串。
但是每次检查主串与子串是否匹配需要依次比对每个字符所以BF算法的时间复杂度就比较高是O(n*m)。我们对朴素的字符串匹配算法稍加改造,引入哈希算法,时间复杂度立刻就会降低。
RK算法的思路是这样的我们通过哈希算法对主串中的n-m+1个子串分别求哈希值然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等那就说明对应的子串和模式串匹配了这里先不考虑哈希冲突的问题后面我们会讲到。因为哈希值是一个数字数字之间比较是否相等是非常快速的所以模式串和子串比较的效率就提高了。
<img src="https://static001.geekbang.org/resource/image/01/ee/015c85a9c2a4adc11236f9a40c6d57ee.jpg" alt="">
不过,通过哈希算法计算子串的哈希值的时候,我们需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。有没有方法可以提高哈希算法计算子串哈希值的效率呢?
这就需要哈希算法设计的非常有技巧了。我们假设要匹配的字符串的字符集中只包含K个字符我们可以用一个K进制数来表示一个子串这个K进制数转化成十进制数作为子串的哈希值。表述起来有点抽象我举了一个例子看完你应该就能懂了。
比如要处理的字符串只包含az这26个小写字母那我们就用二十六进制来表示一个字符串。我们把az这26个字符映射到025这26个数字a就表示0b就表示1以此类推z表示25。
在十进制的表示法中一个数字的值是通过下面的方式计算出来的。对应到二十六进制一个包含a到z这26个字符的字符串计算哈希的时候我们只需要把进位从10改成26就可以。
<img src="https://static001.geekbang.org/resource/image/d5/04/d5c1cb11d9fc97d0b28513ba7495ab04.jpg" alt="">
这个哈希算法你应该看懂了吧现在为了方便解释在下面的讲解中我假设字符串中只包含az这26个小写字符我们用二十六进制来表示一个字符串对应的哈希值就是二十六进制数转化成十进制的结果。
这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。我这有个例子,你先找一下规律,再来看我后面的讲解。
<img src="https://static001.geekbang.org/resource/image/f9/f5/f99c16f2f899d19935567102c59661f5.jpg" alt="">
从这里例子中我们很容易就能得出这样的规律相邻两个子串s[i-1]和s[i]i表示子串在主串中的起始位置子串的长度都为m对应的哈希值计算公式有交集也就是说我们可以使用s[i-1]的哈希值很快的计算出s[i]的哈希值。如果用公式表示的话,就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/c4/9c/c47b092408ebfddfa96268037d53aa9c.jpg" alt="">
不过这里有一个小细节需要注意那就是26^(m-1)这部分的计算我们可以通过查表的方法来提高效率。我们事先计算好26^0、26^1、26^2……26^(m-1)并且存储在一个长度为m的数组中公式中的“次方”就对应数组的下标。当我们需要计算26的x次方的时候就可以从数组的下标为x的位置取值直接使用省去了计算的时间。
<img src="https://static001.geekbang.org/resource/image/22/2f/224b899c6e82ec54594e2683acc4552f.jpg" alt="">
我们开头的时候提过RK算法的效率要比BF算法高现在我们就来分析一下RK算法的时间复杂度到底是多少呢
整个RK算法包含两部分计算子串哈希值和模式串哈希值与子串哈希值之间的比较。第一部分我们前面也分析了可以通过设计特殊的哈希算法只需要扫描一遍主串就能计算出所有子串的哈希值了所以这部分的时间复杂度是O(n)。
模式串哈希值与每个子串哈希值之间的比较的时间复杂度是O(1)总共需要比较n-m+1个子串的哈希值所以这部分的时间复杂度也是O(n)。所以RK算法整体的时间复杂度就是O(n)。
这里还有一个问题就是,模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,如果超过了计算机中整型数据可以表示的范围,那该如何解决呢?
刚刚我们设计的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。因为我们是基于进制来表示一个字符串的,你可以类比成十进制、十六进制来思考一下。实际上,我们为了能将哈希值落在整型数据范围内,可以牺牲一下,允许哈希冲突。这个时候哈希算法该如何设计呢?
哈希算法的设计方法有很多我举一个例子说明一下。假设字符串中只包含az这26个英文字母那我们每个字母对应一个数字比如a对应1b对应2以此类推z对应26。我们可以把字符串中每个字母对应的数字相加最后得到的和作为哈希值。这种哈希算法产生的哈希值的数据范围就相对要小很多了。
不过你也应该发现这种哈希算法的哈希冲突概率也是挺高的。当然我只是举了一个最简单的设计方法还有很多更加优化的方法比如将每一个字母从小到大对应一个素数而不是123……这样的自然数这样冲突的概率就会降低一些。
那现在新的问题来了。之前我们只需要比较一下模式串和子串的哈希值,如果两个值相等,那这个子串就一定可以匹配模式串。但是,当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。
实际上,解决方法很简单。当我们发现一个子串的哈希值跟模式串的哈希值相等的时候,我们只需要再对比一下子串和模式串本身就好了。当然,如果子串的哈希值与模式串的哈希值不相等,那对应的子串和模式串肯定也是不匹配的,就不需要比对子串和模式串本身了。
所以哈希算法的冲突概率要相对控制得低一些如果存在大量冲突就会导致RK算法的时间复杂度退化效率下降。极端情况下如果存在大量的冲突每次都要再对比子串和模式串本身那时间复杂度就会退化成O(n*m)。但也不要太悲观一般情况下冲突不会很多RK算法的效率还是比BF算法高的。
## 解答开篇&amp;内容小结
今天我们讲了两种字符串匹配算法BF算法和RK算法。
BF算法是最简单、粗暴的字符串匹配算法它的实现思路是拿模式串与主串中是所有子串匹配看是否有能匹配的子串。所以时间复杂度也比较高是O(n*m)n、m表示主串和模式串的长度。不过在实际的软件开发中因为这种算法实现简单对于处理小规模的字符串匹配很好用。
RK算法是借助哈希算法对BF算法进行改造即对每个子串分别求哈希值然后拿子串的哈希值与模式串的哈希值比较减少了比较的时间。所以理想情况下RK算法的时间复杂度是O(n)跟BF算法相比效率提高了很多。不过这样的效率取决于哈希算法的设计方法如果存在冲突的情况下时间复杂度可能会退化。极端情况下哈希算法大量冲突时间复杂度就退化为O(n*m)。
## 课后思考
我们今天讲的都是一维字符串的匹配方法,实际上,这两种算法都可以类比到二维空间。假设有下面这样一个二维字符串矩阵(图中的主串),借助今天讲的处理思路,如何在其中查找另一个二维字符串矩阵(图中的模式串)呢?
<img src="https://static001.geekbang.org/resource/image/00/c9/00c353326466a8ce4e790e36924704c9.jpg" alt="">
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。