上篇【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 的题。