【问题标题】:Regex vs brute-force for small strings正则表达式与小字符串的蛮力
【发布时间】:2016-10-23 18:26:33
【问题描述】:

在测试小字符串(例如 isPhoneNumber 或 isHexadecimal)时,使用正则表达式会带来性能优势,还是强制使用它们会更快?仅通过检查给定字符串的字符是否在指定范围内来强制使用它们不会比使用正则表达式更快吗?

例如:

public static boolean isHexadecimal(String value)
{
    if (value.startsWith("-"))
    {
        value = value.substring(1);
    }

    value = value.toLowerCase();

    if (value.length() <= 2 || !value.startsWith("0x"))
    {
        return false;
    }

    for (int i = 2; i < value.length(); i++)
    {
        char c = value.charAt(i);

        if (!(c >= '0' && c <= '9' || c >= 'a' && c <= 'f'))
        {
            return false;
        }
    }

    return true;
}

对比

Regex.match(/0x[0-9a-f]+/, "0x123fa") // returns true if regex matches whole given expression

似乎有一些与正则表达式相关的开销,即使模式是预编译的,只是因为正则表达式必须在许多一般情况下工作。相比之下,蛮力方法完全符合要求,仅此而已。我错过了正则表达式的一些优化吗?

【问题讨论】:

  • 即使有性能优势,我也更愿意看到正则表达式而不是解析代码。
  • 当您必须针对单个正则表达式在“纯代码”中平衡多个测试时,特别是对于解释语言,这个问题并没有真正的答案。大多数时候,使用编译语言,“纯代码”方式更快(但这一切都取决于您需要测试的内容)
  • “蛮力”听起来您想针对所有可能的有效字符串测试输入。 “检查给定字符串的字符是否在指定范围内”正是像/^[range]*$/ 这样的正则表达式所做的。
  • 最重要的是,您的两种解决方案完全不同。与您的isHexadecimal 方法等效的正则表达式将是/-?0x[0-9a-f]*/i(尽管我相信您实际上想要/-0x[0-9a-fA-F]*/)。
  • 我认为您必须使用“蛮力”以外的其他术语。据我了解,您在问“纯代码”是否比正则表达式更快。 “蛮力”是一种特殊的方式,包括测试解决问题的所有可能性(这是一种愚蠢的方式,但它用于例如破解密码)。无论替代方案如何,“蛮力”是最慢且切割器较少的算法。如果你想得到相关的答案,你应该改写你的问题。

标签: regex string performance brute-force


【解决方案1】:

检查字符串字符是否在一定范围内正是构建正则表达式的目的。他们将表达式转换为一系列原子指令;他们实际上是在写出您的手动解析步骤,但级别较低。

正则表达式的缓慢之处在于将表达式转换为指令。当多次使用正则表达式时,您可以看到真正的性能提升。那时您可以提前编译表达式,然后简单地将生成的编译指令应用于匹配、搜索、替换等。

与任何与性能有关的情况一样,执行一些测试并测量结果

【讨论】:

    【解决方案2】:

    我写了一个小基准来估计以下性能:

    • NOP 方法(了解基线迭代速度);
    • OP 提供的原始方法;
    • 正则表达式;
    • 编译正则表达式;
    • @maraca 提供的版本(没有 toLowerCasesubstring);
    • “fastIsHex”版本(基于switch),我添加只是为了好玩。

    测试机配置如下:

    • JVM:Java(TM) SE 运行时环境(内部版本 1.8.0_101-b13)
    • CPU:Intel(R) Core(TM) i5-2500 CPU @ 3.30GHz

    这是我对原始测试字符串 "0x123fa" 和 10.000.000 次迭代得到的结果:

    Method "NOP" => #10000000 iterations in 9ms
    Method "isHexadecimal (OP)" => #10000000 iterations in 300ms
    Method "RegExp" => #10000000 iterations in 4270ms
    Method "RegExp (Compiled)" => #10000000 iterations in 1025ms
    Method "isHexadecimal (maraca)" => #10000000 iterations in 135ms
    Method "fastIsHex" => #10000000 iterations in 107ms
    

    正如您所见,即使是 OP 的原始方法也比 RegExp 方法更快(至少在使用 JDK 提供的 RegExp 实现时)。

    (供您参考)

    基准代码:

    public static void main(String[] argv) throws Exception {
        //Number of ITERATIONS
        final int ITERATIONS = 10000000;
    
        //NOP
        benchmark(ITERATIONS,"NOP",() -> nop(longHexText));
    
        //isHexadecimal
        benchmark(ITERATIONS,"isHexadecimal (OP)",() -> isHexadecimal(longHexText));
    
        //Un-compiled regexp
        benchmark(ITERATIONS,"RegExp",() -> longHexText.matches("0x[0-9a-fA-F]+"));
    
        //Pre-compiled regexp
        final Pattern pattern = Pattern.compile("0x[0-9a-fA-F]+");
        benchmark(ITERATIONS,"RegExp (Compiled)", () -> {
            pattern.matcher(longHexText).matches();
        });
    
        //isHexadecimal (maraca)
        benchmark(ITERATIONS,"isHexadecimal (maraca)",() -> isHexadecimalMaraca(longHexText));
    
        //FastIsHex
        benchmark(ITERATIONS,"fastIsHex",() -> fastIsHex(longHexText));
    }
    
    public static void benchmark(int iterations,String name,Runnable block) {
        //Start Time
        long stime = System.currentTimeMillis();
    
        //Benchmark
        for(int i = 0; i < iterations; i++) {
            block.run();
        }
    
        //Done
        System.out.println(
            String.format("Method \"%s\" => #%d iterations in %dms",name,iterations,(System.currentTimeMillis()-stime))
        );
    }
    

    NOP 方法:

    public static boolean nop(String value) { return true; }
    

    fastIsHex 方法:

    public static boolean fastIsHex(String value) {
    
        //Value must be at least 4 characters long (0x00)
        if(value.length() < 4) {
            return false;
        }
    
        //Compute where the data starts
        int start = ((value.charAt(0) == '-') ? 1 : 0) + 2;
    
        //Check prefix
        if(value.charAt(start-2) != '0' || value.charAt(start-1) != 'x') {
            return false;
        }
    
        //Verify data
        for(int i = start; i < value.length(); i++) {
            switch(value.charAt(i)) {
                case '0':case '1':case '2':case '3':case '4':case '5':case '6':case '7':case '8':case '9':
                case 'a':case 'b':case 'c':case 'd':case 'e':case 'f':
                case 'A':case 'B':case 'C':case 'D':case 'E':case 'F':
                    continue;
    
                default:
                    return false;
            }
        }
    
        return true;
    }
    

    所以,答案是否定的,对于短字符串和手头的任务,RegExp 并不快。

    当谈到更长的字符串时,平衡是完全不同的, 以下是我生成的 8192 长十六进制字符串的结果:

    hexdump -n 8196 -v -e '/1 "%02X"' /dev/urandom
    

    和 10.000 次迭代:

    Method "NOP" => #10000 iterations in 2ms
    Method "isHexadecimal (OP)" => #10000 iterations in 1512ms
    Method "RegExp" => #10000 iterations in 1303ms
    Method "RegExp (Compiled)" => #10000 iterations in 1263ms
    Method "isHexadecimal (maraca)" => #10000 iterations in 553ms
    Method "fastIsHex" => #10000 iterations in 530ms
    

    如您所见,手写方法(由 macara 和我的 fastIsHex 编写的方法)仍然胜过 RegExp,但原始方法没有, (由于 substring()toLowerCase())。

    旁注:

    这个基准测试确实非常简单,只测试“最坏情况”场景(即完全有效的字符串),现实生活中的结果,混合数据长度和非 0 有效 - 无效比率,可能完全不同.

    更新:

    我也尝试了char[]数组版本:

     char[] chars = value.toCharArray();
     for (idx += 2; idx < chars.length; idx++) { ... }
    

    它甚至比 getCharAt(i) 版本慢了一点:

      Method "isHexadecimal (maraca) char[] array version" => #10000000 iterations in 194ms
      Method "fastIsHex, char[] array version" => #10000000 iterations in 164ms
    

    我的猜测是由于 toCharArray 中的数组复制。

    更新(#2):

    我已经运行了额外的 8k/100.000 次迭代测试,以查看“maraca”和“fastIsHex”方法之间的速度是否存在任何真正的差异,并且还对它们进行了规范化以使用完全相同的前置条件代码:

    运行#1

    Method "isHexadecimal (maraca) *normalized" => #100000 iterations in 5341ms
    Method "fastIsHex" => #100000 iterations in 5313ms
    

    运行 #2

    Method "isHexadecimal (maraca) *normalized" => #100000 iterations in 5313ms
    Method "fastIsHex" => #100000 iterations in 5334ms
    

    即这两种方法之间的速度差异充其量是微不足道的,并且可能是由于测量错误(因为我在我的工作站上运行它而不是专门设置的干净测试环境)。

    【讨论】:

    • 非常好,您是否还检查了先将 String 转换为 charArray 的版本?如果 .length().charAt() 调用效率不高,那可能是值得的。
    • 顺便说一句。要真正将您的版本与我的版本进行比较,您必须在我的代码中删除对一元加号的检查或将其添加到您的代码中。开关更快似乎很奇怪,因为在我看来这与 c == '1' || 相同c == '2' || ... || c == 'F' 平均来说是更多的检查。
    • AFAIK Java 将为 switch 语句生成不同的字节码,请参阅:stackoverflow.com/questions/767821/…stackoverflow.com/questions/10287700/…。 (并且不会做布尔'||')。但在这种情况下,我认为这两种方法几乎相当,差异在误差范围内(也许即使由于额外的'+'检查,我也会尝试一下,一旦我有一些时间)。
    • 我使用您的代码测试了一些实现 - 但我无法解释为什么使用 brics 编译的正则表达式如此之快。也许 jit 编译器有一些魔力。您可以考虑将此基准转换为 jmh 微型基准。
    【解决方案3】:

    解决问题的蛮力方法是系统地测试所有组合。这不是你的情况。

    可以从手写程序中获得更好的性能。如果您事先知道,您可以利用数据分发。或者,您可以制作一些适用于您的案例的巧妙捷径。但确实不能保证你写的东西会自动比正则表达式更快。正则表达式的实现也得到了优化,你很容易得到比这更糟糕的代码。

    您问题中的代码确实没有什么特别之处,而且很可能与正则表达式相当。当我测试它时,没有明显的赢家,有时一个更快,有时另一个,差异很小。你的时间是有限的,明智地考虑你把时间花在哪里。

    【讨论】:

      【解决方案4】:

      您误用了“蛮力”一词。更好的术语是 ad hoc 自定义匹配。

      Regex 解释器通常比自定义模式匹配器慢。正则表达式被编译成字节码,编译需要时间。即使忽略编译(如果您只编译一次并匹配一个非常长的字符串和/或多次编译可能没问题,因此编译成本并不重要),在匹配解释器中花费的机器指令是自定义匹配器没有的开销有。

      在正则表达式匹配器胜出的情况下,通常正则表达式引擎是用非常快的本机代码实现的,而自定义匹配器是用较慢的代码编写的。

      现在您可以将正则表达式编译为本机代码,其运行速度与完善的自定义匹配器一样快。这是例如的方法lex/flex 等。但最常见的库或内置语言不采用这种方法(Java、Python、Perl 等)。他们使用口译员。

      原生代码生成库往往使用起来很麻烦,除非在 C/C++ 中它们已经流行了几十年。

      在其他语言中,我是状态机的粉丝。对我来说,它们比正则表达式或自定义匹配器更容易理解和正确。以下是针对您的问题的一个。状态 0 是起始状态,D 代表十六进制数字。

      机器的实施可以非常快。在 Java 中,它可能看起来像这样:

      static boolean isHex(String s) {
        int state = 0;
        for (int i = 0; i < s.length(); i++) {
          char c = s.charAt(i);
          switch (state) {
            case 0:
              if (c == '-') state = 1;
              else if (c == '0') state = 2;
              else return false;
              break;
            case 1:
              if (c == '0') state = 2;
              else return false;
              break;
            case 2:
              if (c == 'x') state = 3;
              else return false;
              break;
            case 3:
              if (isHexDigit(c)) state = 4;
              else return false;
              break;
            case 4:
              if (isHexDigit(c)) ; // state already = 4
              else return false;
              break;
          }
        }
        return true;
      }
      
      static boolean isHexDigit(char c) {
        return '0' <= c && c <= '9' || 'A' <= c && c <= 'F' || 'a' <= c && c <= 'f';
      }
      

      代码不是很短,但它是图表的直接翻译。除了简单的印刷错误,没有什么可以搞砸的。

      在 C 中,您可以将状态实现为 goto 标签:

      int isHex(char *s) {
        char c;
        s0:
          c = *s++;
          if (c == '-') goto s1;
          if (c == '0') goto s2;
          return 0;
        s1:
          c = *s++;
          if (c == '0') goto s2;
          return 0;
        s2:
          c = *s++;
          if (c == 'x') goto s3;
          return 0;
        s3:
          c = *s++;
          if (isxdigit(c)) goto s4;
          return 0;
        s4: 
          c = *s++;
          if (isxdigit(c)) goto s4;
          if (c == '\0') return 1;
          return 0;
      }
      

      这种用C 编写的goto 匹配器通常是我见过的最快的。在我使用旧 gcc (4.6.4) 的 MacBook 上,这个只能编译成 35 条机器指令。

      【讨论】:

      • 我同意——理论上你是对的。然而实践(见我的帖子)表明,Java 中高效的正则表达式实现可以胜过你的 Java 实现(对我来说很难相信,也许基准测试中有错误,但我找不到)。
      • @CoronA 你应该阅读stackoverflow.com/questions/504103/…。你的时代真的不可信。还有oracle.com/technetwork/articles/java/…
      • ... 并且 jmh 基准测试显示了相同的结果,令人惊讶。如果需要,我可以在 github 上发布 sn-ps 以更好地重现这些结果。
      • @Gene,IMO 一个同时处理前缀 (0x00) 和十六进制字符串主体的奇异自动机,在这种情况下可能不是最佳选择,因为检查状态会受到惩罚,对于每个输入的十六进制数字,尽管状态不会再改变,一旦你超过了前缀。您的 C 实现有效地解决了这个问题,通过使用 GOTO 在 D 状态内循环,但在 Java 中,单独循环遍历“body”应该更有效。
      • @zeppelin 你说得对,Java 不承认最快的状态机实现。只是相当快。如果您愿意以空间换取速度,可以使用矩形数组 char x state -> state 来恢复一些速度。
      【解决方案5】:

      通常哪个更好取决于您的目标。如果可读性是主要目标(应该是什么,除非您检测到性能问题),那么正则表达式就可以了。

      如果性能是您的目标,那么您必须首先分析问题。例如。如果您知道它是电话号码或十六进制数字(仅此而已),那么问题就会变得简单得多。

      现在让我们看看你的函数(性能方面)检测十六进制数:

      1. 获取子字符串不好(通常是创建一个新对象),最好使用索引并推进它。
      2. 与其使用 toLower(),不如将其与大小写字母进行比较(字符串仅迭代一次,不会执行多余的替换,也不会创建新对象)。

      所以性能优化的版本可能看起来像这样(您可以通过使用 charArray 而不是字符串来进一步优化):

      public static final boolean isHexadecimal(String value) {
        if (value.length() < 3)
          return false;
        int idx;
        if (value.charAt(0) == '-' || value.charAt(0) == '+') { // also supports unary plus
          if (value.length() < 4) // necessairy because -0x and +0x are not valid
            return false;
          idx = 1;
        } else {
          idx = 0;
        }
        if (value.chartAt(idx) != '0' || value.charAt(idx + 1) != 'x')
          return false;
        for (idx += 2; idx < value.length(); idx++) {
          char c = value.charAt(idx);
          if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
            return false;
        }
        return true;
      }
      

      【讨论】:

        【解决方案6】:

        Well implemented 正则表达式可以比相同模式的天真蛮力实现更快。 另一方面,您始终可以针对特定情况实施更快的解决方案。 同样如上文所述,大多数流行语言的实现效率不高(在某些情况下)。

        只有在性能是绝对优先事项并且经过广泛的测试和分析时,我才会实施自己的解决方案。

        【讨论】:

        • 要参考的文档指出,良好实现的正则表达式确实可以扩展(与 Java、C#、Perl、PHP、Python ... 提出的那些相反)。在实践中,可能没有任何正则表达式实现优于已经提到的手工编码实现
        【解决方案7】:

        要获得比简单的手工编码验证器更好的性能,您可以使用基于确定性自动机的正则表达式库,例如Brics Automaton

        我写了一个简短的 jmh 基准测试:

        @State(Scope.Thread)
        public abstract class MatcherBenchmark {
        
           private String longHexText;
        
           @Setup
           public void setup() {
             initPattern("0x[0-9a-fA-F]+");
             this.longHexText = "0x123fa";
           }
        
           public abstract void initPattern(String pattern);
        
           @Benchmark
           @BenchmarkMode(Mode.AverageTime)
           @OutputTimeUnit(TimeUnit.MICROSECONDS)
           @Warmup(iterations = 10)
           @Measurement(iterations = 10)
           @Fork(1)
           public void benchmark() {
             boolean result =  benchmark(longHexText);
             if (!result) {
                throw new RuntimeException();
             }
           }
        
           public abstract boolean benchmark(String text);
        
           @TearDown
           public void tearDown() {
             donePattern();
             this.longHexText = null;
           }
        
           public abstract void donePattern();
        
        }
        

        并通过以下方式实现它:

        @Override
        public void initPattern(String pattern) {
            RegExp r = new RegExp(pattern);
            this.automaton = new RunAutomaton(r.toAutomaton(true));
        }
        
        @Override
        public boolean benchmark(String text) {
            return automaton.run(text);
        }
        

        我还为 Zeppelins、Genes 和已编译的 java.util.Regex 解决方案创建了基准测试,以及一个带有 rexlex 的解决方案。这些是我机器上 jmh 基准测试的结果:

        BricsMatcherBenchmark.benchmark      avgt   10  0,014 �  0,001  us/op
        GenesMatcherBenchmark.benchmark      avgt   10  0,017 �  0,001  us/op
        JavaRegexMatcherBenchmark.benchmark  avgt   10  0,097 �  0,005  us/op
        RexlexMatcherBenchmark.benchmark     avgt   10  0,061 �  0,002  us/op
        ZeppelinsBenchmark.benchmark         avgt   10  0,008 �  0,001  us/op
        

        使用非十六进制数字 0x123fax 开始相同的基准测试会产生以下结果(注意:我在 benchmark 中为该基准测试反转了验证)

        BricsMatcherBenchmark.benchmark      avgt   10  0,015 �  0,001  us/op
        GenesMatcherBenchmark.benchmark      avgt   10  0,019 �  0,001  us/op
        JavaRegexMatcherBenchmark.benchmark  avgt   10  0,102 �  0,001  us/op
        RexlexMatcherBenchmark.benchmark     avgt   10  0,052 �  0,002  us/op
        ZeppelinsBenchmark.benchmark         avgt   10  0,009 �  0,001  us/op
        

        【讨论】:

          【解决方案8】:

          Regex 有很多优势,但 Regex 仍然存在性能问题。

          【讨论】:

            猜你喜欢
            • 2021-10-09
            • 2015-07-13
            • 2017-01-29
            • 2012-08-07
            • 2011-04-06
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多