字符串匹配问题也算是我们开发过程中经常遇到的一个问题,今天我们总结几个常用的字符串匹配的方式:
BF(brute Force)暴力匹配。
这种模式暴力简单,从第一个字符开始匹配,如果不匹配了就,从第二个字符开始再试,依次类推指定匹配成功或者匹配到最后。
如下图所示:
image.png
这种方式的优势就是代码简单,直观,在匹配字符串不是很长的时候可以使用;缺点就是会重复比较很多次。
RK算法
这个算法是有Robin和Karp发明的,所以以他们的名字命名了这个算法,它其实是对BF算法的升级版。BF算法最多要比较 n-m + 1次,RK算法的思路是提前将这n-m+1个子串求hash,然后通过比较hash值是否相等来判断字符串是否匹配。因为hash值是一个整数,其比较起来会比字符串比对快很多。如果hash值不相等那肯定匹配不成功,如果相等也可能是因为hash冲突,所以这时还需要做一次字符串匹配。
BM算法
我们先来看个例子
image.png
如上图所示,当我们进行字符串匹配是,主串中的c和模式中的d不匹配,此时按照BF算法,应该是往后移动一位,去和bca比较。但是我们通过观察发现,模式串中并么有c这个字符,所以完全可以直接跳到c后面的字符开始比较。这样就跳过了很多无用的比较。BM算法用的就是这个思想。
BM算法包含两部分坏字符规则(bad character rule), 好后缀规则(good suffix shift),下面我们依次来看着两部分
坏字符规则(bad character rule )
BM算法在做字符匹配的时候和我们习惯的匹配顺序不同,它是从后往前匹配
image.png
如图所示习惯我们是先匹配第一个字符,然后第二,第三个;BM算法是先匹配第三个,然后是第二,第一个。
它从后往前匹配,我们将第一个没有匹配上的主串中的字符称作坏字符。 上图中字符c即为坏字符。
接下来我们拿字符c在模式串中查找,注意,这里也是从后往前匹配。发现c在模式串中并不存在。这是我们可以安全的将字符串往后滑动3个字符,然后重新开始匹配
image.png
再次重后往前重新匹配,这时找到的坏字符是a,我们接着拿a在模式字符串中匹配,和字符串中的第0个字符串匹配了。所以此时我们滑动2个字符,让主串中的a和模式串中的a对齐。
这里需要注意的是,如果模式串中坏字符出现了多次,此时我们选择最靠右的那个字符和坏字符对齐。
为了方便叙述,这里我们引入几个概念。我们把坏字符对应的模式串中的字符下标记为si; 把坏字符在模式串中匹配的字符的下标记为xi;如果没找到则xi为-1;模式串往后移动的位数就是si - xi.
image.png
在上面的例子中,si为2,xi为0,所以移动2-0=2个字符;
通过这种方式我们可以跳过很多无用的比较,但是也可能引入问题,那就是当si < ci的时候,可能导致字符串往后退。如aaaaaaaa和baaa匹配的时候,b和a不匹配,此时si为0,xi为3, si-xi=-3.所以我们需要再引入另外一个规则好后缀规则
好后缀规则:我们把模式串中已经匹配的那部分字符称为好后缀,如下图所示bc即为好后缀,记为{u}。
image.png
上述匹配结束之后,如果我们采用坏字符规则去定位下一次匹配的位置显然是不合适的。
我们看好后缀规则是如何工作的?
我们拿好后缀{u}去模式串里面匹配,如果匹配到字符串{u},那么我就滑动动到{u}和{u}对齐的位置,如下图所示
image.png
如果模式串中没有字符串与{u}匹配,我们该如何滑动呢?
image.png
如上图所示,我们并不能够直接滑动到{u}后面,因为c可与和模式串的第一个字符匹配。所以这里又引出来另外一种情况:当{u}的后缀串可以和模式串的前缀串匹配时,我们找到最长的匹配串{v},并将它们对齐。如下图所示
image.png
如果没有字符可以匹配,那么直接跳到{u}后面即可。
有了坏字符和好后缀两个规则之后,我们就可以分别计算两者需要滑动的位数,然后取最大的一个最为真正滑动的位数,这样就避免了坏字符的向后滑动问题,同时保证了尽量多的向前滑动。
算法实现
接下来我们看看算法实现:
- 坏字符规则:其中最麻烦的就是定位坏字符在模式串中的位置,如果每次都挨个字符去比对,显然效率不高。这里我们可以给模式串构建一个散列表,这样就可以很快的定位坏字符所对应的下标索引了
java 代码实现如下:
public HashMap generateBC(char[] pattern){
HashMap hashMap = new HashMap();
if(pattern == null || pattern.length == 0){
return hashMap;
}
for(int i = 0; i < pattern.length; i++){
hashMap.put(pattern[i], i);
}
return hashMap;
}
public int bm(char[] source, char[] pattern){
HashMap hashMap = generateBC(pattern);
if(source == null || pattern == null) return -1;
int start = 0;
while(start <= source.length - pattern.length){
int index = pattern.length - 1;
while(index >= 0){
if(source[start + index] != pattern[index]){
break;
}
index--;
}
if(index < 0) return start;
int si = index;
int xi = (hashMap.get(source[start + index])) == null? -1 : hashMap.get(source[start + index]);
start = start + (si - xi);
}
return -1;
}
- 好后缀规则: 这里我们也是提前做一个预处理,把每个可能的好后缀对应的匹配位置提前计算出来;以及可能的前缀也标出来。
最好将坏字符,好后缀合并起来的java code如下
package string;
import java.util.HashMap;
public class BM {
public HashMap generateBC(char[] pattern){
HashMap hashMap = new HashMap();
if(pattern == null || pattern.length == 0){
return hashMap;
}
for(int i = 0; i < pattern.length; i++){
hashMap.put(pattern[i], i);
}
return hashMap;
}
public int bm(char[] source, char[] pattern){
HashMap hashMap = generateBC(pattern);
int[] suffix = new int[pattern.length];
boolean[] prefix = new boolean[pattern.length];
generatesGS(pattern, suffix, prefix);
if(source == null || pattern == null) return -1;
int start = 0;
while(start <= source.length - pattern.length){
int index = pattern.length - 1;
while(index >= 0){
if(source[start + index] != pattern[index]){
break;
}
index--;
}
if(index < 0) return start;
//bad character move step
int si = index;
int xi = (hashMap.get(source[start + index])) == null? -1 : hashMap.get(source[start + index]);
int bcStep = si - xi;
//good suffix move step
int gsStep = 0;
if(index < pattern.length - 1){
gsStep = moveByGS(index, pattern.length, suffix, prefix);
}
start = start + Math.max(bcStep, gsStep);
}
return -1;
}
private int moveByGS(int index, int length, int[] suffix, boolean[] prefix) {
int suffixLength = length - index - 1;
if(suffix[suffixLength - 1] != -1){
return suffixLength;
}
while(suffixLength > 0){
if(prefix[suffixLength - 1]){
return suffixLength;
}
suffixLength--;
}
return suffixLength;
}
public void generatesGS(char[] pattern, int[] suffix, boolean[] prefix){
for(int i = 0; i < pattern.length; i++){
suffix[i] = -1;
prefix[i] = false;
}
//find the common suffix for pattern[0, i] with pattern[0, pattern.length-1]
for(int i = 0; i < pattern.length; i++){
int matchIndex = i;
int matchedLength = 0;
while(matchIndex >= 0 && pattern[matchIndex] == pattern[pattern.length - 1 - matchedLength]){
suffix[matchedLength] = matchIndex;
matchedLength++;
matchIndex--;
}
if(matchIndex < 0){
prefix[i] = true;
}
}
}
}
KMP算法
KMP思路和BM类似也是尽可能的多往前多滑动几位。在KMP匹配算法中,我们是从前往后匹配,将没有匹配的字符叫做坏字符,已经匹配的字符串叫做好前缀。
image.png
如上图所示,当我们遇到坏字符时,如果好前缀的后缀能够和模式串的前缀相匹配,那么我们只需要将它们对齐,然后再匹配,如下图所示。
image.png
通过观察我们可以发现,当好前缀是ababa时,只需要将模式串向前移动两个字符,然后接着匹配即可。这个过程是固定不变的,即每次好前缀是ababa时都是向前滑动两位,然后接着匹配。同理,对于任意一个好前缀我们都可以提前计算出它应该向前滑动的距离来。
如下图所示,当好前缀是a的时候,它没有后缀;当好前缀是ab时,它的后缀b不能喝模式串的前缀匹配;当好前缀是aba时,它的后缀a可以和模式串前缀a匹配;依次类推,我们可以构造出来一个表来存储每个好前缀在遇到坏字符时应该怎么移动。有了这个表我们就知道了什么时候应该怎样移动。
image.png
java code实现如下
public int kmp(char[] source, char[] pattern) {
if (source == null || pattern == null) return -1;
int[] next = getNext(pattern);
int index = 0;
for (int sIndex = 0; sIndex < source.length; sIndex++) {
while (index > 0 && source[sIndex] != pattern[index]) {
index = next[index - 1] + 1;
}
if (source[sIndex] == pattern[index]) {
index++;
}
if (index == pattern.length) {
return sIndex - pattern.length + 1;
}
}
return -1;
}
private int[] getNext(char[] pattern) {
if(pattern == null) return null;
int[] next = new int[pattern.length];
for(int i = 0; i < next.length; i++){
next[i] = -1;
}
int k = -1;
for(int i = 1; i < pattern.length; i++){
while(k != -1 && pattern[k + 1] != pattern[i]){
k = next[k];
}
if(pattern[k + 1] == pattern[i]){
k++;
}
next[i] = k;
}
return next;
}