106

这是一系列教育正则表达式文章的第二部分。它显示了如何使用前瞻和嵌套引用来匹配非常规语言 a n b n。嵌套引用首先在:这个正则表达式如何找到三角数?

一种原型非正则语言是:

L = { an bn: n > 0 }

这是所有非空字符串的语言,由一定数量的a' 后跟相等数量的b' 组成。这种语言中的字符串示例有ab, aabb, aaabbb.

泵引理可以证明这种语言是非常规的。它实际上是一种原型上下文无关语言,可以通过上下文无关文法 S → aSb | ab生成。

尽管如此,现代正则表达式实现清楚地识别的不仅仅是常规语言。也就是说,它们不是形式语言理论定义的“常规”。PCRE 和 Perl 支持递归正则表达式,.NET 支持平衡组定义。更不“花哨”的功能,例如反向引用匹配,意味着正则表达式不是正则表达式。

但是这个“基本”功能到底有多强大?例如,我们可以L用 Java 正则表达式识别吗?我们是否可以将环视和嵌套引用结合起来,并使用一种模式String.matches来匹配诸如ab, aabb,aaabbb等之类的字符串?

参考

相关问题

4

3 回答 3

151

答案是,不用说,是的!您当然可以编写一个 Java 正则表达式模式来匹配a n b n。它对断言使用肯定的前瞻,对“计数”使用一个嵌套引用。

这个答案不是立即给出模式,而是引导读者完成推导它的过程。随着解决方案的缓慢构建,给出了各种提示。在这方面,希望这个答案不仅仅包含另一个简洁的正则表达式模式。希望读者也能学习如何“用正则表达式思考”,如何将各种构造和谐地组合在一起,以便他们将来可以自己推导出更多的模式。

用于开发解决方案的语言将是 PHP,因为它简洁。模式最终确定后的最终测试将在 Java 中完成。


第 1 步:前瞻断言

让我们从一个更简单的问题开始:我们想要匹配a+字符串的开头,但前提是它后面紧跟b+. 我们可以使用^锚定我们的匹配,并且由于我们只想匹配a+没有 的b+,我们可以使用前瞻断言(?=…)

这是我们的带有简单测试工具的模式:

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+成为比赛的一部分,但我们确实希望将其捕获到第 1 组中。另外,由于我们预计会有更复杂的模式,让我们使用x修饰符来自由间距,所以我们可以使我们的正则表达式更具可读性。

基于我们之前的 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

请注意, egaaa|bjoin-ing 每个组使用 捕获的结果'|'。在这种情况下,第 0 组(即模式匹配的内容)被捕获aaa,第 1 组被捕获b

课程:您可以在环视中捕捉。您可以使用自由间距来增强可读性。


第 3 步:将前瞻重构为“循环”

在我们介绍我们的计数机制之前,我们需要对我们的模式进行一次修改。目前,前瞻在+重复“循环”之外。到目前为止这很好,因为我们只是想断言有一个b+跟随 our a+,但我们最终真正想做的是断言对于a我们在“循环”中匹配的每个,都有一个对应b的与之对应。

我们暂时不用担心计数机制,只需按如下方式进行重构:

  • 首先重构a+(?: a )+(注意(?:…)是非捕获组)
  • 然后在这个非捕获组内移动前瞻
    • 请注意,我们现在必须“跳过”a*才能“看到” b+,因此相应地修改模式

所以我们现在有以下内容:

$r3 = '/ ^ (?: a (?= a* (b+) ) )+ /x';
#          │     │      └──┘ │ │
#          │     │        1  │ │
#          │     └───────────┘ │
#          │       lookahead   │
#          └───────────────────┘
#           non-capturing group

输出与以前相同(如在 ideone.com 上所见),因此在这方面没有变化。重要的是,现在我们在“循环”的每次迭代中都做出断言。+对于我们当前的模式,这不是必需的,但接下来我们将使用自引用为我们“计数”第 1 组。

课程:您可以在非捕获组内捕获。可以重复环顾四周。


第 4 步:这是我们开始计数的步骤

下面是我们要做的:我们将重写第 1 组,这样:

  • 在 的第一次迭代结束时+,当第一次a匹配时,它应该捕获b
  • 在第二次迭代结束时,当另一个a匹配时,它应该捕获bb
  • 在第三次迭代结束时,它应该捕获bbb
  • ...
  • 在第n次迭代结束时,第 1 组应捕获b n
  • 如果没有足够b的数据捕获到第 1 组,那么断言就会失败

因此,现在(b+)的第 1 组将不得不重写为类似(\1 b). 也就是说,我们尝试将 a “添加”b到上一次迭代中捕获的组 1 中。

这里有一个小问题,这个模式缺少“基本情况”,即它可以在没有自引用的情况下匹配的情况。需要一个基本情况,因为第 1 组开始“未初始化”;它还没有捕获任何东西(甚至没有一个空字符串),所以自引用尝试总是会失败。

有很多方法可以解决这个问题,但现在让我们将自引用匹配设为可选,即\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 组使用自我参考来“计数”!但是等等……第二个也是最后一个测试用例出了点问题!!s不够多b,不知怎么算错了!我们将在下一步检查为什么会发生这种情况。

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


步骤 4½:了解出了什么问题

b问题是,由于我们将自引用匹配设为可选,当's不足时,“计数器”可以“重置”回 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

啊哈!在我们的第 4 次迭代中,我们仍然可以匹配\1,但我们无法匹配\1b!由于我们允许自引用匹配是可选的\1?,因此引擎会回溯并采用“不,谢谢”选项,这样我们就可以匹配和捕获b

但是请注意,除了第一次迭代之外,您始终可以只匹配 self-reference \1。当然,这是显而易见的,因为它是我们刚刚在上一次迭代中捕获的,并且在我们的设置中,我们总是可以再次匹配它(例如,如果我们bbb上次捕获,我们保证仍然会有bbb,但可能或bbbb这次可能不是)。

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


第 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!!!

瞧!!!问题解决了!!!我们现在正在正确地计算,正是我们想要的方式!

课程:了解贪婪、不情愿和所有格重复之间的区别。Optional-Posssive 可以是一个强大的组合。


第 6 步:收尾工作

所以我们现在拥有的是一个a重复匹配的模式,对于每一个匹配的模式,在组 1 中a都有一个对应的b捕获。+当没有更多时终止a,或者如果断言失败,因为没有对应b的 for一个a

要完成这项工作,我们只需要附加到我们的模式\1 $。现在这是对第 1 组匹配的内容的反向引用,然后是行锚的末尾。锚确保字符串中没有任何多余b的 ';换句话说,事实上我们有一个n b n

这是最终的模式,带有额外的测试用例,包括一个 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 个匹配项:abaabbaaabbba 5000 b 5000。在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*在前瞻中,实际上是“主+循环”,都允许回溯。鼓励读者确认为什么这在正确性方面不是问题,以及为什么同时使两个所有格都可以工作(尽管在同一模式中混合强制和非强制所有格量词可能会导致误解)。

还应该说,虽然有一个匹配 n b n 的正则表达式模式很整洁但这实践中并不总是“最佳”解决方案。一个更好的解决方案是简单地匹配^(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 几乎可以做任何事情。它显然不能,即使它可以做的事情,如果它导致更简单的解决方案,至少应该考虑部分委托给宿主语言。

如顶部所述,虽然这篇文章必须标记[regex]为 stackoverflow,但它可能不止于此。虽然学习断言、嵌套引用、所有格量词等当然是有价值的,但这里更大的教训可能是人们可以尝试解决问题的创造性过程,当你遇到问题时通常需要的决心和努力工作各种约束,各个部分的系统组合以构建工作解决方案等。


奖励材料!PCRE递归模式!

由于我们确实提出了 PHP,所以需要说 PCRE 支持递归模式和子例程。因此,以下模式适用于preg_match如在 ideone.com 上所见):

$rRecursive = '/ ^ (a (?1)? b) $ /x';

目前 Java 的正则表达式不支持递归模式。


更多奖励材料!匹配一个n b n c n !!

所以我们已经看到了如何匹配非常规但仍然与上下文无关的a n b n,但是我们是否也可以匹配甚至不是上下文无关的a n b n c n ?

答案当然是肯定的!鼓励读者尝试自己解决这个问题,但下面提供了解决方案(ideone.com 上的 Java 实现)。

^ (?: a (?= a* (\1?+ b) b* (\2?+ c) ) )+ \1 \2 $

于 2010-09-04T22:50:02.263 回答
21

鉴于没有提及 PCRE 支持递归模式,我只想指出描述相关语言的 PCRE 最简单和最有效的示例:

/^(a(?1)?b)$/
于 2010-09-06T10:01:33.533 回答
14

如问题中所述 - 使用 .NET 平衡组,类型a n b n c n d n ...z n的模式可以轻松匹配为

^
  (?<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

于 2010-09-06T15:36:48.277 回答