字符串匹配
某日,程序员小明接到了宇宙厂的面试邀请,他觉得特别兴奋,自己的人生好像迎来了光明。于是按照约定到了面试的日期之后他准时到达宇宙厂。不一会儿一个面试官就把他带走面试了,面试官倒不如传说的一般,上来让小明手撕红黑树,但是也给了一个算法题。
大致意思:给定一个赎金信 (ransom) 字符串和一个杂志(magazine)字符串,判断第一个字符串ransom能不能由第二个字符串magazines里面的字符构成。如果可以构成,返回 true ;否则返回 false 。
小明一看,很简单嘛,于是他一把梭子噼里啪啦的输出,然后把答案给面试官看了。于是跟大部分面试宇宙厂的人结局一样:小明,卒。然后小明就失落的离开的宇宙厂,但是他还是在心里默默给自己打气,他相信宇宙厂以后一定会有他的一个工位。
朴素算法
下面贴出小明的答案,然后分析一下他卒的原因。
首先简单分析一下小明的答案。
跳过前面条件判断的部分,到达for循环,大致意思就是从 source 的第0个位置开始和 target 的第0个位置进行匹配(之后把 source 简称为 s , target 简称为 t),如果两个位置的字符相等则取 s 和 t 各自加 1 位置的直到 t 的循环结束返回出,在此过程中,如果 s 和 t 相关位置上的字符不想等则跳出循环,然后s的起始位置加 1,t的起始位置变为0,进行循环往复的匹配逻辑,直到最后返回flase。
代码逻辑比较简单,也很直白。按照国际惯例来分析一下方法的时间复杂度:
-
假如s为 abcdeee,t为 abcd, 这时候只需要一次t长度的循环次数就可以完成匹配,显然t的长度是一个4,这时候时间复杂度为O(1)。这里假设t的长度为m,时间复杂度应该为O(m),但是一般情况下长度m都很小,属于一个很小的常数,所以这种情况一般都会称时间复杂度为 O(1)。
-
假如s为 abceefjbeijing,t为 beijing,按照上方的代码逻辑,则需要对 a、b、c、d都进行单独一次匹配后之后,然后进行 beijing 的匹配,假设 s 的长度为 n, 这时候的时间复杂度为 O(n)。
-
假如s为 0000000001,t为 00001, 在匹配时候每次都是循环到 t 最后一位的时候才发现他们是不匹配的,假设s的长度为n,t的长度为m,则需要t在 (n - m) 之前的位置都进行m次循环,并且得出不匹配的结果。直到 (n - m + 1)时候才得到全部匹配,这时候的时间复杂度为 O((n - m) * m)。
显然在第三情况中,这个算法的效率太低了,很明显的问题就是就是对于s的匹配一直在不断的回溯,那么能不能实现不回溯,完成匹配呢?当然是有的。
KMP算法
先不上代码,免得看的一脸疑问,请看下面分析。
假设1
假如现在s为 abcdefghi,t为 abcdm,按照上面的朴素算法,两者都从a的位置开始进行循环判断,一直匹配到t中的m发现两者不匹配。然后s从b开始,t从a开始继续匹配逻辑,如此循环往复。
但是仔细观察你会发现,在t中a与后面位置的字符串都不相同,那就意味着在主串a的位置匹配后,我们完全有理由知道a与b、c、d这算三个字符不匹配,这时候完全可以把下一次匹配t的起始位置换成e,从而节省b、c、d这三个位置的匹配次数。
这是整个KMP算法的核心所在,跳过不必要的匹配,让s主串避免一些不必要的匹配。那么是不是所有的情况都想我们刚才演示的例子一样,t的起始位置直接从上一次匹配的最后一个位置开始呢?来看下一个假设。
假设2
假设s为 abcabcabd,t为 abcabd,把s的数据放入数组s[]{a, b, c, a, b, c, a, b, c},把t的数据放入t[]{a, b, c, a, b, d},按照上面的假设一的分析,匹配开始,两种都从头a也就是s[0]、t[0]开始进行匹配,然后在t串循环到d时候发现不匹配也就是s[5] != t[5],那么现在我们能不能暴力一点直接让s串开始从s[5]、t串从t[0]开始,显然不可以,因为在s[0]到s[5]之间,存在t[0] = s[3], t[1] = s[4],如果按照假设一的结论直接让s从s[5]开始、t从t[0]开始,那么结果显然是错的。因为如果t从t[5]开始、s从s[3]开始我们可以完成对t的完全匹配从而得出,所以下一次匹配的正确结论是s从s[5]开始,t从t[3]开始。
有人会有疑问了刚才没有从s[5]开始进行匹配而是从s[3]开始,是不是因为s[3]和t[0]相当啊,那是不是我们不用管别的,直接从s[0]到s[5]直间来找跟s[0]一样的位置开始就可以了?
假设3
假设s为 abcaefghi,t为 abcaem,把s的数据放入数组s[]{a, b, c, a, b, f, b, c},把t的数据放入t[]{a, b, c, a, e, m}。匹配开始,两种都从头也就是s[0]、t[0]开始进行匹配,然后在t串循环到e发现s[5] != t[5],这时候按照假设2最后的疑惑,发现s[3] = t[0],那么下次循环s从s[5]开始,t从t[2]开始,这样显然是不正确的,因为这时候只有s[3] = t[3],但是s[4] != t[1]。
对于假设的思考
通过这三个假设我们不难发现,假设上一次匹配终止的位置是s中的i位置,对于s[],为了不回溯,s必须至少从s[i]开始循环,但是t可以不从t[0]开始。但是如果不从t[0]开始,而从t[n]开始,必须保证一点,那就是s[i-1] = t[n-1], s[i-2]= t[i-2],以此论推,直到t[0]为止。
继续拿假设2讨论,我们发现第二次的匹配位置s为s[5],t为t[3],这时候的t[0] = s[3],t[1] = s[4],同时在上一次循环匹配结束之前可以证得s[3] = t[3], s[4] = t[4], 这时候可以得出s[0] = s[3], s[1] = s[4]。从这一点可以得出如下结论:
假设上一次循环的位置为t[n],如果存在0到k,且k 通过上图假设得出的结论,我们发现如果在匹配进行之前,对t[]做一次处理,得到t[]每个位置在循环匹配失败之后下一次的起始位置值,那么朴素算法就没有了不断重复的回溯,时间复杂度就会得到极大的改善,那么怎么去获得t[]每个位置对应的k值呢? 我们使用一个next[]来存放对应的k值,那么毫无疑问next[]的长度为t[]的长度,next[0] = 0,这注意我们数组里面值的意思,它表示的是t串在相应位置匹配失败后下一次匹配的开始位置,这里可能和别人的文章有点出入。 现在开始next[]求值的推导过程,假设next[n] = k,那么如何去取的next[n+1]的值呢?从t[k]和t[n]入手: 假设t[k] = t[n],那么很容易可以推导出next[n+1] = k+1; 假设t[k] != t[n]呢?按照现在的情况如果k != 0,可以得出以下条件: 存在两段闭空间[0, k-1],[n-k, n-1] 且 k < n,使得t[0] = t[n-k]、t[1] = t[n-k+1]、t[2] = t[n-k+2] .... t[k-1] = t[n-1]。 假如next[k] = m ,存在两段闭空间[0, m-1], [k-m, k-1] 且 m < k,使得t[0] = t[k-m],t[1] = t[k - m]、t[2] = t[k-m+2] ... t[m-1] = t[k-1]。 由上述1. 2. 可得知存在闭空间[0, m-1], [n-m, n-1], 使得t[0]= t[n-m]、t[1] = t[n-m+1]...t[m-1] = t[n-1], 这时候继续判断t[m] 是否等于t[n],如果相等则next[n+1]= m+1;如果不相等则重复上面的假设步骤,直到next[0]位置。 在上述关系中, m < k < n。 经过测试可得,net[1]必然为0。k为下一次轮询的起始位置。 这是改良之后的完整代码,时间复杂度从原来的O((n - m) * m) 提升到了O(n + m)。 凡事总是没有最好,只有更好,那么刚才的分析过程有没有再优化的空间呢? 回想一下刚才的分析,当t从t[i-n]开始循环,当t虚幻到t[i]时候,s循环到s[n],这个时候t[n] != s[i],此时如果s[k] = s[n],那么s[k] != t[i]。 那么进入下次的循环过程中t[0]、t[1]、t[2]...t[k-1]会依次落在上次t[n-k],t[n-k+1]...t[n-1]的位置。同时t[0] = s[i-k]、t[1] = s[i-k+1] ... t[k-1] = s[i-1], t[k]会落在上一次t[n]的位置和s[i]进行比对,由上面分析可知,s[k] != t[i],所以这次比对注定是失败的,这是不是就是我们可以优化的地方,且看next[]求值优化后的代码。 在发现s[k] = s[n]的时候,算作查找next[n]失败,继续进入下一次迭代查找next[n] = next[next[k]]。 深吸一口气放松一下心情,脑中可以仔细的想想刚才的分析过程,当然动手来练习一下可能效果更佳。在整个解析过程中,你会发现美丽的数学无处不在,同时感叹于前人的智慧。道阻且长,且行且珍惜。 我本凡尘微草,却也心向天空。求的next[]
next[]代码如下:
字符串匹配完整代码如下
mkp算法改进
总结
关注我的公众号,一起来学习更多的知识吧~