如何使这个正则表达式不会导致“灾难性回溯”?

Posted

技术标签:

【中文标题】如何使这个正则表达式不会导致“灾难性回溯”?【英文标题】:How can I make this regular expression not result in "catastrophic backtracking"? 【发布时间】:2012-04-18 21:52:28 【问题描述】:

我正在尝试使用从http://daringfireball.net/2010/07/improved_regex_for_matching_urls 获得的匹配正则表达式的 URL

(?xi)
\b
(                       # Capture 1: entire matched URL
  (?:
    https?://               # http or https protocol
    |                       #   or
    www\d0,3[.]           # "www.", "www1.", "www2." … "www999."
    |                           #   or
    [a-z0-9.\-]+[.][a-z]2,4/  # looks like domain name followed by a slash
  )
  (?:                       # One or more:
    [^\s()<>]+                  # Run of non-space, non-()<>
    |                           #   or
    \(([^\s()<>]+|(\([^\s()<>]+\)))*\)  # balanced parens, up to 2 levels
  )+
  (?:                       # End with:
    \(([^\s()<>]+|(\([^\s()<>]+\)))*\)  # balanced parens, up to 2 levels
    |                               #   or
    [^\s`!()\[\];:'".,<>?«»“”‘’]        # not a space or one of these punct chars
  )
)

根据对another question 的回答,似乎有些情况会导致此正则表达式为backtrack catastrophically。例如:

var re = /\b((?:https?:\/\/|www\d0,3[.]|[a-z0-9.\-]+[.][a-z]2,4\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\];:'".,<>?«»“”‘’]))/i;
re.test("http://google.com/?q=(AAAAAAAAAAAAAAAAAAAAAAAAAAAAA)")

...可能需要很长时间才能执行(例如在 Chrome 中)

在我看来问题出在这部分代码上:

(?:                       # One or more:
    [^\s()<>]+                  # Run of non-space, non-()<>
    |                           #   or
    \(([^\s()<>]+|(\([^\s()<>]+\)))*\)  # balanced parens, up to 2 levels
  )+

...这似乎大致相当于(.+|\((.+|(\(.+\)))*\))+,看起来它包含(.+)+

我可以做一些改变来避免这种情况吗?

【问题讨论】:

真的,你应该把这个正则表达式扔掉,然后想出一个你需要的。我还没有看到一个应用程序既蓬松到可以使用正则表达式进行 URL 解析(而不是真正的解析器),又足够严重到需要处理 URL 中的嵌套括号。以“https?://”开头并以第一个字符结尾,该字符应该在正确的 URL 中进行 % 编码,但不是将处理几乎所有内容,并且不会导致正则表达式匹配器呈指数增长。 你试过 Rubular 吗?它下面有一个方便的备忘单,您可以添加各种测试表达式以确保它有效。 (P.S. 我知道这是针对 js 的,但这仍然是一个方便的资源。)rubular.com 【参考方案1】:

将其更改为以下内容应该可以防止灾难性的回溯:

(?xi)
\b
(                       # Capture 1: entire matched URL
  (?:
    https?://               # http or https protocol
    |                       #   or
    www\d0,3[.]           # "www.", "www1.", "www2." … "www999."
    |                           #   or
    [a-z0-9.\-]+[.][a-z]2,4/  # looks like domain name followed by a slash
  )
  (?:                       # One or more:
    [^\s()<>]+                  # Run of non-space, non-()<>
    |                           #   or
    \(([^\s()<>]|(\([^\s()<>]+\)))*\)  # balanced parens, up to 2 levels
  )+
  (?:                       # End with:
    \(([^\s()<>]|(\([^\s()<>]+\)))*\)  # balanced parens, up to 2 levels
    |                               #   or
    [^\s`!()\[\];:'".,<>?«»“”‘’]        # not a space or one of these punct chars
  )
)

所做的唯一更改是在正则表达式的每个“平衡括号”部分中删除第一个 [^\s()&lt;&gt;] 之后的 +

这里是用JS测试的单行版本:

var re = /\b((?:https?:\/\/|www\d0,3[.]|[a-z0-9.\-]+[.][a-z]2,4\/)(?:[^\s()<>]+|\(([^\s()<>]|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]|(\([^\s()<>]+\)))*\)|[^\s`!()\[\];:'".,<>?«»“”‘’]))/i;
re.test("http://google.com/?q=(AAAAAAAAAAAAAAAAAAAAAAAAAAAAA")

原始正则表达式的问题部分是平衡括号部分,为了简化回溯发生原因的解释,我将完全删除其中的嵌套括号部分,因为它与此处无关:

\(([^\s()<>]+|(\([^\s()<>]+\)))*\)    # original
\(([^\s()<>]+)*\)                     # expanded below

\(                # literal '('
(                 # start group, repeat zero or more times
    [^\s()<>]+        # one or more non-special characters
)*                # end group
\)                # literal ')'

考虑一下字符串'(AAAAA' 会发生什么,文字( 将匹配,然后AAAAA 将被组消耗,而) 将无法匹配。此时该组将放弃一个A,留下AAAA 并试图在此时继续比赛。因为该组后面有一个*,所以该组可以匹配多次,所以现在您将有([^\s()&lt;&gt;]+)* 匹配AAAA,然后在第二遍时A。当这失败时,额外的A 将被原始捕获放弃并被第二次捕获消耗。

这会持续很长时间,导致以下尝试匹配,其中每个逗号分隔的组表示该组匹配的不同时间,以及该实例匹配的字符数:

AAAAA
AAAA, A
AAA, AA
AAA, A, A
AA, AAA
AA, AA, A
AA, A, AA
AA, A, A, A
....

我可能算错了,但我很确定在确定正则表达式无法匹配之前,它总共需要 16 个步骤。随着您继续向字符串中添加其他字符,解决此问题的步骤数呈指数增长。

通过删除 + 并将其更改为 \(([^\s()&lt;&gt;])*\),您可以避免这种回溯情况。

重新添加替换以检查嵌套括号不会导致任何问题。

请注意,您可能希望在字符串末尾添加某种锚点,因为目前"http://google.com/?q=(AAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 将匹配到( 之前,所以re.test(...) 将返回true,因为http://google.com/?q=匹配。

【讨论】:

不错的答案。 @David - 除了 F.J 提出的提高速度的建议之外,您还应该尝试原子分组技巧。 @SteveWortham - 我认为原子团实际上可能会破坏这个,看看这个JSFiddle。正则表达式(?=([abc]))\1* 将等效于a*b*c*,具体取决于首先看到[abc] 中的哪个字符。 啊,看来我测试得还不够彻底。

以上是关于如何使这个正则表达式不会导致“灾难性回溯”?的主要内容,如果未能解决你的问题,请参考以下文章

如何彻底避免正则表达式的灾难性回溯?

Go 正则表达式中没有灾难性的回溯吗?

正则表达式灾难性回溯

使用正则表达式模式时的灾难性回溯错误

Java 9+中的灾难性回溯正则表达式示例[关闭]

灾难性回溯搜索括号中的数字