1 蛮力算法
1.1 算法描述
蛮力串匹配是最直接最直觉的方法。可假想地将文本串和模式串分别写在两条印有等间隔方格的纸带上,文本串对应的纸带固定,模式串纸带的首字符与文本串纸带的首字符对齐,两个都沿水平方向放置。于是,只需将P(长度m)与T(长度n)中长度为m的n - m + 1个子串逐一对比,即可确定可能的匹配位置。
不妨按自左向右的次序考查各子串。在初始状态下,T的前m个字符将与p的m个字符两两对齐。接下来,自左向右检查相互对齐的这m对字符:若当前字符对相互匹配,则转向下一对字符;反之一旦失配,则说明在此位置文本串与模式串不可能完全匹配,于是可将P对应的纸带右移一个字符,然后从其首字符开始与T中对应的新子串重新对比。若经过检查,当前的m对字符均匹配,则意味着整体匹配成功,从而返回匹配子串的位置。
蛮力算法的正确性显而易见:既然只有在某一轮的m次比对全部成功之后才成功返回,故不至于误报;反过来,所有对齐位置都会逐一尝试,故亦不至漏报。
1.2 算法实现
给出蛮力算法的两个实现版本。两者原理相同、过程相仿,但分别便于引入后续的不同改进算法,故在此先做一比较。
// #1 int match(char* P, char* T) { size_t n = strlen(T), i = 0; size_t m = strlen(P), j = 0; while (j < m && i < n) { if (T[i] == P[j]) { i++; j++; } else { i -= j; j = 0; } } return i - j; }
如上版本1借助整数i和j,分别指示T和P中当前接受比对的字符T[i]和P[j]。若当前字符对匹配,则i和j同时递增以指向下一个字符。一旦j增长到m则意味着发现了匹配,即可返回P相对于T的对齐位置i - j。一旦当前字符对失配,则i回退并指向T中当前对齐位置的下一字符,同时j复位P的首字符处,然后开始下一轮比对。
// #2 int match(char* P, char* T) { size_t n = strlen(T), i = 0; size_t m = strlen(P), j; for (i = 0; i < n - m + 1; i++) { for (j = 0; j < m; j++) if (T[i + j] != P[j]) break; if (j >= m) break; } return i; }
如上版本2借助整数i指示P相对于T的对齐位置,且随着i不断递增,对齐的位置逐步右移。在每一对齐位置i处,另一整数j从0递增至m - 1,依次指示当前接收比对的字符为T[i + j]与P[j]。因此,一旦发现匹配,即可直接返回当前的对齐位置i。
1.3 时间复杂度
从理论上讲,蛮力算法之多迭代n - m + 1轮,且各轮至多需进行m次比对,故总共只需做不超过(n - m + 1) * m次比对。因为m << n,渐进的时间复杂度为O(n * m)。
2 KMP算法
2.1 构思
蛮力算法在最坏情况下所需时间,为文本串长度与模式串长度的乘积,故无法应用于规模稍大的应用环境,很有必要改进。
不难发现,问题在于存在大量的局部匹配:每一轮的m次比对中,仅最后一次可能失配。而一旦发现失配,文本串、模式串的字符指针都要回退,并从头开始下一轮尝试。
实际上,这类重复的字符比对操作没有必要。既然这些字符在前一轮迭代中已经接受过比对并且成功,那么就可以利用这些信息对蛮力算法进行改进。
2.2 next表
一般地,假设前一轮比对终止于T[i] != P[j]。指针i不必回退,而是将T[i]与P[t]对齐并开始下一轮比对。那么t应该如何选取呢?
经过前一轮的比对,已经确定匹配范围应为:P[0, j) = T[i - j, i)
于是,若模式串P经适当右移以后,能够与T的某一个(包含T[i]在内的)子串完全匹配,则一项必要条件是: P[0, t) = T[i - t, i) = P[j - t, j),也就是说在P[0, j)中长度为t的真前缀,应与长度为t的真后缀完全匹配。
故t必来自集合: N(P, j) = { 0 <= t < j | P[0, t) = P[j - t, j) }
一般地,该集合可能包含多个这样的t。但需要特别注意的是,其中具体由哪些t值构成,仅取决于模式串P以及前一轮比对的首个失配位置P[j],而与文本串T无关!
若下一轮比对将从T[i]与P[t]的比对开始,这等效于将P右移j - t个单元,位移量与t成反比。因此,为保证P与T的对齐位置(指针i)绝不倒退,同时又不致遗漏任何可能的匹配,应在集合N(P, j)中挑选最大的t。也就是说,当有多个值值得试探的右移方案时,应该保守的选择其中移动距离最短者,于是,令next[j] = max( N(P, j) ),则一旦法线P[j]与T[i]失配,即可转而将P[next[j]]与T[i]彼此对准,并从这一位置开始继续下一轮比对。
既然集合N[P, j]仅取决于模式串P与失配位置j,而与文本串无关,作为其中的最大元素,next[j]也必然具有这一性质。于是,对于任一模式串P,不妨通过预处理提前计算出所有位置j所对应的next[j]值,并整理为表格以便以后反复查询。
2.3 KMP算法
这里假定同过buildNext()构造出模式串P的next表,对照版本1的蛮力算法,只是在else分支对失配情况的处理有所不同,这一是KMP算法的精髓所在。
int match(char* P, char* T) { int* next = buildNext(P); int n = (int)strlen(T), i = 0; int m = (int)strlen(P), j = 0; while (j < m && i < n) if (0 > j || T[i] == P[j]) { i++; j++; } else { j = next[j]; } delete[] next; return i - j; }
2.4 构造next表
int* buildNext(char* P) { size_t m = strlen(P), j = 0; int* N = new int[m]; int t = N[0] = -1; while (j < m - 1) if (0 > t || P[j] == P[t]) { j++; t++; // N[j] = (P[j] != P[t] ? t : N[t]); } else t = N[t]; return N; }
注释部分为改进版本