正则表达式进阶
在掌握了正则表达式的基础知识后,本章将深入探讨更高级的特性,包括零宽断言、反向引用、递归模式等。这些高级特性能够帮助你解决更复杂的文本处理问题。
零宽断言
零宽断言(Zero-width assertions)是正则表达式中非常强大的特性,它们匹配一个位置而不是具体的字符。换句话说,它们不会消耗任何字符,只是检查某个位置是否满足特定条件。
正向先行断言 (?=...)
正向先行断言用于检查某个位置后面是否跟着指定的模式,但不包括该模式在匹配结果中。
// 匹配后面跟着 "Bar" 的 "foo"
const str = "fooBar fooBaz";
str.match(/foo(?=Bar)/g); // ["foo"] - 只匹配第一个 foo,因为它后面是 Bar
// 实际应用:匹配带有特定后缀的单词
const words = "running jumping walking eating";
words.match(/\w+(?=ing)/g); // ["runn", "jump", "walk", "eat"]
// 解释:匹配所有后面跟着 "ing" 的单词(不包含 "ing")
# Python 示例
import re
# 提取美元价格的数字部分
prices = "$100 €200 ¥300"
re.findall(r"(?<=\$)\d+", prices) # ['100']
# 解释:(?<=\$) 是正向后行断言,匹配前面是 $ 的位置
负向先行断言 (?!...)
负向先行断言用于检查某个位置后面不跟着指定的模式。
// 匹配后面不是 "Bar" 的 "foo"
const str = "fooBar fooBaz";
str.match(/foo(?!Bar)/g); // ["foo"] - 只匹配第二个 foo
// 实际应用:匹配不以 .txt 结尾的文件名
const files = ["readme.txt", "script.js", "style.css", "data.json"];
files.filter(f => /^(?!.*\.txt$).*$/i.test(f));
// 结果: ["script.js", "style.css", "data.json"]
正向后行断言 (?<=...)
正向后行断言用于检查某个位置前面是否跟着指定的模式。
// 匹配前面是 $ 的数字
const str = "$100 €200 ¥300";
str.match(/(?<=\$)\d+/g); // ["100"]
// 提取括号内的内容
"hello(world)".match(/(?<=\()[^)]+/); // ["world"]
// 提取引号内的内容
const text = 'He said "hello" and she said "hi"';
text.match(/(?<=")[^"]+(?=")/g); // ["hello", "hi"]
负向后行断言 (?<!...)
负向后行断言用于检查某个位置前面不跟着指定的模式。
// 匹配前面不是 $ 的数字
const str = "$100 200 ¥300";
str.match(/(?<!\$)\d+/g); // ["00", "200", "300"]
// 注意:$100 中的 "00" 被匹配了,因为它前面不是 $
// 实际应用:匹配不在引号内的单词
const text = 'He said "hello" loudly';
text.match(/\b\w+\b(?<!"\w+)/g); // 需要更复杂的表达式
零宽断言对比表
| 断言语法 | 名称 | 含义 | 示例 |
|---|---|---|---|
(?=...) | 正向先行断言 | 后面跟着... | \d(?=px) 匹配后面是 px 的数字 |
(?!...) | 负向先行断言 | 后面不跟着... | \d(?!px) 匹配后面不是 px 的数字 |
(?<=...) | 正向后行断言 | 前面跟着... | (?<=\$)\d+ 匹配前面是 $ 的数字 |
(?<!...) | 负向后行断言 | 前面不跟着... | (?<!\$)\d+ 匹配前面不是 $ 的数字 |
实用场景
零宽断言特别适合用于:
- 密码强度验证(同时包含多种字符类型)
- 提取特定上下文中的数据
- 排除某些模式的匹配
反向引用
反向引用允许你在正则表达式中引用之前捕获的分组,这在匹配重复模式时非常有用。
基本用法
// 匹配重复的单词
const str = "the the cat sat on the the mat";
str.match(/\b(\w+)\s+\1\b/g); // ["the the", "the the"]
// \1 引用第一个分组的内容
// 匹配重复的字符
"aabb".match(/(\w)\1(\w)\2/); // ["aabb", "a", "b"]
// 解释:\1 匹配第一个分组的重复,\2 匹配第二个分组的重复
// 匹配对称的 HTML 标签
const html = "<div>content</div><span>text</span>";
html.match(/<(\w+)>[^<]*<\/\1>/g); // ["<div>content</div>", "<span>text</span>"]
// \1 引用标签名,确保开始和结束标签一致
命名反向引用
如果你使用了命名分组,可以通过名称引用:
// JavaScript 命名分组反向引用
const regex = /(?<word>\w+)\s+\k<word>/;
"hello hello".match(regex); // 匹配成功
// Python 命名分组反向引用
import re
pattern = re.compile(r"(?P<word>\w+)\s+(?P=word)")
pattern.search("hello hello") # 匹配成功
实际应用案例
// 验证回文(正读反读都一样的字符串)
function isPalindrome(str) {
const clean = str.toLowerCase().replace(/[^a-z0-9]/g, "");
const len = clean.length;
const half = Math.floor(len / 2);
// 构建正则表达式检查前后对称
const regex = new RegExp(`^(.{${half}}).?\\1$`);
return regex.test(clean);
}
isPalindrome("A man, a plan, a canal: Panama"); // true
isPalindrome("race a car"); // false
// 匹配重叠的标签
const nested = "<div><span>text</span></div>";
const match = nested.match(/<(\w+)>(?:[^<]|<(?!\/\1>))*<\/\1>/);
// 匹配最外层的 div 标签
递归与平衡组
某些正则引擎支持递归模式,用于匹配嵌套结构。
PCRE/Python 的递归模式
import regex # 需要安装 regex 模块:pip install regex
# 匹配嵌套的括号
pattern = regex.compile(r"\((?:[^()]|(?R))*\)")
# 测试
pattern.match("(a)") # 匹配
pattern.match("(a(b)c)") # 匹配
pattern.match("(a(b(c)d)e)") # 匹配
pattern.match("(a(b)c") # 不匹配(不平衡)
平衡组(.NET/JavaScript 新特性)
平衡组用于匹配正确嵌套的结构:
// JavaScript 暂不支持递归,但可以匹配有限层级的嵌套
// 匹配最多 3 层嵌套的括号
const nested3 = /\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)/;
nested3.test("(a)"); // true
nested3.test("(a(b)c)"); // true
nested3.test("(a(b(c)d)e)"); // true
nested3.test("(a(b(c(d)e)f)g)"); // false(超出 3 层)
原子组与占有量词
原子组(atomic groups)和占有量词(possessive quantifiers)用于优化性能,防止不必要的回溯。
原子组 (?>...)
原子组内的匹配一旦成功,就不会回溯。
// 没有原子组的情况 - 会回叩
const regex1 = /\d+\d/;
"123".match(regex1); // 匹配成功
// 使用原子组 - 不会回叩
// 注:JavaScript 不支持原子组,以下是 PCRE 语法
// (?>\d+)\d 在 "123" 上会失败,因为 \d+ 会吞下所有数字,不会回退给后面的 \d
占有量词
占有量词在量词后加 +,表示不回溯。
// PCRE 语法
// \d++\d 不会回叩
// \d+?\d 是非贪婪匹配
兼容性说明
原子组和占有量词主要在 PCRE、Perl、Java 等引擎中支持。JavaScript 和 Python 标准库暂不支持。
条件匹配
条件匹配允许根据某个条件选择不同的匹配分支。
// PCRE 语法
// (?(1)yes|no) - 如果第 1 个分组匹配成功,匹配 yes,否则匹配 no
// 示例:匹配单引号或双引号包围的内容
// ['text'] 或 ["text"]
const pattern = /\[(['"])[^\1]*\1\]/;
// 更复杂的条件示例(PCRE)
// (?(?=condition)yes-pattern|no-pattern)
注释与模式修饰
内联注释
某些引擎支持在正则表达式中添加注释:
// PCRE 语法
const pattern = new RegExp(`
(?<year>\\d{4}) # 年
- # 分隔符
(?<month>\\d{2}) # 月
- # 分隔符
(?<day>\\d{2}) # 日
`, 'x'); // x 标志启用忽略空白和注释模式
模式内联修饰
// 在模式内部切换标志
/(?i)hello/ // 忽略大小写
/(?i:hello)/ // 仅对分组内容忽略大小写
/(?-i)hello/i // 对该分组关闭大小写忽略
Unicode 属性转义
Unicode 属性转义允许你基于 Unicode 字符属性进行匹配。
// JavaScript (ES2018+)
// \p{L} - 任何字母
// \p{N} - 任何数字
// \p{P} - 任何标点
// \p{Han} - 汉字
// 匹配中文字符
/\p{Han}+/u.test("你好世界"); // true
// 匹配任何字母
/\p{L}+/u.test("Hello"); // true
// 匹配表情符号
/\p{Emoji}/u.test("😀"); // true
# Python 示例
import re
# \p{Han} 在 Python 中需要使用 regex 模块
# 标准库可以用 Unicode 范围
re.findall(r"[\u4e00-\u9fff]+", "你好世界") # ['你好世界']
性能优化技巧
1. 避免回溯灾难
某些模式会导致指数级回溯:
// 危险模式:重叠的量词
/(a+)+b/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaa!"); // 可能卡死
// 改进:使用具体的字符类
/a+b/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaa!"); // 快速失败
2. 使用具体字符类
// 较慢
/.*?/.test(str);
// 较快
/[^\n]*?/.test(str);
3. 锚点优化
// 如果确知模式在开始位置,使用 ^ 锚点
/^\d+/.exec(str); // 比 /\d+/ 更快
4. 编译缓存
import re
# 坏:每次都重新编译
for text in texts:
if re.match(r"\d+", text): # 每次都编译
pass
# 好:预先编译
pattern = re.compile(r"\d+")
for text in texts:
if pattern.match(text): # 使用缓存的模式
pass
常见陷阱与解决方案
1. 点号不匹配换行符
// 问题
/".*"/.test('"line1\nline2"'); // false
// 解决方案 1:使用 [\s\S] 匹配任何字符
/"[\s\S]*"/.test('"line1\nline2"'); // true
// 解决方案 2:使用 s 标志
/".*"/s.test('"line1\nline2"'); // true
2. 贪婪匹配的意外结果
// 问题
"<p>text</p>".match(/<.*>/); // ["<p>text</p>"]
// 解决
"<p>text</p>".match(/<[^>]+>/); // ["<p>"]
// 或使用非贪婪量词
"<p>text</p>".match(/<.*?>/); // ["<p>"]
3. 特殊字符未转义
// 问题
/price: $100/.test("price: $100"); // false($ 是特殊字符)
// 解决
/price: \$100/.test("price: $100"); // true
4. 字符类中的特殊含义
// 问题:在字符类中,某些字符仍有特殊含义
/[a-z^]/.test("^"); // true(^ 在非开头位置时是普通字符)
// 注意:在字符类中,- 表示范围,\ 表示转义
/[a\-z]/.test("-"); // true
进阶练习
- 匹配嵌套的括号:编写正则表达式匹配正确嵌套的圆括号
- 密码强度验证:至少 8 位,包含大小写字母、数字和特殊字符
- 提取 Markdown 链接:从
[text](url)格式中提取文本和 URL - 匹配有效的 IP 地址:验证 IPv4 地址的合法性(0-255 范围)
- 提取 XML/HTML 属性:从
<tag attr="value">中提取属性名和值
点击查看参考答案
// 1. 匹配嵌套括号(JavaScript 限制层数)
const nested = /\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)/;
// 2. 密码强度(使用零宽断言)
const password = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
// 3. 提取 Markdown 链接
const mdLink = /\[([^\]]+)\]\(([^)]+)\)/;
"[Google](https://google.com)".match(mdLink);
// 结果: ["[Google](https://google.com)", "Google", "https://google.com"]
// 4. 验证 IPv4 地址
const ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// 5. 提取 HTML 属性
const attr = /(\w+)=["']([^"']+)["']/g;
'<div class="container" id="main">'.matchAll(attr);
// 结果: [['class="container"', 'class', 'container'], ['id="main"', 'id', 'main']]
小结
本章介绍了正则表达式的高级特性:
- 零宽断言:
(?=)、(?! )、(?<= )、(?<! )用于位置检查 - 反向引用:
\1、\k<name>引用已捕获的分组 - 递归模式:匹配嵌套结构
- 原子组与占有量词:防止回溯,优化性能
- 条件匹配:根据条件选择分支
- Unicode 属性转义:基于 Unicode 属性匹配
- 性能优化:避免回溯灾难,使用具体字符类
掌握这些高级特性后,你可以处理更复杂的文本处理任务。但请记住,正则表达式并非万能,对于过于复杂的模式,考虑使用解析器或其他工具可能更合适。