数据结构与算法学习总结(六)——字符串的模式匹配算法

基本概念

字符串是一种特殊的线性表,即元素都是”字符“的线性表。

字符是组成字符串的基本单位,字符的取值依赖于字符集,例如二进制的字符集为0,1,则取值只能为(0,1),再比如英语语言,则包括26个字母外加标点符号。

例如"abcde"就是一个字符串,其中'a','b','c','d','e'都分别是串中的字符,串的长度是5。

线性表的存储结构同样适用于字符串,但是因为链式结构的额外开销,大部分情况下会采用顺序结构。

子串定义:串中任意个连续的字符组成的子序列称为该串的子串

例如:abc 就是 abcde的其中一个子串

特殊子串:

1.空字符串是任意串的子串

2.任意串S都是S本身的子串

3.真子串:非空且不为自身的子串(即不属于1,2两种情况下的)

 

关于字符串的其他运算不多做说明了,大多数人都太熟悉了,来看看难点的字符串的模式匹配以及实现算法吧。

模式匹配

模式匹配是数据结构中字符串的一种基本运算,给定一个串,要求在某个字符串中找出与该串相同的所有子串,这就是模式匹配

假设P是给定的子串,T是待查找的字符串,要求从T中找出与P相同的所有子串,这个问题成为模式匹配问题。P称为模式,T称为目标。如果T中存在一个或多个模式为P的子串,就给出该子串在T中的位置,称为匹配成功;否则匹配失败。

朴素模式匹配算法

假设T的长度为n,P的长度为m,比较容易想到的一个方法是初始时设置i=0&j=0,然后同时增大i与j判断n_i与m_j是否相等,直到j=m-1,如果一直相等,则匹配成功,如果n_i与m_j不相等,则设置i=i+1,j=0重新开始匹配,直到找到结果,或者i=n-m为止。

这种方式被称为BF算法(Brute Force),也被叫做朴素模式匹配算法或穷举法,代码实现如下(java):

package top.zhanglugao.patternmatch;

public class PatternMatch {

    public static void main(String[] args) {
        //目标串T
        String T="abacbcdhjk";
        //模式P
        String P="abad";
        String P2="cbcd";
        int i = bruteForcePatternMatch(P, T);
        System.out.println(i);
        i=bruteForcePatternMatch(P2,T);
        System.out.println(i);
        
        //运行结果分别是-1与3 程序无误
    }

    /***
     *
     * @param P     模式P
     * @param T     目标串T
     * @return
     */
    public static int bruteForcePatternMatch(String P,String T){
        int n=T.length();
        int m=P.length();
        //如果P的长度大于T 不可能查找成功返回-1
        if(n

下面来分析一下这个算法的复杂度:

匹配成功+最坏的情况下,如果直到最后一次才成功,那么i的值为(n-m)+1,j的值为m,总共比较次数为((n-m)+1)*m次,时间复杂度为O(n*m)。

匹配成功+最好的情况是T的前m个字符与P相等,总比较次数为m,时间复杂度为O(m)。

匹配失败+最坏的情况与匹配成功时相同。

匹配失败+最好的情况是每次比较第一个字符时就发现不相等,那么比较次数为(n-m)+1,时间复杂度为O(n)。

KMP算法(无回溯算法)

上面的穷举法是没有问题的,但不够好,如果是人为来寻找的时候,不会每次都将i回溯到i+1的位置。看下面比较的图。

数据结构与算法学习总结(六)——字符串的模式匹配算法_第1张图片

在红色的位置出现了不匹配的情况,如果按照上面的朴素算法,会将i右移一位重新比较,但我们可能一眼就能看出来应该将i右移3位再比较,再之前的比较是无意义的,那么这其中有没有规律呢?我们尝试多观察一些同样的情况。

数据结构与算法学习总结(六)——字符串的模式匹配算法_第2张图片

这个例子,我们显然应该右移两位,因为T中C前面的A与P开头的A是一致的。

数据结构与算法学习总结(六)——字符串的模式匹配算法_第3张图片

这里应该右移三位,达到下面的状态:

数据结构与算法学习总结(六)——字符串的模式匹配算法_第4张图片

仔细观察下可以发现这样的数学规律,如果存在一个常数k,使得T[i]之前k位与P开头的前k位相同,则可以设置j=k,继续比较。

也就是j要移动到的下一个位置k,存在最前面的k个字符与j之前的k个字符时相等的。即P[0]到P[k-1]==P[j-k]到P[j-1]

用图来表示的话就是下面这样:

数据结构与算法学习总结(六)——字符串的模式匹配算法_第5张图片

其实也就是当匹配到P[j]!=T[i]时,其实意味着P位置j之前与T[i-j]是相等的,即T[i-j ~ i-1] == P[0 ~ j-1],再根据前面的P[0 ~ k-1]==P[j-k ~ j-1]可以得到T[i-k ~ i-1] == P[0 ~ k-1],这也是为什么可以跳过前面的部分直接时j=k继续比较。

接下来的问题是怎么求解k了,这样我们才能顺序编写程序来实现逻辑。

再根据前面的P[0 ~ k-1]==P[j-k ~ j-1],我们可以确定k的值只跟当前模式P以及当前j的值有关,而跟目标串T没有关系,而且对于每个位置j有确定的一个k。假设存储k的数组为next[]

            举个例子,当P=‘abaabcac’时,其各位置的next值计算过程为:

            a  j=0, 特殊情况,设置next(j)=-1
            b  j=1, 满足0             a  j=2, 子串P1 != P0,k不存在,next(j)=0
            a  j=3, 存在且仅存在子串P2==P0,next(j)=k=1
            b  j=4, 存在且仅存在子串P3==P0,next(j)=k=1
            c  j=5, 存在最大子串P3P4==P0P1,next(j)=k=2
            a  j=6, 不存在要求子串,next(j)=0
            c  j=7, 存在且仅存在子串P6==P0,next(j)=k=1

尝试用java写个最弱智版本的求next数组的办法,之后我们再看看能不能优化,代码如下:

    /***
     * 获得next数组 对于循环中的j 寻找P[0]到p[k-1]=p[j-k]=p[j-1]  如果不存在这样的k k=0  因为java中substring方法含头不含尾的特性 对于尾部要进行+1的特殊处理
     * @param P  模式P
     * @return
     */
    public static Integer[] getNextArray(String P) {
        Integer[] next = new Integer[P.length()];
        next[0]=-1;
        for (int j = 1; j < P.length(); j++) {
            for (int k = j-1; k >0; k--) {
                System.out.println("j="+j+"&k="+k+"&P(0)到P(k-1)="+P.substring(0, k-1+1)+"&P(j)到P(j - 1)="+P.substring(j - k, j - 1+1));
                if (!P.substring(0, k-1+1).equals("")&&P.substring(0, k-1+1).equals(P.substring(j - k, j - 1+1))) {
                    next[j] = k;
                    System.out.println("j="+j+"&k="+k);
                    break;
                }
            }
            if (next[j] == null) {
                //没查找到
                System.out.println("没查找到 j=" + j);
                next[j] = 0;
            }
        }
        return next;
    }

这个方法显然效率不能让人接受,我们继续寻找一下规律。还是对于P=‘abaabcac’时,

a  j=0, 特殊情况,设置next(j)=-1
            b  j=1, 满足0             a  j=2, 子串P1 != P0,k不存在,next(j)=0,P[next(j)]=a=P[j]
            a  j=3, 存在且仅存在子串P2==P0,next(j)=k=1,P[next(j)]=b!=P[j]
            b  j=4, 存在且仅存在子串P3==P0,next(j)=k=1,P[next(j)]=b=P[j]
            c  j=5, 存在最大子串P3P4==P0P1,next(j)=k=2,P[next(j)]=a!=P[j]
            a  j=6, 不存在要求子串,next(j)=0,P[next(j)]=a=P[j]
            c  j=7, 存在且仅存在子串P6==P0,next(j)=k=1,P[next(j)]=b!=P[j]

用递推法可以发现的规律是(当然我这种凡人是发现不了的,书上说了我才能确认。T_T):

  • 当P[next(j)]=P[j]时,next(j+1)=next(j)+1。这是可以用公式证明的,因为在P[j]之前已经有P[0 ~ k-1] == p[j-k ~ j-1]。(next[j] == k)这时候现有P[k] == P[j],我们是不是可以得到P[0 ~ k-1] + P[k] == p[j-k ~ j-1] + P[j]。即:P[0 ~ k] == P[j-k ~ j],即next[j+1] == k + 1 == next[j] + 1。所以按照上面的情况,在j=2时满足这个条件,可以推到出j=3时,next[3]=k+1=1。在j=4时再次满足条件,所以满足next[5]=k+1=2。同理next[7]=next[6]+1=1
  • 那么当P[next(j)]!=p[j]的时候呢,我们先看个图:数据结构与算法学习总结(六)——字符串的模式匹配算法_第6张图片,像上边的例子,我们已经不可能找到[ A,B,A,B ]这个最长的后缀串了,但我们还是可能找到[ A,B ]、[ B ]这样的前缀串的。所以这个过程像不像在定位[ A,B,A,C ]这个串,当C和主串不一样了(也就是k位置不一样了),那当然是把指针移动到next[k]啦,所以只需要执行k=next[k]

经过优化过的求next数组的代码就变成了:

public static Integer[] getNextArray2(String P) {
        char[] p=P.toCharArray();
        Integer[] next = new Integer[P.length()];
        next[0] = -1;
        int j = 0;
        int k = -1;
        while (j < P.length() - 1) {
            if (k == -1 || p[j] == p[k]) {
                next[++j] = ++k;
            } else {
                k = next[k];
            }
        }
        return next;
    }

下面就可以写KMP算法了,代码实现如下:

public static int KMP(String ts, String ps) {
        char[] t = ts.toCharArray();
        char[] p = ps.toCharArray();
        int i = 0; // 主串T的位置
        int j = 0; // 模式串P的位置
        Integer[] next = getNextArray2(ps);
        while (i < t.length && j < p.length) {
            if (j == -1 || t[i] == p[j]) { // 当j为-1时,要移动的是i,当然j也要归0
                i++;
                j++;
            } else {
                // i不需要回溯了
                // i = i - j + 1;
                j = next[j]; // j回到指定位置
            }
        }
        if (j == p.length) {
            return i - j;
        } else {
            return -1;
        }
    }

算法分析:

由于j=next[j],每执行一次必然使得j减少,而使得j增加的操作只有j++;那么如果j=next[j]执行次数如果超过n(T的长度)次就会变成负数了。所以时间效率为O(n),同理求Next数组时间复杂度为O(m),所以KMP算法的时间复杂度为O(n+m),对比朴素的O(m*n)提升很大了。

 

ps:花了一整天,哎。CSDN针对我啊,每次都要审半天。

你可能感兴趣的:(数据结构与算法)