关于从普通文本提取正则表达式的再思考

rex按:写完上一篇文章之后,一直在考虑如何真正实现从普通文本中归纳正则表达式的实现。走了许多弯路,也学了不少知识。例如,perl黑豹书上复杂的数据结构、匿名散列和数组、refenrence;紫龙书上的状态机的构造,数据结构上图论的知识,都是很有用的。另外还新学了graphviz的用法。以前觉得很神秘,不过一用才发现很直观。本文的插图是使用online版本的graphviz画的。

除了本文的这种实现方法(基于图),我还使用另一种方式实现了,很简单:基于关键词。具体作法是,逐一读取每一行文本,使用\s+等将其split开,形成array;然后再对所有的array进行求交集的操作(使用hash),得到每一行都有的关键词;然后按从左到右的顺序建立这覆的正则式^(.*?)keyword1(.*?)keyword2….keywordN(.*?)$,再分别匹配每一行文本,得到hash的hash表,或者array的array,转置,并列输出,得到^(option1|option2…)keyword1(option..)…$这样的正则式。最后作为验证,再将所最终生成的正则与每一行匹配测试一下。

这样以词为单位做完之后,再逐个字母地分隔开来,递归地处理(option1|option2…)的部分。先是单词级,再是字母级,有利于先在最大程度上找出重复的内容;而且粗化和细化的处理过程,思路是一致的,粒度不同罢了。

新手请自重,高手请赐教,我的思路未必是正确或最优的。

问题                                                                       

                                                        

有文本文件text.txt,内容如下:
this is a red fox
this is a blue firefox
this is a pig
a red fox
请写一则程序,根据文本内容,自动构造(比较合理的)正则表达式,使之能够匹配文件中每一行文本。

标准正则                                                         

有两种极端的解法是不可取的:

  1. ^.*$
  2. ^(this is a red fox|this is a blue firefox|this is a pig|a red fox)$

第一种失之于太宽泛,第二种失之于太狭隘。太宽泛则泥沙俱下,无论什么文本都能匹配;太狭隘则僵化死板,缺乏灵活性。好的正则表达式源于例文本(从例文本中提取规律),又高于例文本(能匹配同规律的其它文本)。匹配什么,排除什么,都有定则,所谓“君子有所为而有所不为”,指的就是这种情况(貌似跑题了:))。

那么,如何是比较靠谱的正则表达式呢?以上文的例子而言,可以是:
^(this is )?a (red fox|blue firefox|pig)$

现在我们向着标准答案出发。

思路                                                                       

任何复杂的电路图,都可以拆分为三种简单的关系:串联,并联,短路。正则表达式也同理。

既然是一条正则匹配所有的文本,那么这条正则(记为$re)也应该匹配第一行文本。

第一行文本为this is a red fox。那么,从^this is a red fox$应该是$re的一个(真)子集。它的路径为:“^”->this->is->a->red->fox->”$”。全部节点之间,是串联关系,从左到右依次排列即可。

示意图如下(可以点击看全尺寸图,下同):

Photobucket 

同理,第二行文本也应该是$re的子集。不过,由于已经存在了由^->this->is->a的路径,到a时出现支路,a->blue->firefox->$

将此路径添加到示意图上,得到:

Photobucket 

显而易见,这两条并列的支路,始于a,终于$,可以使用|来并列之。

好了,我们总结一下规律:

并列:如果存在A->B->C,且同时存在A->D->C,则B与D之间是并联关系。即出发点相同,结束点相同,且出发点与结束点之间各有一个以上的节点。并列使用括号来表示,之间以|分隔。例如,对于A->B->C,A->D->C,则可以使用A(B|D)C来表示其正则关系。

为什么要强调是一个以上节点呢?这里先卖个关子。请继续阅读。

再往下,this is a pig,同理,只需要在原图基础上添加a->pig->$的支路即可。此时图示如下:

Photobucket

最后一条,a red fox。这条貌似复杂,但是只需在^->a之间新添加了一条路径而已;a->red->fox->$之间原有路径,可以继续使用。此时,得到完整的示意图如下:

Photobucket 
 此时,观察可知,一种新的情况出现了。同时存在^->a,和“^”->this->is->a两条路径。想一下初中物理电路图,我们可以将这种情况称为“短路”,即,“^”->this->is->a这个线路的^、a两个节点之间,添加了一条无障碍通道,它能无视this、is的存在,因此,让this->is这条路径成为可选项。再总结一下规律:

如果有A->B->…C->D的路径,且有A->D的路径,则称A->D之间存在短路,此时,B->…->C可以用(B->…->C)?来表示(就是用括号来表示被短路的部分,问号表示短路之)。

顶点A,D之间,最多存在一个短路关系。但是可以有1或更多条并列的关系存在。

好了,分析结束,得到这样的正则式:
^(this is )?a (red fox|blue firefox|pig)$

这也就是为什么上文要强调是一个节点的缘故。

如果我们再精益求精的话,可以对red fox|blue firefox|pig这部分递归地进行上述分析过程,进而得到 (red |blue fire)fox|pig这样的结果。

实现                                                                       

思路有了,编程就简单了。perl中,固然可以使用比较简洁的hash表来表示链表之间的关系:
例如:

my $hash;

$$hash{“^”}{“this”}{“is”}{“a”}{“red”}{“fox”}{“\$”}=”";
$$hash{“^”}{“this”}{“is”}{“a”}{“blue”}{“firefox”}{“\$”}=”";

但是,节点的增删修改都是麻烦事。(我在hash迷宫中lost了很久才爬出来)

抽空补了一下有向图的知识,觉得可以简化问题如下。

Photobucket 

上图其实是一个有向图,只需记录所有的顶点集合,路径集合,再来求各路径之间的关系;最后打印输出,即是所求。

顶点集合为:

(^, this, is, a, red, fox, blue, firefox, pig, $);

通路关系集合为:

(^->this, this->is,…)

这两个集合在读取文本文件行的时候可以一次性建立。不复杂。关键是关系的确立。

再次总结,如下:
  • 从一个顶点A出发的N条支路必定汇合(只是有时是同一个点,有时不在同一点而已。本文给出的例子是最简单的情况,这里可以假设为汇合到同一点)于M点。
  • 这N条路中,每一条路径的长短以经过的节点个数来计算。例如上图中,^到a有一条路,上面的路径为2,下面的路径为0。
  • 短的支路决定了这N条支路的关系。
  • 长度为任意两点之间,最多只可能有一条长度为0的边。
  • 如果存在长度为0的边,则其余的同级的支路被短路。
  • 长度不为0的N-1条支路之间是并列关系。
  • 整个图始于^,终于$。
这些条件、判断,均可以细化为函数。具体的程序从略。

[译]递归正则表达式

原文在此rex译于2009年12月15~17日,翻译过程中使用的是google docs@prism@firefox@ubuntu 9.10,很爽的体验。感谢余晟老师在正则和翻译方面的悉心指导。

递归正则表达式

平时我们用到的正则表达式,其实没那么“规则”。多数编程语言所支持的扩展的正则表达式,其运算能力比起形式语言理论所定义的“规则”正则表达式要强得多。

rex注:在这里其实可以看到,如果将正则表达式译为正规表达式,一切就都通顺了:
平时我们用到的正规表达式,其实没那么“正规”。多数编程语言所支持的扩展的正规表达式,其运算能力比起正式语言理论所定义的“正规”正规表达式要强得多。

regular expression的日文是“正规表现”,在鸟哥书中,好像也将其称为正规表达式。via

例如,经常用到的捕获缓存,就是用来帮助我们临时存储任意正则表达式模式,以便重复使用。 又如,“环视断言”能让正则表达式引擎在做决定之前先偷偷看看环视一下。这些扩展让正则表达式非常强大,足以描述一些“上下文无关语法”。

Perl语言的正则表达式引擎特性异常丰富,其特征之一是懒惰正则子表达式(Lazy regular subexpressions),格式为(??{code}),其中的“code”可以是任意一段perl程序,该子表达式可能匹配时,这段程序就会执行。

我们可以利用这一特征来编写出非常有趣的东西,即将正则表达式自身嵌在它的“code”部分,由此生成递归的正则表达式(a recursive regular expression)!

一直以来,正则表达式无法匹配0n1n这种表达式,也就是由若干个0以及同等数量的1所组成的字符串。如果使用懒惰正则子表达式,这一经典问题就迎刃而解。

下面是匹配0n1n字串的perl正则表达式代码。

$regex = qr/0(??{$regex})?1/;

rex注:紫龙书第四章有云:“文法是比正则表达式表达能力更强的表示方法。每个可以使用正则表达式描述的构造,都可以使用文法来描述,但是反之不成立。换句话说,每个正则语言都是一个上下文无关语言,但是反之不成立。”书中交待的正则,也应该是指的常规正则表达式,而非现代语言中的扩展的正则表达式。一般使用正则表达式来构造小部件,而使用文法来组建语言框架。

此正则表达式匹配一个字符0,之后是正则表达式自身0或1次,之后是字符1。如果正则表达式自身部分不能匹配,那么它只能匹配01;如果自身部分可以匹配,则正则表达式匹配的是00($regex)?11,此时若不能匹配自身则结果是0011,若可以匹配就是000($regex)?111,……依次顺延。

下面是匹配050000150000的Perl程序:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/perl                                                                                                                         
 
$str = "0"x50000 . "1"x50000;
$regex = qr/0(??{$regex})*1/;
 
if ($str =~ /^$regex$/) {
  print "yes, it matches";
}
else {
  print "no, it doesn't match";
}

现在来看题图所示的Yo Dawg正则表达式。你先猜猜它的作用?正确答案是,它匹配(foo(bar())baz)这样完全嵌套的括号表达式(fully parenthesized expression)或((()()())())这样的平衡括号表达式(balanced parentheses)。

1
2
3
4
5
6
7
8
9
$regex = qr/
  \(                 # (1) match an open paren (                               #(1),此处匹配开括号
    (                # followed by                                                      #(2),紧接着是
      [^()]+       #   (3) one or more non-paren character            #(3),1个或多个非括号字符
    |               # OR                                                                    #(4),或
      (??{$regex})   #   (5) the regex itself                                   #(5),正则式自身
    )*              # (6) repeated zero or more times                     #(6),重复0或多次
  \)                 # (7) followed by a close paren )                         #(7),紧接着是闭括号
/x;

rex注:关于Yo Dawg图片的含义,可以参考这里。基本上是全是“Yo dawg, I herd you like X, so we put a Y in your Y so you can Z while you Z”的结构的配图文字。

构造此正则表达式的思路是这样的。对于完全嵌套的括号表达式,它的开始字符是一个开括号。这是最简单的一步,我们直接写出(上面程序中的(1))。同理,它的结束字符是闭括号,于是得到(7)。现在该动脑筋了,括号中间是什么呢?对,它可以是既不是开括号又不是闭括号的任意字符(第(3)点),也可以是另一个完全嵌套的表达式(即第(5)点)。所有的这些,既可以只匹配0次(第(3)点),以便构造最小的完全嵌套括号表达式(),也可以匹配多次来匹配较复杂的表达式。

去掉 /x 选项(即,不再使用多行风格的注释模式),可以简记为:

$regex = qr/\(([^()]+|(??{$regex}))*\)/;

但是切勿在正式产品中使用这一特性,它太诡异,不好把握。建议使用较稳定成熟的Text::BalancedRegexp::Common 模块。

rex注:对于(??{code}),perl官方的提示是:此正则表达式仅作测试使用,可能有更新而不作提示。代码执行时产生的副作用,因版本而异,运行结果或有不同,取决于正则引擎的后期优化。

最后提醒大家,在Perl 5.10中已经可以使用递归捕获缓存来替代懒惰代码子表达式了,运行结果相同。

下面是匹配0n1n的递归捕获缓存语法(?N)的实现:

my $rx = qr/(0(?1)*1)/;

(?1)*的含义是“匹配第一组0或多次”,这里的第一组是指整个正则表达式。

请自行动手,重写平衡括号的正则表达式,当作练习。

祝玩得开心!