覚え書き: 先読み・後読みを含む正規表現の数学的定式化
いかにも既知な気がしますが、ぱっと既存の議論が探せないのでとりあえず書き出してみます。
※用語は独自のものです。また、証明・定義は厳密に検証しているわけではないので誤りがあるかもしれません。
この定義でカバーできること
- 先頭
^
- 末尾
$
- 肯定先読み
(?= ... )
- 否定先読み
(?! ... )
- 肯定後読み
(?<= ... )
- 否定後読み
(?<! ... )
この定義でカバーしないこと
- 選択の左右の優先度 (
r1|r2
vs.r2|r1
) - 最長優先と最短優先の区別 (
r*
vs.r*?
)
基本の正規表現
基本の正規表現は通常以下のように定義されます。
- 文字リテラル
(a は文字a )a \in \Sigma - 空文字列
と連接\epsilon (R_1R_2 ,R_1 は正規表現)R_2 - 空集合
と選択\emptyset (R_1 | R_2 ,R_1 は正規表現)R_2 - 繰り返し
(R_1\text{*} は正規表現)R_1
また具象構文レベルでは演算子のグルーピングのために括弧
派生規則 (既存の構文の組み合わせとして書けるルール) として以下のようなものがあります。
- 文字クラス …… 文字リテラルの選択として書ける。
- 回数指定の繰り返し (
,R_1\text{?} など) ... 連接、選択、繰り返しの組み合わせで書ける。R_1\text{+} - 空文字列は通常プリミティブに含めるが、空集合の繰り返しとして書けるので省略することも可能。
また、容認規則 (正規表現を分解・再構築する演算として書けるルール) として以下のようなものがあります。
- 否定 (
) …… DFAに翻訳し、受理状態を逆転させてからFloyd-Warshallで正規表現を再構築する。\text{!}R_1 - 共通部分 (
) …… NFAに翻訳し、状態集合の直積を作る。両方が受理状態であるときに受理と定義し、Floyd-Warshallで正規表現を再構築する。R_1 \text{\&} R_2
基本の正規表現は、ある文字列 (
文脈つき正規表現
先読みや後読みを解釈するには、文字列の3つ組
言い換えると、これは全体文字列
まず基本の正規表現をこの形に拡張します。
- リテラル:
((l, s, r) \mathop{=\sim} a )a \in \Sigma \iff s = a - 空文字列:
(l, s, r) \mathop{=\sim} \epsilon \iff s = \epsilon - 連接:
(l, s, r) \mathop{=\sim} R_1R_2 次を満たす\iff ,s_1 が存在:s_2 ,s = s_1s_2 かつ(l, s_1, s_2r) \mathop{=\sim} R_1 (ls_1, s_2, r) \mathop{=\sim} R_2 - 空集合: 常に
(l, s, r) \not\mathop{=\sim} \emptyset - 選択:
(l, s, r) \mathop{=\sim} R_1|R_2 \iff または(l, s, r) \mathop{=\sim} R_1 (l, s, r) \mathop{=\sim} R_2 - 繰り返し:
(l, s, r) \mathop{=\sim} R_1\text{*} 次を満たす\iff (0を含む) およびn \in \mathbb{N} ,s_1 , ...s_2 が存在:s_n かつ、任意のs = s_1s_2\ldots s_n ,i \in \mathbb{N} に対して1 \leq i \leq n (ls_1s_2\ldots s_{i-1}, s_i, s_{i+1}s_{i+2}\ldots s_nr) \mathop{=\sim} R_1
すると、これらの基本の構成のみからなる正規表現は
これらを導入したのは、新たにアサーションを含む正規表現の意味論を定義するためでした。これは以下のように定義されます。
- 開始:
(l, s, r) \mathop{=\sim} ^ \iff かつl = \epsilon s = \epsilon - 終了:
(l, s, r) \mathop{=\sim} $ \iff かつs = \epsilon r = \epsilon - 肯定先読み:
(l, s, r) \mathop{=\sim} (\text{?=}R_1) \iff かつ、次を満たすs = \epsilon が存在:r_1, r_2 かつr = r_1r_2 (l, r_1, r_2) \mathop{=\sim} R_1 - 否定先読み:
(l, s, r) \mathop{=\sim} (\text{?!}R_1) \iff かつ、任意のs = \epsilon に関して、r_1, r_2 ならばr = r_1r_2 (l, r_1, r_2) \not\mathop{=\sim} R_1 - 肯定後読み:
(l, s, r) \mathop{=\sim} (\text{?<=}R_1) \iff かつ、次を満たすs = \epsilon が存在:l_1, l_2 かつl = l_1l_2 (l_1, l_2, r) \mathop{=\sim} R_1 - 否定後読み:
(l, s, r) \mathop{=\sim} (\text{?<=}R_1) \iff かつ、任意のs = \epsilon に関して、l_1, l_2 ならばl = l_1l_2 (l_1, l_2, r) \not\mathop{=\sim} R_1
なお、この中では否定先読みと否定後読みがあれば他のものは再現できます。
- 開始:
(?<!.)
(.
は全ての文字にマッチする正規表現とする) - 終了:
(?!.)
- 肯定先読み:
(?!(?! ... ))
- 肯定後読み:
(?<!(?<! ... ))
またプログラミングで出てくる正規表現では空集合リテラル (?!)
などと書けることが多いです。空集合はれっきとした正規言語であるため、これが書けない場合は理論的には正規表現に期待される表現力を持たないことになります。
その他、行境界 (言語によっては ^
や $
がこの意味になる)、単語境界 (\b
, \<
, \>
) なども上記のアサーションプリミティブの組み合わせで書けます。
正規言語
正規表現で表される言語は正規言語として様々な方法で特徴づけられるので、文脈つき正規表現で表される文脈つき言語にも特徴づけを考えたくなります。ここでは以下のように定義してみます。
定義1.1 (文脈つき正規言語)
この定義が正規表現側の性質と一致することはあとで確認します。
DFA, NFA
上で正規言語を定義するにあたって
ただし、
- 状態集合を3つに分割し、
(Q = Q_0 \cup Q_1 \cup Q_2 ,Q_0 ,Q_1 は互いに共通部分を持たない) とする。Q_2 - 受理状態は
に限定する。F \subseteq Q_2 -
以外の文字を入力したときの遷移先は同じ集合にとどまる。 (↓ からはQ_i にのみ遷移する)Q_i -
からQ_0 を入力したときの遷移先は↓ に限る。Q_1 -
からQ_1 を入力したときの遷移先は↓ に限る。Q_2 -
からQ_2 を入力したときの遷移は定義しない。↓
この改良版のDFA・NFAとオリジナルのDFA・NFAには適当な対応関係を考えることができます。ただし、どんなsuffixも受理しない状態の扱いには以下のような注意が必要です。
- オリジナルの定義では、そのような状態があれば
を入力しても自分自身に遷移することが可能だが、改良版では↓ ,Q_0 ,Q_1 で階層化されているため定義を分ける必要がある。Q_2 - オリジナルの定義では、
が多すぎる文字列の対応のためにそのような状態を残しておく必要があるが、改良版では定義しなくても問題ない。↓
正規表現からNFAへ
定理1.2 (正規表現の解釈の正規性) 文脈つき正規表現があらわす文脈つき言語は正規である。
証明. 正規表現の構造にそって、対応するε-NFAを構成する。ここでは多数ある場合分けのうちいくつかのみ示す。
連接
- 左文脈用の状態
-
Q_0 \times Q'_0 - 次のステップへの遷移:
が入力されたら↓ のオートマトンにR_1 を入力して遷移する↓
- 次のステップへの遷移:
-
- マッチ範囲内の状態
-
Q_1 \times Q'_0 - 次のステップへの遷移:
が入力されたら\epsilon ,R_1 の両方のオートマトンにR_2 を入力して遷移する↓
- 次のステップへの遷移:
-
Q_2 \times Q'_1 - 次のステップへの遷移:
が入力されたら↓ のオートマトンにR_2 を入力して遷移する↓
- 次のステップへの遷移:
-
- 右文脈用の状態
Q_2 \times Q'_2
選択
繰り返し
- 左文脈用の状態
- 状態A:
Q_0 - 次のステップへの遷移:
が入力されたら、↓ を保存して状態Bに移る。このとき冪集合Q_0 部分には\mathcal{P}(Q_2) を保存する。\emptyset
- 次のステップへの遷移:
- 状態A:
- マッチ範囲内の状態
- 状態B:
(内部遷移を持たない)Q_0 \times \mathcal{P}(Q_2) - 次のステップへの遷移:
が入力されたら、元の状態を保存しつつ\epsilon を入力した結果も得て状態Cに遷移する。↓ - または、状態Dに遷移する。
- 次のステップへの遷移:
- 状態C:
Q_0 \times \mathcal{P}(Q_2) \times Q_1 - 次のステップへの遷移:
が入力されたら、\epsilon 部分にQ_1 を入力して↓ の状態を得、Q_2 に追加して状態Bに戻る。\mathcal{P}(Q_2)
- 次のステップへの遷移:
- 状態D:
\mathcal{P}(Q_2)
- 状態B:
肯定先読み
- 左文脈用の状態
-
Q_0 -
が入力されたら、内部のオートマトンにも↓ を入力して次の状態に遷移する。↓
-
-
- マッチ範囲内の状態
-
(内部遷移を持たない)Q_1 -
が入力されたら、そのまま次の状態に遷移する。↓
-
-
- 右文脈用の状態
-
Q_1 -
が入力されたら、内部のオートマトンに\epsilon を入力して次の状態に遷移する。↓
-
Q_2
-
NFAから正規表現へ
定理 1.3 (正規表現の表現力) 全ての文脈つき正規言語は文脈つき正規表現であらわせる。
証明. NFAの
始状態,
パーティションに基づいた正規性
ここまでの議論ではカーソルに
まず集合の分割の正規性を考えます。
定義1.4 (集合の分割) 集合
定義1.5 (正規な分割)
すると文脈つき言語の正規性は以下のように言い換えられます。
定理1.6 (パーティションに基づいた正規性)
証明. (
\Sigma^* / \{ (l_1, l_2) \in \Sigma^* \mid \forall s, r \in \Sigma^*.\ (l_1, s, r) \in L \Leftrightarrow (l_2, s, r) \in L \} \Sigma^* / \{ (s_1, s_2) \in \Sigma^* \mid \forall l, r \in \Sigma^*.\ (l, s_1, r) \in L \Leftrightarrow (l, s_2, r) \in L \} \Sigma^* / \{ (r_1, r_2) \in \Sigma^* \mid \forall l, s \in \Sigma^*.\ (l, s, r_1) \in L \Leftrightarrow (l, s, r_1) \in L \}
また、これらの元は全て正規である。 (syntactic monoidの特定の部分集合の引き戻しとして表せるため)
これらを
(
- 第1段階:
の状態がそのまま入っている。A_1 - 第2段階:
の状態A_2 \times だけの状態が入っている。P_1 - 第3段階:
の状態A_3 \times \timesP_1 だけの状態が入っている。P_2
これらの状態を繋ぎ合わせて
実際の検索関数との関係
ここで定義したのは、文字列中の始点と終点を指定したときに、それがマッチするかどうかという条件です。
function matchRange(r: RegExp, s: string, start: number, end: number): boolean;
実際の正規表現ライブラリではこの形でAPIが提供されていることは稀で、さらに以下のような構成を経たものが使われます。
- 始点から終点を決定する処理 (終点を決定しなくてよい)
- 始点を決定する処理 (始点も決定しなくてよい)
たとえばJavaScriptの場合、1の構成を経たAPIとして「sticky search有効時のexec」があります。これはlastIndexで指定した始点からのみ探索を行うものです。
さらに、2の構成を行ったAPIとしては以下のようなものがあります。
- 通常のexec (一番手前の有効な始点を返す)
- global search有効時のexec (lastIndexで指定した位置以降で、一番手前の有効な始点を返す)
- global searchやsticky searchが有効な場合のmatchAllやreplaceAll (直前の検索結果の終点をもとに、次の始点の制約を決定する)
今回の定式化では、この「始点から終点を決定する処理」を記述するには不十分です。バックトラックベースの正規表現エンジンでは、分岐の優先度に依存して挙動が変わりますが、今回定義したモデルではこの情報を捨ててしまっているからです。たとえば a|ab
と ab|a
は挙動が違いますし、 a+
と a+?
も挙動が違いますが、このことを説明できるモデルになっていません。
また、今回定義したモデルだけでは \0
以外のキャプチャグループの振る舞いも表現できません。このあたりを定義するにはさらなる工夫が必要です。
Discussion