louzhedong / blog

前端基础,深入以及算法数据结构

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

初识正则表达式引擎

louzhedong opened this issue · comments

可能用过的正则表达式

  1. 强密码(必须包含大小写字母和数字的组合,不能使用特殊字符,长度在 8-10 之间)
^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])[a-zA-Z0-9]{8,10}$
  1. 电子邮件
^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4})*$
  1. 匹配base64
^data:([A-Za-z-+\/]+);base64,(.+)$

起源

1940年:正则表达式最初的想法来自两位神经学家:沃尔特·皮茨与麦卡洛克,他们研究出了一种用数学方式来描述神经网络的模型。
1956年:一位名叫史蒂芬·克林的数学科学家发表了一篇题目是《神经网事件的表示法》的论文,利用称之为正则集合的数学符号来描述此模型,引入了正则表达式的概念。正则表达式被作为用来描述其称之为“正则集的代数”的一种表达式,因而采用了“正则表达式”这个术语。
1968年:C语言之父、UNIX之父肯·汤普森把这个“正则表达式”的理论成果用于做一些搜索算法的研究,他描述了一种正则表达式的编译器,于是出现了应该算是最早的正则表达式的编译器qed(这也就成为后来的grep编辑器)。
Unix使用正则之后,正则表达式不断的发展壮大,然后大规模应用于各种领域,根据这些领域各自的条件需要,又发展出了许多版本的正则表达式,出现了许多的分支。我们把这些分支叫做“流派”。
1987年:Perl语言诞生了,它综合了其他的语言,用正则表达式作为基础,开创了一个新的流派,Perl流派。之后很多编程语言如:PythonJava、Ruby、.Net、PHP等等在设计正则式支持的时候都参考Perl正则表达式

引擎分类

正则表达式有一系列的语法,随便举几个例子:
  1. 横向模糊匹配:量词 *, +, ?, {m,n}
  2. 纵向模糊匹配:字符集 [abc], 多选分支(p1|p2|p3)
这里不讲使用,而是会着重描述一下正则表达式的正则引擎及其原理
正则表达式一般都是通过有限自动机来实现的,正则表达式匹配字符串的过程,简单来说可以分解为两步:
  1. 将正则表达式转换成有限自动机
  2. 有限自动机输入字符串执行
正则引擎主要可以分为不同的两大类:DFA和NFA(其实还有一种 POSIX NFA,是根据NFA引擎出的规范版本,但使用比较少,所以不讨论)。例如My�SQL用的是DFA引擎,而Java、PHP、Python则采用的是NFA引擎
当然JavaScript和大多数语言一样,也是采用的NFA,我们可以用下面这个表达式来测试使用引擎
"NFA not".match(/NFA|NFA not/)
如果是NFA引擎,则会返回NFA
如果是DFA 或 POSIX NFA 引擎,则会返回NFA not
WHY?

前置

FA(有限自动机,又称有限状态机)

首先我们需要了解一下有限自动机
我们可以把有限自动机理解为一个机器人,在这个机器人眼里,所有事物都是由有限节点组成。机器人按照顺序读取有限节点,并达成有限状态。最终输出accept或reject作为最终状态
直接总结一下FA的关键特点:
  • 有一个起点,被一个没有起点的箭头指向
  • 有一个终点,用双圈来表示
  • 有限状态集
  • 根据当前状态和输入来到达下一个状态
如上图,它有【S1, S2】两个状态,其中起点和终点都是S1
如果当前状态是S1,输入0,会流转到下一个状态S2;如果输入1,会流转到下一个状态S1
再比如上面提到过的检测base64:^data:([A-Za-z-+\/]+);base64,(.+)$
它的有限状态机如下:
再比如匹配日期(比如YYYY/MM/DD):^(19|20)?[0-9]{2}[- /.](0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])$
它的有限状态机如下:
任何一个正则语句都可以转换成一个有限自动机

DFA(Deterministic finite automaton)

DFA引擎匹配过程
来看一个简单的例子:'tonight'.match(/to(nite|knite|night)/)
文本中的to不在话下,直接能匹配上,之后就有三个备选项。文本走到n时,它发现正则只有两个选项符合要求,经过i走到g的时候,只剩一个分支符合要求了。当然,还要继续匹配,然后发现,第三个分支完美符合要求。如果这时候第三个分支不是nignt,那这次匹配就是失败了。
DFA引擎的一些特点
  1. 文本主导:按照文本的顺序执行,这也就能说明为什么DFA引擎是确定型(deterministic)了,稳定!
  2. 记录当前有效的所有可能:我们看到当执行到(nite|knite|night)时,同时比较表达式中的niteknitenight,所以会需要更多的内存。
  3. 每个字符只检查一次:这提高了执行效率,而且速度与正则表达式无关。
  4. 不能使用反向引用等功能:因为每个字符只检查一次,文本位置只记录当前比较值,所以不能使用反向引用、环视等一些功能!
DFA 引擎在线性时状态下执行,因为它们不要求回溯(并因此它们永远不测试相同的字符两次)
DFA 是 文本主导引擎

NFA(Non-deterministic finite automaton)

NFA引擎匹配过程
依旧是这个例子:'tonight'.match(/to(nite|knite|night)/)
文本中的to也不在话下,直接都匹配上了。之后就有三个备选项,在这里就和DFA有所区别了:它不会同时去匹配多个选项,而是每一种都去尝试一下,第一个选项在t这里停止了,接着第二个选项在k这里也停止了。而第三个选项能完全匹配成功。
NFA引擎的一些特点
  1. 表达式主导:按照表达式的一部分执行,如果不匹配换其他部分继续匹配,直到表达式匹配完成。
  2. 会记录某个位置:我们看到当执行到(nite|knite|night)时,NFA引擎会记录字符的位置,然后选择其中一个先匹配。
  3. 单个字符可能检查多次:我们看到当执行到(nite|knite|night)时,比较nite后发现不匹配,于是NFA引擎换表达式的另一个分支knite,同时文本位置回溯,重新匹配字符'night'。这也是NFA引擎是非确定型的原因,同时带来另一个问题效率可能没有DFA引擎高。
  4. 可实现反向引用等功能:因为具有回溯这一步,所以可以很容易的实现反向引用、环视等一些功能!
NFA包含回溯算法也叫试探法,从一条路往前走,能进则进,不能进则退回来,换一条路再试。回溯算法说白了就是穷举法。因此,在最坏情况下,它的执行速度可能非常慢。
NFA 是 表达式主导引擎
更进一步,我们能发现NFA的匹配方式是我们熟悉的DFS(深度优先搜索)

回溯

回溯是NFA最重要的部分
NFA引擎最重要的性质是:它会依次处理各个子表达式或组成元素,遇到需要在两个可能成功的可能中进行选择的时候,它会选择其一,同时记住另一个,以备稍后可能的需要。

回溯的两个要点

  1. 如果有多个选择,应该首先选用哪一个
原则:如果需要在‘进行尝试’和‘跳过尝试’之间选择,对于匹配优先量词,引擎会优先选择‘进行尝试’,而对于忽略优先量词,会选择‘跳过尝试’
什么是匹配优先,什么是忽略优先
正则表达式,量词是匹配优先的,也就是说,量词会尽量地吃,直到由于吃得太多,导致后面没法匹配,才吐出来一个。
举例来说,文本ab1cd2,正则表达式 .*[0-9]
匹配过程:*一直吃到2,发现坏了,数字没法匹配了,于是吐出2,匹配成功,结束。也就是说.*匹配了ab1cd
如果想让.*[0-9]匹配ab1cd2两次,怎么办?
忽略量词优先,.*?[0-9],量词后面加一个问号。也就是说,让*尽量地少吃。
匹配过程:*不吃,不吃不行啊,a不能匹配数字,于是吃下a,b不能匹配数字,于是再吃下b,1匹配数字,结束。开始下一个匹配。
  1. 当回溯进行时,应该选取哪个保存的状态
原则:距离当前最近存储的选择就是当本地失败强制回溯时返回的。使用的原则是LIFO原则

DFA和NFA对比

引擎 功能 速度 预编译 编译 稳定性
DFA 简单 优化简单 慢,占内存多 稳定
NFA 丰富 优化复杂 快,占内存少 一般
问:为什么在编译阶段NFA比DFA快?
答:首先,正则表达式在计算机看来只是一串符号,正则引擎首先肯定要解析它。NFA引擎只需要编译就好了;而DFA引擎则比较繁琐,编译完还不算,还要遍历出表达式中所有的可能。因为对DFA引擎来说机会只有一次,它必须得提前知道所有的可能,才能匹配出最优的结果。
问:为什么在运行阶段NFA比DFA慢?
答:DFA引擎在匹配途中一遍过,非常丝滑。相反NFA引擎就比较苦逼了,它得不厌其烦的去尝试每一种可能性,可能一段文本它得不停返回又匹配,重复好多次。当然运气好的话也是可以一遍过的。
NFA和DFA并非是不能并存的,有些工具是兼具两种匹配引擎的,来使自身具备DFA的高效和NFA的多功能的。比如GNU的grep和awk,在完成是否匹配的任务的时候使用高效的DFA引擎,完成复杂任务的时候也是尽量使用DFA,如果功能上无法满足需要就切换成NFA引擎

实现简单引擎

这里我们来实现一个简单的正则匹配引擎,以此加深对正则的理解
再回顾一下正则表达式匹配字符串的过程,可以分解为两步:
  1. 将正则表达式转换成有限自动机
  2. 有限自动机输入字符串执行
我们的整体思路也是如此,首先将正则表达式转换成自动机,再让自动机匹配字符串即可

规则介绍

  1. [] 表示匹配字符串集合中的一个字符,后面可以接* 和 +
  2. * 表示匹配0或0个以上字符
  3. + 表示匹配1或1个以上字符
例如:lou*[bcd]*[efg]+

思路

  1. 按照我们上面说的,第一步是要将正则表达式转换成一个有限自动机
由于我们输入的是字符串,字符串能直接转换成自动机吗,显然是不行的
所以我们先需要解析我们的字符串
首先我们将每个具有匹配意义的字符串定义为一个TOKEN,其中每一个TOKEN都是一个对象,里面保存着下一个可能匹配的所有分支
那么lou*[bcd]*[efg]+就可以按照以下划分生成一个TOKEN序列
  • l
  • o
  • u*
  • [bcd]*
  • [efg]+
直接看代码
// TOKEN对象定义
interface TOKEN {
isStart?: boolean;
isEnd?: boolean;
pattern: Array<string>; // 所有可能字符串
classifier: string; // 量词 * 和 + 以及 'none'
next: Array<TOKEN>;
}
/**
* 将字符串转成TOKEN序列
*/
const CLASSIFIERS = ['*', '+'];
function generateTOKENList(str: string): Array<TOKEN> {
const length = str.length;
let TOKENList: Array<TOKEN> = [];
let collection = []; // []符号包裹的字符串算作一个集合
let collectionFlag = false;
for (let i = 0; i < length; i++) {
const char = str[i];
if (!collectionFlag) { // 不在集合匹配中
if (char === '[') {
collectionFlag = true;
} else {
let classifier = 'none';
let nextStr = str[i + 1];
if (nextStr && CLASSIFIERS.indexOf(nextStr) > -1) {
classifier = nextStr;
i++;
}
TOKENList.push({
isStart: false,
pattern: [char],
classifier,
next: []
});
}
} else {
if (char != ']') {
collection.push(char);
} else {
collectionFlag = false;
let classifier = 'none';
let nextStr = str[i + 1];
if (nextStr && CLASSIFIERS.indexOf(nextStr) > -1) {
classifier = nextStr;
i++;
}
TOKENList.push({
isStart: false,
pattern: collection,
classifier,
next: []
});
collection = [];
}
}
}
return TOKENList;
}
经过转换后我们输入的字符串会变成这个样子
接下来我们给 TOKENList 数组加入起始状态和结束状态。之后我们给起始状态的 next 初始化,再循环遍历数组,为数组的每一项的 next 初始化,这样就通过 next 中存储的指针将自动机的各个状态串联起来了。
interface TOKEN_START {
isStart: boolean;
next: Array<TOKEN>;
}
interface TOKEN_END {
isEnd: boolean;
}
function getNext(TOKENList: Array<TOKEN>, index: number): Array<TOKEN> {
const next = [];
const length = TOKENList.length;
for (let i = index + 1; i < length; i ++) {
const nextTOKEN = TOKENList[i];
next.push(nextTOKEN);
if (nextTOKEN.classifier !== '*') {
break;
}
}
return next;
}
function getGenerator(TOKENList: Array<TOKEN>): TOKEN_START {
let start: TOKEN_START = {
isStart: true,
next: []
};
let end: TOKEN_END = {
isEnd: true
}
TOKENList.push(end as any);
const length = TOKENList.length;
start.next = getNext(TOKENList, -1);
for (let i = 0; i < length; i++) {
const currentTOKEN = TOKENList[i];
currentTOKEN.next = getNext(TOKENList, i);
if (CLASSIFIERS.indexOf(currentTOKEN.classifier) > -1) {
currentTOKEN.next.push(currentTOKEN);
}
}
return start;
}
输出的结果如下
  1. 现在我们已经将正则表达式转换成一个自动机,接下来就是用这个自动机去匹配字符串了
通过递归对每一个字符进行匹配,不断移动焦点
function isMatch(str: string, generator: TOKEN): boolean {
if (generator.isStart) {
for (const nextTOKEN of generator.next) {
if (isMatch(str, nextTOKEN)) return true;
}
return false;
} else if (generator.isEnd){
return str.length ? false : true;
} else {
if (!str.length) {
return false;
}
if (generator.pattern.indexOf(str[0]) === -1) {
return false;
} else {
const restStr = str.slice(1);
for (const nextTOKEN of generator.next) {
if (isMatch(restStr, nextTOKEN)) return true;
}
return false;
}
}
}
至此,我们就完成了一个简单的正则表达式引擎
尝试几个测试用例,结果如下

优化

既然JavaScript是NFA引擎的,因此控制回溯是控制正则表达式性能的关键
控制回溯又可以拆分成两部分:
第一是控制备选状态的数量
第二是控制备选状态的顺序
备选状态的数量当然是核心,然而如果备选状态虽然多,却早早的匹配成功了,早匹配早下班,也就没那么多糟心事了
所以我们可以从这些方面来进行优化
正则表达式自身提供的一些优化:
  1. 如果正则的开头是 ^,引擎会只从字符串开始进行尝试,而不去尝试其它的位置(减少搜索的初始状态);
  2. 如果正则的结尾是 $ 并且没有 +* 这种可以无限重复的量词,正则引擎会尝试从末尾的倒数若干字符进行尝试,例如 rex(-zeng)?$ 最多匹配 8 个字符,因此引擎会从倒数第八个字符开始尝试(减少搜索的初始状态);
  3. 如果当前剩余的正则可以成功匹配的最小长度大于字符串的剩余长度,引擎会直接报告本次匹配失败(利用矛盾来提前剪枝);
  4. 把连续的确定字符当作一个元素,例如 \srex\d 只有三个元素:\srex\d(减少递归层数);
开发者能做的一些优化:
  1. 使用更精确的元素和量词缩小查找范围:我们可以用字符集代替.,特定的量词代替`*`。这样能减少很多不必要的遍历。
  2. 优化多选分支,将更容易匹配到的选项放在前面:例如这个用来匹配域名的正则 \.(?:com|org|net|cn|me|info)\b,因为更多的域名是 .com 的,引擎在更多的情况下第一次尝试就可以匹配到结果。
  3. 缩短匹配字符串:如果不幸正则的执行复杂度比较高,如果匹配字符串的长度较短(<10),那么回溯的深度相对可控,不容易引发大的性能问题。

学习资料

  1. 正则表达式转换成图形
  2. regex101
  3. 《精通正则表达式》