正則表達式不要背

正則表達式一直是困擾很多程序員的一門技術,當然也包括曾經的我。大多數時候我們在開發過程中要用到某些正則表達式的時候,都會打開谷歌或百度直接搜索然後拷貝粘貼。當下一次再遇到相同問題的時候,同樣的場景又再來一遍。作爲一門用途很廣的技術,我相信深入理解正則表達式並能融會貫通是值得的。所以,希望這篇文章能幫助大家理清思路,搞懂正則表達式各種符號之間的內在聯繫,形成知識體系,當下次再遇到正則表達式的時候可以不借助搜索引擎,自己解決。

正則表達式到底是什麼

正則表達式(Regular Expression)其實就是一門工具,目的是爲了字符串模式匹配,從而實現搜索和替換功能。它起源於上個20世紀50年代科學家在數學領域做的一些研究工作,後來才被引入到計算機領域中。從它的命名我們可以知道,它是一種用來描述規則的表達式。而它的底層原理也十分簡單,就是使用狀態機的思想進行模式匹配。大家可以利用https://regexper.com這個工具很好地可視化自己寫的正則表達式:

/\d\w+/這個正則生成的狀態機圖:

對於具體的算法實現,大家如果感興趣可以閱讀《算法導論》。

從字符出發

我們學習一個系統化的知識,一定要從其基礎構成來了解。正則表達式的基本組成元素可以分爲:字符和元字符。字符很好理解,就是基礎的計算機字符編碼,通常正則表達式裏面使用的就是數字、英文字母。而元字符,也被稱爲特殊字符,是一些用來表示特殊語義的字符。如^表示非,|表示或等。利用這些元字符,才能構造出強大的表達式模式(pattern)。接下來,我們就來從這些基本單位出發,來學習一下如何構建正則表達式。

單個字符

最簡單的正則表達式可以由簡單的數字和字母組成,沒有特殊的語義,純粹就是一一對應的關係。如想在'apple'這個單詞裏找到‘a'這個字符,就直接用/a/這個正則就可以了。

但是如果想要匹配特殊字符的話,就得請出我們第一個元字符\, 它是轉義字符字符,顧名思義,就是讓其後續的字符失去其本來的含義。舉個例子:

我想匹配*這個符號,由於*這個符號本身是個特殊字符,所以我要利用轉義元字符\來讓它失去其本來的含義:

/\*/

如果本來這個字符不是特殊字符,使用轉義符號就會讓它擁有特殊的含義。我們常常需要匹配一些特殊字符,比如空格,製表符,回車,換行等, 而這些就需要我們使用轉義字符來匹配。爲了便於記憶,我整理了下面這個表格,並附上記憶方式:

特殊字符 正則表達式 記憶方式
換行符 \n new line
換頁符 \f form feed
回車符 \r return
空白符 \s space
製表符 \t tab
垂直製表符 \v vertical tab
回退符 [\b] backspace,之所以使用[]符號是避免和\b重複

多個字符

單個字符的映射關係是一對一的,即正則表達式的被用來篩選匹配的字符只有一個。而這顯然是不夠的,只要引入集合區間和通配符的方式就可以實現一對多的匹配了。

在正則表達式裏,集合的定義方式是使用中括號[]。如/[123]/這個正則就能同時匹配1,2,3三個字符。那如果我想匹配所有的數字怎麼辦呢?從0寫到9顯然太過低效,所以元字符-就可以用來表示區間範圍,利用/[0-9]/就能匹配所有的數字, /[a-z]/則可以匹配所有的英文小寫字母。

即便有了集合和區間的定義方式,如果要同時匹配多個字符也還是要一一列舉,這是低效的。所以在正則表達式裏衍生了一批用來同時匹配多個字符的簡便正則表達式:

匹配區間 正則表達式 記憶方式
除了換行符之外的任何字符 . 句號,除了句子結束符
單個數字, [0-9] \d digit
除了[0-9] \D not digit
包括下劃線在內的單個字符,[A-Za-z0-9_] \w word
非單字字符 \W not word
匹配空白字符,包括空格、製表符、換頁符和換行符 \s space
匹配非空白字符 \S not space

循環與重複

一對一和一對多的字符匹配都講完了。接下來,就該介紹如何同時匹配多個字符。要實現多個字符的匹配我們只要多次循環,重複使用我們的之前的正則規則就可以了。那麼根據循環次數的多與少,我們可以分爲0次,1次,多次,特定次。

0 | 1

元字符?代表了匹配一個字符或0個字符。設想一下,如u果你要匹配colorcolour這兩個單詞,就需要同時保證u這個字符是否出現都能被匹配到。所以你的正則表達式應該是這樣的:/colou?r/

>= 0

元字符*用來表示匹配0個字符或無數個字符。通常用來過濾某些可有可無的字符串。

>= 1

元字符+適用於要匹配同個字符出現1次或多次的情況。

特定次數

在某些情況下,我們需要匹配特定的重複次數,元字符{}用來給重複匹配設置精確的區間範圍。如'a'我想匹配3次,那麼我就使用/a{3}/這個正則,或者說'a'我想匹配至少兩次就是用/a{2,}/這個正則。

以下是完整的語法:

- {x}: x次

- {min, max}: 介於min次到max次之間

- {min, }: 至少min次

- {0, max}: 至多max次

由於這些元字符比較抽象,且容易混淆,所以我用了聯想記憶的方式編了口訣能保證在用到的時候就能回憶起來。

匹配規則 元字符 聯想方式
0次或1次 ? ,此事
0次或無數次 * 宇宙洪荒,辰宿列張:宇宙伊始,從無到有,最後星宿佈滿星空
1次或無數次 + 一加, +1
特定次數 {x}, {min, max} 可以想象成一個數軸,從一個點,到一個射線再到線段。min和max分別表示了左閉右閉區間的左界和右界

位置邊界

上面我們把字符的匹配都介紹完了,接着我們還需要位置邊界的匹配。在長文本字符串查找過程中,我們常常需要限制查詢的位置。比如我只想在單詞的開頭結尾查找。

單詞邊界

單詞是構成句子和文章的基本單位,一個常見的使用場景是把文章或句子中的特定單詞找出來。如:

The cat scattered his food all over the room.

我想找到cat這個單詞,但是如果只是使用/cat/這個正則,就會同時匹配到catscattered這兩處文本。這時候我們就需要使用邊界正則表達式\b,其中b是boundary的首字母。在正則引擎裏它其實匹配的是能構成單詞的字符(\w)和不能構成單詞的字符(\W)中間的那個位置。

上面的例子改寫成/\bcat\b/這樣就能匹配到cat這個單詞了。

字符串邊界

匹配完單詞,我們再來看一下一整個字符串的邊界怎麼匹配。元字符^用來匹配字符串的開頭。而元字符$用來匹配字符串的末尾。注意的是在長文本里,如果要排除換行符的干擾,我們要使用多行模式。試着匹配I am scq000這個句子:

I am scq000.
I am scq000.
I am scq000.

我們可以使用/^I am scq000\.$/m這樣的正則表達式,其實m是multiple line的首字母。正則裏面的模式除了m外比較常用的還有i和g。前者的意思是忽略大小寫,後者的意思是找到所有符合的匹配。

最後,總結一下:

邊界和標誌 正則表達式 記憶方式
單詞邊界 \b boundary
非單詞邊界 \B not boundary
字符串開頭 ^ 頭尖尖那麼大個
字符串結尾 $ 終結者,美國科幻電影,美元符$
多行模式 m標誌 multiple of lines
忽略大小寫 i標誌 ignore case, case-insensitive
全局模式 g標誌 global

子表達式

字符匹配我們介紹的差不多了,更加高級的用法就得用到子表達式了。通過嵌套遞歸和自身引用可以讓正則發揮更強大的功能。

從簡單到複雜的正則表達式演變通常要採用分組、回溯引用和邏輯處理的思想。利用這三種規則,可以推演出無限複雜的正則表達式。

分組

其中分組體現在:所有以()元字符所包含的正則表達式被分爲一組,每一個分組都是一個子表達式,它也是構成高級正則表達式的基礎。如果只是使用簡單的(regex)匹配語法本質上和不分組是一樣的,如果要發揮它強大的作用,往往要結合回溯引用的方式。

回溯引用

所謂回溯引用(backreference)指的是模式的後面部分引用前面已經匹配到的子字符串。你可以把它想象成是變量,回溯引用的語法像\1,\2,....,其中\1表示引用的第一個子表達式,\2表示引用的第二個子表達式,以此類推。而\0則表示整個表達式。

假設現在要在下面這個文本里匹配兩個連續相同的單詞,你要怎麼做呢?

Hello what what is the first thing, and I am am scq000.

利用回溯引用,我們可以很容易地寫出\b(\w+)\s\1這樣的正則。

回溯引用在替換字符串中十分常用,語法上有些許區別,用$1,$2...來引用要被替換的字符串。下面以js代碼作演示:

var str = 'abc abc 123';
str.replace(/(ab)c/g,'$1g');
// 得到結果 'abg abg 123'

如果我們不想子表達式被引用,可以使用非捕獲正則(?:regex)這樣就可以避免浪費內存。

var str = 'scq000'.
str.replace(/(scq00)(?:0)/, '$1,$2')
// 返回scq00,$2
// 由於使用了非捕獲正則,所以第二個引用沒有值,這裏直接替換爲$2

有時,我們需要限制回溯引用的適用範圍。那麼通過前向查找和後向查找就可以達到這個目的。

前向查找

前向查找(lookahead)是用來限制後綴的。凡是以(?=regex)包含的子表達式在匹配過程中都會用來限制前面的表達式的匹配。例如happy happily這兩個單詞,我想獲得以happ開頭的副詞,那麼就可以使用happ(?=ily)來匹配。如果我想過濾所有以happ開頭的副詞,那麼也可以採用負前向查找的正則happ(?!ily),就會匹配到happy單詞的happ前綴。

後向查找

介紹完前向查找,接着我們再來介紹一下它的反向操作:後向查找(lookbehind)。後向查找(lookbehind)是通過指定一個子表達式,然後從符合這個子表達式的位置出發開始查找符合規則的字串。舉個簡單的例子: applepeople都包含ple這個後綴,那麼如果我只想找到appleple,該怎麼做呢?我們可以通過限制app這個前綴,就能唯一確定ple這個單詞了。

/(?<=app)ple/

其中(?<=regex)的語法就是我們這裏要介紹的後向查找。regex指代的子表達式會作爲限制項進行匹配,匹配到這個子表達式後,就會繼續向查找。另外一種限制匹配是利用(?<!regex) 語法,這裏稱爲負後向查找。與正前向查找不同的是,被指定的子表達式不能被匹配到。於是,在上面的例子中,如果想要查找appleple也可以這麼寫成/(?<!peo)ple

需要注意的,不是每種正則實現都支持後向查找。在javascript中是不支持的,所以如果有用到後向查找的情況,有一個思路是將字符串進行翻轉,然後再使用前向查找,作完處理後再翻轉回來。看一個簡單的例子:

// 比如我想替換apple的ple爲ply
var str = 'apple people';
str.split('').reverse().join('').replace(/elp(?=pa)/, 'ylp').split('').reverse().join('');

最後回顧一下這部分內容:

回溯查找 正則 記憶方式
引用 \0,\1,\2 和 0,1, $2 轉義+數字
非捕獲組 (?:) 引用表達式(()), 本身不被消費(?),引用(:)
前向查找 (?=) 引用子表達式(()),本身不被消費(?), 正向的查找(=)
前向負查找 (?!) 引用子表達式(()),本身不被消費(?), 負向的查找(!)
後向查找 (?<=) 引用子表達式(()),本身不被消費(?), 後向的(<,開口往後),正的查找(=)
後向負查找 (?<!) 引用子表達式(()),本身不被消費(?), 後向的(<,開口往後),負的查找(!)

邏輯處理

計算機科學就是一門包含邏輯的科學。讓我們回憶一下編程語言當中用到的三種邏輯關係,與或非。

在正則裏面,默認的正則規則都是的關係所以這裏不討論。

關係,分爲兩種情況:一種是字符匹配,另一種是子表達式匹配。在字符匹配的時候,需要使用^這個元字符。在這裏要着重記憶一下:只有在[]內部使用的^才表示非的關係。子表達式匹配的非關係就要用到前面介紹的前向負查找子表達式(?!regex)或後向負查找子表達式(?<!regex)

或關係,通常給子表達式進行歸類使用。比如,我同時匹配a,b兩種情況就可以使用(a|b)這樣的子表達式。

邏輯關係 正則元字符
[^regex]和!
|

總結

對於正則來說,符號之抽象往往讓很多程序員卻步。針對不好記憶的特點,我通過分類和聯想的方式努力讓其變得有意義。我們先從一對一的單字符,再到多對多的子字符串介紹,然後通過分組、回溯引用和邏輯處理的方式來構建高級的正則表達式。

在最後,出個常用的正則面試題吧:請寫出一個正則來處理數字千分位,如12345替換爲12,345。請嘗試自己推理演繹得出答案,而不是依靠搜索引擎:)。

——本文首發於個人公衆號,轉載請註明出處———


最後,歡迎大家關注我的公衆號,一起學習交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章