最近自己一直在看字符串匹配的算法,结合自己擅长的java语言,聊聊java中字符串匹配的一些感想。
一、char类型的大小
字符串匹配,其实无非就是对文本和模式中逐个字符进行比较。在java中,char类型的变量是占两个字节的。也就是说,字符串匹配时是每次两个字节去比较。而在C语言中,char类型是占一个字节的,所以匹配时只能一个字节一个字节地去匹配。所以对于同样的文本和模式,java中匹配的次数会比c语言少一倍。
这么分析貌似觉得java的字符串匹配会比较快,其实不然。
现在流行的字符串匹配算法,除了蛮力法外,其它算法都需要额外的存储空间(典型的空间换时间)。而额外的空间大小,往往又是跟字符集相关的。例如sunday算法(其它算法也类似),对模式进行预处理时,把模式中所有字符的位置记录下来,具体实现一般是采用哈希方式,即把模式串中每个char字符映射到一个数组中,对应数组元素的值就是该字符在模式串的下标。哈希数组的大小跟char类型大小相关。如果char是一个字节,数组大小为256;如果char是两个字节,数组大小为65536!65536个元素的int数组在java中占用256kb内存!
虽然可以通过设计哈希函数,使用小一点的数组来减小空间。但是这样必然会有冲突,怎么解决冲突也是一个难题。况且有了冲突之后,算法的性能还能不能达到预期效果也是一个问题。估计美国人研究这些算法时都是只用ASCII,最多也就是256个字符,额外空间还不算大,但是想要把算法扩展到unicode就成了一个挑战(除非不在乎额外空间)。
二、神奇的String.indexOf(String)
使用java的人估计都会用过这个方法。不知道有没有人会去认真查看这个方法的源码。其实源码也很简单,是使用蛮力法进行匹配的。
/**
* Code shared by String and StringBuffer to do searches. The
* source is the character array being searched, and the target
* is the string being searched for.
*
* @param source the characters being searched.
* @param sourceOffset offset of the source string.
* @param sourceCount count of the source string.
* @param target the characters being searched for.
* @param targetOffset offset of the target string.
* @param targetCount count of the target string.
* @param fromIndex the index to begin searching from.
*/
static int indexOf(char[] source, int sourceOffset, int sourceCount,
char[] target, int targetOffset, int targetCount,
int fromIndex) {
if (fromIndex >= sourceCount) {
return (targetCount == 0 ? sourceCount : -1);
}
if (fromIndex < 0) {
fromIndex = 0;
}
if (targetCount == 0) {
return fromIndex;
}
char first = target[targetOffset];
int max = sourceOffset + (sourceCount - targetCount);
for (int i = sourceOffset + fromIndex; i <= max; i++) {
/* Look for first character. */
if (source[i] != first) {
while (++i <= max && source[i] != first);
}
/* Found first character, now look at the rest of v2 */
if (i <= max) {
int j = i + 1;
int end = j + targetCount - 1;
for (int k = targetOffset + 1; j < end && source[j]
== target[k]; j++, k++);
if (j == end) {
/* Found whole string. */
return i - sourceOffset;
}
}
}
return -1;
}
这个算法的时间复杂度很明显是O(m*n),n和m分别为文本和模式的长度。但是它实际运行的效率可是比其它复杂度为O(n)的算法还要高。
算法 | 时间(ms) |
---|---|
Rabin-Karp | 1921 |
KMP | 6822 |
sunday | 1103 |
horspool | 252 |
String.indexOf(String) | 504 |
其实认真观察其代码,会发现它在一个大循环里面,是有两个小循环。
第一个循环是检测相同的首字符:
/* Look for first character. */
if (source[i] != first) {
while (++i <= max && source[i] != first);
}
第二个循环再做余下字符的比较:
if (i <= max) {
int j = i + 1;
int end = j + targetCount - 1;
for (int k = targetOffset + 1; j < end && source[j]
== target[k]; j++, k++);
if (j == end) {
/* Found whole string. */
return i - sourceOffset;
}
}
但是一般写字符串匹配蛮力法时,都是把这两个循环合并成一个的,如下所示
public static int forceSearch(String text, String pattern) {
int patternLength = pattern.length();
int textLength = text.length();
for (int i = 0, n = textLength - patternLength; i <= n; i++) {
int j = 0;
for (; j < patternLength && text.charAt(i + j) == pattern.charAt(j); j++) {
;
}
if (j == patternLength) {
return i;
}
}
return -1;
}
这个forceSearch算法进行和上表一样的测试时,花费时间为3090ms(时间为String.indexOf的6倍)。但如果在循环中加上这段代码,会发现效率和String.indexOf(String)没有什么区别。
if (text.charAt(i) != first) {//char first = pattern.charAt(0);
while (++i <= n && text.charAt(i) != first)
;
}
也就是说,上面这个检查首字符的循环非常地块。
为什么会这样?我猜测原因有两个:
- 这个循环充分地利用了cpu缓存。
- java的JIT编译器检测到这个循环是热点代码,在运行时把它直接编译成机器指令。
对于第1个理由,我用分别用C语言编写带首字符检查循环的代码和不带首字符循环的代码,却发现两者运行时间差不多。
所以不是因为缓存的原因,而是JIT???我不知道自己在C上做的测试是否标准。我把这个问题放在StackOverflow上,有人建议我查看汇编来分析,但已我现在的基础,还看不懂汇编。
总之不管怎么样,java中String.indexOf方法性能很高,仅比horspool慢一点,但是horspool额外使用了一个256大小的整形数组。这也可以理解为什么java官方使用蛮力法。
三、工具和书籍
其实现在已经有很多性能很快的文本查找工具:GNU GREP、ACK、Silver Search……如果有时间,个人打算参照这些软件的算法,自己实现一个java版本的
至于书籍,感觉字符串匹配的算法书比较少,而且都是比较老的:《柔性字符串匹配》、《Algorithms on Strings, Trees and Sequences》