R

ReflexKR

Reflex 官方文档

看得见的正则表达式

·

课程概览

1

消失的支付记录:按原样查找文字

2

肇事逃逸车辆:字符类

3

撕破的电话簿:量词

4

虚假注册:锚点与校验

5

五花八门的日期:分组与选择

6

凌晨 3 点的访问:命名分组与 Match 对象

7

见报前夜:替换与分割

8

回溯者的挑战书:贪婪、懒惰与占有

9

保险箱的规则:前后查找

10

同样的手法:反向引用与条件模式

11

读不懂的正则:标志与编译

12

回溯者的真面目:总复习与分词器

看得见的正则表达式
第10课

时间 7:30–8:20

同样的手法:反向引用与条件模式

CASE-010

Grep & Co. 数据侦探事务所

委托书

同样的手法

委托人

姜队长

证物

寄给姜队长的三封恐吓信,外加昨天又来的一封。鉴证组把四封信逐行誊抄了下来。

破案条件

分辨出哪几封出自同一个人,并用模式把依据摆出来。

一个下雨的傍晚,姜队长把四个塑料证物袋摊在桌上。“恐吓信。三封是上周开始陆续寄来的,还有一封昨天才到,就在报纸登出报道之后。”所长扶了扶眼镜。“也就是说,混进了一个模仿的家伙。证据会留下模式。同一只手写的东西,带着同样的习惯。”

这是鉴证组把四封信逐行誊抄的记录。正文(msg=)是一行简短的英文,意思大概是“周五服务器会垮掉”“没人读得懂我”“终点见”“快交钱”。

#1 2026.10.18 key='K-17' tel=(010)-0000-1018 msg=the server server falls on friday
#2 2026-10-19 key="K-17" tel=010-0000-1019 msg=no one can can read me
#3 2026/10/20 key='K-17' tel=(010)-0000-1020 msg=see you At at the end
#4 2026-10/21 key="K-17' tel=(010-0000-1021 msg=pay now or else

乍一看,四封信都差不多。今天我们用反向引用来找习惯,这种语法问的是:“和前面抓到的一模一样的东西,有没有再次出现?”

10-1. 再找一次前面抓到的文字:\1(10 分钟)

所长指着第一封信。“server server,同一个词敲了两遍。第二封也有 can can。”看起来像是手误,可如果三封都有,那就是习惯了。

第 5 课学过分组编号。这个编号在模式内部也能调用。\1 的意思是“和第 1 组刚刚抓到的文字完全相同的文字”。这叫作反向引用(backreference)。

import re

line = "msg=the server server falls on friday"
m = re.search(r"\b(\w+)\s+\1\b", line)
print(m.group())    # server server
print(m.group(1))   # server
  • (\w+) 一旦抓到 server,从那一刻起 \1 就变成了 server 这串文字。
  • \1 并不是把模式 \w+ 再写一遍。所以它不会匹配两个不同的单词。
re.search(r"(\w+)\s+(\w+)", "the server").groups()   # ('the', 'server')
re.search(r"(\w+)\s+\1", "the server")               # None

前后的 \b 也很重要。去掉的话,连 can cannot 里的 can can 也会被当成“重复的单词”。

re.search(r"(\w+)\s+\1", "can cannot")       # <re.Match object; span=(0, 7), match='can can'>
re.search(r"\b(\w+)\s+\1\b", "can cannot")   # None

证据分析室

re.search(r"", text)

返回值

现在的模式随便两个单词都能抓。把第二个 \w+ 换成 \1,只找同一个单词出现两次的地方。

看分析室的结果,有个地方不对劲:第三封信的 At at 没被找出来。因为 A 和 a 是不同的字符。加上 re.I(忽略大小写,第 11 课会正式讲),反向引用在比较时也会忽略大小写。

re.findall(r"\b(\w+)\s+\1\b", letters)          # ['server', 'can']
re.findall(r"\b(\w+)\s+\1\b", letters, re.I)    # ['server', 'can', 'At']
print(re.search(r"(a)\1", "aA", re.I))          # <re.Match object; span=(0, 2), match='aA'>

这里的 letters 是装着上面四行信件内容的字符串。从结果看,同一个词敲两遍的习惯只出现在第 1、2、3 封信里。

10-2. 第 5 课的作业:分隔符要前后一致(8 分钟)

第 5 课里所长搁下了一个问题。用 [-./] 来挑分隔符的话,像 2026-10/20 这种混用分隔符的日期也会通过,因为字符类在每个位置上是各挑各的。用反向引用先抓住第一个分隔符,再要求第二个位置是同一个字符就行了。

re.fullmatch(r"\d{4}[-./]\d\d[-./]\d\d", "2026-10/21")   # <re.Match object; span=(0, 10), match='2026-10/21'>
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026-10/21")    # None
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026/10/20")    # <re.Match object; span=(0, 10), match='2026/10/20'>

命名分组(第 6 课)在模式内部也能调用,语法是 (?P=名字)。分组一多,名字比编号好读。

DATE = r"(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)"
m = re.search(DATE, "#3 2026/10/20 key='K-17'")
print(m.groupdict())   # {'y': '2026', 'sep': '/', 'm': '10', 'd': '20'}
  • 命名分组也有编号,所以在 (?P=sep) 的位置写 \2 也一样。
  • 调用同一个分组的写法,在不同场合各不相同:模式内部用 (?P=sep),替换字符串里用 \g<sep>(第 7 课),Match 对象上用 m["sep"]。

证据分析室

re.search(r"", text)

返回值

这是第 5 课的日期模式。把第一个 [-./] 用括号括起来,把第二个 [-./] 换成 \1,淘汰掉分隔符混用的日期。

10-3. 开引号与闭引号(7 分钟)

来看 key= 后面的值。第 1、3 封用单引号 'K-17',第 2 封用双引号 "K-17"。只有第 4 封是 "K-17',用双引号开头、单引号结尾。把开引号抓成分组,收尾时要求 \1,就能知道引号是否配对。

QUOTED = r'(["\']).*?\1'
for line in letters.splitlines():
    m = re.search(QUOTED, line)
    print(line[:2], m.group() if m else None)
# #1 'K-17'
# #2 "K-17"
# #3 'K-17'
# #4 None
  • Python 字符串里得同时放下两种引号,所以写成了 \'。在正则里,\' 就是普通的 '。
  • 中间写成 .*?(懒惰量词,第 8 课)是有原因的。写成 .* 的话,会从第一个引号一口气吃到最后一个同样的引号。
re.search(r'(["\']).*\1', '"a" and "b"').group()    # '"a" and "b"'
re.search(r'(["\']).*?\1', '"a" and "b"').group()   # '"a"'

证据分析室

re.search(r"", text)

返回值

现在的模式把开引号和闭引号分开挑选,所以引号不配对也能通过。把开引号那边抓成分组,再把闭引号那边换成 \1。通过之后,也看看 findall 的结果。

可是把同一个模式放进 findall,结果却很奇怪。

re.findall(QUOTED, letters)                        # ["'", '"', "'"]
[m.group() for m in re.finditer(QUOTED, letters)]  # ["'K-17'", '"K-17"', "'K-17'"]

这正是第 5 课学过的规则:只要有一个分组,findall 就只返回那个分组。而这次也没法改成 (?:...),因为那样 \1 要调用的分组就没了。

多特的提醒

我一开始也用 findall 去收集配对的引号,结果只叼回来一大把引号字符。反向引用离不开分组,所以需要整个匹配时,就用 finditer 取 m.group()。也可以把整个模式再用括号包一层,不过那样编号会往后挪,\1 就变成 \2 了。

10-4. 分组抓到了就用这个,否则用那个:条件模式(10 分钟)

来看电话号码 tel=。第 1、3 封像 (010)-0000-1018 这样把区号用括号括起来,第 2 封没有括号。第 4 封是 (010-0000-1021,只有左括号。“有左括号,就必须有右括号”这条规则,用条件模式来写。

(?(1)yes|no) 的意思是:“如果第 1 组参与了匹配,就用 yes 模式,否则用 no 模式。”no 的部分可以省略。(?(1)yes) 就是“有那个分组就要求 yes,没有就什么都不要求”。

PHONE = r"(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}"
for tel in ["(010)-0000-1018", "010-0000-1019", "(010-0000-1021", "010)-0000-1021"]:
    print(tel, bool(re.fullmatch(PHONE, tel)))
# (010)-0000-1018 True
# 010-0000-1019 True
# (010-0000-1021 False
# 010)-0000-1021 False
  • (\()?:把左括号抓成第 1 组,没有也可以。
  • (?(1)\)):第 1 组抓到了就要求右括号,没抓到就直接通过。

命名分组则用名字而不是编号来设条件,语法是 (?(名字)yes|no)。下面的规则是:列表编号写成 (1) 的要以右括号结尾,像 2. 这样没有括号的要以句点结尾。

ITEM = r"(?P<p>\()?\d+(?(p)\)|\.)"
for s in ["(1)", "2.", "(3.", "4)"]:
    print(s, bool(re.fullmatch(ITEM, s)))
# (1) True
# 2. True
# (3. False
# 4) False

证据分析室

re.search(r"", text)

返回值

现在的模式里 \(? 和 \)? 互不相识,所以只有一边括号也能通过。把左括号写成 (\()? 来抓,再把右括号换成 (?(1)\))。

就算破案条件全部通过,看分析室里第 4 封信那一行,010-0000-1021 依然被标了出来。search 和 finditer 可以从任何位置开始匹配,所以它跳过左括号,从后面开始了。在前面加上 tel=,把起始位置钉住。

re.search(PHONE, "tel=(010-0000-1021").group()           # '010-0000-1021'
re.search(r"tel=" + PHONE + r"\b", "tel=(010-0000-1021")  # None

10-5. \10 是第几组(5 分钟)

反向引用的编号变成两位数时就容易搞混。模式里的 \10 不是“第 1 组后面跟字符 0”,而是第 10 组。没有第 10 组就报错,也不会被当作八进制字符来读。

re.compile(r"(a)\10")
# re.PatternError: invalid group reference 10 at position 4

p = re.compile(r"(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)\10")
print(p.fullmatch("abcdefghijj"))   # <re.Match object; span=(0, 11), match='abcdefghijj'>
  • 想要“第 1 组后面跟字符 0”,就像 \1[0] 这样把 0 放进字符类,隔开。
  • 替换字符串里用的 \g<1>0(第 7 课)在模式里不能用。在模式里,\g 是未知的转义,会报错。
  • 只有以 \0 开头或者正好三位数字时,才是八进制字符(\101 是 A)。第 12 课还会再看到。
print(re.fullmatch(r"(a)\1[0]", "aa0"))   # <re.Match object; span=(0, 3), match='aa0'>

破案(10 分钟)

把四种习惯写成规则,数一数每封信符合几条。

RULES = {
    "반복": re.compile(r"\b(\w+)\s+\1\b", re.I),
    "날짜": re.compile(r"\d{4}([-./])\d\d\1\d\d"),
    "따옴표": re.compile(r'key=(["\']).*?\1'),
    "괄호": re.compile(r"tel=(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}\b"),
}
for line in letters.splitlines():
    hits = [name for name, rx in RULES.items() if rx.search(line)]
    print(line[:2], hits)
# #1 ['반복', '날짜', '따옴표', '괄호']
# #2 ['반복', '날짜', '따옴표', '괄호']
# #3 ['반복', '날짜', '따옴표', '괄호']
# #4 []

规则名是韩文:반복 是“重复”,날짜 是“日期”,따옴표 是“引号”,괄호 是“括号”。

第 1、2、3 封的四种习惯完全一致:同一个词敲两遍,分隔符只用一种,引号和括号一次都没配错。第 4 封只模仿了外表,习惯一个也没有。

姜队长咂了咂嘴。“这么说,第 4 封是看了报道来捣乱的,真货是那三封。”所长点点头。“真货很细致。是个连一对括号都不会配错的家伙。”

结案报告 — 恐吓信第 1、2、3 封出自同一人(回溯者)之手。重复的单词、一致的日期分隔符、引号与括号的配对全部吻合。第 4 封判定为模仿信,另行移交姜队长。

正要合上报告,所长又拿起了信件原件。有几行是鉴证组没有誊抄的:三封真信的最底下,各有一行只由括号和反斜杠组成的怪字符。

练习

  1. 写一个模式,找出像 0000 这样同一个数字连续出现四次的地方。提示:\1 后面也可以加量词。
  2. 在 <b>bold</b> <i>x</b> 里,只找出开始标签和结束标签名字相同的部分。结果应该只有 <b>bold</b> 一个。
  3. 用条件模式写一个规则:邮箱地址如果以 < 开头,就必须以 > 结尾。<kim@example.com>、kim@example.com 要通过,<kim@example.com 要失败。

回溯者的踪迹

(a+)+$

三封信的最后一行。按顺序接起来,就成了一行。——如果你们读得懂的话。

所长的笔记本

  • 模式里的 \1 是和第 1 组实际抓到的文字完全相同的文字,不是把模式再写一遍。

  • 重复单词 \b(\w+)\s+\1\b,统一日期分隔符 \d{4}([-./])\d\d\1\d\d。

  • 命名分组用 (?P=name) 再次调用。替换里用 \g<name>,Match 上用 m['name']。

  • 引号配对 (["']).*?\1。findall 只返回引号,要整体就用 finditer。

  • 条件模式 (?(1)yes|no)、(?(name)yes|no)。no 可以省略:(?(1)yes)。

  • re.I 下反向引用也忽略大小写。\10 是第 10 组,第 1 组后面跟 0 要写 \1[0]。

← 上一课

第9课 · 保险箱的规则:前后查找

下一课 →

第11课 · 读不懂的正则:标志与编译

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex