【KMP算法-中篇】next 数组的递推:往前填一位,靠的是往回退几步

0 阅读10分钟

上篇【KMP算法-上篇】匹配失败之后,KMP 凭什么敢一次跳过一大段讲了 next 数组怎么用:

匹配失败时,S1 的比对位置不动,S2 按 next 数组往回退。

退回去之后,S2 要重新比的那一段前缀,和刚才已经匹配上的 S2 后缀本来就相等,所以不用再比,直接继续往后比。

这篇讲两件事:

next 数组自己怎么算出来,以及整趟走下来为什么只花 O(n + m)。

头两个位置是定死的

这里假设 S2 至少有一个字符。

位置 0 的 next 值恒为 -1,跟 S2 里是哪些字符无关。

它前面一个字符都没有,前缀和后缀都无从谈起,按约定写成 -1。

位置 1 的值同样是定死的,是 0。

它前面只有一个字符,又不能取到整段,只能拿空的前缀去对空的后缀,长度是 0。

从位置 2 开始才需要一个个往下算。

位置 2 很好看出来:

它前面是两个字符,相同就是 1,不同就是 0。

麻烦的是再往后的位置。

不过它们走的是同一套办法:

先看前一个位置记下的数,再顺着它一点点往前找。

不用跳的时候,加一就行

换一个串来看,设 S2 的前 15 个字符是 "abatabasabataba"。

位置 15 的 next 值是 7。

这个 7 说的是:

前面那 15 个字符里,前 7 个和后 7 个完全一样,都是 "abataba"。

想取 8 个的话,前 8 个是 "abatabas",后 8 个是 "sabataba",第一个字符就对不上。

所以,找不出比 7 个字符更长的一对。

现在要算位置 16 的值。

先说一句,位置 16 自己填什么字符,跟这一位的值没关系。

决定它的是位置 15 上那个字符,和前面已经算好的那些数。

那么从位置 15 记下的数开始,它是 7。

7 既是那对相等前缀和后缀的长度,也正好是前缀后面那一位的下标,就是位置 7。

拿位置 7 的字符去比位置 15 的字符:

位置 7 是 's',位置 15 也是 's',对上了。

位置 16 的值就是 7 加 1,得 8。

对上意味着,原来那对长度 7 的相等前缀和后缀,各自往后接上一个相同的字符,就长成了一对长度 8 的相等前缀和后缀。

那 8 已经是最大的了。

反过来假设一次:

假设位置 16 的值能到 9 或者更大。

那从位置 0 到位置 15 这 16 个字符里,就有一对长度至少为 9 的相等前缀和后缀。

把这一对各自去掉最后一个字符,剩下的还是一对相等的前缀和后缀,长度至少为 8。

而剩下的这一对,落在从位置 0 到位置 14 这一段里。

位置 15 的 next 值,就是这 15 个字符里前缀和后缀相等的最大长度。

也就是说,位置 15 的 next 值至少是 8。

可它明明是 7,矛盾。

所以只要位置 7 的字符和位置 15 的字符相等,位置 16 的值就正好是 8,不可能更大。

要跳的时候,跳到哪儿

前 15 个字符不动,只换位置 15 上那一个字符,位置 7 就未必对得上了。

换不同的字符,走法也不一样。

1.换成 't'

位置 7 的 's' 和它不相等,直接加一就行不通了。

那就再退一步。

位置 7 自己也有 next 值,是 3。

拿位置 3 的字符再比一次:

位置 3 是 't',跟位置 15 上的 't' 对上了。

位置 16 的值就是 3 加 1,得 4。

2.换成 'b'

这个时候,位置 7 就对不上了,退到位置 3;

位置 3 的 't' 也对不上,再退到位置 1(位置 3 的 next 值正好是 1);

位置 1 是 'b',跟位置 15 上的 'b' 对上了。

位置 16 的值就是 1 加 1,得 2。

3.换成 'a'

位置 7、3、1 挨个比过去都不相等,一直退到位置 0;

位置 0 是 'a',跟位置 15 上的 'a' 对上了。

位置 16 的值就是 0 加 1,得 1。

4.换成 'f'

位置 7、3、1、0 全都不相等,再往下退就是 -1,退到头了。

位置 16 的值只能是 0。

五条路并排放在一起:

位置 15 换成比过哪些位置位置 16 的值
's'位置 7,对上8
't'位置 7 不等,位置 3 对上4
'b'位置 7、3 不等,位置 1 对上2
'a'位置 7、3、1 不等,位置 0 对上1
'f'位置 7、3、1、0 全不等,退到头0

路径看着像乱跳,其实每一步踩的都是同一条规则:

当前试的是哪个位置,就换成这个位置的 next 值。

7 换成 3,是因为位置 7 的 next 值是 3;

3 又换成 1,是因为位置 3 的 next 值是 1。

为什么盯着前缀后面那一位往前跳

位置 15 的值是 7,这句话展开就是下面这两段字符完全相同:

它们后面各自接着一个字符,一个在位置 7,一个在位置 15。

这两个字符只要也相等,两边就一起往前增加一位,拼出一对长度 8 的相等前缀和后缀。

这就是先拿位置 7 去试的原因。

现在位置 7 和位置 15 不相等,长度 8 已经不可能了。

接下来要想的是,靠一对比长度 8 更短的前缀和后缀,还能给这一位凑出个数来。

要凑,就得在已经确认相等的那两段里面,各挑一段尽可能长的出来,还得让挑出来的这两段彼此相等。

既然这两段字符完全一样,在第一段里挑前缀、在第二段里挑后缀让它们相等,和在第一段里挑前缀、在第一段里挑后缀让它们相等,就是同一回事。

在同一段里挑前缀和后缀,正是位置 7 自己的 next 值要回答的:

前 7 个字符里,最长的一对相等前缀和后缀有多长。

位置 7 的 next 值是 3,所以下一个要试的长度就是 3,对应去比的是位置 3。

再不行就顺着往下:

位置 3 的 next 值是 1,换 1;

位置 1 的 next 值是 0,换 0;

位置 0 的 next 值是 -1,到头。

不能跳到别的位置上去。

要的是尽可能长,而比当前长度短的长度里,还能凑出来的最长一对,正好就是当前位置记着的 next 值。

往下每次该试多长,next 值已经给定了,照它一步步走就行。

算 next 数组的代码就三个分支

public static int[] nextArray(char[] s, int m) {
    if (m == 1) {                    // 只有一位,直接给定
        return new int[] { -1 };
    }
    int[] next = new int[m];
    next[0] = -1;                    // 这两位是规定好的
    next[1] = 0;
    int i = 2;                       // 正在算 next 值的位置
    int cn = 0;                      // 当前正在试的那个长度,拿 s[cn] 去和 i - 1 位置比
    while (i < m) {
        if (s[i - 1] == s[cn]) {
            next[i++] = ++cn;        // 对上了,这一位的值就是当前长度加一
        } else if (cn > 0) {
            cn = next[cn];           // 没对上,把当前长度换成它自己的 next 值
        } else {
            next[i++] = 0;           // 退到 0 还是没对上,这一位就是 0
        }
    }
    return next;
}
代码大白话
next[0] = -1 和 next[1] = 0头两个位置规定好了,不用算
s[i - 1] == s[cn]拿 s[cn] 上的字符,去比前一位
next[i++] = ++cn对上了,cn 先加一,这一位的值就是加一后的 cn
cn = next[cn]没对上,把当前长度换成它自己的 next 值,换个更短的再试
next[i++] = 0退到 0 还是没对上,这一位就是 0

cn 记的是当前正在试的那个长度,也就是下一步拿哪个位置的字符去比。

它一开始是 0,是因为算位置 2 的时候,前一位记下的数是 0。

每成功一次,它就加一,加出来的数就是这一位的 next 值,也是下一步要比的那一位。

每失败一次,它就换成自己的 next 值,也就是换一个更短的长度接着试。

算 next 数组为什么只花 O(m)

看代码里在动的量,其实是两个:i 和 cn。

i 从 2 一路变大到 m,循环就结束了。

cn 会变大也会变小,单看它看不出什么来。

这时候可以自己凑一个量出来,把两个方向的运动绑到一起。

让 i 和 cn 做个差,得到 i - cn。

先看三个分支各自让这两个量怎么变。

字符相等那个分支,i 和 cn 各加一:

i 变大了,i - cn 不变。

cn 换成自己的 next 值那个分支,cn 变小:

i 不变,i - cn 变大了。

cn 已经退到 0 那个分支,只有 i 加一:

i 变大了,i - cn 也变大了。

三个分支里,每一个都至少让这两个量里的一个变大。

再看这两个量的上限。

i 最大是 m。

i - cn 里,i 最大为 m、cn 最小为 0,所以它也最大到 m。

两个量只增不减,各自最多增加 m 次,合起来最多 2m 次。

而循环每转一次,至少会让其中一个变大。

两边的账一对,整个循环超不过 2m 次,算 next 数组这一趟就是 O(m)。

主流程为什么只花 O(n)

主流程里是同一个手法,换成 x 和 y 这两个量。

public static int kmp(char[] s1, char[] s2) {
    int n = s1.length, m = s2.length;
    int x = 0;                        // S1 上正在比对的位置
    int y = 0;                        // S2 上正在比对的位置
    int[] next = nextArray(s2, m);
    while (x < n && y < m) {
        if (s1[x] == s2[y]) {
            x++;                      // 相等,两个位置一起往后
            y++;
        } else if (next[y] == -1) {   // S2 退无可退,S1 换下一个起点
            x++;
        } else {
            y = next[y];              // S1 不动,S2 退到 next 值上
        }
    }
    return y == m ? x - m : -1;       // S2 走完说明配上了,起点是 x - m
}

x 是 S1 上的比对位置,最大到 n。

拿它和 y 做个差,得到 x - y:

x 最大 n、y 最小 0,所以这个量也最大到 n。

三个分支:

两个字符相等,两个比对位置一起往后走,x 变大,x - y 不变。

S2 退不动、S1 换起点,x 加一,x 变大,x - y 也变大。

S2 还能退,y 换成自己的 next 值,y 变小,x 不变,x - y 变大。

同样是每一个分支都至少让一个量变大,两个量的上限都是 n。

所以主流程的循环也超不过 2n 次,是 O(n)。

最后把两趟放在一起看

两趟加起来:算 next 数组 O(m),匹配 O(n),整趟就是 O(n + m)。

单看某一个位置,它可能要退好几次。

可 m 个位置放在一起算,总共只有 O(m),摊到每个位置上,平均就是常数级。

上篇里最直接的做法,O(n × m),到这儿就压下来了。

原理讲完了。

下一篇看题,两道能直接用上 KMP 的题。