我不了解.NET,因为我没有详细研究过它的源代码。
但是,在 Java,尤其是 Oracle/Sun 实现中,我可以说这很可能是由于循环结构开销。
在这个答案中,每当我提到 Java 中正则表达式的实现时,我指的是 Oracle/Sun 实现。其他的实现我还没有研究过,所以真的不能说什么。
贪婪量词
我只是意识到这部分与问题无关。不过这里介绍了贪心量词是如何实现的,这里就不说了。
给定一个原子A 和一个贪婪量词A*(重复的次数在这里并不重要),贪婪量词将尝试匹配尽可能多的A,然后尝试续集(无论在A* 之后出现),失败时,一次回溯一个重复,然后重试续集。
问题是回溯到哪里。仅仅为了找出位置而重新匹配整个事物是非常低效的,因此我们需要为每次重复存储重复完成匹配的位置。重复的次数越多,保存到目前为止所有状态以进行回溯所需的内存就越多,更不用说捕获组(如果有的话)。
按照问题中的方式展开正则表达式并不能逃避上述内存要求。
固定长度原子的简单案例
但是,对于[^@]*这样的简单情况,你知道原子A(在这种情况下为[^@])只能匹配固定长度的字符串(长度为1),只能匹配最后一个位置和长度match 是有效执行匹配所必需的。 Java 的实现包括一个study 方法来检测像这样的固定长度模式以编译成循环实现(Pattern.Curly 和Pattern.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 次的累积时间。
我没有具体的数字要发布,因为我发现自己的结果相当不稳定。但是,根据我的测试,我通常发现第一个正则表达式比第二个正则表达式快。
通过生成 500 个字符串,每个字符串最多可以有 40000 个字符长,我发现当输入导致它们在不到 10 秒的时间内运行时,2 个正则表达式之间的差异更加突出(大约 1 到 2 秒)。当输入导致它们运行更长时间(40 多秒)时,两个正则表达式的运行时间大致相同,最多相差几百毫秒。