オートマトン(DFA)での桁DPを理解する
まとめ
文字列の識別を DFA として表現することで、複数の条件や状態遷移を分けて記述し、これらの組み合わせで桁DPを構成するフレームワークが出来ました!
いかがでしたか?
参考文献は以下の通りです。ありがとうございました。
オートマトン上の DP (桁 DP の一般化) - kuretchi’s blog
数学が多少出てきますが、面倒であれば実装&問題例に飛んでも問題ありません。何をやりたいかは十分掴めると思います。
追記(ツイートの記録)
ちょっと考え事をして、昨晩のDFA記事を実装例以外爆破しようという気持ちになりました
— しの (@shino_skycrew) October 3, 2020
今日明日本当に暇がないので、明後日ごろガッサリ書き換えます
忘れないうちに
— しの (@shino_skycrew) October 3, 2020
・特別に切り出したものだけをDFAと呼ぶのではなく、配列上でループする基本的な種のDPはオートマトンを動かしていると見れそう
・桁DPがたまに大変になるのは、複数の条件を同時に捌くので分岐が増えたり、状態定義があまり自然でない(個人の感想)から
・桁DPをメモ化再帰で書くとありがたみが大きいのは、よくわからない状態をあまり考えず、部分問題への帰着を記述すればよいから
— しの (@shino_skycrew) October 3, 2020
・DFAの合成で書くありがたみは、再利用できること、状態遷移を見やすい形で記述できてそれで十分なこと
実際にDFAの最小化を実装して、数え上げテクニック集の「DPの状態をまとめる」がどんな感じになるか見てみたい
— しの (@shino_skycrew) October 6, 2020
桁DPをわざわざDFAで書く利点無くない?って考えてたら、optさんの記事のと全く同じものになってしまった
— しの (@shino_skycrew) October 16, 2020
ループの内側で状態をそれぞれ遷移させて、acceptしないものはcontinueで枝刈りするというだけのことで、特別なものは特になさそうですか
— しの (@shino_skycrew) October 16, 2020
状態の最小化とかは置いておくとすると
— しの (@shino_skycrew) October 16, 2020
バグらない、頻出の DFA を毎回書かなくていい、とかになるんじゃないでしょうか
— 熨斗袋 (@noshi91) October 16, 2020
文字列
有限個の記号集合 $\Sigma$ をアルファベットとし、$\Sigma$ の要素を並べたものを文字列(または語)とします。
長さ $n$ の文字列全体の集合を $\Sigma^n$ とします。
任意の長さの文字列全体の集合を $\Sigma^* = \Sigma^0 \cup \Sigma^1 \cup \Sigma^2 \cup \cdots$ とします。
有限オートマトン
有限個の状態と遷移と動作の組み合わせからなる数学的に抽象化された「ふるまいのモデル」である。
文字列から文字を $1$ 文字ずつ読み取りながら、内部状態を遷移させていく機構。
有限オートマトンには、次のような種類があります。
- 決定的有限オートマトン(DFA)
- 非決定的有限オートマトン(NFA)
- $\varepsilon$動作を含む非決定的有限オートマトン($\varepsilon$-NFA)
ここでは DFA のみを扱います。
決定性有限オートマトン(DFA)
決定性有限オートマトン(Deterministic Finite Automaton)は、現在の内部状態と受け取った文字によって次の状態が一意に定まるような有限オートマトン。
最終的な内部状態に応じて、入力文字列を受理(accept)するかどうかを決定します(文字列の識別)。
形式的な定義は次の通りです。
$M = (Q, \Sigma, \delta, q_0, F)$ であって、以下の性質を満たすものを DFA とする。
$Q$:有限集合(状態集合)
$\Sigma$:有限集合(文字集合)
$\delta : Q \times \Sigma \rightarrow Q$(遷移関数)
$q_0 \in Q$(初期状態)
$F \subset Q$(受理状態の集合)
文字列 $a = a_0a_1\cdots a_{n-1}$ が与えられたとき、各 $i=0,\ldots,n-1$ について帰納的に
$$ q_{i+1} = \delta(q_i, a_i) $$
とおく。$q_n \in F$ であることを、$A$ が文字列 $a$ を受理するという。
見た目が怖いようですが、読んでみるとそんなに怖くない。数学が苦しければ、下の実装&問題例にある Dfa
トレイトを参照してください。$M$ とは machine の頭文字であると思われます(たぶん)
また、$M$ という DFA が受理する文字列全体を、$M$ によって受理される言語 $L(M)$ といいます。
やってみよう(桁DPっぽいやつ)
次のような言語 $L_{3mul}$ を考えます。
$$ L_{3mul} = \{ x \in \{0, 1\}^* \mid x \text{は $3$ の倍数の$2$進数表示}\} $$
これを受理する DFA 、$M_{3mul} = (Q, \Sigma, \delta, q_0, F)$ を構成しましょう。
ただし空文字列 $\varepsilon$ は $0$ に対応すると考えて $\varepsilon \in L_{3mul}$ とします。
状態集合、文字集合、受理状態の集合をそれぞれ次のように定義します。
$$ \begin{aligned} Q &= \{q_0, q_1, q_2\} \\ \Sigma &= \{0, 1\} \\ F &= \{q_0\} \end{aligned} $$
状態 $q_i$ は $x \equiv i \pmod 3$ であることを表しています。
遷移関数 $\delta$ は、次の遷移表の通りになります。
input | $0$ | $1$ |
---|---|---|
$q_0$ | $q_0$ | $q_1$ |
$q_1$ | $q_1$ | $q_2$ |
$q_2$ | $q_2$ | $q_0$ |
こうして $L_{3mul}$ を受理する DFA $M_{3mul}$ が構成されました。
3の倍数判定機の完成です。
同様に、「$L$ 以下」や「いずれかの桁に $3$ を含む」といったさまざまな識別を行うDFAを、ループで行う基本的な桁DPと全く同じ要領で構成できます。$DP[i][\mathrm{state}]$ として書いていたところ、$\mathrm{state}$ や $i$ を $Q$ として持つということです。
それでは桁DPを書く上で DFA はどのように役に立つのでしょうか。
DFA から DFA を構成
ある文字集合 $\Sigma$ 上の $2$ つの DFA、$M_1 = (Q_1, \Sigma, \delta_1, q_{10}, F_1)$ と $M_2 = (Q_2, \Sigma, \delta_2, q_{20}, F_2)$ が与えられたとします。
この $2$ つがどちらも受理する文字列を、その場合に限り受理する DFA $M = (Q, \Sigma, \delta, q_0, F)$ を構成してみます。$M_1$ “かつ” $M_2$ のイメージです。
$$ \begin{aligned} Q &= Q_1 \times Q_2 \\ \delta((q_{1i}, q_{2i}), a_i) &= (\delta_1(q_{1i}, a_i), \delta_2(q_{2i}, a_i)) \\ q_0 &= (q_{10}, q_{20}) \\ F &= F_1 \cap F_2 \end{aligned} $$
$M$ が $M_1,M_2$ を保持していて、それぞれに同じ文字列を与え、どちらも受理されたら $M$ も受理する、というような形です。
ここで、たとえば $\Sigma = \{0,1,\ldots, 9\}$ として、$M_1$ は与えられた文字列が $3$ の倍数であるかを識別し、$M_2$ は $L$ 以下であるかどうかを判定するとしましょう。
これらから構成された $M$ は、$L$ 以下の $3$ の倍数を受理します。
いったんこれらを書いておけば、桁DPが出るたびに tight や何やを管理したり複雑な条件分岐を書いたりせず、適当な DFA を組み合わせるだけでよくなります!
実装&問題例
ここから、簡単な要素の組み合わせで複雑な判定や数え上げが行えることを示したいです。DFAを定義しながら、AtCoder の問題を $3$ つ解いていきます。
DFA が行うべき振る舞いを表す DFA
トレイトを定義します。Rustです。trait
は Java の interface
のようなやつです。
|
|
next
の第三引数 i
は、文字 a
が文字列中の何番目の文字であるかを表しています。本来はこれを State
に持たせて next
のたびにインクリメントすればいいんですが、$2$ つの場所で i
を管理するのはなんかなあと思ったのと、場合によって多少速くなります。
successful
と unsuccessful
は枝刈り用の関数で、実装はオプショナルです。Option
などのイケてる構造体を使わないのはうーんという感じなんですが、扱いやすいと思いました。
次に、DFA が受理する長さ $n$ の文字列を数える関数を定義します。いわゆる「配るDP」です。
|
|
HashMap
で 状態 => 個数
を管理しています。配列の添え字に状態を持たせた方が速いのはそうなんですが、往々にして状態数はそこまで大きくならないですしというところです。
BTreeMap
でもよいですが、状態数が増えたときに HashMap
の方が速い気がしました。気のせいという説もあります。
また今回は個数を持ちまわしていますが、これは何かしらの構造に一般化できます。kuretchiさんのブログもご参照ください。個数の代わりに総和 mod M
を求める例もあります。
問題に移ります。
TDPC-E 数
$N$ 以下の正整数であって、十進法表記したときの各桁の数の和が $D$ の倍数であるものの個数を $\mathrm{mod} 1,000,000,007$ で求めよ。
まず、$N$ 以下を表現する DFA を実装します。$N$ は上界が $10^{10000}$ と大きいので、文字列(バイト列)(への参照)として持たせます。
|
|
tight などのフラグ変数の代わりに、Ordering
によって状態が自然に表現されたことは驚きでした。これは $N$ と入力文字列を 0
から i
桁目までで比較した結果を表しています。
x.then(y)
は $2$ つの比較結果 x
、y
を結合します。x
優先であり、x != Ordering::Equal
ならば x
を、そうでない場合は y
を返します。
次は、桁和が $D$ の倍数であることを表す DFA です。状態は毎回 $\mathrm{mod}$ を取ります。
|
|
受け取る文字列がこれらの双方に受理されることが必要十分です。
|
|
以上は一度書いたら使い回せるものたちです。桁DPでよく出てくる要素だと思います。
煩雑なようですが、かなりの部分を関数のシグネチャが占めており、これは一瞬で補完してくれます(rust-analyzer
に感謝)
あとは And(Leq(N), DigitSumMultipleOf(D))
が受理する文字列を count
して終了です。0
を除外するため、結果から 1
を引いておきます。
|
|
提出 257 ms, 2.2MB
案外、十分な速度になりました。
JOI2012yo-F ジグザグ数
(要約)
各桁の数が「…→増加→減少→増加→減少→…」となっている正の整数を「ジグザグ数」と呼ぶ。
ジグザグ数の例)$2947$、$71946$
ジグザグ数でない例)$123$、$71446$
なお、$1$ 桁の正の整数はジグザグ数であるとする。
$A$ 以上 $B$ 以下の $M$ の倍数であるジグザグ数の個数を $\mathrm{mod} 10000$ で求めよ。
x >= A
は !(x < A)
と表せるので、Not
と Lt
を実装します。後者は Leq
とほとんど同じです。
|
|
|
|
次に、$M$ の倍数 DFA です。DigitSumMultipleOf
とほとんど同じです。
|
|
完成です。
|
|
a
が整数型に収まらないときの 0
詰めで、うまいやり方があれば教えてください。
提出 1566 ms, 3.0 MB
JOI ということもあってか、状態数が多いです。TL(10 sec)に対しては余裕ですが、他の提出と比べるといくらか遅めのようです。
ABC138-F Coincidence
解説の通り、これは次の問題に帰着されます。
次の条件を満たす整数の組 $(x, y)$ の個数を求めよ。
- $x \geq L$
- $y \leq R$
- $x$ と $y$ の MSB(最上位ビット)の位置が同じ
- $2$ 進数での各桁 $i$ について、$x_i \leq y_i$
$3$ つめと $4$ つめに対応する SameMsb
、Subset
を実装します。
$2$ つのバイナリ列 $x,y$ を同時に読み込んでいくことを考え、文字集合はバイトの対としています。
|
|
|
|
$x \geq L$、$y \leq R$ という制約を表現するため、$2$ つの文字列それぞれに DFA を独立に走らせる Prod
を定義します。
|
|
これらを適当に組み合わせ、AC です。
|
|
提出 7 ms, 2.2 MB
まとめ
文字列の識別を DFA として表現することで、複数の条件や状態遷移を分けて記述し、これらの組み合わせで桁DPを構成するフレームワークが出来ました!
いかがでしたか?