[数据结构]字符串模式匹配中的kmp算法

    最近在学习数据结构,看到“字符串的模式匹配”这一小节中,有关于搜索子串的算法分析,里面介绍了一个kmp匹配算法,由于内容比较绕脑,我在此做个记录。
    首先是介绍我们最基础的子串搜索算法,无非就是两重循环,外层是主串字符数量的次数,作为让子串第一个字符的比对开始索引;内层是子串字符数量的次数,挨个与主串比对,遇到不一致的就终止,如果内层顺利走完,则代表在主串中成功找到子串。
    下面是Java语言的简单示例

    static int normalIndex(String mainStr, String childStr) {
        char mChars[] = mainStr.toCharArray();
        char childChars[] = childStr.toCharArray();
        int i = 0, j, k;
        while(i++ < mChars.length - childChars.length) {
            k = 0;
            j = i;
            while(k < childChars.length) {
                if(mChars[j] != childChars[k]) break; // 发现不一致则终止
                ++j;
                ++k;
            }
            if(k == childChars.length) return i; // 子串所有字符都按顺序在主串中出现,则说明成功找到
        }
        return -1;
    }
    
    public static void main(String[] args) {
        System.out.println(normalIndex("Hello world!", "llo"));
    }

    这个算法有一个问题,就是可能会进行无效计算,我们看下面的例子:
        主串:a b c a b c d
        子串:a b c d
    我们从肉眼上其实能够看出,上述代码外层循环中的第2、3次就是无效计算,它的对比状态如下:
        第2次外层循环(i=1):
                a b c a b c d
                   a b c d
        (第3次也类似,就不列举了)
        问题其实很容易看懂,就是我们知道匹配失败的是第4个字符,而前三个字符又不重复,因此,再下一次匹配时,前三个字符就无需再次进行匹配。

        发现问题之后我们需要想办法解决,解决的办法就是把子串做一个快进动作,书上一般描述为“滑动”。

        这里其实不太好理解,从肉眼上看,这里是向前滑动,但逻辑上其实是向后滑动。所以从下文开始,我们都采取向后滑动的说法。

        现在唯一要解决的问题就是通过数学的方法知道某次对比失败后的滑动步数。有两个细节我们先明确:

  • 计算滑动步数只与子串有关
    因为在某次对比失败之前,子串与主串是完全重复的,因此滑动步数只与子串相关(具体来说是已经对比成功的部分,这部分字符组合的重复规律就会作为计算滑动步数的唯一凭据)。
  • 单次滑动步数越少越好
    在可能计算出的多个滑动步数中,要选取最大的一个,不然可能会漏数据。我们可以通过下面的示例来观察:
        主串:a b a b a b a b c d e f g
        子串:a b a b a b c
    当对比到第6个字符时,出现不一致,此时,我们可以得到的滑动步数集合是{2, 4},即第二次对比时我们可以把子串的第1个字符与主串的第3个或第5个对齐然后往后进行。但如果我们选择小的滑动步数2,则对比会失败。

    设主串为S,子串为C,在某次匹配中若Si != Ci,且在C0~Ci-1中若无重复段,则下次应以C0开始匹配Si;而当有重复时,应找到一个Ck符合[C0...Ck-1]与[Ci-k...Ci-1]顺序逐项相等,此时,下次匹配则以Ck与Si为起点,k应是一个最大的值,这样才能避免数据丢失。

向后滑动是指有重复段时相对于无重复段的逻辑方向,是把本应在下一次匹配时与Si对照的C0向后滑,滑动了k个长度。

        滑动步数的计算比较简单,就是查看已对比成功部分的重复段,选取一个最靠前的段长度。采用最容易理解的代码大概如下所示:

    // 工具方法,判定字符数组内两个段是否顺序相等
    static boolean calcSegmentEqual(char[] chars, int start1, int start2, int len) {
        while(start1 <= len) {
            if(chars[start1++] != chars[start2++]) return false;
        }
        return true;
    }
    
    // 计算在某处比对失败时应该滑动的步数
    // 返回值是一个数组,表示从第1个字符到最后一个字符对比失败时分别的最佳滑动步数
    static int[] calcStepNumber(String str) {
        char[] chars = str.toCharArray();
        int[] stepNumbers = new int[chars.length];
        stepNumbers[1] = stepNumbers[0] = 0; // 前两个字符未比中都不滑动
        stepNumbers[2] = chars[1] == chars[0] ? 1 : 0; // 第三个字符未比中时若前两个字符相同则可以回退一步
        int i = 3, j;
        while(i < chars.length) {
            j = 0;
            while(j < i - 1) {
                if(calcSegmentEqual(chars, 0, j + 1, i - 2 - j)) { // 尝试找最大的段,从0到i-2与1到i-3开始,依次尝试递减段长,最终会减到1
                    stepNumbers[i] = i - 1 - j; // 若找到一个段,则立即绑定它
                    break;
                }
                ++j;
            }
            ++i;
        }
        return stepNumbers;
    }
    
    public static void main(String[] args) {
        System.out.println(Arrays.toString(calcStepNumber("abaabcac")));
    }

        将滑动步数与子串搜索结合起来之后,代码如下:

    // ... 上文就不重复贴出了
    static int kmpIndex(String mainStr, String childStr) {
        char mChars[] = mainStr.toCharArray();
        char childChars[] = childStr.toCharArray();
        int[] steps = calcStepNumber(childStr);
        int i = 0, j = 0;
        while(i < mChars.length && j < childChars.length) {
            if(mChars[i] == childChars[j]) { // 相等时两个索引都前进
                ++i;
                ++j;
            } else { // 不相等时
                if(j == 0) // 若子串第一个字符就不匹配,尝试把主串索引加1下次再试
                    ++i;
                else
                    j = steps[j]; // 若前面有匹配成功了j个字符,则用启用滑动步数
            }
        }
        if(j == childChars.length) return i - childChars.length;
        return -1;
    }
    
    public static void main(String[] args) {
        //System.out.println(Arrays.toString(calcStepNumber("abaabcac")));
        System.out.println(kmpIndex("aaabc", "aab"));
        System.out.println(kmpIndex("ababababc", "abababc"));
        System.out.println(kmpIndex("Hello world!", "llo"));
    }

        根据书上的说法,计算滑动步数的代码可以简化,因为滑动步数是有着递进关系的,一般性地有Step(i)=fx(Step(i-1)),接下来我们要继续讨论如何总结这种递进关系,运用到代码中以节省计算量。
        书上的举了一个例子,假如有Step(8)=3,请求得Step(9)的值。如何解题呢?我们画出一个图一看就懂了:


Step(8)=3

        可以看出,若要想Step(8)=3,则5~7必定与0~2顺序相等,且没有更长的相等段了。那么在此时,Step(9)最长的段可能是什么?只能是0~3与5~8!如下图:

Step(9)=4

        所以我们可以得出第一条公式:设字符串序列为C,则若Ci-1=CStep(i-1),则Step(i)=Step(i-1)+1。
        当然,有相等就有不相等,若上题字符序列中3与8不等怎么办呢?此时Step(9)的值等于多少,改如何推算呢,是否应该切换到旧的算法,暴力找最长段呢?答案是不需要的。我们再画一个图表示3与8不等时到底发生了什么:
kmp

        在上面的图示中,我们用子串去匹配子串,我们发现,当出现C3!=C8时,Step(9)的值其实就跟Step(3)产生关系了。

  • 当Step(3)为0时,我们判断C0(即Step(3))是否等于C8,若相等,则Step(9)=1
  • 当Step(3)为1时,我们需要判定C1(即Step(3))是否等于C8
    • 若相等,则Step(9)=2
    • 若不等,则Step(9)又与Step(1)产生关系
      • 若C0(即Step(1))与C8相等,则Step(9)=1;若不等,则Step(9)=0

        从上文的推理中我们可以总结出第二条公式:设字符串序列为C,则若Ci-1!=CStep(i-1),则需递归搜索与Ci-1相等的字符,搜索的索引从Step(Step(i-1))开始,一直到Step(0),直至搜索到相等的字符或者搜索到第0个字符,若期间搜索到匹配的字符,则将此时的Step(k)+1作为Step(i),若搜索到第0个字符还不匹配,则Step(i)=0。

例如字符串C=“abaabc”(i=0和1时Step(i)=0)
    i=2时,有C0!=C1,因此Step(2)=0;
    i=3时,Step(2)=0,有C0==C2,因此Step(3)=Step(2)+1=1;
    i=4时,Step(3)=1,有C1!=C3,需查看Step(1),有C0==C3,因此Step(4)=Step(1)+1=1;
    i=5时,Step(4)=1,有C1==C4,因此Step(5)=Step(4)+1=2。

        下面就是使用代码来描述这个过程:

    static int[] calcStepNumber(String str) {
        char[] chars = str.toCharArray();
        int[] steps = new int[chars.length];
        steps[0]  = steps[1] = 0;
        int i = 2;
        int j = 0;
        while(i < chars.length) {
            if(chars[i - 1] == chars[j]) {
                steps[i++] = ++j;
            } else if(j == 0) {
                steps[i++] = 0;
            } else
                j = steps[j];
        }
        // 下文的代码可能更容易理解
        /*steps[2] = chars[0] == chars[1] ? 1 : 0;
        int lastStep = 0;
        for(int i = 3; i < chars.length; ++i) {
            lastStep = steps[i - 1];
            while(true) {
                if(chars[i - 1] == chars[lastStep]) {
                    steps[i] = lastStep + 1;
                    break;
                } else {
                    if(lastStep == 0) {
                        steps[i] = 0;
                        break;
                    }
                    else {
                        lastStep = steps[lastStep];
                    }
                }
            }
        }*/
        return steps;
    }

        整合后代码的运行就大家自行尝试吧。

    参考文献:
    [1]李伟生.数据结构(第2版)[M].北京:中央广播电视大学出版社,2015:75-81.

你可能感兴趣的:([数据结构]字符串模式匹配中的kmp算法)