算法--字符串模式匹配相关算法

373 阅读5分钟

声明:以下内容绝大部分摘自《阮一峰的网络日志》

引入主题

  思考一下:这个查找功能底层你会怎么实现?

一、模式匹配

  【百度百科】模式匹配是数据结构中字符串的一种基本运算,给定一个子串,要求在某个字符串中找出与该子串相同的所有子串,这就是模式匹配。 假设P是给定的子串,T是待查找的字符串,要求从T中找出与P相同的所有子串,这个问题成为模式匹配问题。P称为模式,T称为目标。如果T中存在一个或多个模式为P的子串,就给出该子串在T中的位置,称为匹配成功;否则匹配失败。

二、暴力算法(Brute Force)/Naive Algorithm

1、算法思想:逐位匹配,假设主串的位置i子串的位置j,如果有位置j和位置i的字符相等的话,i++, j++。如果匹配失败,则回溯到主串的下一个位置重新逐位匹配。

  上图来源《大话数据结构》

2、代码实现(Java)

/**
 * 朴素的模式匹配算法
 * @author admin
 */
public class BruteForce {
	/**
	 * 返回子串t在主串s中第pos个字符后的位置。若不存在返回-1
	 */
	int index(String text, String pattern) {
		// 判空
		if(null == text || null == pattern ){
			return -1;
		}
		int n = text.length();
		int m = pattern.length();
		for (int s = 0; s <= n - m; s++) {
			boolean isEqual = true;
			int i = 0;
			while(isEqual && i < m){
				if(text.charAt(s + i) == pattern.charAt(i)){
					i++;
				}else{
					isEqual = false;
				}
			}
			if(isEqual){
				return s;
			}
		}
		return -1;
	}

	public static void main(String[] args) {
		BruteForce sample = new BruteForce();
		int a = sample.index("goodgoogle", "google");
		if (a == -1) {
			System.out.println("匹配失败");
		}else {
			System.out.println("匹配成功,开始下标为:" + a);
		}
	}
}

3、性能分析(设n为主串长度,m为模式串长度)

  时间复杂度分析如下:

  最好的情况:O(1) T:googlegood P: google 一次完成

  最坏的情况:O((n-m+1)*m) T:aaaaaaaaaaaaab P: aaab 每次都到最后一个才失败,总比较次数(n-m+1)m

  空间复杂度为O(1)

  这是最直接也是大多数人首先想到的,想一想应该还有更好的方法。

二、KMP算法(Knuth-Morris-Pratt Algorithm)

1、举个栗子

  T: BBC ABCDAB ABCDABCDABDE P:ABCDABD

  针对上面给定的T串与P串,采用暴力方法前几步如下:

  当第5步时空格与“D”不匹配时,其实知道前面六个字符是"ABCDAB"(因为已经比较了)。设法利用这个已知信息,不要把"搜索位置"回溯到已经比较过的位置,继续把它向后移,这样就提高了效率。

  现在想法有了,那么怎么实现呢?

  先给出如下图所示的一张部分匹配表PMT(Partial Match Table)

  在第5步里,由“D”与“ ”不匹配知道,前面6个是匹配的,最有一个匹配的值为“B”(P串里的第二个"B")对应PMT里的值为2,我们先按

公式: 移动位数 = 已匹配的字符数 - 对应的部分匹配值

1、已匹配长度为6,最后匹配为“B”(第二个),对应PMT的值为2,计算出移动步数为6-2=4

2、已匹配长度为2,最后匹配为“B”(第一个),对应PMT的值为0,计算出移动步数为2-0=2

3、“空格”与“A”不匹配,向后移动一步

4、已匹配长度为6,最后匹配为“B”(第二个),对应PMT的值为2,计算出移动步数为6-2=4

5、匹配成功,如果还要继续匹配,则后移7-0=7 再继续匹配

2、部分匹配表PMT(Partial Match Table)

  先说明两个个概念:

  前缀:除了最后一个字符以外,一个字符串的全部头部组合。

  后缀:除了第一个字符以外,一个字符串的全部尾部组合。

以“table”举例,前缀有:“t”、“ta”、“tab”、“tabl”,后缀有:“able”、“ble”、“le”、“e”。

PMT中的"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。

上面的字符串"ABCDABD”的PMT推导过程如下:

1、我们先把“ABCDABD”按匹配过程拆分为“A”、“AB”、“ABC”、“ABCD”、“ABCDA”、“ABCDAB”、“ABCDABD”。

  1)“A”:

  前缀:{}

  后缀:{}

所以共有元素最长的长度为0;

  2)“AB”:

  前缀:[“A”]

  后缀:[“B”]

所以共有元素最长的长度为0;

  3)“ABC”:

  前缀:[“A”、“AB”]

  后缀:[“BC”、“C”]

所以共有元素最长的长度为0;

  4)“ABCD”:

  前缀:[“A”、“AB”、“ABC”]

  后缀:[“BCD”、“CD”、“D”]

所以共有元素最长的长度为0;

  5)“ABCDA”:

  前缀:[“A”、“AB”、“ABC”、“ABCD”]

  后缀:{“BCDA”、“CDA”、“DA”、“A”}

所以共有元素为[“A”],共有元素最长的长度为1;

  6)“ABCDAB”:

  前缀:[“A”“AB”、“ABC”、“ABCD”、“ABCDA”]

  后缀:[“BCDAB”、“CDAB”、“DAB”、“AB”“A”]

所以共有元素为[“A”、“AB”],共有元素最长的长度为2;

  6)“ABCDABD”:

  前缀:[“A”、“AB”、“ABC”、“ABCD”、“ABCDA”、“ABCDAB”]

  后缀:[“BCDABD”、“CDABD”、“DABD”、“ABD”、“BD”、“D”]

所以共有元素最长的长度为0;

得出pmt=[0, 0, 0, 0, 1, 2, 0]

pmt的代码实现:

/**
 * 字符串模式匹配KMP算法--next数组方式
 *
 * @author admin
 */
public class KmpWithPmt {
	/**
	 * 计算出pmt数组
	 *
	 * @param pattern 模式串 ABCDABD
	 * @return pmt数组
	 */
	public static Integer[] pmt(String pattern) {
		int length = pattern.length();
		Integer[] pmt = new Integer[length];
		pmt[0] = 0;
		int k = 0;
 		for (int i = 1; i < length; i++) {
			while (k > 0 && pattern.charAt(k) != pattern.charAt(i)) {
            	// 这里可以直接给k赋值0
				k = pmt[k - 1];
			}
			if (pattern.charAt(i) == pattern.charAt(k)) {
				k++;
			}
			System.out.println("pmt[" + i + "] = " + k);
			pmt[i] = k;
		}
		return pmt;
	}

	/**
	 * kmp实现
	 *
	 * @param text    目标串
	 * @param pattern 模式串
	 * @return 出现位置集合
	 */
	public static List<Integer> kmp(String text, String pattern) {
		List<Integer> positions = new ArrayList<>();
		Integer[] pmt = pmt(pattern);
		System.out.println(Arrays.asList(pmt));
		//count指向模式串
		int count = 0;
		for (int i = 0, k = text.length(); i < k; i++) {
			//失配
			while (count > 0 && pattern.charAt(count) != text.charAt(i)) {
				//模式串指向前缀最有可能匹配的位置
				count = pmt[count - 1];
			}
			//匹配,指针向前走
			if (pattern.charAt(count) == text.charAt(i)) {
				count++;
			}
			//匹配成功
			if (count == pattern.length()) {
				positions.add(i - pattern.length() + 1);
				//寻找下一匹配位置
				count = pmt[count - 1];
			}
		}
		return positions;
	}

	public static void main(String[] args) {
		String text = "BBC ABCDAB ABCDABCDABDEABCDABD$EABCDABD";
		String pattern = "ABCDABD";
		List<Integer> result = kmp(text, pattern);
		System.out.println("匹配下标:" + result.toString());
	}

}

next数组:把模式串P各个位置的j值的变化定义为一个数组next,那么next的长度就是P串的长度。函数定义如下:

其实就是pmt向右移动一格,然后第一位设置成-1(也没啥特别的意义,就是写代码的时候好判断)

如果有T="aaaaabcde",P="aaaaax":

  T[i]和P[j]已经匹配失败,下一步会与P[ next[j] ]相匹配,但是如果P[j] == P[ next[j] ],那么后面T[i]和P[ next[j] ]的匹配也必然会失败,所以我们将P[ next[j] ]之前的子串的最长公共前后缀 next[ next[j] ]赋值给next[j]。

next及优化后nextVal的代码实现:

/**
 * 字符串模式匹配KMP算法--next数组方式
 *
 * @author admin
 */
public class KmpWithNext {
	/**
	 * 求出一个字符数组的next数组
	 *
	 * @param pattern 字符串
	 * @return next数组
	 */
	public static Integer[] getNext(String pattern) {
		int length = pattern.length();
		Integer[] next = new Integer[length];
		int k = next[0] = -1;
		int j = 0;
		while (j < length - 1) {
			if (k == -1 || pattern.charAt(j) == pattern.charAt(k)) {
				k++;
				j++;
				// TODO 此处可优化
				next[j] = k;
			} else {
				k = next[k];
			}
		}
		return next;
	}

	/**
	 * 求出一个字符数组的next数组(优化)
	 *
	 * @param pattern 字符串
	 * @return next数组
	 */
	public static Integer[] getNextVal(String pattern) {
		int length = pattern.length();
		Integer[] next = new Integer[length];
		int k = next[0] = -1;
		int j = 0;
		while (j < length - 1) {
			if (k == -1 || pattern.charAt(j) == pattern.charAt(k)) {
				k++;
				j++;
				//next[j] = k;
				next[j] = pattern.charAt(j) != pattern.charAt(k) ? k : next[k];
			} else {
				k = next[k];
			}
		}
		return next;
	}

	/**
	 * 对主串text和模式串pattern进行KMP模式匹配
	 *
	 * @param text    主串
	 * @param pattern 模式串
	 * @return 若匹配成功,返回pattern在text中的位置(第一个相同字符对应的位置),若匹配失败,返回-1
	 */
	public static List<Integer> kmpMatch(String text, String pattern) {
		List<Integer> positions = new ArrayList<>();
		int textLen = text.length();
		int patternLen = pattern.length();
//		Integer[] next = getNext(pattern);
		Integer[] next = getNextVal(pattern);
		System.out.println("next" + Arrays.asList(next));
		int j = 0;
		for (int i = 0; i < textLen; i++) {
			//相等
			if (text.charAt(i) == pattern.charAt(j)) {
				//子串匹配完毕,记录位置,并且j=0找下一个匹配的子串
				if (j == patternLen - 1) {
					positions.add(i - patternLen + 1);
					j = 0;
				} else {
					//子串匹配位置加1
					j++;
				}
			} else {
				//如果子串位置>0
				if (j > 0) {
					//不匹配的话从匹配表里面找到子串新的匹配位置
					j = next[j];
					//接着比较
					i--;
				}
			}
		}
		return positions;
	}

	public static void main(String[] args) {
		String text = "BBC ABCDAB ABCDABCDABDEEABCDABD";
		String pattern = "ABCDABD";
		List<Integer> result = kmpMatch(text, pattern);
		System.out.println("匹配成功,开始下标为:" + result.toString());
	}
}

3、性能分析(m为模式串P的长度,n为待匹配串T的长度)

  时间复杂度:O(m + n)。其中预处理阶段:计算next数组时间复杂度O(m),匹配过程时间复杂度O(n),两个环节独立串行,所以整体时间复杂度为O(m+n)。

  空间复杂度:O(m)。存放next数组。

那么我们的文本编辑器的查找是使用的KMP算法吗?大部分不是采用KMP算法

二、BM算法(Boyer-Moore Algorithm)

  没错,大部分文本编辑器的的查找是使用该算法实现。

1、例子

  T:HERE IS A SIMPLE EXAMPLE   P:EXAMPLE

这其实就是就是后缀暴力匹配法

/**
 * 后缀暴力匹配
 * 
 *@param text    待匹配串
 *@param pattern 模式串
 *@return 匹配下标
 */
 public static int bfSuffix(String text, String pattern) {
 	int textLength = text.length();
    int patternLength = pattern.length();
    for(int j = 0; j <= textLength - patternLength; j++) {
    	int i = patternLength - 1;
        for( ; i >= 0 && pattern.charAt(i) == text.charAt(i + j); --i) {
        }
        if(i < 0) {
        	return j;
        }
    }
    return -1;
}

  "S"与"E"不匹配。这时,"S"就被称为"坏字符"(bad character),即不匹配的字符。我们还发现,"S"不包含在搜索词"EXAMPLE"之中,这意味着可以把搜索词直接移到"S"的后一位。

  依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在搜索词"EXAMPLE"之中。所以,将搜索词后移两位,两个"P"对齐。

  我们由此总结出"坏字符规则":

  后移位数 = 坏字符的位置 - 搜索词中的上一次出现位置(如果坏字符不包含在P串中,值为-1)

  比较前面一位,"MPLE"与"MPLE"匹配。我们把这种情况称为"好后缀"(good suffix),即所有尾部匹配的字符串。注意,"MPLE"、"PLE"、"LE"、"E"都是好后缀。

  比较前一位,发现"I"与"A"不匹配。所以,"I"是"坏字符"。

  根据"坏字符规则",此时搜索词应该后移 2 - (-1)= 3 位。问题是,此时有没有更好的移法?

  此时存在"好后缀"。所以,可以采用"好后缀规则":

  后移位数 = 好后缀的位置 - 搜索词中的上一次出现位置

  举例来说,如果字符串"ABCDAB"的后一个"AB"是"好后缀"。那么它的位置是5(从0开始计算,取最后的"B"的值),在"搜索词中的上一次出现位置"是1(第一个"B"的位置),所以后移 5 - 1 = 4位,前一个"AB"移到后一个"AB"的位置。

  再举一个例子,如果字符串"ABCDEF"的"EF"是好后缀,则"EF"的位置是5 ,上一次出现的位置是 -1(即未出现),所以后移 5 - (-1) = 6位,即整个字符串移到"F"的后一位。

  这个规则有三个注意点:

1"好后缀"的位置以最后一个字符为准。假定"ABCDEF""EF"是好后缀,则它的位置以"F"为准,即5(从0开始计算)。
(2)如果"好后缀"在搜索词中只出现一次,则它的上一次出现位置为 -1。比如,"EF""ABCDEF"之中只出现一次,则它的上一次出现位置为-1(即未出现)。
(3)如果"好后缀"有多个,则除了最长的那个"好后缀",其他"好后缀"的上一次出现位置必须在头部。比如,假定"BABCDAB""好后缀""DAB""AB""B",请问这时"好后缀"的上一次出现位置是什么?回答是,此时采用的好后缀是"B",它的上一次出现位置是头部,即第0位。这个规则也可以这样表达:
如果最长的那个"好后缀"只出现一次,则可以把搜索词改写成如下形式进行位置计算"(DA)BABCDAB",即虚拟加入最前面的"DA"

  回到上文的这个例子。此时,所有的"好后缀"(MPLE、PLE、LE、E)之中,只有"E"在"EXAMPLE"还出现在头部,所以后移 6 - 0 = 6位。

  可以看到,"坏字符规则"只能移3位,"好后缀规则"可以移6位。所以,Boyer-Moore算法的基本思想是,每次后移这两个规则之中的较大值。

  更巧妙的是,这两个规则的移动位数,只与搜索词有关,与原字符串无关。因此,可以预先计算生成《坏字符规则表》和《好后缀规则表》。使用时,只要查表比较一下就可以了。

  继续从尾部开始比较,"P"与"E"不匹配,因此"P"是"坏字符"。根据"坏字符规则",后移 6 - 4 = 2位。

  从尾部开始逐位比较,发现全部匹配,于是搜索结束。如果还要继续查找(即找出全部匹配),则根据"好后缀规则",后移 6 - 0 = 6位,即头部的"E"移到尾部的"E"的位置。

2、算法实现

抄自网上

public class BoyerMoore {
	public static void main(String[] args) {
		String text = "HERE IS A SIMPLE EXAMPLE";
		String pattern = "EXAMPLE";
		BoyerMoore bm = new BoyerMoore();
		bm.boyerMoore(pattern, text);
	}

	private void preBmBc(String pattern, int patLength, Map<String, Integer> bmBc) {
		System.out.println("bmbc start process...");
		for (int i = patLength - 2; i >= 0; i--) {
			if (!bmBc.containsKey(String.valueOf(pattern.charAt(i)))) {
				bmBc.put(String.valueOf(pattern.charAt(i)), patLength - i - 1);
			}
		}
	}

	private void suffix(String pattern, int patLength, int[] suffix) {
		suffix[patLength - 1] = patLength;
		int q = 0;
		for (int i = patLength - 2; i >= 0; i--) {
			q = i;
			while (q >= 0 && pattern.charAt(q) == pattern.charAt(patLength - 1 - i + q)) {
				q--;
			}
			suffix[i] = i - q;
		}
	}

	private void preBmGs(String pattern, int patLength, int[] bmGs) {
		int i, j;
		int[] suffix = new int[patLength];
		suffix(pattern, patLength, suffix);
		//模式串中没有子串匹配上好后缀,也找不到一个最大前缀
		for (i = 0; i < patLength; i++) {
			bmGs[i] = patLength;
		}
		//模式串中没有子串匹配上好后缀,但找到一个最大前缀
		j = 0;
		for (i = patLength - 1; i >= 0; i--) {
			if (suffix[i] == i + 1) {
				for (; j < patLength - 1 - i; j++) {
					if (bmGs[j] == patLength) {
						bmGs[j] = patLength - 1 - i;
					}
				}
			}
		}
		//模式串中有子串匹配上好后缀
		for (i = 0; i < patLength - 1; i++) {
			bmGs[patLength - 1 - suffix[i]] = patLength - 1 - i;
		}
		System.out.print("bmGs:");
		for (i = 0; i < patLength; i++) {
			System.out.print(bmGs[i] + ",");
		}
		System.out.println();
	}

	private int getBmBc(String c, Map<String, Integer> bmBc, int m) {
		//如果在规则中则返回相应的值,否则返回pattern的长度
		if (bmBc.containsKey(c)) {
			return bmBc.get(c);
		} else {
			return m;
		}
	}

	public void boyerMoore(String pattern, String text) {
		int m = pattern.length();
		int n = text.length();
		Map<String, Integer> bmBc = new HashMap<String, Integer>();
		int[] bmGs = new int[m];
		//proprocessing
		preBmBc(pattern, m, bmBc);
		preBmGs(pattern, m, bmGs);
		//searching
		int j = 0;
		int i = 0;
		int count = 0;
		while (j <= n - m) {
			//用于计数
			for (i = m - 1; i >= 0 && pattern.charAt(i) == text.charAt(i + j); i--) {
				count++;
			}
			if (i < 0) {
				System.out.println("one position is:" + j);
				j += bmGs[0];
			} else {
				j += Math.max(bmGs[i], getBmBc(String.valueOf(text.charAt(i + j)), bmBc, m) - m + 1 + i);
			}
		}
		System.out.println("count:" + count);
	}
}

3、性能分析

  预处理阶段时间和空间复杂度都是是O(m+),(sigma)是字符集大小,一般为256;

  搜索阶段时间复杂度是O(mn);

  最坏的情况下算法需要进行3n次字符比较操作

  算法在最好的情况下达到O(n / m),比如在文本串bn中搜索模式串am-1b ,只需要n/m次比较