[数据结构]字符串模式匹配中的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.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容