关于python:正则表达式匹配第一个非重复字符

Regular Expression Matching First Non-Repeated Character

DR

EDCOX1〔0〕与文本中包含的第一个非重复字符不匹配(它总是在第一个非重复字符之前或之前返回字符,或者在没有重复字符的情况下,在字符串结束之前返回字符)。我的理解是Re.Sqh()如果没有匹配,应该返回任何一个。我只感兴趣理解为什么这个正则表达式不是用Python EDCOX1 1模块来工作的,而不是用任何其他方法来解决这个问题。

全背景

问题描述来自https://www.codeeval.com/open_challenges/12/。我已经用一个非regex方法解决了这个问题,但是我重新讨论了它,以扩展我对Python的re模块的理解。我认为可以工作的正则表达式(命名的和未命名的backreferences)是:

(?P.)(?!.*(?P=letter))(.)(?!.*\1)(同样的结果是python2和python3)

我的整个程序看起来像这样

1
2
3
4
5
6
7
8
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()
             )

一些输入/输出对是:

1
2
3
4
5
6
7
8
9
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)只返回regex模式产生匹配的第一个位置(如果找不到匹配,则返回none)。
  • .group()相当于.group(0)返回整个匹配

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

1
2
3
4
5
6
7
8
9
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 StackOverflow

我知道lookbehind和negative lookbehind仅限于3个字符的最大固定长度字符串,即使它们的计算结果为固定长度字符串,也不能包含backreference,但我没有看到文档指定任何对negative lookbehind的限制。


我们以你的tooth为例-这里是regex引擎所做的(为了更好地理解,简化了很多)

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

1
2
tooth
^  °

接下来,以o为例,展望未来——然后失败,因为还有另一个o

1
2
tooth
 ^°

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

1
2
tooth
  ^

所以你的regex不匹配第一个未重复的字符,但是第一个,在字符串的末尾没有更多的重复。


塞巴斯蒂安的回答已经很好地解释了为什么你目前的尝试不起作用。

.NET

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

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

演示链接

这是因为.NET支持可变长度的lookbehinds。您还可以使用python获得这个结果(见下文)。

因此,对于每个字母(?.),我们检查:

  • 如果在输入(?!.*?\k)中进一步重复
  • 如果在(?.+?)之前就已经遇到过(我们必须在返回时跳过正在测试的字母,因此是+)。

Python

python regex模块还支持可变长度的lookbehinds,因此上面的regex将使用一个小的语法变化:您需要用\g替换\k(这与此模块\g是一个组回引用非常不幸,而pcre是一个递归)。

正则表达式为:

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

下面是一个例子:

1
2
3
4
5
6
7
$ 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不支持可变长度的lookbehinds,我们需要以某种方式记住在输入中是否已经遇到了给定的字母。

不幸的是,regex引擎不提供随机访问内存支持。就通用内存而言,我们能得到的最好的是一个堆栈——但这还不够,因为一个堆栈只允许我们访问其最顶层的元素。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 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)
)

这是如何工作的:

  • 首先,EDCOX1×8的锚点确保我们只处理输入字符串一次。
  • 然后,对于每一个字母EDOCX1,9的字母表,我们将设置一个IS复制标志dX
    • 条件模式EDOCX1,11,在这里使用:
      • 条件是EDOCX1,12,如果输入字符串包含EDCOX1,9,两次,那么这是正确的。
      • 如果条件为真,那么then子句是(?),它是一个空捕获组,将匹配空字符串。
      • 如果条件为假,则不会匹配dX
    • 接下来,我们懒散地跳过字母表中的有效字母:[a-c]*?
    • 我们在与埃多克斯的最后一场比赛中淘汰了他们。
    • 现在,我们试图匹配一个没有设置dX标志的字母。为此,我们将做一个有条件的分支:(?(dX)(*FAIL)|X)
      • 如果dX匹配(意味着X是一个重复字符),我们将(*FAIL)强制引擎回溯并尝试不同的字母。
      • 如果dX不匹配,我们尝试匹配X。在这一点上,如果成功的话,我们知道X是第一个不重复的字母。

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

1
2
3
4
5
(?:
  a (*THEN) (?(da)(*FAIL))
| b (*THEN) (?(db)(*FAIL))
| c (*THEN) (?(dc)(*FAIL))
)

这是更优化的。它首先匹配当前字母,然后才检查它是否是副本。

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

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 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))
)

这是关于regex101的演示,包括单元测试。

如果需要更大的字母表,可以扩展这个模式,但显然这不是一个通用的解决方案。它主要是出于教育目的,不应用于任何严重的应用。

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

  • \A变为^
  • X (*THEN) (?(dX)(*FAIL))可改为(?(dX)(?!)|X)
  • 您可以扔掉\k,将最后一个未捕获的组(?:)替换为命名组,如(?),并将其内容视为结果。

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


正则表达式对于任务来说不是最优的,即使使用RE的替代实现,RE不限制固定长度字符串(如Matthew Barnett的正则表达式)的后视。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)))


regex不起作用的原因是它将不匹配后面跟着同一个字符的字符,但是没有什么可以阻止它匹配后面不跟着同一个字符的字符,即使前面跟着同一个字符。