字符串匹配算法总结

举例来说,有一个字符串"BBC ABCDAB ABCDABCDABDE",我想知道,里面是否包含另一个字符串"ABCDABD"?

暴力搜索

首先,字符串"BBC ABCDAB ABCDABCDABDE"的第一个字符与搜索词"ABCDABD"的第一个字符,进行比较。因为B与A不匹配,所以搜索词后移一位。
在这里插入图片描述
因为B与A不匹配,搜索词再往后移。
在这里插入图片描述
就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。
在这里插入图片描述
直到字符串有一个字符,与搜索词对应的字符不相同为止。

在这里插入图片描述
这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。这种方法就是暴力解法。

    /**
     * 暴力搜索
     *
     * @param txt 文本串
     * @param pat 目标串
     * @return 目标串在文本串中首次出现的位置
     */
    private static int forceSearch(String txt, String pat) {
        int M = txt.length();
        int N = pat.length();
        for (int i = 0; i <= M - N; i++) {
            int j;
            for (j = 0; j < N; j++) {
                if (txt.charAt(i + j) != pat.charAt(j))
                    break;
            }
            if (j == N)
                return i;
        }
        return -1;
    }

KMP

Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。
首先,要了解两个概念:“前缀"和"后缀”。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。
"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,

  - "A"的前缀和后缀都为空集,共有元素的长度为0;

  - "AB"的前缀为[A],后缀为[B],共有元素的长度为0;

  - "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;

  - "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;

  - "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1;

  - "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2;

  - "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。

在这里插入图片描述
一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。
怎么做到这一点呢?可以针对搜索词,算出一张《部分匹配表》(Partial Match Table)。
已知空格与D不匹配时,前面六个字符"ABCDAB"是匹配的。查表可知,最后一个匹配字符B对应的"部分匹配值"为2,因此按照下面的公式算出向后移动的位数:

移动位数 = 已匹配的字符数 - 对应的部分匹配值

因为 6 - 2 等于4,所以将搜索词向后移动4位。
在这里插入图片描述

 /**
     * KMP搜索
     * 移动位数 = 已匹配的字符数 - 对应的部分匹配值
     *
     * @param txt  文本串
     * @param pat  目标串
     * @param next 部分匹配表
     * @return 目标串在文本串中首次出现的位置
     */


    private static int KMPSearch(String txt, String pat, int[] next) {
        int M = txt.length(); //文本串长度
        int N = pat.length(); //目标串长度
        int i = 0;
        int j = 0;
        while (i < M && j < N) {
            if (j == -1 || txt.charAt(i) == pat.charAt(j)) {
                i++;
                j++;
            } else {
                j = next[j];
            }
        }
        if (j == N)
            return i - j;
        else
            return -1;
    }

    /**
     * 部分匹配表计算方法
     * next[i] = "前缀"和"后缀"的最长的共有元素的长度
     *
     * @param pat 目标串
     * @return 部分匹配表
     */
    private static int[] getNext(String pat) {
        int[] next = new int[pat.length()];
        int N = pat.length();
        next[0] = -1;
        int k = -1;
        int j = 0;
        while (j < N - 1) {
            if (k == -1 || pat.charAt(j) == pat.charAt(k)) {
                ++k;
                ++j;
                next[j] = k;
            } else
                k = next[k];
        }
        System.out.println(Arrays.toString(next));
        return next;
    }

BM

Boyer-Moore算法不仅效率高,而且构思巧妙,容易理解。1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了这种算法。

下面,我根据Moore教授自己的例子来解释这种算法。

在这里插入图片描述
假定字符串为"HERE IS A SIMPLE EXAMPLE",搜索词为"EXAMPLE"。

在这里插入图片描述
首先,"字符串"与"搜索词"头部对齐,从尾部开始比较。

这是一个很聪明的想法,因为如果尾部字符不匹配,那么只要一次比较,就可以知道前7个字符(整体上)肯定不是要找的结果。

我们看到,"S"与"E"不匹配。这时,“S"就被称为"坏字符”(bad character),即不匹配的字符。我们还发现,"S"不包含在搜索词"EXAMPLE"之中,这意味着可以把搜索词直接移到"S"的后一位。

在这里插入图片描述
依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在搜索词"EXAMPLE"之中。所以,将搜索词后移两位,两个"P"对齐。

在这里插入图片描述
我们由此总结出"坏字符规则":

后移位数 = 坏字符的位置 - 搜索词中的上一次出现位置

如果"坏字符"不包含在搜索词之中,则上一次出现位置为 -1。

以"P"为例,它作为"坏字符",出现在搜索词的第6位(从0开始编号),在搜索词中的上一次出现位置为4,所以后移 6 - 4 = 2位。再以前面第二步的"S"为例,它出现在第6位,上一次出现位置是 -1(即未出现),则整个搜索词后移 6 - (-1) = 7位。

在这里插入图片描述
依然从尾部开始比较,"E"与"E"匹配。

在这里插入图片描述
比较前面一位,"LE"与"LE"匹配。

在这里插入图片描述
比较前面一位,"PLE"与"PLE"匹配。

在这里插入图片描述
比较前面一位,“MPLE"与"MPLE"匹配。我们把这种情况称为"好后缀”(good suffix),即所有尾部匹配的字符串。注意,“MPLE”、“PLE”、“LE”、"E"都是好后缀。

在这里插入图片描述
比较前一位,发现"I"与"A"不匹配。所以,“I"是"坏字符”。

在这里插入图片描述

根据"坏字符规则",此时搜索词应该后移 2 - (-1)= 3 位。问题是,此时有没有更好的移法?

在这里插入图片描述
我们知道,此时存在"好后缀"。所以,可以采用"好后缀规则":

后移位数 = 好后缀的位置 - 搜索词中的上一次出现位置

举例来说,如果字符串"ABCDAB"的后一个"AB"是"好后缀"。那么它的位置是5(从0开始计算,取最后的"B"的值),在"搜索词中的上一次出现位置"是1(第一个"B"的位置),所以后移 5 - 1 = 4位,前一个"AB"移到后一个"AB"的位置。

再举一个例子,如果字符串"ABCDEF"的"EF"是好后缀,则"EF"的位置是5 ,上一次出现的位置是 -1(即未出现),所以后移 5 - (-1) = 6位,即整个字符串移到"F"的后一位。

这个规则有三个注意点:

(1)"好后缀"的位置以最后一个字符为准。假定"ABCDEF"的"EF"是好后缀,则它的位置以"F"为准,即5(从0开始计算)。

(2)如果"好后缀"在搜索词中只出现一次,则它的上一次出现位置为 -1。比如,"EF"在"ABCDEF"之中只出现一次,则它的上一次出现位置为-1(即未出现)。

(3)如果"好后缀"有多个,则除了最长的那个"好后缀",其他"好后缀"的上一次出现位置必须在头部。比如,假定"BABCDAB"的"好后缀"是"DAB"、“AB”、“B”,请问这时"好后缀"的上一次出现位置是什么?回答是,此时采用的好后缀是"B",它的上一次出现位置是头部,即第0位。这个规则也可以这样表达:如果最长的那个"好后缀"只出现一次,则可以把搜索词改写成如下形式进行位置计算"(DA)BABCDAB",即虚拟加入最前面的"DA"。

回到上文的这个例子。此时,所有的"好后缀"(MPLE、PLE、LE、E)之中,只有"E"在"EXAMPLE"还出现在头部,所以后移 6 - 0 = 6位。

在这里插入图片描述
可以看到,"坏字符规则"只能移3位,"好后缀规则"可以移6位。所以,Boyer-Moore算法的基本思想是,每次后移这两个规则之中的较大值。

更巧妙的是,这两个规则的移动位数,只与搜索词有关,与原字符串无关。因此,可以预先计算生成《坏字符规则表》和《好后缀规则表》。使用时,只要查表比较一下就可以了。

在这里插入图片描述
继续从尾部开始比较,“P"与"E"不匹配,因此"P"是"坏字符”。根据"坏字符规则",后移 6 - 4 = 2位。

在这里插入图片描述
从尾部开始逐位比较,发现全部匹配,于是搜索结束。如果还要继续查找(即找出全部匹配),则根据"好后缀规则",后移 6 - 0 = 6位,即头部的"E"移到尾部的"E"的位置。


    /**
     * Boyer-Moore 算法
     * 坏字符 好后缀
     *
     * @param txt   文本串
     * @param pat   目标串
     * @param right 数组
     * @return 目标串在文本串中首次出现的位置
     */
    public static int BoyerMooreSearch(String txt, String pat, int[] right) {
        int M = txt.length();
        int N = pat.length();
        int skip;
        for (int i = 0; i <= M - N; i += skip) {
            skip = 0;
            for (int j = N - 1; j >= 0; j--) {
                if (pat.charAt(j) != txt.charAt(i + j)) {
                    skip = j - right[txt.charAt(i + j)];
                    if (skip < 1) {
                        skip = 1;
                    }
                    break;
                }
            }
            if (skip == 0)
                return i;
        }
        return -1;
    }

    /**
     * 统计目标串每个字符最后出现的位置
     *
     * @param pat 目标串
     * @return int[]
     */
    public static int[] getRight(String pat) {
        int[] right = new int[256];
        for (int i = 0; i < 256; i++) {
            right[i] = -1;
        }
        for (int i = 0; i < pat.length(); i++) {
            right[pat.charAt(i)] = i;
        }
        return right;
    }

sunday

Sunday算法由Daniel M.Sunday在1990年提出,它的思想跟BM算法很相似

只不过Sunday算法是从前往后匹配,在匹配失败时关注的是主串中参加匹配的最末位字符的下一位字符。

  • 如果该字符没有在模式串中出现则直接跳过,即移动位数 = 模式串长度 + 1;
  • 否则,其移动位数 = 模式串长度 - 该字符最右出现的位置(以0开始) = 模式串中该字符最右出现的位置到尾部的距离 + 1。

下面举个例子说明下Sunday算法。假定现在要在主串”substring searching”中查找模式串”search”。
刚开始时,把模式串与文主串左边对齐:
在这里插入图片描述

结果发现在第2个字符处发现不匹配,不匹配时关注主串中参加匹配的最末位字符的下一位字符,即标粗的字符 i,因为模式串search中并不存在i,所以模式串直接跳过一大片,向右移动位数 = 匹配串长度 + 1 = 6 + 1 = 7,从 i 之后的那个字符(即字符n)开始下一步的匹配,如下图:
在这里插入图片描述

结果第一个字符就不匹配,再看主串中参加匹配的最末位字符的下一位字符,是’r’,它出现在模式串中的倒数第3位,于是把模式串向右移动3位(m - 3 = 6 - 3 = r 到模式串末尾的距离 + 1 = 2 + 1 =3),使两个’r’对齐,如下:
在这里插入图片描述
匹配成功。

回顾整个过程,我们只移动了两次模式串就找到了匹配位置,缘于Sunday算法每一步的移动量都比较大,效率很高。

偏移表
在预处理中,计算大小为∣∑∣的偏移表。
在这里插入图片描述

例如: P = “search”

m = 6 
shift[s] = 6 - max(s的位置) = 6 - 0 = 6 
shift[e] = 6 - max(e的位置) = 6 - 1 = 5 
shift[a] = 6 - max(a的位置) = 6 - 2 = 4 
shift[r] = 6 - max(r的位置) = 6 - 3 = 3 
shift[c] = 6 - max(c的位置) = 6 - 4 = 2 
shift[h] = 6 - max(h的位置) = 6 - 5 = 1 
shift[其他] = m + 1 = 6 + 1 = 7
 public static int SundaySearch(String txt, String pat) {
        int M = txt.length();
        int N = pat.length();
        int i, j;
        int skip = -1;
        for (i = 0; i <= M - N; i += skip) {
            for (j = 0; j < N; j++) {
                if (txt.charAt(i + j) != pat.charAt(j)){
                    if (i == M - N)
                        break;
                    skip = N - getIndex(pat, txt.charAt(i + N));
                    break;
                }
            }
            if (j == N)
                return i;
        }
        return -1;
    }

    public static int getIndex(String pat, Character c) {
        for (int i = pat.length() - 1; i >= 0; i--) {
            if (pat.charAt(i) == c)
                return i;
        }
        return -1;
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值