KMP模式匹配算法详解(Java实现)

本文参考:《大话数据结构》 —— 程杰


一、简介

  在进行字符串匹配时,KMP算法与朴素算法最大的区别就在于KMP算法省去了主串与子串不必要的回溯,这也是KMP算法(在主串有较多重复时)更加高效的关键。

  例一:

主串:a b c d e f g a b ...
子串:a b c d e x

  若用朴素算法进行匹配:

第一次匹配:
    0 1 2 3 4 5 6 7 8
    a b c d e f g a b ...
    a b c d e x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
    0 1 2 3 4 5 6 7 8
    a b c d e f g a b ...
      a b c d e x
主串从第 5 位回溯至第 1 位,子串从第 5 位回溯至第 0 位,第 1 位不等,比较了 1 次
第三次匹配:
    0 1 2 3 4 5 6 7 8
    a b c d e f g a b ...
        a b c d e x
......
第四次匹配:
    0 1 2 3 4 5 6 7 8
    a b c d e f g a b ...
          a b c d e x
......

  可以看出,用朴素算法进行匹配时,第二、三、四、五次匹配均为没有必要的,因为子串自身无重复,且子串与主串的 0-4 位相等,所以子串的第 0 位必定与主串的第 1、2、3、4位不等。

  若用KMP算法进行匹配:

第一次匹配:
    0 1 2 3 4 5 6 7 8
    a b c d e f g a b ...
    a b c d e x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
    0 1 2 3 4 5 6 7 8
    a b c d e f g a b ...
              a b c d e x
主串不回溯,子串从第 5 位回溯至第 0 位,第 1 位不等,比较了 1 次

  从上述例子可以看出KMP算法的第一个优点:避免了主串不必要的回溯。事实上,主串的任何回溯都是不必要的,所以在KMP算法中,任何情况下主串都不回溯

  例二:

主串:a b c a b c a b x ...
子串:a b c a b x

  若用朴素算法进行匹配:

第一次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
    a b c a b x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
      a b c a b x
主串从第 5 位回溯至第 1 位,子串从第 5 位回溯至第 0 位,第 1 位不等,比较了 1 次
第三次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
        a b c a b x
第 2 位不等,比较了 1 次
第四次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
          a b c a b x
相等,比较了 6 次

  这一次,子串自身出现了重复,即第 0-1 位的 ab 和第 3-4 位的 ab 相等,所以若继续按照例一的方式避免子串的回溯,就会出现下面的情况:

第二次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
              a b c a b x

  我们错过了正确答案。所以第四次匹配是必要的,不能跳过。但第二、三次匹配也是必要的吗?不难看出,答案是否定的。

  若用KMP算法进行匹配:

第一次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
    a b c a b x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
    0 1 2 3 4 5 6 7 8
    a b c a b c a b x ...
          a b c a b x
子串从第 5 位回溯至第 3 位,相等,比较了 1 次

  由于在进行第一次匹配时,我们已经知道主串的 3-4 位为ab,所以在第二次匹配中,我们完全可以直接让主串的第 5 位与子串的第 3 位进行比较,来避免子串不必要的回溯,减少比较次数,这也是KMP算法的第二个优点。

  以上就是KMP算法的基本思想,但在具体实现中,我们不可能人工推算这些数值,因此我们需要一个数组来记录子串应回溯到的位置。


二、生成next数组

方便起见,对任意字符串变量str,本文中的非代码部分统一用 str[x] 表示 str.charAt(x)

  因为主串不回溯,所以KMP算法的实现靠的是主串的累加和子串的回溯。在朴素算法中,每次比较后,若不相等,子串都会回溯至开头,但并不是每次都需要这么做(如例二)。为了避免这种不必要的回溯,我们需要一个next数组来记录子串应回溯至哪一位。

  注意:每个子串都对应一个next数组,而且该数组的值与主串无关,因为子串回溯的位置只与子串自身的重复程度有关

  假设主串为String S,子串为String T,在匹配时,S的当前下标为iT的当前下标为j,那么next[]中记录的就是,在比较时,若T[i]S[x](x表示任意位置)不等时,T应该回溯到的位置next[i]

  以T:abcabx为例:

j: 0 1 2 3 4 5
T: a b c a b x
若T[0]与S[x]不等,T应回溯至T[0],故next[0] == -1
若T[1]与S[x]不等,T应回溯至T[1],故next[1] == 0	
若T[2]与S[x]不等,T应回溯至T[1],故next[2] == 0
若T[3]与S[x]不等,T应回溯至T[0],故next[3] == -1
若T[4]与S[x]不等,T应回溯至T[1],故next[4] == 0
若T[5]与S[x]不等,T应回溯至T[3],故next[5] == 2
至于为什么减一,在接下来的代码中可以看出

  根据上述例子可以看出,在给next[]赋值时,主要依据是T[0]T[j-1]这段字符的前缀和后缀的重复程度。当T[j]前这段字符的前缀(T[0])和后缀(T[j-1])不等时,T应由T[j]回溯至T[0];若相等,再比较T[0+1]T[j-1+1]

  说了这么多,让我们结合具体实现来看:

/* 为避免和匹配时的i、j混淆,这里使用m、n */
public static int[] getNext(String T) {
    int next[] = new int[T.length()];
    int m = 0;
    int n = -1;
    next[0] = -1;
    while (m < T.length() - 1) {
        if (n == -1 || T.charAt(m) == T.charAt(n)) {
            m++;
            n++;
            if (T.charAt(m) != T.charAt(n)) {
                next[m] = n;
            } else {
                next[m] = next[n];
            }
        } else {
            n = next[n];
        }
    }
    return next;
}

  第 3 行:声明数组next[],长度与T的长度相同。
  第 4 行:声明变量m=0。变量m用来控制循环次数,同时用来表示后缀最后一位的下标。
  第 5 行:声明变量n=-1。变量n用来表示前缀最后一位的下标,n==-1表示令前缀回溯至T[0]
  第 6 行:令next[0]=-1。这里的-1可以看作一个标记,表示“准备”状态,即T将回溯至T[0]
  接下来就是第 7-19 行的循环。该循环的主要思想与上述相同,即T[n]表示当前前缀最后一位,T[m]表示当前后缀最后一位,起始时n=-1m=0。进入循环后,先进行判断,若n==-1(即n在起始位置),或当前前缀和后缀相等,需要继续比较下一位,则令mn自增,即前缀和后缀均向后增加一位,再次判断自增后是否满足条件;若不满足,即n不在起始位置,当前前缀和后缀也不相等(更直观地说,就是在某位置的前缀和后缀的前x位相等,最后一位不等),则令n回溯至与前缀中重复的那一位相同的位置(若存在,不存在则回溯至起始位置),即令n=next[n]
  当然,只比较是不行的,还要把比较的结果赋给next[],由于next[]对应T的每一位的值,所以每当m自增,都要给next[m]赋值。赋值前我们要先判断自增后的T[m]T[n]是否相等:
    1、不等。此时有两种情况,一是因为n=-1进入外层判断,这种情况为mn自增后T回溯至T[0],即从头开始比较,若不等,则下次比较时T仍应从T[0]开始比较,故将n赋给next[m](这种情况下n始终为0);二是因为T[m]==T[n]进入外层判断,这种情况为前缀与后缀相等后mn自增,例如例二中的T:abcabx,匹配时,若T[5]!=S[5]T应回溯至T[2],即应有next[5]==2(此时m==5n==2,由m==3n==0自增而来),故将n赋给next[m]。综合这两种情况可以得出,若不等,则有next[m] = n
    2、相等。例如T:abcabx,匹配时,若T[4]!=S[x],此时j==4,因为T[1]==T[4],所以T应回溯至T[next[1]]而不是T[next[4]],即当后缀与前缀有重复时,匹配不等时可以直接回溯至与前缀中重复的那一位相同的位置。所以,若相等,则有next[m] = next[n]
  循环结束后返回next[]

  注意:1、这里的前缀与后缀不一定是单个字符,如T:abcabx,当i==4时,前缀为ab,后缀为ab,只不过比较时比较的是前缀和后缀的最后一位。
     2、n=-1时,主串的下一位与T[0]比较;n=0时,主串的当前位与T[0]比较。
     3、mn仅仅是生成next[]时的中间变量,该方法中只有next[]与字符串匹配直接相关。
     4、只有当前前缀与后缀相等,或T回溯至T[0]时,mn才自增。


三、应用next数组

  直接上代码:

public static int KMP(String S, String T, int pos) {
    int i = pos - 1;
    int j = -1;
    int next[] = getNext(T);
    while (i < S.length() && j < T.length()) {
        if (j == -1 || S.charAt(i) == T.charAt(j)) {
            i++;
            j++;
        } else {
            j = next[j];
        }
    }
    if (j == T.length()) {
        return i - T.length();
    } else {
        return -1;
    }
}

  知道了next[]的生成原理,应用就很简单了。

  第 2 行:声明变量i=pos-1pos表示从第几位开始匹配,一般为0,即从头开始匹配。同时用来作为下标表示后缀。
  第 3 行:声明变量j=-1。与getNext()中的n相同,变量j用来表示前缀最后一位的下标,j==-1表示令前缀回溯至T[0]
  第 4 行:从getNext()接收next[]
  第 5-12 行的循环与getNext()中的基本相同,只是不需要给next[]赋值了。循环条件是i;若i=S.length(),表示S中不存在T,若j=T.length(),表示已匹配成功;若都符合条件,表示正在匹配,进入循环。进入循环后进行判断,若j==-1,即从头开始匹配,或当前前缀与后缀相等,则令ij自增;若不满足条件,则令j按照next[]回溯。
  循环结束后判断j的值,若与T的长度相等,即比较过T的最后一位后仍相等,j再次自增,说明匹配成功,返回i - T.length(),即用匹配成功时的后缀减去T的长度,得到的ST的位置;若不相等,则说明在S中未匹配到T,返回-1


四、总结

  至此,KMP模式匹配算法就讲解完毕了。最后再强调一下需要注意的地方:
    1、KMP算法的核心就是避免所有不必要的回溯,并用next[]数组记录应该回溯到的位置,用指定回溯的位置代替遍历式地回溯,以此提高效率。可以看出,next[]的生成是KMP算法的核心,应重点理解。
    2、每个子串都对应一个唯一的next[]数组,与要匹配的主串无关,因为在KMP算法中,主串不回溯,只自增,next[]记录的是子串要回溯到的位置。
    3、getNext()KMP()的结构十分相似,但思想有很大不同,要注意区分:getNext()中只有一个字符串,进行的是前缀和后缀的比较;KMP()中有两个字符串,进行的是字符串和字符串的比较,比较后子串根据next[]进行回溯。
    4、如果不理解,可以带入一个子串推导一遍,这样能更直观地理解其中的过程。
    5、不难看出,KMP算法仅在子串自身重复度较高时更高效,若子串重复度不高,则与朴素算法差别不大。

  最后附上完整代码:

public class Main {
    public static void main(String[] args) {
        System.out.println(KMP("abcabcabx", "abcabx", 0));
    }

    public static int KMP(String S, String T, int pos) {
        int i = pos - 1;
        int j = -1;
        int next[] = getNext(T);
        while (i < S.length() && j < T.length()) {
            if (j == -1 || S.charAt(i) == T.charAt(j)) {
                i++;
                j++;
            } else {
                j = next[j];
            }
        }
        if (j == T.length()) {
            return i - T.length();
        } else {
            return -1;
        }
    }

    public static int[] getNext(String T) {
        int next[] = new int[T.length()];
        int m = 0;
        int n = -1;
        next[0] = -1;
        while (m < T.length() - 1) {
            if (n == -1 || T.charAt(m) == T.charAt(n)) {
                m++;
                n++;
                if (T.charAt(m) != T.charAt(n)) {
                    next[m] = n;
                } else {
                    next[m] = next[n];
                }
            } else {
                n = next[n];
            }
        }
        return next;
    }
}

以下是朴素算法作为对比:

public class Main {
    public static void main(String[] args) {
        System.out.println(KMP("abcabcabx", "abcabx", 0));
    }

    public static int KMP(String S, String T, int pos) {
        int i = pos;
        int j = 0;
        while (i < S.length() && j < T.length()) {
            if (S.charAt(i) == T.charAt(j)) {
                i++;
                j++;
            } else {
                i = i - j + 1;
                j = 0;
            }
        }
        if (j == T.length()) {
            return i - T.length();
        } else {
            return 0;
        }
    }
}

你可能感兴趣的:(算法)