1、概述
平衡组是微软在.NET中提出的一个概念,主要是结合几种正则语法规则,提供对配对出现的嵌套结构的匹配。.NET是目前对正则支持最完备、功能最强大的语言平台之一,而平衡组正是其强大功能的外在表现,也是比较实用的文本处理功能,目前只有.NET支持,相信后续其它语言会提供支持。
平衡组可以有狭义和广义两种定义,狭义平衡组指.NET中定义的(?Expression)语法,广义平衡组并不是固定的语法规则,而是几种语法规则的综合运用,我们平时所说的平衡组通常指的是广义平衡组。本文中如无特殊说明,平衡组这种简写指的是广义平衡组。
正是由于平衡组功能的强大,所以带来了一些神秘色彩,其实平衡组并不难掌握。下面就平衡组的匹配原理、应用场景以及性能调优展开讨论。
2、平衡组匹配原理
2.1 预备知识
平衡组通常是由量词,分支结构,命名捕获组,狭义平衡组,条件判断结构组成的,量词和分支结构这里不做介绍,这里只对命名捕获组,狭义平衡组和条件判断结构做下说明。
2.1.1命名捕获组
语法:(?Expression)
(?’name’Expression)
以上两种写法在.NET中是等价的,都是将“Expression”子表达式匹配到的内容,保存到以“name”命名的组里,以供后续引用。
对于命名捕获组的应用,这里不做重点介绍,只是需要澄清一点,平时使用捕获组时,一般反向引用或Group对象使用得比较多,可能会有一种误解,那就是捕获组只保留一个匹配结果,即使一个捕获组可以先后匹配多个子串,也只保留最后一个匹配到的子串。但事实是这样吗?
举例来说:
源字符串:abcdefghijkl
正则表达式:(?[a-z]{2})+
命名捕获组chars最终捕获的是什么?
string test = "abcdefghijkl"; Regex reg = new Regex(@"(?[a-z]{2})+"); Match m = reg.Match(test); if (m.Success) { richTextBox2.Text += "匹配结果:" + m.Value + "n"; richTextBox2.Text += "Group:" + m.Groups["chars"].Value + "n"; } /*--------输出-------- 匹配结果:abcdefghijkl Group:kl */
从m.Groups[“chars”].Value的输出上看,似乎确实是只保留了一个匹配内容,但却忽略了一个事实,Group实际上是Capture的一个集合
string test = "abcdefghijkl"; Regex reg = new Regex(@"(?[a-z]{2})+"); Match m = reg.Match(test); if (m.Success) { richTextBox2.Text += "匹配结果:" + m.Value + "n"; richTextBox2.Text += "Group:" + m.Groups["chars"].Value + "n--------------n"; foreach (Capture c in m.Groups["chars"].Captures) { richTextBox2.Text += "Capture:" + c + "n"; } } /*--------输出-------- 匹配结果:abcdefghijkl Group:kl -------------- Capture:ab Capture:cd Capture:ef Capture:gh Capture:ij Capture:kl */
平时应用时可能会忽略这一点,因为很少遇到一个捕获组先后匹配多个子串的情况,而在一个捕获组只匹配一个子串时,Group集合中就只有一个Capture元素,所以内容是一样的。
string test = "abcdefghijkl"; Regex reg = new Regex(@"(?[a-z]{2})"); Match m = reg.Match(test); if (m.Success) { richTextBox2.Text += "匹配结果:" + m.Value + "n"; richTextBox2.Text += "Group:" + m.Groups["chars"].Value + "n--------------n"; foreach (Capture c in m.Groups["chars"].Captures) { richTextBox2.Text += "Capture:" + c + "n"; } } /*--------输出-------- 匹配结果:ab Group:ab -------------- Capture:ab */
捕获组保存的是一个集合,而不只是一个元素,这一知识点对于理解平衡组的匹配原理是有帮助的。
2.1.2狭义平衡组
语法:(?Expression)
其中“Close”是命名捕获组的组名,也就是“(?Expression)”中的“name”,可以省略,通常应用时并不关注,所以一般都是省略的,写作“(?Expression)”。作用就是当此处的“Expression”子表达式匹配成功时,则将最近匹配成功到的命名为“Open”组出栈,如果此前不存在匹配成功的“Open”组,那么就报告“(?Expression)”匹配失败,整个表达式在这一位置也是匹配失败的。
2.1.3条件判断结构
语法:(?(Expression)yes|no)
(?(name)yes|no)
对于“(?(Expression)yes|no)”,它是“(?(?=Expression)yes|no)”的简写形式,相当于三元运算符
(?=Expression) ? yes : no
表示如果子表达式“(?=Expression)”匹配成功,则匹配“yes”子表达式,否则匹配“no”子表达式。如果“Expression”与可能出现的命名捕获组的组名相同,为避免混淆,可以采用“(?(?=Expression)yes|no)”方式显示声明“Expression”为子表达式,而不是捕获组名。
“(?=Expression)”验证当前位置右侧是否能够匹配“Expression”,属于顺序环视结构,是零宽度的,所以它只参与判断,即使匹配成功,也不会占有字符。
举例来说:
源字符串:abc
正则表达式:(?(?=a)w{2}|w)
当前位置右侧如果是字符“a” ,则匹配两个“w”,否则匹配一个“w”。
string test = "abc"; Regex reg = new Regex(@"(?(?=a)w{2}|w)"); MatchCollection mc = reg.Matches(test); foreach(Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- ab c */
对于“(?(name)yes|no)”,如果命名捕获组“name”有捕获,则匹配“yes”子表达式,否则匹配“no”子表达式。这一语法最典型的一种应用是平衡组。
当然,以上两种语法中,“yes”和“no都是可以省略的,但同一时间只能省略一个,不能一起省略。平衡组的应用中就是省略了“no”子表达式。
2.2平衡组的匹配原理
平衡组的匹配原理可以用堆栈来解释,先举个例子,再根据例子进行解释。
源字符串:a+(b*(c+d))/e+f-(g/(h-i))*j
正则表达式:)|[^()])*(?(Open)(?!)))
需求说明:匹配成对出现的()中的内容
string test = "a+(b*(c+d))/e+f-(g/(h-i))*j"; Regex reg = new Regex(@")|[^()])*(?(Open)(?!)))"); MatchCollection mc = reg.Matches(test); foreach (Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- (b*(c+d)) (g/(h-i)) */
下面来考察一下这个正则,为了阅读方便,写成宽松模式。
Regex reg = new Regex(@"( #普通字符“(” ( #分组构造,用来限定量词“*”修饰范围 (?() #命名捕获组,遇到开括弧'Open'计数加1 | #分支结构 (?)) #狭义平衡组,遇到闭括弧'Open'计数减1 | #分支结构 [^()]+ #非括弧的其它任意字符 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'Open',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace);
对于一个嵌套结构而言,开始和结束标记都是确定的,对于本例开始为“(”,结束为“)”,那么接下来就是考察中间的结构,中间的字符可以划分为三类,一类是“(”,一类是“)”,其余的就是除这两个字符以外的任意字符。
那么平衡组的匹配原理就是这样的:
1.先找到第一个“(”,作为匹配的开始
2.在第1步以后,每匹配到一个“(”,就入栈一个Open捕获组,计数加1
3.在第1步以后,每匹配到一个“)”,就出栈最近入栈的Open捕获组,计数减1
4.后面的(?(Open)(?!))用来保证堆栈中Open捕获组计数是否为0,也就是“(”和“)”是配对出现的
5.最后的“)”,作为匹配的结束
匹配过程(以下匹配过程,如果觉得难以理解,可以暂时跳过,先学会如何使用,再研究为什么可以这样用吧)
首先匹配第一个“(”,然后一直匹配,直到出现以下两种情况之一:
a)堆栈中Open计数已为0,此时再遇到“)”
b)匹配到字符串结束符
这时控制权交给(?(Open)(?!)),判断Open是否有匹配,由于此时计数为0,没有匹配,那么就匹配“no”分支,由于这个条件判断结构中没有“no”分支,所以什么都不做,把控制权交给接下来的“)”
如果上面遇到的是情况a),那么此时“)”可以匹配接下来的“)”,匹配成功;如果上面遇到的是情况b),那么此时会进行回溯,直到“)”匹配成功为止,否则报告整个表达式匹配失败。
由于.NET中的狭义平衡组“(?Expression)”结构,可以动态的对堆栈中捕获组进行计数,匹配到一个开始标记,入栈,计数加1,匹配到一个结束标记,出栈,计数减1,最后再判断堆栈中是否还有Open,有则说明开始和结束标记不配对出现,不匹配,进行回溯或报告匹配失败;如果没有,则说明开始和结束标记配对出现,继续进行后面子表达式的匹配。
需要对“(?!)”进行一下说明,它属于顺序否定环视,完整的语法是“(?!Expression)”。由于这里的“Expression”不存在,表示这里不是一个位置,所以试图尝试匹配总是失败的,作用就是在Open不配对出现时,报告匹配失败。
3、平衡组的应用及优化
平衡组提供了嵌套结构的匹配功能,这一创新是很让人兴奋的,因为此前正则对于嵌套结构的匹配是无能为力的。然而功能的强大,自然也带来了实现的复杂,正则书写得不好,可能会存在效率陷阱,甚至导致程序崩溃,这里介绍一些基本的优化方法。
3.1单字符嵌套结构平衡组优化
单字符的嵌套结构指的是开始和结束标记都单个字符的嵌套结构,这种嵌套相对来说比较简单,优化起来也比较容易。先从上面提到的例子开始。
3.1.1贪婪与非贪婪模式
上面给的例子是一种做了部分优化的常规写法,算作是版本1吧,它做了哪些优化呢,先来看下完全没有做过优化的版本0吧。
string test = "a+(b*(c+d))/e+f-(g/(h-i))*j"; Regex reg0 = new Regex(@"( #普通字符“(” ( #分组构造,用来限定量词“*”修饰范围 (?() #命名捕获组,遇到开括弧Open计数加1 | #分支结构 (?)) #狭义平衡组,遇到闭括弧Open计数减1 | #分支结构 . #任意字符 )*? #以上子串出现0次或任意多次,非贪婪模式 (?(Open)(?!)) #判断是否还有'OPEN',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace); MatchCollection mc = reg0.Matches(test); foreach (Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- (b*(c+d)) (g/(h-i)) */
接下来对比一下版本1。
Regex reg1 = new Regex(@"( #普通字符“(” ( #分组构造,用来限定量词“*”修饰范围 (?() #命名捕获组,遇到开括弧'Open'计数加1 | #分支结构 (?)) #狭义平衡组,遇到闭括弧'Open'计数减1 | #分支结构 [^()]+ #非括弧的其它任意字符 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'Open',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace);
看到区别了吗?版本1对版本0的改进主要有两个地方,一个是用“[^()]+”来代替“.”,另一个是用“*”来代替“*?”,也就是用贪婪模式来代替非贪婪模式。
如果使用了小数点“.”,那么为什么不能在分组内使用“.+”,后面又为什么不能用“*”呢?只要在上面的正则中使用并运行一下代码就可以知道了,匹配的结果是
(b*(c+d))/e+f-(g/(h-i))
而不是
(b*(c+d))
(g/(h-i))
因为无论是分组内使用“.+”还是后面使用“*”,都是贪婪模式,所以小数点会一直匹配下去,直到匹配到字符串的结束符才会停止,然后进行回溯匹配。为了取得正确结果,必须使用非贪婪模式“*?”。
这就类似于用“”去匹配“(abc)def(ghi)”一样,得到的结果是“(abc)def(ghi)”,而不是通常我们希望的“(abc)”和“(ghi)”。这时要用非贪婪模式“”来得到正确的结果。
贪婪模式和非贪婪模式在匹配失败时,回溯的次数基本上是一样的,效率上没有多大区别,但是在匹配成功时,贪婪模式比非贪婪模式回溯的次数要少得多,效率要高得多。
对于“”如果既要得到正确的匹配结果,又要提高匹配效率,可以使用排除型捕获组+贪婪模式的方式,即“”。
版本0的平衡组也是一样,可以使用排除字符组“[^()]+”和贪婪模式“*”结合的方式,提高匹配效率,得到的就是版本1的平衡组。
相对于版本0,或许你会认为版本1的写法是很自然的,但是如果不了解这样一个演进过程,那么在字符序列嵌套结构平衡组优化时,就不会是那么自然的一件事了。
3.1.2 分支结构
接下来就是分支结构的优化。
语法:(Exp1|Exp2|Exp3)
因为分支结构的匹配规则是,从左向右尝试匹配,当左侧分支匹配成功时,就不再向右尝试。所以使用分支结构时,可以根据以下两条规则进行优化:
1.尽量抽象出每个分支中的公共的部分,使最后的表达式中,每个分支共公部分尽可能的少,比如(this|that)的匹配效率是没有th(is|at)高的。
2. 在不影响匹配结果的情况下,把出现概率高的分支放在左侧,出现概率低的分支放右侧。
对于本例中的分支结构,已经没有公共部分,符合第一条规则,再看下第二条规则,开始标记“(”和结束标记“)”出现的概率基本上是一样的,而除“(”和“)”之外的字符出现的概率是比“(”和“)”出现的概率高的,所以应该把“[^()]+”分支放在左侧。
版本1由于采用了排除型捕获组,所以这三个分支没有包含关系,左右顺序对结果不会造成影响,可以调整顺序。因为这是已经经过优化的了,而如果是版本0,由“.”对“(”和“)”有包含关系,就不能调整顺序了。
在版本1基础上对分支结构进行优化后,就得到版本2。
string test = "a+(b*(c+d))/e+f-(g/(h-i))*j"; Regex reg2 = new Regex(@"( #普通字符“(” ( #分组构造,用来限定量词“*”修饰范围 [^()]+ #非括弧的其它任意字符 | #分支结构 (?() #命名捕获组,遇到开括弧Open计数加1 | #分支结构 (?)) #狭义平衡组,遇到闭括弧Open计数减1 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'OPEN',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace); MatchCollection mc = reg2.Matches(test); foreach (Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- (b*(c+d)) (g/(h-i)) */
3.1.3 捕获组
这里面主要涉及到了两个捕获组“(?()”和“(?))”,而在平衡组的应用中,我是只关心它是否匹配了,而对于匹配到的内容是不关心的。对于这样一种需求,可以用以下方式实现
( (?)
)(?)
“(?)”和“(?)”这两种方式只是使用了命名捕获组,捕获的是一个位置,它总是能够匹配成功的,而匹配的内容是空的,分配的内存空间是固定的,可以有效的节省资源,这在单字符嵌套结构中并不明显,但是在字符序列嵌套结构中就比较明显了。
由于捕获组是直接跟在开始或结束标记之后的,所以只要开始或结束标记匹配成功,命名捕获组自然就会匹配成功,对于功能是没有任何影响的。
那么把标记和捕获组调整一下顺序是否可以呢?从功能上来讲,是可以的,但是匹配的流程上会有所不同,先是捕获组匹配成功,入栈,然后再匹配标记,成功则继续匹配,不成功则该分支匹配失败,进行回溯,出栈,继续尝试下一分支。这样将增加许多入栈和出栈的操作,对匹配效率是有影响的,所以这种方式并不可取。
在版本2基础上对捕获组进行优化后,就得到版本3。
string test = "a+(b*(c+d))/e+f-(g/(h-i))*j"; Regex reg3 = new Regex(@"( #普通字符“(” ( #分组构造,用来限定量词“*”修饰范围 [^()]+ #非括弧的其它任意字符 | #分支结构 ( (?) #命名捕获组,遇到开括弧Open计数加1 | #分支结构 ) (?) #狭义平衡组,遇到闭括弧Open计数减1 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'OPEN',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace); MatchCollection mc = reg3.Matches(test); foreach (Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- (b*(c+d)) (g/(h-i)) */
3.1.4 固化分组
看到有些人使用平衡组时用到了固化分组,但并不是所有人都明白固化分组的作用。
语法:(?>Expression)
用“”去匹配“(abc)”是可以匹配成功的,因为不用回溯,相对于“”这种非贪婪模式,效率上有所提升,但是对于匹配失败的情况又如何呢?
源字符串:(abc
正则表达式:
匹配中间过程这里不再详述,可以参考NFA引擎匹配原理。
当“[^()]+”匹配到结束位置时,控制权交给“)”,匹配失败,进行回溯,而由于前面使用了“[^()]+”这种排除型字符组,所以可供回溯的位置,不会存在可以匹配“)”的情况,这时候的回溯是完全没有意义的,只会浪费时间,但是由于传统NFA引擎的特点,必须回溯所有可能之后才会报告匹配失败。
这时可以用固化分组来进行优化,一旦占有字符,就不再释放。也就是一旦占有,就不再记录可供回溯的可能。通常是与排除型字符组或顺序否定环视一起使用的。
优化后的正则表达式:
需要说明的一点,固化分组要作用于量词修饰的子表达式才有意义,对于“(?>abc)”由于内容是固定的,根本就不会产生回溯,所以使用固化分组是没有意义的。
对于平衡组的应用也是一样,如果分组构造中没有量词,那么使用固化分组就是没有意义的,比如版本0
Regex reg = new Regex(@”)|.)*?(?(Open)(?!)))”);
这种场景下使用固化分组就是没有意义的。
在版本3基础上对捕获组进行优化后,就得到版本4。
string test = "a+(b*(c+d))/e+f-(g/(h-i))*j"; Regex reg4 = new Regex(@"( #普通字符“(” (?> #分组构造,用来限定量词“*”修饰范围 [^()]+ #非括弧的其它任意字符 | #分支结构 ( (?) #命名捕获组,遇到开括弧Open计数加1 | #分支结构 ) (?) #狭义平衡组,遇到闭括弧Open计数减1 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'OPEN',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace); MatchCollection mc = reg4.Matches(test); foreach (Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- (b*(c+d)) (g/(h-i)) */
那么对于分组构造外层的“*”修饰的子表达式是否可以使用固化分组呢?答案是否定的,因为平衡组通常是要进行回溯才能最终匹配成功的,所以如果使用固化分组,不记录回溯可能的话,将无法得到正确结果。
3.1.5 进一步优化讨论
那么现在是不是已经完成优化了呢?是的,通常可以这么认为。在一般应用当中,这已经是从正则层面上来说,最优方案了。
但是在有些场景下,由于Compiled模式可以有效提高分支结构的匹配效率,所以对于源字符串比较复杂的情况,牺牲一些编译时间和内存,还是可以有效提高匹配效率的。
Regex reg5 = new Regex(@"( #普通字符“(” (?> #分组构造,用来限定量词“*”修饰范围 [^()]+ #非括弧的其它任意字符 | #分支结构 ( (?) #命名捕获组,遇到开括弧Open计数加1 | #分支结构 ) (?) #狭义平衡组,遇到闭括弧Open计数减1 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'OPEN',有则说明不配对,什么都不匹配 ) #普通闭括弧 ", RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); MatchCollection mc = reg5.Matches(test); foreach (Match m in mc) { richTextBox2.Text += m.Value + "n"; } /*--------输出-------- (b*(c+d)) (g/(h-i)) */
并不是所有应用场景都适合使用Compiled模式,比如上面这个例子里的源字符串如果是“a+(b*(c+d))/e+f-(g/(h-i))*j”,本身是非常简单的,使用Compiled模式将是得不偿失的。什么时候使用,要根据具体问题具体分析。
3.2 字符序列嵌套结构平衡组应用
字符序列嵌套结构的匹配,典型的应用就是html标签的提取。由于上面详细说明了单字符嵌套结构的优化过程,这里主要讲应用场景,个别涉及到优化的地方再讨论。
字符序列嵌套结构的匹配,举例来说,取div标签。源字符串如下:
012
3.2.1提取最外层嵌套结构
提取最外层div标签,分析过程及构造方式与单字符嵌套结构差不多,只是捕获组等内容稍稍复杂点,先给出实现,再进行解释。
string test = @"01"; Regex reg = new Regex(@"(?isx) #匹配模式,忽略大小写,“.”匹配任意字符2]*> #开始标记“#结束标记“” (?> #分组构造,用来限定量词“*”修饰范围]*> (?) #命名捕获组,遇到开始标记,入栈,Open计数加1 | #分支结构(?) #狭义平衡组,遇到结束标记,出栈,Open计数减1 | #分支结构 (?:(?!?divb).)* #右侧不为开始或结束标记的任意字符 )* #以上子串出现0次或任意多次 (?(Open)(?!)) #判断是否还有'OPEN',有则说明不配对,什么都不匹配
”
“);
MatchCollection mc = reg.Matches(test);
foreach (Match m in mc)
{
richTextBox2.Text += m.Value + “n——————–n”;
}
/*——–输出——–
——————–
——————–
*/