算法之字符串模式匹配

算法之字符串模式匹配

导读

字符串模式匹配是常见的算法之一,在实际生活中有较高的使用频率。本文详细介绍两种最常见的字符串模式匹配算法:

  • 朴素模式匹配
  • KMP模式匹配

字符串模式匹配,也称子串的定位操作,通俗的说就是在一个主串中判断是否存在给定的子串(又称模式串),若存在,则返回匹配成功的索引。如:

主串:zhuanlanzhihu

子串:zhihu

主串中包含子串"zhihu",说明匹配成功,且返回的索引为:8

:本文所有出现的字符串的下标都是从0开始标记,并用Java语言实现算法。

朴素模式匹配

串的朴素模式匹配算法也称为BF(Brute-Force)算法,其基本思想是:从主串的第一个字符起与子串的第一个字符进行比较,若相等,则继续逐对字符进行后续的比较;若不相等,则从主串第二个字符起与子串的第一个字符重新比较,以此类推,直到子串中每个字符依次和主串中的一个连续的字符序列相等为止,此时称为匹配成功。如果不能在主串中找到与子串相同的字符序列,则匹配失败。BF算法是最原始、最暴力的求解过程,但也是其他匹配算法的基础。下面通过具体Demo演示该算法的基本思想。

主串:zhihzhiuzhihu

子串:zhihiu

:绿颜色代表匹配成功的字符,红颜色代表匹配失败的字符

首先,将主串的第一个字符与子串的第一个字符进行比较,即主串中的第一个字符'z'与子串的第一个字符'z'进行比较,二者相等,依次继续比较,主串第一个字符后面的'h'、 'i' 、'h'分别与子串第一个字符后面的'h' 、'i'、 'h'进行比较,都分别对应相等,继续比较主串的'z'与子串的'u',因为'z'与'u'不相等,则趟匹配失败。

这时,将主串的指针回溯到第一次比较开始字符的下一个字符即'h',子串从第一个字符'z'与'h'比较,'z'与'h'不相等,进行下一趟比较。

同理依次比较,主串的'i'与'z'不相等,本趟匹配失败。继续从主串的下一个字符'h'与子串的第一个字符'z'进行比较,'h'与'z'不相等,本趟匹配失败。

同理,继续从主串的下一个字符'z'与子串的'z'比较,相等,继续逐次对应比较,'h'与'h'相等,'i'与'i'相等,但后面的对应的'u'与'h'不相等,匹配再次失败。

主串需要回溯到'z'的下一个字符'h'处,子串从头来继续匹配,即'h'与'z'不相等;主串的下一个字符继续与子串第一个字符比较,即'i'与'z'比较不相等;主串的下一个字符继续与子串第一个字符比较,即'u'与'z'不相等。

最后,主串的下一个字符'z'与子串的第一个字符'z'比较相等,继续逐次比较,这时发现对应相同位置的字符都相等,至此,在主串中成功匹配子串,并且位置为:8

根据算法的基本思想,编写完整的BF代码,为了方便起见,测试使用main()方法。

import java.util.Scanner;

public class BF {
	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		System.out.println("Please input main string:");
		String mainString = scanner.nextLine();// 从标准输入读取主串
		System.out.println("Please input sub string:");
		String subString = scanner.nextLine();// 从标准输入读取子串
		scanner.close();
		BF.match(mainString, subString);
	}

	/**
	 * @param s
	 *            主串
	 * @param t
	 *            子串
	 */
	public static void match(String s, String t) {
		int index = 0;// 成功匹配的位置
		int sLength = s.length();// 主串长度
		int tLength = t.length();// 子串长度
		if (sLength < tLength) {
			System.out.println("Error.The main string is greater than the sub string length.");
			return;
		}
		int i = 0;
		int j = 0;
		while (i < sLength && j < tLength) {
			if (s.charAt(i) == t.charAt(j)) {// 判断对应位置的字符是否相等
				i++;// 若相等,主串、子串继续依次比较
				j++;
			} else {// 若不相等
				i = i - j + 1;// 主串回溯到上次开始匹配的下一个字符
				j = 0;// 子串从头开始重新匹配
			}
		}
		if (j >= tLength) {// 匹配成功
			index = i - j;
			System.out.println("Successful match,index is:" + index);
		} else {// 匹配失败
			System.out.println("Match failed.");
		}
	}
}

测试结果:



KMP模式匹配

KMP模式匹配算法,是一个效率非常高的字符串匹配算法。其全称是Knuth–Morris–Pratt string searching algorithm,是由三个人于1977年共同发表的,其中就有他:

他是谁呢?他就是高德纳(Donald Ervin Knuth),堪称计算机科学理论与技术的经典巨著《计算机程序设计的艺术》的作者,这本书被《美国科学家》杂志列为20世纪最重要的12本物理科学类专著之一,与爱因斯坦《相对论》、狄拉克《量子力学》、理查·费曼《量子电动力学》等经典比肩而立,他也荣获1974年度的图灵奖。闲话少说,回归正题。在学习KMP算法之前需要明白什么是字符串的前缀后缀,以及前后缀相同元素长度概念。

前缀:指字符串中除了最后一个字符以外,其余字符的全部头部顺序组合。

后缀:指字符串中除了第一个字符以外,其余字符的全部尾部顺序组合。


前后缀相同元素长度:指字符串所有的前缀与后缀字符依次相同的长度。

首先,KMP算法是对传统BF算法的改善,怎么改善的呢?在BF算法中,每当主串与子串对应位置的字符匹配失败时,主串的位置指针就往前回溯,子串位置指针从头开始,然后重新匹配。实质上,每当匹配失败时可以得出两个结论:

  • 本趟匹配失败
  • 子串当前匹配失败字符之前的字符是匹配成功的

BF算法正是没有利用第二条结论的信息,所以效率低。而KMP算法充分利用了第二条结论的信息,从而避免一些明显不合法的移位,加快匹配过程。如:

当'D'与'A'匹配失败,按照BF算法是不假思索的把子串整体右移一位,主串不动,然后再逐次对应比较。而KMP算法的核心思想是尽可能的让子串向右远移,每次匹配失败后子串向右移动越远,比较的次数减少了,算法的性能自然就提高了。

先看子串的子串的各个前缀后缀的最大相同元素长度分别为:

那么究竟如何判断每次匹配失败后子串需要尽可能地右移多少位呢?这时,我们观察子串失配位置前的字符,也就是:ABDAB这部分,ABDAB的前后缀相同元素是:AB,也就是说ABDAB的前后缀相同元素长度为:2,根据上表可知,失配时,子串向右尽可能远移的位数为:已匹配字符数 - 失配字符的上一位字符所对应的最大长度值,即需要右移3位,而这比BF算法每次失配只右移一位少了两趟的比较,效率显而易见。这是通过前后缀相同元素长度来判断的,为了能用算法实现该过程,需要引入next数组,next数组表示子串当前匹配失败字符之前的字符串(子串的子串)的前后缀相同元素长度 ,根据上图,可求得next数组如下:

不难发现,next数组是将前后缀串相同元素长度整体向右移一位,并人为规定next数组的第一个元素值是-1,因为一个字符串的第一个字符的前面不可能有其他字符,所以就不会存在所谓的前后缀相同元素,同理,一个字符串的第二个字符前只有一个字符,并且前缀与后缀只能是字符串的真子集,所以由一个字符构成的字符串是没有前缀与后缀的,也就是说前后缀相同元素长度为0。即:next[0]=-1,next[1]=0

:也有人把next[0]规定为0,这并不影响算法的实现

求出了next 数组后,从而有:每当主串与子串对应字符失配时,子串整体向右移动的最大位数是:失配字符所在的位置 - 失配字符对应的next[]数值

原理说完,接下来就是最核心的内容是如何通过算法描述next[]数组。

设子串为“p0p1...pm-1”,当子串的字符pj与主串中相应的字符Si不相等时,因其前j个字符(p0p1...pj-1)已经获得正确匹配,所以若子串中的“p0p1...pk-1”与“pj-kpj-k+1...pj-1”相同,这时让Si与pk进行比较。所以next[]数组应当为:


根据上述公式,next[]数组的第一个元素值是-1,并且子串当前字符匹配失败时子串前面已匹配正确的所有字符构成的字符串若没有没有共同前后缀(前缀完全与后缀相等),即前后缀相同元素长度为0,即当前匹配失败的位置的next[]数值为0,所以说白了这里的other就是指的是匹配失败时子串的子串没有相同的前后缀。这里最难的是公式的中间部分如何求解max(k),很多博客或书上都是使用两个变量去求解,让人难以理解,这里我用一个变量j去求解。

从左到右求解一个字符串的各个位置的next[]值,每当右移一个字符,新增的字符X(t.charAt(j-1))需要用到上一个字符B的next[]值,因为B的next[]存放着子串(模式串)的B之前的子串的前后缀相同元素长度,而字符串下标是从0开始的,所以这个长度值在字符串中的指向的是Y(t.charAt(next[j-1])) ,归根结底next[j]是与next[j-1]有关的。详细分成这三种情况:

若 t.charAt(next[j-1])==t.charAt(j-1)为真,即上图中的Y与X相等,则当前的next[j]=next[j-1]+1

若 t.charAt(next[j-1])==t.charAt(j-1)为假,即上图中的Y与X不相等,这是需要考虑X是否该串的第一个字符相等,若相等,next[j]=1

如果上述的两种情况都不成立,也就是说子串(模式串)截止到前一个字符的子串没有共同元素的前后缀,换句话说就是前后缀相同元素长度为0,即next[j]=0

以下是代码实现next[]数组:

	/**
	 * @param t
	 *            要生成next[]数组的字符串,在KMP算法中是子串(模式串)
	 * @return 给定字符串的next[]数组
	 */
	public static int[] next(String t) {
		int[] next = new int[t.length()];
		next[0] = -1;// 这里规定next[]第1个元素值为-1
		next[1] = 0;
		int j = 2;// 从给定的字符串的第3个字符(下标从0开始)开始计算next[j]数值
		while (j < t.length()) {// 从第三个字符开始逐次求解对应的next[]值
			if (t.charAt(next[j - 1]) == t.charAt(j - 1)) {// 判断Y与X是否相等(X,Y对应上图,下同)
				next[j] = next[j - 1] + 1;// 若Y与X相等,当前next[j]为上一个next[]数组值加1
				j++;// 开始自增一个字符,准备下一次求解
			} else if (t.charAt(0) == t.charAt(j - 1)) {// 若不相等,判断X与子串(模式串)第一个字符是否相同
				next[j] = 1;// 若相同,找到一个长度为1的相同前后缀,即next[j]为1
				j++;
			} else {// 若上述两个条件都不满足,即意味着没有相等的前后缀,即next[j]为0
				next[j] = 0;
				j++;
			}
		}
		return next;
	}

但是上面的代码细细思考,会发现一个小小的问题。在while循环内部的if语句判断时,t.charAt(next[j-1])==t.charAt(j-1) 当next[j-1]正好等于0时,与下面的else if(t.charAt(0)==t.charAt(j-1))是重复判断的,为了使代码更精炼,应该考虑在第一个if判断语句中添加一个限制条件,即:next[j-1]!=0 这样组成if (next[j-1]!=0 && t.charAt(next[j - 1]) == t.charAt(j - 1)),这样我们就完成了用一个变量j算出next[]数组,大功告成。


求解出最关键的next[]后,只需要对BF算法基础上略加修改,每次匹配失败时,获得该位置的next[]数值,将这个数值赋予变量j(子串的指针)即可。这样就能保证每次匹配失败后让子串右移尽可能远的距离,这也就是KMP算法的核心所在。


以下是KMP算法的完整代码,同样也是在main()中做了测试:


import java.util.Scanner;

/**
 * @author RunDouble
 * 
 */
public class KMP {

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		System.out.println("Please input main string:");
		String mainString = scanner.nextLine();// 从标准输入读取主串
		System.out.println("Please input sub string:");
		String subString = scanner.nextLine();// 从标准输入读取子串
		scanner.close();
		KMP.kmp(mainString, subString);
	}

	/**
	 * @param t
	 *            要生成next[]数组的字符串,在KMP算法中是子串(模式串)
	 * @return 给定字符串的next[]数组
	 */
	public static int[] next(String t) {
		int[] next = new int[t.length()];
		next[0] = -1;// 这里规定next[]第1个元素值为-1
		next[1] = 0;
		int j = 2;// 从给定的字符串的第3个字符(下标从0开始)开始计算next[j]数值
		while (j < t.length()) {// 从第三个字符开始逐次求解对应的next[]值
			if (next[j - 1] != 0 && t.charAt(next[j - 1]) == t.charAt(j - 1)) {// 判断Y与X是否相等(X,Y对应上图,下同)
				next[j] = next[j - 1] + 1;// 若Y与X相等,当前next[j]为上一个next[]数组值加1
				j++;// 开始自增一个字符,准备下一次求解
			} else if (t.charAt(0) == t.charAt(j - 1)) {// 若不相等,判断X与子串(模式串)第一个字符是否相同
				next[j] = 1;// 若相同,找到一个长度为1的相同前后缀,即next[j]为1
				j++;
			} else {// 若上述两个条件都不满足,即意味着没有相等的前后缀,即next[j]为0
				next[j] = 0;
				j++;
			}
		}
		return next;
	}

	public static void kmp(String s, String t) {
		int[] next = next(t);// 调用next(String t)方法
		int index = 0;// 成功匹配的位置
		int sLength = s.length();// 主串长度
		int tLength = t.length();// 子串长度
		if (sLength < tLength) {
			System.out.println("Error.The main string is greater than the sub string length.");
			return;
		}
		int i = 0;
		int j = 0;
		while (i < sLength && j < tLength) {
			/*
			 * 如果j = -1, 或者当前字符匹配成功(即s.charAt(i) == t.charAt(j)), 都令i++,j++
			 */
			if (j == -1 || s.charAt(i) == t.charAt(j)) {
				i++;
				j++;
			} else {
				/*
				 * 如果j != -1,且当前字符匹配失败, 则令 i 不变,j = next[j], next[j]即为j所对应的next值
				 */
				j = next[j];
			}
		}
		if (j >= tLength) {// 匹配成功
			index = i - j;
			System.out.println("Successful match,index is:" + index);
		} else {// 匹配失败
			System.out.println("Match failed.");
		}
	}
}



测试结果:

总结

目前所做的字符串模式匹配只是匹配第一个子串出现的位置,要想匹配所有的子串需要对代码略加修改。最后提一下两种算法的时间复杂度。设主串与子串长度分别为m和n,BF算法在最坏的情况下,每一趟不成功的匹配都是在子串的最后的一个字符与主串中相应的字符匹配失败,即在最坏情况下时间复杂度为:O(n*m)。最好的情况是,子串所有字符匹配成功之前的所有次不成功匹配都是子串的第一个字符与主串相应的字符不相同,可得O(n+m)。对于KMP算法的时间复杂度为:O(n)。现在回过头来看KMP算法,只要抓住“主串不回溯,子串往远移”这句话,尽量减少主串与子串的匹配次数以达到快速匹配的目的,问题就能迎刃而解了。具体的实现关键在于如何实现一个next[]数组,这个数组本身包含了子串的局部匹配信息。当然还有其他的串的模式匹配算法,如Boyer-MooreRabin-Karp等字符串查找算法。

到此,本文结束,祝各位新年健康幸福。

编辑于 2017-01-03

文章被以下专栏收录