R

ReflexKR

Reflex 官方文档

看得见的正则表达式

·

课程概览

1

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

2

肇事逃逸车辆:字符类

3

撕破的电话簿:量词

4

虚假注册:锚点与校验

5

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

6

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

7

见报前夜:替换与分割

8

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

9

保险箱的规则:前后查找

10

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

11

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

12

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

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

时间 1:40–2:30

撕破的电话簿:量词

CASE-003

Grep & Co. 数据侦探事务所

委托书

撕破的电话簿

委托人

胡同里“野花”花店的老板

证物

一本被雨淋湿撕破的熟客电话簿,用 OCR 识别出来的名单。分隔符五花八门,还混进了莫名其妙的数字。

破案条件

从名单里收集所有的电话号码和邮政编码。

早上一上班,所长已经把昨天写的车牌模式投在了屏幕上。“与其把 \d 写四遍,不如直接写‘四遍’。今天学这个。”这时门开了,胡同里花店的老板抱着一叠湿透的纸走进来。“熟客的电话簿全被雨淋湿了。我扫描后转成了文字,可乱得一塌糊涂。”

OCR 识别出来的名单如下。每个号码的分隔符都不一样,最后一行看着也有点不对劲。

kim   010-0000-1234   04542
lee   010 0000 9876   04545
park  02-0000-5678    04538
choi  010.0000.4321   04539
yoon  1234-5678-9012

用来写“同一个东西出现几次”的语法,叫作量词。今天就用量词来整理这份名单。

3-1. 正好几次:{m} 和 {m,n}(7 分钟)

量词决定紧挨在前面的那一块重复几次。\d{4} 和 \d\d\d\d 是一样的。来把第 2 课的车牌模式缩短一下。

re.findall(r"\d{2}[가-힣]\d{4}", "12가3457 123가4567")   # ['12가3457', '23가4567']

是变短了,可问题也露出来了。2019 年起发行的新式车牌,前面是三位数。从 123가4567 里,它丢掉了开头的 1,只截下了 23가4567。

{m,n} 表示至少 m 次、至多 n 次。把前面改成 \d{2,3},两位和三位就都能接住了。

re.findall(r"\d{2,3}[가-힣]\d{4}", "12가3457 123가4567")   # ['12가3457', '123가4567']
  • 像 {2,3} 这样,逗号前后不要加空格。加了空格就不再是量词了(见下面多特的提醒)。
  • 邮政编码是五位数字,写成 \d{5}。

证据分析室

re.search(r"", text)

返回值

这是第 2 课的长模式。用 \d{2,3} 和 \d{4} 把它缩短,让新式三位数车牌也能通过。

3-2. 以上、以下:{m,} 和 {,n}(6 分钟)

{m,n} 的其中一边可以空着不写。

  • {m,}:m 次以上,上不封顶
  • {,n}:0 次以上、n 次以下。因为 0 次也可以,所以连一个字符都没有的位置也能匹配
re.findall(r"a{2,}", "a aa aaaa")   # ['aa', 'aaaa']
re.findall(r"\d{3,}", "1 22 333 4444")   # ['333', '4444']
re.findall(r"\d{,2}", "123")        # ['12', '3', '']

最后那个结果末尾的 '',是在字符串最末尾、数字为 0 个的位置上匹配到的。这种空匹配在 3-6 里细说。

如果 { 不是量词的样子,它就被当成普通字符。a{x} 找的是 a、{、x、} 这四个字符。

re.findall(r"a{x}", "a{x} ax")      # ['a{x}']
re.findall(r"a{2, 3}", "aa a{2, 3}")   # ['a{2, 3}']

多特的提醒

我像 a{2, 3} 这样在逗号后面多敲了一个空格,结果它一声不吭,悄悄找了个莫名其妙的东西。不像量词的话,它就只当普通字符看嘛。没报错,不代表模式就是对的。花括号里只写数字和逗号哦。

3-3. 最常用的三个:* + ?(8 分钟)

最常用的几种次数,各有一个单字符的名字。

量词含义等价写法
*0 次以上{0,}
+1 次以上{1,}
?0 次或 1 次(可有可无){0,1}
re.findall(r"ab*", "a ab abbb")      # ['a', 'ab', 'abbb']
re.findall(r"ab+", "a ab abbb")      # ['ab', 'abbb']
re.findall(r"ab?", "a ab abbb")      # ['a', 'ab', 'ab']
re.findall(r"colou?r", "color colour colouur")   # ['color', 'colour']

? 正适合“可有可无的字符”。名单里的手机号,分隔符有连字符、空格、句点,各不相同。把分隔符的位置写成 [-. ]?,三者有一个也行,完全没有也行。

re.findall(r"010[-. ]?\d{4}[-. ]?\d{4}", phonebook)
# ['010-0000-1234', '010 0000 9876', '010.0000.4321']

照第 2 课学的,方括号里的 . 是真正的句点,- 在最前面,所以也是字符本身。

证据分析室

re.search(r"", text)

返回值

这个模式只认连字符。把分隔符的位置改成 [-. ]?,让空格、句点、没有分隔符的情况都能通过。

3-4. 量词只管紧挨在前面的那一块(5 分钟)

量词只作用于紧挨在前面的那一块。一块,指的是一个字符、一个方括号字符类,或者一个 \d 这样的简写。所以 ab+ 不是“ab 重复多次”,而是“a 后面跟着多个 b”。

re.findall(r"ab+", "abbb abab")      # ['abbb', 'ab', 'ab']
re.fullmatch(r"ab+", "abab")         # None
re.fullmatch(r"(ab)+", "abab")       # <re.Match object; span=(0, 4), match='abab'>

想让多个字符一起重复,就用括号把它们包成一块。(ab)+ 是“ab 出现 1 次以上”。括号除了打包以外还有一个重要的作用,第 5 课再学。

re.fullmatch 只有在整个字符串都符合模式时才匹配。具体区别第 4 课再讲。

证据分析室

re.search(r"", text)

返回值

ha+ 只重复 a。用括号把整个 ha 包起来再重复。(切换成 findall 的话结果看起来会很奇怪,原因第 5 课揭晓。)

3-5. 量词很贪心(4 分钟)

量词默认是贪婪的(greedy):在允许的范围内,能拿多少就拿多少。

re.search(r"\d+", "tel 0100000")     # <re.Match object; span=(4, 11), match='0100000'>
re.search(r"\d{2,4}", "123456")      # <re.Match object; span=(0, 4), match='1234'>
re.findall(r"\d{2,4}", "123456")     # ['1234', '56']

\d{2,4} 两位也行,但它先拿走四位。只有后面的模式失败时,它才一个一个地吐回来。这种“吐回来再试一次”叫作回溯,到了第 8 课,它就是让事务所停摆的元凶。少拿一点的懒惰量词 +?,也到那时再学。

3-6. 全部找出来:findall 和 finditer(10 分钟)

现在把整份名单里找到的东西收集起来。假设上面的名单存放在字符串 phonebook 里。

re.findall 把匹配到的文字以字符串列表的形式返回。这是模式里没有括号时的行为;有括号时怎样,第 5 课再看。

re.findall(r"\d{5}", phonebook)     # ['04542', '04545', '04538', '04539']

re.finditer 返回一个逐个取出 Match 对象的迭代器。连位置(span)都需要时就用它。

for m in re.finditer(r"\d{5}", phonebook):
    print(m.group(), m.span())
# 04542 (22, 27)
# 04545 (50, 55)
# 04538 (78, 83)
# 04539 (106, 111)
  • 迭代器是需要时才一个个去找,所以即使字符串很长也省内存。
  • 迭代器用完一遍就没了。想再遍历一次,就重新调用 re.finditer。

这里要小心的是空匹配。*、?、{,n} 都允许 0 次,所以在一个字符都没有的位置也能匹配成功。findall 也会把这种空匹配当作 '' 放进结果里。

re.findall(r"\d*", "a12b")    # ['', '12', '', '']
re.findall(r"\d+", "a12b")    # ['12']

a 前面一个空匹配,然后是 12,b 前面一个空匹配,字符串末尾又一个空匹配。要收集什么东西时,就用不允许 0 次的 + 或 {1,}。

证据分析室

re.search(r"", text)

返回值

\d* 在每个没有字符的位置都会留下一个空字符串。把 * 改成 +,只收集成块的数字。

破案(10 分钟)

电话号码是:区号或开头 23 位,中间 34 位,最后 4 位。分隔符可有可无。邮政编码是五位数。

phones = re.findall(r"\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}", phonebook)
print(phones)
# ['010-0000-1234', '010 0000 9876', '02-0000-5678', '010.0000.4321', '234-5678-9012']

postcodes = re.findall(r"\d{5}", phonebook)
print(postcodes)   # ['04542', '04545', '04538', '04539']

四个电话号码和四个邮政编码都收齐了。可第五个“电话号码” 234-5678-9012 是什么?花店老板看了一眼屏幕,摇摇头:“那不是电话号码,是我记下的银行卡号。”

re.search(r"\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}", "yoon  1234-5678-9012")
# <re.Match object; span=(7, 20), match='234-5678-9012'>

模式跳过了 1234 的第一个字符 1,从数字正中间的第 7 号位置开始匹配。这和 3-1 里的 23가4567 是同一种事故。

证据分析室

re.search(r"", text)

返回值

这是破案用的模式。看看最后一行的高亮,是不是跳过 1 才开始的。这个问题第 4 课来解决。

结案报告 — 从 OCR 名单中恢复出电话号码 4 条、邮政编码 4 条,已交给花店老板。银行卡号的一部分被误当成电话号码的问题,暂列为未解决。

所长盯着最后一行看了很久。“从数字中间开始了。这个模式不知道号码从哪儿开始、到哪儿结束。”下一课,就来学怎样把这个边界写进模式里。

练习

  1. 先猜猜 \d{2,3}[-. ]?\d{3,4}[-. ]?\d{4} 在 01000001234(没有分隔符的号码)里会找到什么,再运行看看。
  2. 猜猜 re.findall(r"\d?", "a1") 的结果。空字符串有几个?
  3. 用 re.finditer 找出名单里的电话号码,每行打印出号码和它的起始位置。

所长的笔记本

  • {m} 正好 m 次,{m,n} 是 m~n 次,{m,} 是 m 次以上,{,n} 是 0~n 次。逗号旁边不许有空格。

  • * 是 0 次以上,+ 是 1 次以上,? 是 0 次或 1 次。

  • 量词只管紧挨在前面的那一块。ab+ 和 (ab)+ 不一样。

  • 量词是贪婪的,能拿多少就拿多少。

  • re.findall 返回字符串列表,re.finditer 返回 Match 迭代器。

  • 允许 0 次的模式会产生空匹配 ''。要收集东西就用 +。

← 上一课

第2课 · 肇事逃逸车辆:字符类

下一课 →

第4课 · 虚假注册:锚点与校验

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex