【正则表达式】从入门到精通(大误)

前言

正则表达式(英语:Regular Expression,在代码中常简写为regex、regexp或RE),可以用来检索、替换那些匹配某个模式的文本。不管在开发还是日常生活中都可以发挥重要的作用,这篇文章主要是从正则表达式的匹配原理上来介绍并学习如何写出优雅的正则。

1. 基础

基础知识可以查阅 MDN RegExp,这里只对一些元字符以外的部分进行一个说明,附件在最后。

1.1 分组、环视、忽略优先量词

  • 分组

    • 捕获分组 (x):匹配 x,并在正则中可通过 \1 访问,在 js 中可通过 RegExp.$1 访问
    • 非捕获分组 (?:x) :匹配 x,不进行捕获,只做一个分组效果,无法通过上述方法访问
    • 命名捕获 /(?<name>x)/(ES9实现): 匹配 x 字符并将其放入匹配结果中的 groups 对象中,对象名为 name,值为 x;

  • 环视(逆序环视在 ES9 实现

    • 正序肯定环视 /x(?=y)/:匹配一个后面是 y 的 x
    • 正序否定环视 /x(?!y)/:匹配一个后面不是 y 的 x
    • 逆序肯定环视 /(?<=x)y/:匹配一个前面是 x 的 y
    • 逆序否定环视 /(?<!x)y/:匹配一个前面不是 x 的 y
  • 忽略优先量词(??、*?、+?、{n,}?、{n, m}?)

    普通的 *、?、+、等都是匹配优先,也就是在匹配过程中会尽量匹配更多的元素,然后通过回溯来找到匹配结果。而忽略优先量词则刚好相反,会尽可能少的匹配字符,然后逐步扫描获得匹配结果。

    /\w+\d+/.exec('abc123def456') // abc123def456

    /\w+?\d+/.exec('abc123def456') // abc123

  • 标识符 /u:支持 unicode 匹配

    /^.\$/.test('a') // true
    /^.\$/.test('?') // false
    /^.\$/u.test('?') // true
    复制代码
  • 标识符 /s:允许 . 匹配上包含(换行符等)在内的所有字符

    /hi.welcome/.test('hi\nwelcome') // false
    /hi.welcome/s.test('hi\nwelcome') // true
    复制代码

1.2 js 暂未实现的部分❌:

用 js 描述,不等于可以使用 js 执行,可以用 php 测试:PHP 在线代码运行,要注意改成 PHP 语法。

  • 条件判断 (?if then|else):

    /g(o)?(?(1)o|a)d/.test('good') // true

    表示先匹配 g,接下来如果匹配到了 o,则接着也匹配 o,否则匹配 a,最后匹配 d,?(1) 代表第一个括号匹配成功

    /(?(?=x)xy|ab)/.test('xy') // true

    /(?(?=x)xy|ab)/.test('ab') // true

    else 部分可以不写

  • 固化分组 (?>x):固话括号内的内容不会改变,除非整个括号被弃用在外部重新回溯。

    /(?>\w+)-/ 可以匹配 'hello-regexp' 中的 'hello-',并且在 - 匹配不上时就会返回匹配失败,不会再进行回溯
    
    /(?>\w+)-/.test('hello-regexp') // true
    
    // 由于一步匹配到了 p,且不会交还字符,导致匹配失败
    /(?>\w+)0/.test('hello0regexp') // false
    复制代码

    此处在后续还有补充,这里只是做个示例。

  • 注释和模式修饰词

    • 模式修饰词:(?i)x(?-i),中间部分 x 字符不区分大小写
    • 模式作用范围:(?i:x) 括号内的 x 字符不区分大小写(?i)x(?-i),中间部分 x 字符不区分大小写
    • 注释:(?#...) 和 # 注释作用,如果支持宽松排列可以直接用 #
    • 文字文本范围:\Q…\E,中间所有字符会当成普通文本
  • 占有优先量词:?+、*+、++、{n,}+、{n, m}+

    特点:匹配完就不会再交还字符,不会保存之前的回溯位置,某种程度类似固化分组

    /\w++-/.test('hello-regexp') // true

    /\w++0/.test('hello0regexp') // false

  • \G:本次匹配的开始位置(上次匹配的结束位置),用 php 示例理解一下:

    preg_match_all("/(\G\d),/", "1,2,a,3", $matches);
    
    foreach ($matches[1] as $match) {
        echo $match . '-';
    }
    // 1-2-
    复制代码

2. 引擎

正则表达式不同语言会有不同的引擎来解释正则语法,这是我们后面针对匹配原理来优化正则表达式的基础,例如固化分组、占有优先量词这种。

2.1 类别

  • DFA

    awk(大多数版本)、egrep(大多数版本)、mysql等。 特点是文本主导的匹配,会出现最长结果,多选结构与顺序无关,稳定速度快。

  • NFA

    Javascript、Java、Python、PHP等。 特点是表达式主导的匹配,多选结构从左往右,需要回溯但能力很强。

  • POSIX NFA

    mawk等,POSIX标准规定的某个正则表达式的应有行为。

  • NFA/DFA混合

    GNU awk、GNU grep/egrep等。

2.2 NFA 与 DFA

  • 区分:看是否支持忽略优先量词

  • 比较

    • 预编译阶段:通常 NFA 更快,内存也更少;而另外两种没有很大区别
    • 匹配速度: DFA 不需做太多优化;NFA 与正则表达式有关
    • 匹配结果: DFA 与 POSIX NFA 是最左最长;NFA 依据表达式匹配的实际文本结果
    • 匹配能力:NFA 多出一些功能——1.捕获括号;2.环视;3.忽略优先量词;4.占有优先量词和固化分组

3. 匹配原理

3.1 回溯

正则表达式在遇到量词或多选结果时会记录一个状态,在后续的匹配过程中失败时会回到上一个记录的状态,然后选择另一个方向进行下一次尝试,直到匹配成功或者状态用完匹配失败,.* 的回溯是很可怕的。

这个重复回到上个状态的过程就是回溯,记录下的状态就叫做备用状态

3.1.1 分支选择

在面对分支时正则表达式是如何确定选择哪一条的呢?是进行尝试还是跳过尝试呢?这里主要会依据匹配量词来区分,简单来说面对匹配优先量词的量词会进行尝试,而面对忽略优先量词则会跳过尝试

3.1.2 备用状态

以 /\d+/ 匹配 'a 1234 num' 来图解过程的备用状态:

问题:在左边的匹配中,是不含以上状态的,但 /\d*/ 匹配会包括下图这个状态吗?

答案:不会,* 号代表 0 次或任意次,当从 a 字符开始匹配时,\d 并不能匹配,所以 * 就相当于匹配 0 次,就算匹配成功,不会再往后走了。

3.1.3 举个例子

/".*"/.test('The name "McDonald's" is said "makudonarudo" in Japanese') 的过程:

  • POSIX NFA 匹配过程 POSIX NFA 会多次尝试来确定最长的匹配结果(虽然此处已经知道是到 D,但还是会尝试图中各个可能)。

  • NFA 匹配过程 先匹配 " 号,然后 .* 贪婪匹配到字符串结尾 B,由于匹配不到结尾的 " 号,所以 .* 会交还字符串到 C 位置再匹配到 do 后的 " D,匹配成功。

3.2 应用原理

我们必须先掌握正则表达式应用的基本知识,然后才能从根本上写出优雅的表达式。 正则表达式应用到目标字符串的过程大致分为下面几步:

3.2.1 编译

检查正则表达式的语法正确性,如果正确,就将其编译为内部形式,这部分主要由正则表达式引擎自动完成。

3.2.2 传动开始

传动装置将正则引擎“定位”到目标字符串的起始位置。

3.2.3 元素检测

引擎开始测试正则表达式和文本,依次测试正则表达式的各个元素。

  • 相连元素

    例如 hello 中的 h、e、l、l、o 等等,会依次尝试,只有当某个元素匹配失败时才会停止。

  • 量词修饰元素

    控制权在量词(检查量词是否应该继续匹配)和被限定的元素(测试能否匹配)之间轮换。

  • 控制权在捕获型括号内外进行切换会带来一些开销

    括号内的表达式匹配的文本必须保留,这样才能通过 $1 来引用。因为一对括号可能属于某个回溯分支,括号的状态就是用于回溯的状态的一部分,所以进入和退出捕获型括号时需要修改状态。

3.2.4 寻找匹配结果

如果找到一个匹配结果,传统型 NFA 会“锁定”在当前状态,报告匹配成功。而对 POSIX NFA 来说,如果这个匹配是迄今为止最长的,它会记住这个可能的匹配,然后从可用的保存状态继续下去。保存的状态都测试完毕之后返回最长的匹配。

3.2.5 传动装置的驱动过程

如果没有找到匹配,传动装置就会驱动引擎,从文本中的下一个字符开始新一轮的尝试(回到 3.2.3)。

3.2.6 匹配彻底失败

如果从目标字符串的每一个字符(包括最后一个字符之后的位置)开始的尝试都失败了,就会报告匹配彻底失败。

4. 高效编写

在通过以上的了解,我们对正则表达式的引擎以及匹配原理都有了一定的了解,有了这些,我们就能从根本上写出复杂且高效的正则表达式了。这部分主要是从原理上介绍如何来优化我们的正则。

4.1 核心思想

编写或优化点有很多方面,总的来说可以总结为以下 3 个方向:

  • 加速某些操作(如加速匹配成功或失败的报告)

  • 避免冗余操作(如只匹配期望的文本,排除不期望的文本)

  • 易于控制和理解(\w 匹配数字修改为 \d)

4.2 常见优化措施

4.2.1 编译前的优化

一般都是由正则引擎完成,所以对这部分我们能做的有限,但还是能从两个部分进行优化:

  • 长度判断:/1\d{10}/ 匹配 11 位手机号码

  • 预查必须字符/子字符串优化: /password:\s\w+/ 来匹配 'username: hello password: hello123' 这里通过必须字符 password 来进行预查,提升效率

4.2.2 通过传动装置进行优化
  • 字符串起始(结束)/行锚点优化:通过添加 ^、$ 首尾锚点进行位置确定

  • 独立锚点优化: /^abc|^123/ 修改为 /^(?:abc|123)/ 有些正则引擎只对第一个 ^abc 起作用

  • 隐式锚点优化:

    .*、.+ 开头的正则在没有全局多选结构的情况下,则可认为在开头有一个隐式的 ^,这样就能使用字符串起始/行锚点优化

  • 内嵌文字字符串检查优化 (高级版的预查必须字符/子字符串优化):

    /\b(perl|java).regex.info\b/ 匹配 'java.regex.info'

    然后从 .regex.info 往前数 4 个字符开始真正的正则匹配

    注意: 这里距离固定才行,此例都是 4 ,如果是 (js|java) 这种就不行了

4.2.3 优化正则表达式本身
  • 文字字符串连接优化:把 abc 当成一个元素,而不是 a、b、c 三个元素 化三次迭代为一次

  • 独立文本优化:/a{2,4}/ 修改为 /aaa{0,2}/

  • 化简量词优化:

    /.*/ 与 /(?:.)*/ 在逻辑上相等,但是 .* 会作为一个整体考虑,速度会更快,而 (?:.)* 在括号内外的控制权转移时会消耗时间

  • 消除无必要括号:等价情况下去除多余括号——改 /(?:.)*/ 为 /.*/

  • 消除不需要的字符组:改单个字符组为字符,字符组会更费时间。改 /[a]/ 为 /a/

  • 忽略优先量词之后的字符优化:

    • 在靠近开头使用忽略优先量词
    • 在靠近结尾使用匹配优先量词
    • 通常情况忽略优先比匹配优先要慢,另一个原因就是如果忽略优先量词在捕获括号内,则会在控制权交换过程中造成额外开销
  • ❌(js暂不支持)使用占有优先量词削减状态

    /\w+:/ 匹配 'username' 当 : 无法匹配时,会逐步回溯到开始,但是使用固化分组或者占有优先量词会在匹配不到 : 时报出匹配失败

  • 量词等价转换:/\d\d\d\d/ 修改成 /\d{4}/,某些对对量词做了优化的工具会更快

  • 拆分正则表达式:

    很多时候,应用多个小的正则表达式比一个大而全的正则表达式要快。 要用 January、February、March 之类的检查一个字符串中是否有月份,比一个 /January|February|March/ 等等要快

  • 模拟开头字符识别:使用环视预查

  • 主导引擎的匹配:/this|that/ 修改为 /th(?:is|at)/

  • 消除循环 此处的循环主要是指多选结构当中的星号所引起的多次来回匹配。

    • 寻找通用套路 /normal+(special normal+)*/
      • 在匹配双引号字符串时,引号自身和转义斜线是“特殊”的——因为引号能够表示字符串的结尾,反斜线表示它之后的字符不会终结整个字符串,所以 normal 部分就是 [^"\\],special 部分就是 \\.,能得出下面的修改;
      • /"(\\.|[^"\\]+)*"/ 修改为 /"[^"\\]*(\\.[^"\\.]*)*"/,+ 换成了 * 号是没有副作用的,且匹配适应性更广,可以用数学归纳法自行判断
      • 避免无休止匹配
        • special 部分和 normal 部分,匹配的开头不能重合
        • normal 部分必须匹配至少一个字符
        • special 部分必须是固化的

5. 练习

5.1 去除一段文本中的重复单词

去除下面字符串中连续的两个单词,但是不要破坏正常出现的单词。

This is the theater you have been to to.

5.2 将驼峰变量名转换为下划线变量名

示例:将 ImageUrlList 转换成 image_url_list

5.3 获取温度值去除小数部分并替换进第一个 i 标签内

温度可能是华氏度也可能是摄氏度,格式如:+35C、-123.123F

<i>要匹配并被替换的内容</i><i>不需要匹配替换的内容</i>
复制代码

6. 练习答案

5.1答案及思路

相关知识点:分组、反向引用、单词边界

const str = 'This is the theater you have been to to';

const pattern = /\b([a-z]+)\s\1\b/ig;

const result = str.replace(pattern, (match, ...args) => {
  return args[0];
});
console.log(result); // This is the theater you have been to
复制代码

匹配思路:

  1. 要想找到重复单词,首先要找到单词,那么第一步就是写出 /[a-z]+\s/ig (全局不分大小写匹配)这个匹配字母和后面一个空格的正则表达式;

  2. 重复单词必定就是和前面单词是一样的,那么可以通过括号捕获前一次的匹配,然后通过反向引用 \1 来匹配上一次匹配的结果,从而达到检测重复的目的,就能得到以下的正则表达式:/([a-z]+)\s\1/ig (\1 匹配括号内的[a-z]+ 结果,也就是上一个单词);

  3. 我们第二步的匹配已经很接近最终答案了,但是如果这样去匹配是得不到我们想要的结果的。我们一眼看上去就知道重复的是最后的 to to,但是我们写下的正则表达式还会“机智地”帮我们找到 This is、the theater 里的 is 和 the,原因就是我们没有区分单词的匹配,找到原因后就很容易了,我们给正则表达式加上单词边界的限制就可以了,于是得到了最终的表达式 /\b([a-z]+)\s\1\b/ig

5.2答案及思路

相关知识点:环视

const str = 'ImageUrlList';
const pattern = /(?<=[a-z])(?=[A-Z])/g; // 极限优化情况
const result = str.replace(pattern, '_').toLowerCase();
console.log(result); // image_url_list
复制代码

匹配思路:

  1. 大多数时候我们可能很简单的会想用 /[A-Z][a-z]+/ 这种方式来匹配 Image、Url、List 然后分别用这些单词加一个下划线去替换原来的部分,仔细想来其实原来的部分并不需要改变,我们只需要在合适的位置插入一个 _ 下划线即可,那么就可以通过环视来找到这个位置;

  2. 通过第一个步骤的分析,我们可以发现我们要找的位置是 eU、lL 这个字符的中间,那么就可以得出环视的目标:前一个字符是小写字母,后一个字符是大写字母;

  3. 前一个字符是小写字母的环视:/(?<=[a-z])/;后一个字符是大写字母的环视:/(?=[A-Z])/,结合在一起就是我们答案中的部分;

  4. 这里还有一点补充,在这个例子中,/(?<=[a-z])(?=[A-Z])/g 或者 /(?=[A-Z])(?<=[a-z])/g 都是可以的,原因是这里先判断左边还是右边是无关紧要的,必须要在同一个位置两边都检测成功才会匹配,通俗理解就是这两个位置结合才能使正则匹配成功,所以位置的先后顺序并不会影响最后的结果;

5.3答案及思路

相关知识点:非捕获括号、忽略优先量词

// 获取格式化的温度
const temperature = '-123.456789C';
const patternT = /^([+-]?[0-9]+(?:\.[0-9]+)?)([CF])$/;
patternT.exec(temperature);
const t = `${RegExp.$1}${RegExp.$2}`;

// 替换内容
const string = '<i>要匹配并被替换的内容</i><i>不需要匹配的内容</i>';
const patternS = /(?<=(?:<i>)).*?(?=(?:<\/i>))/;
const result = string.replace(patternS, t);
console.log(result); // <i>-123.456789C</i><i>不需要匹配的内容</i>
复制代码

匹配思路:

  1. 温度部分:

    • 我们要保留整数部分,那么就取出符号和整数部分以及末尾的温度符号,通过两个捕获型括号拿到对应的数据,组装就可以了即可;
    • 整数部分: /^([+-]?[0-9]+)/,温度符号:/([CF])$/
    • 小数部分要怎么处理呢?我们知道小数部分有可能出现,也有可能不出现,那么可以通过 ? 量词来进行限定,.xxx 作为一个组来匹配:(.[0-9]+)? 就表示小数部分的匹配了;
    • 那结果就出现了——/^([+-]?[0-9]+(\.[0-9]+)?)([CF])$/,但是我们在取值的时候需要通过 RegExp.$1 和 RegExp.$3 来拼接,我们不需要 $2 的这个捕获,所以通过非捕获型括号 (?:) 就实现我们想要的效果,最终结果就是答案里的部分了;
  2. 匹配标签内容部分:

    • 首先要获得 i 标签而不是其他标签里的内容,我们可以通过编写正则表达式 /<i>.*</i>/ 实现;
    • 但是这样会过多匹配到后一个 i 标签,于是可以采用忽略优先来匹配加上问号量词:/<i>.*?</i>/
    • 最后为了匹配更精准与不获取无关内容,我们加上环视与非捕获分组,就形成了最后答案中出现的正则;

7. 附件

xmind 以及 png 下载:百度网盘 ,提取码:gz7r

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值