• 理解KMP算法


    问题背景

      问题可以描述成:给定一个字符串文本T,要从中找出是否含有某个子串P。我们把P叫做模式字符串。这个问题最直接的解法就是逐个匹配:先将T和P左对齐,从头开始依次比较P中的每个字符是否和T中对应的字符相同。例如,T 为“a a b a c a b a b c a b a c a b”,P为“a b a b c a b”,一开始将T和P左对齐并依次比较各个字符是否相同,

    (1) T: a a b a c a b a b c a b a c a b
        P: a b a b c a b

      当我们比较到第二个字符的时候,发现不一样,于是将P右移一位,再从头开始匹配

    (2) T: a a b a c a b a b c a b a c a b
        P:   a b a b c a b

      这时候我们发现前三个字符是相同的,匹配到第四个字符的时候不同,匹配失败。于是继续将P右移一位,再次进行以上的匹配过程,直至P的所有字符和T中的某个子串匹配。对于上面这个例子,当我们进行到如下所示的比较时,即匹配成功

    (3) T: a a b a c a b a b c a b a c a b
        P:           a b a b c a b

      如果我们不是这么走运,当比较到T的最后一个字符时,仍然没有完全匹配,则匹配失败。


    如何更快地匹配

      在上述的匹配过程中,我们每次只将P右移一位,因此匹配过程很缓慢。通过观察我们发现,有时候仅仅将P右移一位的匹配是明显无效的,从而可以一次右移多位,提高匹配效率。具体来说,我们看上面的第二次匹配,如果仅仅将P右移一位,因为P的第一个字符a和T中已匹配子串(a b a)的第二个字符,也就是P的第二个字符b不同,所以右移一位是明显无效的。我们可以直接将P右移两位进行匹配,这样提高了效率。为什么只右移两位、而不多移几位呢?因为当右移两位的时候,P的第一个字符和T中已匹配子串的最后一个字符相同,这时候我们需要继续比较后面的字符才能确定是否匹配成功。

      更一般化的描述是,假设当前P的前q个字符已经匹配,而第q+1个字符不匹配,这时候我们的目标是确定最多能将P右移多少位、从而跳过那些明显无效的匹配。从上面的例子可以发现,要完成这个目标,我们只需要知道P的前q个字符的信息即可。这种利用模式字符串本身的局部特征提高匹配效率的方法,就是KMP算法的精髓所在。KMP算法的命名来自它的三个发现者D.E.Knuth,J.H.Morris和V.R.Pratt的名字的首字母,这种方法可以大大提高字符串匹配的效率,从而将求解这一问题降至线性复杂度。

      回到前面的问题,如何利用模式字符串本身的局部特征呢?回顾前面的过程,当我们每次决定能右移多少位的时候,实际是在拿已匹配部分的前缀和它本身的后缀相比,我们最多只能右移到已匹配子串的前缀和后缀刚好匹配的位置。前缀和后缀相匹配的部分越长,我们能右移的步数就越少;前缀和后缀相匹配的部分越短,我们能右移的步数就越多。例如,假设当前已匹配部分是a b c a b,它的前缀有四个a、a b、a b c、a b c a,对应的后缀有四个b、a b、c a b、b c a b。我们依次比较每一对前缀后缀,可能有多个相匹配的前缀后缀,我们只需要找到最长的那个就OK啦,如下:

    a b c a b     | a b c a b        | a b c a b
      a b c a b   |     a b c a b    |       a b c a b

      当执行第三次比较时,前缀后缀匹配(都是a b),最大匹配长度是2。已匹配子串总长度是5,所以这时可以直接将P右移5 - 2 = 3位(因为前两次右移已经明显无效啦)。可以设想,对于q = 1, 2, ... , m,其中m为模式字符串P的长度,如果我们已经找到P的每个子串P[1...q]的最大前缀后缀匹配长度,记为π[q],那么当P的第q+1个字符和T中对应字符不匹配时(意味着前q个字符已匹配),我们可以直接将P右移q-π[q]位、从而跳过那些明显无效的匹配。这里的π[q]称为前缀函数,他的元素定义为:

    定义一:π[q]的值为P的子串P[1...q]的最大前缀后缀匹配长度。

      有了π[q],我们就可以执行KMP快速匹配了。


     如何计算前缀函数

      在实际操作中,给定模式字符串P,我们先要求出它的前缀函数π[q], 然后执行快速匹配操作。本文到此为止,都很好理解。而如何计算前缀函数,才是比较难理解的部分,《算法导论》上关于这一部分,又是符号又是公式,又是定理 又是引理的,看的让人云里雾里(可能是为了完整的描述和证明这个问题)。当然,你依然可以用蛮力的方法计算前缀函数,这里介绍一种更高效的方法。

      因为显然π[1] = 0,所以考虑用递归方法。假设我们已经知道k=π[q](设k > 0(条件1))的值,要求π[q+1],即子串P[1...(q+1)]的最大前缀后缀匹配长度。根据定义,P[1...q]的最大前缀后缀匹配长度为π[q],如下图,用红色x表示,这时再增加一个字符P[q+1]时,前缀后缀是否依然匹配呢?我们先比较P[q+1]与P的第π[q] + 1 = k + 1个字符,即两个*表示的字符:

    Index : 1 2 ... π[q] π[q]+1 ... @ @ ... q q+1
    P : x x ... x * ... x x ... x *

      (注:上图中两个@符号表示的下标分别为q - π[q] + 1和q - π[q] + 2。)

      如果P[q+1] == P[k+1](条件2),那么可以直接增加第q+1个字符、且此时前缀后缀刚好匹配,所以π[q+1] = π[q] + 1 = k + 1。然而,大多数时候我们没有这么“走运”,即P[q+1] != P[k+1](条件3),然而这时我们不能直接得到π[q+1] = 0。因为虽然P[q+1] != P[k+1],但是如果前缀子串P[1...π[q]](也是子串P[1...q]的后缀,上图中的红色部分)本身又有相匹配的前缀后缀,假设长度为L,0 < L< π[q],也即P[1...q]的前L个字符和它的后L个字符匹配(令k = L,这时又回到了条件1),那么我们还要将字符P[q+1]和P[l+1]比较,如果P[q+1] == P[L+1](回到条件2),那么π[q+1] =L + 1;否则(回到条件3)我们要继续查看子串P[1...k]中是否有相匹配的前缀后缀……如此往复,直至匹配子串中找不到相匹配的前缀后缀(条件4)--就拿上图来说,假设子串P[1...π[q]]没有相匹配的前缀后缀,此时,我们直接比较P[q+1]和P[1],如果P[q+1] == P[1],则π[q+1] = 1;否则π[q+1] = 0。这样,就可以求出π[q+1]的值;利用递归就可以求出π的所有元素。

      那么问题来了,子串P[1...π[q]]本身的最大前缀后缀匹配长度是多少?没错,这就是我们前面给出的定义一,答案是π[π[q]]。这里递归地利用了前面的求解结果,这也是整个问题最难理解的部分。如果这里理解了,剩下的问题就简单了。

      这里我们给个例子说明以上求解过程。假设 P = “a b a b a a c”,要求π[q]。

    1. 显然π[1] = 0。
    2. 求π[2],q = 1,因为π[1] = 0,满足(条件4),所以比较P[2]和P[1],不相等,所以π[2] = 0。
    3. 求π[3],q = 2,满足(条件4),所以比较P[3]和P[1],相等,所以π[3] = 1。
    4. 求π[4],q = 3,满足(条件1),所以比较P[4]和P[π[3]+1] = P[2],相等,满足(条件2),所以π[4] = π[3] + 1 = 2。
    5. 求π[5],q = 4,满足(条件1),所以比较P[5]和P[π[4]+1] = P[3],相等,满足(条件2),所以π[5] = π[4] + 1 = 3。
    6. 求π[6],q = 5,满足(条件1),所以比较P[6]和P[π[5]+1] = P[4],如下图
    Index: 1 2 3 4 5 | 6 7
    P : a b a b a | a c
    P : a b a b a | a c

      图中红色部分是子串P[1...5]相匹配的最大前缀后缀。虽然P[6] != P[4]满足(条件3),但是我们不能直接得到π[6] = 0;这时还要看P[1...π[5]]是否有相匹配的前缀后缀及其最大长度是多少,答案是l = π[π[5]] = π[3] = 1 > 0,回到(条件1),所以继续比较P[6] 和 P[l + 1] = P[2],不相等,满足(条件3),因此我们继续查看P[1...l] = P[1...1]是否有相匹配的前缀后缀及其最大长度是多少,答案是l = π[1] = 0,满足(条件4),因此比较P[6]和P[1],相等,所以 π[6] = 1。

      求π[7],q = 6,满足(条件1),所以比较P[7]和P[π[6]+1] = P[2],不相等,满足(条件3),因为l = π[π[6]] = π[1] = 0,满足(条件4),因此比较P[7]和P[1],不相等,所以 π[7] = 0。


       计算前缀函数的伪代码。最后给出计算前缀函数的伪代码,转自《算法导论》:

     
    COMPUTE-PREFIX-FUNCTION(P)
    1  m = length(P)
    2  π[1] = 0
    3  k = 0
    4  for q = 2 to m
    5    while k > 0 and P[k + 1] != P[q]
    6      k = π[k]
    7    if  P[k + 1] == P[q]
    8      k = k + 1
    9    π[q] = k
    10  return π
     

      首先可以保证第5行的k一定等于π[q - 1]:当q = 2时,第一次进入第5行,第2行和第3行初始化保证了k = π[2 - 1] = π[1] = 0;其它情况下代码第9行对此做了保证。所以在计算π[q]时,我们首先判断是否满足条件1(第5行:k > 0),同时判断是否满足条件3(第5行:P[k + 1] != P[q]),如果都满足,则更新k的值(第6行)并继续判断;否则,如果满足条件2(第7行:P[k + 1] == P[q]),则将k值加1(第8行);如果不满足条件2,即满足条件4,此时k = 0,所以直接将新的k值赋给π[q](第9行)。最后,对所有的q都计算完成后,返回前缀函数(第10行)。

    kmp-matcher(T, P)
        n<-length[T]
        m<-length[P]
        pi<-compiter-prefix-function(P)
        q<-0
        for i<-1 to n
            do while q>0 and P[q+1]!=T[i]
                do q<-pi[q] //前缀的前缀...
            if P[q+1]==T[i]
                then q<-q+1
            if q==m
                then print “Pattern occurs with shift”i-m
                        q<-pi[q]

     KMP代码:

    public class KMP {
        private String text;
        private String pattern;
        private int[] n;
    
        public KMP(String text, String pattern) {
            super();
            this.text = text;
            this.pattern = pattern;
            this.n = new int[pattern.length()];
            computePrifix(pattern);
        }
    
        private void computePrifix(String pattern) {
            int m = pattern.length();
            n[0] = 0;
            int k = 0;
            for (int q= 2; q <= pattern.length(); q++) {
                while (k > 0 && pattern.charAt(k) != pattern.charAt(q-1)) {
                    k = n[k-1];
                }
                if (pattern.charAt(k) == pattern.charAt(q-1))
                    k = k + 1;
                n[q-1] = k;
            }
        }
    
        public int kmp() {
            int length = text.length();
            int m = pattern.length();
            if (m > length)
                return -1;
            int q = 0;
            for (int i = 0; i < length; i++) {
                while (q > 0 && pattern.charAt(q) != text.charAt(i)) {
                    q = n[q-1];
                }
                if (pattern.charAt(q)== text.charAt(i))
                    q++;
                if (q == m)
                    return i-m;
            }
            return -1;
        }
        
        public static void main(String[] args) {
            KMP kmp=new KMP("aabacababcabacab", "ababcab");
            System.out.println(kmp.n);
            for (int i = 0; i <kmp.pattern.length(); i++) {
                System.out.println(kmp.n[i]);
            }
            System.out.println(kmp.kmp());
        }
    }
    View Code
  • 相关阅读:
    ecshop 商品分类下的销售排行
    ecshop批量清除商品的精品新品热销属性
    ECSHOP二次开发之给商品增加新字段
    ECSHOP首页调用文章内的缩略图
    ECSHOP给分类添加代表图
    ECSHOP首页促销商品下显示促销时间
    鼠标点击后更换背景
    ECSHOP如何修改商品评论或留言的日期
    ECSHOP设置指定IP才能登录后台
    ecshop远程图片本地化保存相册图片
  • 原文地址:https://www.cnblogs.com/wxgblogs/p/5700907.html
Copyright © 2020-2023  润新知