字符串匹配

基础概念

  • 字符串匹配
    在日常操作电脑中,经常用到查找操作。这里用到很重要的概念就是字符串匹配,所谓字符串匹配就是在主串中搜索模式串是否存在及其存在的位置。
  • 主串模式串
    在字符串A中查找字符串B,那字符串A就是主串,字符串B就是模式串。
    我们把主串的长度记作n,模式串的长度记作m。因为我们是在主串中查找模式串,所以n>m。

几种单模式串匹配算法

  1. BF(暴力)算法
  2. RK算法
  3. BM算法
  4. KMP算法

1. BF(Brute Force)算法

字符串匹配_第1张图片
时间复杂度O(n*m),其中n是主串长度,m是模式串长度。
缺陷:忽略了已检测过的文本信息。

2. RK(Rabin-Karp)算法

如果模式串长度为m,主串长度为n,那在主串中,就会有n-m+1个长度为m的子串。
BF算法需要对比n-m+1次,每次对比都需要依次对比m个字符。

RK算法的思路是:

  • 通过哈希算法对主串中的n-m+1个子串分别求哈希值
  • 然后逐个与模式串的哈希值比较大小
  • 如果某个子串的哈希值与模式串相 等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题,后面我们会讲到)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的, 所以模式串和子串比较的效率就提高了。

字符串匹配_第2张图片
简单的哈希算法,需要遍历子串的每个字符,尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。

巧妙的设计哈希算法。假设要匹配的字符串的字符集中只包含K个字符,我们可以用一个K进制数来表示一个子串,这个K进制数转化成十 进制数,作为子串的哈希值。

假设字符串中只包含a~z这26个小写字符,我们用二十六进制来表示一个字符串,对 应的哈希值就是二十六进制数转化成十进制的结果。
字符串匹配_第3张图片
这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。
字符串匹配_第4张图片
相邻两个子串s[i-1]和s[i] (i表示子串在主串中的起始位置,子串的长度都为m)。我们可以使用s[i-1]的哈希值很快的计算出s[i]的哈希值。
字符串匹配_第5张图片
h[i] = (h[i-1] - 26^(m-1)*(s[i-1]-‘a’)) * 26 + (s[i+m-1] - ‘a’)

可以提前计算26^(m-1)这部分的值,然后通过查表的方式提高效率。
字符串匹配_第6张图片
RK算法包含两部分,计算子串哈希值和模式串哈希值与子串哈希值之间的比较。

  • 第一部分,我们前面也分析了,可以通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以这部分的时间复杂度是O(n)。
  • 第二部分,模式串哈希值与每个子串哈希值之间的比较的时间复杂度是O(1),总共需要比较n-m+1个子串的哈希值,所以,这部分的时间复杂度也是O(n)。

所以,RK算法整体的时间复杂度就是O(n)。

如上这种哈希算法是不会有哈希冲突的,因为我们是基于进制来表示一个字符串的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。

问题:模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,可能会超过了计算机中整型数据可以表示的范围。

建议:设计数值较小的哈希函数,可能会有哈希冲突。在哈希值相等时,还需再对比一下子串和模式串本身。

3. BM(Boyer-Moore)算法

我们把模式串和主串的匹配过程,看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时,BF算法和RK算法的做法是,模式串往后滑动一位,然后从模式 串的第一个字符开始重新匹配。
字符串匹配_第7张图片
在这个例子里,主串中的c,在模式串中是不存在的,所以,模式串向后滑动的时候,只要c与模式串有重合,肯定无法匹配。所以,我们可以一次性把模式串往 后多滑动几位,把模式串移动到c的后面。
字符串匹配_第8张图片
字符串匹配的关键,就是模式串的如何移动最高效。

BM算法本质上就是寻找一种规律,使得模式串和主串匹配过程中,当遇到字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串尽量往后多滑动几位。

BM算法包含两部分

  • 坏字符规则(bad character rule)
  • 好后缀规则(good suffix rule)

BM算法模式串的匹配方式是从末尾往前倒着匹配。
字符串匹配_第9张图片

坏字符规则

从模式串的末尾往前倒着匹配,当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符(主串中的字符)。
字符串匹配_第10张图片

  • 坏字符c在模式串不存在,这个时候,我们可以将模式串直接往后滑动三(模式串长度)位,将模式串滑动到c后面的位置,再从模式串的末尾字符开始比较。
    字符串匹配_第11张图片
  • 坏字符a在模式串中存在,模式串中下标是0的位置也是字符a。这种情况下,我们可以将模式串往后滑动两位,让两个a上下对齐,然后再从模式串的末尾字符开 始,重新匹配。
    字符串匹配_第12张图片
  • 规律:当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作xi。如果不存在, 我们把xi记作-1。那模式串往后移动的位数就等于si-xi。(注意,我这里说的下标,都是字符在模式串的下标)
    字符串匹配_第13张图片
  • 若xi有多个,选择下标最大的那个,即最靠后的那个。

利用坏字符规则,BM算法在最好情况下的时间复杂度非常低,是O(n/m)。

  • 比如,主串是aaabaaabaaabaaab,模式串是aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的模式串和主串的时候,BM算法非常高效。

  • 不过,单纯使用坏字符规则还是不够的。因为根据si-xi计算出来的移动位数,有可能是负数,比如主串是aaaaaaaaaaaaaaaa,模式串是baaa。不但不会向后滑动模式串,还有可能倒退。所以,BM算法还需要用到“好后缀规则”。

好后缀规则

字符串匹配_第14张图片
我们把已经匹配的bc叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串 中{u}对齐的位置。
字符串匹配_第15张图片

  • 若在模式串中找不到另一个等于{u}的子串,我们就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都不可能匹配主串中{u}的情况。
    字符串匹配_第16张图片
    当模式串中不存在等于{u}的子串时,直接将模式串滑动到主串{u}的后面。是否有点太过头呢?
    字符串匹配_第17张图片
    如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步往后滑动模式串的过程中。
  • 只要主串中的{u}与模式串有重合,那肯定就无法完全匹配。
  • 但是当模式串滑动到前缀与主串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。
    字符串匹配_第18张图片
    所以,在好后缀模式下,若模式串中找不到和好后缀完全匹配的子串,那么:
  • 先看好后缀在模式串中,是否有另一个匹配的子串
  • 还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。
    字符串匹配_第19张图片
    BM算法会分别计算好后缀和坏字符往后滑动的位数,然后取两者中的大者,作为模式串往后滑动的位数。

BM算法实现

1.坏字符规则实现

“坏字符规则”本身不难理解。当遇到坏字符时,要计算往后移动的位数si-xi,其中xi的计算是重点,我们如何求得xi呢?
如果我们拿坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能。

  • 利用哈希表,将模式串中的每个字符及其下标都存到哈希表中

假设字符集为256,每个字符大小为1个字节,可用大小为256的数组,记录每个字符在模式串中出现的位置,数组下标对应字符的ASCII码值,数组值为字符在模式串中的下标。
字符串匹配_第20张图片
如上过程翻译成代码如下,其中变量b是模式串,m是模式串长度,bc是刚刚讲的哈希表:

const SIZE int = 256

func generateBC(b []byte, m int, bc []int) {
        for i := 0; i < SIZE; i++ {
              bc[i] = -1//初始化bc
        }
        for i := 0; i < m; i++ {
             ascii := int(b[i])//计算b[i]的asccii值
             bc[ascii] = i
        }
}

字符串匹配_第21张图片
单有坏字符规则的BM算法,代码如下:

func bm(mainStr, modeStr []byte) int {
	bc := make([]int, SIZE)
	n := len(mainStr)
	m := len(modeStr)
	generateBC(modeStr, m, bc) //构建坏字符哈希
	i := 0                     //i表示主串与模式串对齐的第一个字符位置
	for i <= n-m {
		j := 0
		for j = m - 1; j >= 0; j-- { //模式串从后往前匹配
			if mainStr[i+j] != modeStr[j] {
				break //坏字符对应模式串中的下标是j
			}
		}
		if j < 0 {
			return i //匹配成功,返回主串与模式串第一个匹配的字符的位置
		}
		moveNum := j - bc[int(mainStr[i+j])]
		if moveNum <= 0 { //坏字符可能产生负数的移位
			moveNum = 1
		}
		i = i + moveNum
	}
	return -1
}

好后缀规则实现

回顾一下,前面讲过好后缀的处理规则中最核心的内容:

  • 在模式串中,查找跟好后缀匹配的另一个子串;
  • 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;

若这两个操作直接使用“暴力”匹配,会使得BM算法效率不高。而好后缀也是模式串本身的后缀子串,所以在正式匹配之前,可以对模式串做预处理操作,预先计算好模式串中的每个后缀子串,对应的另一个可匹配子串的位置。
字符串匹配_第22张图片

  • 通过长度可以唯一确定一个后缀子串。

现在引入suffx数组,数组下标k表示后缀子串的长度,下标对应的数组值表示,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标位置。
字符串匹配_第23张图片

  • 若模式串中有多个(大于1个)子串跟后缀子串{u}匹配,那suffix数组中该存储模式串中最靠后的那个子串的起始位置,也就是下标最大的那个子串的起始位置。

suffx数组可以解决好后缀在模式串中能找到另一个可匹配的情况,但是我们还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。

  • 用bool类型的prefix数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。
    字符串匹配_第24张图片
  • 我们拿下标从0到i的子串(i可以是0到m-2)与整个模式串,求公共后缀子串。
  • 如果公共后缀子串的长度是k,那我们就记录suffix[k]=j(j表示公共后缀子串的起始下标)。
  • 如果j等于0,也就是说,公共后缀子串也是模式串的前缀子串,我们就记录prefix[k]=true。
    字符串匹配_第25张图片
    把suffix数组和prefix数组的计算过程,用代码实现出来,如下所示:
func generateGS(modeStr []byte, suffix []int, prefix []bool) {
	m := len(modeStr)
	for i := 0; i < m; i++ {
		suffix[i] = -1    //默认是找不到和好后缀匹配的子串
		prefix[i] = false //初始化
	}
	for i := 0; i < m-1; i++ { //modeStr[:i],即前缀子串
		j := i
		k := 0                                       //公共后缀子串长度
		for j >= 0 && modeStr[j] == modeStr[m-k-1] { //与modeStr[:m-1]求公共后缀
			j--
			k++
			suffix[k] = j + 1 //j+1表示公共后缀子串在modeStr[:i]中的起始下标,当有多个{u},suffix[k]会被后来者覆盖
		}
		if j == -1 {
			prefix[k] = true //表示有和后缀子串匹配的前缀子串
			//cabcabcab的suffix[3]=3,prefix[3]为true
		}
	}

}

有了这两个数组后,在模式串和主串匹配过程中,遇到不能匹配字符时,根据好后缀规则,移动过程如下:

  • 假设好后缀的长度是k。我们先拿好后缀,在suffix数组中查找其匹配的子串。
  • 如果suffix[k]不等于-1(-1表示不存在匹配的子串),那我们就将模式串往后移动j- suffix[k]+1位(j表示坏字符对应的模式串中的字符下标)。
    字符串匹配_第26张图片
  • 如果suffix[k]等于-1,表示模式串中不存在另一个跟好后缀匹配的子串片段,可如下处理,注意这时prefix[k]中的k是小于suffix[k]中的k。
    字符串匹配_第27张图片
  • 如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移m位。
    字符串匹配_第28张图片
    BM算法的完整版代码如下:
func bm(mainStr []byte, modeStr []byte) int {
	bc := make([]int, SIZE)
	n := len(mainStr)
	m := len(modeStr)
	generateBC(modeStr, m, bc) //构建坏字符哈希
	suffix := make([]int, m)
	prefix := make([]bool, m)
	generateGS(modeStr, suffix, prefix)
	i := 0 //i表示主串与模式串对齐的第一个字符位置
	for i <= n-m {
		j := 0
		for j = m - 1; j >= 0; j-- { //模式串从后往前匹配
			if mainStr[i+j] != modeStr[j] {
				break //坏字符对应模式串中的下标是j
			}
		}
		if j < 0 {
			return i //匹配成功,返回主串与模式串第一个匹配的字符的位置
		}
		x := j - bc[int(mainStr[i+j])] //坏字符规则算出来的移动位数
		y := 0
		if j < m-1 { //如果有好后缀(j = m-1时,表示没有好后缀)
			y = moveByGS(j, m, suffix, prefix) //返回好后缀规则下,模式串移动的位数
		}
		i = i + mathutil.Max(x, y) //坏字符规则和好后缀规则,取移动位数更多的
		//这里i一定大于0,因为若无好后缀,bc[int(mainStr[i+j])]为-1,那坏字符规则得到的移动位数一定大于0
	}
	return -1
}

//j表示坏字符对应的模式串中的字符下标,m表示模式串长度
func moveByGS(j, m int, suffix []int, prefix []bool) int {
	k := m - 1 - j       //好后缀长度
	if suffix[k] != -1 { //模式串中存在和好后缀匹配的子串
		return j - suffix[k] + 1
	}
	
	//这个for循环就是遍历好后缀的后缀子串,看是否存在prefix[m-r]为true的情况
	for r := j + 2; r <= m-1; r++ {
		//j+2若为m,此时表示好后缀长度为1,此时移动m位即可
		if prefix[m-r] == true { //m-r就是好后缀子串的长度
			return r
		}
	}
	//若好后缀的两个规则都不命中,则移动m位
	return m
}

BM算法总结

  • BM算法核心思想是,利用模式串本身的特点,在模式串中某个字符与主串不能匹配的时候,将模式串往后多滑动几位,以此来减少不必要的字符比较,提高匹配的效率。
  • BM算法构建的规则有两类,坏字符规则和好后缀规则。好后缀规则可以独立于坏字符规则使用。因为坏字符规则的实现比较耗内存,为了节省内存,我们可以只用好后缀规则来实现BM算法。

4. KMP算法

  • KMP算法的核心思想,跟上一节讲的BM算法非常相近。我们假设主串是a,模式串是b。
    在模式串和主串匹配过程中,把不能匹配的那个字符叫作坏字符,把已匹配的那段字符串叫作好前缀。
    字符串匹配_第29张图片
  • 当遇到坏字符的时候,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较
    字符串匹配_第30张图片
    KMP算法试图在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?
  • 我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。
  • 假设最长的可匹配的那部分前缀子串是{v},长度是k。我们把模式串一次性往后滑动j-k位,相当于,每次遇到坏字符的时候,我们就把j更新为k,i不变,然后继续比较。
    字符串匹配_第31张图片
  • 我们把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串;对应的前缀子串,叫作最长可匹配前缀子串。
    字符串匹配_第32张图片
  • 求好前缀的最长可匹配前缀子串和后缀子串时,只涉及模式串本身,因此可以预先处理。

KMP算法提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为next数组。

  • 数组下标是每个前缀结尾字符下标,数组值是这个前缀的最长可匹配前缀子串的结尾字符下标
    字符串匹配_第33张图片
  • 笨的方法,比如要计算下面这个模式串b的next[4],我们就把b[0, 4]的所有后缀子串,从长到短找出来,依次看看,是否能跟模式串的前缀子串匹配。很显然,这个方法也可以计算得到next数组,但是效率非常低。
    字符串匹配_第34张图片
如何高效计算next数组?
  • 假设next[i-1]=k,即b[0,k-1]是b[0,i-1]的最长可匹配前缀子串,如果b[0,k-1]的下一个字符b[k]与b[0,i-1]的下一个字符b[i]匹配,那子串b[0,k]就是b[0,i]的最长可匹配前缀子串,所以next[i]=k。
    字符串匹配_第35张图片
  • 若b[0, k-1]的下一字符b[k]跟b[0, i-1]的下一个字符b[i]不相等呢?查看b[0,i-1]的次长可匹配前缀子串的下一个字符是否等于b[i],依次找下去
    字符串匹配_第36张图片
    KMP算法代码完整版如下:
func getNext(modeStr []byte) []int {
	m := len(modeStr)
	next := make([]int, m)
	next[0] = -1 //表示好前缀长度为1,无后缀,next[0]为-1
	k := -1      //初始为-1,表示最开始匹配时,无好前缀一说,还是要逐个字符匹配
	for i := 1; i < m; i++ {
		//如下这个for循环,即非简单+1的情况下,依次找next[k]的最长可匹配前缀子串的结尾字符下标
		for k != -1 && modeStr[k+1] != modeStr[i] { //k!=-1表示当前已有好前缀
			k = next[k]  //相当于求modeStr[:k]的最长可匹配前缀子串
			//为什么是k=next[k],从上图看,y即是这里的k,已知next[i-1]=k,求next[i]
			//因为modeStr[k+1]不等于modeStr[i],故只能看modeStr[:x](x取值为k-1到0)是否与modeStr[i-x:i]是否匹配
			//常规操作是比较modeStr[k-1]是否等于modeStr[i],若不等,再看k-2,依次顺序比较
			//假设modeStr[k-1]等于modeStr[i],那么还要看modeStr[:k-2]是否匹配modeStr[i-k+1:i-1],若不满足
			//则不行,还得往下找,既然一定要满足这个条件,那么可以利用next[k]求出下一个
			//最长可匹配前缀子串的位置,再比对下一个字符是否等于modeStr[i]
		}
		//如下情况,要么是k=-1,要么是modeStr[k+1] == modeStr[i]
		if modeStr[k+1] == modeStr[i] {
			k++
		}
		next[i] = k
	}
	return next
}

func kmp(mainStr, modeStr []byte) int {
	n := len(mainStr)
	m := len(modeStr)
	next := getNext(modeStr)
	j := 0
	for i := 0; i < n; i++ { //i代表主串中与模式串首字符对齐的位置
		for j > 0 && mainStr[i] != modeStr[j] {
			j = next[j-1] + 1 //更新j为模式串下次开始匹配的字符位置
			//next[j-1]表示当前好前缀是modeStr[:j-1]
		}
		if mainStr[i] == modeStr[j] { //j代表模式串中坏字符位置
			j++
		}
		if j == m { //主串中找到匹配串
			return i - m + 1
		}
	}
	return -1
}

KMP算法总结

  • next数组计算的时间复杂度是O(m)
  • 匹配过程的时间复杂度是O(n)
  • KMP算法的时间复杂度是O(n+m)

总结

  • BF算法是最简单、粗暴的字符串匹配算法,时间复杂度也比较高,是O(n*m),n、m表示主串和模式串的长度。不过,在实际的软件开发中,因为这种算法实现简单,对于处理小规模的字符串匹配很好用。
  • BM(Boyer-Moore)算法。它是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的KMP算法的3到4倍。

leetcode字符串习题

https://leetcode-cn.com/tag/string/

你可能感兴趣的:(数据结构与算法之美)