[译]揭秘检验素数的正则表达式

680 阅读5分钟

原文地址:iluxonchik.github.io/regular-exp…

版权归原作者所有!

介绍

前一阵我搜索“最有效率的检查正则表达式的方式”,找到了如下这段代码。

public static boolean isPrime(int n) {
    return !new String(new char[n]).matches(".?|(..+?)\\1+");
}

我被吸引住了。虽然这可能不是效率最高的,但肯定是最难懂的之一,这勾起了我的好奇心。究竟为什么匹配.?|(..+?)\1+就能判断一个数不是素数呢?

如果感兴趣的话可以继续往下看,我会详细分析这个正则表达式并解释内部原理。这个解释应该是不与编程语言相关的,但是我会提供上述代码的 Python 和 JavaScript 版本并解释它们之间的细微差别。

我会解释^.?$|^(..+?)\1+$是如何筛出素数的,为什么要这样写而不是.?|(..+?)\1+(就是上面 Java 的写法)?这与 String.matches() 的工作原理有关,稍后解释。

虽然关于此主题也有一些人写过博客,但是他们都不够深入,没有充分解释重要细节。我希望本文可以让任何人都能理解——不论是正则表达式专家还是新手。

正则表达式

首先给出一些定义,熟悉正则表达式的读者可以略过此节。

好的,现在让我们谈谈正则表达式(也就是 regex)语法。有很多正则表达式风格,我不会专注于任何特定的风格,因为这不是本文的重点。此处介绍的概念在所有最常见的风格中都以类似的方式工作。如果您想了解有关正则表达式的更多信息,请访问Regular-Expressions.info,它是学习正则表达式并稍后用作参考的绝佳资源。

这是一份备忘单,其中包含以下解释所需的概念:

  • ^-匹配字符串中第一个字符之前的位置
  • $-匹配字符串中最后一个字符之后的位置
  • .-匹配除换行符以外的任何字符
  • |-匹配左侧或右侧的所有内容,可以视为or运算符。
  • () 界定捕获组。通过将正则表达式的一部分放在括号之间,可以将正则表达式的该部分组合在一起。可以将量词(例如+)应用于整个组,或限定替换(即:|)到正则表达式的一部分。除此之外,括号还会创建一个编号的捕获组,以后可以使用后向引用来引用它。
  • \ <number_here>-后向引用匹配捕获组先前匹配的文本。<number_here>是组号(上面说的括号创建了一个编号捕获组)。
  • +-匹配前面的 token(例如,可以是一个字符或一组字符)一次或多次
  • *-与前面的 token 匹配0次或多次
  • ?用在+*量词之后,使该量词变成非贪婪

译注:?作为量词代表匹配0次或一次,即“可有可无”。

捕获组和后向引用

括号会创建编号捕获组,意思是使用括号时,可以创建一个与某些字符匹配的组,以后可以引用那些匹配的字符。数字以在正则表达式中出现的顺序从1开始分配给组。例如,假设有以下正则表达式:^aa(bb)cc(dd)$。在这种情况下,我们有2个组。

这如果要引用与(bb)匹配的内容,用\1。为了引用与(dd)匹配的字符,用\2。放在一起,正则表达式^aa(bb)cc(dd)\1$与字符串aabbccddbb匹配。\1表示与组(bb)相匹配的内容,在这种情况下,是字符串bb

贪婪和非贪婪量词

+是贪婪量词,这意味着它将试图尽可能多地重复前面的token,即它将试图试消耗尽可能多的输入。*量词也是如此。

比如,我们有字符串<p>The Documentary</p> (2005)(译注:美国说唱歌手 Game 的一张专辑名)和正则表达式<.+>。匹配的字符串实际上是<p>The Documentary</p>。因为 +将试图消耗尽可能多的输入,因此这意味着它不会在第一个>停止,而是在最后一个>停止。

要使量词成为非贪婪,在其前面加一个问号。就这么简单。还是上面的字符串,但是这次我们只想匹配第一个<>之间的内容,要如何处理?用<.+?>,它将使+量词变得非贪婪,意思是说它将使量词消耗尽可能少的输入。例子中,“尽可能小的”就是<p>,这正是我们想要的!确切地说,它将与两个 p 都匹配:<p></p>

关于^$的一点说明

注意,在上面的两个正则表达式(<.+><.+?>)中,我们都没有使用它们。这意味着什么?这意味着匹配不必从字符串的开头开始,也不必在字符串的结尾结束。以第二个非贪婪的正则表达式(<.+?>)和字符串The Game - <p>The Documentary</p> (2005)为例,我们仍然可以获得预期的匹配项(<p></p>),因为我们没有强迫它在字符串的开头开始并在字符串的结尾结束。

检验素数的正则表达式

终于来到重点了。

你可以忽略正则表达式中的?,这样写是出于性能考量的(稍后解释),它使+非贪婪。如果它让你感到困惑,请忽略它,除了速度较慢(有特例,当数字为素数时,?没有任何区别)没什么差别。

以下所有讨论均假定我们以一进制来表示数字。实际上并非一定要表示为1的序列,它可以是任何可以被.匹配的字符。也就是说 5 不必表示为11111,也可以表示为fffffBBBBB之类的,只要五个相同字符就可以。

概览

^.?$|^(..+)\1+$正则表达式由两部分组成:^.?$^(..+?)\1+$

事先警告,我在接下来的段落中对于^(..+?)\1+$正则表达式的解释中撒了一点小谎,跟正则表达式引擎检查倍数的顺序有关,它实际上是从最高倍数开始到最低倍数,而不是我在这里解释的方式。但是可以放心忽略这种区别,因为正则表达式仍然匹配相同的字符串,只是步骤更多一些。之所以这样做,是因为我相信这种解释不太冗长,更容易理解。

正则表达式引擎将首先尝试匹配^.?$,然后,如果失败,它将尝试匹配^(..+?)\1+$注意,匹配的字符数对应于匹配的数字,即如果匹配了3个字符,则表示匹配了数字3,如果匹配了26个字符,则表示匹配了数字26。

^.?$匹配具有零个或一个字符的字符串(分别对应于数字0和1)。

^(..+?)\1+$首先尝试匹配2个字符(对应于数字2),然后匹配4个字符(对应于数字4),然后匹配6个字符,再匹配8个字符,依此类推。基本上,它将尝试匹配2的倍数。如果失败,它将尝试首先匹配3个字符(对应于数字3),然后匹配6个字符(对应于数字6),然后匹配9个字符,再匹配12个字符,依此类推。这意味着它将尝试匹配3的倍数。如果失败,它将尝试匹配4的倍数,如果失败,它将尝试匹配5的倍数,依此类推,直到尝试匹配的倍数的数字是字符串的长度(失败的情况)或匹配成功(成功的情况)。

深入理解

正则表达式的字面含义:

  • ^.?$^(..+?)\1+$两者之一将被匹配
  • 匹配项必须在整个字符串上,即从字符串的开头开始,到字符串的结尾结束

1. ^.?$正则表达式如何工作

^.?$将匹配0或1个字符。如果满足以下条件,则匹配成功:

  • 该字符串仅包含1个字符——这意味着我们正在处理数字1,根据定义,1不是素数。
  • 该字符串包含0个字符(空字符串)——这意味着我们正在处理数字0,而0当然不是素数,因为我们可以将0除以我们任何数,当然除了0本身。

2. ^(..+?)\1+$正则表达式如何工作

^(..+?)\1+$将首先尝试匹配2的倍数,然后匹配3的倍数,然后匹配4的倍数……依此类推,直到它尝试的数字的倍数要匹配的是字符串的长度,或者匹配成功。

现在让我们关注括号,这里有(..+?)。请注意,这里有一个+,表示“一个或多个前面的token”。此正则表达式将首先尝试匹配(..)(2个字符),然后匹配(...)(3个字符),然后匹配(....)(4个字符)……依此类推,直到字符串的长度我们达到了匹配条件或匹配成功。

在匹配了一定数量的字符之后(让我们将其称为x),正则表达式将尝试查看字符串的长度是否为x的倍数。有一个向后引用:\1+。现在,如前所述,它将尝试重复捕获#1组中的匹配一次或多次(实际上是“更多或一次”,这里撒了一点谎),这意味着首先,它将尝试匹配字符串中的x*2个字符,然后匹配x*3,然后匹配x*4……依此类推。如果在其中任何一项中能够匹配成功,它将返回(这意味着该数字不是素数)。如果失败(当x*<number>超过我们要匹配的字符串的长度时失败),它将尝试相同的操作,但是使用x+1个字符,即首先(x+1)*2,然后是(x+1)*3,然后是(x+1)*4……依此类推。当(..+?)匹配到的字符串长度达到整个字符串长度时,匹配过程将停止并返回失败。如果匹配成功,则将其返回。

关于?

好吧,我提到你可以忽略正则表达式中的?,因为它仅出于性能方面的考虑而存在,这是事实,我将在此解释它的实际作用。

?使前面的+非贪心。在实践中这是什么意思呢?假设我们的字符串是111111111111111(对应于数字15)。我们称L为字符串的长度,此处,L = 15。

?存在时,+将尝试尽可能少地匹配其先前的token(在这种情况下为.)。这意味着正则表达式(..+?)会尝试匹配..,然后是...,然后是....……最后整个正则表达式将成功。注意,(..+?)中的步骤数为4(首先它匹配2,然后是3,然后是4,然后是5)。

如果我们省略了?,那么它将反过来:首先,它将尝试匹配...............(数字15 ,即我们的L),然后是..............(数字14,即L-1),依此类推,直到最后整个正则表达式将成功。请注意,即使结果与(..+?)中的结果相同,但在(..+)中步数为11而不是4。根据定义,L的任何除数都不得大于L/2(L / 2 = 15/2 = 7),因此,这意味有8步完全是浪费的,因为首先我们将可除性测试为15,然后是14,然后是13,依此类推,直到5。

惊天谎言

实际上我对于倍数的匹配的解释中撒了谎。还是以111111111111111为例。我说首先会测试除以2,然后是2*2,……,2*7,最后失败在2*8;然后测试除以3,最后在3*5时成功。这实际上是当正则表达式是^.?$|^(..+?)\1+?$时所做的事情(注意结尾的?,反向引用的+非贪婪)。

实际情况(^(..+?)\1+$)恰恰相反。仍然会先尝试测试2的可除性,但是并非尝试匹配2*2个字符,而是尝试匹配2*7,然后匹配2*6……然后是2*2,失败,然后再次尝试将运气除以3,首先尝试匹配3 * 5个字符,立即成功。

请注意,在第二种情况下(实际情况),所需步骤更少:第一种情况下需要11个步骤,而第二种情况下则需要7个步骤。虽然这两个版本结果,但本博客文章中介绍的版本效率更高。

Java 的情况

我说过,由于String.matches()在 Java 中工作的方式的特殊性,匹配非素数的正则表达式不是上面的代码示例中的正则表达式(.?|(..+?)\1+),实际上是^.?$|^(..+?)\1+$。因为String.matches()匹配整个字符串,而不是字符串的任何子字符串。基本上,它在本文中的正则表达式中“自动插入”了^$

如果你不想强制在 Java 中对整个字符串进行匹配,则可以使用PatternMatcherMatcher.find()方法。

new String(new char [n])返回一个由n个空字符组成的String(正则表达式中的.与它们匹配)。

代码示例

Python

def is_prime(n):
    return not re.match(r'^.?$|^(..+?)\1+$', '1'*n)

JavaScript(ES6)

function isPrime(n) {
    var re = /^.?$|^(..+?)\1+$/;
    return !re.test('1'.repeat(n));
}

String.prototype.repeat()是 ES6 的才能用的方法,如果想用老的 ES 版本:

function isPrime(n) {
    var re = /^.?$|^(..+?)\1+$/;
    return !re.test(Array(n+1).join('1'));
}

注意参数是n+1,因为实际上我们是在用1来插空,所以n+1个元素才有n个空。

结语

到此为止,希望这个正则表达式检验素数的原理之谜已经解开。记住这个方法效率很差,检验素数还有很多高效算法。但是无论如何,这个正则表达式很有趣。

我鼓励你去regex101这样的网站玩一玩,特别是如果你仍然不十分了解本文介绍的所有内容时,可以尝试一下。关于这个网站的一件很酷的事情是它包括了对正则表达式的解释(在右边的列),以及正则表达式引擎必须执行的步骤数(在修饰符框正上方的矩形),这是一个很好的选择在贪婪和非贪婪情况下查看性能差异(通过执行的步骤数)的方法。