原創:打碼日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。
簡介
現如今,正則表達式幾乎是程序員的必備技能了,它入手確實很容易,但如果你不仔細琢磨學習,會長期停留在正則最基本的用法層面上。
因此,本篇文章,我會介紹一些能用正則解決的場景,但這些場景如果全自己琢磨實現的話,需要花一些時間才能完成,或者就完全想不出來,另外也會介紹一些正則表達式的性能問題。
匹配多個單詞
比如我想匹配zhangsan、lisi、wangwu這三個人名,這是一個很常見的場景,其實在正則裏面也算基本功,但鑑於本人初入門時還是在網上搜索得到的答案,還是值得提一下的!
實現如下:
zhangsan|lisi|wangwu
其中|表示或的含義,就是匹配zhangsan或lisi或wangwu了。
匹配重複數字
匹配如1111、2222、3333這樣的4位長度的重複數字,突一想,這不用\d{4}就解決了嚒,其實不然,因為\d{4}可以匹配1111,但也可以匹配1234啊。
寫法如下:
(\d)\1{3}
\d匹配第一個數字,後面的\1匹配前面\d匹配的內容,重複3次,這樣就可以匹配1111或2222這樣的4位數字串了。
匹配各種空白
在使用正則時,常用\s來匹配空白,但遺憾的是,還是有一些Unicode的空白字符,\s無法匹配,這時可以嘗試POSIX字符類\p{Space},我在Java中驗證通過,可以匹配ascii空白字符與Unicode空白字符,如果是其它語言的話,可能正則語法會稍有區別。
位置匹配
正則表達式中\G與環視是比較難理解的,因為這兩個東西很多書上只是介紹了匹配的規則,沒有説出實質,導致死記的規則過一段時間就忘,也不明白這兩東西有啥用。
我們轉換一下思維,其實在正則表達式中,匹配目標只有兩個,一是匹配字符串中的字符,二是匹配字符串中的位置,如下圖:
上邊的hello,有5個字符可以匹配,另外還有6個位置可以匹配,而^hello中^就是代表匹配開頭的位置,所以如果是_hello就無法被^hello匹配,因為_與h之間的位置並不是開頭,不能與^匹配!
常見位置匹配規則
| 規則 | 匹配的位置 |
|---|---|
| ^ \A | 匹配開始位置 |
| $ \z \Z | 匹配結束位置 |
| \b \B | 匹配單詞與非單詞邊界位置 |
| \G | 匹配當前匹配的開始位置 |
| (?=a) (?!a) | 正向環視,看看當前位置後面是否是a,或不是a |
| (?<=a) (?<!a) | 逆向環視,看看當前位置前面是否是a,或不是a |
^與\A
^ 匹配文本開始位置,但在多行匹配模式下,^匹配每一行的開始位置。
\A 僅僅只能匹配開始位置,不管什麼匹配模式下
$與\Z
$ 匹配文本末尾位置,但在多行匹配模式下,$匹配每一行的末尾位置。
\Z 僅僅只能匹配末尾位置,不管什麼匹配模式下
\b與\B
\b匹配單詞邊界,在Java中,單詞邊界即是字母與非字母之間的位置,中文不認為是單詞,另外文本開頭與文本結尾也是單詞邊界
\B匹配非單詞邊界
\G
匹配上次匹配的結束位置或當前匹配的開始位置,第一次匹配時,匹配文本開始位置,如下:
從1234a5678中找單個數字,如果用\d去找,可以找到8個,但使用\G\d去找,卻只能找到4個
查找過程:
第1次查找,\G匹配文本開始位置,1與\d匹配,找到第1個匹配,即1
第2次查找,\G匹配1後面2前面之間的位置,2與\d匹配,找到第2個匹配,即2
第3次查找,\G匹配2後面3前面之間的位置,3與\d匹配,找到第3個匹配,即3
第4次查找,\G匹配3後面4前面之間的位置,4與\d匹配,找到第4個匹配,即4
第5次查詢,\G匹配4後面5前面之間的位置,但a與\d不匹配,匹配結束,總共找到4個匹配。
環視
(?=a) 與 (?!a)
正向肯定(否定)環視,用來檢測當前位置後面字符是否是a,或不是a
(?<=a) 與 (?<!a)
逆向肯定(否定)環視,用來檢查當前位置前面字符是否是a,或不是a
如下,查找被()包裹的單詞,使用環視限定單詞左邊是(,右邊是)
位置可被多次匹配
文本中的一個位置,可以同時匹配多個規則,且與規則在正則表達式中的先後順序無關,例如下面3個正則表達式是等價的:
^abc
^^^^^^abc
^(?=a)\b^^^abc
下面舉兩個實際例子體會一下位置匹配!
例1:密碼強度校驗
前端校驗密碼強度時,經常有這樣的要求,長度8到10位,且必須包含數字、字母、標點符號,可通過一個正則表達式校驗出來,如下:
^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\p{P}).{8,10}$
其中,(?=.*[0-9])表示開頭位置的後面一定要有數字,(?=.*[a-zA-Z])表示開頭位置後面一定要有字母,(?=.*\p{P})表示開頭位置的後面一定要有標點符號,.{8,10}表示匹配8到10位字符,這幾個正則合在一起,就實現了校驗密碼強度的要求。
例2:千分位數字
有時我們需要將123456789變成123,456,789這樣的千分位數字,這個使用正則就可以實現,如下,將此正則匹配到的位置,替換為,:
(?!^)(?=(\d{3})+$)
其中,(?=(\d{3})+$)表示匹配位置,這個位置後面必須要有一組或多組3個數字,滿足這樣條件的位置有3個,開頭與1之間的位置,3和4之間的位置,6和7之間的位置,然後(?!^)又限制了同樣的這些位置,不能是開頭,就只能3和4,6和7之間的位置滿足要求了,所以替換之後,就變成了123,456,789。
匹配帶引號字符串
匹配諸如"hello,world"這樣的帶引號的字符串,很容易想到,用"[^"]+"即可,但是如果引號字符串裏面允許用\來轉義"呢,如"hello \"bob\"!",如果用"[^"]+"來匹配的話,就只會匹配到"hello \"了,顯然不對,可以先自行想想如何用正則實現。
...
...
...
想不出來?我們可以換一個視角,包含帶\開頭轉義字符的字符串,其實可以拆解為",hello ,\"bob,\"!,",然後再泛化為正則形式,",[^\\"]*,\\.[^\\"]*,\\.[^\\"]*,",組合在一起如下:
"[^\\"]*(?:\\.[^\\"]*)*"
表達式中多了個(?:),這表示非捕獲分組,可以用來提高正則匹配性能,而由於字符串中有可能沒有\開頭的轉義字符,故(?:\\.[^\\"]*)後面是*,直接由[^\\"]*匹配完引號內所有內容。
別搞炸了CPU
正則表達式如果寫得很複雜,就需要謹慎評估了,因為有可能平時運行得好好的,但遇到一些特殊情況,會導致CPU直接100%,比如還是上面那個匹配帶引號字符串的場景,有同學可能會給出這樣的正則:
"([^\\"]+|\\.)*"
乍一看,這個正則很完美,[^\\"]+匹配非轉義字符的部分,\\.匹配\",\n之類的。這個正則在遇到滿足條件的字符串時完全沒有問題(如"hello \"bob\"!"),而遇到不滿足條件的字符串時,正則匹配複雜度會隨着字符串長度呈指數式上升,導致CPU 100%,如"hello \"bob\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!,其中"沒有閉合。
public static void main(String[] args) {
long begin = System.currentTimeMillis();
boolean isMatch = "\"hello \\\"bob\\\"!!!!!!!!!!!!!!!!!!".matches("\"([^\\\\\"]+|\\\\.)*\"");
System.out.println(String.format("%s ms, isMatch: %s", System.currentTimeMillis() - begin, isMatch));
}
這段java代碼,在我機器上跑完要2s的樣子,但如果字符串中再加4個!,運行時間立馬上升到17s,性能下降非常恐怖!
原因
如果知道一些正則匹配原理,應該知道正則在匹配時,如果匹配不上,會將已經匹配的字符吐出來,再看看是否能夠匹配,這叫回溯,比如".*"匹配"hello",先正則中的"匹配上了字符串中的",然後.*依次匹配了h,e,l,l,o,",最後正則中的"匹配字符串結尾位置,匹配不上,這時正則引擎會讓前面的.*吐出它匹配的",然後吐出來的這個",剛好可以和正則中的"匹配,這樣就匹配成功了。
那如果是"hello這樣沒有閉合的字符串,.*會一直吐字符,一直到它沒有字符可吐,發現還是匹配不上,這樣整個匹配才認定為匹配失敗。
是的,正則中包含匹配量詞?,*,+時,你就可以想像為它們一直在吃字符,當後面的規則匹配不上時,會強迫它又吐出來,而如果是懶惰匹配量詞??,*?,+?,你就可以想像它先不吃,當後面的規則匹配不上時,會強迫它去吃。
我們再來分析下"([^\\"]+|\\.)*"匹配"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!為啥會如此低效!
注:為了分析方便,我簡化了待匹配字符串,但效果是一樣的
- 首先
[^\\"]+吃掉了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!。 - 然後發現正則中
"與字符串結尾位置不匹配,開始回溯。 - 然後
[^\\"]+吐出一個!,注意這裏,由於外層還有一個*貪婪量詞,吐出來的!又被[^\\"]+|\\.中的[^\\"]+吃掉了,它吃掉後,到了字符串結尾,發現結尾又與正則中的"不匹配,又要求[^\\"]+|\\.中的[^\\"]+吐出剛吃掉的!,結果吐出後又不匹配。 - 然後又逼着最前面的那個
[^\\"]+吐出倒數第二個!,注意,再次吐出!後,當前匹配位置後面有兩個!,可惡的是,這兩個!又被後面[^\\"]+|\\.中的[^\\"]+吃掉了,然後悲劇重演,它又要吐出來,如此循環往復,計算量指數級上升。
解決辦法
其實可以看出來,造成這個問題是因為正則表達式中有兩個量詞,內層有一個+,外層有一個*,不信的話,你可以嘗試用^(a+)*$去匹配aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0,同樣的會非常慢。
而要解決這個問題,有兩個辦法。
- 讓
[^\\"]+吐出來的字符,無法被外層正則中另一個貪婪的自己吃掉,比如前面介紹的"[^\\"]*(?:\\.[^\\"]*)*",[^\\"]*吐出來的字符,是無法被\\.[^\\"]*吃掉的,因為吐出來的一定不是\,而\\.[^\\"]*要先吃一個\。 - 明知道自己吐出來的字符後,後面的規則也無法匹配,那就讓量詞吃掉字符後不吐,比如將正則修改為
"([^\\"]++|\\.)*"這樣,+變成了++,像這種量詞後面再加+號的,比如?+,*+,++,這表示佔有量詞,吃完字符後就不會吐了。
注:佔有量詞不要亂用,有時吐出來字符可以讓整個正則匹配,而你強制讓它不吐出來,反而讓它匹配不了了,如^.+b$可以匹配ab,但如果你用^.++b$就無法匹配ab了,因為.吃掉了ab,吐出一個b剛好可以使後面的b匹配。而^[^b]++b$這種用法就是對的,因為^b吐出來的字符肯定不能和後面的b匹配,就沒必要再吐了。
總結
正則表達式很強大,用好它事半功倍,但也需要了解它的執行過程,避免指數級回溯陷阱。
往期內容
好用的parallel命令
還在胡亂設置連接空閒時間?
常用網絡命令總結
使用socat批量操作多台機器