Java indexOf 函数比 Rabin-Karp 更有效?文本的搜索效率

2022-09-03 04:45:49

几周前,我向Stackoverflow提出了一个问题,关于创建一个有效的算法来搜索大块文本中的模式。现在我正在使用字符串函数 indexOf 进行搜索。一项建议是使用Rabin-Karp作为替代方案。我写了一个小测试程序,如下所示来测试Rabin-Karp的实现。

public static void main(String[] args) {
    String test = "Mary had a little lamb whose fleece was white as snow";

    String p = "was";
     long start  = Calendar.getInstance().getTimeInMillis();
     for (int x = 0; x < 200000; x++)
         test.indexOf(p);
     long end = Calendar.getInstance().getTimeInMillis();
     end = end -start;
     System.out.println("Standard Java Time->"+end);

    RabinKarp searcher = new RabinKarp("was");
    start  = Calendar.getInstance().getTimeInMillis();
    for (int x = 0; x < 200000; x++)
    searcher.search(test);
    end = Calendar.getInstance().getTimeInMillis();
    end = end -start;
    System.out.println("Rabin Karp time->"+end);

}

以下是我正在使用的Rabin-Karp的实现:

import java.math.BigInteger;
import java.util.Random;

public class RabinKarp {
private String pat; // the pattern // needed only for Las Vegas
private long patHash; // pattern hash value
private int M; // pattern length
private long Q; // a large prime, small enough to avoid long overflow
private int R; // radix
private long RM; // R^(M-1) % Q
static private long dochash = -1L;

public RabinKarp(int R, char[] pattern) {
    throw new RuntimeException("Operation not supported yet");
}

public RabinKarp(String pat) {
    this.pat = pat; // save pattern (needed only for Las Vegas)
    R = 256;
    M = pat.length();
    Q = longRandomPrime();

    // precompute R^(M-1) % Q for use in removing leading digit
    RM = 1;
    for (int i = 1; i <= M - 1; i++)
        RM = (R * RM) % Q;
    patHash = hash(pat, M);
}

// Compute hash for key[0..M-1].
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;
}

// Las Vegas version: does pat[] match txt[i..i-M+1] ?
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;
}

// check for exact match
public int search(String txt) {
    int N = txt.length();
    if (N < M)
        return -1;
    long txtHash;
    if (dochash == -1L) {
        txtHash = hash(txt, M);
        dochash = txtHash;
    } else
        txtHash = dochash;

    // check for match at offset 0
    if ((patHash == txtHash) && check(txt, 0))
        return 0;

    // check for hash match; if hash match, check for exact match
    for (int i = M; i < N; i++) {
        // Remove leading digit, add trailing digit, check for match.
        txtHash = (txtHash + Q - RM * txt.charAt(i - M) % Q) % Q;
        txtHash = (txtHash * R + txt.charAt(i)) % Q;

        // match
        int offset = i - M + 1;
        if ((patHash == txtHash) && check(txt, offset))
            return offset;
    }

    // no match
    return -1; // was N
}

// a random 31-bit prime
private static long longRandomPrime() {
    BigInteger prime = new BigInteger(31, new Random());
    return prime.longValue();
}

// test client

}

Rabin-Karp的实现的工作原理是它返回我正在寻找的字符串的正确偏移量。然而,令我惊讶的是当我运行测试程序时发生的时序统计信息。它们分别是:

Standard Java Time->39
Rabin Karp time->409

这真的很令人惊讶。Rabin-Karp(至少在这里实现)不仅不比标准的java indexOf String函数快,而且速度慢一个数量级。我不知道出了什么问题(如果有的话)。有人对此有想法吗?

谢谢

埃利奥特


答案 1

我之前回答了这个问题,艾略特指出我大错特错了。我向社区道歉。

String.indexOf代码没有什么神奇之处。它不是原生优化或类似的东西。您可以从 String 源代码中复制 indexOf 方法,它的运行速度也一样快。

我们在这里看到的是O()效率和实际效率之间的差异。对于长度为N的字符串和长度为M的模式,Rabin-Karp是O(N + M)和O(NM)的最坏情况。当你研究它时,String.indexOf()也有一个最好的O(N+M)和一个最坏的O(NM)案例。

如果文本包含许多与模式开头的部分匹配,则 Rabin-Karp 将保持接近其最佳情况性能,而 String.indexOf 则不会。例如,我在一百万个“0”上测试了上面的代码(这次是正确的:-)),后跟一个“1”,搜索1000个“0”,后面跟着一个“1”。这迫使 String.indexOf 达到其最坏情况的性能。对于这种高度退化的测试,Rabin-Karp算法比indexOf快约15倍。

对于自然语言文本,Rabin-Karp将保持接近最佳大小写,indexOf只会略微恶化。因此,决定因素是每个步骤执行的操作的复杂性。

在它最里面的循环中,indexOf 扫描匹配的第一个字符。在每次迭代中必须:

  • 递增循环计数器
  • 执行两个逻辑测试
  • 执行一个阵列访问

在Rabin-Karp中,每次迭代都必须:

  • 递增循环计数器
  • 执行两个逻辑测试
  • 执行两个数组访问(实际上是两个方法调用)
  • 更新哈希,上面需要 9 个数值运算

因此,在每次迭代中,Rabin-Karp将越来越落后。我试图将哈希算法简化为XOR字符,但我仍然有一个额外的数组访问和两个额外的数字操作,所以它仍然很慢。

此外,当找到匹配项时,Rabin-Karp 只知道哈希匹配,因此必须测试每个字符,而 indexOf 已经知道第一个字符匹配,因此少做一个测试。

在维基百科上读到Rabin-Karp被用来检测抄袭之后,我拿起了圣经的路得记,删除了所有的标点符号,把所有的东西都做成了小写,只剩下不到10000个字符。然后,我搜索了“andthewomenherneighboursgaveitaname”,它出现在文本的末尾。String.indexOf仍然更快,即使只有XOR哈希。但是,如果我删除了String.indexOfs能够访问String的私有内部字符数组并强制它复制字符数组的优势,那么最后,Rabin-Karp确实更快。

然而,我故意选择了这段经文,因为路得记中有213个“和”和28个“ 和”。相反,如果我只搜索最后一个字符“ursgaveitaname”,那么文本中只有3个“urs”,因此indexOf返回更接近其最佳情况并再次赢得比赛。

作为一个更公平的测试,我从文本的后半部分随机选择了20个字符的字符串,并对其进行了计时。Rabin-Karp 比在 String 类之外运行的 indexOf 算法慢约 20%,比实际的 indexOf 算法慢 70%。因此,即使在它应该适合的用例中,它仍然不是最佳选择。

那么拉宾-卡普有什么用呢?无论要搜索的文本的长度或性质如何,在每一个字符比较时,它都会变慢。无论我们选择什么哈希函数,我们肯定需要进行额外的数组访问和至少两个数值运算。更复杂的哈希函数将减少错误匹配,但需要更多的数字运算符。拉宾-卡普根本无法跟上。

如上所述,如果我们需要找到一个以经常重复的文本块为前缀的匹配项,indexOf可能会更慢,但是如果我们知道我们正在这样做,那么看起来我们仍然最好使用indexOf来搜索没有前缀的文本,然后检查前缀是否存在。

根据我今天的调查,我看不出拉宾·卡普的额外复杂性何时会得到回报。


答案 2

以下是java.lang.String的源代码。indexOf 是第 1770 行。

我的怀疑是,由于您在如此短的输入字符串上使用它,Rabin-Karp算法在java.lang.String的indexOf的看似幼稚的实现上的额外开销,您没有看到该算法的真实性能。我建议在更长的输入字符串上尝试使用它来比较性能。