【正則表達式】從入門到精通(大誤)

前言

正則表達式(英語:Regular Expression,在代碼中常簡寫爲regex、regexp或RE),能夠用來檢索、替換那些匹配某個模式的文本。無論在開發仍是平常生活中均可以發揮重要的做用,這篇文章主要是從正則表達式的匹配原理上來介紹並學習如何寫出優雅的正則。php

1. 基礎

基礎知識能夠查閱 MDN RegExp,這裏只對一些元字符之外的部分進行一個說明,附件在最後。html

1.1 分組、環視、忽略優先量詞

  • 分組java

    • 捕獲分組 (x):匹配 x,並在正則中可經過 \1 訪問,在 js 中可經過 RegExp.$1 訪問
    • 非捕獲分組 (?:x) :匹配 x,不進行捕獲,只作一個分組效果,沒法經過上述方法訪問
    • 命名捕獲 /(?<name>x)/(ES9實現): 匹配 x 字符並將其放入匹配結果中的 groups 對象中,對象名爲 name,值爲 x;

    命名捕獲

  • 環視(逆序環視在 ES9 實現mysql

    • 正序確定環視 /x(?=y)/:匹配一個後面是 y 的 x
    • 正序否認環視 /x(?!y)/:匹配一個後面不是 y 的 x
    • 逆序確定環視 /(?<=x)y/:匹配一個前面是 x 的 y
    • 逆序否認環視 /(?<!x)y/:匹配一個前面不是 x 的 y
  • 忽略優先量詞(??、*?、+?、{n,}?、{n, m}?)正則表達式

    普通的 *、?、+、等都是匹配優先,也就是在匹配過程當中會盡可能匹配更多的元素,而後經過回溯來找到匹配結果。而忽略優先量詞則恰好相反,會盡量少的匹配字符,而後逐步掃描得到匹配結果。sql

    /\w+\d+/.exec('abc123def456') // abc123def456工具

    /\w+?\d+/.exec('abc123def456') // abc123學習

  • 標識符 /u:支持 unicode 匹配測試

    /^.\$/.test('a') // true
    /^.\$/.test('🐶') // false
    /^.\$/u.test('🐶') // true
    複製代碼
  • 標識符 /s:容許 . 匹配上包含(換行符等)在內的全部字符優化

    /hi.welcome/.test('hi\nwelcome') // false
    /hi.welcome/s.test('hi\nwelcome') // true
    複製代碼

1.2 js 暫未實現的部分❌:

用 js 描述,不等於可使用 js 執行,能夠用 php 測試:PHP 在線代碼運行,要注意改爲 PHP 語法。

  • 條件判斷 (?if then|else):

    /g(o)?(?(1)o|a)d/.test('good') // true

    表示先匹配 g,接下來若是匹配到了 o,則接着也匹配 o,不然匹配 a,最後匹配 d,?(1) 表明第一個括號匹配成功

    /(?(?=x)xy|ab)/.test('xy') // true

    /(?(?=x)xy|ab)/.test('ab') // true

    else 部分能夠不寫

  • 固化分組 (?>x):固話括號內的內容不會改變,除非整個括號被棄用在外部從新回溯。

    /(?>\w+)-/ 能夠匹配 'hello-regexp' 中的 'hello-',而且在 - 匹配不上時就會返回匹配失敗,不會再進行回溯
    
    /(?>\w+)-/.test('hello-regexp') // true
    
    // 因爲一步匹配到了 p,且不會交還字符,致使匹配失敗
    /(?>\w+)0/.test('hello0regexp') // false
    複製代碼

    此處在後續還有補充,這裏只是作個示例。

  • 註釋和模式修飾詞

    • 模式修飾詞:(?i)x(?-i),中間部分 x 字符不區分大小寫
    • 模式做用範圍:(?i:x) 括號內的 x 字符不區分大小寫(?i)x(?-i),中間部分 x 字符不區分大小寫
    • 註釋:(?#...) 和 # 註釋做用,若是支持寬鬆排列能夠直接用 #
    • 文字文本範圍:\Q…\E,中間全部字符會當成普通文本
  • 佔有優先量詞:?+、*+、++、{n,}+、{n, m}+

    特色:匹配完就不會再交還字符,不會保存以前的回溯位置,某種程度相似固化分組

    /\w++-/.test('hello-regexp') // true

    /\w++0/.test('hello0regexp') // false

  • \G:本次匹配的開始位置(上次匹配的結束位置),用 php 示例理解一下:

    preg_match_all("/(\G\d),/", "1,2,a,3", $matches);
    
    foreach ($matches[1] as $match) {
        echo $match . '-';
    }
    // 1-2-
    複製代碼

2. 引擎

正則表達式不一樣語言會有不一樣的引擎來解釋正則語法,這是咱們後面針對匹配原理來優化正則表達式的基礎,例如固化分組、佔有優先量詞這種。

2.1 類別

  • DFA

    awk(大多數版本)、egrep(大多數版本)、mysql等。 特色是文本主導的匹配,會出現最長結果,多選結構與順序無關,穩定速度快。

  • NFA

    Javascript、Java、Python、PHP等。 特色是表達式主導的匹配,多選結構從左往右,須要回溯但能力很強。

  • POSIX NFA

    mawk等,POSIX標準規定的某個正則表達式的應有行爲。

  • NFA/DFA混合

    GNU awk、GNU grep/egrep等。

2.2 NFA 與 DFA

  • 區分:看是否支持忽略優先量詞

  • 比較

    • 預編譯階段:一般 NFA 更快,內存也更少;而另外兩種沒有很大區別
    • 匹配速度: DFA 不需作太多優化;NFA 與正則表達式有關
    • 匹配結果: DFA 與 POSIX NFA 是最左最長;NFA 依據表達式匹配的實際文本結果
    • 匹配能力:NFA 多出一些功能——1.捕獲括號;2.環視;3.忽略優先量詞;4.佔有優先量詞和固化分組

3. 匹配原理

3.1 回溯

正則表達式在遇到量詞或多選結果時會記錄一個狀態,在後續的匹配過程當中失敗時會回到上一個記錄的狀態,而後選擇另外一個方向進行下一次嘗試,直到匹配成功或者狀態用完匹配失敗,.* 的回溯是很可怕的。

這個重複回到上個狀態的過程就是回溯,記錄下的狀態就叫作備用狀態

3.1.1 分支選擇

在面對分支時正則表達式是如何肯定選擇哪一條的呢?是進行嘗試仍是跳過嘗試呢?這裏主要會依據匹配量詞來區分,簡單來講面對匹配優先量詞的量詞會進行嘗試,而面對忽略優先量詞則會跳過嘗試

3.1.2 備用狀態

以 /\d+/ 匹配 'a 1234 num' 來圖解過程的備用狀態:

備用狀態

問題:在左邊的匹配中,是不含以上狀態的,但 /\d*/ 匹配會包括下圖這個狀態嗎?

備用狀態2

答案:不會,* 號表明 0 次或任意次,當從 a 字符開始匹配時,\d 並不能匹配,因此 * 就至關於匹配 0 次,就算匹配成功,不會再日後走了。

3.1.3 舉個例子

/".*"/.test('The name "McDonald's" is said "makudonarudo" in Japanese') 的過程:

POSIX NFA

  • POSIX NFA 匹配過程 POSIX NFA 會屢次嘗試來肯定最長的匹配結果(雖然此處已經知道是到 D,但仍是會嘗試圖中各個可能)。

  • NFA 匹配過程 先匹配 " 號,而後 .* 貪婪匹配到字符串結尾 B,因爲匹配不到結尾的 " 號,因此 .* 會交還字符串到 C 位置再匹配到 do 後的 " D,匹配成功。

3.2 應用原理

咱們必須先掌握正則表達式應用的基本知識,而後才能從根本上寫出優雅的表達式。 正則表達式應用到目標字符串的過程大體分爲下面幾步:

3.2.1 編譯

檢查正則表達式的語法正確性,若是正確,就將其編譯爲內部形式,這部分主要由正則表達式引擎自動完成。

3.2.2 傳動開始

傳動裝置將正則引擎「定位」到目標字符串的起始位置。

3.2.3 元素檢測

引擎開始測試正則表達式和文本,依次測試正則表達式的各個元素。

  • 相連元素

    例如 hello 中的 h、e、l、l、o 等等,會依次嘗試,只有當某個元素匹配失敗時纔會中止。

  • 量詞修飾元素

    控制權在量詞(檢查量詞是否應該繼續匹配)和被限定的元素(測試可否匹配)之間輪換。

  • 控制權在捕獲型括號內外進行切換會帶來一些開銷

    括號內的表達式匹配的文本必須保留,這樣才能經過 $1 來引用。由於一對括號可能屬於某個回溯分支,括號的狀態就是用於回溯的狀態的一部分,因此進入和退出捕獲型括號時須要修改狀態。

3.2.4 尋找匹配結果

若是找到一個匹配結果,傳統型 NFA 會「鎖定」在當前狀態,報告匹配成功。而對 POSIX NFA 來講,若是這個匹配是迄今爲止最長的,它會記住這個可能的匹配,而後從可用的保存狀態繼續下去。保存的狀態都測試完畢以後返回最長的匹配。

3.2.5 傳動裝置的驅動過程

若是沒有找到匹配,傳動裝置就會驅動引擎,從文本中的下一個字符開始新一輪的嘗試(回到 3.2.3)。

3.2.6 匹配完全失敗

若是從目標字符串的每個字符(包括最後一個字符以後的位置)開始的嘗試都失敗了,就會報告匹配完全失敗。

4. 高效編寫

在經過以上的瞭解,咱們對正則表達式的引擎以及匹配原理都有了必定的瞭解,有了這些,咱們就能從根本上寫出複雜且高效的正則表達式了。這部分主要是從原理上介紹如何來優化咱們的正則。

4.1 核心思想

編寫或優化點有不少方面,總的來講能夠總結爲如下 3 個方向:

  • 加速某些操做(如加速匹配成功或失敗的報告)

  • 避免冗餘操做(如只匹配指望的文本,排除不指望的文本)

  • 易於控制和理解(\w 匹配數字修改成 \d)

4.2 常見優化措施

4.2.1 編譯前的優化

通常都是由正則引擎完成,因此對這部分咱們能作的有限,但仍是能從兩個部分進行優化:

  • 長度判斷:/1\d{10}/ 匹配 11 位手機號碼

  • 預查必須字符/子字符串優化: /password:\s\w+/ 來匹配 'username: hello password: hello123' 這裏經過必須字符 password 來進行預查,提高效率

4.2.2 經過傳動裝置進行優化

  • 字符串起始(結束)/行錨點優化:經過添加 ^、$ 首尾錨點進行位置肯定

  • 獨立錨點優化: /^abc|^123/ 修改成 /^(?:abc|123)/ 有些正則引擎只對第一個 ^abc 起做用

  • 隱式錨點優化:

    .*、.+ 開頭的正則在沒有全局多選結構的狀況下,則可認爲在開頭有一個隱式的 ^,這樣就能使用字符串起始/行錨點優化

  • 內嵌文字字符串檢查優化 (高級版的預查必須字符/子字符串優化):

    /\b(perl|java).regex.info\b/ 匹配 'java.regex.info'

    而後從 .regex.info 往前數 4 個字符開始真正的正則匹配

    注意: 這裏距離固定才行,此例都是 4 ,若是是 (js|java) 這種就不行了

4.2.3 優化正則表達式自己

  • 文字字符串鏈接優化:把 abc 當成一個元素,而不是 a、b、c 三個元素 化三次迭代爲一次

  • 獨立文本優化:/a{2,4}/ 修改成 /aaa{0,2}/

  • 化簡量詞優化:

    /.*/ 與 /(?:.)*/ 在邏輯上相等,可是 .* 會做爲一個總體考慮,速度會更快,而 (?:.)* 在括號內外的控制權轉移時會消耗時間

  • 消除無必要括號:等價狀況下去除多餘括號——改 /(?:.)*/ 爲 /.*/

  • 消除不須要的字符組:改單個字符組爲字符,字符組會更費時間。改 /[a]/ 爲 /a/

  • 忽略優先量詞以後的字符優化:

    • 在靠近開頭使用忽略優先量詞
    • 在靠近結尾使用匹配優先量詞
    • 一般狀況忽略優先比匹配優先要慢,另外一個緣由就是若是忽略優先量詞在捕獲括號內,則會在控制權交換過程當中形成額外開銷
  • ❌(js暫不支持)使用佔有優先量詞削減狀態

    /\w+:/ 匹配 'username' 當 : 沒法匹配時,會逐步回溯到開始,可是使用固化分組或者佔有優先量詞會在匹配不到 : 時報出匹配失敗

  • 量詞等價轉換:/\d\d\d\d/ 修改爲 /\d{4}/,某些對對量詞作了優化的工具會更快

  • 拆分正則表達式:

    不少時候,應用多個小的正則表達式比一個大而全的正則表達式要快。 要用 January、February、March 之類的檢查一個字符串中是否有月份,比一個 /January|February|March/ 等等要快

  • 模擬開頭字符識別:使用環視預查

  • 主導引擎的匹配:/this|that/ 修改成 /th(?:is|at)/

  • 消除循環 此處的循環主要是指多選結構當中的星號所引發的屢次來回匹配。

    • 尋找通用套路 /normal+(special normal+)*/
      消除循環
      • 在匹配雙引號字符串時,引號自身和轉義斜線是「特殊」的——由於引號可以表示字符串的結尾,反斜線表示它以後的字符不會終結整個字符串,因此 normal 部分就是 [^"\\],special 部分就是 \\.,能得出下面的修改;
      • /"(\\.|[^"\\]+)*"/ 修改成 /"[^"\\]*(\\.[^"\\.]*)*"/,+ 換成了 * 號是沒有反作用的,且匹配適應性更廣,能夠用數學概括法自行判斷
        概括
      • 避免無休止匹配
        • special 部分和 normal 部分,匹配的開頭不能重合
        • normal 部分必須匹配至少一個字符
        • special 部分必須是固化的

5. 練習

5.1 去除一段文本中的重複單詞

去除下面字符串中連續的兩個單詞,可是不要破壞正常出現的單詞。

This is the theater you have been to to.

5.2 將駝峯變量名轉換爲下劃線變量名

示例:將 ImageUrlList 轉換成 image_url_list

5.3 獲取溫度值去除小數部分並替換進第一個 i 標籤內

溫度多是華氏度也多是攝氏度,格式如:+35C、-123.123F

<i>要匹配並被替換的內容</i><i>不須要匹配替換的內容</i>
複製代碼

6. 練習答案

5.1答案及思路

相關知識點:分組、反向引用、單詞邊界

const str = 'This is the theater you have been to to';

const pattern = /\b([a-z]+)\s\1\b/ig;

const result = str.replace(pattern, (match, ...args) => {
  return args[0];
});
console.log(result); // This is the theater you have been to
複製代碼

匹配思路:

  1. 要想找到重複單詞,首先要找到單詞,那麼第一步就是寫出 /[a-z]+\s/ig (全局不分大小寫匹配)這個匹配字母和後面一個空格的正則表達式;

  2. 重複單詞一定就是和前面單詞是同樣的,那麼能夠經過括號捕獲前一次的匹配,而後經過反向引用 \1 來匹配上一次匹配的結果,從而達到檢測重複的目的,就能獲得如下的正則表達式:/([a-z]+)\s\1/ig (\1 匹配括號內的[a-z]+ 結果,也就是上一個單詞);

  3. 咱們第二步的匹配已經很接近最終答案了,可是若是這樣去匹配是得不到咱們想要的結果的。咱們一眼看上去就知道重複的是最後的 to to,可是咱們寫下的正則表達式還會「機智地」幫咱們找到 This is、the theater 裏的 is 和 the,緣由就是咱們沒有區分單詞的匹配,找到緣由後就很容易了,咱們給正則表達式加上單詞邊界的限制就能夠了,因而獲得了最終的表達式 /\b([a-z]+)\s\1\b/ig

5.2答案及思路

相關知識點:環視

const str = 'ImageUrlList';
const pattern = /(?<=[a-z])(?=[A-Z])/g; // 極限優化狀況
const result = str.replace(pattern, '_').toLowerCase();
console.log(result); // image_url_list
複製代碼

匹配思路:

  1. 大多數時候咱們可能很簡單的會想用 /[A-Z][a-z]+/ 這種方式來匹配 Image、Url、List 而後分別用這些單詞加一個下劃線去替換原來的部分,仔細想來其實原來的部分並不須要改變,咱們只須要在合適的位置插入一個 _ 下劃線便可,那麼就能夠經過環視來找到這個位置;

  2. 經過第一個步驟的分析,咱們能夠發現咱們要找的位置是 eU、lL 這個字符的中間,那麼就能夠得出環視的目標:前一個字符是小寫字母,後一個字符是大寫字母;

  3. 前一個字符是小寫字母的環視:/(?<=[a-z])/;後一個字符是大寫字母的環視:/(?=[A-Z])/,結合在一塊兒就是咱們答案中的部分;

  4. 這裏還有一點補充,在這個例子中,/(?<=[a-z])(?=[A-Z])/g 或者 /(?=[A-Z])(?<=[a-z])/g 都是能夠的,緣由是這裏先判斷左邊仍是右邊是可有可無的,必需要在同一個位置兩邊都檢測成功纔會匹配,通俗理解就是這兩個位置結合才能使正則匹配成功,因此位置的前後順序並不會影響最後的結果;

5.3答案及思路

相關知識點:非捕獲括號、忽略優先量詞

// 獲取格式化的溫度
const temperature = '-123.456789C';
const patternT = /^([+-]?[0-9]+(?:\.[0-9]+)?)([CF])$/;
patternT.exec(temperature);
const t = `${RegExp.$1}${RegExp.$2}`;

// 替換內容
const string = '<i>要匹配並被替換的內容</i><i>不須要匹配的內容</i>';
const patternS = /(?<=(?:<i>)).*?(?=(?:<\/i>))/;
const result = string.replace(patternS, t);
console.log(result); // <i>-123.456789C</i><i>不須要匹配的內容</i>
複製代碼

匹配思路:

  1. 溫度部分:

    • 咱們要保留整數部分,那麼就取出符號和整數部分以及末尾的溫度符號,經過兩個捕獲型括號拿到對應的數據,組裝就能夠了便可;
    • 整數部分: /^([+-]?[0-9]+)/,溫度符號:/([CF])$/
    • 小數部分要怎麼處理呢?咱們知道小數部分有可能出現,也有可能不出現,那麼能夠經過 ? 量詞來進行限定,.xxx 做爲一個組來匹配:(.[0-9]+)? 就表示小數部分的匹配了;
    • 那結果就出現了——/^([+-]?[0-9]+(\.[0-9]+)?)([CF])$/,可是咱們在取值的時候須要經過 RegExp.$1 和 RegExp.$3 來拼接,咱們不須要 $2 的這個捕獲,因此經過非捕獲型括號 (?:) 就實現咱們想要的效果,最終結果就是答案裏的部分了;
  2. 匹配標籤內容部分:

    • 首先要得到 i 標籤而不是其餘標籤裏的內容,咱們能夠經過編寫正則表達式 /<i>.*</i>/ 實現;
    • 可是這樣會過多匹配到後一個 i 標籤,因而能夠採用忽略優先來匹配加上問號量詞:/<i>.*?</i>/
    • 最後爲了匹配更精準與不獲取無關內容,咱們加上環視與非捕獲分組,就造成了最後答案中出現的正則;

7. 附件

xmind 以及 png 下載:百度網盤 ,提取碼:gz7r

相關文章
相關標籤/搜索