从暴力法到 KMP 算法的演进,JAVA和JS实现

 

  KMP算法是目前应用非常广泛的一种字符串匹配算法,因为其代码量比较少,相较传统的解法又多了一个初学会比较陌生的辅助数组,所以在刚接触的情况下比较难以理解。

  需要说明的是,kmp算法有非常多的改进或衍生版本,看每一个版本的说明时建议看到底再看其它版本,防止概念混淆。比如 next 数组的定义,某些版本由 prefix 数组演变而来,而有些直接将 prefix 数组当做 next 数组使用(比如本博客)。

  下面我们从字符串匹配的暴力解法开始,看一下从传统暴力解法到 KMP 算法的演变思路。首先来个总结,这部分如果对kmp已经有一定的了解可以看下,否则可以直接略过。

  理解算法的思路我们应该去把握核心概念和思想,个人理解,kmp 算法的核心是通过 target 数组本身的重复结构,在回溯 source 数组时直接回溯到最近的一个可以作为 target 数组头元素且后续已确定的元素可以作为 target 数组头元素的后继元素的元素,略过无效元素的比对。

  而如何找到最近的有效的可以作为 target 头元素的元素则是实现上述思想的核心问题,解决这个问题我们依靠的是 next 数组。

  next 数组的意义是,当 source i到j 与 target m到n 已经匹配时,我们可以通过 target 的 m 到 n 完全确定 source i到j 的值,这样我们只需要研究 target 的 m 到 n 中哪些元素可以作为新一轮比对中 target 的开端数组,便可以求出在 source 中 j 需要回退的距离。

  next 数组的语义是,如果当前比对的两个元素是 source[i] 与 target[j] 发生失配,next[j] 表示 target[j] 可以作为 target 中某个元素下标为 n ,而我们可以保证在 source 中,target[0] - target[n] 与 source[i-n] - source[i] 是完全匹配的,所以 i 可以直接跳转到 source[i-n]。也就是说,next[j] 表示 target[j] 可以作为 target 的第 n 个元素,n 之前的元素我们无需再进行比对。

  基于以上思路,next 数组求解的 JS 实现如下:

function kmpNext(target){
    var length=target.length;
    //创建 next 数组
    var nextArr=new Array(length);
    nextArr[0]=0;
    //数组下标指针 i 起始位置为 0,随着比对结果调整 i 的指向
    var i = 0;
    //nextArr 数组下标,依次填充 nextArr。分两种情况:target[i]==target[j]与target[i]!=target[j]
    for(var j=1;j<length;j++){
        //如果 j 指向的元素可以作为第 i 个元素
        if(target[i]==target[j]){
            i++;
            nextArr[j]=i;
        }else{
            //如果 j 指向的元素不能作为第 i 个元素,能否作为第 i 个元素的前驱元素
            while(i>0&&target[i]!=target[j]){
                //从上一个可以作为 nextArr[i-1] 指向的元素开始匹配,查看 target[j] 是否可以作为 target[next[i-1]+1]
                i=nextArr[i-1];
            }
            if(i>0){
                nextArr[j]=nextArr[i]+1;
                i++;
            }else{
                //如果 i 直接回退到 0 ,查看target[j] 是否可以作为 target[0]
                if(target[0]==target[j]){
                    nextArr[j]=1;
                    i++;
                }else{
                    nextArr[j]=0;
                }
            }
        }
    }
    return nextArr;
}

  关键在红框部分:

  下面我们正式从 0 开始:

  首先我们有一个源字符串 source ,和一个目标字符串 target,我们想要知道 source 中是否包含 target,以及如果包含的话,target 在 source 中的位置。

  

  按照暴力的解法,我们的思路是,维护两个指针 i 和 j ,分别指向 source 和 target 的第零个元素。

  对 i 和 j 指向的元素进行比较,如果相同,i 和 j 同时后移一位继续比较,如果不同, i 恢复为 i -  j + 1 ,而 j 恢复为 0 。也就是 target 从头开始与 source 的下一个 i 进行比较。

  我们看一下比较的过程:

  首先我们初始化两个指针 i 与 j ,将其分别指向 source 与 target 的头元素:

   我们发现两个元素都是 a ,是相等的,i 与 j 同时后移:

   第二个元素时 b ,也相等,继续后移:

   还是相等,继续后移:

   现在我们发现,i 与 j 指向的元素不再相等了,也就是说,i 从零开始与 target 匹配是匹配不上的,我们尝试 i 从 1  开始与 target 进行匹配:

   就这样,我们开始了新一轮的匹配,整个比对过程周而复始的重复上述步骤,最坏的情况下需要将 以 i (0 到 length-4)为开头的子串均与 target 进行比较一次,如果 source 长度为 m ,target 长度为 n ,那么该算法的时间复杂度为 O( m*n )。

  暴力解法JAVA实现如下:

    /**
     * @Author Nxy
     * @Date 2020/2/16 17:47
     * @Param source:源字符串数组,target:目标字符串数组
     * @Return -1:target 不是 source 的子串;其它返回值:target 在 source 中的位置
     * @Exception
     * @Description 判断目标字符串是否为源字符串子串
     */
    public static final int isSonStr(char[] source, char[] target) {
        if (target == null || source == null) {
            throw new RuntimeException("入参存在空串!");
        }
        int sourceLength = source.length;
        int targetLength = target.length;
        //特殊情况处理
        if (targetLength > sourceLength) {
            return -1;
        }
        int i=0;
        while(i < sourceLength-targetLength) {
            for (int j = 0; j <= targetLength; j++) {
                //target 匹配完成,返回结果
                if (j == targetLength) {
                    return i - j ;
                }
                if (source[i] == target[j]) {
                    i++;
                } else {
                    i = i - j + 1;
                    break;
                }
            }
        }
        return -1;
    }

  暴力解法 JS 实现如下:

function isSonArr(source,target){
    if(typeof(source)=="undefined"||typeof(target)=="undefined"){
        throw new Error("入参存在空串!");
    }
    var sourceLength=source.length;
    var targetLength=target.length;
    if(targetLength>sourceLength){
        return -1;
    }
    var i=0;
    while(i<sourceLength-targetLength){
        for(var j=0;j<=targetLength;j++){
            if(j==targetLength){
                return i-j;
            }
            if(source[i]==target[j]){
                i++;
            }else{
                i=i-j+1;
                break;
            }
        }
    }
    return -1;
}

  如果只是要得到正确结果的话,暴力解法没有任何问题,但是如果对时间复杂度有一定的要求,暴力解法就有些力不从心了。

  我们回看一下,比对的过程,当我们发现,i 与 j 所指向的元素不同时:

  对于 i 指针的回退,我们会直接回退到 i-j+1 ,我们能不能直接略过已经比对过的元素(标红)呢:

   从肉眼看便知道答案是否定的。我们不能略过 source[3] 也就是 a 。造成这种问题的原因是,source[3] 虽然不等于 target[3] ,但是 source[3] = target[0] , source[3] 虽然不能作为子串的第四个元素,但是 source[3] 是可以做为子串的起点

  所以我们在略过已经比对过的元素时,原则便是不能略过 source 中可以作为 target 前缀的元素。 

   我们要做的是找到 source 中除 source[0] 外下一个可以作为 target[0] 的元素。这时我们在 source 中 i 应该回退到哪里呢?

   source[0],source[1],source[2] 我们都已经做过比对,其分别等于 target 中的 0到2 ,且在 target 中我们便知道 0到2 下标的元素不可以作为头元素,那么0,1,2我们全部略过,至于 source[3] ,因为不等于target[3] ,所以我们无法从target 知道它是否可以作为 target 的头元素,保险起见我们从 source[3] 开始与 target[0] 进行下一轮的比对,将 source[1],source[2] 都略过,这样便避免了许多无效计算。

  以上思路和核心是,我们从 target 中得知,已比对过的元素中哪些可以作为 target 的起始元素,这些元素不能略过,应做为起始位置与 target 进行对比。而其余不能作为起始元素的,我们直接略过避免无效的运算。

  我们对原来的算法做一下优化,我们新建一个数组,用于标识 target 中哪些元素可以作为 target 的起始元素。很显然数组为:[ 1 ,0,0,0 ] 。也就是说这些元素除头元素外都不能作为 target 的起始元素。那么我们在进行比对时,每次 source[i] != target[j] ,i 不必回退到 i-j+1 ,i 只需要呆在原地与 target[0] 比较就好了,因为我们知道 source 的 0到 2 与 target 的 0到 2 均相等,而且 target 的 0 到 2 均不能作为 target 的起始元素,回退到这些元素与 target[0] 比较没有意义。

  那么下面让我们来看一种更复杂的情况,也就是 target 中除头元素外,有元素可以作为起始元素的情况,来讨论如何求得我们应略过的位数:

 

   我们略过相同的部分,直接到需要 i 发生回溯的比较位置:

 

  我们可以看到,当 i = j =4 时,source [ 4 ]  !=  target [ 4 ] ,此时我们需要将指针 i 进行回溯。

  按照暴力解法, i 应该回溯到 1 位置。但是正如上面所说,因为两个数组中下标为 0 到 3  的元素相同,所以我们可以直接从 target 中判断, source 中下标 0 到 3 的元素可不可以作为 target 的头元素。

  我们可以看到:

  下标为 1 的元素不能作为头元素,所以我们在将 i 进行回退时可以直接略过:

  原来我们需要将 i 回退为 i-j+1=1 的位置来与 target 进行新一轮的比较,但现在因为我们知道了,从 target 数组看,b是不能作为头元素的,我们可以直接略过。

  我们新建一个数组,用于标识出 target 数组中,可以作为头元素的元素,0 表示该元素不能作为头元素,而 1 表示该元素可以作为头元素:

  得到的辅助数组我们称为 prefix 数组(前缀表),当我们的 i 与 j 指向的元素不匹配时,如果 next[j] =1, 也就是当前 j 可以作为 target 的头元素,我们直接将 i 移动到 source 中指向这个 j 的元素,也就是 source 中当前 i 不变,j 置为 0 。

  这样,我们可以在 j 指向可以作为头元素的元素时,若需要回退 i ,减少 i 的回退次数。

  但还有一种情况是,如果在 j 为 3 时发生了不匹配:

   通过上面的数组我们发现 target[3] 不能作为 target 的头元素,所以 i 依然要回退到最初的位置进行比较。

  但实际上,因为其前驱元素可以作为 target 的头元素,所以 i 只需要向前移动一位即可。

  为了表示这种关系,我们将 next[4] 中的置为 2,表示在其前驱元素可以作为 target 数组的头元素的前提下,其可以作为 target 数组的第二个元素(可以作为第 n 个元素的情况同样)。这样,i 在回溯时只需要回溯一个位置,回溯到离其最近的头元素即可。 

  这样,我们在 i 与 j 不匹配时,通过查 next 表,便可以知道 i 需要向前回溯几个位置,也就是 i 需要向前回溯 next[j]-1 个位置(next[j] 非零的情况下)。

  我们再来考虑一种情况,当 j 指向 target[5] 也就是 c 时,如果发生不匹配是否需要将 i 回退到与 target[3] 对应的 source 的元素呢。答案是没有必要的,因为虽然 target[2]=a 可以作为 target[0],target[3]=b 可以作为 target[1] ,但是 target[4]=b 不能作为 target[2] ,所以即使从 target[2] 作为头开始匹配,也会在 target[4] 处匹配失败

  以上过程说明,我们在 i 跳跃时,不只是跳跃到离当前 i 最近的头元素,还要跳到离当前 i 最近的有效的头元素。

  而最近的头元素是否有效,需要看当前 i 的前驱元素是否是有效的。

  所以我们的比对过程便成了:

  如果 source[i] == target[j] ,则 i++,j++

  如果 source[i] != target[j] ,我们需要查询 next[j-1] 是否为0,如果为 0 ,则 i 不变,j变为0 继续比对。否则 i 回退 next[j-1] 位回退到最近的有效头元素,j 变为 0 继续比对。

  当元素发生失配时,source中发生失配的元素与target中对应的元素不同,我们不能通过 target 的 next 数组判断 source 中的该元素是否可以作为 target 中的第 n 号元素,所以我们通过其前驱元素(因为前驱元素 source 中与 target 中相同)判断距离最近的可作为头元素的节点是否有效,从而决定是否跳转。整个逻辑比较复杂,思路清奇,总之我是裂开了。

  下面上代码,已经过 leetcode 验证正确性:

/**
     * @Author Nxy
     * @Date 2020/2/17 19:18
     * @Param
     * @Return
     * @Exception
     * @Description kmp 算法判断 target 是否为 source 的子串
     */
    public static int kmp(char[] source, char[] target) {
        int[] prefixArr = kmpNext(target);
        int sourceLength = source.length;
        int targetLength = target.length;
        if (targetLength == 0) {
            return 0;
        }
        int i = 0;
        for (int j = 0; j <= targetLength; j++) {
            if (j == targetLength) {
                return i - j;
            }
            if (i == sourceLength) {
                return -1;
            }
            if (source[i] == target[j]) {
                i++;
            } else {
                if (j == 0) {
                    i++;
                    j = -1;
                } else {
                    int pre = prefixArr[j - 1];
//                    i = i - pre;
//                    j = -1;
                    j = pre - 1;
                }
            }
        }
        return -1;
    }

    /**
     * @Author Nxy
     * @Date 2020/2/17 21:15
     * @Param
     * @Return
     * @Exception
     * @Description 计算 target 的 next 数组
     */
    public static int[] kmpNext(char[] target) {
        if (target == null) {
            return null;
        }
        int[] next = new int[target.length];
        next[0] = 0;
        for (int i = 1, j = 0; i < target.length; i++) {
            while (j > 0 && target[j] != target[i]) {
                j = next[j - 1];
            }
            if (target[i] == target[j]) {
                j++;
            }
            next[i] = j;
        }
        return next;
    }

 

posted @ 2020-02-17 17:34  牛有肉  阅读(294)  评论(0编辑  收藏  举报