Python下的正則表達式原理和優化筆記


最近的時間內對正則表達式進行了一點點學習。所選教材是《mastering regular expressions》,也就是所謂的《精通正則表達式》。讀過一遍后,頓感正則表達式的強大和精湛之處。其中前三章是對正則表達式的基本規則的介紹和鋪墊。七章以后是對在具體語言下的應用。而核心的部分則是四五六這三章節。
        其中第四章是講了整個正則表達式的精華,即傳統引擎NFA的回溯思想。第五章是一些例子下對回溯思想的理解。第六章則是對效率上的研究。根源也是在回溯思想上的引申和研究。
        這篇文章是我結合python官方re模塊的文檔以及這本書做一個相應的總結。
        其中官方的文檔:  http://docs.python.org/3.3/library/re.html 
        由於我都是在python上聯系和使用的,所以后面的問題基本都是在python上提出來的,所以這本書中的其它正則流派我均不涉及。依書中,python和perl風格差不多,屬於傳統NFA引擎,也就是以“表達式主導“,采用回溯機制,匹配到即停止(  順序敏感,不同於POSIX NFA等采用匹配最左最長的結果)。 
        對於回溯部分,以及談及匹配的時候,將引擎的位置總是放在字符和字符之間,而不是字符本身。比如^對應的是第一個字符之前的那個”空白“位置。

基礎規則的介紹

python中的轉義符號干擾

        python中,命令行和腳本等,里面都會對轉義符號做處理,此時的字符串會和正則表達式的引擎產生沖突。即在python中字符串'\n'會被認為是換行符號,這樣的話傳入到re模塊中時便不再是‘\n’這字面上的兩個符號,而是一個換行符。所以,我們在傳入到正則引擎時,必須讓引擎單純的認為是一個'\'和一個'n',所以需要加上轉義符成為'\\n',針對這個情況,python中使用raw_input方式,在字符串前加上r,使字符串中的轉義符不再特殊處理(即python中不處理,統統丟給正則引擎來處理),那么換行符就是r'\n'

基本字符

.        #普通模式下,匹配除換行符外的任意字符。(指定DOTALL標記以匹配所有字符)

 

量詞限定符

*        #匹配前面的對象0個或多個。千萬不要忽略這里的0的情況。
+        #匹配前面的對象1個或多個。這里面的重點是至少有一個。
?        #匹配前面的對象0個或1個。
{m}      #匹配前面的對象m次
{m,n}    #匹配前面的對象最少m次,最多n次。

 

錨點符

^        #匹配字符串開頭位置,MULTILINE標記下,可以匹配任何\n之后的位置
$        #匹配字符串結束位置,MULTILINE標記下,可以匹配任何\n之前的位置

 

正則引擎內部的轉義符號

\m    m是數字,所謂的反向引用,即引用前面捕獲型括號內的匹配的對象。數字是對應的括號順序。
\A    只匹配字符串開頭
\b    可以理解一個錨點的符號,此符號匹配的是單詞的邊界。這其中的word定義為連續的字母,數字和下划線。
      准確的來說,\b的位置是在\w和\W的交界處,當然還有字符串開始結束和\w之間。
\B    和\b對應,本身匹配空字符,但是其位置是在非"邊界"情況下.
      比如r'py\B'可以匹配'python',但不能匹配'py,','py.' 等等
\d    匹配數字
\D    匹配非數字
\s    未指定UNICODE和LOCALE標記時,等同於[ \t\n\r\f\v](注意\t之前是一個空格,表示也匹配空格)
\S    與\s相反
\w    未指定UNICODE和LOCALE標記時,等同於[a-zA-Z0-9_]
\W    和\w相反
\Z    只匹配字符串結尾
其他的一些python支持的轉移符號也都有支持,如前面的'\t'

 

字符集

[]
尤其注意,這個字符集最終 只匹配一個字符(既不是空,也不是一個以上!),所以前面的一些量詞限定符,在這里失去了原有的意義。
另外,'-'符號放在兩個字符之間的時候,表示ASCII字符之間的所有字符,如[0-9],表示0到9. 
而放在字符集開頭或者結尾,或者被'\'轉義時候,則只是表示特指'-'這個符號 
最后,當在開頭的地方使用'^',表示排除型字符組.

括號的相關內容

普通型括號 

(...)    普通捕獲型括號,可以被\number引用。

 

擴展型括號

(?aiLmsx)
a        re.A
i        re.I    #忽略大小寫
L        re.L
m        re.M
s        re.S    #點號匹配包括換行符
x        re.X    #可以多行寫表達式
如:
re_lx = re.compile(r'(?iS)\d+$')
re_lx = re.compile(r'\d+',re.I|re.S)    #這兩個編譯表達式等價
(?:......)      #非捕獲型括號,此括號不記錄捕獲內容,可節省空間    
(?P<name>...)   #此捕獲型括號可以使用name來調用,而不必依賴數字。使用(?P=name)調用。
(?#...)         #注釋型括號,此括號完全被忽略
(?=...)   #positive lookahead assertion    如果后面是括號中的,則匹配成功
(?!...)   #negative lookahead assertion    如果后面不是括號中的,則匹配成功
(?<=...)  #positive lookbehind assertion   如果前面是括號中的,則匹配成功
(?<!...)  #negative lookbehind assertion   如果前面不是括號中的,則匹配成功 
 #以上四種類型的斷言,本身均不匹配內容,只是告知正則引擎是否開始匹配或者停止。
 #另外在后兩種后項斷言中,必須為定長斷言。
(?(id/name)yes-pattern|no-pattern)
#如有由id或者name指定的組存在的話,將會匹配yes-pattern,否則將會匹配no-pattern,通常情況下no-pattern可以省略。

 

匹配優先/忽略優先符號

在量詞限定符中,默認的情況都是匹配優先,也就是說,在符合條件的情況下,正則引擎會盡量匹配多的字符( 貪婪規則
當在這些符號后面加上'?',則正則引擎會成為忽略優先,此時的正則引擎會匹配  盡可能少的字符。

如'??'會先匹配沒有的情況,然后才是1個對象的情況。而{m,n}?則是優先匹配m個對象,而不是占多的n個對象。

相關進階知識

python屬於perl風格,屬於傳統型NFA引擎,與此相對的是POSIX NFA和DFA等引擎。所以大部分討論都針對傳統型NFA

傳統型NFA中的順序問題

NFA是基於表達式主導的引擎,同時,傳統型NFA引擎會在找到第一個符合匹配的情況下立即停止:即得到匹配之后就停止引擎。
而POSIX NFA 中不會立刻停止,會在所有可能匹配的結果中尋求最長結果。這也是有些bug在傳統型NFA中不會出現,但是放到后者中,會暴露出來。
引申一點,NFA學名為”非確定型有窮自動機“,DFA學名為”確定型有窮自動機“
這里的非確定和確定均是對被匹配的目標文本中的字符來說的,在NFA中,每個字符在一次匹配中即使被檢測通過,也不能確定他是否真正通過,因為NFA中會出現回溯!甚至不止一兩次。圖例見后面例子。而在DFA中,由於是目標文本主導,所有對象字符只檢測一遍,到文本結束后,過就是過,不過就不過。這也就是”確定“這個說法的原因。

回溯/備用狀態

備用狀態

當出現可選分支時,會將其他的選項存儲起來,作為備用狀態。當前的匹配失敗時,引擎進行回溯,則會回到最近的備用狀態。
匹配的情況中,匹配優先與忽略優先某種意義上是一致的,只是順序上有所區別。當存在多個匹配時,兩種方式進行的情況很可能是不同的,但是當不存在匹配時,他們倆的情況是一致的,即必然嘗試了所有的可能。

回溯機制兩個要點

  1. 在正則引擎選擇進行嘗試還是跳過嘗試時,匹配優先量詞和忽略優先量詞會控制其行為。
  2. 匹配失敗時,回溯需要返回到上一個備用狀態,原則是后進先出(后生成的狀態首先被回溯到)
回溯典型舉例:
上圖可以看到,傳統型NFA到D點即匹配結束。而在陰影中POSIX NFA的匹配流程,需要找到所有結果,  並在這些結果中取最長的結果返回

作為對比說明,下面是目標文本不能匹配時,引擎走過的路徑:
如下圖,我們看到此時POSIX NFA和傳統型NFA的匹配路徑是一致的

 

 

以上的例子引發了一個匹配時的思考,很多時候我們應該盡量避免使用'.*' ,因為其總是可以匹配到最末或者行尾,浪費資源。
既然我們只尋求引號之間的數據,往往可以借助排除型數組來完成工作。
此例中,使用'[^'']*'這個來代替'.*'的作用顯而易見,我們只匹配非引號的內容,那么遇到第一個引號即可退出*號控制權。

固化分組思想

 固化分組的思想很重要,  但是python中並不支持。使用(?>...)括號中的匹配時如果產生了備選狀態,那么一旦離開括號便會被立即  引擎拋棄掉(從而無法回溯!)。舉個典型的例子如: 
'\w+:'

 

這個表達式在進行匹配時的流程是這樣的,會優先去匹配所有的符合\w的字符,假如字符串的末尾沒有':',即匹配沒有找到冒號,此時觸發回溯機制,他會迫使前面的\w+釋放字符,並且在交還的字符中重新嘗試與':'作比對
但是問題出現在這里:    \w是不包含冒號的,顯然無論如何都不會匹配成功,可是依照回溯機制,引擎還是得硬着頭皮往前找,這就是對資源的浪費。
所以我們就需要避免這種回溯,對此的方法就是將前面匹配到的內容固化不令其存儲備用狀態!,那么引擎就會因為沒有備用狀態可用而只得結束匹配過程。大大減少回溯的次數!

Python模擬固化過程

雖然python中不支持,但書中提供了利用前向斷言來模擬固化過程。

(?=(...))\1

 

本身, 斷言表達式中的結果是不會保存備用狀態的,而且他也不匹配具體字符,但是通過巧妙的 添加一個捕獲型括號來反向引用這個結果,就達到了固化分組的效果!對應上面的例子則是: 
'(?=(\w+))\1:'

 

多選結構

多選結構在傳統型NFA中, 既不是匹配優先也不是忽略優先。而是按照順序進行的。所以有如下的利用方式

 

  1. 在結果保證正確的情況下,應該優先的去匹配更可能出現的結果。將可能性大的分支盡可能放在靠前
  2. 不能濫用多選結構,因為當匹配到多選結構時,緩存會記錄下相應數目的備用狀態。舉例子:[abcdef]和‘a|b|c|d|e|f’這兩個表達式,雖然都能完成你的某個目的,但是盡量選擇字符型數組,因為后者會在每次比較時建立6個備用狀態,浪費資源。

 

一些優化的理念和技巧

平衡法則

好的正則表達式需尋求如下平衡:
  1. 只匹配期望的文本,排除不期望的文本。(善於使用非捕獲型括號,節省資源)
  2. 必須易於控制和理解。避免寫成天書。。
  3. 使用NFA引擎,必須要保證效率(如果能夠匹配,必須很快地返回匹配結果,如果不能匹配,應該在盡可能短的時間內報告匹配失敗。

處理不期望的匹配

在處理過程中,我們總是習慣於使用星號等非硬性規定的量詞(其實是個不好的習慣),
這樣的結果可能導致我們使用的匹配表達式中沒有必須匹配的字符,例子如下:

'[0-9]?[^*]*\d*'    #只是舉個例子,沒有實際意義。

 

上面的式子就是這種情況,在目標文本是“理想”時,可能出現不了什么問題,但是如果本身數據有問題。那么這個式子的匹配結果就完全不可預知。 
原因就在於他沒有一部分是必須的!它匹配任何內容都是成功的。。。 

對數據的了解和假設

其實在處理很多數據的時候,我們的操作數據情況都是不一樣的,  有時會很規整,那么我們可以省掉考慮復雜表達式的情況,  但是反過來,當來源很雜亂的時候,就需要思考多一些,對各種可能的情形做相應的處理。
 

引擎中一般存在的優化項

 

編譯緩存
反復使用編譯對象時,應該在使用前,使用re.compile()方法來進行編譯,這樣在后面調用時不必每次重新編譯。節省時間。尤其是在循環體中反復調用正則匹配時。
錨點優化
配合一些引擎的優化,應盡量將錨點單獨凸顯出來。對比^a|^b,其效率便不如^(a|b)
同樣的道理,系統也會處理行尾錨點優化。所以在寫相關正則時,如果有可能的話,將錨點使用出來。
量詞優化
引擎中的優化,會對如.* 這樣的量詞進行統一對待,而不是按照傳統的回溯規則,所以,從理論上說'(?:.)*' 和'.*'是等價的,不過具體到引擎實現的時候,則會對'.*'進行優化。速度就產生了差異。
消除不必要括號以及字符組
這個在python中是否有  未知。只是在支持的引擎中,會對如[.]中轉化成\.,因為顯然后者的效率更高(字符組處理引起額外開銷)
 
以上是一些引擎帶的優化,自然實際上是我們無法控制的的,不過了解一些后,對我們后面的一些處理和使用有很大幫助。

其他技巧和補充內容

過度回溯問題

消除指數級匹配

形如下面:
(\w+)*

 

這種情況的表達式,在匹配長文本的時候會遇到什么問題呢,如果在文本匹配失敗時(別忘了,如果失敗,則說明已經回溯了  所有的可能),想象一下,*號退一個狀態,里面的+號就包括其余的  所有狀態,驗證都失敗后,回到外面,*號  退到倒數第二個備用狀態,再進到括號內,+號又要回溯一邊比上一輪差1的  備用狀態數,當字符串很長時,  就會出現指數級的回溯總數。系統就會'卡死'。甚至當有匹配時,這個匹配藏在回溯總數的中間時,也是會造成卡死的情況。所以,使用NFA的引擎時,必須要注意這個問題!

我們采用如下思路去避免這個問題:
占有優先量詞(python中使用前向斷言加反向引用模擬)
道理很簡單,既然龐大的回溯數量都是被儲存的備用狀態導致的,那么我們直接使引擎放棄這些狀態。說到底是擺脫(regex*)* 這種形式。

import re
re_lx = re.compile(r'(?=(\w+))\1*\d')

 

效率測試代碼

在測試表達式的效率時,可借助以下代碼比較所需時間。在兩個可能的結果中擇期優者。

import re
import time
re_lx1 = re.compile(r'your_re_1')
re_lx2 = re.compile(r'your_re_2')

starttime = time.time()
repeat_time = 100
for i in range(repeat_time):
    s='test text'*10000
    result = re_lx1.search(s)
time1 = time.time()-starttime
print(time1)

starttime = time.time()
for i in range(repeat_time):
    s='test text'*10000
    result = re_lx2.search(s)
time2 = time.time()-starttime
print(time2)

 

量詞等價轉換

現在來看看大括號量詞的效率問題
1,當大括號修飾的對象是類似於字符數組或者\d這種  非確定性字符時,使用大括號效率高於重復疊加對象。即:
\d{5}優於\d\d\d\d\d
經測試在python中后者優於前者。會快很多.
2,但是當重復的字符時確定的某一個字符時,則簡單的重復疊加對象的效率會高一些。這是因為引擎會對單純的字符串內部優化(雖然我們不知道具體優化是如何做到的)
aaaaa 優於a{5}
總體上說'\d' 肯定是慢於'1'
我使用的python3中的re模塊,經測試,不使用量詞會快。
綜上,python中總體上使用量詞不如簡單的列出來!(與書中不同!)

錨點優化的利用

下面這個例子假設出現匹配的內容在字符串對象的結尾,那么下面的第一個表達式是快於第二個表達式的,原因在於前者有錨點的優勢。

re_lx1 = re.compile(r'\d{5}$')    
re_lx2 = re.compile(r'\d{5}')    #前者快,有錨點優化

 

排除型數組的利用

繼續,假設我們要匹配一段字符串中的5位數字,會有如下兩個表達式供選擇:
經過分析,我們發現\w是包含\d的,當使用匹配優先時,前面的\w會包含數字,之所以能匹配成功,或者確定失敗,是后面的\d迫使前面的量詞交還一些字符。
知道這一點,我們應該盡量避免回溯,一個順其自然的想法就是不讓前面的匹配優先量詞涉及到\d

re_lx1 = re.compile(r'^\w+(\d{5})')
re_lx2 = re.compile(r'^[^\d]+\d{5}')    #優於上面的表達式

 

總體來說,在我們沒有時間去深入研究模塊代碼的時候,只能通過嘗試和反復修改來得到最終的復合預期的表達式。

常識優化措施

然而我們利用可能的提升效果去嘗試修改的時候很有可能  適得其反 ,  因為某些我們看來緩慢的回溯在正則引擎內部會進行一定的優化 ,
“取巧”的修改又可能會關閉或者避開了這些優化,所以結果也許會令我們很失望。
以下是書中提到的一些  常識性優化措施:
避免重新編譯(循環外創建對象)
使用非捕獲型括號(節省捕獲時間和回溯時狀態的數量)
善用錨點符號
不濫用字符組
提取文本和錨點。將他們從可能的多選分支結構中提取出來,會提取速度。
最可能的匹配表達式放在多選分支前面

 


 

一個很好用的核心公式

’opening normal*(special normal*)* closing‘

這個公式  特別用來對於匹配在兩個特殊分界部分(可能不是一個字符)內的normal文本,special則是處理當分界部分也許和normal部分混亂的情況。
有如下的三點避免這個公式無休止匹配的發生。
  1. special部分和normal部分匹配的開頭不能重合。一定保證這兩部分在任何情況下不能匹配相同的內容,不然在無法出現匹配時遍歷所有情況,此時引擎的路徑就不能確定。
  2. normal部分必須匹配至少一個字符
  3. special部分必須是固定長度的

舉個例子:

[^\\"]+(\\.[^\\"]+)* #匹配兩個引號內的文本,但是不包括被轉義的引號

 from:http://www.codeweblog.com/python%E4%B8%8B%E7%9A%84%E6%AD%A3%E5%88%99%E8%A1%A8%E8%BE%BE%E5%BC%8F%E5%8E%9F%E7%90%86%E5%92%8C%E4%BC%98%E5%8C%96%E7%AC%94%E8%AE%B0/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM