《算法》笔记 15 - 子字符串查找
- 暴力子字符串查找算法
- 隐式回退
- 性能
- 显式回退
- Knuth-Morris-Pratt算法
- 确定有限状态自动机
- DFA的构造
- 性能
- Boyer-Moore算法
- 跳跃表的构建
- 性能
- Rabin-Karp指纹字符串算法
- 关键思想
- Horner方法
- 性能
字符串的一种基本操作就是子字符串查找。比如在文本编辑器或是浏览器中查找某个单词时,就是在查找子字符串。子字符串的长度(可能为100或1000)相对于整个文本的长度(可能为100万甚至是10亿)来说一般是很短的,在如此多的字符中找到匹配的模式是一个很大的挑战,为此计算机科学家们发明了多种有趣、经典且高效的算法。
暴力子字符串查找算法
要解决这个问题,首先想到的是暴力查找的方法,在文本中模式可能出现匹配的任何地方检查是否匹配。
隐式回退
首先是隐式回退的实现方式,之所以叫隐式回退,在与显式回退的实现对比后就明白原因了。
public static int search(String pat, String txt) {
int patL = pat.length();
int txtL = txt.length();
for (int i = 0; i <= txtL - patL; i++) {
int j;
for (j = 0; j < patL; j++)
if (txt.charAt(i + j) != pat.charAt(j))
break;
if (j == patL)
return i;
}
return txtL;
}
用一个索引i跟踪文本,另一个索引j跟踪模式。对于每个i,代码首先将j重置为0,并不断把它加1,直到找到了一个不匹配的字符,或者j增加到patL,此时就找到了匹配的子字符串。
性能
在一般情况下,索引j增长的机会很少,绝大多数时候在比较第一个字符是就会产生不匹配。
在长度为N的文本中,查找长度为M的子字符串时:
- 在最好情况下,对于每个i,在索引j=0时就发现了不匹配,一共需要进行N次字符比较;
- 在最坏的情况下,对于前N-1个i,索引j都需要增加到M-1次,才会发现不匹配,最后一次匹配,比如文本和模式都是一连串的A接一个B。这种情况下,对于N-M+1个可能的匹配位置,都需要M次比较,一共需要进行M*(N-M+1)次比较,因为N远大于M,所以忽略小数值后结果为~NM。
显式回退
public static int search1(String pat, String txt) {
int j, patL = pat.length();
int i, txtL = txt.length();
for (i = 0, j = 0; i <= txtL && j < patL; i++) {
if (txt.charAt(i) == pat.charAt(j))
j++;
else {
i -= j;
j = 0;
}
}
if (j == patL)
return i - patL;
else
return txtL;
}
与隐式回退一样,也使用了索引i和j分别跟踪文本和模式的字符,但这里的i指向的是匹配过的字符序列的末端,所以这里的i相当与隐式回退中的i+j,如果字符不匹配,就需要回退i和j的位置,将j回退为0,指向模式的开头,将i指向本次匹配的开始位置的下一个字符。
隐式回退的实现方式中,匹配位置的的末端字符是通过i+j指定的,所以只需要将j重新设为0,就实现了文本和模式字符的回退。
Knuth-Morris-Pratt算法
暴力算法在每次出现不匹配时,都会回退到本次匹配开始位置的下一个字符,但其实在不匹配时,就能知道一部分文本的内容,因此可以利用这些信息减少回退的幅度,Knuth-Morris-Pratt算法(简称KMP算法)就是基于这种思想。
比如,假设文本只有A和B构成,那么在查找模式字符串为B A A A A A A A时,如果在匹配到第6个字符时出现不匹配,此时可以确定文本中的前6个字符就是B A A A A B,接下来不需要回退i,只需将i+1, j+2后,继续和模式的第二个字符匹配即可,因为模式的第一个字符是B,而上一次匹配失败的末尾字符也是B。
而对于文本A A B A A B A和模式A A B A A A,在文本的第6个字符处发现不匹配后,应该从第4个字符开始重新匹配,这样就不会错过已经匹配的部分了。
KMP算法的主要思想就是提前判断如何重新开始查找,而这种判断只取决于模式本身。
确定有限状态自动机
KMP算法中不会回退文本索引i,而是使用一个二维数组dfa来记录匹配失败时模式索引j应该回退多远。对于每个字符c,在比较了c和pat.charAt(j)之后,dfa[c][i]表示的是应该和下个文本字符比较的模式字符的位置。
这个过程实际上是对确定有限状态自动机(DFA)的模拟,dfa数组定义的正是一个确定有限状态自动机。DFA由状态(数字标记的圆圈)和转换(带标签的箭头)组成,模式中的每个字符都对应着一个状态,用模式字符串的索引值表示。
【DFA】图和数组
在标记为j的状态中检查文本中的第i个字符时,自动机会沿着转换dfa[txt.charAt(i)][j]前进并继续将i+1,对于一个匹配的转换,就向右移动一位,对于一个不匹配的智慧,就根据自动机的指示回退j。自动机从状态0开始,如果到达了最终的停止状态M,则查找成功。
DFA的构造
DFA是KMP算法的核心,构造给定模式对应的DFA也是这个算法的关键问题。DFA指示了应该如何处理下一个字符。如果在pat.charAt(j)处匹配成功,DFA应该前进到状态j+1。但如果匹配失败,DFA会从已经构造过的模式中获取到需要的信息,以模式 A B A B A C的构造过程举例:
下面表格表示dfa[][],横向表头表示模式字符,括号中是当前的状态。
1.初始状态,各位置都是0:
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
---|---|---|---|---|---|---|
A | 0 | 0 | 0 | 0 | 0 | 0 |
B | 0 | 0 | 0 | 0 | 0 | 0 |
C | 0 | 0 | 0 | 0 | 0 | 0 |
2.先看字符匹配成功时的情况,这时对应的状态会指向下一个状态: | ||||||
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
-: | :-: | :-: | :-: | :-: | :-: | :-: |
A | 1 | 0 | 3 | 0 | 5 | 0 |
B | 0 | 2 | 0 | 4 | 0 | 0 |
C | 0 | 0 | 0 | 0 | 0 | 6 |
3.在状态0,匹配失败时,无论是B还是C都退到初始状态,重新开始;
4.在状态1,匹配失败时,如果此时的字符为A,则文本为A A,可以跳过状态0,直接到状态1,与A(0)行为一致,所以将A(0)的值复制到B(1),在DFA中对应的值也为1;而对于字符C,文本为A C,只能退回到状态0,重新开始
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
---|---|---|---|---|---|---|
A | 1 | 1 | ||||
B | 0 | 2 | ||||
C | 0 | 0 |
5.在状态2,匹配失败时,如果此时的字符为B,则文本为A B B,回到状态0;如果是字符C,文本为A B C,也只能退回到状态0。
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
---|---|---|---|---|---|---|
A | 1 | 1 | 3 | |||
B | 0 | 2 | 0 | |||
C | 0 | 0 | 0 |
6.在状态3,匹配失败时,如果此时的字符为A,则文本为A B A A,直接到状态1;如果是字符C,文本为A B A C,回到状态0
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
---|---|---|---|---|---|---|
A | 1 | 1 | 3 | 1 | ||
B | 0 | 2 | 0 | 4 | ||
C | 0 | 0 | 0 | 0 |
7.在状态4,匹配失败时,如果此时的字符为B,则文本为A B A B B,回到状态0;如果是字符C,文本为A B A B C,回到状态0
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
---|---|---|---|---|---|---|
A | 1 | 1 | 3 | 1 | 5 | |
B | 0 | 2 | 0 | 4 | 0 | |
C | 0 | 0 | 0 | 0 | 0 |
8.在状态5,匹配失败时,如果此时的字符为a,则文本为A B A B A A,回到状态1;如果是字符B,文本为A B A B A B,回到状态4,因为前面的A B A B都是匹配的,可以跳过。
- | A(0) | B(1) | A(2) | B(3) | A(4) | C(5) |
---|---|---|---|---|---|---|
A | 1 | 1 | 3 | 1 | 5 | 1 |
B | 0 | 2 | 0 | 4 | 0 | 4 |
C | 0 | 0 | 0 | 0 | 0 | 6 |
通过以上过程可知,在计算状态为j的DFA时,总能从尚不完整、已经计算完成的j-1个状态中得到所需的信息。
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];
}
代码中,用X维护了每次重启时的状态,然后具体的做法是:
- 匹配失败时,将dfa[][X]复制到dfa[][j];
- 匹配成功时,将dfa[pat.chatAt(j)][j]的值设为j+1;
- 将X更新为dfa[pat.charAt(j)][X]
初始化完成dfa后,查找的代码为:
public int search(String txt) {
int i, j, N = txt.length(), 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;
}
性能
在长度为N的文本中,查找长度为M的子字符串时,KMP算法会先初始化dfa,访问模式字符串中的每个字符一次,查找时在最坏情况下,会把文本中的字符都访问一次,所以KMP算法访问的字符最多为N+M个。
KMP算法为最坏情况提供线性级别运行时间的保证。虽然在实际应用中,KMP算法相比暴力算法的速度优势并不明显,因为现实情况下的文本和模式一般不会有很高的重复性。
但KMP算法还有一个非常重要的优点,就是它不需要在输入中回退,这使得KMP算法非常适合在长度不确定的输入流中进行查找,而那些需要回退的算法在处理这种输入时却需要复杂的缓冲机制。
Boyer-Moore算法
KMP算法不需要在输入中回退,但接下来学习的Boyer-Moore算法却利用回退获得了巨大的性能收益。
Boyer-Moore算法是从右向左扫描模式字符串的,比如在查找模式字符串B A A B B A A时,如果匹配了第7、第6个字符,然后在第5个字符处匹配失败,那么就可以知道文本中对应的第5 6 7个字符分别是X A A,而X必然不是B,接下来就可以直接跳到第14个字符了。但并不是每次都能前进这么大的幅度,因为模式的结尾部分也可能出现在文本的其他位置,所以和KMP算法一样,这个算法也需要一个记录重启位置的数组。
跳跃表的构建
有了记录重启位置的数组,就可以在匹配失败时,知道应该向右跳跃多远了,使用一个right数组记录字母表中的每个字符在模式中出现的最靠右的地方,如果字符在模式中不存在,则表示为-1。right数组又称为跳跃表。
构建跳跃表时,先将所有元素设为-1,然后对于0到M-1的j,将right[pat.chatAt(j)]设为j。
public BoyerMoore(String pat) {
this.pat = pat;
int M = pat.length();
int R = 256;
right = new int[R];
for (int c = 0; c < R; c++)
right[c] = -1;
for (int j = 0; j < M; j++)
right[pat.charAt(j)] = j;
}
模式 N E E D L E对应的跳跃表为
A B C D E ... L M N
-1 -1 -1 3 5 -1 4 -1 0
算法会使用一个索引i在文本从左向右移动,用索引j在模式中从右向左移动,然后不断检查txt.charAt(i+j)和pat.charAt(j)是否匹配,如果对于模式中的所有字符都匹配,则查找成功;如果匹配失败,分三种情况处理:
- 如果造成匹配失败的字符不在模式中,则将模式字符串向右移动j+1个位置,小于这个偏移量都会使模式字符串覆盖的区域中再次包含该字符;
. . . T L E . . .
N E E D L E
^ ^
i j
. . . T L E . . .
N E E D L E
^ ^
i增大j+1 j重置为M-1
- 如果字符在模式中,则根据跳跃表,使该字符和它在模式字符串中对应最右的位置对齐,小于这个偏移量也会使该字符与模式中其它字符重叠;
. . . N L E . . .
N E E D L E
^ ^
i j
. . . N L E . . .
N E E D L E
^
i增大j-right['N']
- 如果根据跳跃表得出的结果,无法使模式字符串向右移动,则将i+1,保证至少向右移动了一个位置。
. . . . . E L E . . .
N E E D L E
^ ^
i j
. . . . . E L E . . .
N E E D L E
这种情况会使模式字符串向右移动,只能将i+1
. . . . . E L E . . .
N E E D L E
^
i=i+1
查找算法的实现为:
public int search(String txt) {
int N = txt.length();
int M = pat.length();
int skip;
for (int i = 0; i <= N - M; i += skip) {
skip = 0;
for (int j = M - 1; j >= 0; j--) {
if (pat.charAt(j) != txt.charAt(i + j)) {
skip = j - right[txt.charAt(i + j)];
if (skip < 1)
skip = 1;
break;
}
}
if(skip==0) return i;
}
return N;
}
性能
最好情况下,每次跳跃的距离都是M,那么最终只需要N/M次比较。
在最坏的情况下,跳跃失效,等同于暴力算法,比如在一连串A A A A A ...中查找B A A ...,这时需要M*N次比较。
在实际应用场景中,模式字符串中仅含有字符集中的少量字符是很常见的,因此几乎所有的比较都会使算法跳过M个字符,所以一般来说算法需要~N/M次比较。
Rabin-Karp指纹字符串算法
M.O.Rabin和R.A.Karp发明的基于散列的字符串查找算法,与前面两种算法的思路完全不同。计算模式字符串的散列值,然后使用相同的散列函数,计算文本逐位计算M个字符的散列值,如果得出的散列值与模式字符串的散列值相同,就再继续逐字符验证一次,确保匹配成功。
但如果直接按照这种方式,得出的算法效率减比暴力算法还要低很多,而Rabin和Karp发明了一种能够在常数时间内算出M个字符的子字符串散列值的方法,这使得这种算法的运行时间降低到了线性级别。
关键思想
散列函数一般使用除留余数法,除数选择一个尽量大的素数。算法的关键在于只要知道上一个位置M个字符的散列值,就能够快速得算出下一个位置M个字符的散列值。以数字字符串来举例:
2 6 5 3 5 %997=613
5 9 2 6 5 3 5
5 9 2 6 5 %997=442
9 2 6 5 3 %997=929
2 6 5 3 5 %997=613(匹配)
在上面的过程中,模式字符串26535可以看作一个十进制的数字,它要匹配的每M个字符也都可以看作是一个M为的整数,59265用997取余后的结果为442,接下来92653取余时不需要重头计算
59265=5*10000+9265
92653=9265+3*1
所以
92653=(59265-5*10000)*10+3*1
而对于普通的字符串,如果它的字符集中有R个字符,则可以把这些字符串看作是R进制的数字,用ti表示txt.charAt(i),则:
xi=tiRM-1+ti+1RM-2+...+ti+M-1R0
与得出92653的过程一样,将模式字符串右移一位即等价于将xi替换为:
xi+1=(xi-tiRM-1)R+ti+M
Horner方法
计算散列值时,如果是int值,可以直接取余,但对于一个相当于R进制的数字的字符串,要得出它的余数,就需要用Horner方法,Horner方法的理论依据是,如果在每次算术操作之后都将结果除Q取余,这等价于在完成了所有算术操作之后再将最后的结果除Q取余:
private long hash(String key, int M) {
long h = 0;
for (int j = 0; j < M; j++)
h = (R * h + key.charAt(j)) % Q;
return h;
}
对于一个R进制的数字,从左至右,将它的每一位数字的散列值乘以R,加上这个数字,并计算除Q的余数。
那么随着索引i的增加,逐位计算M个字符的散列值时,就可以利用跟Horner方法相同的原理了:
public class RabinKarp {
private String pat;
private long patHash;
private int M;
private long Q;
private int R = 256;
private long RM;
public RabinKarp(String pat) {
this.pat = pat;
M = pat.length();
Q = longRandomPrime();
RM = 1;
for (int i = 1; i <= M - 1; i++)
RM = (R * RM) % Q;
patHash = hash(pat, M);
}
private boolean check(String txt, int i) {
for (int j = 0; j < M; j++)
if (pat.charAt(j) != txt.charAt(i + j))
return false;
return true;
}
private long hash(String key, int M) {
long h = 0;
for (int j = 0; j < M; j++)
h = (R * h + key.charAt(j)) % Q;
return h;
}
// a random 31-bit prime
private static long longRandomPrime() {
BigInteger prime = BigInteger.probablePrime(31, new Random());
return prime.longValue();
}
public int search(String txt) {
int N = txt.length();
long txtHash = hash(txt, M);
if (patHash == txtHash && check(txt, 0))
return 0;
for (int i = M; i < N; i++) {
txtHash = (txtHash + Q - RM * txt.charAt(i - M) % Q) % Q;
txtHash = (txtHash * R + txt.charAt(i)) % Q;
if (patHash == txtHash)
if (check(txt, i - M + 1))
return i - M + 1;
}
return N;
}
}
性能
取余所选的Q值是一个很大的素数,这里使用的是使用BigInteger.probablePrime生成的一个31位素数,其大小与231接近,那么在将模式字符串的散列值与文本中M个字符的散列值比较时,产生冲突的概率约为2-31,这是一个极小的值,所以算法中的check方法可以省略掉,Rabin-Karp算法的性能为线性级别,