跳到主要内容

正则表达式进阶

在掌握了正则表达式的基础知识后,本章将深入探讨更高级的特性,包括零宽断言、反向引用、递归模式等。这些高级特性能够帮助你解决更复杂的文本处理问题。

零宽断言

零宽断言(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

进阶练习

  1. 匹配嵌套的括号:编写正则表达式匹配正确嵌套的圆括号
  2. 密码强度验证:至少 8 位,包含大小写字母、数字和特殊字符
  3. 提取 Markdown 链接:从 [text](url) 格式中提取文本和 URL
  4. 匹配有效的 IP 地址:验证 IPv4 地址的合法性(0-255 范围)
  5. 提取 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 属性匹配
  • 性能优化:避免回溯灾难,使用具体字符类

掌握这些高级特性后,你可以处理更复杂的文本处理任务。但请记住,正则表达式并非万能,对于过于复杂的模式,考虑使用解析器或其他工具可能更合适。

参考资源