最近两天一直在看《精通正则表达式》这本书,收获颇多。当看到书中关于使用正则表达式完成千分位分隔符时,我知道自己该写一篇博客了,平复一下我躁动的心情。因为这套经典的面试题原来一直都在书中有满满的答案......
一、一些必要的知识
本文中所有的代码示例都会以Perl代码给出,当然都非常简单,为了让没有对Perl语言基础的小白们快速上手,我在这里稍微提一下。
Perl定义变量
$price = $2.33;
Perl使用正则表达式替换文案
# 定义变量 $name
$name = "Jeffs";
# 使用正则表达式在文案Jeffs的字符s前面添加撇号
# =~ :为匹配操作符号
# s:表示依据后面的正则替换匹配的文案
$name =~ s/Jeffs/Jeff's/g;
print "$name";
将上面的代码保存到test.pl文件中,然后运行如下命令,即可查看运行结果。
perl -w test.pl
二、切入正题
首先是一个问题:如何将一个数字使用逗号,进行千分位分隔?示例如下:
1234567 =====>>>> 1,234,567
请此处停顿30秒,思考一下自己能使用什么方法完成这个问题,有几种方法完成这个问题?
或许有些人已经想用
for
循环来解决这个问题,当然这样也是一种,但这不是一个好方法,也不是我们这里要讨论方法,我们这里只讨论使用正则。
要解决上面的问题,我这里有七种正则表达式的方案实现,详见下表
序号 | 解决方案 | 评价 |
---|---|---|
方案一 | s/(?<=\d)(?=(\d\d\d)+$)/,/g |
并没有占用任何文本,同时使用了肯定顺序环视和肯定逆序环视匹配需要的位置,即撇号插入的位置 |
方案二 | s/(?=(\d\d\d)+$)(?<=\d)/,/g |
跟方案一相同,只是替换了环视的位置 |
方案三 | s/(?<=\d)(?=(\d\d\d)+\b)/,/g |
跟方案一的使用场景不同,适合在一段语句中匹配数字使用 |
方案四 | s/(?<=\d)(?=(\d\d\d)+(?!\d))/,/g |
是对方案三的加强版本,可以处理"123456Hz" 这类的字符串。例如:123456789Hz ---->>>> 123,456,789Hz |
方案五 | s/(\d)(?=(\d\d\d)+(?!\d))/$1,/g |
使用了顺序环视和分组匹配,消除了上述几种方案中的肯定逆序环视 |
方案六 | s/(\d)((?:\d\d\d)+\b)/$1,$2/g |
这种只使用分组匹配的方案是不行了,原因见下文 |
方案七 | 见下文 | 在方案六的基础上,结合了编程语言 |
三、正则中的环视和单词边界
我们可以思考一下,千分位分隔符,其实就是把[,]放到源数字的的合适位置而已,那么我们的思路应该放到位置,而不是“字符”本身。当你的思路一直是如何通过搬动字符来跟[,]糅合,那肯定是陷进了泥潭!
千分位分隔符就是从右到左,每隔三位,增加一个[,],目标字段最前端不能出现[,],比如,1,234,567
是不合法的。
上述方案中用到了一些特殊的元字符,更确切的说应该是元字符序列,比如[?<=]、[?=],这两个在正则中被称为环视, 所谓环视就是通过环视元字符来确定要匹配字符的位置,但是被环视元字符约束的字符不被匹配。
类型 | 正则表达式 | 匹配成功条件 |
---|---|---|
肯定逆序环视 | (?<=...) |
子表达式能够匹配左侧文本 |
否定逆序环视 | (? |
子表达式不能匹配左侧文案 |
肯定顺序环视 | (?=...) |
子表达式能够匹配右侧文案 |
否定顺序环视 | (?!...) |
子表达式不能匹配右侧文案 |
方案一:s/(?<=\d)(?=(\d{3})+$)/,/g
$num = '1230456789';
$num =~ s/(?<=\d)(?=(\d{3})+$)/,/g;
print "$num";
运行结果:
1,230,456,789
这个正则主要有两部分组成,使用/(?<=\d)/
(下面称规则1)和/(?=(\d{3})+$)/
(下面称规则2)确定[,]的位置。规则二是一个肯定顺序环视,表示要匹配的位置后面必须有3位数字的倍数,并且必须以3位数字的倍数结尾,比如6位, 9位。规则一是一个肯定逆序环视,表示要匹配的位置前面必须有一个数字,用来消除规则二导致前面出现[,]的情况。例如,123,456,789
.
也许有些人会好奇,为什么规则二最后要加一个末尾匹配/$/呢。假如不加这个末尾匹配,会得到怎样的结果呢?运行代码,最后得到的结果是1,2,3,0,4,5,6,789
,这是为什么呢!原因是(?=(\d{3})+)
表示要匹配的位置右侧只要有3个数字就行了,所以会出现只有最后三个数字之间没有[,],而其他数字之间都有[,]的情况了。
方案二:s/(?=(\d{3})+$)(?<=\d)/,/g
跟方案一相同,只是替换了环视的位置。无论是逆序环视,还是顺序环视,都是确定匹配的位置。所以哪个放前,哪个放后都是相同的。
$num = '1230456789';
$num =~ s/(?=(\d{3})+$)(?<=\d)/,/g;
print "$num";
运行结果:
1,230,456,789
方案三:s/(?=(\d{3})+\b)(?<=\d)/,/g
$str = 'The population of 29844215 is growing';
$str =~ s/(?=(\d{3})+\b)(?<=\d)/,/g;
print "$str";
运行结果:
The population of 29,844,215 is growing
这个正则跟方案一中区别在于使用场景的差别。跟规则二中结尾处的元字符不同。方案一中是/$/
, 方案三中是/\b/
。如果继续使用/$/
,最后的结果会是什么呢?好奇的宝宝们可以自己试一下,肯定是匹配不成功的呀!why?
因为/$/
是末尾匹配符,很明显$str
中 "29844215"
后面还有其他非数字的字符,所以匹配不成功。但如果是/\b/
, 则表示以已单词边界为界,自然在"29844215"
之后不管出现什么都是白搭,只要不跟"29844215"
组成一个单词就行。
方案四:s/(?<=\d)(?=(\d\d\d)+(?!\d))/,/g
可能有些人觉得上面的正则已经算是完美了,但我想说还没有。比如要处理下面这段文案中的数字+字符的组合体,方案三就不再适用了。
This is 123456789Hz!
当然在方案三的基础上,我们只需要做一下简单的修改,就能满足我们需求。最后修改后的结果以及运行的示例如下:
$str = 'This is 123456789Hz!';
$str =~ s/(?<=\d)(?=(\d\d\d)+(?!\d))/,/g;
print "$str";
运行结果:
This is 123,456,789Hz!
很简单,是不是,在方案三的基础上,将规则二中末尾的\b
替换成/(?!\d))/
,这个否定顺序环视表示要匹配的位置结尾一定不是数字。是不是比方案三更完善,更通用呢!
从方案一到方案四, 我们都是在大量使用环视,那有个疑问,除了环视,我们还有没有其他的方式来实现呢!答案是肯定的,那么我们就看下面几种方案。
方案五:s/(\d)(?=(\d\d\d)+(?!\d))/$1,/g
消除规则中的肯定逆序环视,示例以及运行结果如下:
$str = 'This is 123456789Hz!';
$str =~ s/(\d)(?=(\d\d\d)+(?!\d))/$1,/g;
print "$str";
运行结果:
This is 123,456,789Hz!
在此规则中,我们删除了用于消除规则二造成的首行会出现的[,]的问题,取而代之的是(\d)
, 我们使用了分组匹配, 并将匹配到的结果保存到$1
中, 然后在使用/$1,/
完成需求。个人认为从效率来讲这方案肯定是不如前面的几种方案,因为使用了分组匹配,增加了替换的成本。当然这只是我当前的猜测,因为我还没有学习到正则表达式效率的比较!
方案六:s/(\d)((?:\d\d\d)+\b)/2/g
按照方案五的思路,假如我们完全不用环视,只通过分组匹配,能得到我们想要的结果吗?不妨我们先来试一下。
$str = '123456789';
$str =~ s/(\d)((?:\d\d\d)+\b)/$1,$2/g;
print "$str";
运行结果
123,456789
结果并没有达到我们的预期,这是为什么呢?原因是:/(?:\d\d\d)+/
匹配的数字属于最终匹配文本,所以不能作为“未匹配的”部分,供/g的下一次匹配迭代使用。
一次迭代完成时,下一次迭代会从上一次匹配的终点开始尝试。我们希望的是,在插入逗号以后,还能够继续检查这个值,以决定是否需要再插入逗号。但是在这个例子中,重新开始的起点是整个数值的末尾。使用顺序环视的意义在于,检查某个位置,但检查时匹配的字符并不算在(最终)“匹配的字符串”内。
方案七:
方案六的经验告诉我们,单纯的使用分组匹配是不能实现我们的功能的。那么能否结合编程语言呢,尽管这不是我们所推荐的,但这也是一种方法。实际上,方案六中的表达式仍然可以用来解决这个问题,但正则表达式必须由宿主语言反复调用,例如通过一个while
循环,每次检查的都是上次修改后的字符串。每次替换操作都会添加一个[,](对目标字符串中的每个数值都是如此,因为/g的存在),具体如下:
$str = '123456789';
while($str =~ s/(\d)((?:\d\d\d)+\b)/$1,$2/g){
# 循环内不用进行任何操作————我们希望的是重复这个循环,直到匹配失败
}
print "$str";
运行结果:
123,456,789
4四、结语
纯属借花献佛,还请多读书!