一文说透如何用正则完成“千分位分隔符”

  最近两天一直在看《精通正则表达式》这本书,收获颇多。当看到书中关于使用正则表达式完成千分位分隔符时,我知道自己该写一篇博客了,平复一下我躁动的心情。因为这套经典的面试题原来一直都在书中有满满的答案......

一、一些必要的知识

  本文中所有的代码示例都会以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四、结语

   纯属借花献佛,还请多读书!

你可能感兴趣的:(一文说透如何用正则完成“千分位分隔符”)