数据结构与算法--KMP算法查找子字符串

部分内容和图片来自这三篇文章: 这篇文章这篇文章还有这篇他们写得非常棒。结合他们的解释和自己的理解,完成了本文。

上一节介绍了暴力法查找子字符串,同时也发现了该算法效率并不高。当失配位置之前已经有若干字符匹配时,暴力法很多步骤是多余的。举个KMP算法的例子,看图1

可以看到子串p和主串t在红框处失配了,失配之前的字符串ABC已经匹配。ABA第一个字符A和后面的字符都不同,所以可以放心地直接将子串p的p[0]对齐失配处i,让p[0]和t[i]接着比较。如图2

这是子串p第一个字符和其后的各个字符都不同的情况,如果其后存在相同的字符呢,比如下面图3

失配处之前的字符串已经匹配且为ABA存在相同相同的字符A。这下我们不敢轻易将p[0]移动到和t[i]对齐比较了。因为有个相同的A,所以应该尝试着先移动到那个地方,万一就能匹配上了呢。图4

不巧,匹配再次失败了,只是这种情况下失败了而已。试想如果主串t是ABABADHIJK,子串p还是ABAD,按照上面的步骤,刚好就匹配成功!所以现在知道为什么不要一下就移动到将p[0]与t[i]对齐了吧,因为有可能,所以得尝试。上一步是必须的,不是多余步骤。接着看类似的例子,图5

失配处之前的字符串ABCAB已经匹配,且存在相同的字符串AB。按照上面的思路,应该移动到下图的位置。如图6

这样想来,我们可能认为只要子串中有重复字符,就应该像上面那样移动。再看这个例子,没图了抱歉,凑合着看吧。

ABCADEFGHI
ABCADZ

t串的E和p串的Z处失配,之前的字符串ABCAD已经匹配,且存在相同的字符A,试着移动将p[0]移动到第二个A处,如下

ABCADEFGHI
   ABCADZ

可以看到,在子串中虽然存在相同字符A,但是第一个A之后的B即p[1]和第二个A之后的D即p[4]不同,这个信息我们事先就可以知道。所以即使将A对齐了,p[1]和t[4]比较,t[4] == p[4]也就是p[1]和p[4]比较,肯定不匹配的。这步是多余的!我们可以直接移动到让p[0]和t[i]对齐。如下

ABCADEFGHI
     ABCADZ

所以,存在相同字符并不能作为子串移动位置的判断条件。实际上,确定子串移动位置的是字符串相同前缀、后缀的最大长度。

字符串相同前缀、后缀的最大长度以及next数组

什么叫字符串的前缀,后缀呢?

  • 前缀:除开末尾字符,所有包含首字符的字符串集合;
  • 后缀:除开首字符外,所有包含末尾字符的字符串集合

举个简单的例子,如字符串ABCA,它的前缀有A, AB, ABC,它的后缀有BCA, CA, A,前后缀比较,只有一对相同字符串,且长度为1,所以字符串ABCA相同前缀、后缀的最大长度为1。再看字符串ABCAB,它的前缀有A, AB, ABC, ABCA,它的后缀有BCAB, CAB, AB, B,只有一对相同的字符串AB,且长度为2,故最大长度为2。

既然确定模式字符串移动位置的是字符串相同前缀、后缀的最大长度,这里说的字符串具体来说指的是失配位置之前的字符串。即失配位置之前的字符串的相同前缀、后缀的最大长度k,决定了模式串p[k]应该和失配处t[i]对齐。由于在模式字符串的每个位置都可能失配,所以需要求出模式字符串失配位置之前的字符串的相同前后缀的最大长度,用一个数组存储起来,这个数组称为next数组

如模式字符串ABCDABX,如果在X处失配,求出X之前的子字符串ABCDAB的相同前后缀最大长度为2,如表格中最后一行。又X处索引为6,所以next[6] = 2。如果在D处失配则求出D之前字符串ABC的相同前后缀的最大长度,为0,见表中第3行数据。又D的索引为3,则next[3] = 0。再看在第二个字符处失配,B之前有一个子字符串A,只有一个字符谈不上前后缀,所以相同前后缀的最大长度为0,见表中第1行数据。

那如果在第一个字符A处就失配了呢?由于第一个字符A之前没有子字符串了,按照约定,我们令其相同前后缀的最大长度为-1。所以next[0] = -1。下面next数组的代码实现中会具体说明这个约定的值next[0]的值为什么不是-8, -9或者是0。

按照上面的思路,给出一个字符串就可以写出它的next数组了。还是上面的ABCDABX

失配字符:索引 A :0 B :1 C :2 D :3 A :4 B :5 X : 6
next数组值 -1 0 0 0 0 1 2

这个表格next数组的值和上面的“最大公共元素长度”相比,其实就是先令next[0] = -1,再将这些最大公共元素长度的值填入next[1]~next[size - 1]即可。由此得到next数组为[-1, 0, 0, 0, 1, 2]

好,求出了next数组就好办了。当某一个字符在位置j处失配时,next[j]就指示了模式字符串应该移动到哪个位置。根据next[j]移动到哪儿呢?具体来说就是让next[j]成为新的j,让模式字符串移动,直到p[j]与失配处t[i]对齐,然后让p[j]再和t[i]比较一次。

为了验证这一说法,再次看图1

模式字符串ABCE的next数组为[-1, 0, 0, 0],在j = 3处失配,next[3] = 0,所以让p[0]和t[i]对齐比较。

同样的再看图5

模式字符串的next数组为[-1, 0, 0, 0, 1, 2],在j = 5处失配,next[5] = 2,所以让p[2]和t[i]对齐比较。

第二遍看这些图,是不是清晰多了!

next数组的代码实现

关键是如何通过代码来求模式字符串的next数组,像上面那样列出字符串的所有前后缀,然后比较出相同前后缀的最大长度吗?当然不是,那不是最好的方法。求next数组其实可以看成:字符串自己和自己匹配的过程。

private static int[] getNext(String p) {
    int M = p.length();
    int[] next = new int[M];
    next[0] = -1;
    int j = 0;
    int k = -1;
    while (j < M - 1) {
        if (k == -1 || p.charAt(k) == p.charAt(j)) {
        next[++j] = ++k;
        } else {
        k = next[k];
        }
    }
    return next;
}

拿字符串ABCA作为例子,next数组是[-1, 0, 0, 1]

首先next数组的长度应该和模式字符串的长度一样。所以有int[] next = new int[M];然后next[0]无脑设置成-1。为什么是-1呢?其实可以发现next[1]也是个定值,为0。这是因为索引1之前只有一个字符,它没有前缀后缀之说。if分支里的条件必须是k == -1,这样当第一次进入if分支时,才能保证next[0+1] = -1 + 1,即next[1] = 0。接下来该填next[2]了,理论上为0。自增后k = 0,j = 1, 比较p[0]与p[1],不相同,转向else分支,next[0]赋值给k,因为要给next[2]填入值,所以必须要进入if分支,要么只有next[0] = -1赋值给k后,才能保证一定能进入if分支。从而next[1 + 1] = -1 + 1next[2] = 0。这样就解释了为什么以上的代码实现中,next[0]为什么要设置成-1。

其他的,while里之所以是j < M - 1而不是j < M,是因为下面这句next[++j] = ++k;是先自增后存入的,这意味着最后能存到next[M - 1],刚刚存满数组。如果条件是j < M则会越界。另外p.charAt(k)表示的是前缀的单个字符, p.charAt(j)表示的是后缀的单个字符。

上面代码实现中,并不是列出了所有的前后缀再一一比较的。那么这种实现一定正确吗?我们来看。

next[j] == k应该很好理解,k代表的就是j位置前字符串相同前后缀的最大长度,在这里是2。现在比较p[k]和p[j],相同,所以最大长度应该变成3,进入if分支,next[j + 1] = next[j] + 1 = k + 1

如果p[k]和p[j]不相同呢?

这时next[j + 1]怎么填写呢?理论上来说,列出所有前后缀后,一一比较可以得知有相同前后缀为AB,最大长度为2。转入else分支, k = next[k],这是什么意思呢?看起来是一种递归,如果递归直到k == -1,则说明找不到相同的前后缀,next[j + 1] = 0。看下图,这实际上是模式字符串自己的前缀在和自己的后缀作匹配ABAC可看作模式字符串,其next数组为[-1, 0, 0, 1],它和DABABC在索引k处失配,回忆文章开头,当失配位置之前已经有一些字符匹配,应该怎么移动模式字符串呢?失配处k,k = next[k] = 1,所以让ABAC的第1个位置和j位置对齐。哈!对齐后刚好B和位置j的B相同(如右图),接着进入if分支,则next[j + 1] = k + 1 = 2,与理论值吻合。现在再看k = next[k],是不是一目了然?!

如果模式字符串自己和自己匹配这个事搞不懂,没关系,我们换个角度看问题。看下图

已知:

  • next[j] = k
  • next[k] = 绿色色块所在的索引
  • next[绿色色块所在的索引] = 黄色色块所在的索引
  1. next[j] = k可知字符串A1 == 字符串A2

  2. next[k] == 绿色色块所在的索引可知B1 == B2,又A1 == A2,所以A1的后缀B2与A2的后缀B3相同。所以B1 == B2 == B3

  3. next[绿色色块所在的索引] == 黄色色块所在的索引,可以得到字C1 == C2。又B1 == B2 == B3,可知它们的后缀C2 == C3 ==C4,综上有C1 == C2 == C3 == C4

现在假如p[k] != p[j],则最大长度的前后缀A1和A2分别添加了一个字符后的新字符肯定不是相同的前后缀了,我们退而看看原先第二长的相同前后缀,B1和B3,它们分别加上后一个字符后是否会相同呢,如果相同,则B1与B3加上后一个字符后的新字符就成为了最大长度的相同前后缀;如果不同,再选原先第三长的子串C1和C4,递归查找,直到最后k == -1。如何比较B1和B3后一位字符呢?k = next[k]就是令新的k值为绿色色块(也是串B1后一位字符)所在的索引,此时再让p[k]和p[j]位置对齐(B1和B3重合)比较的就自然是B1与B3的后一位字符了

KMP算法的实现

好了,next数组怎么求讲了,主串和子串匹配时如何根据求得的next数组来移动模式字符串也讲过了。是时候上KMP的实现代码了!

public static int search(String p, String t) {
    // 根据模式字符串获得next数组
    int[] next = getNext(p);
    int N = t.length();
    int M = p.length();
    int i = 0;
    int j = 0;
    while (i < N && j < M) {
        if (j == -1 || p.charAt(j) == t.charAt(i)) {
            i++;
            j++;
        } else {
            j = next[j];
        }
    }
    if (j == M) {
        return i - j;
    } else {
        return -1;
    }
}

先是获得模式字符串的next数组,然后i, j分别是指针主串和子串的指针,当然一开始指向0。如果字符相同,则执行if分支,直到遇到失配字符,转入else分支,next数组指示了子串的哪个位置和失配位置t[i]对齐再次比较。有种情况比较特殊:如果子串在第一个字符(j = 0)处就失配了,那么先转else让j = next[0] = -1,紧接着进入if分支,主串指针i向右移动一位,子串指针j回到位置0,这和暴力法是一个做法。i - j的含义是子串开头在主串中的索引,我们要返回的的正是这个值。

KMP算法的优化

以上KMP算法的实现已经比之前的暴力法好多了,但它也存在多余比较的情况,看下图。

按照上面KMP算法的思想,B != Cj处失配。由于j = next[j] = 1,应该让p[1]和t[i]对齐继续比较。如下

可是我们发现,子串移动之后还是B和C比较,我们刚才失配时就得知B不匹配了,这次的B当然还是不匹配,这步就是多余的。如果聪明些,我们应该使用next[1]的值而不是next[3],从而可以直接将子串移动到p[0]与t[i]对齐。也就是说令next[3] = next[1]

这种情况发生在当p[j] = p[next[j]],而next[j] = k,条件简化为p[j] = p[k]。首先会让p[k]t[i]对齐,然而这步是不必要的,所以还不如跳过这步,让p[next[k]t[i]对齐,即用next[k]的值取代next[j]的值。说取代还是太麻烦了,为何不一开始就改变next数组,只要遇到某个字符满足p[j] = p[k],next[j]的值就直接使用next[k]的值好了。

由此看来,next数组的求法就得改变了。

看字符串AA,按照原来next数组的求法肯定是[-1, 0],因为前两位是定值。

我们来检验p[j] = p[k]这个条件。next[0]还是-1这个改不了。在j = 1处,k = next[1] = 0p[1] == p[0]条件满足!所以应该用next[0]的值取代next[1]。此时next数组变成[-1, -1]

再看上面ABAB,按照原来的next数组求法是[-1, 0, 0, 1]k = next[1] = 0p[1] != p[0]条件不满足,next数组值还是0不改变。next[2] = 0p[2] == p[0]条件满足。所以用next[0]的值取代next[2];k = next[3] = 1p[3] == p[1]条件满足,应该用next[1]的值取代next[3]。综上,此时next数组百变成[-1, 0, -1, 0]

好,现在知道怎么求优化后的next数组了。那么用代码怎么实现呢?其实改动的地方就一处。先上代码,在解释。

private static int[] betterGetNext(String p) {
    int M = p.length();
    int[] next = new int[M];
    next[0] = -1;
    int j = 0;
    int k = -1;
    while (j < M - 1) {
        if (k == -1 || p.charAt(k) == p.charAt(j)) {
            if (p.charAt(k + 1) == p.charAt(j + 1)) {
            next[++j] = next[++k];
            } else {
            next[++j] = ++k;
            }
        } else {
            k = next[k];
        }
    }
    return next;
}

k == -1或者当前字符相同时多了一句判断if (p.charAt(k + 1) == p.charAt(j + 1)),它紧接着预判下一个字符是否也相等,如果相等,则满足条件p[j] = p[k],想想为什么?

  1. 当前字符相同的情况下,下一个字符也相同。下图当前字符p[k - 1] == p[j - 1],预判下一个字符p[k] == p[j],且next[j] = k,满足条件,所以next[j]应该直接使用next[k]的值。

  1. k == -1时,说明当前字符不相同,和上面一样假设当前字符在j - 1处,预判下一个字符p[0] == p[k] == p[j],且next[j] = k = 0无疑(next[j + 1]才是1),满足条件,所以next[j]直接使用next[0]的值即-1。

以上两种情况都满足next[j] = kp[k] = p[j]。现在应该清楚增加的那句if判断是怎么工作的了吧。

试试用新的next数组实现测试下。

package Chap5;

public class KMPSearch {
    private static int[] getNext(String p) {
        int M = p.length();
        int[] next = new int[M];
        next[0] = -1;
        int j = 0;
        int k = -1;
        while (j < M - 1) {
            if (k == -1 || p.charAt(k) == p.charAt(j)) {
                next[++j] = ++k;
            } else {
                k = next[k];
            }
        }
        return next;
    }

    private static int[] betterGetNext(String p) {
        int M = p.length();
        int[] next = new int[M];
        next[0] = -1;
        int j = 0;
        int k = -1;
        while (j < M - 1) {
            if (k == -1 || p.charAt(k) == p.charAt(j)) {
                if (p.charAt(k + 1) == p.charAt(j + 1)) {
                    next[++j] = next[++k];
                } else {
                    next[++j] = ++k;
                }
            } else {
                k = next[k];
            }
        }
        return next;
    }

    public static int search(String p, String t) {
        // 根据模式字符串获得next数组
        int[] next = betterGetNext(p);
        int N = t.length();
        int M = p.length();
        int i = 0;
        int j = 0;
        while (i < N && j < M) {
            if (j == -1 || p.charAt(j) == t.charAt(i)) {
                i++;
                j++;
            } else {
                j = next[j];
            }
        }
        if (j == M) {
            return i - j;
        } else {
            return -1;
        }
    }

    public static void main(String[] args) {
        int index = search("abab", "abacghababzz");
        System.out.println(index);
    }
}

输出6,没毛病。


by @sunhaiyu

2017.8.4

数据结构与算法--KMP算法查找子字符串的更多相关文章

  1. 数据结构与算法--Boyer-Moore和Rabin-Karp子字符串查找

    数据结构与算法--Boyer-Moore和Rabin-Karp子字符串查找 Boyer-Moore字符串查找算法 注意,<算法4>上将这个版本的实现称为Broyer-Moore算法,我看了 ...

  2. 经典算法 KMP算法详解

    内容: 1.问题引入 2.暴力求解方法 3.优化方法 4.KMP算法 1.问题引入 原始问题: 对于一个字符串 str (长度为N)和另一个字符串 match (长度为M),如果 match 是 st ...

  3. 笔记-算法-KMP算法

    笔记-算法-KMP算法 1.      KMP算法 KMP算法是一种改进的字符串匹配算法,KMP算法的关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的.具体实现就是实现一 ...

  4. 值得花费一周研究的算法 -- KMP算法(indexOf)

    KMP算法是由三个科学家(kmp分别是他们名字的首字母)创造出来的一种字符串匹配算法. 所解决的问题: 求文本字符串text内寻找第一次出现字符串s的下标,若未出现返回-1. 例如 text : &q ...

  5. C 查找子字符串

    自己用 C 写的一个查找子字符串的函数 int findstr(char *str,char *substr) //C实现 find{ if(NULL == str || NULL== substr) ...

  6. 查找子字符串----KMP算法深入剖析

    假设主串:a b a b c a b c a c b a b      子串:a b c a c 1.一般匹配算法 逐个字符的比较,匹配过程如下: 第一趟匹配 a b a b c a b c a c ...

  7. 关于《数据结构》课本KMP算法的理解

    数据结构课上讲的KMP算法和我在ACM中学习的KMP算法是有区别的,这里我对课本上的KMP算法给出我的一些想法. 原理和之前的KMP是一样的https://www.cnblogs.com/wkfvaw ...

  8. 数据结构20:KMP算法(快速模式匹配算法)详解

    通过上一节的介绍,学习了串的普通模式匹配算法,大体思路是:模式串从主串的第一个字符开始匹配,每匹配失败,主串中记录匹配进度的指针 i 都要进行 i-j+1 的回退操作(这个过程称为“指针回溯”),同时 ...

  9. (原创)数据结构之利用KMP算法解决串的模式匹配问题

      给定一个主串S(长度<=10^6)和一个模式T(长度<=10^5),要求在主串S中找出与模式T相匹配的子串,返回相匹配的子串中的第一个字符在主串S中出现的位置. 输入格式: 输入有两行 ...

随机推荐

  1. Python 内置函数汇总

    循环设计与循环对象 range() enumerate() zip() iter() 函数对象 map() filter() reduce() 序列操作 all([True, 1, "hel ...

  2. 超好用的Redis管理及监控工具,使用后可大大提高你的工作效率!

    Redis做为现在web应用开发的黄金搭担组合,大量的被应用,广泛用于存储session信息,权限信息,交易作业等热数据.做为一名有10年以上JAVA开发经验的程序员,工作中项目也是广泛使用了Redi ...

  3. Vue 项目实战系列 (三)

    我们继续前两节的开发.本节教程实现的效果如下: 效果很简单,但是实现起来却要用到Vue的很多知识,下面我们将一步一步的实现这个效果. 首先这些城市的信息都是从后台的server里面获取的,所以我们需要 ...

  4. Tp框架获取客户端IP地址

    /** * 获取客户端IP地址 * @param integer $type 返回类型 0 返回IP地址 1 返回IPV4地址数字 * @return mixed */ function get_cl ...

  5. php中的四种排序算法

    . 冒泡排序 思路分析:在要排序的一组数中,对当前还未排好的序列,从前往后对相邻的两个数依次进行比较和调整,让较大的数往下沉,较小的往上冒.即,每当两相邻的数比较后发现它们的排序与排序要求相反时,就将 ...

  6. JAVA基础——最简单的多重循环程序

    Java 循环语句之多重循环 循环体中包含循环语句的结构称为多重循环.三种循环语句可以自身嵌套,也可以相互嵌套,最常见的就是二重循环.在二重循环中,外层循环每执行一次,内层循环要执行一圈. 如下所示: ...

  7. 设备像素比dpr介绍

    首先介绍一下概念 devicePixelRatio其实指的是window.devicePixelRatio window.devicePixelRatio是设备上物理像素和设备独立像素(device- ...

  8. Linux 下挂在ntfs 硬盘

    CentOS 7 下想要挂载NTFS的文件系统该怎么办呢? 我们需要一个NTFS-3G工具,并编译它之后在mount就可以了,就这么简单. 首先要进入官网下载NTFS-3G工具 http://www. ...

  9. 修改system 密码

    运行cmd命令行 录入 sqlplus /nolog  无用户名登录 conn /as sysdba  连接到数据本地数据 alter user system identified by passwo ...

  10. Struts2的知识点小总结

    strust2是 struts1和webwork的结合  (本质相当于servlet) 核心:拦截器 interceptor  action ognl和valuestack 使用struts的准备工作 ...