【数据结构与算法】->算法->字符串匹配基础(上)->BF 算法 & RK 算法

Ⅰ 前言

字符串匹配这个功能,对于任何一个开发工程师来说,应该都不陌生。我们用的最多的就是编程语言中提供的字符查找函数,比如 Java 中的 indexOf(),Python 的find()等等,它们的底层就是接下来我们要说的字符串匹配算法。

字符串匹配算法很多,分单模式串匹配和多模式串匹配,这篇文章我们就来说说两个相对来说比较简单、好理解的单模式串匹配,它们分别是 BF 算法 和 RK 算法。

RK 算法其实相当于是 BF 算法的改进,巧妙地借助了哈希算法,让匹配的效率有了很大的提示。现在我们来进入正题,看看这两个算法。

Ⅱ BF 算法

A. 原理

BF 算法中的 BF 是 Brute Force 的缩写,翻译过来叫作 暴力匹配算法,也叫朴素匹配算法。顾名思义,这个算法的匹配方式比较“暴力”,因而也就会比较简单,但是相应的性能也会不高。

在讲解之前,我们先引入两个概念:主串模式串。这两个概念很好理解,比如我们在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。我们把主串的长度记作 n,模式串的长度记作 m,因为我们是在主串中查找模式串,所以 n > m。

作为最简单、最暴力的字符串匹配算法,BF 算法的思想可以用一句话来概括:我们在主串中,检查起始位置分别是 0、1、2… n - m 且长度为 m 的 n - m + 1 个子串,看有没有和模式串匹配的。

我用下图作个例子,来说明这个思路。

在这里插入图片描述
从上面的算法思想和例子可以想到,在极端情况下,比如主串是 “aaaaaaa…aaaaa”,模式串是 “aaaaaaab”,我们每次都对比 m 个字符,要对比 n - m + 1 次,所以,这种算法的最坏情况时间复杂度是是 O(n * m)。

尽管理论上,BF 算法的时间复杂度很高,但在实际开发中,BF 算法是一个很常用的算法,有两个原因:

第一,实际的软件开发中,大部分情况下,主串和模式串的长度都不会太长。而且每次模式串和主串中的子串匹配的时候,当途中遇到不能匹配的字符的时候,就可以停止了,不需要把 m 个字符都对比一下。所以,尽管理论上算法的最坏情况时间复杂度是 O(n * m),但是在大部分情况下,算法的执行效率要比这个高得多。

第二,朴素字符串匹配算法思想简单,代码实现也非常简单,所以不容易出错,而且 bug 也容易暴露和修复。在工程中,我们在满足性能要求的前提下,简单是首选。这也是我们常说的 KISS(Keep it Simple and Stupid) 设计原则。

所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。

B. 代码实现

BF 算法比较简单,我直接将代码贴出,供大家参考。

package com.tyz.string_matching.core;

public class BruteForce {

	public BruteForce() {
	}
	
	/**
	 * BF算法
	 * @param strOne 主串
	 * @param strTwo 模式串
	 * @return
	 */
	public static int bruteForce(String strOne, String strTwo) {
		char[] arrA = strOne.toCharArray();
		char[] arrB = strTwo.toCharArray();
		int m = arrA.length;
		int n = arrB.length;
		int k;
		
		for (int i = 0; i < m-n+1; i++) {
			k = 0;
			for (int j = 0; j < n; j++) {
				if (arrA[i + j] == arrB[j]) {
					k++;
				} else {
					break; //如果出现主串与模式串的不匹配,直接跳出这轮循环,模式串向后移动
				}
			}
			if (k == n) {
				return i;
			}
		}
		
		return -1;
	}

}


Ⅲ RK 算法

A. 原理

PK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。这个算法理解起来也不难,它就相当于是 BF 算法的升级版。

在前面的 BF 算法中,如果模式串长度为 m,主串长度为 n,那么在主串中,就会有 n-m+1 个长度为 m 的子串,我们只需要暴力地对比这 n-m+1 个子串与模式串,就可以找出主串与模式串匹配的子串。

但是,每次检查主串与子串是否匹配,需要依次对比每个字符,所以 BF 算法的时间复杂度就比较高,是 O(n * m)。我们对朴素的字符串匹配算法稍加改造,引入哈希算法,时间复杂度就会立刻降低。

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

在这里插入图片描述
不过,通过哈希算法计算子串的哈希值的时候,我们需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。有没有方法可以提高哈希算法计算子串哈希值的效率呢?

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

我们来看一个具体的例子。

比如要处理的字符串中只包含 a ~ z 这 26 个小写字母,那我们就用二十六进制来表示一个字符串。我们把 a ~ z 这 26 个字符映射到 0 ~ 25 这 26 个数字,a 就表示数字 0,b 就表示 1,以此类推,z 表示 25。

那么 abc = 0 * 26 * 26 + 1 * 26 + 2 = 28 。

关于进制转化,可以看我下面这篇文章,我不再赘述。

【程序员必修数学课】->基础思想篇->二进制->原码&反码&补码的数学论证

这就是我们设计的一个哈希算法。现在,我假设字符串中只包含 a ~ z 这二十六个字母,我们用二十六进制来表示一个字符串,对应的哈希值就是二十六进制数转化成十进制的结果。

这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定的关系。

比如有两组字符串:

在这里插入图片描述
从上面的例子中,我们很容易就能得到这样的规律:相邻两个字串 s[i-1] 和 s[i] (i 表示子串在主串中的起始位置,子串的长度都为 m),对应的哈希值计算公式有交集,也就是说,我们可以使用 s[i-1] 的哈希值很快地算出 s[i] 的哈希值。

在这里插入图片描述
这里还有个小细节需要注意,就是 26 m-1 这部分的计算,我们可以通过查表的方式来提高效率。我们事先计算好 260,261,262…… 26m-1 ,并且存储在一个长度为 m 的数组中,公式中的“次方”就对应数组的小标。当我们需要计算 26 的 x 次方的时候,就可以从数组的下标为 x 的位置取值,直接使用,省去了计算的时间。

这个思想我在我之前的代码优化中也用到了,感兴趣的同学可以跳转过去看看。

【C语言基础】->哥德巴赫猜想验证->筛选法->算法极限优化之你不可能比我快

【C语言基础】->自幂数优化->这个算法快得像一道闪电

在这里插入图片描述
在前面我们说,RK 算法的效率要比 BF 算法高,现在,我们就来分析一下,RK 算法的时间复杂度。

整个 RK 算法包含两部分,计算子串哈希值和模式串哈希值与子串哈希值之间的比较。第一部分,通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以这部分的时间复杂度是 O(n)。

模式串哈希值与每个子串哈希值之间的比较的时间复杂度是 O(1),总共需要比较 n-m+1 个子串的哈希值,所以,RK 算法整体的时间复杂度就是 O(n)。

这里还有一个问题,模式串很长的话,相应的主串中的子串也会很长,通过上面的哈希算法得到的哈希值就可能很大,如果超过了计算机中整形数据可以表示的范围,那要如何解决呢?

刚刚我们设计的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。实际上,我们为了能将哈希值落在整形数据范围内,可以牺牲一下,允许哈希冲突。这个时候哈希算法该怎么设计呢?

哈希算法的设计方法有很多,我举一个例子。比如我们用每个字母代替一个数字,a 对应 1,b 对应 2 …… z 对应 26。我们可以把字符串中每个字母对应的数字相加,最后得到的和作为哈希值。这种哈希算法产生的哈希值的数据范围就相对要小很多了。

不过,我们应该可以预见,这种哈希算法的哈希冲突的概率挺高的,不过我只是举了一个小例子,还有很多很多方法,比如将每个字母对应从小到大一个素数等等,这样冲突的概率会降低一些。

但是新的问题就随即产生了。之前我们只需要比较一下模式串和子串的哈希值,如果两个值相等,那这个子串就一定可以匹配模式串。但是,当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。

实际上,解决方法很简单。当我们发现一个子串的哈希值跟模式串的哈希值相等的时候,我们只需要再对比一下子串和模式串本身就好了。当然,如果子串的哈希值与模式串的哈希值不相等,那对应的子串和模式串肯定也是不匹配的,就不需要比对子串和模式串本身了。

所以,哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致 RK 算法的时间复杂度退化,效率下降,极端情况下,如果存在大量的冲突,每次都要再对比子串和模式串本身,那时间复杂度就会退化成 O(n*m)。不过,一般情况下,冲突不会很多,RK 算法的效率还是比 BF 算法高的。

B. 代码实现

RK 算法的代码也比较简单,我就采用我们一开始说的二十六进制的方法来设计哈希函数,代码如下👇

package com.tyz.string_matching.core;

public class RabinKarp {

	public RabinKarp() {
	}
	
	/**
	 * RK 算法
	 * @param strOne 主串
	 * @param strTwo 模式串
	 * @return
	 */
	public static int rabinKarp(String strOne, String strTwo) {
		char[] arrOne = strOne.toCharArray();
		char[] arrTwo = strTwo.toCharArray();
		int m = arrOne.length;
		int n = arrTwo.length;
		
		int[] table = new int[26]; //快速取出26的次方
		int[] hash = new int[m - n + 1]; //主串中的子串的哈希值,用作和模式串进行比较
		
		int temp = 1;
		int value = 0; //模式串的哈希值
		
		for (int i = 0; i < 26; i++) {
			table[i] = temp;
			temp *= 26;
		}
		
		for (int i = 0; i < m-n+1; i++) {
			temp = 0;
			for (int j = 0; j < n; j++) {
				temp += (arrOne[i+j] - 'a') * table[n-j-1];
			}
			hash[i] = temp;
		}
		
		for (int i = 0; i < n; i++) {
			value += (arrTwo[i] - 'a') * table[n - i - 1];
		}
		
		for (int i = 0; i < hash.length; i++) {
			if (hash[i] == value) {
				return i;
			}
		}
		
		return -1;
	}

}

若对更难的 BM 和 KMP 算法有兴趣的同学可以跳转到我下面两篇文章👇

【数据结构与算法】->算法->字符串匹配基础(中)->BM算法->KMP 三倍性能的强大算法

【数据结构与算法】->算法->字符串匹配基础(下)->KMP 算法

另,这篇文章的内容来源于极客时间王争的《数据结构与算法之美》。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值