字符串匹配方法对于在编辑文本程序时,能极大的提升响应效率,如网址查询搜索引擎,DNA序列匹配等。
基本定义
字符串匹配问题的形式定义:
- 文本(Text)是一个长度为 n 的数组 T[1..n];
- 模式(Pattern)是一个长度为 m 且 m≤n 的数组 P[1..m];
- T 和 P 中的元素都属于有限的字母表 Σ 表;
- 如果 0≤s≤n-m,并且 T[s+1..s+m] = P[1..m],即对 1≤j≤m,有 T[s+j] = P[j],则说模式 P 在文本 T 中出现且位移为 s,且称 s 是一个有效位移(Valid Shift)。
比如上图中,目标是找出所有在文本 T = abcabaabcabac 中模式 P = abaa 的所有出现。该模式在此文本中仅出现一次,即在位移 s = 3 处,位移 s = 3 是有效位移。
字符串匹配算法通常分为两个步骤:预处理(Preprocessing)和匹配(Matching)。所以算法的总运行时间为预处理和匹配的时间的总和。下图描述了常见字符串匹配算法的预处理和匹配时间。
其中涉及到的解决字符串匹配的算法包括:朴素算法(Naive Algorithm)、Rabin-Karp 算法、有限自动机算法(Finite Automation)、 Knuth-Morris-Pratt 算法(即KMP Algorithm )、Boyer-Moore 算法、Simon 算法、Colussi 算法、Galil-Giancarlo 算法、Apostolico-Crochemore 算法、Horspool 算法和 Sunday 算法等。
朴素的字符串匹配算法
朴素的字符串匹配算法又称为暴力匹配算法(Brute Force Algorithm),它的主要特点是:
- 没有预处理阶段;
- 滑动窗口总是后移 1 位;
- 对模式中的字符的比较顺序不限定,可以从前到后,也可以从后到前;
- 匹配阶段需要 O((n - m + 1)m) 的时间复杂度;
- 需要 2n 次的字符比较;
很显然,朴素的字符串匹配算法 NAIVE-STRING-MATCHER 是最原始的算法,它通过使用循环来检查是否在范围 n-m+1 中存在满足条件 P[1..m] = T [s + 1..s + m] 的有效位移 s。
1 NAIVE-STRING-MATCHER(T, P)
2 n = length[T]
3 m = length[P]
4 for s = 0 to n - m
5 if P[1 .. m] = T[s + 1 .. s + m]
6 then print "Pattern occurs with shift" s
如上图中,对于模式 P = aab 和文本 T = acaabc,将模式 P 沿着 T 从左到右滑动,逐个比较字符以判断模式 P 在文本 T 中是否存在。
可以看出,NAIVE-STRING-MATCHER 没有对模式 P 进行预处理,所以预处理的时间为 0。而匹配的时间在最坏情况下为 Θ((n-m+1)m),如果 m = [n/2],则为 Θ(n^2)。
Rabin-Karp算法
对于一个时间复杂度为O((N-M+1)*M)的字符串匹配算法,即Rabin-Karp算法。Rabin-Karp算法的预处理时间是O(m), 匹配时间O((N-M+1)*M),既然与朴素算法的匹配时间一样,而且还多了一些预处理时间,那为什么我们 还要学习这个算法呢?
虽然Rain-Karp在最坏的情况下与朴素的世间复杂度一样,但是实际应用中往往比朴素算法快很多。而且该算法的 期望匹配时间是O(N+M)。在朴素算法中,我们需要挨个比较所有字符,才知道目标字符串中是否包含子串。那么, 是否有别的方法可以用来判断目标字符串是否包含子串呢?
答案是肯定的,确实存在一种更快的方法。为了避免挨个字符对目标字符串和子串进行比较, 我们可以尝试一次性判断两者是否相等。因此,我们需要一个好的哈希函数(hash function)。 通过哈希函数,我们可以算出子串的哈希值,然后将它和目标字符串中的子串的哈希值进行比较。 这个新方法在速度上比暴力法有显著提升。
Rabin-Karp算法的思想:
- 假设子串的长度为M,目标字符串的长度为N
- 计算子串的hash值
- 计算目标字符串中每个长度为M的子串的hash值(共需要计算N-M+1次)
- 比较hash值
- 如果hash值不同,字符串必然不匹配,如果hash值相同,还需要使用朴素算法再次判断
为了快速的计算出目标字符串中每一个子串的hash值,Rabin-Karp算法并不是对目标字符串的 每一个长度为M的子串都重新计算hash值,而是在前几个字串的基础之上, 计算下一个子串的 hash值,这就加快了hash之的计算速度,将朴素算法中的内循环的时间复杂度从O(M)将到了O(1)。
public void search(char[] pat, char[] txt, int q) {
int M = pat.length;
int N = txt.length;
int i, j;
int d =256; //模
int p = 0; // hash value for pat
int t = 0; // hash value for txt
int h = 1;
for (i = 0; i < M-1; i++)
h = (h*d)%q;
for (i = 0; i < M; i++) {
p = (d*p + pat[i])%q;
t = (d*t + txt[i])%q;
}
for (i = 0; i <= N - M; i++) {
if ( p == t ) {
for (j = 0; j < M; j++) {
if (txt[i+j] != pat[j])
break;
}
if (j == M) {
System.out.println("Pattern found at index: "+ i);
}
}
if ( i < N-M ) {
t = (d*(t - txt[i]*h) + txt[i+M])%q;
if(t < 0)
t = (t + q);
}
}
}
Knuth-Morris-Pratt 算法(KMP)
Knuth-Morris-Pratt 字符串查找算法,简称为 KMP算法,常用于在一个文本串 S 内查找一个模式串 P 的出现位置。这个算法由 Donald Knuth、Vaughan Pratt、James H. Morris 三人于 1977 年联合发表,故取这 3 人的姓氏命名此算法。
KMP算法背后的基本思想是:每当我们检测到不匹配(在一些匹配之后),我们就已经知道了文本中的一些字符(因为它们在不匹配之前匹配了模式字符)。我们利用这些信息来避免匹配我们知道无论如何匹配的字符。
我们来观察一下朴素的字符串匹配算法的操作过程。如下图(a)中所描述,在模式 P = ababaca 和文本 T 的匹配过程中,模板的一个特定位移 s,q = 5 个字符已经匹配成功,但模式 P 的第 6 个字符不能与相应的文本字符匹配。
此时,q 个字符已经匹配成功的信息确定了相应的文本字符,而知道这 q 个文本字符,就使我们能够立即确定某些位移是非法的。例如上图(a)中,我们可以判断位移 s+1 是非法的,因为模式 P 的第一个字符 a 将与模式的第二个字符 b 匹配的文本字符进行匹配,显然是不匹配的。而图(b)中则显示了位移 s’ = s+2 处,使模式 P 的前三个字符和相应的三个文本字符对齐后必定会匹配。KMP 算法的基本思路就是设法利用这些已知信息,不要把 "搜索位置" 移回已经比较过的位置,而是继续把它向后面移,这样就提高了匹配效率。
前缀和后缀
"前缀"和"后缀"。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。如下图所示,为字符串bread的前后缀:
部分匹配表
前缀函数:对于一个模式P,我们引入模式的前缀函数 π(Pi),π 包含有模式与其自身的位移进行匹配的信息。这些信息可用于避免在朴素的字符串匹配算法中,对无用位移进行测试。
π[q] = max {k : k < q 且 Pk ⊐ Pq} ,其中,k为已匹配字符数,Pk ⊐ Pq 表示P[1..k]为P[1..q]的后缀。
π[q],即"部分匹配值" 代表当前字符之前的字符串中,最长的共同前缀后缀的长度,也就是"前缀"和"后缀"的最长的共有元素的长度。下图给出了关于模式 P = ababababca 的完整前缀函数 π,可称为部分匹配表:
计算过程:
- π[1] = 0,a 仅一个字符,前缀和后缀为空集,共有元素最大长度为 0;
- π[2] = 0,ab 的前缀 a,后缀 b,不匹配,共有元素最大长度为 0;
- π[3] = 1,aba,前缀 a ab,后缀 ba a,共有元素最大长度为 1;
- π[4] = 2,abab,前缀 a ab aba,后缀 bab ab b,共有元素最大长度为 2;
- π[5] = 3,ababa,前缀 a ab aba abab,后缀 baba aba ba a,共有元素最大长度为 3;
- π[6] = 4,ababab,前缀 a ab aba abab ababa,后缀 babab abab bab ab b,共有元素最大长度为 4;
- π[7] = 5,abababa,前缀 a ab aba abab ababa ababab,后缀 bababa ababa baba aba ba a,共有最大长度为 5;
- π[8] = 6,abababab,前缀 .. ababab ..,后缀 .. ababab ..,共有元素最大长度为 6;
- π[9] = 0,ababababc,前缀和后缀不匹配,共有元素最大长度为 0;
- π[10] = 1,ababababca,前缀 .. a ..,后缀 .. a ..,共有元素最大长度为 1;
所以P模式的部分匹配表,π 数组为:
其代码实现为KMP的辅助前缀函数prefix:
int[] prefix(char[] p) {
int[] pi = new int[p.length];//π数组pi,1至m位,0位不取
pi[1] = 0;//一个字符的前后缀都为空,公共元素长度为0
int k = 0;//k为当前字串的前后缀组合中共有元素最长的长度
//计算存储从2开始的子串的最大的公共长度π[i]值
for (int i = 2; i <= pi.length; i++) {
//如果已经存在公共元素长度k>0,且最大的公共长度的下一位和当前字符不同
while (k > 0 && p[k+1] != p[i]) {
/*
*若字符串为ababaca,当 i=5 时,子串为ababa,k=3,子串前后缀最长公共元素为aba
*当 i=6 时,子串为ababac,因为abab不等于abac即 p[k+1]!=p[i];
*所以最大公共元素长度k要重新计算,新的位置为前缀中的当前最长公共元素aba(1,2,3处的)和
*其后缀中的当前最长公共元素aba(3,4,5处的)的最长公共元素a(1处和5处),即k=pi[k];
*/
k = pi[k];
}
//最大的公共长度的下一位和当前字符相同
if (p[k+1] == p[i])
k++; //则最大长度加一
pi[i] = k;//记录i处子串的π值
}
return pi;
}
测试函数为:
public static void main(String[] args) {
String T = "#BBC ABCDAB ABCDABCDABDE";
String P = "#ABCDABD";
char[] t = T.toCharArray();
char[] p = P.toCharArray();
int n = t.length-1;
int m = p.length-1;
int[] pi = prefix(p);
int q = 0;
for (int i = 1;i <= n;i++) {
while (q > 0 && p[q+1] != t[i]) q = pi[q];
if (p[q+1] == t[i]) q++;
if (q == m) {
System.out.println("找到一个,T的位置:"+(i-m+1)+"到"+i);
q = pi[q];//寻找下一个匹配的位置
}
}
}
static int[] prefix(char[] p) {
int[] pi = new int[p.length];
pi[1] = 0;//一个字符的前后缀都为空,公共元素长度为0
int k = 0;//k为当前字串的前后缀组合中共有元素最长的长度
//从2开始的子串的最大的公共长度π[i]值
for (int i = 2; i < pi.length; i++) {
//如果已经存在公共元素长度k>0,且最大的公共长度的下一位和当前字符不同
while (k > 0 && p[k+1] != p[i])
k = pi[k];//返回上一次的最大长度处
//最大的公共长度的下一位和当前字符相同
if (p[k+1] == p[i])
k++; //则最大长度加一
pi[i] = k;//记录i处子串的π值
}
return pi;
}
推荐阮一峰老师的文章,讲的通俗易懂
Boyer-Moore算法
参考文章:
《算法导论》