Knuth-Morris-Pratt子字符串查找算法
KMP算法的基本思想是当出现不匹配时,就能知晓一部分文本内容(因为在匹配失败之前它们已经和模式相匹配)。我们可以利用这些信息避免将指针回退到所有这些已知的字符之前。
KMP算法的主要思想是提前判断如何重新开始查找,而这种判断只取决于模式本身。
模式指针的回退
在KMP子字符串查找算法中,不会回退文本指针i,而是使用一个数组dfa[][]来记录匹配失败时模式指针j应该回退多远。对于每个字符c,在比较了c和pat.chatAt(j)之后,dfa[c][j]表示的是应该和下个文本字符比较和模式字符的位置。在查找中,dfa[txt.charAt(i)][j]是在比较了txt.charAt(i)和pat.charAt(j)之后应该和txt.charAt(i+1)比较的模式字符位置。在匹配时会继续比较下一个字符,因此dfa[pat.charAt(j)][j]总是j+1。
KMP查找算法
当i和j所指向的字符匹配失败时,模式可能匹配的下一个位置应该从i-dfa[txt.charAt(i)][j]处开始。从该位置开始的dfa[txt.charAt(i)][j]个字符和模式的前dfa[txt.charAt(i)][j]个字符应该相同,因此无需回退指针i,只需要将j设为dfa[txt.charAt(i)][j]并将i加1即可,这正是当i和j所指向的字符匹配时的行为。
DFA模拟
dfa[][]数组正是一个确定优先状态自动机(DFA)。如图显示的确定有限状态自动机是由状态(数字标记的圆圈)和转换(带标签的箭头)组成的。模式中的每个字符都对应着一个状态,每个此类状态能够转换为字母表中的任意字符。
对于子字符串查找问题,这些转换中只有一条是匹配转换,其他都是非匹配转换。所有状态都和字符的比较相对应,每个状态都表示一个模式字符串的索引值。当我们在标记为j的状态中检查文本中的第i个字符时,自动机的行为是这样的:“沿着转换dfa[txt.charAt(i)][j]前进并继续检查下一个字符(将i加1)”。对于一个匹配的转换,就向右移动一位,因为dfa[pat.charAt(j)][j]的值总是j+1;对于一个非匹配转换,就向左移动。我们还包含了一个不会进行任何转换的停止状态M。自动机从状态0开始:如果自动机到达了状态M,那么就在文本中找到了和模式匹配的一段子字符串;如果自动机在文本结束时都未能到达状态M,那么就可以知道文本中不存在匹配该模式的子字符串。
每个模式字符串都对应着一个自动机(由保存了所有转换的dfa[][]数组表示)。
构造DFA
KMP算法的关键问题:如何计算给定模式相对应的dfa[][]数组?
当在pat.charAt(j)处匹配失败时,希望了解的是,如果回退了文本指针并右移一位之后重新扫描已知的文本字符,DFA的状态会是什么?我们其实并不想回退,只是想将DFA重置到适当状态,就好像已经回退过文本指针一样。
这里的关键在于需要重新扫描的文本字符正是pat.charAt(1)到pat.charAt(j-1)之间,忽略了首字母是因为模式需要右移一位,忽略了最后一个字符是因为匹配失败。这些模式中的字符都是已知的,因此对于每个可能匹配失败的位置都可以预先找到重启DFA的正确状态,如图所示。
DFA应该如何处理下一个字符?和回退时的处理方式相同,除非在pat.charAt(j)处匹配成功,这时DFA应该前进到状态j+1。例如,对于ABABAC,要判断在j=5时匹配失败后DFA应该怎么做。通过DFA可以知道完全回退之后算法会扫描BABA并达到状态3,因此可以将dfa[][3]复制到dfa[][5]并将C所对应的元素的值设为6,因为pat.charAt(5)是C(匹配)。因为在计算DFA的第j个状态时只需要知道DFA是如何处理前j-1个字符的,所以总能从尚不完整的DFA中得到所需的信息。
计算中最后一个关键细节是,因为重启位置X<j,所以可以由已经构造的DFA部分来完成在处理dfa[][]的第j列时维护重启位置X这个任务。X的下一个值是dfa[pat.charAt(j)][X]。
由上述讨论可得以下代码来构造给定模式的DFA:
dfa[pat.charAt(0)][0] = 1;
for (int X = 0, j = 1;j < M;j++) {
for (int c = 0;c < R;c++) {
dfa[c][j] = dfa[c][X];
}
dfa[pat.charAt(j)][j] = j + 1;
X = dfa[pat.charAt(j)][X];
}
对于每个j,它将会:
- 将dfa[][X]复制到dfa[][j](对于匹配失败的情况);
- 将dfa[]pat.charAt(j)[j]设为j+1(对于匹配成功的情况);
- 更新X。
构造轨迹:
性能
对于长度为M的模式字符串和长度为N的文本,KMP算法访问的字符不会超过M+N个。
构造DFA所需的时间和空间将与MR成正比(R为字母表大小)。
KMP算法为最坏情况提供的线性级别运行时间保证是一个重要的理论成果。在实际应用中,它比暴力算法的速度优势并不十分明显,因为极少有应用程序需要在重复性很高的文本中查找重复性很高的模式。但该方法的一个优点是不需要在输入中回退。这使得KMP子字符串查找算法更适合在长度不确定的输入流中进行查找,需要回退的算法在这种情况下则需要复杂的缓冲机制。
代码
package section5_3;
public class KMP {
private String pat;
private int[][] dfa;
public KMP(String pat) {
//由模式字符串构造DFA
this.pat = pat;
int M = pat.length();
int R = 256;
dfa = new int[R][M];
dfa[pat.charAt(0)][0] = 1;
for (int X = 0, j = 1;j < M;j++) {
for (int c = 0;c < R;c++) {
dfa[c][j] = dfa[c][X]; //复制匹配失败情况下的值
}
dfa[pat.charAt(j)][j] = j + 1; //设置匹配成功情况下的值
X = dfa[pat.charAt(j)][X]; //更新重启状态
}
}
public int search(String txt) {
int i, j;
int N = txt.length();
int M = pat.length();
for (i = 0, j = 0;i < N && j < M;i++) {
j = dfa[txt.charAt(i)][j];
}
if (j == M) return i - M; //找到匹配(到达模式字符串的结尾)
else return N; //未找到匹配(到达文本字符串的结尾)
}
public static void main(String[] args) {
String pat = "ABABAC";
String txt = "BCBAABACAABABACAA";
KMP kmp = new KMP(pat);
System.out.println("text :" + txt);
int offset = kmp.search(txt);
System.out.print("pattern :");
for (int i = 0;i < offset;i++) {
System.out.print(" ");
}
System.out.println(pat);
}
}
输出: