四、正则表达式回溯法原理
概念理解起来比较容易。
比如用 /ab{1,3}c/
去匹配下面两个字符串。
- 当匹配
abbbc
,按顺序匹配,到了第 3 个 b
后,直接匹配 c
,这样就没有回溯。 - 当匹配
abbc
,按顺序匹配,到了第 2 个 b
后,由于规则是 b{1,3}
,则会继续往下匹配,然后发现下一位是 c
,于是回退到前一个位置,重新匹配,这就是回溯。
另外像 /".*"/
来匹配 "abc"de
的话,就会有三个回溯情况,为了减少不必要的回溯,我们可以把正则修改为 /"[^"]*"/
。
介绍
回溯法,也称试探法,本质上是深度优先探索算法,基本思路是:匹配过程中后退到之前某一步重新探索的过程。
1. 常见的回溯形式
- 贪婪量词
多个贪婪量词挨着存在,并相互冲突时,会看匹配顺序,深度优先搜索:
-
"12345".match(/(\d{1,3})(\d{1,3})/);
-
// ["12345", "123", "45", index: 0, input: "12345"]
- 惰性量词
有时候会因为回溯,导致实际惰性量词匹配到的不是最少的数量:
-
"12345".match(/(\d{1,3}?)(\d{1,3})/);
-
// 没有回溯的情况 ["1234", "1", "234", index: 0, input: "12345"]
-
"12345".match(/^\d{1,3}?\d{1,3}$/);
-
// 有回溯的情况 ["12345", index: 0, input: "12345"]
- 分支结构
分支机构,如果一个分支整体不匹配,会继续尝试剩下分支,也可以看成一种回溯。
-
"candy".match(/can|candy/); // ["can", index: 0, input: "candy"]
-
"candy".match(/^(?:can|candy)$/); // ["candy", index: 0, input: "candy"]
2. 本章小结
简单总结:一个个尝试,直到,要么后退某一步整体匹配成功,要么最后试完发现整体不匹配。
- 贪婪量词:买衣服砍价,价格高了,便宜点,再便宜点。
- 懒惰量词:卖衣服加价,价格低了,多给点,再多给点。
- 分支结构:货比三家,一家不行换一家,不行再换。
五、正则表达式的拆分
拆分正则代码块,是理解正则的关键。
在 JavaScrip 正则表达式有以下结构:
- 字面量: 匹配一个具体字符,如
a
匹配字符 a
。 - 字符组: 匹配一个有多种可能性的字符,如
[0-9]
匹配任意一个数字。 - 量词: 匹配一个连续出现的字符,如
a{1,3}
匹配连续最多出现 3 次的 a
字符。 - 锚: 匹配一个位置,如
^
匹配字符串的开头。 - 分组: 匹配一个整体,如
(ab)
匹配 ab
两个字符连续出现。 - 分支: 匹配一个或多个表达式,如
ab|bc
匹配 ab
或 bc
字符。
另外还有以下操作符:
优先级 | 操作符描述 | 操作符 |
1 | 转义符 | |
2 | 括号和方括号 | |
3 | 量词限定符 | |
4 | 位置和序列 | |
5 | 管道符 | ` |
Tips:优先级从上到下,由高到低。
1. 注意要点
- 匹配字符串整体
不能写成 /^abc|bcd$/
,而是要写成 /^(abc|bcd)$/
。
- 量词连缀问题
需要匹配:每个字符是 a
/ b
/ c
中其中一个,并且字符串长度是 3 的倍数:
不能写成 /^[abc]{3}+$/
,而是要写成 /([abc]{3})+/
。
- 元字符转义问题
元字符就是正则中的特殊字符,当匹配元字符就需要转义,如:
^
、 $
、 .
、 *
、 +
、 ?
、 |
、 \
、 /
、 (
、 )
、 [
、 ]
、 {
、 }
、 =
、 !
、 :
、 -
。
-
// "[abc]" => /\[abc\]/ 或者 /\[abc]/
-
// "{1,3}" => /\{1\}/ 或者 /\{1}/ 因为不构成字符组
2. 案例分析
- 身份证号码
-
/^(\d{15}|\d{17})[\dxX]$/.test("390999199999999999");// true
- IPV4地址
需要好好分析:
-
let r = /^((0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5])\.){3}(0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5])$/
六、正则表达式的构建
正则的构建需要考虑以下几点的平衡:
- 匹配预期的字符串
- 不匹配非预期的字符串
- 可读性和可维护性
- 效率
我们还需要考虑这么几个问题:
- 是否需要使用正则
如能使用其他 API 简单快速解决问题就不需要使用正则:
-
"2019-03-16".match(/^(\d{4})-(\d{2})-(\d{2})/); // 间接获取 ["2019", "03", "16"]
-
"2019-03-16".split("-"); // ["2019", "03", "16"]
-
"?id=leo".search(/\?/); // 0
-
"?id=leo".indexOf("?"); // 0
-
"JavaScript".match(/.{4}(.+)/)[1]; // "Script"
-
"JavaScript".substring(4); // "Script"
- 是否需要使用复杂正则
/(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/
将这个正则拆分成多个小块,如下:
-
var regex1 = /^[0-9A-Za-z]{6,12}$/;
-
var regex2 = /^[0-9]{6,12}$/;
-
var regex3 = /^[A-Z]{6,12}$/;
-
var regex4 = /^[a-z]{6,12}$/;
-
function checkPassword (string) {
-
if (!regex1.test(string)) return false;
-
if (regex2.test(string)) return false;
-
if (regex3.test(string)) return false;
-
if (regex4.test(string)) return false;
-
return true;
-
}
1. 准确性
即需要匹配到预期目标,且不匹配非预期的目标。
- 匹配固定电话
如需要匹配下面固定电话号码,可以分别写出对应正则:
-
055188888888 => /^0\d{2,3}[1-9]\d{6,7}$/
-
0551-88888888 => /^0\d{2,3}-[1-9]\d{6,7}$/
-
(0551)88888888 => /^0\d{2,3}-[1-9]\d{6,7}$/
然后合并:
-
let r = /^0\d{2,3}[1-9]\d{6,7}$|^0\d{2,3}-[1-9]\d{6,7}$|^\(0\d{2,3}\)[1-9]\d{6,7}$/
然后提取公共部分:
-
let r = /^(0\d{2,3}|0\d{2,3}-|\(0\d{2,3}\))[1-9]\d{6,7}$/
再优化:
-
let r = /^(0\d{2,3}-?|\(0\d{2,3}\))[1-9]\d{6,7}$/
- 匹配浮点数
先确定,符号部分( [+-]
)、整数部分( \d+
)和小数部分( \.\d+
)。
-
1.23、+1.23、-1.23 => /^[+-]?\d+\.\d+$/
-
10、+10、-10 => /^[+-]?\d+$/
-
.2、+.2、-.2 => /^[+-]?\.\d+$/
整理后:
-
let r = /^[+-]?(\d+\.\d+|\d+|\.\d+)$/;
-
// 考虑不匹配 +.2 或 -.2
-
let r = /^([+-])?(\d+\.\d+|\d+|\.\d+)$/;
-
// 考虑不匹配 012 这类 0 开头的整数
-
let r = /^[+-]?(\d+)?(\.)?\d+$/;
2. 效率
正则表达式运行过程:
- 编译
- 设定起始位置
- 尝试匹配
- 若匹配失败则返回前一步重新匹配
- 返回匹配成功失败的结果
我们常常优化对 3和4
步进行优化:
- 使用具体字符组替代通配符,消除回溯
如 /"[^"]*"/
代替 /".*?"/
。
- 使用非捕获型分组
当不需要使用分组引用和反向引用时,此时可以使用非捕获分组。
如 /^[-]?(?:\d\.\d+|\d+|\.\d+)$/
代替 /^[-]?(\d\.\d+|\d+|\.\d+)$/
。
- 独立出确定字符
加快判断是否匹配失败,进而加快移位的速度。
如 /aa*/
代替 /a+/
。
- 提取分支公共部分
减少匹配过程中可消除的重复。
如 /^(?:abc|def)/
代替 /^abc|^def/
。
- 减少分支的数量,缩小它们的范围
如 /rea?d/
代替 /red|read/
。
七、正则表达式编程
这里要掌握正则表达式怎么用,通常会有这么四个操作:
- 验证
- 切分
- 提取
- 替换
1. 四种操作
- 验证
匹配本质上是查找,我们可以借助相关API操作:
-
// 检查字符串是否包含数字
-
let r = /\d/, s = "abc123";
-
!!s.search(r); // true
-
r.test(s); // true
-
!!s.match(r); // true
-
!!r.exec(s); // true
- 切分
-
"leo,pingan".split(/,/); // ["leo", "pingan"]
-
let r = /\D/, s = "2019-03-16";
-
s.split(r); // ["2019", "03", "16"]
-
s.split(r); // ["2019", "03", "16"]
-
s.split(r); // ["2019", "03", "16"]
- 提取
-
// 提取日期年月日
-
let r = /^(\d{4})\D(\d{2})\D(\d{2})$/;
-
let s = "2019-03-16";
-
s.match(r); // ["2019-03-16", "2019", "03", "16", index: 0, input: "2019-03-16"]
-
r.exec(s); // ["2019-03-16", "2019", "03", "16", index: 0, input: "2019-03-16"]
-
r.test(s); // RegExp.$1 => "2019" RegExp.$2 => "03" RegExp.$3 => "16"
-
s.search(r);// RegExp.$1 => "2019" RegExp.$2 => "03" RegExp.$3 => "16"
- 替换
-
// yyyy-mm-dd 替换成 yyyy/mm/dd
-
"2019-03-16".replace(/-/g, "/");
2. 相关API注意
-
search
和 match
参数问题
这两个方法会把字符串转换成正则,所以要加转义
-
let s = "2019.03.16";
-
s.search('.'); // 0
-
s.search('\\.'); // 4
-
s.search(/\./); // 4
-
s.match('.'); // ["2", index: 0, input: "2019.03.16"]
-
s.match('\\.'); // [".", index: 4, input: "2019.03.16"]
-
s.match(/\./); // [".", index: 4, input: "2019.03.16"]
-
// 其他不用转义
-
s.split('.');
-
s.replace('.', '/');
-
match
返回结果的格式问题
match
参数有 g
会返回所有匹配的内容,没有 g
则返回标准匹配格式:
-
let s = "2019.03.16";
-
s.match(/\b(\d+)\b/); // ["2019", "2019", index: 0, input: "2019.03.16"]
-
s.match(/\b(\d+)\b/g); // ["2019", "03", "16"]
-
test
整体匹配时需要使用 ^
和 $
-
/123/.test("a123b"); // true
-
/^123$/.test("a123b"); // false
-
/^123$/.test("123"); // true
-
split
的注意点
split
第二个参数是 结果数组的最大长度:
-
"leo,pingan,pingan8787".split(/,/, 2); // ["leo", "pingan"]
使用正则分组,会包含分隔符:
-
"leo,pingan,pingan8787".split(/(,)/); // ["leo", ",", "pingan", ",", "pingan8787"]
- 修饰符
修饰符 | 描述 |
| 全局匹配,即找到所有匹配的,单词是 |
| 忽略字母大小写,单词是 |
| 多行匹配,只影响 |
文章到这结束,感谢阅读,也感谢老姚大佬的这本书