首先:精彩的问题。我认为尝试将正则表达式引擎发挥到极致会很有启发意义。
基本的 .NET 解决方案
你们在 cmets 中说使用 .NET 会很容易,但由于还没有答案,我想我会写一个。
您可以使用 .NET 的可变长度后视和平衡组来解决问题 1. 和 2.。大部分工作由平衡组完成,但可变长度的后视对于能够检测到从同一行开始的多个匹配项至关重要。
无论如何,这是模式:
(?<= # lookbehind counts position of X into stack
^(?:(?<a>).)* # push an empty capture on the 'a' stack for each character
# in front of X
) # end of lookbehind
X # match X
(?=.*\n # lookahead checks that there are two more Xs right below
(?:(?<-a>)(?<b>).)* # while we can pop an element from stack 'a', push an
# element onto 'b' and consume a character
(?(a)(?!)) # make sure that stack 'a' is empty
X.*\n # match X and the rest of the line
(?:(?<-b>).)* # while we can pop an element from stack 'b', and consume
# a character
(?(b)(?!)) # make sure that stack 'b' is empty
X # match a final X
) # end of lookahead
此模式必须与RegexOptions.Multiline 一起使用,以使^ 匹配行的开头(显然与RegexOptions.IgnorePatternWhitespace 一起使用以使freespacing 模式起作用)。
这里有一些额外的 cmets:
通过将除了最初的 X 之外的所有内容都放入环视中,我们不会遇到重叠匹配甚至从同一行开始的匹配的问题。但是,lookbehind 必须是可变长度的,这肯定会将任何此类解决方案限制为 .NET。
其余的依赖于对平衡组的良好掌握。我不会在这里详细介绍,因为它适用于quite long answers in itself。 (有关更多信息,请参阅MSDN 和 this blog post)
lookbehind 只匹配^.*,所以直到行首的所有内容,但对于每个.,我们将一个空捕获压入堆栈a,从而将X 的位置计算为堆栈。
然后在使用前瞻中的其余行之后,我们再次匹配.*,但在使用每个.之前,我们从堆栈中弹出一个元素a(这会导致失败,一次a是空的)并将一个空捕获推送到b(这样我们就不会忘记第三行必须有多少个字符)。
为了确保我们真正清空整个堆栈,我们使用(?(a)(?!))。这是一个条件模式,如果堆栈a 不为空(否则简单地跳过),它会尝试匹配(?!)。而(?!) 是一个空的否定前瞻,它总是失败。因此,这只是编码,“a 不为空吗?失败。否则,继续”。
既然知道我们在新行中使用了正确数量的字符,我们会尝试匹配 X 和该行的其余部分。然后我们再次使用堆栈b 重复相同的过程。现在不需要压入任何新堆栈,因为如果这可行,我们就完成了。在此之后我们检查b 是否为空,并匹配第三个X。
最后,一个优化方面的说明:如果所有重复都包裹在原子组中(从而模拟所有格量词,.NET 不支持),这种模式仍然有效!这将节省大量的回溯。此外,如果我们至少将堆栈弹出量词放在原子组中,我们可以摆脱两个(?(...)(?!)) 检查(因为这些仅在前面的重复必须回溯的情况下才需要) .
完整的 .NET 解决方案
(只有最勇敢的冒险者才能跟随我进入我即将进入的黑暗洞穴……)
正如 cmets 中所讨论的,此解决方案有一个缺点:它计算重叠匹配。例如
..X..
..X..
..X..
..X..
给出两个匹配项,一个在第一行,一个在第二行。我们想避免这种情况,只报告一场比赛(如果有 6 到 8 个Xs,则报告两场,如果有 9 到 11 场Xs,则报告三场,依此类推)。另外,我们想在第1,第4,第7,...X报告比赛。
我们可以通过要求第一个X 前面是满足我们要求的其他3 个Xs 的整数倍来调整上述模式以允许此解决方案。检查这一点的基本思想使用与以前相同的堆栈操作(除了我们在 3 个堆栈之间移动,以便在找到三个 Xs 后我们最终回到我们开始的地方)。要做到这一点,我们必须稍微调整一下lookbehind。
但有一个问题。 .NET 的可变长度lookbehind 使用另一个.NET 独有的功能RightToLeftMode,其中从右到左读取(和匹配)模式。通常这不需要打扰我们,但是当我们将它与平衡组结合起来时,我们可能会在for some unpleasant surprises 中。特别是,在考虑我们的捕获堆栈如何演变时,我们还需要从右到左(或从下到上)构造(并读取)表达式。
因此,当您阅读以下表达式(以及我的注释)时,请从最外层的末尾开始(您必须滚动一点) - 即就在唯一的顶级 X 之前;然后一直阅读到顶部。然后往后看再继续。
(?<=
# note that the lookbehind below does NOT affect the state of stack 'a'!
# in fact, negative lookarounds can never change any capturing state.
# this is because they have to fail for the engine to continue matching.
# and if they fail, the engine needs to backtrack out of them, in which
# case the previous capturing state will be restored.
(?<! # if we get here, there is another X on top of the last
# one in the loop, and the pattern fails
^ # make sure we reached the beginning of the line
(?(a)(?!)) # make sure that stack 'a' is empty
(?:(?<-a>).)* # while we can pop an element from stack 'a', and consume
# a character
X.*\n # consume the next line and a potential X
)
# at this point we know that there are less than 3 Xs in the same column
# above this position. but there might still be one or two more. these
# are the cases we now have to eliminate, and we use a nested negative
# lookbehind for this. the lookbehind simply checks the next row and
# asserts that there is no further X in the same column.
# this, together with the loop, below means that the X we are going to match
# is either the topmost in its column or preceded by an integer multiple of 3
# Xs - exactly what we are looking for.
(?:
# at this point we've advanced the lookbehind's "cursor" by exactly 3 Xs
# in the same column, AND we've restored the same amount of captures on
# stack 'a', so we're left in exactly the same state as before and can
# potentially match another 3 Xs upwards this way.
# the fact that stack 'a' is unaffected by a full iteration of this loop is
# also crucial for the later (lookahead) part to work regardless of the
# amount of Xs we've looked at here.
^ # make sure we reached the beginning of the line
(?(c)(?!)) # make sure that stack 'a' is empty
(?:(?<-c>)(?<a>).)* # while we can pop an element from stack 'c', push an
# element onto 'a' and consume a character
X.*\n # consume the next line and a potential X
(?(b)(?!)) # make sure that stack 'b' is empty
(?:(?<-b>)(?<c>).)* # while we can pop an element from stack 'b', push an
# element onto 'c' and consume a character
X.*\n # consume the next line and a potential X
(?(a)(?!)) # make sure that stack 'a' is empty
(?:(?<-a>)(?<b>).)* # while we can pop an element from stack 'a', push an
# element onto 'b' and consume a character
X.*\n # consume the next line and a potential X
)* # this non-capturing group will match exactly 3 leading
# Xs in the same column. we repeat this group 0 or more
# times to match an integer-multiple of 3 occurrences.
^ # make sure we reached the beginning of the line
(?:(?<a>).)* # push an empty capture on the 'a' stack for each
# character in front of X
) # end of lookbehind (or rather beginning)
# the rest is the same as before
X # match X
(?=.*\n # lookahead checks that there are two more Xs right below
(?:(?<-a>)(?<b>).)* # while we can pop an element from stack 'a', push an
# element onto 'b' and consume a character
(?(a)(?!)) # make sure that stack 'a' is empty
X.*\n # match X and the rest of the line
(?:(?<-b>).)* # while we can pop an element from stack 'b', and consume
# a character
(?(b)(?!)) # make sure that stack 'b' is empty
X # match a final X
) # end of lookahead
Working demo on RegexHero.net.
这次我把所有的解释都穿插在了模式中。因此,如果您按照我上面推荐的方式阅读模式,您会在需要时得到正确的解释......
现在这简直是一头野兽。但它现在满足了整个规范,并展示了 .NET 的正则表达式的强大功能。而且,虽然这看起来很可怕,但我认为(一旦你意识到从右到左的事情)这比 PCRE 的可比解决方案(使用递归或其他方式)更容易理解。
正如 Kobi 在下面的评论中提到的那样,如果您接受在单个匹配项的多次捕获中找到您的结果(例如,如果您有 7 个 Xs 的列,那么这可能会缩短很多)获得一场比赛,但在某个组中有 2 次捕获)。您可以通过将主要(前瞻)部分重复 1 次或多次并捕获初始的 X 来做到这一点(尽管将所有内容都放在前瞻中)。然后后视不需要计算Xs 的三倍,而只需要检查没有前导X。这可能会将图案的大小减半。
部分PCRE解决方案
(如果只有最勇敢的冒险者跟随我完成最后的解决方案,我可能在接下来的旅程中只剩下疯子......)
为了证明我刚才所说的上述解决方案与 PCRE 的比较,让我们看看我们如何甚至可以远程解决 PCRE 中的全部问题。如果没有可变长度的后视和平衡组,我们将不得不更加努力地工作。
Qtax(OP)为他的第一个问题(检查字符串是否包含任何X-column)提供了一个绝妙的解决方案,使用自引用组进行计数。这是一个非常优雅和紧凑的解决方案。但是因为每个匹配从行的开头到开始列的X,并且匹配不能重叠,所以我们不能在每行得到多个匹配。我们可以尝试将所有内容都放在一个前瞻中(这样实际上什么都没有匹配),但是两个零宽度匹配也永远不会从同一个位置开始 - 所以我们仍然会在每个候选行中得到一个匹配。
但是确实可以使用 PCRE 至少解决问题 2 的第一部分:计算从每行开始的列数(因此计算X 列的总数)。由于我们无法以单个匹配的形式获得此计数(请参阅上一段),并且我们无法以单个组或捕获的形式获得此计数(因为 PCRE 仅提供固定且有限数量的捕获,而不是 .NET )。我们可以做的是对匹配中的列数进行编码。
方法如下:对于每一行,我们检查是否有一列开始。如果是这样,我们在某个捕获组中包含一个字符。然后,在报告成功匹配之前,我们尝试找到尽可能多的列 - 为每个列添加一个字符到该特定组。通过这样做,我们在特定捕获的长度中对从每行开始的列数进行编码。
实际上,在正则表达式中实现这个概念比听起来要复杂得多(而且听起来已经相当复杂)。无论如何,这里是:
^
(?:(?|
(?(5)(?![\s\S]*+\5))
(?!(?!)()())
(?=
(?:
.
(?=
.*+\n
( \3? . )
.*+\n
( \4? . )
)
)*?
X .*+\n
\3
X .*+\n
\4
)
()
|
(?(5)(?=[\s\S]*+\5)|(?!))
(?:
.
(?=
.*+\n
( \1? .)
.*+\n
( \2? .)
)
)+?
(?=
(?<=X).*+\n
(\1)
(?<=X).*+\n
(\2)
(?<=X)
)
(?=
([\s\S])
[\s\S]*
([\s\S] (?(6)\6))
)
){2})+
(实际上,它比这更容易一些 - 请参阅 Qtax 的答案以了解如何简化这种方法。出于学术原因,我将把这种方法留在这里,因为可以从中学到一些非常先进和有趣的技术- 见最后的总结。)
是的,没有注释。我想,无论如何,没有人会真正阅读它们,所以我将尝试将这个表达式分解为多个部分(我将采用自上而下的方法)。
那么让我们来看看来自地狱的洋葱的外层:
^
(?:(?|
checkForNextColumn
|
countAndAdvance
){2})+
所以我们的匹配再次锚定在行的开头。然后我们有一个(?:...{2})+,这意味着重复偶数次。这就是两个子模式的交替。这些子模式代表了我上面提到的步骤。第一个检查是否有从当前行开始的另一列,第二个注册一个计数并为第一个子模式的另一个应用程序准备引擎的状态。所以控制权交给了第二个模式——第一个只是使用前瞻检查另一列,因此是一个零宽度模式。这就是为什么我不能简单地将所有东西都包装在+ 中,而必须做{2})+ 的事情——否则零宽度组件只会被尝试一次;这是几乎所有引擎都应用的必要优化,以避免出现(a*)+ 等模式的无限循环。
还有一个(非常重要的细节):我使用(?|...) 进行替换。在这种分组中,每个备选方案都以相同的组号开始。因此在/(?|(a)|(b))/ 中a 和b 都可以被捕获到组1 中。这是允许子模式之间“通信”的关键技巧,因为它们可以修改相同的组。
无论如何...所以我们有这两个子模式。我们想确保控制在它们之间真正交替。因此,如果它是最后一个匹配的组,则每个组都会失败。我们通过将模式包装在一些分组和引用魔法中来做到这一点:
^(?:(?|
(?(5)(?![\s\S]*+\5)) # if group 5 has matched before make sure that
# it didn't match empty
checkForNextColumn # contains 4 capturing groups
() # this is group 5, match empty
|
(?(5)(?=[\s\S]*+\5)|(?!)) # make sure that group 5 is defined and that it
# matched empty
advanceEngineState # contains 4 capturing groups
(?=
([\s\S]) # this is group 5, match non-empty
[\s\S]* # advance to the end very end of the string
([\s\S] (?(6)\6)) # add a character from the end of the string to
# group 6
)
){2})+
因此,在每个备选方案结束时,我们将使该备选方案的条件无效,甚至开始匹配。在第二个备选方案的最后,我们还将使用 Qtax 概述的技术将一个字符包含到组 6 中。这是计数步骤。即,6 组将包含与从当前行开始的列一样多的字符。
现在checkForNextColumn 将真的只是 Qtax 在前瞻中的解决方案。不过,它还需要进行一次修改,为了证明这一点,我们将首先研究advanceEngineState。
让我们考虑一下我们希望如何修改状态,以便 Qtax 的解决方案匹配行中的第二列。假设我们有输入
..X..X..
..X..X..
..X..X..
我们想找到第二列。这可以通过从第一个 X 之后的位置开始匹配来完成,并且组 \1 和 \2 已经分别初始化为第 2 行和第 3 行的前三个字符 (..X)(而不是其中是空的)。
现在让我们尝试这样做:匹配直到并包括下一个开始一列的X 的所有内容,然后用相应的行前缀填充两个组以用于checkForNextColumn 模式。这又是 Qtax 的模式,除了我们将 X 计算在内(而不是在它之前停止),并且我们需要将捕获添加到一个单独的组中。所以这里是advanceEngineState:
(?:
.
(?=
.*+\n
( \1? .)
.*+\n
( \2? .)
)
)+?
(?=
(?<=X) .*+\n
(\1)
(?<=X) .*+\n
(\2)
(?<=X)
)
请注意我是如何将Xs 转换为lookbehinds 的,以进一步了解一个字符,以及我如何有效地将\1 的最终内容复制到\3 以及将\2 的最终内容复制到\4。
因此,如果我们现在在前瞻中使用 Qtax 的解决方案作为 checkForNextColumn,使用组 \3 和 \4 而不是 \1 和 \2,我们应该完成了。
但是我们如何使这些组\3 和\4 而不是\1 和\2?我们可以以()() 开始模式,这将始终匹配,而不影响引擎的光标,但将组计数增加2。但是,这是有问题的:这会将组1 和2 重置为空字符串,这如果我们找到第二列,advanceEngineState 将处于不一致的状态(因为引擎的全局位置已提前,但计数组再次为零)。所以我们想让这两个组进入模式,但不影响他们当前正在捕获的内容。我们可以通过使用我已经提到的 .NET 解决方案来做到这一点:负面环视中的组不会影响捕获的内容(因为引擎需要从环视中回溯才能继续)。因此,我们可以使用(?!(?!)()())(一个永远不会导致模式失败的负前瞻)在我们的模式中包含两组从未使用过的括号。这允许我们在我们的第一个子模式中使用组3 和4,同时保持组1 和2 在下一次迭代的第二个子模式中保持不变。总之这是checkForNextColumn:
(?!(?!)()())
(?=
(?:
.
(?=
.*+\n
( \3? . )
.*+\n
( \4? . )
)
)*?
X .*+\n
\3
X .*+\n
\4
)
其中大部分看起来真的很熟悉。
原来如此。针对某些输入运行此操作将为我们提供一个组 6,其中包含一个捕获,每行都有一个以列开头的行 - 捕获的长度将告诉我们从那里开始的列数。
Yes, it really works (live demo).
请注意,这(就像基本的 .NET 解决方案一样)会多算超过 3 Xs 长的列。我想可以通过前瞻来纠正这个计数(与完整的 .NET 解决方案的后视类似),但这留给读者作为练习。
有点不幸的是,这个解决方案的基本问题已经非常复杂并且使解决方案膨胀(75% 的行大多只是 Qtax 解决方案的副本)。因为周围的框架有一些非常有趣的技术和教训:
- 我们可以有多个子模式来完成特定的匹配/计数任务,并让它们通过相互捕获组“通信”,方法是将它们置于
(?|...) 交替并循环它们。
- 我们可以强制零宽度替代方案一遍又一遍地执行,方法是在将所有内容放入
+ 之前将它们包装在有限量词中,例如 {2}。
- 可以在一个子模式中跳过组号(不会影响捕获的内容),方法是将它们置于永不失败的负前瞻中,例如
(?!(?!)())。
- 控制可以在子模式之间来回传递,方法是在进入交替时检查的特定组中捕获某些东西或什么都没有。
这允许进行一些非常强大的计算(我已经看到 PCRE 实际上是图灵完备的)——尽管这对于生产性使用来说肯定是错误的方法。但是仍然试图理解(并提出)这样的解决方案可能是一个非常具有挑战性的问题,并且在某种程度上是有益的解决问题的练习。