正则表达式匹配第一个非重复字符

Posted

技术标签:

【中文标题】正则表达式匹配第一个非重复字符【英文标题】:Regular Expression Matching First Non-Repeated Character 【发布时间】:2017-02-09 08:19:13 【问题描述】:

TL;DR

re.search("(.)(?!.*\1)", text).group() 不匹配文本中包含的第一个非重复字符(它总是返回第一个非重复字符处或之前的字符,如果没有非重复字符,则返回字符串末尾之前的字符.我的理解是,如果没有匹配项,re.search() 应该返回 None 。 我只是想了解为什么此正则表达式无法使用 Python re 模块按预期工作,而不是任何其他解决问题的方法

全背景

问题描述来自https://www.codeeval.com/open_challenges/12/。我已经使用非正则表达式方法解决了这个问题,但重新访问它以扩展我对 Python 的 re 模块的理解。 我认为可行的正则表达式(命名与未命名的反向引用)是:

(?P<letter>.)(?!.*(?P=letter))(.)(?!.*\1)(在 python2 和 python3 中的结果相同)

我的整个程序是这样的

import re
import sys
with open(sys.argv[1], 'r') as test_cases:
    for test in test_cases:
        print(re.search("(?P<letter>.)(?!.*(?P=letter))",
                        test.strip()
                       ).group()
             )

一些输入/输出对是:

rain | r
teetthing | e
cardiff | c
kangaroo | k
god | g
newtown | e
taxation | x
refurbished | f
substantially | u

根据我在https://docs.python.org/2/library/re.html阅读的内容:

(.) 创建一个与任何字符匹配的命名组,并允许以后将其反向引用为\1(?!...) 是一个否定的前瞻,它将匹配限制在 ... 不匹配的情况。 .*\1 表示任意数量(包括零)的字符,后跟之前与 (.) 匹配的任何字符 re.search(pattern, string) 仅返回正则表达式模式产生匹配的第一个位置(如果找不到匹配则返回 None) .group() 等价于 .group(0),它返回整个匹配项

我认为这些部分应该可以解决上述问题,并且它确实像我认为的大多数输入一样工作,但在teething 上失败了。向它抛出类似的问题表明,如果它们是连续的,它似乎会忽略重复的字符:

tooth | o      # fails on consecutive repeated characters
aardvark | d   # but does ok if it sees them later
aah | a        # verified last one didn't work just because it was at start
heh | e        # but it works for this one
hehe | h       # What? It thinks h matches (lookahead maybe doesn't find "heh"?)
heho | e       # but it definitely finds "heh" and stops "h" from matching here
hahah | a      # so now it won't match h but will match a
hahxyz | a     # but it realizes there are 2 h characters here...
hahxyza | h    # ... Ok time for ***

我知道lookbehind 和negative lookbehind 仅限于最多3 个字符的固定长度字符串,并且即使它们评估为固定长度的字符串也不能包含反向引用,但我没有看到文档指定对负前瞻的任何限制。

【问题讨论】:

这就是您正确提出正则表达式问题的方式。干得好。 我通常会放弃草稿问题,因为我在记录我的尝试、研究和期望时找到了答案——或者在问题完全写完后我正在修复格式时我很少找到答案。不过,这完全让我无法理解。 我发现我自己也经常遇到这种情况。不太常见的是网站上的一个相当新的人在来到这里之前提出了一个正则表达式问题,并有如此有据可查的尝试来解决问题。令人耳目一新。 注意:当前答案不提供此标题的唯一正则表达式解决方案:。他们只说 OP 为何以及如何错误地认为他的解决方案背后的想法是错误的。这个赏金是在这个问题上开始的,它可以得到这个问题的解决方案。 @revo 给你,我添加了一个简单的 .NET 和一个 Python 解决方案;)我不确定这是否适用于 PCRE,我需要考虑一下。 【参考方案1】:

Sebastian's answer 已经很好地解释了为什么您当前的尝试不起作用。

.NET

由于 你是 revo 对 .NET 风格的解决方法感兴趣,因此解决方案变得微不足道:

(?<letter>.)(?!.*?\k<letter>)(?<!\k<letter>.+?)

Demo link

这是可行的,因为 .NET 支持可变长度后视。您也可以使用 Python 获得该结果(见下文)。

所以对于每个字母(?&lt;letter&gt;.),我们检查:

如果在输入中进一步重复 (?!.*?\k&lt;letter&gt;) 如果之前已经遇到过(?&lt;!\k&lt;letter&gt;.+?) (我们必须在后退时跳过我们正在测试的字母,因此+)。

Python

Python regex module 也支持可变长度的lookbehinds,因此上面的正则表达式只需稍作语法更改即可工作:您需要将\k 替换为\g(这与此模块\g 一样非常不幸是组反向引用,而 PCRE 是递归)。

正则表达式是:

(?<letter>.)(?!.*?\g<letter>)(?<!\g<letter>.+?)

这是一个例子:

$ python
Python 2.7.10 (default, Jun  1 2015, 18:05:38)
[GCC 4.9.2] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import regex
>>> regex.search(r'(?<letter>.)(?!.*?\g<letter>)(?<!\g<letter>.+?)', 'tooth')
<regex.Match object; span=(4, 5), match='h'>

PCRE

好的,现在事情开始变糟了:由于 PCRE 不支持可变长度的后视,我们需要以某种方式记住输入中是否已经遇到给定的字母。 p>

很遗憾,正则表达式引擎不提供随机存取内存支持。就通用内存而言,我们能得到的最好的东西是 stack - 但这还不够,因为堆栈只允许我们访问其最顶层的元素。

如果我们接受将自己限制在给定的字母表中,我们可以滥用捕获组来存储标志。让我们在三个字母 abc 的有限字母表上看到这个:

# Anchor the pattern
\A

# For each letter, test to see if it's duplicated in the input string
(?(?=[^a]*+a[^a]*a)(?<da>))
(?(?=[^b]*+b[^b]*b)(?<db>))
(?(?=[^c]*+c[^c]*c)(?<dc>))

# Skip any duplicated letter and throw it away
[a-c]*?\K

# Check if the next letter is a duplicate
(?:
  (?(da)(*FAIL)|a)
| (?(db)(*FAIL)|b)
| (?(dc)(*FAIL)|c)
)

这是如何工作的:

首先,\A 锚确保我们将只处理输入字符串一次 然后,对于我们字母表中的每个字母X,我们将设置一个重复标志dX: 这里使用了条件模式(?(cond)then|else): 条件为(?=[^X]*+X[^X]*X),如果输入字符串包含两次X,则为真。 如果条件为真,then 子句为(?&lt;dX&gt;),这是一个空捕获组,将匹配空字符串。 如果条件为假,dX 组将不匹配 接下来,我们懒惰地跳过字母表中的有效字母:[a-c]*? 我们在最后一场比赛中用\K将他们淘汰出局 现在,我们正在尝试匹配 一个dX 标志设置的字母。为此,我们将做一个条件分支:(?(dX)(*FAIL)|X) 如果匹配到dX(意味着X 是重复字符),我们(*FAIL),强制引擎回溯并尝试不同的字母。 如果dX匹配,我们会尝试匹配X。此时,如果成功,我们知道X 是第一个不重复的字母。

模式的最后一部分也可以替换为:

(?:
  a (*THEN) (?(da)(*FAIL))
| b (*THEN) (?(db)(*FAIL))
| c (*THEN) (?(dc)(*FAIL))
)

哪个有些更优化了。它匹配当前的字母 first 并且只有 then 检查它是否是重复的。

小写字母a-z 的完整模式如下所示:

# Anchor the pattern
\A

# For each letter, test to see if it's duplicated in the input string
(?(?=[^a]*+a[^a]*a)(?<da>))
(?(?=[^b]*+b[^b]*b)(?<db>))
(?(?=[^c]*+c[^c]*c)(?<dc>))
(?(?=[^d]*+d[^d]*d)(?<dd>))
(?(?=[^e]*+e[^e]*e)(?<de>))
(?(?=[^f]*+f[^f]*f)(?<df>))
(?(?=[^g]*+g[^g]*g)(?<dg>))
(?(?=[^h]*+h[^h]*h)(?<dh>))
(?(?=[^i]*+i[^i]*i)(?<di>))
(?(?=[^j]*+j[^j]*j)(?<dj>))
(?(?=[^k]*+k[^k]*k)(?<dk>))
(?(?=[^l]*+l[^l]*l)(?<dl>))
(?(?=[^m]*+m[^m]*m)(?<dm>))
(?(?=[^n]*+n[^n]*n)(?<dn>))
(?(?=[^o]*+o[^o]*o)(?<do>))
(?(?=[^p]*+p[^p]*p)(?<dp>))
(?(?=[^q]*+q[^q]*q)(?<dq>))
(?(?=[^r]*+r[^r]*r)(?<dr>))
(?(?=[^s]*+s[^s]*s)(?<ds>))
(?(?=[^t]*+t[^t]*t)(?<dt>))
(?(?=[^u]*+u[^u]*u)(?<du>))
(?(?=[^v]*+v[^v]*v)(?<dv>))
(?(?=[^w]*+w[^w]*w)(?<dw>))
(?(?=[^x]*+x[^x]*x)(?<dx>))
(?(?=[^y]*+y[^y]*y)(?<dy>))
(?(?=[^z]*+z[^z]*z)(?<dz>))

# Skip any duplicated letter and throw it away
[a-z]*?\K

# Check if the next letter is a duplicate
(?:
  a (*THEN) (?(da)(*FAIL))
| b (*THEN) (?(db)(*FAIL))
| c (*THEN) (?(dc)(*FAIL))
| d (*THEN) (?(dd)(*FAIL))
| e (*THEN) (?(de)(*FAIL))
| f (*THEN) (?(df)(*FAIL))
| g (*THEN) (?(dg)(*FAIL))
| h (*THEN) (?(dh)(*FAIL))
| i (*THEN) (?(di)(*FAIL))
| j (*THEN) (?(dj)(*FAIL))
| k (*THEN) (?(dk)(*FAIL))
| l (*THEN) (?(dl)(*FAIL))
| m (*THEN) (?(dm)(*FAIL))
| n (*THEN) (?(dn)(*FAIL))
| o (*THEN) (?(do)(*FAIL))
| p (*THEN) (?(dp)(*FAIL))
| q (*THEN) (?(dq)(*FAIL))
| r (*THEN) (?(dr)(*FAIL))
| s (*THEN) (?(ds)(*FAIL))
| t (*THEN) (?(dt)(*FAIL))
| u (*THEN) (?(du)(*FAIL))
| v (*THEN) (?(dv)(*FAIL))
| w (*THEN) (?(dw)(*FAIL))
| x (*THEN) (?(dx)(*FAIL))
| y (*THEN) (?(dy)(*FAIL))
| z (*THEN) (?(dz)(*FAIL))
)

这里是demo on regex101,带有单元测试。

如果您需要更大的字母表,您可以扩展此模式,但显然这不是通用解决方案。它主要具有教育意义,不应该用于任何严肃的应用。


对于其他风格,您可以尝试调整模式以用更简单的等价物替换 PCRE 功能:

\A 变为 ^ X (*THEN) (?(dX)(*FAIL)) 可以替换为 (?(dX)(?!)|X) 您可以丢弃\K 并将最后一个非capturnig 组(?:...) 替换为(?&lt;letter&gt;...) 之类的命名组,并将其内容视为结果。李>

唯一需要但有点不寻常的构造是条件组(?(cond)then|else)

【讨论】:

这确实回答了这个问题(作为一个 .NET 解决方案),但我必须指定并提及 不使用后视的可变长度功能,因为 OP 和其他答案已经谈到知道这一点,赏金就无法开始。打扰一下。我的错。 这是一个巧妙的解决方法,我最喜欢这部分 [a-z]*?\K。但是,正如您所指出的,这不是一个通用的解决方案。 +1。你可以在最后获得赏金。【参考方案2】:

即使您使用不限制后向固定长度字符串(例如 Matthew Barnett 的正则表达式)的 re 替代实现,正则表达式也不是该任务的最佳选择。

最简单的方法是计算字母的出现次数并以频率等于 1 打印第一个:

import sys
from collections import Counter, OrderedDict

# Counter that remembers that remembers the order entries were added
class OrderedCounter(Counter, OrderedDict):
    pass

# Calling next() once only gives the first entry
first=next

with open(sys.argv[1], 'r') as test_cases:
    for test in test_cases:
        lettfreq = OrderedCounter(test)
        print(first((l for l in lettfreq if lettfreq[l] == 1)))

【讨论】:

对引用 Matthew Barnett 的 regex 表示 +1。尽管我明确表示我对其他正则表达式模块不感兴趣,但旨在最终替换标准库中的reregex 模块是一个值得的例外。其余的答案对我没有太大帮助,因为我已经在没有正则表达式的情况下以类似的方式解决了问题,但仍然可能对其他用户有帮助。清晰的代码和简洁的解释,增加了很多其他答案没有涵盖的内容。【参考方案3】:

让我们以您的 tooth 为例 - 这是正则表达式引擎的作用(为了更好地理解而进行了很多简化)

t 开始,然后在字符串中向前看 - 并且向前看失败,因为还有另一个 t

tooth
^  °

接下来以o,在字符串中向前看 - 失败,因为还有另一个o

tooth
 ^°

接下来取第二个 o,在字符串中向前看 - 没有其他 o 存在 - 匹配它,返回它,工作完成。

tooth
  ^

所以你的正则表达式不匹配第一个未重复的字符,而是第一个,在字符串末尾没有进一步重复。

【讨论】:

谢谢。很好的例子!那么,由于re 对负面后视的限制,我是否已经接近了,或者您是否需要一种完全不同的方法来使用正则表达式来做到这一点? 我不认为你很接近(但可能会更正),但是我手头并没有真正的正则表达式解决方案。【参考方案4】:

您的正则表达式不起作用的原因是它不会匹配 跟随 相同字符的字符,但没有什么可以阻止它匹配不是 的字符em>后跟同一个字符,即使它前面有同一个字符。

【讨论】:

谢谢。斜体字确实帮助我磨练了区别。那么,由于re 对负面后视的限制,我是否已经接近了,或者您是否需要一种完全不同的方法来使用正则表达式来做到这一点?塞巴斯蒂安比你早 1 分钟得到它,但如果时间不同,我会接受你的回答。简洁,但完全回答了问题。 我不是正则表达式专家,但我相信你可以破解一个方法来做到这一点。但是,非正则表达式解决方案会更简单且更具可读性。 而且可能也有更好的性能。可变长度的前瞻和(如果我能破解它有效)后瞻对性能没有好处。我对该问题的非正则表达式解决方案是最多 2 次将字符串传递到 1。)计算每个字符的出现次数,将总和存储在字典中 2。)打印计数为 1 的第一个字符。虽然这种尝试确实帮助我学到了一些东西.再次感谢!

以上是关于正则表达式匹配第一个非重复字符的主要内容,如果未能解决你的问题,请参考以下文章

正则表达式的空值该如何写?

15.python正则匹配 元字符转义重复或捕获分组断言:零度断言负向零宽断言贪婪非贪婪引擎选项

JS正则表达式

非连字符的正则表达式匹配 - Python

正则表达式如何匹配多行的所有任意字符

JavaScript正则表达式,这一篇足矣