JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

書寫模式:三種運算

有限自動機是你拿來運行的機器;正規表示式則是你動手書寫的模式。只用三個微小的基本情況,再加上三種運算——聯集、串接與 Kleene 星號——你就能為任何一個正規語言取一個名字。

從你運行的機器,到你書寫的模式

到目前為止,這座階梯談的都是你拿來運行的機器:像旋轉閘門、只記得目前狀態的 DFA,以及會把自己分身、同時試遍每條路的 NFA。你餵進一個字串,機器運轉一番,最後說接受或拒絕。正規表示式則是同一具望遠鏡的另一端。它不是一台用來判斷成員資格的裝置,而是一段精簡的記號——你寫下的一個模式,直接描述一個正規語言

把這個差別想成食譜對上廚師。DFA 是廚師:把一道菜交給它,它告訴你這道菜是否合乎規則。正規表示式則是食譜:一道寫下來的公式,恰好生成所有被允許的菜色。到這一階梯結束時,我們會證明這兩種觀點其實是同一回事(Kleene 定理);但在這第一篇裡,我們只先學會食譜的語言。令人驚訝的是,這套語言只需要寥寥幾個零件:三個基本情況與三種運算。

三個基本情況

正規表示式是從最小可能的模式開始,再層層組合而成。起步用的磚塊是基本情況,恰好有三個。第一,對字母表 Sigma(Σ,允許使用的字母所成的集合)裡的每個符號 a,寫成 a 的表示式代表只含一個字串的語言 {a}——就是單一字母的字串 a,別無其他。這些原子,就是所有模式最終落底之處。

另外兩個基本情況是特殊常數。符號 epsilon(ε,空字串)本身就是一個正規表示式,它代表語言 {epsilon}——只含一個字串、也就是長度為零那個字串的語言。而代表空集合的符號則表示空語言 { }——一個完全不含任何字串的語言。初學者老是把後面這兩個搞混,所以請把差別釘牢:{epsilon} 是一個非空的語言,碰巧裝著那個字串(一個成員);而 { } 什麼都不裝(零個成員)。一個是裝著一張白紙的盒子,另一個是空盒子。

三種運算

給定兩個正規表示式 R 與 S,三種運算把它們黏成更大的表示式。第一個是聯集,寫成 R + S(有些書寫作 R | S)。它的意思是「一個符合 R 符合 S 的字串」——即兩個語言的集合聯集。所以在 Sigma = {a, b} 上,模式 a + b 代表 {a, b}:可以配一個 a,也可以配一個 b。聯集就是「在這些選項中擇一」的招式,正是你早已熟悉的那個集合運算。

第二個是串接,只要把模式並排寫在一起就是了:R S(有時寫作 R · S)。它的意思是「一個符合 R 的字串,緊接著 一個符合 S 的字串」——把一個 R 字串和一個 S 字串首尾相黏。所以 a b 代表 {ab}:先一個 a,再一個 b。這就是你在基礎階梯認識過的那個把字串相黏的串接,只是從單一字串提升到了整個語言:它把每一個 R 字串和每一個 S 字串配對。第三個運算是星號,值得獨立成一段。

第三個、也是最強的,是 Kleene 星號,寫成 R*(單一個星號)。R* 的意思是「零個或多個 R 字串串接在一起」。所以 a* 代表 {epsilon, a, aa, aaa, ...}——任意數量的 a,包括一個都沒有。正因為「包括一個都沒有」,epsilon 永遠落在 R* 裡:零份 R 就是空字串。星號是唯一能從有限模式製造出無限語言的運算,也正是正規表示式能在不需要寫下無限多東西的情況下描述無限重複的原因。

優先級、括號,以及 plus 簡寫

由於我們把這些運算寫成一行內聯,就需要一條規則來決定誰結合得最緊——就像算術裡 a + b * c 的意思是 a + (b * c),而不是 (a + b) * c。正規表示式的標準運算子優先級,由緊到鬆是:先星號,再串接,最後聯集。 所以 a b* 會解析成 a (b*)——一個 a 後接任意數量的 b——而不是 (a b)*。同樣地,a + b c 會解析成 a + (b c)。當這個預設不是你要的時候,就用括號覆寫它,跟代數裡一模一樣:(a + b)* 真的是「任何由 a 和 b 組成的字串」,跟 a + b* 是截然不同的語言。

Same symbols, different parse trees -- precedence matters:

  a b*            =  a (b*)          { a, ab, abb, abbb, ... }
  (a b)*          =  (a b)*          { epsilon, ab, abab, ababab, ... }

  a + b c         =  a + (b c)       { a, bc }
  (a + b) c       =  (a + b) c       { ac, bc }

  a + b*          =  a + (b*)        { a, epsilon, b, bb, bbb, ... }
  (a + b)*        =  (a + b)*        all strings over {a, b}, incl. epsilon

Precedence, tightest -> loosest:   star  >  concatenation  >  union
從真實例子讀出優先級規則。注意 (a + b)* 是那個百無禁忌的整個語言,而 a + b* 是小得多的東西。

有一個方便的簡寫到處都看得到:plus 運算子 R+,意思是「一個或多個 R 字串」(星號但排除空的情況)。它純粹是方便用的,不是第四種基本運算——依定義,R+ 恰好就是 R R*,一個 R 後接零個或多個 R。同樣地,人們會寫 R? 表示「可有可無」(也就是 R + epsilon)。知道這些只是縮寫,能讓核心保持誠實:整套形式體系仍然只立基於聯集、串接與星號。

建構真實的模式,以及幾條代數恆等式

我們來真的寫一個模式,描述一個熟悉的語言:Sigma = {a, b} 上a 的個數為偶數的所有字串。在 DFA 階梯你為這件事建過一台兩狀態機器;這裡是它的食譜。a 的個數為偶數,意味著 a 成雙成對出現,而任意的 b 可以隨意撒在各處。表示式 b* (a b* a b*)* 捕捉了它:先放任意一段 b,再把「一個 a、一些 b、再一個 a、一些 b」重複你想要的次數(重複零次就只剩 b*)。外層星號的每一圈恰好加入兩個 a,所以總數永遠保持偶數。

因為模式是帶有意義的語法,兩個看起來不同的表示式,可以代表同一個語言——而判定它們何時相同的規則,就是代數恆等式。有些很直覺:聯集滿足交換律(R + S = S + R)與冪等律(R + R = R),而空集合常數是聯集的單位元(R + { } = R)。串接對聯集滿足分配律,R (S + T) = R S + R T,就像乘法對加法那樣。而空字串是串接的單位元:R epsilon = epsilon R = R。

星號有它自己幾條值得停下來想一想的古怪定律。(R*)* = R*:對已經加過星號的東西再加星號,什麼也沒多——因為「(零個或多個 R)的零個或多個」仍然就是「零個或多個 R」。另外 { }* = epsilon 而 epsilon* = epsilon——對「什麼都沒有」或「空字串」加星號,兩者都塌縮成空字串。這些恆等式不是無聊的瑣碎知識:它們是把一個冗長表示式化簡成整潔表示式的日常工具,而當我們在這一階梯稍後開始把機器轉回正規表示式時,會非常倚重它們。

這條路要通往哪裡

你現在握有正規表示式的整套字母表:三個基本情況(一個符號、epsilon、空集合)、三種運算(聯集、串接、星號)、一條優先級規則,以及那些方便的簡寫。本階梯接下來的指南會用它做三件大事。第二篇精確釘下一個模式代表哪個語言。第三篇證明那個招牌結果——Kleene 定理——正規表示式、DFA 與 NFA 描述的恰好是同一類,也就是正規語言;這正是為什麼你先前看到的自動機模型之間的等價,也延伸到了記號上。

第四、五篇接著讓那個定理變得可建構實用:如何機械地把一個模式變成自動機(Thompson 構造法),又如何把自動機變回模式(狀態消去法),以及最後,你文字編輯器裡那個「regex」和這個乾淨的數學物件有什麼關係。要帶往那裡的一句公允警告:工程上的 regex 引擎加進了像反向參照(backreference)與前瞻(lookahead)之類的功能,這些超出了數學物件的範圍——它們可以匹配非正規的語言,也可能在看似無害的輸入上爆炸成災難性的運行時間。你剛學到的純形式體系絕不會這樣。把乾淨的理論和雜亂的工具在腦中清楚分開,本階梯接下來的內容就會顯得理所當然。