正则表达式之反向引用和前瞻后顾

有这么一道PHP面试题:将一个数字每三位用','隔开。比如'123456789'==>'123,456,789'。面试者给出的答案是: 

$num = "123456789";
$num = preg_replace('/(?<=[0-9])(?=(?:[0-9]{3})+(?![0-9]))/', ',', $num); 
echo $num;

而实际上 php 有个函数 number_format()完全可以完成这道题目,并且还能搞定浮点型的数字,比如'1234567.4321'==>'1,234,567.4321'。

不过面试者用到了正则中的前瞻后顾,而我之前对这个知识点很模糊,于是学了学正则的前瞻(?=pattern)、后顾(?<=pattern)、逆前瞻(?!pattern)、逆后顾(?<!pattern)以及正则的反向引用(pattern)。

反向引用

对一个正则表达式模式或部分模式两边添加圆括号将导致相关匹配存储到一个临时缓冲区中,所捕获的每个子匹配都按照在正则表达式模式中从左到右出现的顺序存储。缓冲区编号从 1 开始,最多可存储 99 个捕获的子表达式。每个缓冲区都可以使用 '\n' 访问。可以使用非捕获元字符 '?:'、'?=' 或 '?!' 来忽略对相关匹配的保存。

我们要匹配一个时间字符串,要求字符串格式必须是'2017-03-23 12:34:43'或'2017_03_23 12:34:43'。

正则表达式如下:

preg_match('/^\d{4}([\-\_])\d{2}\1\d{2}\s\d{2}(\:)\d{2}\2\d{2}$/', $str);

这里就用了反向引用,其中\1对应([\-\_]),\2对应(\:),这样就能保证2017后面的符号和03后面的符号一模一样,即使是'2017-03_23 12:34:43'也不能通过匹配.

反向引用是一个获取匹配,括号中匹配的内容会被存储供以后使用。反向引用因为有个储存的过程,因而速度回慢一些。有时候我们会用到‘()’,但是在后面并不会再使用‘()’中匹配的内容,这时候我们可以用(?:pattern),它和前面讲的前瞻后顾都是非获取匹配。括号中匹配的内容不会被存储。比如: industr(?:y|ies) 就比 'industry|industries' 更简略并且不会影响匹配速度。

正则的方向

正则表达式中的前和后和我们一般理解的前后有点不同。一段文本,我们一般习惯把文本开头的方向称作“前面”,文本末尾方向称为“后面”。但是对于正则表达式引擎来说,因为它是从文本头部向尾部开始解析的,因此对于文本尾部方向,因为这个时候正则引擎还没走到那块,所以他是正则的前方。而对文本头部方向,因为正则引擎已经走过了那一块地方,所以他是正则的前方。如下图所示:

image_thumb.png 

前瞻后顾

$str = "garen.goh and green are good names."

我们想把字符串里"goh"的"g"字替换为'G',而其他的"g"不变,这时就需要用到前瞻后顾。

正则表达式如下:

preg_replace('/(?<=are\s)g(?=[a-z])/', 'G', $str);

结果:garen.goh and green are Good names.

反过来,我们不想替换"good"的"g",但是想要将其他"g"替换为'G'。这时就要用到逆前瞻,逆后顾

正则表达式如下:

preg_replace('/(?<!\s)g|g(?!ood)/', 'G', $str); 或 preg_replace('/(?<!\s)g|g(?=reen)/', 'G', $str);

结果:Garen.Goh and Green are good names.

注意:后顾功能在大多数语言中有长度限制(长度必须固定,不能是变长),只能使用定长的表达式,像\w+和\d*这样的表达式长度可变,不能用在后顾中。用在后顾功能中属于语法错误,实际上不是语法错误,是正则表达式本身太扯淡,软件没实现这个功能,不让你用罢了。

Java对后顾功能的支持度,可以用?符号了,不能用+号,因为+号实在太扯淡,能匹配一个字符,也能匹配一万个字符,人家Sun公司的码农根本实现不出来,所以不给你提供这个功能。

.NET对后顾功能的支持度最高,可以匹配可变长度字符,可以用+号了,不愧是世界首富比尔盖茨微软,多砸钱,不信有实现不出来的功能。

16394460U-0.JPG

深入理解

现在我们再来看文章开头的面试题,面试者给出的答案就用了前瞻后顾:

$num = preg_replace('/(?<=[0-9])(?=(?:[0-9]{3})+(?![0-9]))/', ',', $num); 

首先,我们要知道匹配替换的内容是什么?答案是空,是每3个数字之间的空。我们拆分这个正则:

前瞻:(?=(?:[0-9]{3})+(?![0-9]))

后顾:(?<=[0-9])

我们先来看比较简单的后顾‘(?<=[0-9])’,它的意思是后方要有一个数字,这是要排除数字刚好为 3*n 的情况,没有这个后顾时'123456789'会替换为',123,456,789',明显第一个','是我们不需要的。

然后来看前瞻'(?=(?:[0-9]{3})+(?![0-9]))',前瞻中实际上又套用一个逆前瞻,前瞻分为两部分,匹配内容:‘(?:[0-9]{3})+’以及该内容的前瞻‘(?![0-9])’,内容表示匹配 3*n 个数字。使用了(?:pattern)。内容的前瞻表示内容前方不能是数字,如果没有这个嵌套的前瞻,结果将会是‘1,2,3,4,5,6,789’。

能理解透这个正则,应该对前瞻后顾就有了比较深入的理解了。这个正则只能解决整数,如果是浮点型或者是小数,就不适用了。例如:$str = '123456789.987654321',这是一个小数字符串(浮点数可没这么长),按照这个正则,结果是:123,456,789.987,654,321。这不是我们要的结果。我想过自己写一个,但是似乎都不行。

我的答案是:

preg_replace('/(?<=\d)(?=(\d{3})+(?=\.))/', ',', $str);	123456789==>123456789

能解决小数,但是整数又不行,整数没有'.'。但是如果加上'$',小数部分也会被替换

preg_replace('/(?<=\d)(?=(\d{3})+(?=\.|$))/', ',', $str);	123456789.987654321==>123,456,789.987,654,321

然后我想通过判断是否有'.'来区分整数和小数,不同的数用不同的正则:

preg_replace('/(?<=\d)(?=(\d{3})+(?=\.)|(?<!\.\d*)(\d{3})+(?=$))/', ',', $str);

肯定还是不行,后顾的长度必须确定有'\d*'会报错。

要求只用一个正则,希望有大牛给出答案。。。

热门文章