[R語言專題] 運用R語言玩轉文字探勘 – 正規表達式

0

Last Updated on 2023-10-05

Home » R語言教學 » R語言專題 » [R語言專題] 運用R語言玩轉文字探勘 – 正規表達式

在清理文字資料的過程中,一定會用上一個神兵利器:「正規表達式」(regular expression,簡稱為 regex 或 regexp)。


在利用文字探勘技術,完成讓人看完眼睛為之一亮的分析之前,我們要先確保資料集的品質足夠優異,否則只會應驗時常聽到的名言:「garbage in, garbage out」。

若我們以網路媒體文章,或者擷取社群媒體與論壇上的內容,當成文字探勘標的,一定要清理/洗原始資料(data cleaning/cleansing),之後才會開始分析。大家常說資料分析師的工作中,清理資料時間占8成、分析資料時間占2成。

就我的經驗來說,平常若打交道的以數值資料為主,確實是這個比例;但如果是文字資料,恐怕比例會更極端到清資料就要花上9成時間。在清理文字資料的過程中,一定會用上一個神兵利器:「正規表達式」(regular expression,簡稱為 regex 或 regexp)。底下,我們就來介紹正規表達式的用途與用法。

正規表達式介紹

什麼時候會用上正規表達式呢?舉例來說,若想檢查一段英文字串,是否包含英文母音”aeiou”,我們可以利用stringr裡面的函數str_detect()確認。

str_detect()函數裡面,會放入想要比對的字串,對應參數為string,以及想要比對的字串模式,對應參數為pattern

library(tidyverse)
#> ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
#> ✔ dplyr     1.1.2     ✔ readr     2.1.4
#> ✔ forcats   1.0.0     ✔ stringr   1.5.0
#> ✔ ggplot2   3.4.2     ✔ tibble    3.2.1
#> ✔ lubridate 1.9.2     ✔ tidyr     1.3.0
#> ✔ purrr     1.0.1     
#> ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
#> ✖ dplyr::filter() masks stats::filter()
#> ✖ dplyr::lag()    masks stats::lag()
#> &#x2139; Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
str_detect(string = c("Sistar", "BTS", "AOA", "BTOB", "Apink"), 
           pattern = "a")
#> [1]  TRUE FALSE FALSE FALSE FALSE
str_detect(string = c("Sistar", "BTS", "AOA", "BTOB", "Apink"), 
           pattern = "e")
#> [1] FALSE FALSE FALSE FALSE FALSE

我們可以想像,在pattern當中,逐一放入a、e、i、o、u、A、E、I、O、U,接著再將結果利用|(代表「或」)確認,到底哪些韓團名稱之中有母音。

白話一點解釋,就是確認這些名字當中,「有沒有a」、「有沒有e」、…一路往下比對,最後再選出「有a」或「有e」或「有i」…。

這裡面的「有沒有a」就是一種想找的字串模式。不過,比起一個一個比對,利用正規表達式,可以更快完成上述任務。來看下面的案例。

str_detect(string = c("Sistar", "BTS", "AOA", "BTOB", "f(x)", "SS501"), 
           pattern = "a|e|i|o|u|A|E|I|O|U")
#> [1]  TRUE FALSE  TRUE  TRUE FALSE FALSE
str_detect(string = c("Sistar", "BTS", "AOA", "BTOB", "f(x)", "SS501"), 
           pattern = "[aeiouAIEOU]")
#> [1]  TRUE FALSE  TRUE  TRUE FALSE FALSE

無論是"a|e|i|o|u|A|E|I|O|U",或者"[aeiouAIEOU]",雙引號當中字串與符號的拼接,就是「正規表達式」的具體實例。

套個比喻,正規表達式像是一種搜尋文字的公式。當我們想從文章中找出符合特定模式的一段文字,就可以拿正規表達式,作為找出該模式的搜尋規則

在上面尋找母音的範例中,|代表「或」,放在[]裡面的字母全部都會用來比對,因此兩者可以達成相同效果。其實,在利用str_c()函數時:

str_c(string = c("Sistar", "BTS", "AOA", "BTOB", "f(x)", "SS501"), pattern = "a")

這當中的"a",同樣也是字串模式,正規表達式則是更進一步,利用提前約定好的符號,增添搜尋規則的豐富程度。

這些提前約定好的符號,在英文中被稱作metacharacter,對應中文翻譯有「元字符/元」、「中繼字符/元」、「保留字符/元」、「特殊字符/元」等,我習慣用保留字元稱呼,接下來都會直接使用這個說法。

上面用過的|、底下會介紹的*,甚至是括號(),都屬於保留字元的範疇。

當我們在模式中寫到這些保留字元時,程式能夠立刻知道,我不是想尋找|這個符號,而是想利用「或」的功能。如果我們真的想在字串中尋找|符號,則要另外在前面加上兩條反斜線「轉義」(escaping)。

# 想找 S 或 B
str_detect(string = c("SS501", "BTS|BTOB"), 
           pattern = "S|B")
#> [1] TRUE TRUE
# 想找 | 符號
str_detect(string = c("SS501", "BTS|BTOB"), 
           pattern = "\\|")
#> [1] FALSE  TRUE

第二個例子中,我們就是用\\消除|「或」的意義,讓電腦知道我們不是要利用「或」的功能,而是真的想尋找|符號是否出現於文中。

不過,你可能會想說,為什麼要用兩條反斜線,才能轉義?

原因在於,當\加上某些英文字母,本來就有特殊意義,例如\t代表定位字元(tab)、\n代表換行,這些結合\與字母的特殊序列,被稱為character escapes,對應中文為「逃脫字元」。

我們先來看在R語言中使用換行\n符號時,「印出」結果會是什麼長相。平常我們都會用print()函數,或者直接呈現某個R語言物件的內容。但在這邊我們會用cat()函數,差別在哪呢?

利用print()(還有直接印出字串物件)時,它會保留字串的實際值,因為它是用來輸出R語言物件(data object)。

print("abc\nabc")
#> [1] "abc\nabc"

相對來說,cat()函數則是用可讀格式(readable format)將幾個字串連接(concatenate)在一起後輸出。

cat("abc\nabc")
#> abc
#> abc

如果加上\逃脫,就不會換行了。

cat("abc\\nabc")
#> abc\nabc

因為逃脫字元本身就含有一個\,因此,在R語言當中,想要完成轉義,需要第二個反斜線。

在上面的例子中,我們看到\n真的讓”abc”與”abc”之間換行;但轉義後\n失去換行的意義,直接印出。所以前面想要找|符號,沒有要利用它的「或」功能,就要加上\\在前面才能處理。後面會列出更多逃脫字元及應用。

稍微總結這個小節的重點:正規表達式是由想搜尋的字元和保留字元拼湊而成的文字搜尋公式,是字串處理必學的重要利器,也是確保文字探勘擁有好品質的重要基礎。

底下我們就從最基礎的正規表達式語法開始介紹。

正規表達式基礎

保留字元

我們利用str_detect()函數,還有包含3個韓團在內的字串c("BTOB", "BTS", "CNBLUE"),用來說明保留字元,掌握它就能發揮正規表達式的一半功力了!

# 想找包含 BT 在內的字串
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "BT")
#> [1]  TRUE  TRUE FALSE

我們先來認識正規表達式當中有哪些保留字元吧。

. ^ $ * + ?  [ ] { } | \ ( )

這一串的每個符號都是保留字元,也就是說,它們各自擁有特別的功能。

  • . 能夠配對「換行字元」以外任意字元,一個.代表一個字元。
# 想找包含BT.在內的字串,.可以是任意字元
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "BT.")
#> [1]  TRUE  TRUE FALSE
# 想找 BT.B,沒有緊接著B就會配對失敗
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "BT.B")
#> [1]  TRUE FALSE FALSE
# 換行 \n 比對失敗,但 tab 比對成功
str_detect(string = c("BT\nOB", "BT\tS", "CNBLUE"), 
           pattern = "BT.")
#> [1] FALSE  TRUE FALSE

  • ^ 能夠配對字串起始位置的字元,也就是比對開頭的字元,要放在用來比對的字元前面
# 想找 B 開頭的字串
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "^B")
#> [1]  TRUE  TRUE FALSE

  • $ 能夠配對字串結束位置的字元,也就是比對末端的字元,要放在用來比對的字元後面
# 想找 B 開頭的字串
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "B$")
#> [1]  TRUE FALSE FALSE

  • | 代表「或者」,能比對左邊的規則或右邊的規則;另外&不是一個保留字元喔,因為如果想找abc連續3個字母,只要直接列在規則中;如果想找同時包含abyz,我們會另外用代表「合樣」(lookahead以及lookbehind)概念的方法判斷,因為相對複雜,後面會另闢小節說明。
# 想找包含 O 的字串,或者用 E 結尾的字串
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "O|E$")
#> [1]  TRUE FALSE  TRUE

  • [] 代表字元的集合,它可以納入多個字元,比對時會全部考慮進來;裡面還能放進許多程式語言都廣泛採用的表達方式,例如用[0-9]代表數字、[a-z]代表小寫英文字母,下個小節會再詳細介紹。
# 想找有 A, O, E 在內的字串
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "[AOE]")
#> [1]  TRUE FALSE  TRUE
# 想找有大寫英文字母的字串
str_detect(string = c("BTOB", "BTS", "CNBLUE"), 
           pattern = "[A-Z]")
#> [1] TRUE TRUE TRUE

  • () 讓字元以「組」為單位比對;它還有其他進階用法,後面會另外介紹。
# 想找包含 abab 或 acab 在內的字串
str_detect(string = c("ab", "abab", "acab", "abaa"), 
           pattern = "(ab|ac)(ab)")
#> [1] FALSE  TRUE  TRUE FALSE

  • {} 用來表示次數或者範圍,可以用來設定它前面規則比對出現次數的下限(至少出現幾次)與上限(最多出現幾次)。在R語言裡面可以只寫下限,但不能只寫上限喔!
# 想找包含 s/S 連續出現 1 - 2 次的字串
str_detect(string = c("SS501", "ss501", "SSS", "Apink"), 
           pattern = "[Ss]{1,2}")
#> [1]  TRUE  TRUE  TRUE FALSE
# 想找包含 s/S 連續出現至少 3 次的字串
str_detect(string = c("SS501", "ss501", "SSS", "Apink"), 
           pattern = "[Ss]{3,}")
#> [1] FALSE FALSE  TRUE FALSE

  • ? 用來表示它前面的規則出現一次或零次,也就是說,?前面的字元或字元組是可有可無。
# 想找包含 BTOS 或 BTS 在內的字串
# 可以有O(出現1次)也可以沒有O(出現0次)
str_detect(string = c("BTOB", "BTS", "BTSB"), 
           pattern = "BTO?S")
#> [1] FALSE  TRUE  TRUE

  • * 用來表示它前面的規則出現多次或零次,這邊的多次包含剛好一次。

我們改用str_extract_all()函數說明,它可以從字串中取出符合規則的一小段字串。

# 想找包含 BT or BTO or BTS在內的字串
# BTOOS 最能說明「出現多次」的意義
str_extract_all(string = c("BTOB", "BTS", "BTSB", "BTC", "BTOOS"), 
                pattern = "BT[OS]*")
#> [[1]]
#> [1] "BTO"
#> 
#> [[2]]
#> [1] "BTS"
#> 
#> [[3]]
#> [1] "BTS"
#> 
#> [[4]]
#> [1] "BT"
#> 
#> [[5]]
#> [1] "BTOOS"

  • + 用來表示它前面的規則出現一次或多次,也就是說,+前面的字元或字元組至少出現一次。
# 想找包含 BTO 或 BTS 在內的字串
# BTC 不符合所以沒有取出任何字串
str_extract_all(string = c("BTOB", "BTS", "BTSB", "BTC"), 
                pattern = "BT[OS]+")
#> [[1]]
#> [1] "BTO"
#> 
#> [[2]]
#> [1] "BTS"
#> 
#> [[3]]
#> [1] "BTS"
#> 
#> [[4]]
#> character(0)

  • \ 代表轉義,前面提過在R語言中要有兩道斜線才能轉義。
# 想找包含 | 或者 包含 . 在內的字串
# 第一個 | 有轉義,第二個 | 沒有因此代表「或」
str_detect(string = c("B|B", "B.", "B", "."), 
                pattern = "\\||\\.")
#> [1]  TRUE  TRUE FALSE  TRUE

整理上面的規則,我們可以簡單分類:

  • 開始與結束:^$
  • 規則重複:?*+{}
  • 「或」:|[]
  • 任意比對:.
  • 其他:轉義\和組別()

將規則結合起來,正規表達式就能發揮很強大的功能!

逃脫字元

前面提過,正規表達式中\能夠「轉義」,替字元跳脫出其字面意義。在R語言裡面,應用正規表達式時使用\相對麻煩,為什麼呢?

因為,不只正規表達式中的\具有特殊意義,R語言本來就會使用\轉義。我們來讀一份文字檔,在讀取之前,先用截圖看長相。

文字檔案內容

文字檔案內容

我們可以看到裡面有換行,「音樂」和「時間」之間也有tab。把文字匯入R語言當中,又會長什麼樣子呢?

example &lt;- read_file("data/regex-example.txt")

先直接印出來看,可以看到用\n代表換行、\t代表tab。

example
### [1] "現在我有冰淇淋。\n\n我很喜歡冰淇淋,但是,速度與激情九,速度與...速度與激情九,我最~喜歡!\n\n所以現在是音樂\t時間!\n準備,1 2 3"

也可以直接看實際在文件中會呈現的長相。

cat(example)
## 現在我有冰淇淋。 ## ## 我很喜歡冰淇淋,但是,速度與激情九,速度與...速度與激情九,我最~喜歡! ## ## 所以現在是音樂 時間! ## 準備,1 2 3

我們先來看R語言中常見的跳脫字元:

特殊字元說明
\n換行字元
\t定位字元 (tab)
\r回車字元
\b退格字元
\a鈴聲字元
\f換頁字元
\\單一反斜線
\"雙引號
\'單引號

我們來看這些符號在文件中會長什麼樣子。

不過,只有特定系統或環境輸出\a時會發出鈴聲,但我現在用的 MAC 電腦不會,所以底下會跳過。

# 換行字元
cat("你\n好")
#> 你
#> 好

# 定位字元
cat("你\t好")
#> 你  好

下一個是回車字元。回車(carriage return)字元的功能是讓滑鼠游標移到現在這行的開始位置,不會跳到下一行。

# 回車字元
cat("你\r好")
#> 你好

執行上面這段程式碼時,會先印出"你",接著\r會回到開頭,再接著印出"好"。因此輸出結果會是"好",因為"好"蓋過"你"

接下來則是退格字元,它會讓游標移動到前一個位置,所以輸出後一個字元時,會蓋過前面的字元,所以"你"就被覆蓋過去。

# 退格字元
cat("我想說你\b好")
#> 我想說你好

上面都是有特殊意義的跳脫字元,我們接著來看其他和R語言中和標點符號有關的例子。

平常要表達字串時,我們會用雙引號""或單引號''將字串包在其中。不過,如果想在字串中使用雙引號或單引號,例如Charles' car或者Jane said, "..."怎麼辦?

一般來說,我們想在字串中使用單引號,就會在賦值時使用雙引號;同理,想在字串中使用雙引號,就會在賦值時使用單引號。

cat("Charles' car")
#> Charles' car
cat('Jane said, "..."')
#> Jane said, "..."

但總有些時候,我們不能這麼做,像是Jane said, "Charle's car..."

cat("Jane said, "Charles' car..."")

#> Error: &lt;text>:1:18: unexpected symbol
#> 1: cat("Jane said, "Charles
#>                      ^

上面出現錯誤,因為R語言以為"Charle的雙引號代表字串結束,沒想到那其實是在引述Jane的話。這時,\就能派上用場!

cat("Jane said, \"Charles' car...\"")
#> Jane said, "Charles' car..."

cat("你\\好")
#> 你\好
cat("你\"好")
#> 你"好
cat("你\'好")
#> 你'好

上面都沒問題,但接下來就會出錯囉!

cat("你"好")

#> Error: &lt;text>:1:8: unexpected symbol
#> 1: cat("你"好
#>            ^

因為沒有使用轉義,電腦以為到你就結束了,沒想到後面還有一個好,這樣看起來覺得缺了一個雙引號,因此回報錯誤。這樣是不是有比較理解R語言中的逃脫字元了呢?

最後,我們再來釐清一件事情。在上面的例子中,我們只要使用\'就能讓'逃脫掉它原先在R語言中的功能,也就是賦值之用。事實上,無論是'或者",都是R語言中的基本函數!你可以在R Console裡輸入?base::Quotes,R Studio右下角的Help頁面,就會跳出介紹。

這些是在R語言當中的轉義。但是,在正規表達式中,同樣會使用\轉義。舉例來說,前面我們提過|代表「或」,所以如果我想找字串中有沒有|符號,不能直接用|符號,要不然電腦會以為我們要利用「或」功能,得在前面加上\才行。

但是這樣還不夠!因為就像上面剛提到的,R語言中\本身就有轉義的意思,所以我們要把這個代表轉義的\再轉義掉。如果我想找|符號,就得在前面加上兩次\,變成\\|,才能讓R語言知道,原來我就是要找`|。

這樣的說明,有沒有比較清楚R語言中的轉義,以及正規表達式的轉義了呢?

字元集

介紹[]時有提到,在包含R語言在內的眾多程式語言之中,可以利用類似[a-z]的表示方式,比對特定字元的集合。"[a-z]"代表「從A到Z的所有大寫英文字母」,它一定比在[]列出26個字母省時。

這個寫法被稱為「字元集」(character set/class)。雖然[]屬於保留字元的一部分,但因為延伸應用很多所以特別介紹。常見的字元集有這些:

字元集合解釋意思其他表示方法
[:digit:]比對任意數字[0-9]
[:lower:]比對任意小寫字母[a-z]
[:upper:]比對任意大寫字母[A-Z]
[:alpha:]比對任意字母[a-zA-Z]
[:alnum:]比對任意字母和數字[a-zA-Z0-9]
[:blank:]比對空格和定位字元[ \t]
[:space:]比對任意空白字元,包含空格、定位字元、換行字元(\n)、換頁字元(\f)、回車字元(\r[ \t\n\r\f]
[:punct:]比對任意標點符號
[:print:]比對任意可印刷字元,等同任意字母和數字、標點符號、空白字元
[:cntrl:]比對任意不可印刷字元,也稱為控制字元。在字元集(ASCII 或 Unicode)中有些具有編碼的字元有特殊用途,例如讓設備發出聲音、刪除前一個字等,不太會出現在文章或文件當中
# 想找包含 a 或任意數字在內的字串
str_detect(string = c("SS501", "ss501", "apink", "Apink"), 
           pattern = "a|[0-9]")
#> [1]  TRUE  TRUE  TRUE FALSE
# 跟上面意義相同
str_detect(string = c("SS501", "ss501", "apink", "Apink"), 
           pattern = "a|[:digit:]")
#> [1]  TRUE  TRUE  TRUE FALSE

# 想找包含空格或定位字元在內的字串
str_detect(string = c(" ", "\t", "a", "\f"), 
           pattern = "[:blank:]")
#> [1]  TRUE  TRUE FALSE FALSE
# 跟上面意義相同
str_detect(string = c(" ", "\t", "a", "\f"), 
           pattern = "[ \t]")
#> [1]  TRUE  TRUE FALSE FALSE

前面學過^可以用來比對字串開頭,但如果放在[]裡面,它就變成取否定,也就是「不要…」的意思。

# 想找「包含數字」以外字元的字串,代表數字以外都算
str_detect(string = c("SS501", "501", "apink", "Apink"), 
           pattern = "[^0-9]")
#> [1]  TRUE FALSE  TRUE  TRUE
# 想找「包含數字、大寫英文字母」以外字元的字串
str_detect(string = c("SS501", "501", "apink", "Apink"), 
           pattern = "[^0-9A-Z]")
#> [1] FALSE FALSE  TRUE  TRUE
# 想找「包含數字、大寫A-R」以外字元的字串
str_detect(string = c("SS501", "501", "apink", "Apink"), 
           pattern = "[^0-9A-R]")
#> [1]  TRUE FALSE  TRUE  TRUE

有了[]這個武器後,我們後續可以結合其他特殊字元,提升正規表達式的豐富程度。

除了[]可以表示字元集,還有這幾個正規表達式中的逃脫字元,也有類似用法:\d, \D, \s, \S, \w, \W

  • \d 的意義和[0-9]相同
  • \D\d的反義,和[^0-9]相同
  • \w 能比對任意字母、數字或底線(undescroe),和[a-zA-Z0-9_]相同
  • \W\w的反義,和[^a-zA-Z0-9_]相同
  • \s 能比對任意空白字元,和[:space:]相同
  • \S\s的反義,和[^[:space:]]相同
str_detect(c("0", "\f", "\n", " "), "[:space:]")
#> [1] FALSE  TRUE  TRUE  TRUE
str_detect(c("0", "\f", "\n", " "), "[^[:space:]]")
#> [1]  TRUE FALSE FALSE FALSE

進階正規表達式語法

貪婪與非貪婪比對

在R語言中,正規表達式預設(default)會「貪婪」(greedy)地比對。這是什麼意思呢?

舉例來說:

str_extract("BTOATOBTOCTOB", "B.*B")
#> [1] "BTOATOBTOCTOB"

在想找尋的模式中,.代表「換行字元」以外任意字元,*則表示前面規則出現0或多次,結合起來代表我們想從字串中擷取「B開頭、B結尾」的一段字串。在貪婪模式下,配對時正規表達式不會滿足於擷取出"BTOATOB",它會貪婪地說「我全都要」!因此最後才會擷取到整串"BTOATOBTOCTOB"

要怎麼變得不貪婪(non-greedy)?只要在?*+{}等「量化符」(quantifiers)補上?之後,正規表達式就會知道只要找出「最短」部分的字串就好。

str_extract("BTOATOBTOCTOB", "B.*?B")
#> [1] "BTOATOB"

要注意的是,本來?在正規表達式中,就有零次或一次的意義,和貪婪/非貪婪使用的?無關,不要搞混囉。

# ? 用來代表零次或一次
str_detect(c("color", "colour"), 
           "colou?r")
#> [1] TRUE TRUE
# ? 用來變成非貪婪比對
str_extract(c("color", "colour", "colorcolor"), 
           "colo.*r")
#> [1] "color"      "colour"     "colorcolor"
str_extract(c("color", "colour", "colorcolor"), 
           "colo.*?r")
#> [1] "color"  "colour" "color"

瞻前顧後

前面提過許多正規表達式的規則,但它們都還沒辦法找到底下提的這種場景:

想像一下,我們想確認某段文字中,有沒有先提到「樂天桃猿」,接著提到「林子偉」。注意,如果是先提到「林子偉」後面才提到「樂天桃猿」就不算數!

為什麼會有這樣的需求?因為我想找林子偉已經在樂天桃猿打球的報導,這類報導應該會寫「樂天桃猿隊的林子偉」;如果是稍早一些的報導,可能會先提台鋼雄鷹隊的林子偉,接著才提到他被交易至樂天桃猿。

如果不管順序,我們可以這樣找:

# 敘述一
str_detect("報導:樂天桃猿隊的林子偉", "樂天桃猿") &amp; str_detect("報導:樂天桃猿隊的林子偉",  "林子偉")
#> [1] TRUE
# 敘述二
str_detect("報導:台鋼雄鷹隊的林子偉被交易至樂天桃猿隊", "樂天桃猿") &amp; str_detect("報導:台鋼雄鷹隊的林子偉被交易至樂天桃猿隊",  "林子偉")
#> [1] TRUE

上方寫法中,只要字串中同時包含兩者,就會代表比對正確;但如果有先後順序,我們就要利用進階語法,讓電腦知道敘述一符合需求、敘述二不符需求。

正規表達式中,前瞻(lookahead)和後顧(lookbehind)可以幫助我們滿足上述需求。補充一下,其實前瞻和後顧是對岸用語,但台灣說法不一、且沒有一個公約數,所以就先用搜尋結果最多的前瞻和後顧。

前瞻代表「向前看」,後顧則是「往回看」。因為正規表達式是從左到右比對,所以我們可以想像,從左邊往右邊走的時候,先探頭向前看未來發生什麼事情,這個動作就是「前瞻」,至於往回看後面有什麼東西,就是「後顧」了。

類型語法說明舉例
正向前瞻(?=...)確認某串字元在比對模式後,但不會將其納入結果。「樂天桃猿」後面「林子偉」
負向前瞻(?!...)確認某串字元不在比對模式後。「樂天桃猿」後面沒有「林子偉」
正向後顧(?<=...)確認某串字元在比對模式前,但不會將其納入結果。「林威助」前面「中信兄弟」
負向後顧(?<!...)確認某串字元不在比對模式前。「林威助」前面沒有「中信兄弟」

因此,利用「正向前瞻」,我們能找到確保「樂天桃猿」後有「林子偉」在的字串:

# 敘述一
str_detect("報導:樂天桃猿隊的林子偉先前在大聯盟打球", "樂天桃猿.*(?=林子偉)")
#> [1] TRUE
# 敘述二
str_detect("報導:台鋼雄鷹隊的林子偉被交易至樂天桃猿隊,他曾在大聯盟打球", "樂天桃猿.*(?=林子偉)")
#> [1] FALSE

敘述一中,當正規表達式比對到樂天桃猿時,它會往前看(往未來看)有沒有林子偉?發現有之後就安心了。敘述二中,因為樂天桃猿隊之後沒有林子偉,所以比對結果為FALSE

具體來說,前瞻比對的其實不是「樂天桃猿」和「林子偉」兩者,而是比對「有林子偉」在後面、往前看能看到的「樂天桃猿」。我們可以直接用str_extract(),能夠更好掌握概念。

str_extract("樂天桃猿隊新進球員林子偉先前在大聯盟打球", "樂天桃猿.*(?=林子偉)")
#> [1] "樂天桃猿隊新進球員"
str_extract("樂天桃猿隊新進球員林子偉先前在大聯盟打球", "樂天桃猿.*(?=球員)")
#> [1] "樂天桃猿隊新進"

其實談前瞻和後顧概念時,方向容易讓人混淆,但只要想著前瞻是往未來(因此看字串後面)、後顧是回頭(因此看字串前面),就能稍微理解。

既然有正向前瞻,當然也有負向前瞻,表格中有介紹語法為(?!...)。沿用上面的例子,這次我們特別指名,要找「台鋼雄鷹」,但如果後面、往前看會發現「林子偉」,那就不要。

# 敘述一:
str_detect("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹(?!.*林子偉)")
#> [1] FALSE
# 敘述二:
str_detect("報導:台鋼雄鷹隊的洪一中教練很知名", "台鋼雄鷹(?!.*林子偉)")
#> [1] TRUE
# 敘述三:
str_detect("報導:樂天桃猿交易來林子偉,台鋼雄鷹隊也獲得其他球員", "台鋼雄鷹(?!.*林子偉)")
#> [1] TRUE

詳細點說,"台鋼雄鷹(?!.*林子偉)"當中,首先確保要有「台鋼雄鷹」,接著括號中的?!.*林子偉,意思則是接在台鋼熊鷹後面配對到不管經過多少個字(.*),都不要(?!)接著林子偉(林子偉)。

你可能會有疑問,為什麼不能寫成"台鋼雄鷹.*(?!林子偉)"?這是因為,這個正規表達式會去找後面沒有林子偉接著的字串,而報導:台鋼雄鷹隊的林子偉先前在大聯盟打球整句就滿足上述規則,因為句中包含台鋼雄鷹,而且.*屬於貪婪比對,整個句子說起來滿足台鋼雄鷹.*,且後面沒有接著林子偉。我們可以額外用str_extract()確認,取出的是整個句子。

# 敘述一:
str_detect("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*(?!林子偉)")
#> [1] TRUE
str_extract("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*(?!林子偉)")
#> [1] "台鋼雄鷹隊的林子偉先前在大聯盟打球"

如果我們把"台鋼雄鷹.*(?!林子偉)"稍作修改,變成非貪婪的版本"台鋼雄鷹.*?(?!林子偉)",是否有所改善?遺憾的是,結果仍然相同。變成非貪婪後,雖然不會直接比對到整個句子,而是比對盡可能少的字元,這樣在比對到台鋼雄鷹四個字後,先是滿足正向出現的條件,接著看後面接的字為隊的林子偉...,沒有緊接著林子偉,因此滿足規則。我們可以額外用str_extract()確認,取出的是「台鋼雄鷹」四個字。

# 敘述一:
str_detect("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*?(?!林子偉)")
#> [1] TRUE
str_extract("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*?(?!林子偉)")
#> [1] "台鋼雄鷹"

介紹完正向與負向前瞻,接著我們來看後顧。

先從正向後顧介紹,它的意思就是走路走到一半回頭往後看,字串前面有沒有出現特定模式。

舉例來說,我們想找前面有「中信兄弟」的「林威助」。

# 敘述一
str_detect("報導:中信兄弟前總教練林威助淡出台灣棒球界", "(?&lt;=中信兄弟).*林威助")
#> [1] TRUE
# 敘述二
str_detect("報導:前阪神虎隊球星林威助加入中信兄弟", "(?&lt;=中信兄弟).*林威助")
#> [1] FALSE

至於負向後顧,我們使用相似例子,改找前面沒有「總教練」的「林威助」。

# 敘述一
str_detect("報導:中信兄弟前總教練林威助淡出台灣棒球界", "(?&lt;!中信兄弟.*)林威助")
#> Error in stri_detect_regex(string, pattern, negate = negate, opts_regex = opts(pattern)): Look-Behind pattern matches must have a bounded maximum length. (U_REGEX_LOOK_BEHIND_LIMIT, context=`(?&lt;!中信兄弟.*)林威助`)

沒想到馬上遇到 Error!根據錯誤訊息,R語言中的後顧需要限定長度。

# 敘述一
str_detect("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "(?&lt;!總教練.{1,10})林威助")
#> [1] FALSE
# 敘述二
str_detect("報導:林威助卸下總教練職務,轉任海外顧問", "(?&lt;!總教練.{1,10})林威助")
#> [1] TRUE
# 敘述三
str_detect("報導:林威助被球迷目睹出現在某間餐廳中孤獨地用餐", "(?&lt;!總教練.{1,10})林威助")
#> [1] TRUE
# 敘述四(超過10個字)
str_detect("報導:中信兄弟對總教練一二三四五六七八九十,林威助轉任海外顧問", "(?&lt;!總教練.{1,10})林威助")
#> [1] TRUE

在上面的正規表達式中,我限制總教練後面出現的字數到林威助之間,最多只能出現 10 個字,第四個案例中雖然林威助前面出現總教練,但因為之間相隔11個字,所以仍舊輸出TRUE

如果不想限制字長怎麼辦?我們可以換個想法改用負向前瞻。怎麼做呢?既然林威助前面不能有總教練(負向後顧),代表總教練後面不能有林威助(負向前瞻),同時我們仍要正面比對林威助,因此寫法也會分兩部分,第一部分是總教練後面不能有林威助,第二部分則是林威助。

# 敘述一
str_detect("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")
#> [1] FALSE
# 敘述二
str_detect("報導:林威助卸下總教練職務,轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")
#> [1] TRUE
# 敘述二
str_detect("報導:林威助被球迷目睹出現在某間餐廳中孤獨地用餐", "^(?!.*總教練.*林威助).*林威助")
#> [1] TRUE

# 敘述一
str_extract("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")
#> [1] NA
# 敘述二
str_extract("報導:林威助卸下總教練職務,轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")
#> [1] "報導:林威助"
# 敘述二
str_extract("報導:林威助被球迷目睹出現在某間餐廳中孤獨地用餐", "^(?!.*總教練.*林威助).*林威助")
#> [1] "報導:林威助"

"^(?!.*總教練.*林威助).*林威助"當中: * ^ 代表從開始位置檢查
* (?!.*總教練.*林威助)則是一個負向前瞻的語法,因此?!後面的內容代表「我不要」
* *總教練.*林威助的意思是總教練後面跟著林威助
* 負向前瞻區塊的意思就會是「總教練後面跟著林威助的我都不要」
* .*林威助代表前面有任意字元接著林威助

整體來說,意義就會是「總教練後面跟著林威助的我都不要」且接著林威助。不過你可能會想問,為什麼要多加一個^

# 有加
str_extract("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")
#> [1] NA
# 沒加
str_extract("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "(?!.*總教練.*林威助).*林威助")
#> [1] "教練動刀,林威助"

如果有加,電腦就會檢查從字串開始到結束,比對前面沒有總教練的林威助,因此會比對不到顯示NA,這符合我們的需求。沒加的話,電腦會從任意處開始尋找,當它在「總」字比對時,「總教練」後面的確緊跟著「林威助」的模式,因此比對失敗(FALSE),但比對不會在此終止,它會繼續嘗試在下一個位置進行比對。

直到比對到「總教練動刀,」的位置,負向前瞻斷言就成功了,因為從這個位置到字串結束,不包含「總教練」後面跟著「林威助」的模式。正規表達式會繼續比對「林威助」,因此回報「教練動刀,林威助」。「總教練動刀,」有總教練在前因此不行,但「教練動刀,」沒有總教練在前,它就是.*的具體比對內容。

一定要記得,在R語言裡面,前瞻沒有字長限制,後顧則有,因此不能使用*或者+,因此有時候要變招改用前瞻喔。

分組和捕獲

施工中

斷言

施工中

實際應用

驗證電子郵件地址

想找出符合規則的電子信箱,應該怎麼做?我們先來看Gmail的設定規則,裡面提到3個重點:

  1. 使用者名稱可以包含英文字母 (a-z)、數字 (0-9) 和半形句號 (.)。
  2. 使用者名稱不能包含 AND 符號 (&)、等號 (=)、底線 (_)、單引號 (’)、破折號 (-)、加號 (+)、半形逗號 (,)、角括號 (<、>) 或是連續使用半形句號 (.)。
  3. 使用者名稱的開頭和結尾皆可使用非英數字元,但不得使用半形句號 (.)。除此規則外,半形句號 (.) 對 Gmail 地址沒有影響。

除了參考Gmail規定以外,根據經驗,電子信箱的一般長相是OOXX@abc.com或是OOXX@abc.com.tw

其中.tw可有可無,且tw也可能是其他代號,例如kr。另外,也並非所有信箱都會用.com,還有其他用法如.net

不過,總會有些電子信箱的設計規則不符經驗,也跟Gmail設立規則不同。

因此,我們假定「正確的」電子信箱後綴可以不用是.com,同時還可能會有.tw這種後綴;同時,我們假定只能使用英文字母、數字、半形句號,不允許英文以外的字母,也不能用其他標點符號,@只能用1次。

就位置來說參考Gmail規則,不能連續使用半型句號,開頭和結尾都不能使用半型句號(不能有.ab@gmail.com也不能有ab.@gmail.com

確認需求後,要怎麼設計它的正規表達式呢?

# 想找出電子信箱地址
# "", "wrong.email@", "another_example@domain.net"
str_detect(string = c("example@example.com", 
                      ".a@example.com", 
                      "a.@example.com", 
                      "error@.", 
                      "example@example.rlover", 
                      "@example@example.rlover"), 
                pattern = "^[a-z0-9\\.]{1,}@[a-z]{1,}\\.[a-z]{1,}")
#> [1]  TRUE  TRUE  TRUE FALSE  TRUE FALSE
str_detect(string = c("example@example.com", 
                      ".a@example.com", 
                      "a.@example.com", 
                      "a..a@example.com", 
                      "error@.", 
                      "example@example.rlover", 
                      "@example@example.rlover"), 
                pattern = "^(?!\\.)(?!.*\\.\\.)[a-zA-Z0-9.]+(?&lt;!\\.)@[a-zA-Z]+(\\.[a-zA-Z]+)+$")
#> [1]  TRUE FALSE FALSE FALSE FALSE  TRUE FALSE

綽號比對

舉個綜合的例子,大家可能聽過,中華職棒有位明星球員乃耀·阿給(Ngayaw·Ake’,漢名林智勝),他有個「大師兄」的綽號。如果想比對字串中有沒有出現這個綽號,應該怎麼寫正規表達式?聽起來很簡單,只要寫「大師兄」就好對吧?

很遺憾,實際觀察社群媒體和論壇討論就會發現,球迷們不只叫他大師兄,也會簡稱師兄,所以我們應該比對「師兄」就好,但因為有位球員林智平的綽號叫「小師兄」,所以我們要排除小師兄;又有可能球迷在討論棒球界的師兄弟/妹關係,所以也不要「師兄弟/妹」。總結來說,我們要前面不要是「小」、後面不是「弟/妹」的師兄。

這完全就是前瞻和後顧派上用場的好時機!

搜索和替換操作

施工中

密碼強度檢查

施工中

網址搜尋

施工中

No Comments

Leave a Reply