为什么^(?:x + y){5} $的性能比^ x + yx + yx + yx + yx + y $慢

时间:2015-01-27 01:41:33

标签: java .net regex performance

我让以下编译的正则表达式匹配一堆字符串,包括.net(N)和Java(J)。通过多次运行,正则表达式 1 与正则表达式 2 之间存在一致的差异,无论是在.net还是在Java中:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  #  regex                             N secs   N x  J secs   J x 
──────────────────────────────────────────────────────────────────
  1  ^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$    8.10     1    5.67     1 
  2  ^(?:[^@]+@){5}$                    11.07  1.37    6.48  1.14 
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

正则表达式编译器可以而且应该不会展开并以其他方式将等效构造规范化为最佳效果吗?

如果它们“可以而且应该”,至少可以编写 regex优化器,它在编译之前修改正则表达式字符串。

使用的代码的关键部分:

达网络

// init
regex = new Regex(r, RegexOptions.Compiled | RegexOptions.CultureInvariant);

// test
sw = Stopwatch.Start();
foreach (var s in strs)
  if (regex.isMatch(s))
    matches++;
elapsed = sw.Elapsed;

爪哇

// init
pat = Pattern.compile(r);

// test
before = System.currentTimeMillis();
for (String s : strs)
  if (pat.matcher(s).matches())
    matches++;
elapsed = System.currentTimeMillis() - before;

3 个答案:

答案 0 :(得分:6)

我不了解.NET ,因为我没有详细研究过它的源代码。

但是,Java中的,特别是Oracle / Sun实现,我可以说这很可能是由于循环结构开销

在这个答案中,每当我提到Java中的regex实现时,我指的是Oracle / Sun实现。我还没有研究过其他的实现,所以我真的不能说什么。

贪婪量词

我只是意识到这部分与这个问题没什么关系。然而,它介绍了如何以这种方式实现贪婪量词,所以我将其留在这里。

给定一个带有贪心量词A的原子A*(重复次数在这里并不重要),贪心量词将尝试尽可能多地匹配A,然后尝试续集(无论A*之后发生什么),失败时,一次回溯一次重复,然后用续集重试。

问题是回溯到的位置。重新匹配整个事物只是为了找出位置是非常低效的,所以我们需要存储重复完成匹配的位置,每次重复。你重复得越多,你需要保留所有状态以便回溯的内存越多,而不是提及捕获组(如果有的话)。

按照问题所做的方式展开正则表达式并没有逃避上面的内存要求。

具有固定长度原子的简单情况

但是,对于[^@]*这样的简单情况,您知道原子A(在本例中为[^@])只能匹配固定长度的字符串(长度为1),只有最后一个匹配需要位置和匹配的长度来有效地执行匹配。 Java的实现包括study方法来检测这些固定长度模式以编译为循环实现(Pattern.CurlyPattern.GroupCurly)。

这是第一个正则表达式^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$在编译成Pattern类内的节点链后的样子:

Begin. \A or default ^
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Dollar(multiline=false). \Z or default $
java.util.regex.Pattern$LastNode
Node. Accept match

循环结构开销

如果长度不固定,就像问题中正则表达式(?:[^@]+@)中的原子^(?:[^@]+@){5}$一样,Java的实现会切换到递归来处理匹配(Pattern.Loop)。

Begin. \A or default ^
Prolog. Loop wrapper
Loop[1733fe5d]. Greedy quantifier {5,5}
  java.util.regex.Pattern$GroupHead
  Curly. Greedy quantifier {1,2147483647}
    CharProperty.complement. S̄:
      BitClass. Match any of these 1 character(s):
        @
    Node. Accept match
  Single. Match code point: U+0040 COMMERCIAL AT
  GroupTail. --[next]--> Loop[1733fe5d]
Dollar(multiline=false). \Z or default $
java.util.regex.Pattern$LastNode
Node. Accept match

这会在每次重复通过节点GroupTail --> Loop --> GroupHead时产生额外开销。

您可能会问为什么即使重复次数是固定的,实现也会这样做。由于重复次数是固定的,右边根本没有回溯,所以我们不能只记录重复之前的状态和当前重复的状态吗?

嗯,这是一个反例:^(?:a{1,5}?){5}$只有a的字符串长度15。回溯仍然可以在原子内部发生,因此我们需要按照惯例存储每次重复的匹配位置。

实际时间

我上面讨论的都是Java源代码(以及字节码)级别。虽然源代码可能会揭示实现中的某些问题,但性能最终取决于JVM如何生成机器代码并执行优化。

这是我用于测试的源代码:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.regex.Pattern;


public class SO28161874 {
    // Makes sure the same set of strings is generated between different runs
    private static Random r = new Random();

    public static void main(String args[]) {
        final int rep = 5;

        // String r1 = "^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$";
        String r1 = genUnroll(rep);
        // String r2 = "^(?:[^@]+@){5}$";
        String r2 = genQuantifier(rep);

        List<String> strs = new ArrayList<String>();
        // strs.addAll(generateRandomString(500, 40000, 0.002, false));
        // strs.addAll(generateRandomString(500, 40000, 0.01, false));
        // strs.addAll(generateRandomString(500, 40000, 0.01, true));
        // strs.addAll(generateRandomString(500, 20000, 0, false));
        // strs.addAll(generateRandomString(500, 40000, 0.002, true));
        strs.addAll(generateNearMatchingString(500, 40000, rep));

        /*
        // Assertion for generateNearMatchingString
        for (String s: strs) {
            assert(s.matches(r1.replaceAll("[+]", "*")));
        }
        */

        System.out.println("Test string generated");

        System.out.println(r1);
        System.out.println(test(Pattern.compile(r1), strs));

        System.out.println(r2);
        System.out.println(test(Pattern.compile(r2), strs));
    }

    private static String genUnroll(int rep) {
        StringBuilder out = new StringBuilder("^");

        for (int i = 0; i < rep; i++) {
            out.append("[^@]+@");
        }

        out.append("$");
        return out.toString();
    }

    private static String genQuantifier(int rep) {
        return "^(?:[^@]+@){" + rep + "}$";
    }

    /*
     * count -- number of strings
     * maxLength -- maximum length of the strings
     * chance -- chance that @ will appear in the string, from 0 to 1
     * end -- the string appended with @
     */
    private static List<String> generateRandomString(int count, int maxLength, double chance, boolean end) {
        List<String> out = new ArrayList<String>();

        for (int i = 0; i < count; i++) {
            StringBuilder sb = new StringBuilder();
            int length = r.nextInt(maxLength);
            for (int j = 0; j < length; j++) {
                if (r.nextDouble() < chance) {
                    sb.append('@');
                } else {
                    char c = (char) (r.nextInt(96) + 32);
                    if (c != '@') {
                        sb.append(c);
                    } else {
                        j--;
                    }
                }
            }

            if (end) {
                sb.append('@');
            }

            out.add(sb.toString());

        }

        return out;
    }

    /*
     * count -- number of strings
     * maxLength -- maximum length of the strings
     * rep -- number of repetitions of @
     */
    private static List<String> generateNearMatchingString(int count, int maxLength, int rep) {
        List<String> out = new ArrayList<String>();

        int pos[] = new int[rep - 1]; // Last @ is at the end

        for (int i = 0; i < count; i++) {
            StringBuilder sb = new StringBuilder();
            int length = r.nextInt(maxLength);

            for (int j = 0; j < pos.length; j++) {
                pos[j] = r.nextInt(length);
            }
            Arrays.sort(pos);

            int p = 0;

            for (int j = 0; j < length - 1; j++) {
                if (p < pos.length && pos[p] == j) {
                    sb.append('@');
                    p++;
                } else {
                    char c = (char) (r.nextInt(95) + 0x20);
                    if (c != '@') {
                        sb.append(c);
                    } else {
                        j--;
                    }
                }
            }

            sb.append('@');

            out.add(sb.toString());

        }

        return out;
    }

    private static long test(Pattern re, List<String> strs) {
        int matches = 0;

        // 500 rounds warm-up
        for (int i = 0; i < 500; i++) {
            for (String s : strs)
              if (re.matcher(s).matches());
        }

        long accumulated = 0;

        long before = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            matches = 0;
            for (String s : strs)
              if (re.matcher(s).matches())
                matches++;
        }

        accumulated += System.currentTimeMillis() - before;

        System.out.println("Found " + matches + " matches");

        return accumulated;
    }
}

评论/取消注释不同的生成行以进行测试,并弄乱数字。我在每次测试之前通过执行正则表达式500次来预热VM,然后计算累计时间1000次以运行正则表达式。

我没有任何具体的号码要发帖,因为我发现自己的结果相当不稳定。但是,从我的测试来看,我通常会发现第一个正则表达式比第二个正则表达式更快。<​​/ p>

通过生成500个字符串,每个字符串最长可达40000个字符,我发现当输入导致它们在不到10秒的时间内运行时,2个正则表达式之间的差异更加突出(大约1到2秒)。当输入导致它们运行更长时间(40+秒)时,两个正则表达式的运行时间大致相同,最多只有几百毫秒的差异。

答案 1 :(得分:3)

  

为什么^(?:x+y){5}$的效果比^x+yx+yx+yx+yx+y$慢?

因为这里出现了Backtracking的概念。

正则表达式1:

^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$

这里没有回溯,因为每个单独的模式都匹配字符串的特定部分。

enter image description here

正则表达式2:

^(?:[^@]+@){5}$

enter image description here

答案 2 :(得分:0)

MSDN在其Backtracking in Regular Expressions

页面上
  

一般来说,非确定性有限自动机(NFA)引擎就像   .NET Framework正则表达式引擎负责   制作高效,快速的正则表达式   显影剂。

这听起来更像是一个蹩脚的借口,而不是技术上合理的解释为什么没有进行优化 - 如果它意味着包括问题中的一个案例,“一般”似乎意味着。