【算法】牛客网算法进阶班(KMP算法和Manacher算法详解)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ARPOSPF/article/details/82081768

KMP算法和Manacher算法详解


KMP算法(字符串匹配)

给定两个字符串str和match,长度分别为N和M。实现一个算法,如果字符串str中含有子串match,则返回match在str中的开始位置。不含有则返回-1.

例如:

str="acbc",match="bc",返回2;

str="acbc",match="bcc",返回-1;

要求:如果match的长度大于str的长度(M>N),str必然不会含有match,可直接返回-1.但如果N≥M,要求算法复杂度为O(N).

思考:

最普通的解法是从左到右遍历str的每一个字符,然后看如果以当前字符作为第一个字符开始出发是否匹配match,普通算法的时间复杂度较高,从每个字符出发时,匹配的代价都可能是O(N),那么一共有N个字符,所以整体的时间复杂度为O(N*M)。普通解法的时间复杂度这么高,是因为每次遍历到一个字符时,检查工作相当于从无开始,之前的遍历检查不能优化当前的遍历检查。

使用KMP算法快速解决字符串匹配问题:

  1. 首先生成match字符串的nextArr数组,这个数组的长度与match字符串的长度一样,nextArr[i]的含义是在match[i]之前的字符串match[0...i-1]中,必须以match[i-1]结尾的后缀子串(不能包含match[0])与必须以match[0]开头的前缀子串(不能包含match[i-1])最大的匹配长度是多少。这个长度就是nextArr[i]的值。
  2. 假设从str[i]字符出发时,匹配到j位置的字符发现与match中的字符不一致。也就是说,str[i]与match[0]一样,并且从这个位置开始一直可以匹配,即str[i,,,j-1]与match[0...,j-i-1]一样,知道发现str[j]!=match[j-1],匹配停止。因为现在已经有了match字符串的nextArr数组,nextArr[j-1]的值表示match[0...j-i-1]这一段字符串前缀和后缀的最大匹配。下一次直接让str[j]与match[k]进行匹配检查。对于match来说,相当于向右滑动,让match[k]滑动太str[j]同一个位置上,然后进行后续的匹配检查。直到在str的某一个位置把match完全匹配完,就说明str中有match。如果match滑到最后也没有匹配出来,就说明str中没有match.
  3. 匹配过程分析完毕,str中匹配的位置是不退回的,match则一直向右滑动,如果在str中的某个位置完全匹配出match,整个过程停止。否则match滑到str的最右侧过程也停止,所以滑动的长度最大为N,所以时间复杂度为O(N)。

如何快速得到match字符串的nextArr数组,并且要证明得到nextArr数组的时间复杂度为O(M)。对于match[0]来说,在它之前,没有字符,所以nextArr[0]规定为-1。对于match[1]来说,在它之前有match[0],但nextArr数组的定义要求任何子串的后缀不能包括第一个字符(match[0]),所以match[1]之前的字符串只有长度为0的后缀字符串,所以nextArr[1]为0,之后对match[i](i>1)来说,求解过程如下:

  1. 因为从左到右依次求解nextArr,所以在求解nextArr[i]时,nextArr[0...i-1]的值都已经求出。通过nextArr[i-1]的值可以知道B字符串的最长前缀和后缀匹配区域。设L区域为最长匹配的前缀子串,K区域为最长匹配的后缀子串,C为L区域之后的字符,B为K区域之后的字符,A是B字符之后的字符。然后查看字符C与字符B是否相等。
  2. 如果字符C与字符B相等,那么A字符之前的字符串的最长前缀和后缀匹配区域就可以确定,前缀子串为L区域+C字符,后缀子串为K区域+B字符,即nextArr[i] = nextArr[i-1]+1.
  3. 如果字符C与字符B不相等,就看字符C之前的前缀和后缀匹配情况,假设字符C是第cn个字符(match[cn]),那么nextArr[cn]就是其最长前缀和后缀匹配的长度。m区域和n区域分别是字符C之前的字符串的最长匹配的后缀与前缀区域,这是通过nextArr[cn]的值确定的。当然两个区域是相等的,m'区域为k区域最后的区域且长度与m区域一样,因为k区域和L区域是相等的,所以m区域和m'区域也是相等的,字符D为n区域之后的第一个字符,接下来比较字符D是否与字符B相等。
    1. 如果相等,A字符之前的字符串的最长前缀与后缀匹配区域就可以确定,前缀子串为n区域+D字符,后缀子串为m'区域+B字符,则令nextArr[i]=nextArr[cn]+1。
    2. 如果不等,继续往前跳到字符D,之后的过程与跳到字符C类似,一直进行这样的跳过程,跳的每一步都会有一个新的字符和B比较(就像C字符和D字符一样),只要有相等的情况,nextArr[i]的值就能确定。
    3. 如果向前调到最左位置(即match[0]的位置),此时nextArr[0]=-1,说明字符A之前的字符串不存在前缀和后缀匹配的情况,则令nextArr[I]=0。用这种不断向前跳的方式可以算出正确的nextArr[I]值的原因还是因为每跳到一个位置cn,nextArr[cn]的意义就表示它之前字符串的最大匹配长度。

代码:

package NowCoder2;

import java.util.Scanner;

public class KMP {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (sc.hasNext()) {
            String str = sc.nextLine();
            String match = sc.nextLine();
            System.out.println(getIndexOf(str, match));
        }
        sc.close();
    }

    private static int getIndexOf(String str, String match) {
        if (str == null || match == null || match.length() < 1 || str.length() < match.length()) {
            return -1;
        }
        char[] ss = str.toCharArray();
        char[] ms = match.toCharArray();
        int si = 0;
        int mi = 0;
        int[] next = getNextArray(ms);
        while (si < ss.length && mi < ms.length) {
            if (ss[si] == ms[mi]) {
                si++;
                mi++;
            } else if (next[mi] == -1) {
                si++;
            } else {
                mi = next[mi];
            }
        }
        return mi == ms.length ? si - mi : -1;
    }

    private static int[] getNextArray(char[] ms) {
        if (ms.length == 1) {
            return new int[]{-1};
        }
        int[] next = new int[ms.length];
        next[0] = -1;
        next[1] = 0;
        int pos = 2;
        int cn = 0;
        while (pos < next.length) {
            if (ms[pos - 1] == ms[cn]) {
                next[pos++] = ++cn;
            } else if (cn > 0) {//没匹配上,cn还可以往前跳
                cn = next[cn];
            } else {//没配上,cn已经跳到了0的位置,不能往前跳了
                next[pos++] = 0;
            }
        }
        return next;
    }
}

相似题目

给定一个字符串s,请计算输出含有连续两个s作为子串的最短字符串,注意这两个s可能有重叠部分,例如“ababa”包含两个“aba”。

输入描述:输入包括一个字符串s,字符串长度length(1<length<50),s中每个字符都是小写字母。

输出描述:输出一个字符串,即含有连续两个s作为子串的最短字符串。

输入样例:

abracadabra

输出样例:

abracadabracadabra

思考:题目中说是包含两个s作为子串,而且可以有重叠部分,所以说,如果没有重叠的部分,最后输出的便是最长的字符串,即两个s拼接在一起。如果有重叠的部分,也是仅限于s的前n位和s的后n位是一样的才可以。因此,我们可以用最简单的方式,把两个s当成s1和s2,从s1的第2(n)位往后开始和s2的第一位往后的每一个字符进行比对,如果直到s1的结果都是一致的,则最后输出的字符串便是s1的前1(n-1)个字符拼接s2.如果有不一致的,则从s1的第3(n+1)位开始重复比对,以此类推。

代码:

import java.util.Scanner;

public class JD_Code01 {
    public static void main(String[] args){
        Scanner sc = new Scanner(System.in);
        String str = sc.nextLine();
        System.out.println(getString(str));
    }

    private static String getString(String str) {
        if (str==null||str.length()==0){
            return null;
        }
        char[] charArr = str.toCharArray();
        for (int i = 1; i < charArr.length; i++) {
            //每次从s1的第i位与s2的第0位开始比较
            int j = 0;
            int tempi = i;
            //如果相等则继续比较s1和s2的下一位字符
            while (charArr[j]==charArr[tempi]){
                //如果到了s1的最后一位都是相等的,则返回最短字符串
                if (tempi==charArr.length-1){
                    String res = str.substring(0,i);
                    return res+str;
                }
                j++;
                tempi++;
            }
        }
        //如果没有重复,则返回两个s拼接的字符串
        return str+str;
    }
}

Manacher算法

题目:给定一个字符串str,返回str中最长回文子串的长度。

例如:

str="123",其中的最长回文子串为“1”、“2”或者“3”,所以返回1.

str="abc1234321ab",其中最长回文子串为“1234321”,所以返回7.

进阶题目:给定一个字符串str,想通过添加字符的方式使得str整体都变成回文字符串,但要求只能在str的末尾添加字符,请返回在str后面添加的最短字符串。

例如:str="12"。在末尾添加“1”之后,str变成“121”,是回文串。在末尾添加“21”之后,str变成“1221”,也是回文串,但“1”是所有添加方案中最短的,所以返回“1”。

要求:如果str的长度为N,解决原问题和进阶问题的时间复杂度都达到O(N)。

思考:

使用Manacher算法解决原问题的过程:

  1. 因为奇回文和偶回文在判断时比较麻烦,所以对str进行处理,把每个字符开头、结尾和中间插入一个特殊字符‘#’来得到一个新的字符串数组,比如str="bcbaa",处理后为“#b#c#b#a#a#”,然后从每个字符左右扩出去的方式找最大回文子串就方便多了。通过这样的处理方式,最大回文子串无论是偶回文还是奇回文,都可以通过统一的“扩”过程找到,解决了差异性的问题。同时要说的是,这个特殊字符是什么无所谓,甚至可以是字符串中出现的字符,也不会影响最终的结果,就是一个辅助性的功能。
  2. 假设str处理之后的字符串记为charArr,对每个字符(包括特殊字符)都进行“优化后”的扩过程。首先解释如下三个辅助变量的意义。
    • 数组pArr。长度与charArr长度一样。pArr[i]的意义是以i位置上的字符(charArr[i])作为回文中心的情况下,扩出去得到的最大回文半径是多少。
    • 整数pR。这个变量的意义是之前遍历的所有字符的所有回文半径中,最右即将到达的位置。换句话说,pR就是遍历过的所有字符中向右扩出来的最大右边界。只要右边界更往右,pR就更新。
    • 整数index。这个变量表示最近一次pR更新时,那个回文中心的位置。
  3. 只要能够从左到右依次算出数组pArr每个位置的值,最大的那个值实际上就是处理后的charArr中最大的回文半径,根据最大的回文半径,再对应回原字符串的话,整个问题就解决了。步骤3就是从左到右依次计算出pArr数组每个位置的值的过程。
    • 假设现在计算到位置i的字符charArr[i],在i之前位置的计算过程中,都会不断地更新pR和index的值,即位置i之前的index这个回文中心扩出了一个目前最右的回文边界pR。
    • 如果pR-1位置没有包住当前i位置,也就是说,右边界在1位置,1位置为最右回文半径即将到达但还没有达到的位置,所以当前的pR-1位置没有包住当前的i位置。此时和普通做法一样,从i位置字符开始,向左右两侧扩出去检查,此时的“扩”过程没有获得加速。
    • 如果pR-1位置包住了当前的i位置。这种情况下,检查过程是可以获得优化的。这也是manacher算法的核心内容。位置i是要计算回文半径(pArr[i])的位置,pR-1位置此时是包住位置i的。同时根据index的定义,index是pR更新时那个回文中心的位置,所以如果pR-1位置以index为中心对称,即“左大”位置,那么从“左大”位置到pR-1位置一定是以index为中心的回文串,称之为大回文串,同时把pR-1位置称为“右大”位置。既然回文半径数组pArr是从左到右计算的,所以位置i之前的所有位置都已经算过回文半径。假设位置i以index为中心向左对称过去的位置为i',那么位置i'的回文半径也是计算过的。那么以i'为中心的最大回文串大小(pArr[i'])必然只有三种情况,假设以i’为中心的最大回文串的左边界和右边界分别记为“左小”和“右小”。
      • 情况三:“左小”和“左大”是同一位置,即以i'为中心的最大回文串压在了以index为中心的最大回文串的边界上。
      • 情况二:“左小”和“右小”的左侧部分在“左大”和“右大”的外部。
      • 情况一:“左小”和“右小”完全在“左大”和“右大”内部,即以i’为中心的最大回文串完全在以index为中心的最大回文串的内部。
    • 按照步骤3的逻辑从左到右计算出pArr数组,计算完成后再遍历一遍pArr数组,找出最大的回文半径,假设位置i的回文半径最大,即pArr[i] == max。但max只是charArr的最大回文半径,还得对应回原来的字符串,求出最大回文半径的长度(其实就是max-1)。

Manacher算法的时间复杂度为O(N)。关键之处在于估算扩出去检查这一行为发生的数量。原字符串在处理后的长度由N变为2N。要么在计算一个未知的回文半径时完全不需要扩出去检查,要么每一次扩出去检查都会导致pR变量的更新。扩出去检查时都让回文半径到达更右的位置,当然会使pR更新。然而pR最多是从-1增加到2N(右边界),并且从不减少,所以扩出去检查的次数就是O(N)级别的。所以Manacher算法的时间复杂度为O(N)。

代码:

package NowCoder2;

import java.util.Scanner;

public class Manacher {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (sc.hasNext()) {
            String str = sc.nextLine();
            System.out.println(maxLcpsLength(str));
        }
        sc.close();
    }

    private static int maxLcpsLength(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        char[] charArr = manacherString(str);// 12321--> #1#2#3#2#1#
        int[] pArr = new int[charArr.length];
        int index = -1;
        int pR = -1;
        int max = Integer.MIN_VALUE;
        for (int i = 0; i != charArr.length; i++) {
            pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
            while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
                if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
                    pArr[i]++;
                else {
                    break;
                }
            }
            if (i + pArr[i] > pR) {
                pR = i + pArr[i];
                index = i;
            }
            max = Math.min(max, pArr[i]);
        }
        return max - 1;
    }

    private static char[] manacherString(String str) {
        char[] charArr = str.toCharArray();
        char[] res = new char[str.length() * 2 + 1];
        int index = 0;
        for (int i = 0; i != res.length; i++) {
            res[i] = (i & 1) == 0 ? '#' : charArr[index++];
        }
        return res;
    }
}

进阶问题:在字符串的最后添加最少字符串,使整个字符串都称为回文串,其实就是查找在必须包含最后一个字符的情况下,最长的回文子串是什么。那么之前不是最长回文子串的部分逆序过来,就是应该添加的部分。具体做法:从左到右计算回文半径时,关注回文半径最右即将到达的位置(pR),一旦发现已经到达最后(pR==charArr.length),说明必须包含最后一个字符的最长回文半径已经找到,直接退出检查过程,返回该添加的字符串即可。

/**
     * 在字符串的最后添加最少字符,使整个字符串都成为回文串
     * @param str
     * @return
     */
    private static String shortestEnd(String str) {
        if (str == null || str.length() == 0) {
            return null;
        }
        char[] charArr = manacherString(str);
        int[] pArr = new int[charArr.length];
        int index = -1;
        int pR = -1;
        int maxContainsEnd = -1;
        for (int i = 0; i != charArr.length; i++) {
            pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : i;
            while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
                if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
                    pArr[i]++;
                else {
                    break;
                }
            }
            if (i + pArr[i] > pR) {
                pR = i + pArr[i];
                index = i;
            }
            if (pR == charArr.length) {
                maxContainsEnd = pArr[i];
                break;
            }
        }
        char[] res = new char[str.length() - maxContainsEnd + 1];
        for (int i = 0; i < res.length; i++) {
            res[res.length - 1 - i] = charArr[i * 2 + 1];
        }
        return String.valueOf(res);
    }

猜你喜欢

转载自blog.csdn.net/ARPOSPF/article/details/82081768
今日推荐