答案是,不用说,
是的! 您绝对可以编写一个Java正则表达式模式来匹配
anbn。它使用了一个肯定的前瞻来进行断言,并且一个嵌套引用用于"计数"。
与其立即提供模式,本答案将引导读者通过
过程来推导出它。随着解决方案的逐步构建,给出了各种提示。在这方面,希望本答案不仅仅包含另一个整洁的正则表达式模式。希望读者也能学会如何"思考正则表达式",以及如何和谐地组合各种结构,以便他们将来可以自己推导出更多的模式。
开发解决方案所使用的语言将是PHP,因为它简洁明了。一旦确定了模式,最终测试将在Java中进行。
步骤1:先行断言
让我们从一个更简单的问题开始:我们想要匹配字符串开头的a+
,但仅当它紧接着跟着b+
时才匹配。我们可以使用^
来anchor我们的匹配,由于我们只想匹配没有b+
的a+
,所以我们可以使用lookahead断言(?=…)
。
这是我们的模式与简单的测试工具:
function testAll($r, $tests) {
foreach ($tests as $test) {
$isMatch = preg_match($r, $test, $groups);
$groupsJoined = join('|', $groups);
print("$test $isMatch $groupsJoined\n");
}
}
$tests = array('aaa', 'aaab', 'aaaxb', 'xaaab', 'b', 'abbb');
$r1 = '/^a+(?=b+)/';
testAll($r1, $tests);
输出结果为(
在ideone.com上看到):
aaa 0
aaab 1 aaa
aaaxb 0
xaaab 0
b 0
abbb 1 a
这正是我们想要的输出:只有当
a+
出现在字符串开头并且紧接着是
b+
时,才会匹配成功。
课程提示:您可以在环视中使用模式来进行断言。
步骤2:使用前瞻进行捕获(以及自由空格模式)
现在假设虽然我们不希望b+
成为匹配的一部分,但我们仍然想将其capture到第1组中。另外,由于我们预计会有更复杂的模式,因此让我们使用x
修饰符来实现free-spacing,这样我们可以使正则表达式更易读。
基于我们之前的PHP片段,我们现在有了以下模式:
$r2 = '/ ^ a+ (?= (b+) ) /x';
# │ └──┘ │
# │ 1 │
# └────────┘
# lookahead
testAll($r2, $tests);
现在的输出结果为(如在ideone.com上所见):
aaa 0
aaab 1 aaa|b
aaaxb 0
xaaab 0
b 0
abbb 1 a|bbb
请注意,例如
aaa|b
是将每个捕获组用
'|'
连接后的结果。在这种情况下,组0(即模式匹配的内容)捕获了
aaa
,而组1捕获了
b
。
课程:您可以在环视内捕捉。您可以使用自由空间来提高可读性。
第三步:将前瞻式断言重构为“循环”
在引入计数机制之前,我们需要对模式进行一些修改。目前,前瞻式断言在+
重复“循环”之外。这样做是可以的,因为我们只想断言在a+
后面有一个b+
,但是我们真正想做的是断言在“循环”中匹配到的每个a
都有一个相应的b
。
现在暂时不用担心计数机制,只需按照以下方式进行重构:
- 首先将
a+
重构为(?: a )+
(注意(?:…)
是非捕获组)
- 然后将前瞻式断言移至此非捕获组内
- 请注意,现在我们必须“跳过”
a*
才能“看到”b+
,因此必须相应地修改模式
现在我们有了以下内容:
$r3 = '/ ^ (?: a (?= a* (b+) ) )+ /x';
输出与以前相同(
如在ideone.com上所见),因此在这方面没有变化。重要的是,现在我们在
+
“循环”的每次迭代中进行断言。根据我们目前的模式,这不是必要的,但接下来我们将使用自我引用使组1“计数”为我们计数。
教训:你可以在非捕获组内进行捕获。回顾可以重复。
第四步:开始计数
我们要做的是:重写第1组,使其:
- 在第一次
+
迭代结束时,当第一个a
匹配时,它应该捕获b
- 在第二次迭代结束时,当另一个
a
匹配时,它应该捕获bb
- 在第三次迭代结束时,它应该捕获
bbb
- ...
- 在第n次迭代结束时,第1组应该捕获bn
- 如果没有足够的
b
可以捕获到第1组中,则断言将会失败
因此,现在第1组是(b+)
,需要将其重写为类似于(\1 b)
的东西。也就是说,我们尝试向第1组在上一次迭代中捕获的内容“添加”一个b
。
这里有一个小问题,即该模式缺少“基本情况”,即可以在没有自引用的情况下匹配的情况。需要一个基本情况,因为第1组开始“未初始化”;它还没有捕获任何内容(甚至是空字符串),因此自引用尝试将始终失败。
现在有许多方法可以解决这个问题,但是我们先只匹配自引用 optional,即使用\1?
。这种方法可能不完美,但是我们试一下看它的效果,如果出现任何问题,那就及时解决。同时进行一些更多的测试案例。
$tests = array(
'aaa', 'aaab', 'aaaxb', 'xaaab', 'b', 'abbb', 'aabb', 'aaabbbbb', 'aaaaabbb'
);
$r4 = '/ ^ (?: a (?= a* (\1? b) ) )+ /x';
输出现在是(
在ideone.com上看到):
aaa 0
aaab 1 aaa|b
aaaxb 0
xaaab 0
b 0
abbb 1 a|b
aabb 1 aa|bb
aaabbbbb 1 aaa|bbb
aaaaabbb 1 aaaaa|bb
啊哈!看起来我们离解决方案真的很近了!我们成功地让第1组使用自我引用进行了“计数”!但是等等……第二个和最后一个测试用例出现了问题!!b
不够了,而且还算错了!我们将在下一步中检查为什么会发生这种情况。
教训: 初始化自引用组的一种方法是使自引用匹配变成可选项。
第4½步:理解出错的原因
问题在于,由于我们将自我引用匹配设为可选项,当没有足够的b
时,“计数器”可能会“重置”回0。让我们仔细研究在输入aaaaabbb
时模式的每次迭代会发生什么。
a a a a a b b b
↑
_
a a a a a b b b
↑
___
a a a a a b b b
↑
_____
a a a a a b b b
↑
_
a a a a a b b b
↑
___
a a a a a b b b
↑
啊哈!在第四次迭代中,我们仍然可以匹配\1
,但我们无法匹配\1b
!由于我们允许自我参考匹配是可选的,使用\1?
,引擎回溯并选择“不需要”,这样我们就可以只匹配和捕获b
!
请注意,除了在第一次迭代之外,您始终可以匹配自我参考\1
。当然,这很明显,因为它是我们在上一次迭代中刚刚捕获的内容,在我们的设置中,我们总是可以再次匹配它(例如,如果我们上次捕获了bbb
,我们保证还会有bbb
,但这次可能会有bbbb
或者没有)。
教训:小心回溯。正则表达式引擎将尽可能多地回溯,直到给定的模式匹配为止。这可能会影响性能(即catastrophic backtracking)和/或正确性。
步骤5:自我掌控拯救!
“修复”现在应该很明显了:将可选重复与所有格量词结合使用。也就是说,不要简单地使用?
,而是改用?+
(请记住,以所有格量化的重复不会回溯,即使这样的“合作”可能导致整体模式匹配)。
非常非正式地讲,这就是?+
、?
和??
的含义:
?+
- (可选) “它不一定存在,”
- (所有格) “但如果它存在,你必须抓住它并且不能放开!”
?
??
在我们的设置中,第一次不会有
\1
,但之后任何时候都会有,并且我们总是想匹配它。因此,
\1?+
可以完美实现我们的目标。
$r5 = '/ ^ (?: a (?= a* (\1?+ b) ) )+ /x';
现在的输出结果为(
如ideone.com所示):
aaa 0
aaab 1 a|b # Yay! Fixed!
aaaxb 0
xaaab 0
b 0
abbb 1 a|b
aabb 1 aa|bb
aaabbbbb 1 aaa|bbb
aaaaabbb 1 aaa|bbb # Hurrahh!!!
看,问题解决了!我们现在可以准确地按照我们想要的方式进行计数!
教训:学会贪婪、懒惰和占有重复之间的区别。可选-占有可以是一个强大的组合。
第六步:收尾工作
现在我们有一个重复匹配a
的模式,并且对于每个匹配的a
,都有一个对应的b
被捕获在第1组中。当没有更多的a
时,或者当断言失败因为没有对应于a
的b
时,+
终止。
要完成任务,我们只需要将模式附加到\1 $
。这是对第1组匹配的反向引用,后跟行结束锚点。该锚点确保字符串中没有额外的b
,换句话说,实际上我们有anbn。
这是最终的模式,包括一个长度为10,000个字符的测试用例:
$tests = array(
'aaa', 'aaab', 'aaaxb', 'xaaab', 'b', 'abbb', 'aabb', 'aaabbbbb', 'aaaaabbb',
'', 'ab', 'abb', 'aab', 'aaaabb', 'aaabbb', 'bbbaaa', 'ababab', 'abc',
str_repeat('a', 5000).str_repeat('b', 5000)
);
$r6 = '/ ^ (?: a (?= a* (\1?+ b) ) )+ \1 $ /x';
它找到了4个匹配项:
ab
,
aabb
,
aaabbb
和
a5000b5000。在ideone.com上运行仅需要
0.06秒。
步骤7:Java测试
虽然这个模式在PHP中可用,但最终目标是编写一个在Java中可用的模式。
public static void main(String[] args) {
String aNbN = "(?x) (?: a (?= a* (\\1?+ b)) )+ \\1";
String[] tests = {
"",
"ab",
"abb",
"aab",
"aabb",
"abab",
"abc",
repeat('a', 5000) + repeat('b', 4999),
repeat('a', 5000) + repeat('b', 5000),
repeat('a', 5000) + repeat('b', 5001),
};
for (String test : tests) {
System.out.printf("[%s]%n %s%n%n", test, test.matches(aNbN));
}
}
static String repeat(char ch, int n) {
return new String(new char[n]).replace('\0', ch);
}
这个模式按预期运行(如在ideone.com上所见)。
现在我们来到结论...
需要指出的是,前瞻中的a*
,以及实际上的"主+
循环",都允许回溯。鼓励读者确认这不会影响正确性,同时也可以使两者都成为占有优势(尽管在同一模式中混合使用强制和非强制占有量词可能会导致误解)。
还应该说的是,虽然有一个正则表达式模式可以匹配anbn,但在实践中,这并不总是最好的解决方案。更好的解决方案是简单地匹配^(a+)(b+)$
,然后比较托管编程语言中组1和组2捕获的字符串的长度。
在PHP中,它可能看起来像这样(在ideone.com中看到):
function is_anbn($s) {
return (preg_match('/^(a+)(b+)$/', $s, $groups)) &&
(strlen($groups[1]) == strlen($groups[2]));
}
本文的目的并不是要说服读者正则表达式几乎可以做任何事情;显然它不能,即使对于它能做的事情,如果将至少部分委托给托管语言可以导致更简单的解决方案,则应该考虑这种方法。
如上所述,尽管此文章必须被标记为
[regex]
,但它可能涉及更多内容。当然,学习断言、嵌套引用、占有量词等方面是有价值的,但也许更重要的教训是创造性地解决问题的过程,当你面临各种限制时所需的决心和努力,从各个部分系统地组合以构建工作解决方案等。
奖励内容!PCRE递归模式!
既然我们提到了PHP,有必要说明PCRE支持递归模式和子程序。因此,以下模式适用于preg_match
(如在ideone.com上看到):
$rRecursive = '/ ^ (a (?1)? b) $ /x';
目前Java的正则表达式不支持递归模式。
更多奖励材料!匹配anbncn !!
我们已经看到了如何匹配非正则但仍为上下文无关的anbn,但我们还能否匹配甚至不是上下文无关的anbncn?
答案当然是YES!鼓励读者尝试自己解决此问题,但以下提供了解决方案(使用在ideone.com上的Java实现)。
^ (?: a (?= a* (\1?+ b) b* (\2?+ c) ) )+ \1 \2 $