5.打造高效正则表达式

来源:互联网 发布:ppt视频不流畅 知乎 编辑:程序博客网 时间:2024/05/02 00:25

打造高效正则表达式

性能和质量指标

  • 独立测试次数 : 例如 smarty =~ marty, 会独立测试 6m-s、m-m、a-a、r-r、t-t、y-y
  • 回溯次数
  • 准确性

  • 多选时将优先匹配率高的表达式放在前面, 注意不是匹配范围

  • 限制贪婪匹配的作用范围
  • 通常字符组的效率相对比多选结构高

1. 典型示例

1.1 匹配带引号的字符串

  • 传统型 NFA 引擎 : 在遇到第一个完整匹配可能时会停止, 如果没有完整匹配时, 也会尝试所有可能
  • POSIX NFA 引擎 : 在给出结果之前必须尝试所有可能, 多选分支的顺序并不重要
# 使用 NFA 引擎时,会对每个字符都应用多选结构造成效率很低"2\"x3\" likeness" =~ "(\\.|[^\\"])*"   # \\. 匹配 \", [^\\"] 匹配引号中间的其它字符# 传统型 NFA 调换多选结构顺序, 遇到字符串中的转义字符才会按照多选结构进行回溯"2\"x3\" likeness" =~ "([^\\"]|\\.)*"   # 可以充分发挥 * 号贪婪匹配特性# 影响准确性的调换, 如将 "(\\.|[^"])*" 多选结构顺序调换"2\"x3\" likeness" =~ "([^"]|\\.)*"     # 只能匹配 "2\", 因为当 [^"] 无法匹配时, 回溯到分支 \\. 也无法匹配 "2\ 后的 " 号# 传统型 NFA 下的限制贪婪匹配的作用范围, 在 POSIX NFA 引擎下会极大增加尝试所有可能匹配的次数"2\"x3\" likeness" =~ "([^"\\]+|\\.)*"  # 减少了多选结构回溯次数和 * 号的迭代次数(不用多次进入括号)

1.2 测试正则表达式引擎类型

  • 如果其中某个表达式, 即使不能匹配, 也能很快给出结果, 可能就是 DFA
  • 如果只有在能够匹配时才很快出结果, 就是传统型 NFA
  • 如果总是很慢, 就是 POSIX NFA

2. 全面考察回溯

2.1 传统型 NFA 的匹配过程

  • 使用正则 ".*" 匹配 The name "McDonald's" is said "makudonarudo" in Japanese
  • 尝试匹配 The name 不成功 —> 匹配到第一个 " —> *号匹配会贪婪匹配整个字符串 —> *号匹配完成, 开始匹配最后一个 " 双引号 —> 发现没有字符了,开始回溯 —> 回溯到最后一个字母 e 无法匹配 —> 再继续回溯, 直到遇到能够匹配 " 的字符为止(需要回溯13次) —> 将剩下的备用状态抛弃, 报告匹配成功

2.2 POSIX NFA

  • POSIX NFA 匹配到的已经是 “到目前为止最长的匹配”, 但是仍需要尝试所有保存的状态, 确定是否存在更长的匹配, 会丢弃短的匹配结果和不匹配的文本

2.3 无法匹配时必须进行的工作

  • 当无法匹配时, 传统型 NFA 和 POSIX NFA 都会尝试整个序列, 传统型 NAF 必须进行的尝试与 POSIX NFA 进行的尝试一样多

2.4 多选结构的代价很高

  • 不同实现方式效率可能存在差异, 但通常来说字符组的效率相对比多选结构高
The name "McDonald's" is said "makudonarudo" in Japanese      # 测试文本[uvwxyz]    : 字组符表达式, 只需要进行简单的尝试, 共 34 次尝试u|v|w|x|y|z : 多选结构表达式, 需要在匹配到之前每个位置进行 6 次回溯, 共 204 次回溯

3. 性能测试

  • 只记录真正的处理时间, 减少误差
  • 进行大数据的处理, 防止处理速度过快和处理大数据时性能下降
  • 尽量进行准确的处理, 如下例中 while($Count-- > 0) 就会影响准确性

3.1 perl 中测试

$ perluse Time::HiRes 'time';              # 让 time() 返回值更加精确$TimesToDo = 1000;                   # 设定重复次数$TestString = "abababdefg" x 1000;   # 生成长字符串$Count = $TimesToDo;$StartTime = time();while ($Count-- > 0) {               # 注意此处的 Count-- 也会消耗时间    $TestString =~ m/^(a|b|c|d|e|f|g)+$/;}$EndTime = time();printf("多选结构使用 : %.3f 秒.\n", $EndTime - $StartTime);$Count = $TimesToDo;$StartTime = time();while ($Count-- > 0) {    $TestString =~ m/^[a-g]+$/;}$EndTime = time();printf("字符组使用 : %.3f 秒.\n", $EndTime - $StartTime);#================================= 速度相差大约 25 倍 =================================多选结构使用 : 0.252 秒.字符组使用 : 0.010 秒.

Java 中测试

import java.util.regex.Matcher;import java.util.regex.Pattern;public class JavaBenchmark {    public static void main(String[] args) {        Matcher regex1 = Pattern.compile("^(a|b|c|d|e|f|g)+$").matcher("");        Matcher regex2 = Pattern.compile("^[a-g]+$").matcher("");        StringBuilder sb = new StringBuilder();        for (int i = 100; i > 0; i--)             sb.append("abababdefg");        String testString = sb.toString();        {            // 测试多选结构            long count = 1000000;            long startTime = System.currentTimeMillis();            while(--count > 0) {                regex1.reset(testString).find();            }            double seconds = (System.currentTimeMillis() - startTime) / 1000.0;            System.out.println("多选结构使用 : " + seconds + "秒");        }        {            // 测试字符组            long count = 1000000;            long startTime = System.currentTimeMillis();            while(--count > 0) {                regex2.reset(testString).find();            }            double seconds = (System.currentTimeMillis() - startTime) / 1000.0;            System.out.println("字符组使用 : " + seconds + "秒");        }    }}

4. 常见优化措施

4.1 优化基本措施

  • 加速某些操作 : 某些类型的匹配, 例如 \d+ 极为常见, 引擎可能对此有特殊的处理方案, 执行速度比通用的处理机制要快
  • 避免冗余操作 : 如果引擎认为, 对于产生正确结果来说, 某些特殊的操作是不必要的, 或者某些操作能够应用到比之更少的文本, 忽略这些操作能够节省时间; 例如一个以 \A 开头的正则表达式, 如果开头无法匹配, 传动装置不会徙劳地尝试其他位置

4.2 优化注意事项

  • 优化所需时间 + 优化的可能性检测时间 < 节省的时间, 优化才是有益的
  • 不同的引擎使用的优化措施不同, 可能在某种引擎上能大大提高效率, 但在另一种引擎上反而会损耗性能

4.3 正则表达式的应用步骤

  1. 正则表达式编译 : 检查正则表达式的语法正确性, 如果正确会将其编译为内部形式(internal from)
  2. 传动开始 : 传动装置将正则引擎 “定位” 到目标字符串的起始位置
  3. 元素检测 : 引擎开始测试正则表达式和文本, 依次测试正则表达式的各个元素(comp-onent)
  4. 寻找匹配结果 : 传统型 NFA 匹配到结果会锁定在当前状态, 报告匹配成功, 而 POSIX NFA 匹配到迄今最长的匹配, 会记住这个最长的状态, 然后从可用的保存状态继续下去, 保存的状态都测试完毕之后返回最长的匹配
  5. 传动装置的驱动过程 : 如果没有找到匹配, 传动装置就会驱动引擎, 从文本中的下一个字符开始新一轮尝试(回到步骤3)
  6. 匹配彻底失败 : 如果从目标字符串的每一个字符(包括最后一个字符之后的位置)的尝试都失败了, 报告匹配彻底失败
元素检测注意事项 :
  • 相连元素 : 会依次尝试, 只有当某个元素匹配失败时才会停止, 例如 Subject 中的每个元素都会依次尝试
  • 量词修饰的元素 : 控制权在量词(检查量词是否应该继续匹配)和被限定的元素(测试能否匹配)之间轮换
  • 控制权在捕获型括号内外进行切换会带一些开销, 因为括号内的表达式匹配的文本必须保留, 以便反向引用, 进出捕获型括号时需要修改状态

4.4 应用正则之前的优化措施

4.4.1 编译缓存

集成式处理中的编译缓存
  • 内部每个正则表达式都关联到代码的某一部分, 第一次执行时在编译结果与代码之间建立关联, 下次执行时只需要引用即可
  • 非常容易进行编译缓存, 最节省时间, 但是需要一部分内存来保存缓存的表达式
  • Perl 和 awk 都是使用集成式处理方法
程序式处理中的编译缓存
  • 集成式处理中的编译缓存, 与其在程序中所处的位置相关, 当再次执行这段代码时, 编译缓存才能使用重复使用
  • 程序式处理中的编译缓存, 编译形式不与程序的具体位置相连, 通常把最近使用的正则表达式模式保存下来, 关联到最终的编译形式
  • GNU Emacs 缓存最多保存 20 个正则表达式, Tcl 能保存 30 个, PHP 能保存 4000 多个, .NET Framework 默认保存 15 个, 可以设置和禁用
面向对象式处理中的编译缓存
  • 正则表达式何时编译完全由程序员决定
  • Java 通过 Pattern.compile 创建, Python 通过 re.compile 创建, .NET 通过 New Regex 创建
  • 通过对象析构函数或类似的机制抛弃编译好的正则表达式

4.4.2 预查必须字符/子字符串优化

  • 某些系统会在编译阶段快速扫描判断是否存在成功匹配必须的字符或字符串, 如果不存在则不需要进行任何尝试
  • 通常使用 Boyer-Moore 搜索算法检查整个字符串(一种很快的文件检索算法, 字符串越长, 效率越高)

4.4.3 长度判断优化

  • 例如 ^Subject:'(.*) 能匹配的长度是不固定的, 但是至少必须包含 9 个字符, 所以, 如果目标字符串的长度小于 9 则根本不必尝试

4.5 通过传动装置进行优化

  解决的问题 : 即使正则引擎无法预知与某个字符串是否匹配, 也能够减少传动装置的应用正则表达式的位置

4.5.1 锚点优化 : 字符串起始/行

  • 可以判断 字符串起始、行锚点、\A、多次匹配的\G 只在能够匹配的情况下才匹配, 只要在这些位置应用即可

4.5.2 隐式锚点优化

  • 如果正则表达以 .*.+ 开头, 而且没有全局性多选结构, 则可以认为此正则表达式的开关有一个看不见的 ^, 就可以使用行的锚点优化

4.5.3 锚点优化 : 字符串结束/行

  • 遇到未尾为 $ 或者其它结束锚点的正则表达式时, 能够从字符串未尾倒数若干个字符的位置开始尝试匹配
  • 例如 regex(es)?$ 匹配只可能从字符串末尾倒数第8个字符开始匹配,传动装置能够跳到那个位置,忽略前面的字符

4.5.4 开头字符/字符组/子串识别优化

  • 正则表达式的任何匹配必须以特定字符或文字子字符串开头时,容许传动装置进行快速子字符串检查,所以它能够在字符串中合适的位置应用正则表达式
  • 例如 this|that|other 只能从 ot 的位置, 所以传动装置预先检查字符串中的每个字符,只在可能的位置进行应用匹配

4.5.5 内嵌文字字符串检查优化

  • 针对匹配中固定位置出现的文字字符串,只有在内嵌文字字符串与表达式起始位置的距离固定时才能进行
  • 例如 \b(perl|java)\.regex.info\b 那么任何匹配中都要有 .regex.info 所以传动装置能够使用高速的 Boyer-Moore 字符串检索算法寻找 .regex.info, 然后往前 4 个字符, 开始实际应用正则表达式

4.5.6 长度识别传动优化

  • 如果当前位置距离字符串末尾的长度小于成功匹配所需最少长度,传动装置会停止尝试

4.6 优化正则表达式本身

文字字符连接优化
  • 引擎会将 abc 当作一个字符,而不是三个字符,这样整个字符串就可以作为匹配迭代的一个单元,而不需要进行三次迭代
化简量词优化
  • 约束普通元素 : 例如文字字符或字符组的加号、星号之类的量词, 通常要经过优化,避免使用普通 NFA 引逐步处理开销,而使用高速、专门化程序处理
消除无必要括号
  • 如果某种实现方式认为 (?:.)* 与 .* 是完全等价的, 则使用后者替换前者
消除不需要的字符组
  • 只包含单个字符组有点多余,例如会在内部把 [.] 转换为 \.
非贪婪模式量词之后的字符优化
  • 如果文字字符跟在非贪婪模式量词之后,只要引擎没有触及那个文字字符,非贪婪模式量词可以作为贪婪模式量词处理
  • 例如 "(.*?)" 中的 *? 在处理时, 引擎必须在量词作用对象 . 和 " 之后的字符之间切换,会造成额外的开销
过度回溯检测
  • 例如 (.+)* 之类的量词结合结构, 能够制造指数级的回溯, 为了避免这种情况可以限定回溯的次数, 是简单的办法就是在超限时停止匹配
避免指数级(超线性)匹配
  • 在匹配进入超线性状态时进行检测,判断单个量词迭代(循环)的次数是否比目标字符数量多,如果是的活,就记录每个量词对应的子表达式尝试匹配的位置,绕过重复尝试
使用占用优先量词消减状态
  • 一种是在全部尝试完成之后抛弃所有备用状态,另一种是迭代时抛弃上一轮的备用状态(保存一个状态,保证在量词无法继续匹配时引擎还能继续运转)
量词等价转换
  • 对于量词 \d{4}\d\d\d\d 效率快,所以一此工具会进行优化
  • 对于文本字符 ={4}==== 慢, 直接使用 ==== 引擎更容易将其识别为一个文本字符串
需求识别
  • 引擎会预先取消它认为对匹配结果没有价值的工作

5. 提高表达式速度的诀窍

5.1 基本诀窍

  • 编写适于优化的正则表达式 : 编写适应已知优化措施的表达式
  • 模拟优化 : 对于一些所用的程序没有进行优化的地方,通过手工模拟,提升效率;如 this|that 之前添加 (?=t)
  • 主导引擎的匹配 : 使用关于传统型 NFA 引擎工作原理的知识,能够主导引擎更快地匹配; 如将 this|that 更改为 th(?:is|at)

5.2 注意事项

  • 进行看来确实有帮助的改动,有时反而事与愿违,因为这样可能禁用了不知道的,已经生效的其他优化
  • 添加一些内容模拟你知道的不存在的优化措施,但可能会处理那些添加内容的时间多于节省下的时间
  • 添加一些内容模拟一个目前未提供的优化,如果将来升级以后,软件支持此优化,反而会影响或者重复真正的优化
  • 控制表达式尝试触发某种当前可用的优化,将来某些软件升级后可能无法进行某些更高级的优化
  • 为提高效率修改表达式,可能导致表达式难以理解和维护
  • 具体的修改带来的好处或坏处的程序,基本上取决于表达式应用的数据

5.3 常识性优化

  • 避免重新编译,使用编译好的缓存
  • 不需要引用括号内的文本,使用非捕获型括号
  • 不要滥用括号,如 (.)*
  • 不要滥用字符组, 如 ^.*[:]
  • 使用起始锚点, 在 .* 最前面添加 ^\A

5.4 将文字文本独立出来

  • 从量词中提取必须的元素, 如使用 xx* 替代 x+, 使用 -----{0,2} 代替 -{5,7}
  • 提取多选结构开头的必须元素,如使用 th(?:is|at) 替代 (?:this|that), 使用 (?:ab|aa)cd 代替 (?:abcd|aacd)

5.5 将锚点独立出来

  • 在表达式前面独立出 ^ 和 \G, 例如使用 ^(?:abc|123) 代替 ^abc|^123
  • 在表达式末尾独立出 $, 例如使用 (?:abc|123)$ 代替 abc$|123$

5.6 忽略优先还是匹配优先?

  • 具体情况具体分析,例如 ^.*:^.*?:

5.7 拆开正则表达式

  • 有时候, 应用多个小正则表达式的速度比一个大正则表达式要快很多
  • 例如 JanuaryFebruaryMarch 更比 January|Frbruar|March 要快得多

5.8 模拟开头字符识别

  • 例如 Jan|Feb|...|Dec 可以使用 (?=[JFMASOND])(?:Jan|Feb|...|Dec), 利用环视匹配定位开始字母,注意环视结构的时间可能大于优化时间
  • 也可以使用 [JFMASOND](?:(?=<J)an|(?<=eb)|...(?<=D)ec) 可以在大部分引擎上都实现优化, 上面则不然
  • 不要在 Tcl 和 PHP 中这么做

5.9 使用固化分组和占有优先量词

  • 固化分组和占用优先量词能够极大地提高匹配速度,而它们不会改变匹配结果
  • 例如 ^[^:]+: 在第一次无法匹配后,回溯是没有意义的,所以可以修改为固化分组 ^(?>[^:)+): 或者占有优先量词 ^[^:]++ 直接抛弃备用状态

5.10 将最可能匹配的多选分支放在前头

  • 例如匹配主机名时将 (?:areo|biz|com|coop|...) 更改为 (?:com|cn|edu|org|net|...) 可能获得更快速的匹配
  • 注意 : 只适用于传统型的 NFA 引擎

5.11 将结尾部分分散到多选结构内

  • 可以加快匹配失败的速度
  • 例如 (?:com|edu|...|[a-z][a-z])\b 更改为 com\b|edu\b|...\b|[a-z][a-z]\b
  • 这种优化是有风险的, 比如分散的子表达是文字文本或者结尾是 $ 锚点时, 例如将 (?:this|that): 更改为 (?:this:|that:),就违被背了 将文本独立出来的思想

6. 消除循环

  • 出现的根本原因是 多种方式匹配同样的文本

6.1 方法一 : 依据经验构建正则表达式

  • 分析全局匹配情况,提取能够真正匹配成功的子表达式,然后根据这些子表达式重新构建高效的表达式

    “(\.|[^\”]+)*” # 如果无法匹配, 这个表达式需要近乎无限的时间进行尝试, 比如”The name in Japanese\”

1. 分析全局匹配情况
目标字符串 对应表达式 “hi there” "[^\\"]+" “just one \” here” "[^\\"]+\\.[^\\"]+" “some \”quoted\” things” "[^\\"]+\\.[^\\"]+\\.[^\\"]+" “with \”a\” and \”b\”.” "[^\\"]+\\.[^\\"]+\\.[^\\"]+\\.[^\\"]+\\.[^\\"]+" “\”ok\”\n”
”empty \”\” quote” "\\>[^\\"]+\\.\\."<br>"[^\\"]+\\.\\.[^\\"]+"
2. 构造通用的 “消除循环” 解法
  1. normal 部分和 special 部分匹配的开头不能重合, 否则会导致不知道使用哪一个, 造成无休止匹配
  2. 如果能够匹配成功, opening normal 部分必须匹配至少一个字符, 否则由 (special normal*)* 匹配, 会产生循环的 (...*)*
  3. special 部分必须是固化的
# 通用格式opening normal*(special normal*)*closing   # * 号防止内容和引号内的内容为空# 消除例子循环"[^\\"]*(\\.[^\\"]*)*"  

6.2 方法2 : 自顶向下的视角

  • 开始只匹配目标字符串中常见的部分,然后增加对非常见情况的处理
# 可能会无限循环的正则"(\\.|[^\\"]+)*"# 分析过程 :# 1. 通常情况下普通字符比转义字符更多, 所有使用 [^\\"]+ 承担大部分工作# 2. 使用 \\. 来处理偶然出现的转义字符# 3. 转义字符后可能还有普通字符, 使用 [^\\"]* 来处理# 4. 实现了与第一种方式一样的效果"[^\\"]*(\\.[^\\"]*)*"    # *星号防止内容和引号内的内容为空

6.3 方法3 : 使用固化分组和优先量词消除循环

  "(\\.|[^\\"]+)*" 的问题在于无法匹配时会在毫无用处的备用状态之中不断回溯,而我们可
以使用固化分组和占有优先量词抛弃这些状态。

# 使优先量词消除循环"(\\.|[^\\"]++)*"     # 消除多选结构每次迭代时保留的状态"(\\.|[^\\"]+)*+"     # 放弃括号内的备用状态,在完成时不会留下任何状态(推荐)"(\\.|[^\\"]++)*+"    # 都放弃,和上面的效率一样# 使用固化分组消除循环"(?>[^\\"]+|\\.)*"     # 消除多选结构每次迭代时保留的状态"(?>(?>[^\\"]+|\\.)*)" # 同时消除多选结构和外面量词的备用状态

6.4 消除C语言注释匹配的循环

/\*.*?\*/    # 消除正常注释, 如/* 注释 *//\*[^*]*\*/  # 不能消除 /** 注释 **//x[^x]*x+([^/x][^x]*x+)*//\*[^*]*\*+([^/*][^*]*\*+)*/
0 0
原创粉丝点击