正则表达式进阶:贪婪与非贪婪、反向引用、零宽断言
说实话,正则表达式这东西,很多人学完基础就跑了。觉得能匹配个邮箱、电话号码就够了。但你要是真的写过复杂的文本解析任务,就会发现——基础那点东西根本不够用。
我早年接手过一个日志分析项目。几千行的服务器日志,需要提取特定时间戳里的错误码。用基础的正则写了一版,结果匹配出来的内容乱七八糟。很多该抓的没抓到,不该抓的反而抓了一大堆。后来深入研究了贪婪与非贪婪、反向引用这些概念,才真正把正则用明白。
贪婪与非贪婪
先问一个问题:默认情况下,正则引擎是怎么匹配重复量词的?比如 .* 或 .+。
答案是——尽量多匹配。能匹配100个字符绝不只匹配10个。这就是贪婪的本质。
核心区别:
- 贪婪量词:
*、+、{n,}—— 尽量多匹配 - 非贪婪量词:
*?、+?、{n,}?—— 尽量少匹配
举个例子。文本是 "abc"def"ghi",我想提取双引号内的内容。
正则:".*"
匹配结果:"abc"def"ghi"
因为 .* 会一直匹配到最后一个双引号。
这显然不是我想要的。我只要第一个引号对里的内容。
正则:".*?"
匹配结果:"abc"
在 * 后面加个 ?,引擎就会尝试最短匹配。遇到第一个 " 就收手。
我在项目中遇到过一个问题:用贪婪匹配去解析HTML标签。结果把整个页面的内容吞进去了。因为 <.*> 会匹配从第一个 < 到最后一个 >。嗯,那之后我就养成了习惯——凡是没有明确限制的重复匹配,先试非贪婪。
注意:非贪婪并不总是最优解。如果结构非常复杂,非贪婪可能导致回溯过多,性能下降。我建议在能确定性匹配时,优先用字符类或否定类(比如 [^"]*)来代替非贪婪。
反向引用
反向引用的概念很有意思:你在前面捕获了一个组,后面可以直接引用这个组的内容。
语法:
\1 —— 引用第一个捕获组
\2 —— 引用第二个捕获组
...
$1 —— 在替换时引用第一个捕获组(不同语言写法有差异)
举个实际例子。检测重复单词:
文本:我我我吃饭吃饭了了了
正则:(\w)\1+
解释:匹配同一个字符连续出现两次或多次。
匹配结果:我我我、吃饭吃、饭了了了
我曾经在写一个数据清洗工具时,需要把 2024-01-01 格式的日期改成 01/01/2024。反向引用+替换一步搞定:
正则:(\d{4})-(\d{2})-(\d{2})
替换:$2/$3/$1
个人经验:反向引用在HTML/XML标签匹配里非常好用。比如匹配 <div>...</div>,可以用 <([a-z]+)>.*?</\1>。注意这里用了非贪婪,否则会跨标签。
零宽断言
零宽断言,说白了就是:匹配某个位置,但不消耗字符。它检查当前位置的前面或后面是否满足条件,但不把它算进匹配结果里。
| 类别 | 语法 | 含义 |
|---|---|---|
| 正向先行断言 | (?=...) |
后面紧跟着... |
| 负向先行断言 | (?!...) |
后面不跟着... |
| 正向后行断言 | (?<=...) |
前面紧跟着... |
| 负向后行断言 | (?<!...) |
前面不跟着... |
我常用正向先行断言来提取数字后面带单位的内容。比如从 "价格是100元,运费20元" 里提取金额:
正则:\d+(?=元)
匹配结果:100、20(只得到数字,不包含“元”)
负向先行断言呢?比如你想匹配 cat,但不要紧跟在 dog 后面的 cat:
正则:\bcat\b(?!.*dog)
解释:匹配单词cat,但要求后面没有dog这个词出现在同一句话里。
避坑指南:我曾经在分析JSON字符串时,用后行断言来匹配冒号后的值。但注意,JSON的键和值都可能包含引号,边界情况复杂。建议先了解语言对后行断言的支持程度——JavaScript早期版本就不支持后行断言。
零宽断言不占用字符这个特点,让它可以用来做“分割”而不消耗分隔符。比如你想按逗号分割,但逗号前后有空格:
文本:a, b, c
正则:(?<=\s?),\s?
解释:匹配逗号,但前面不消耗空格,后面也不消耗空格。
实际分割结果:a、b、c(干净)
注意性能陷阱:贪婪+大量回溯会导致灾难性回溯,特别是用 (.*)* 这类嵌套重复时。我曾经在生产环境遇到正则直接卡死CPU,后来改用了具体字符类 + 非贪婪,问题才解决。
个人建议:平时写正则,先把默认行为搞清——哪些是贪婪、哪些是零宽。条件允许的话,用在线工具调试一下匹配过程,很多坑能提前暴露。
一个小技巧:如果场景复杂,比如需要同时处理贪婪、反向引用和断言,建议拆成多步。先提取、再替换、再断言过滤。逻辑更清晰,也更容易维护。我见过太多人在一行正则里塞几十个括号,最后自己都看不懂了。