正则表达式

把正则当成一门小语言来记:先是字符和数量,再是位置和分组,最后是"哪个引擎支持哪些语法"。第三部分最容易翻车——同一段表达式在 grep、Python 和 JavaScript 里的行为可能不一样。

基本构件

写法匹配备注
.任意一个字符默认不含换行,除非开 s 标志
\.字面量点号元字符要匹配自身就加反斜杠
[abc]a、b、c 中任意一个方括号内多数元字符失去特殊含义
[^abc]除这三个之外的字符^ 在方括号内表示取反
[a-z0-9_]范围范围按码位排序,[z-a] 是错的
\d \w \s数字 / 单词字符 / 空白部分引擎中文也算 \w,见方言一节
\D \W \S上面三个的补集
* + ?0+ / 1+ / 0 或 1 次
{2,5}2 到 5 次{3} 恰好 3 次;{2,} 至少 2 次
|或优先级最低,a|bc 是 a 或 bc
^ $行首 / 行尾多行模式下是每行首尾
(…)分组并捕获

量词的三种模式

默认是贪婪的:在能满足整体匹配的前提下尽量多吃。加 ? 变懒惰(尽量少吃),加 + 变占有(吃完不吐)。用一段带引号的文本区分它们:

输入: "a" and "b"
".*"    贪婪   → "a" and "b"     一口吃到最后一个引号
".*?"   懒惰   → "a"             吃到最近的引号就停
".*+"   占有   → 匹配失败         吃光后不回退,找不到闭合引号

占有量词(*+、++、?+、{n,m}+)主要在 Java、PCRE、Python regex 模块里有,标准 re 不支持。它的价值是防止回溯,但通常用原子组或改写表达式更通用。

分组

写法含义取用
(abc)捕获组,编号按左括号顺序从 1 开始Python m.group(1),JS $1
(?:abc)只分组不捕获减少无用分组、略微提速
(?P<name>abc)命名组(Python)m.group('name')
(?<name>abc)命名组(.NET / JS / PCRE)$<name>
(?=x) (?!x)正向 / 负向前瞻,不消耗字符
(?<=x) (?<!x)正 / 负向后顾多数引擎要求定长

反向引用 \1 表示"和第一组匹配到的内容完全相同",不是"再匹配一次第一组的模式"。找重复词很好用:

\b(\w+)\s+\1\b      # 匹配 "the the" 这类连续重复的词

边界与位置

\b      单词边界(一边是 \w,一边不是)
\B      非单词边界
^  $    行首 / 行尾
\A  \z  整个字符串的开头 / 结尾(Python 里是 \A 和 \Z)

最容易忽略的是 \b 对中文的行为:中文不属于 \w,所以"中文\b边界"的判定和直觉不同。处理中文文本时,宁可显式写字符范围,也不要依赖 \b。

常见模式

下面这些可以直接用,但请先读每条的说明——正则能校验格式,不能校验语义。

IPv4 地址

\b(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}\b

四段各取 0–255,写法是按范围从大到小枚举:250–255、200–249、100–199、0–199 剩下的部分。[0-9]{1,3} 这种看着简单的写法会放过 999.1.1.1。

日期(YYYY-MM-DD)

^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$

它只保证月份 01–12、日期 01–31,不保证 2 月没有 30 号。真正的日期合法性交给语言自带的日期库去判断,不要试图用正则写完整个闰年规则。

时间(24 小时制)

^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$

十六进制颜色

#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b

六位写在三位前面,否则三位那一支会先吃掉前三个字符,把后面的内容留成垃圾。多选分支的顺序会影响结果,这是写交替时最常见的错误。

解析一行访问日志

^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) ([^"]*?)(?: HTTP/[\d.]+)?" (\d{3}) (\d+|-)

分别得到客户端地址、时间、方法、路径、状态码、字节数。[^\]]+ 这种"取到某个字符为止"的写法,比 .+? 更稳,也更不容易触发回溯。

去重连续空白 / 去掉行尾空白

[ \t]+        → 单空格
[ \t]+$

中文字符

[\u4e00-\u9fff]        基本汉字区
[^\x00-\x7f]           所有非 ASCII 字符(更宽松)

汉字在 Unicode 里分散在多个区段,基本区之外还有扩展 A–G。要"包含所有汉字"就得把多个区段拼起来,实际项目里通常用语言提供的 Unicode 属性,例如 \p{Script=Han}(需要引擎支持)。

性能:小心灾难性回溯

嵌套量词是主要来源。看这个表达式:

^(a+)+$

输入 aaaa…aaa(30 个 a)时它很快;输入 aaaa…aaab 时,引擎要尝试把 30 个 a 分成两组的所有可能组合,耗时随长度指数增长。这类表达式遇到用户输入就是一次拒绝服务。

三条实用原则:

反过来,如果用的是 Go、RE2 这类基于自动机的引擎,就不存在回溯爆炸——代价是不支持反向引用和前后查找。选引擎时先想清楚要哪一边。

方言差异

特性POSIX ERE(grep -E)PCRE / Python reJavaScriptGo(RE2)
\d \w \s标准里没有,GNU 部分支持支持支持支持
懒惰量词 *?不支持支持支持支持
前后查找不支持支持(后顾需定长)支持不支持
反向引用不支持支持支持不支持
命名组不支持(?P<n>…)(?<n>…)(?P<n>…)

另外两个常被忽略的点:grep 默认按行匹配,. 永远碰不到换行;而 Python 的 re.match 只从开头匹配(等价于加了 ^),re.search 才是全串搜索。写 ^…$ 时如果字符串结尾有换行,Python 的 $ 允许在换行前匹配,用 \Z 更严格。

调试建议

  1. 先写测试串。列出 3 个应该匹配、3 个不该匹配的样本,再动手写表达式。没有样本的正则无法验证。
  2. 从宽到窄。先写出能匹配的最小表达式,再逐个加约束,每加一个就跑一次测试串。
  3. 别怕加空格。大部分语言支持 x 标志,忽略空白并允许 # 注释,长表达式可读性会好很多。
  4. 分段验证。把表达式拆成几段分别测试,比盯着一个 200 字符的怪物猜哪里错了快得多。