正規表現マッチングの実装手法の1つとしてPike VMと呼ばれるものがあります。
これはGo言語の正規表現実装やRustのregex
crateで使われている手法であり、正規表現rと入力文字列wに対してO(∣r∣×∣w∣)の計算量でマッチングができるのが特徴です。
Earley法はJay Earleyの提案した文脈自由文法 (CFG) の構文解析手法の1つです。
すべてのCFGを構文解析できる手法で最悪計算量はO(∣w∣3)ですが、無曖昧であればO(∣w∣2)で、決定的であればO(∣w∣)で構文解析ができます。
実装してみると分かりますが、Pike VMとEarley法には類似している点があり、Earley法をPike VMの発展系のように考えることができます。
この記事ではPike VMとEarley法のRubyでの実装を通じて、それらの関係を解説します。
想定読者: 形式言語や構文解析についての基本的な知識がある (NFAやCFGなどを知っている) ことを想定しています。
Pike VM
Pike VMは正規表現マッチングの実装手法の1つです。
Rob Pikeがsam
というテキストエディターの開発の際に実装した正規表現実装のアイディアが元となっている1ため、この名前で呼ばれています。
正規表現は非決定性有限状態オートマトン (NFA) に変換されてマッチングが実行されるため、正規表現マッチングとは結局のところ、いかにしてNFAの非決定的な動作を模倣するかという問題になります。
NFAの非決定的な動作を模倣する方法は、大きく分けて2つの方法が知られています。
1つはバックトラックを用いて逐次的に非決定的な遷移を模倣する方法です。
これは多くの正規表現エンジン (PCREやOnigmoなど) で用いられている方法となっています。
もう1つは、非決定的な遷移をすべて同時に行う方法です。
これはバックトラックを用いません。
NFAからDFAへの変換をマッチングをしながら行なっていくような動作のため、On-the-fly構成と呼ばれます。
Pike VMはOn-the-fly構成に基づくマッチング手法となっています。
ここからはPike VMについて説明しながら、Pike VMをRubyで実装していきます。
Pike VMでのNFA
Pike VMはVMという名前の通りVMによってマッチングを行ないます。
そのため、正規表現はVMの命令列に変換されるのですが、実はそれらの命令はNFAの辺とラベルとして理解する方が分かりやすいです。
そこで、VMの命令をNFA風の図で説明します。
Pike VMの命令は4種類あります。
-
Eps(q′): 空文字列εでq′に遷移できる
-
Branch(q1,q2): 空文字列εでq1かq2に遷移できる
-
Char(σ,q′): 文字σでqに遷移できる
-
Match: 受理状態を表す
これらの命令のデータ型をRubyで定義します。
さらに、これらを使ったPike VMでのプログラム (NFA) も定義しておきます。
この定義で図のNFA (/a*/
に対応するNFA) を表現すると、次のコードのようになります。
正規表現からPike VMのNFAへの変換
Pike VMが正規表現マッチングを行なえることを確認するために、正規表現からPike VMへの変換を実装しておきます。
最初に、正規表現を表すデータ型を定義します。
この型を使って/a*|ab/
を表すには次のようにします。
この型からPike VMのNFAへの変換は次のようなコードで出来ます。
変換結果はこんな風になります。
Pike VMの実装
それではPike VMの実装をします。
最初に書いたとおり、Pike VMはOn-the-fly構築を用いてマッチングを行ないます。
具体的には、次のような流れになります。
- 0文字目のキューに初期状態を追加
- 現在の文字のキューから状態を読み込み、その状態の命令 (遷移) を実行
Eps[q]
: 現在の文字のキューにq
を追加
Branch[q1, q2]
: 現在の文字のキューにq1, q2
を追加
Char[a, q]
: 現在の文字がa
なら、次の文字のキューにq
を追加
- キューから読み込む状態が無くなるまで2を繰り返す
- 文字を進めて次のキューで2と3を繰り返す
- 最後のキューに命令が
Match
の状態が残っていたらマッチと判定
つまり、空文字列で遷移できる部分を可能な限り遷移して、Char命令による遷移で次の文字に移動する、というのを幅優先探索の要領で繰り返していきます。
これをコードにします。
このPikeVM
クラスにNFAとマッチングしたい文字列を渡して、run
メソッドを呼ぶとマッチングが実行されます。
このPikeVM
のマッチングの計算量が、正規表現rと入力文字列wに対してO(∣r∣×∣w∣)となる理由は次の通りです。
- NFAの状態数∣Q∣は正規表現の大きさ∣r∣に比例する。
- 各キューに追加されうる要素はたかだか∣Q∣種類なので
step
のeach
の繰り返し回数はO(∣Q∣)=O(∣r∣)。
run
の中で∣w∣+1回step
を呼び出すので、全体の計算量はO(∣Q∣×∣w∣)=O(∣r∣×∣w∣)。
というわけで、文字列の長さに線形に比例する計算量でマッチングが実装できることが分かりました。
Earley法
次はEarley法について説明します。
Earley法はJay Earleyが1968年に提案したCFGの解析手法の1つ2です。
解析の最悪計算量はO(∣w∣3)ですが、文法が無曖昧な場合はO(∣w∣2)で、決定的な場合はO(∣w∣)で解析できることが知られています。
さらに、少し方法を修正することで、任意のLR(k)文法についてO(∣w∣)で解析できることも知られています3。
実はEarley法は、Pike VMによるマッチングをProcedural Automatonに拡張したものと考えることができます。
Procedural AutomatonやPike VMをこれに対応するために拡張する方法について説明していきます。
Procedural Automaton
Procedural Automatonとは、「他のオートマトンを呼び出すことができる」オートマトンの拡張で、呼び出すオートマトンをすべてまとめた複数のオートマトンのまとまりをSystem of Procedural Automata (SPA)と呼びます45。
SPAはCFG全体を表現できる力があることが知られています。
例えば次の図のSPAは、"({()})({})"
のような(...)
と{...}
が交互に入れ子になって並んでいる場合にマッチするオートマトンとなっています。
Call(A,q′)というのがProcedural Automatonのために追加された新しい命令で、Aを呼び出して、マッチした場合にq′に遷移します。
例でもあるように、この呼び出しは再帰的に行なうことができ、それによってCFGを表現できます。
RubyでもCall
命令と、SPAを表すデータ型を定義しておきます。
このデータ型を使って、さきほどの例を表現すると次のようになります。
CFGからSPAへの変換
CFGの1つの生成規則を正規表現の文字の並び、複数の生成規則を|
による分岐と見做せば、CFGをオートマトンに変換することは難しくありません。
例えば、次のようなCFGの文法があったとします。
AABB→’(’ B ’)’→ε→’{’ A ’}’→ε
このときA→’(’ B ’)’に対応するオーマトンは次のような一直線のものになります。
そして、Aの2つの生成規則 (A→’(’ B ’)’ と A→ε) をまとめたものは次のようになります。
CFGの文法を表すデータ型をこのように定義します。
このCFGの文法からSPAへの変換は次のように実装できます。
Pike VMの拡張
Pike VMをCall命令に対応させるためには、まず、キューに追加する情報を状態だけでなく3つ組[name, q, start_pos]
に修正する必要があります。
これらはそれぞれ、次のような値になります。
name
: 現在呼び出されているオートマトンの名前
q
: 現在のオートマトンの状態
start_pos
: このオートマトンを呼び出しはじめた文字列上の位置
name
とq
は命令を読み込むために必要です。
start_pos
は、これがあることによってオートマトンにマッチしたときに呼び出し元に戻って、次の状態に行けます。
このように拡張したPike VMをEarleyVM
という名前で実装します。
EarleyVM
の使用例は次のようになります。
括弧のネストを正しく解釈できていることが分かります。
あとがき
というわけでPike VMとEarley法について解説しました。
NFAの非決定性を同時に遷移してシミュレートしていく方法の延長線でCFGを構文解析する、という考えがEarley法の発端だと思われます。
そのため、実装という観点から見るとEarley法がPike VMの拡張になるのも道理でしょう。
今回のPike VMの実装はEarley法の解説にスムーズにつなげるため、よくある実装とは少し異なった形になっています。
まず、すべての文字列上の位置のキューを保存していますが、Pike VMであれば現在のキューと次のキューの2つだけで十分です。
また、空文字列で遷移できる部分を一度にすべて遷移することで、キューに追加するのはChar命令とMatch命令の状態だけで十分になります。
Earley法は、計算量的にはLR法にも負けず劣らずのアルゴリズムなのですが、実際に実装してみると途中に生成する (キューに追加する) オブジェクトの生成がボトルネックになって、想像よりも遅くなりがちです。
また、今回は実装しませんでしたが、実際に利用するためには構文解析後に構文木を構築する必要があります。
そのためのデータ構造など、工夫できる点は色々あるので、挑戦してみると面白いと思います。
最後まで目を通していただきありがとうございました。