如何匹配a^n b^n?

108
这是一系列教育性正则表达式文章的第二部分。它展示了如何使用前瞻和嵌套引用来匹配非正则语言anbn。嵌套引用首先在How does this regex find triangular numbers?中介绍。
其中一个典型的非正则语言是:
L = { anbn: n > 0 }
这是由若干个'a'后面跟着相同数量的'b'组成的所有非空字符串的语言。其中一些例子为'ab','aabb','aaabbb'。
这种语言可以通过pumping lemma证明是非正则的。实际上,它是典型的上下文无关语言,可以由上下文无关文法S → aSb | ab生成。
然而,现代正则表达式实现显然识别的不仅是正则语言。也就是说,它们不符合形式语言理论的定义。PCRE和Perl支持递归正则表达式,.NET支持平衡组定义。甚至更“简单”的功能,例如反向引用匹配,也意味着正则表达式不是正则的。
但是,这个“基本”功能有多强大呢?我们能用Java正则表达式识别L吗?例如,我们能否组合先行断言和嵌套引用,并拥有一个适用于 String.matches 的模式,以匹配像abaabbaaabbb等字符串?

参考资料

相关问题


5
这个系列文章是在社区的一些人同意下开始的(http://meta.stackexchange.com/questions/62695/permission-to-start-a-series-of-advanced-regex-articles)。如果反响不错,我打算继续介绍其他更高级和基础的正则表达式特性。 - polygenelubricants
第三部分:https://dev59.com/0HA65IYBdhLWcg3wuhIR - polygenelubricants
1
哇,我从未意识到Java的正则表达式不仅限于普通表达式。我猜这就解释了为什么我一直认为它们没有完全实现。我的意思是,在 Java Regex 中没有内置补集、差集或积集操作符,但这是有道理的,因为它们并不局限于正则语言。 - Lan
此问题已添加到Stack Overflow正则表达式FAQ,位于“高级Regex-Fu”下。 - aliteralmind
3个回答

154
答案是,不用说,是的! 您绝对可以编写一个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+)/';
#          └────┘
#         lookahead

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';
#          │     │      └──┘ │ │
#          │     │        1  │ │
#          │     └───────────┘ │
#          │       lookahead   │
#          └───────────────────┘
#           non-capturing group

输出与以前相同(如在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';
#          │     │      └─────┘ | │
#          │     │         1    | │
#          │     └──────────────┘ │
#          │         lookahead    │
#          └──────────────────────┘
#             non-capturing group

输出现在是(在ideone.com上看到):
aaa 0
aaab 1 aaa|b        # (*gasp!*)
aaaxb 0
xaaab 0
b 0
abbb 1 a|b          # yes!
aabb 1 aa|bb        # YES!!
aaabbbbb 1 aaa|bbb  # YESS!!!
aaaaabbb 1 aaaaa|bb # NOOOOOoooooo....

啊哈!看起来我们离解决方案真的很近了!我们成功地让第1组使用自我引用进行了“计数”!但是等等……第二个和最后一个测试用例出现了问题!!b不够了,而且还算错了!我们将在下一步中检查为什么会发生这种情况。

教训: 初始化自引用组的一种方法是使自引用匹配变成可选项。


第4½步:理解出错的原因

问题在于,由于我们将自我引用匹配设为可选项,当没有足够的b时,“计数器”可能会“重置”回0。让我们仔细研究在输入aaaaabbb时模式的每次迭代会发生什么。

 a a a a a b b b

# Initial state: Group 1 is "uninitialized".
           _
 a a a a a b b b
  
  # 1st iteration: Group 1 couldn't match \1 since it was "uninitialized",
  #                  so it matched and captured just b
           ___
 a a a a a b b b
    
    # 2nd iteration: Group 1 matched \1b and captured bb
           _____
 a a a a a b b b
      
      # 3rd iteration: Group 1 matched \1b and captured bbb
           _
 a a a a a b b b
        
        # 4th iteration: Group 1 could still match \1, but not \1b,
        #  (!!!)           so it matched and captured just b
           ___
 a a a a a b b b
          
          # 5th iteration: Group 1 matched \1b and captured bb
          #
          # No more a, + "loop" terminates

啊哈!在第四次迭代中,我们仍然可以匹配\1,但我们无法匹配\1b!由于我们允许自我参考匹配是可选的,使用\1?,引擎回溯并选择“不需要”,这样我们就可以只匹配和捕获b

请注意,除了在第一次迭代之外,您始终可以匹配自我参考\1。当然,这很明显,因为它是我们在上一次迭代中刚刚捕获的内容,在我们的设置中,我们总是可以再次匹配它(例如,如果我们上次捕获了bbb,我们保证还会有bbb,但这次可能会有bbbb或者没有)。

教训:小心回溯。正则表达式引擎将尽可能多地回溯,直到给定的模式匹配为止。这可能会影响性能(即catastrophic backtracking)和/或正确性。


步骤5:自我掌控拯救!

“修复”现在应该很明显了:将可选重复与所有格量词结合使用。也就是说,不要简单地使用?,而是改用?+(请记住,以所有格量化的重复不会回溯,即使这样的“合作”可能导致整体模式匹配)。

非常非正式地讲,这就是?+???的含义:

?+

  • (可选) “它不一定存在,”
    • (所有格) “但如果它存在,你必须抓住它并且不能放开!”

?

  • (可选) “它不一定存在,”
    • (贪婪) “但如果它存在,你可以暂时抓住它,”
      • (回溯) “但你可能会被要求稍后放开它!”

??

  • (可选) “它不一定存在,”
    • (勉强) “即使它存在,你也不必立即抓住它,”
      • (回溯) “但你可能会被要求稍后抓住它!”
在我们的设置中,第一次不会有\1,但之后任何时候都会有,并且我们总是想匹配它。因此,\1?+可以完美实现我们的目标。
$r5 = '/ ^ (?: a (?= a* (\1?+ b) ) )+ /x';
#          │     │      └──────┘ │ │
#          │     │          1    │ │
#          │     └───────────────┘ │
#          │         lookahead     │
#          └───────────────────────┘
#             non-capturing group

现在的输出结果为(如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时,或者当断言失败因为没有对应于ab时,+终止。

要完成任务,我们只需要将模式附加到\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';
#          │     │      └──────┘ │ │
#          │     │          1    │ │
#          │     └───────────────┘ │
#          │         lookahead     │
#          └───────────────────────┘
#             non-capturing group

它找到了4个匹配项:abaabbaaabbba5000b5000。在ideone.com上运行仅需要0.06秒

步骤7:Java测试

虽然这个模式在PHP中可用,但最终目标是编写一个在Java中可用的模式。

public static void main(String[] args) {
 
        String aNbN = "(?x) (?:  a  (?= a* (\\1?+ b))  )+ \\1";
        String[] tests = {
                "",      // false
                "ab",    // true
                "abb",   // false
                "aab",   // false
                "aabb",  // true
                "abab",  // false
                "abc",   // false
                repeat('a', 5000) + repeat('b', 4999), // false
                repeat('a', 5000) + repeat('b', 5000), // true
                repeat('a', 5000) + repeat('b', 5001), // false
        };
        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 $


6
请高亮小文字并复制粘贴到其他地方。这是有意为之让人难以阅读的:它是一个剧透,是奖励谜题的解决方案。 - polygenelubricants
8
+1:非常好的解释,这些"高级文章"是非常出色的想法。 - Callum Rogers
1
@LarsH PHP的 preg_match()PCRE 的一个例子。Java正则表达式似乎基于较早版本的Perl正则表达式。这意味着PHP正则表达式比Java中的版本更强大。截至 2013-02-21pcre.txt 表示它 与Perl 5.12大致对应。虽然Perl目前已经升级到 5.16, 距离5.18还有几个月。(实际上在这段时间内没有多少内容被添加到正则表达式中) - Brad Gilbert
由于回顾(lookaround)不影响正则表达式可匹配的语言,所以这是否可以写成一个没有回顾(lookarounds)的正则表达式呢? - gatinueta
哇塞... . :o - Christoffer Bubach
显示剩余5条评论

22

鉴于没有提到PCRE支持递归模式,我想指出PCRE中描述所讨论语言的最简单和最有效的示例:

/^(a(?1)?b)$/

+1 哇,我不知道 PCRE 支持递归模式(我还在学习!每天都有新发现!)。我已经修改了文章以适应这个信息。虽然我不认为递归模式可以匹配 a^n b^n c^n - polygenelubricants
需要注意的是,这个选项更简单,但不如发布的答案好——递归在长字符串上会溢出。 - Kobi
@Kobi 这取决于你对“好”的定义。例如,递归解决方案比另一个解决方案快一个数量级(http://codepad.viper-7.com/CWgy7c)。而且它更容易理解。递归解决方案基本上是将语法直接转换为正则表达式(实际上,你可以只用语法形式编写它,它也能工作)。 - NikiC
1
@polygeniclubricants,您可以使用两个递归模式来匹配该模式,一个用于消耗ab而不捕获(并验证递归中是否有相同数量),然后是一个捕获正则表达式,贪婪地消耗所有的a,然后应用递归模式来消耗和验证是否有相同数量的bc。 正则表达式为:/^(?=(a(?-1)?b)c)a+(b(?-1)?c)$/x。 来源:http://nikic.github.io/2012/06/15/The-true-power-of-regular-expressions.html - Josh Reback

15

如问题中所提到的——使用 .NET 平衡组,可以轻松匹配类型为 anbncndn…zn 的模式。

^
  (?<A>a)+
  (?<B-A>b)+  (?(A)(?!))
  (?<C-B>c)+  (?(B)(?!))
  ...
  (?<Z-Y>z)+  (?(Y)(?!))
$
例如:http://www.ideone.com/usuOE
编辑: 还有一个带有递归模式的广义语言的PCRE模式,但需要使用前向查找。我认为这不是上述内容的直接翻译。
^
  (?=(a(?-1)?b))  a+
  (?=(b(?-1)?c))  b+
  ...
  (?=(x(?-1)?y))  x+
     (y(?-1)?z)
$

例如:http://www.ideone.com/9gUwF


1
@poly:谢谢 :). 实际上我不熟悉.NET模式,但对于这种模式,使用平衡组非常容易,因此我补充了这个答案。 - kennytm
你可以用递归模式来实现这个吗?因为如果不能,那就是一个有趣的转折,平衡组可以做递归模式无法做到的事情。(是的,我非常感激这个补充)。 - polygenelubricants
顺便说一下,我忽略了.NET解决方案的原因是因为我未来有计划写一篇关于“如何使用.NET正则表达式匹配a^n b^n?”的文章,但如果你愿意,你也可以写。我不是只为自己写这些文章;我也希望鼓励其他人也这样做,以在网站上拥有优质内容。 - polygenelubricants
如果您找到了使用递归模式的方法,请更新。我尝试使用平衡组来捕获长度构成斐波那契数列的单词,但无法使其正常工作。使用类似于我所做的环视可能是可能的。 - Kobi
有人需要编写一种幽默的 RE 语言,其中包含像 a(?!#@&*b) 这样的表达式... 它的翻译是,“a 怎么会在那里?应该是个 b!” - LarsH
1
我想指出这个模式的PCRE版本有点缺陷,因为它会匹配下一个字符块比前一个字符块更长的情况。请参见此处:https://regex101.com/r/sdlRTm/1 您需要在捕获组后添加(?!b)(?!c)等内容,如下所示:https://regex101.com/r/sdlRTm/2 - jaytea

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接