声明:以下内容绝大部分摘自《阮一峰的网络日志》
引入主题
思考一下:这个查找功能底层你会怎么实现?
一、模式匹配
【百度百科】模式匹配是数据结构中字符串的一种基本运算,给定一个子串,要求在某个字符串中找出与该子串相同的所有子串,这就是模式匹配。 假设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次比较