单模式匹配是处理字符串的经典问题,指在给定字符串中寻找是否含有某一给定的字串。比较形象的是CPP中的strStr()
函数,Java的String类下的indexOf()
函数都实现了这个功能,本文讨论几种实现单模式匹配的方法,包括暴力匹配方法、KMP方法、以及Rabin-Karp方法(虽然Rabin-Karp方法在单模式匹配中性能通常,单其多模式匹配效率较高,且采起非直接比较的方法也值得借鉴)。java
算法 | 预处理时间 | 匹配时间 |
---|---|---|
暴力匹配法 | O(mn) | |
KMP | O(m) | O(n) |
Rabin-Karp | O(m) | O(mn) |
模式匹配类的问题作法都是相似使用一个匹配的滑动窗口,失配时改变移动匹配窗口,具体的暴力的作法是,两个指针分别指向长串的开始、短串的开始,依次比较字符是否相等,当不相等时,指向短串的指针移动,当短串指针已经指向末尾时,完成匹配返回结果。算法
以leetcode28. 实现 strStr()为例给出实现代码(下同)数组
class Solution { public int strStr(String haystack, String needle) { int m = haystack.length(), n = needle.length(); if (needle.length() == 0) return 0; for (int i = 0; i <= m - n; i++) { for (int j = 0; j < n; j++) { if (haystack.charAt(i + j) != needle.charAt(j)) break; if (j == n - 1) return i; } } return -1; } }
值得注意的是,Java中的indexO()
方法即采用了暴力匹配方法,尽管其算法复杂度比起下面要谈到的KMP方法要高上许多。ide
一个可能的解释是,平常使用此方法过程当中串的长度都比较短,而KMP方法预处理要生成next数组浪费时间。而通常规模较大的字符串能够由开发人员自行决定使用哪一种匹配方法。函数
这个算法由高德纳和沃恩·普拉特在1974年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终三人于1977年联合发表。性能
大致想法是,在暴力匹配的前提下,每次失配时,再也不从待匹配串开头开始从新匹配,而是充分利用已经匹配到的部分,具体的就是使用一个部分匹配表(即在程序中常常讲的next数组),利用这一特性以免从新检查先前匹配的字符。设计
好比对于待匹配串abcabce
当我匹配到末尾最后一个e字母时,发现失配,通常的作法是,对于长串指针日后移动一位,而后从待匹配串开始从新匹配,但事实上,咱们发现对于待匹配串失配位置之前的字符串abcabc
来说,存在着一个长度为3的相同的字串abc
,咱们能够把第一个叫作前缀,第二个叫作后缀,因此对于当在后缀下一个字符失配时,咱们只须要回溯到前缀的下一个字符继续匹配便可,对于此串即待匹配串移动到第四个字符(数组下标为3)开始匹配。指针
因此对于KMP算法,核心就是构建待匹配串的部分匹配表。其做用是当模式串第i个位置失配,我没必要从模式串开始再从新匹配,而是移动到前i个字符的某个位置,具体这个位置是前i个字符的最长公共先后缀的长度。code
依旧以abcabce
为例,假如匹配到第i = 5
也就是第六个字母(第二个c)时,失配,那么我只须要退回到i = 2
开始匹配便可,由于匹配到第六个字母时,咱们已经肯定abcab
匹配成功,很明显发现abcab
中出现了两次ab
且分别是先后缀,那么此时只须要从i = 2
接着匹配便可。因此计算部分匹配表本质上就是对模式串自己作了屡次匹配,或者能够理解为模式串构建了一个失配的自动机。blog
因此对于abcabce
很容易计算出部分失配表,特别的i = 0
时令next[0] = -1
。
i |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
模式串 | a | b | c | a | b | c | e |
next[i] | -1 | 0 | 0 | 0 | 1 | 2 | 3 |
给出算法Java实现
class Solution { public int strStr(String haystack, String needle) { int i = 0, j = 0; int sLen = haystack.length(); int pLen = needle.length(); if (pLen == 0) { return 0; } int[] next = getNext(needle); while (i < sLen && j < pLen) { if (j == -1 || haystack.charAt(i) == needle.charAt(j)) { i++; j++; } else { j = next[j]; } } return j == pLen ? (i - j) : -1; } public int[] getNext(String p) { int pLen = p.length(); int[] next = new int[pLen]; int k = -1; int j = 0; next[0] = -1; while (j < pLen -1) { if (k == -1 || p.charAt(j) == p.charAt(k)) { k++; j++; next[j] = k; } else { k = next[k]; } } return next; } }
Rabin–Karp算法由 Richard M. Karp 和 Michael O. Rabin 在 1987 年发表,用来解决模式匹配问题,在多模式匹配中其效率很高,常见的应用就是论文查重。
Rabin–Karp算法采用了计算字符串hash值是否相等的方法来比较字符串是否相等,固然hash算法确定会出现冲突的可能,因此对于计算出hash相等后还需用朴素方法对比是否字符串真的相等。
可是即便计算哈希,也须要每次都计算一个长度为模式串的哈希值,真正巧妙的地方在于,RK算法采起了滚动哈希的方法,咱们假设须要匹配的字符只有26个小写字母来展开讨论。
咱们采起常见的多项式哈希算法来计算
假设主串为abcdefg
,模式串为bcde
,首先计算模式串的hash值,基于上述假设的简体下,为了简化,咱们将字母进一步作一个映射转换成整型(统一减去'a'),那么只须要计算[0,1,2,3]
的哈希值便可,获得
维护一个大小为模式串长度的滑动窗口,开始从主串开头计算窗口内的hash值,好比最开始窗口内字符串为abcd
,此时有
而后此时发现h0与模式串哈希值并不相等,则将窗口日后移动一个单位,此时窗口内的字符串是bcde
,咱们计算它的hash值
但此时显而易见的是,\(h_1\)能够由\(h_0\)计算得来,具体的
因此此时咱们可以由前一个窗口的哈希值以O(1)的时间复杂度计算出下一个窗口的哈希值,以方便比较。
固然显然字符串过长时会存储hash值的变量会溢出,因此须要每次累加时进行一次取模运算,具体的能够选取一个大素数,素数的选择能够参考这里。
下面给出java实现
class Solution { public static int strStr(String haystack, String needle) { int sLen = haystack.length(), pLen = needle.length(); if (pLen == 0) return 0; if (sLen == 0) return -1; int MOD = 997; int power = 1; for (int i = 0; i < pLen; i++) { power = (power * 31) % MOD; } int hash = 0; for (int i = 0; i < pLen; i++) { hash = (hash * 31 + (needle.charAt(i) -'a')) % MOD; } int h = 0; for (int i = 0; i < sLen; i++) { h = (h * 31 + (haystack.charAt(i) - 'a')) % MOD; if (i < pLen - 1) { continue; } if (i >= pLen) { h = (h - (haystack.charAt(i - pLen)-'a') * power) % MOD; if (h < 0) { h += MOD; } } if (hash == h) { int start = i - pLen + 1; boolean equal = true; for(int j = start, k = 0; j <= i; j++,k++) { if (haystack.charAt(j) != needle.charAt(k)) equal = false; } if (equal) return start; } } return -1; } }