这个Java正则表达式如何检测回文?

How does this Java regex detect palindromes?

This is the third part in a series of educational regex articles. It follows How does this regex find triangular numbers? (where nested references is first introduced) and How can we match a^n b^n with Java regex?
(where the lookahead"counting" mechanism is further elaborated upon). This part introduces a specific form of nested assertion, which when combined with nested references allows Java regex to match what most people believe is"impossible": palindromes!!

回文的语言是不规则的;它实际上是上下文无关的(对于给定的字母表)。也就是说,现代的regex实现不仅可以识别常规语言,而且perl/pcre的递归模式和.net的平衡组可以很容易地识别回文(参见:相关问题)。

然而,Java的正则表达式引擎既不支持这些"高级"功能。但是"someone"(*wink*)成功地编写了以下regex,这看起来做得很好(另请参见ideone.com上的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Palindrome {
    // asserts that the entirety of the string matches the given pattern
    static String assertEntirety(String pattern) {
        return"(?<=(?=^pattern$).*)".replace("pattern", pattern);
    }

    public static void main(String[] args) {
        final String PALINDROME =
           "(?x) | (?:(.) add)+ chk"
                .replace("add", assertEntirety(".*? (\\1 \\2?)"))
                .replace("chk", assertEntirety("\\2"));

        System.out.println(PALINDROME);
        // (?x) | (?:(.) (?<=(?=^.*? (\1 \2?)$).*))+ (?<=(?=^\2$).*)

        String[] tests = {
           "",     // true
           "x",    // true
           "xx",   // true
           "xy",   // false
           "xyx",  // true
           "xxx",  // true
           "xxyx", // false
           "racecar",                // true
           "step on no pets",        // true
           "aManaPlanaCanalPanaMa",  // true
           "this is impossible",     // FALSE!!!
        };
        for (String test : tests) {
            System.out.printf("[%s] %s%n", test, test.matches(PALINDROME));
        }
    }
}

所以这似乎有效,但如何?

工具书类

  • java.util.regex.Pattern
  • regular-expressions.info/freespacing (?x),lookarounds (?=…)/(?<=…)等。

COMMON SENSE ALERT!!!

This is not the best way to detect palindromes; it's O(N^3)at best. Performing this detection in a more general purpose programming language is both more efficient and more straightforward.

You wouldn't want to use regex to detect palindromes for the same reasons you wouldn't want to use regex to find prime numbers. That said, you would study how a non-recursive non-balancing group regex can detect palindromes for the same reasons you would study how a regex can be used for primality testing: it's fun, it's challenging, it's educational.

相关问题

  • 如何使用正则表达式检查字符串是否为回文?-这是"不可能的"!(除非…)
  • 如何检查给定的字符串是否是回文?-多种语言的非regex解决方案
  • 如何确定一个数字是否是带有regex的素数?


大局

我们将首先从全局算法中查看这个regex,然后在后面更详细地查看具体的实现细节。ReGEX是对Java代码的一种几乎直接的翻译:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static boolean isPalindrome(String s) {
   if (s.isEmpty()) {
      return true;
   }
   String g2 = null;
   for (char ch : s.toCharArray()) {
      String g1 = String.valueOf(ch);
      //"add"
      if (g2 != null && s.endsWith(g1 + g2)) {
         g2 = g1 + g2;
      } else if (s.endsWith(g1)) {
         g2 = g1;
      } else {
         break;
      }
   }
   return s.equals(g2); //"chk"
}

这显然不是检查回文的最直接/最有效的Java代码,但它是有效的,而且最吸引人的是,它几乎可以直接用ReXEX与一对一映射进行平移。这里是regex,为了方便起见,在这里复制,注释以突出惊人的相似性:好的。

1
2
3
4
5
6
7
8
9
//  isEmpty  _for-loop_
//       ↓  /          \
   "(?x) | (?:(.) add)+ chk"
//             \_/  ↑
//             g1   loop body                   ___g2___
//                                             /        \
           .replace("add", assertEntirety(".*? (\\1 \\2?)"))
           .replace("chk", assertEntirety("\\2"));
                           // s.equals(g2)

附件:ideone.com上源代码的注释和扩展版本好的。

(现在可以忽略assertEntirety的细节:只需将其看作一个黑盒regex机制,它可以对整个字符串进行断言,而不管我们现在在哪里。)好的。

所以基本的算法是,当我们从左到右扫描字符串时,我们试图建立一个后缀,受制于回文约束。然后我们检查是否能够以这种方式构建完整的字符串。如果可以,那么字符串就是回文。另外,作为一种特殊情况,空字符串通常是回文。好的。

一旦理解了全局算法,我们就可以检查regex模式是如何实现它的。好的。所有的String.replace是什么?

Java中的ReGEX模式最终只不过是字符串,这意味着它们可以通过字符串操作来导出任何字符串的方式。是的,我们甚至可以使用regex生成一个regex模式——一种元regexing方法,如果您愿意的话。好的。

考虑这个初始化int常量的例子(它最终只包含一个数字):好的。

1
2
3
final int X = 604800;
final int Y = 60 * 60 * 24 * 7;
// now X == Y

分配给X的数字是一个字面整数:我们可以清楚地看到数字是什么。这与使用表达式的Y不同,但是这个公式似乎传递了这个数字代表什么的概念。即使没有对这些常量进行适当的命名,我们仍然认为Y可能表示一周内的秒数,即使我们可能不立即知道数值是什么。另一方面,使用X,我们精确地知道这个数字,但我们对它代表什么的了解较少。好的。

在代码段中使用字符串替换是一种类似的情况,但对于字符串regex模式来说是如此。不是将模式显式地写为一个文本字符串,有时从简单部分对该值进行系统和逻辑推导("公式")可能更有意义。对于regex,这一点尤其重要,因为我们了解模式的作用,而不是能够看到它作为字符串文字的样子(无论如何,它不太像一个looker,使用所有额外的反斜杠是什么)。好的。

为了方便起见,这里再次复制了部分代码片段:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// the"formula"
     final String PALINDROME =
       "(?x) | (?:(.) add)+ chk"
           .replace("add", assertEntirety(".*? (\\1 \\2?)"))
           .replace("chk", assertEntirety("\\2"));

// the"value"
     System.out.println(PALINDROME);
     //                       ____add_____             chk_
     //               _______/            \____   _______/ \_____
     // (?x) | (?:(.) (?<=(?=^.*? (\1 \2?)$).*))+ (?<=(?=^\2$).*)
     //        |  \_/             \______/     |
     //        |   1                 2         |
     //        |_______________________________|

毫无疑问,在这种情况下,"公式"比最终的字符串"值"可读性强得多。好的。

当然,还有很多更复杂的方法可以通过编程方式生成regex模式,并且可以用这样一种方式来编写,即模糊而不是强调其含义,但是即使是简单的字符串替换,谨慎地使用也会令人惊讶(希望在本例中显示)。好的。

教训:考虑编程生成regex模式。好的。add是如何工作的?

(?:(.) add)+结构,其中add是一个进行某种"计数"的断言,在前两部分已经进行了深入讨论。不过,有两个特点值得注意:好的。

  • (.)捕获到组1中,允许稍后进行反向引用。
  • 这一断言是assertEntirety,而不是仅仅从我们目前的立场展望未来。
    • 稍后我们将更详细地讨论这个问题;只需将其作为断言整个字符串的一种方法。

应用于addassertEntirety的模式如下:好的。

1
2
3
4
5
# prefix   _suffix_
#    ↓    /        \
    .*?   ( \1 \2? )
#         \________/   i.e. a reluctant"whatever" prefix (as short as possible)
#          group 2          followed by a suffix captured into group 2

请注意,组2使用可选的说明符自引用,这是本系列第2部分中已经讨论过的一种技术。不用说第2组就是这个模式中的"计数器":它是一个后缀,我们将在"循环"的每次迭代中尝试向左增长。当我们从左到右迭代每个(.)时,我们试图在后缀前面加上相同的字符(使用对\1的后向引用)。好的。

再次回顾上述模式的Java代码转换,为了方便而在这里复制:好的。

1
2
3
4
5
6
7
if (g2 != null && s.endsWith(g1 + g2)) {   // \2? is greedy, we try this first
   g2 = g1 + g2;
} else if (s.endsWith(g1)) {    // since \2? is optional, we may also try this
   g2 = g1;
} else {        // if there's no matching suffix, we"break" out of the"loop"
   break;
}

\2?是可选的这一事实意味着一些事情:好的。

  • 它为自引用提供了一个"基本情况"(我们这样做的主要原因!)
  • 由于\2?是后缀模式的一部分(因此在整体模式中出现在后面),前缀部分必须是不情愿的,因此.*?而不是.*。这使得江户十一〔九〕得以行使其贪婪。
  • "计数器"也可能"重置"并给出"错误"的结果。
    • 在第2部分中,我们展示了回溯?如何导致同样的问题重置。
      • 我们用所有格量词?+解决了这个问题,但这里不适用。

第三点将在下一节中进一步阐述。好的。

教训:仔细分析模式中贪婪/不情愿重复之间的交互作用。好的。相关问题

  • regex的.*?.*之间的差异
  • 正则表达式:谁更贪婪?

为什么我们需要一个chk阶段?

如前一节所述,可选和可回溯的\2?意味着我们的后缀在某些情况下可以收缩。我们将逐步检查此输入的这种场景:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 x y x y z y x

# Initial state, \2 is"uninitialized"
             _
(x)y x y z y x
  ↑
  # \1 captured x, \2 couldn't match \1\2 (since \2 is"uninitialized")
  #                but it could match \1 so it captured x
           ___
 x(y)x y z y x
    ↑
    # \1 captured y, \2 matched \1\2 and grew to capture yx
             _
 x y(x)y z y x
      ↑
      # \1 captured x, \2 couldn'
t match \1\2,
      #                but it could match \1 so it shrunk to capture x (!!!)
           ___
 x y x(y)z y x
        ↑
        # \1 captured y, \2 matched \1\2 and grew to capture yx
         _____
 x y x y(z)y x
          ↑
          # \1 captured z, \2 matched \1\2 and grew to capture zyx
       _______
 x y x y z(y)x
            ↑
            # \1 captured y, \2 matched \1\2 and grew to capture yzyx
     _________
 x y x y z y(x)
              ↑
              # \1 captured x, \2 matched \1\2 and grew to capture xyzyx

我们可以修改我们的模式(和相应的Java代码)来省略EDCOX1的18个阶段,并且看到确实是这样:好的。

1
2
3
4
5
6
7
8
9
10
11
    // modified pattern without a chk phase; yields false positives!
    final String PALINDROME_BROKEN =
       "(?x) | (?:(.) add)+"
            .replace("add", assertEntirety(".*? (\\1 \\2?)"));

    String s ="xyxyzyx"; // NOT a palindrome!!!

    Matcher m = Pattern.compile(PALINDROME_BROKEN).matcher(s);
    if (m.matches()) {
        System.out.println(m.group(2)); // prints"xyzyx"
    }

如前所述,不是回文的"xyxyzyx"被错误地报告为一个,因为我们没有检查增长的后缀是否最终成为完整的字符串(在本例中显然没有)。因此,chk阶段(即模式\2assertEntirety阶段)在我们的设置中是绝对必要的。我们需要确认我们确实一直在设法增加后缀。如果是这样的话,那么我们就有了回文。好的。

教训:仔细分析可选自引用匹配可能产生的任何意外副作用。好的。主菜:assertEntirety

虽然我们可以编写一个Java ReGeX模式来检测回文,但是这里除了EDCOX1的2个部分之外,所有的内容都已经在本系列的前几部分中被覆盖了。这里唯一的新东西就是这个神秘的黑匣子,这个强大的机制,神奇地让我们做其他"不可能"的事情。好的。

assertEntirety构造基于以下嵌套查找的元模式:好的。

(?<=(?=^pattern$).*)

Ok.

" I can see a place somewhere behind me where I can look ahead and see ^pattern$"

Ok.

< /块引用>

"环顾"这个名字暗示着与我们当前的位置的相对性:我们环顾四周,也许在我们所处的位置的前面或后面。以这种方式把一个了望台嵌套在了望台上,我们就可以比喻地"飞到天上"并看到整个画面。好的。

将这个元模式抽象到assertEntirety中有点像编写预处理替换宏。到处都有嵌套的查找可能会损害可读性和可维护性,因此我们将其封装到assertEntirety中,这不仅隐藏了其内部工作的复杂性,而且还通过给它一个适当的名称进一步强调了其语义。好的。

课程:考虑抽象元模式以隐藏复杂性和传递语义。好的。附录:Java中的无限长度查找

观察型读者会注意到,assertEntirety在lookback中包含一个.*,这使得理论上的最大长度是无限的。不,Java没有正式支持无限长查找。是的,正如这里充分证明的那样,它无论如何都有效。官方上它被归类为"bug";但"某人"(*wink*)也可以将其视为"隐藏的特性"。好的。

这个"bug"很可能在将来被"修复"。删除这个隐藏的特性将打破Java JaveReX回文问题的特定解决方案。好的。相关问题

  • Java中没有明显的最大长度的ReGEX向后看

好啊。