Ⅰ 前言
字符串匹配这个功能,对于任何一个开发工程师来说,应该都不陌生。我们用的最多的就是编程语言中提供的字符查找函数,比如 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语言基础】->哥德巴赫猜想验证->筛选法->算法极限优化之你不可能比我快
在前面我们说,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 算法
另,这篇文章的内容来源于极客时间王争的《数据结构与算法之美》。