CASE-009
Grep & Co. 数据侦探事务所
委托书
委托人
韩星所长
证物
事务所那台老旧的保险箱、六个新密码候选、保险箱里的账本。
破案条件
用一个模式检查新密码规则(8 个字符以上,数字、小写字母、特殊字符各至少一个),并给账本金额加上千位分隔逗号。
所长在角落那台旧保险箱前蹲了下来。“回溯者提保险箱,只是想吓唬人。不过密码今天就换。这回要老老实实按规则来。”
新密码的规则有三条,顺序不限。
규칙 1. 8자 이상
규칙 2. 숫자, 소문자, 특수문자가 각각 하나 이상
규칙 3. 이 모든 걸 정규식 하나로 검사한다
후보:
vault2026!
vault2026
VAULT2026!
v!2
longpassword!
safe#2026box所长的便条是用韩文写的:规则 1,8 个字符以上;规则 2,数字、小写字母、特殊字符各至少一个;规则 3,只用一个正则检查全部条件。下面的“후보”是“候选”。
你想起卡片上的那句话:“前提是你们能在同一个位置把它们全部确认。”今天要学的语法正是这个。
回想一下第 4 课的 ^、$、\b。它们一个字符也不吃,只检查“这里是不是开头”“这里是不是单词边界”这样的位置。这类匹配叫作零宽(zero-width)匹配。
re.findall(r"\b", "hi yo") # ['', '', '', '']
re.sub(r"\b", "|", "hi yo") # '|hi| |yo|'\b 找到的是四个空字符串,也就是四个位置。今天要学的前后查找,就是把这种“位置检查”换成你自己想要的条件的语法。
X(?=Y) 寻找“后面跟着 Y 的 X”。Y 只用来检查,不算进匹配里。这叫作向前查找(lookahead)。
re.findall(r"\d+(?=원)", "3000원짜리 5개, 합계 15000원") # ['3000', '15000']这句韩文的意思是“3000 元的 5 个,合计 15000 元”,원 是韩元,개 是“个”。5개 里的 5 后面没有 원,所以被排除了;找到的数字里也不含 원。想找“以元结尾的数字”却只取出数字本身时,它再合适不过。
证据分析室
re.search(r"", text)
返回值
反过来,X(?!Y) 是“后面不跟着 Y 的 X”,叫作否定向前查找。
re.findall(r"\b(?!tmp)\w+", "tmp_log report tmpfile final") # ['report', 'final']
re.findall(r"\d{3}(?!\d)", "12345 678") # ['345', '678']tmp 开头的单词”。在单词边界的紧后面检查接下来是不是 tmp。像密码规则这种“这个要有、那个要有、另一个也要有”的条件,就把向前查找并排接起来写。
pw = r"(?=.*\d)(?=.*[a-z])(?=.*[^\w\s]).{8,}"| 片段 | 检查的内容 |
|---|---|
(?=.*\d) | 某处有数字 |
(?=.*[a-z]) | 某处有小写字母 |
(?=.*[^\w\s]) | 某处有特殊字符(不是字母、数字、下划线、空白的字符) |
.{8,} | 真正吃下 8 个以上的字符 |
三个向前查找都在**同一个位置(最开头)**检查。它们不吃字符,所以一个查完位置不变,下一个条件又从开头开始。因此接在一起就成了“并且(AND)”。
cands = ["vault2026!", "vault2026", "VAULT2026!", "v!2", "longpassword!", "safe#2026box"]
[c for c in cands if re.fullmatch(pw, c)] # ['vault2026!', 'safe#2026box']证据分析室
re.search(r"", text)
返回值
多特的提醒
向前查找只是往前偷瞄一眼,脚可不动哦。就像我蹲在窗台上往外看,从来不跳出去。所以不管瞄多少次,我都还在原地。
把方向反过来,就是向后查找(lookbehind)。(?<=Y)X 是“前面有 Y 的 X”,(?<!Y)X 是“前面没有 Y 的 X”。
re.findall(r"(?<=\$)\d+", "$30 and 40 and $5") # ['30', '5']
re.findall(r"(?<!\$)\b\d+", "$30 and 40 and $5") # ['40']向后查找有一个限制:Python 的 re 要求往回看的长度是固定的。
re.compile(r"(?<=a+)b") # PatternError: look-behind requires fixed-width pattern
re.compile(r"(?<=ab|cd)e") # 没问题:两个选项都是 2 个字符
re.compile(r"(?<=a|bc)d") # PatternError: look-behind requires fixed-width pattern就算有多个选项,只要长度都一样就行。长度不同的话,就把向后查找拆成两个,像 (?:(?<=a)|(?<=bc))d 这样分开写。
证据分析室
re.search(r"", text)
返回值
保险箱里账本上的数字像 1234567 这样挤在一起,很难读。找到该加逗号的位置,在那里插入逗号就行。既然是找位置,前后查找最合适。
re.sub(r"(?<=\d)(?=(?:\d{3})+(?!\d))", ",", "1234567 and 89 and 1000")
# '1,234,567 and 89 and 1,000'读法是这样的:“前面是数字((?<=\d)),后面接着一个以上的三位数字组((?:\d{3})+),并且数字到那里就结束((?!\d))的位置。”这个位置不是字符,所以 sub 不会删掉任何字符,只是把逗号塞进去。
在向前查找里放一个分组,还能找出相互重叠的匹配。诀窍是每次只把位置挪一格,再用分组取出那个位置上的两个字符。
re.findall(r"(?=(\w\w))", "abc") # ['ab', 'bc']证据分析室
re.search(r"", text)
返回值
完成检查器,把候选筛一遍。
pw = re.compile(r"(?=.*\d)(?=.*[a-z])(?=.*[^\w\s]).{8,}")
for c in cands:
print(f"{c:<14} {'통과' if pw.fullmatch(c) else '탈락'}")
# vault2026! 통과
# vault2026 탈락
# VAULT2026! 탈락
# v!2 탈락
# longpassword! 탈락
# safe#2026box 통과통과 是“通过”,탈락 是“淘汰”。
通过的两个,所长一个也没选。“写进候选名单的那一刻,它就不是密码了。”说完,她避开所有人的视线,亲手定了一个符合规则的新密码,只把检查器放进事务所的公用工具箱。
结案报告 — 做出一个用三个向前查找检查保险箱密码规则的工具。六个候选中只有两个符合规则。密码已由所长另行更换。账本金额已加上千位分隔逗号。
"price: 1200won, tax: 120won" 中只取出 won 前面的数字。re.findall(r"(?<!\d)\d{2}(?!\d)", "1 22 333 44") 的结果。为什么 333 的一部分不会出现?回溯者的踪迹
(a+)+$
保险箱就先放着吧。不过,我给警局的姜队长寄了三封信。你们能证明这三封出自同一个人之手吗?习惯会留在字里行间。
所长的笔记本
前后查找是零宽匹配。不吃字符,只检查位置(和 ^ $ \b 同类)。
X(?=Y) 后面跟着 Y 的 X,X(?!Y) 后面不跟 Y 的 X。
(?<=Y)X 前面有 Y 的 X,(?<!Y)X 前面没有 Y 的 X。向后查找只能是固定长度。
向前查找接在一起就是 AND:(?=.*\d)(?=.*[a-z])(?=.*[^\w\s]).{8,}
找到位置再 sub,就能不删字符只插入:千位分隔逗号。
像 (?=(\w\w)) 这样在向前查找里放分组,可以取出相互重叠的匹配。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。