④ 贪婪与非贪婪
正则匹配默认是 贪婪匹配,即匹配尽可能多的字符~
简单得很,写个例子你就懂了:
re.match(r'^(\d+)(0*)$','12345000').groups() # 原意是想得到('12345', '000')这样的结果的,但输出的却是:('12345000', '') # 由于贪婪匹配,直接把后面的0都给匹配了,结果0*没东西匹配了 # 若果想尽可能少的匹配,可在\d+后加上一个问号?,采用非贪婪匹配,改成介样: re.match(r'^(\d+?)(0*)$','12345000').groups() # 输出结果:('12345', '000')
*0x5、背后的原理
这Part选读哈,不感兴趣可以跳过,多熟悉熟悉上面的正则语法就行了,笔者想弄清楚正则表达式背后原理的原因
网上很多文章都说到:正则表达式令人诟病的性能问题,但却很少人说清具体是什么问题?怎么引起的?如何规避优化~
下述图片和内容大部分来源于:《正则表达式引擎执行原理——从未如此清晰!》
① 正则表达式的工作流程
这里的 预编译 指的是提前初始化好正则对象,如Python中的re库,建议:
- 调用 re.compile(patter) 预编译返回Pattern对象,后续用到正则的地方直接引用;
预编译的方式,在循环中,用同一个正则进行匹配的场景有奇效 (避免重复创建、编译).
② 引擎
程序对正则表达式进行语法分析,建立 语法分析树,再根据分析树结合 正则表达式引擎 生成执行程序 (状态机、又称状态自动机) 用于 字符匹配。
这里的 引擎 就是一套 用于建立状态机的核心算法,主要分为下述两大类:
- DFA (Deterministic finite automaton) → 确定型有穷自动机;
- NFA (Non-deterministic finite automaton) → 非确定型有穷自动机;
拆词:
- 确定型与非确定型 → 字符串在没编写正则表达式的前提下,能否直接确定字符匹配顺序? 能就是确定型,不能的就是不确定型;
- 有穷 → 在有限次数内能得到结果;
- 自动机 → 在设置好匹配规则后由引擎自动完成,不需人为干预;
对比:
- 构造DFA的成本代价 (用内存多、编译速度慢) 远高于NFA,但DFA的执行效率高于NFA;
- 假设字符串长度为n,DFA的匹配时间复杂度为O(n),NFA因为匹配过程中存在大量分支和回溯,假设状态数为s,NFA的匹配的时间复杂度为O(ns);
- NFA的优势是支持更多功能,如捕获Group、环视、量词等高级功能,这些功能都是基于子表达式独立进行匹配,因此在编程语言中,使用正则表达式的库都是 基于NFA实现的;
DFA自动机是如何进行匹配的?
要点:
- 文本主导 → 按文本顺序执行,稳定;
- 记录当前有效的所有可能 → 如执行到(d|b),会同时比较表达式中的a|b,故需要更多的内存;
- 每个字符只检查一次 → 提高了执行效率,且速度与正则表达式无关;
- 不能使用反向引用等功能 → 每个字符只检查一次,位置只记录当前值,故无法使用反向引用、环视等功能;
NFA自动机是如何进行匹配的?
要点:
- 表达式主导 → 按照表达式的一部分执行,不匹配换其他部分继续匹配,直到表达式匹配完成;
- 会记录某个位置 → 如执行到(d|b)时,记录字符的位置,然后选择其中一个先匹配;
- 每个字符可能检查多次 → 执行到(d|b)时,比较d后发现不匹配,换成表达式的另外一个分支b,同时文本位置 回退,重新匹配字符b。这也是NFA非确定及效率可能没有DFA高的原因;
- 能使用反向引用等功能 → 因为有回退功能,所以很容易实现反向引用、环视等功能;
③ NFA自动机的回溯问题
上述 回退
专业术语叫 回溯
,原理类似于走迷宫时走过的路设置一个标志物,不对原路返回,换成另一条路。
回溯机制不但要重新计算 正则表达式 和 文本 的 对应位置
,还需要维护 括号子表达式所匹配的文本状态
,保存到内存中以 数字编号
的组中,这个组也叫 捕获组
。
捕获组保存括号内的匹配结果,后面的正则表达式中可以使用,就是上面说到的 反向引用。
还不是很理解?画个简单的回溯流程示意图~
content_str = "HELLO" content_regex = "HEL+O"
不难看出,回溯问题的导火索就是 贪婪匹配
,吃得多,要吐的也多,如果匹配的文本长度几W,引擎可能就要回溯几w次。
如果碰到复杂的正则表达式,有多个部分要回溯,那回溯次数就是 指数级别。比如文本长度为500,表达式有两部分要回溯,次数可能就是500^2=25w次了。够呛的...
④ 优化
优化的方向:减少引擎回溯次数
+ 更快更直接地匹配到结果
1) 少用贪婪模式
可使用 非贪婪模式(加?,会首先选择最小的匹配范围)
和 独占模式(加+,不匹配结束匹配,不回溯)
来避免回溯。
2) 减少分支选择
少用,一定要用的话可通过下述几种方式优化:
- 选择的顺序 → 更常用的选择项放前面,使得它们能较快地被匹配;
- 提取共用模式 → 如:将(abcd|abef) 替换成→ ab(cd|ef);
- 简单的分支选择 → 不用正则,直接用字符串查找函数(如index())找,效率还高一些;
3) 使用非捕获型括号
一般一个()就是一个捕获组,如果不需要引用括号中的文本,可使用非捕获组 (?:er),既能减少捕获时间,又能减少回溯使用的状态数量。
4) 一些零碎的优化点
- 长度判断优化 → 匹配字符串的长度都没正则长度长,就没必要匹配了;
- 预查必须字符 → 预先扫描必须字符,找都找不到,就没必要匹配了;
- 用好行和字符串开始、结束符 → 用好
^$\A\Z
更精确匹配行头、行尾、字符串开头和字符串结尾; - 别滥用括号 → 在需要的时候才用括号,在其他时候使用括号会阻止某些优化措施;
- 别滥用* → 点符号可以匹配任意字符串,但贪婪匹配会导致大量的回溯;
- 量词等价替换 → a{3} 可比 aaa要快上一些;
- 拆分表达式 → 有时,多个小正则表达式的速度比一个大正则表达式的速度要快很多;
0x6、小结
本节,系统过了一波正则表达式,从知道是什么,到语法,然后到原理,内容虽少,五脏俱全,希望能帮到想学正则的朋友,别再下次一定了,这次一定,学废 正则表达式~
常用的正则表达式模板网上有很多,菜鸟工具 上还挺全,取需,就不搬运了~
再安利一个插件吧:any-rule,同样取需~
最后,附上Python中re模块的常用函数速查,有问题欢迎在评论区反馈,谢谢~
0x7、附:Python中re模块的常用函数
import re # 将正则表达式编译成Pattern对象,好处:预编译+复用 test_pattern = re.compile(正则表达式字符串,标志位修饰符) # 标志位修饰符(flags) 用于控制匹配模式,支持同时选择多个(|连接),有下述这些: # # re.I IGNORECASE → 忽略大小写 # re.M MULTILINE → 多行匹配,影响^和$ # re.S DOTALL → 使.匹配包括换行在内的所有字符 # re.X VERBOSE → 忽略空白和注释,并允许使用'#'来引导一个注释 # re.U UNICODE → 根据Unicode字符集解析字符,影响\w、\W、\b和\B # re.L LOCALE → 做本地化识别(locale-aware)匹配 # 匹配:尝试从字符串的开头进行匹配,匹配成功返回匹配对象,否则返回None re.match(pattern, string, flags=0) # 匹配:扫描整个字符串,返回第一个匹配对象,否则返回None; re.search(pattern, string, flags=0) # 检索:扫描整个字符串,匹配所有能匹配的对象,以列表形式返回; re.findall(pattern, string, flags=0) # 检索:同findall,匹配所有能匹配的对象,但是是以迭代器形式返回; re.finditer(pattern, string, flags=0)参数 # 替换:将匹配的字符串替换为其他字符串,count为替换的最大次数,默认为0,替换所有。 re.sub(pattern, repl, string, count=0, flags=0) # 分割:将匹配的字符串进行分割,返回列表,maxsplit为分割的最大次数,默认为0,分割所有。 re.split(pattern, string, maxsplit=0, flags=0) # 分组:获取匹配结果中,每个分组匹配内容,可传入分组序号,不传整个匹配结果,传获取对应分组内容 pattern_result.group() # 分组:从group(1)开始往后的所有值,返回一个元组 pattern_result.groups() # 匹配的开始、结束位置 pattern_result.start() # 返回匹配的开始位置 pattern_result.end() # 返回匹配的结束位置 pattern_result.span() #返回一个元组,表示匹配位置(开始,结束) # 加载正则表达式字符串前的'r',如re.compile(r'xxx'),作用: # 告知编译器这个str是raw string(原始字符串),不要转义反斜杠,如r'\n'是两个字符 # 反斜杠 + n,而不是换行!