「セグメント木の理屈を学ぶ(1) データ構造とモノイド構造の整合性」「セグメント木の理屈を学ぶ(2) 不変条件を同型として読み直す」の遅延セグメント木版を考えます。
セグメント木は、モノイドの配列に対して、1点更新、区間取得が O(logn) で実行できるデータ構造でしたが、遅延セグメント木は、より多くのメモリ領域を消費する代わりに、モノイドの配列に対して、区間更新、区間取得が O(logn) で実行できます。
区間更新はモノイドのモノイドへの作用
M=(M,⋅,e) を(可換とは限らない)モノイドとして、このモノイドの配列に対する区間更新を考えます。
Segment Tree のお勉強(2) | maspyのHP を参考に、モノイドによるモノイドへの作用として整理します。(maspy氏の記事は右作用だがここでは左作用を扱う)
モノイド F=(F,∘,id) がモノイド M に左作用するとは、 M のモノイド演算と compatible である演算
⋅:F×M→Mが存在し、
- 任意の f∈F,x,y∈M に対して、 f(x⋅y)=f(x)⋅f(y)
- 任意の f,g∈F,x∈M に対して、 (g∘f)⋅x=g⋅(f⋅x)
- 任意の x∈M に対して、 id⋅x=x
が成り立つことを言います。この条件は、 End(M) をM上の自己準同型からなるモノイド(演算は合成) とした時、モノイド準同型
ϕ:F→End(M)を考えていることと同じです。今後、 F の M への作用を f⋅x と書いたり、準同型写像を使って ϕ(f)(x) と書いたりしますが、これら2つは同じものです。
今回は F は非可換でも大丈夫な形で議論を進めます。
AC LibraryのLazy Segtree では F が End(M) の部分モノイドとなる条件に似た形で記載されていますが、こちらも実質的には同じですね。
こう定めると、区間更新は a=(a0,⋯,an−1)∈Mn,f∈F,0≤l<r<n に対して、
ai←f(ai),l≤i≤rと適用すること、と表記できます。
左作用でも右作用でも、好きな方で考えれば良いですが、作用の合成をプログラムとして記述する際には、作用が右なのか左なのかによって書き方が変わってくるので注意が必要ですね。
具体例: 区間アフィン変換・区間総和(非可換な F の例)
抽象的な議論だけだと F の非可換性がどこに効いているか掴みにくいので、具体例を一つ挙げます。競技プログラミングで頻出の「区間に x↦ax+b を適用し、区間の総和を求める」問題がちょうど良い例になっています。
モノイド M: 区間和と区間の要素数の組を持たせる必要があるので、
M=R×Z>0,(s1,c1)⋅(s2,c2)=(s1+s2, c1+c2),e=(0,0)とします。葉ノードには (ai,1) を格納し(値 ai と要素数 1)、内部ノードには子の和が自動的に格納されます。区間和だけでなく要素数も一緒に運ばないと、後述の作用 ϕ(f) が正しく定義できない点がポイントです。
モノイド F: アフィン変換 fa,b(x)=ax+b 全体に、関数合成を演算として入れます。
F={fa,b∣a∈R×,b∈R},id=f1,0合成則を計算すると、
fa2,b2∘fa1,b1=fa2a1, a2b1+b2となります。これは非可換です。実際 a1=a2 のとき、
fa2,b2∘fa1,b1=fa1,b1∘fa2,b2(左辺の a 成分は a2a1、右辺は a1a2 で一致しますが、b 成分は a2b1+b2 対 a1b2+b1 となり、一般には一致しません)。具体的に f2,1,f3,0 を取ると、
f2,1∘f3,0=f6,1,f3,0∘f2,1=f6,3で、確かに異なります。
作用 ϕ:F→End(M):
ϕ(fa,b)(s,c)=(as+bc, c)要素数 c に応じて b を c 倍して足すことで、「区間内の c 個の要素それぞれに b を足す」効果を、和 s の更新一発で表現しています。
作用の公理を確認すると、
- 準同型性 ϕ(f)(x⋅y)=ϕ(f)(x)⋅ϕ(f)(y): x=(s1,c1),y=(s2,c2) に対し、両辺とも (a(s1+s2)+b(c1+c2), c1+c2) となり一致。
- 合成との整合性 ϕ(g∘f)=ϕ(g)∘ϕ(f): g=fag,bg,f=faf,bf とすると、
ϕ(g∘f)(s,c)=(agaf,s+(agbf+bg)c, c)
ϕ(g)(ϕ(f)(s,c))=ϕ(g)(afs+bfc, c)=(ag(afs+bfc)+bgc, c)展開すると両者は一致します。ここで合成の順序を保つ必要があることが、F の非可換性が本質的に効いてくる箇所です。
非可換な F では、あるノードに複数の作用が「後から追加される」順番によって最終的な効果が変わります。例えば、あるノードにまず f2,1 を適用した後に f3,0 を適用したい場合、蓄積すべき合成後の作用は uτ←f3,0∘uτ であり、uτ∘f3,0 ではありません(apply の疑似コードで u[τ] ← f ∘ u[τ] となっているのはこのため)。
もし F が可換なら合成順序を気にせず値を先に評価してから作用を後回しにする最適化([いかたこ]記事の指摘)ができますが、非可換な今回のケースではそれができず、apply が子への再帰の前に必ず pushdown を呼んで「今蓄積されている作用を、追加する前に子へ確定させておく」必要があります(後ほど証明の中で出てきます)。
遅延セグメント木の状態空間
n=2d の深さのセグメント木の状態空間は、
深さ d の完全二分木のノード集合 Td に対してモノイドを割り当て、親が子の積になっているという局所的な条件を課すものでした。
Segd(M)={v∈MTd∣vτ=vlch(τ)⋅vrch(τ)(∀h(τ)>0)}さらに、同型を通じて Segd(M) をモノイドの配列 Mn と同一視していました。
遅延セグメント木では、「値の適用は必要な時にする」スタンスなので、木側の配列として、以前のような局所条件を課さない単なる直積集合をまず考えます。
Std(F,M)=FTd×MTdVald(M)=MTd が(部分的に作用を適用された)値を保持しておく木、 Opd(F)=FTd が M に適用すべき作用を必要な時まで保持しておく木に対応します。
Opd(F) のノード uτ には、 Vald(M) の vτ より下の子ノードたちに将来的に適用したい作用素を保持させます。
記号の準備
ノード τ の親を par(τ) で表します。各ノード τ に対し、根から τ の親までの作用を合成したものを Lτ で表します。つまり、 Lroot=id, τ′=par(τ) である時、 Lτ=Lτ′∘uτ′ として再帰的に定義するものです。
セグメント木の時は、各ノードにそれぞれ区間に対応する計算結果がそのまま格納されており、真の値を読み出す πd は、単に木の葉への制限でした。
ところが遅延セグメント木の場合は、 Op(F)d に残っている作用を Vald(M) に適用しないと真の値が分かりません。
ノードに対応する真の値を
evalτ=Lτ⋅vτ=ϕ(Lτ)(vτ)で定義します。記号の濫用ですが、配列の真の値を読み出す関数
eval:Std(F,M)→Mnを
eval(u,v)i=evalLeafiで定義します。
実装
次に疑似コードを用いて実装を確認します。
遅延セグメント木の初期化
- モノイド M 側: 全ての i に対して ai=e, Vald(M) の全ノードで vτ←e とします。
- 作用するモノイド F 側: 全ての i に対して fi=id, Opd(F) の全ノードで uτ←id とします。
pushdown: 蓄積されている作用の子への伝播
更新、取得関数を考える前に、遅延セグメント木で初めて出てくる「ノードに蓄積されている作用を子に伝播させる」操作を定義します。この操作はユーザーからは直接呼び出されません。疑似コードでは以下のようになります。
pushdown(τ):
if h(τ) = 0: # 葉の場合
return
v[lch(τ)] ← φ(u[τ])(v[lch(τ)])
v[rch(τ)] ← φ(u[τ])(v[rch(τ)])
u[lch(τ)] ← u[τ] ∘ u[lch(τ)]
u[rch(τ)] ← u[τ] ∘ u[rch(τ)]
u[τ] ← id # ノードの作用をクリア
ここで h はノードの高さを返す関数です。
apply(l, r, f): 区間更新
set(i, c) の遅延セグメント木バージョンである apply(l, r, f) を定義します。
apply(l, r, f, τ):
if seg(τ) ∩ [l,r] = ∅:
return
if seg(τ) ⊆ [l,r]:
v[τ] ← φ(f)(v[τ]) # 左作用
u[τ] ← f ∘ u[τ] # 関数の合成
return
pushdown(τ) # 部分的に適用が必要な場合は、蓄積されている作用を子に伝播
apply(l, r, f, lch(τ))
apply(l, r, f, rch(τ))
v[τ] ← v[lch(τ)] · v[rch(τ)]
apply(l, r, f) をルートに対する更新 apply(l, r, f, τ_root) として定義します。
query(l, r): 区間 [l:r] の総積
クエリについては、以前とほぼ同じ実装で、子の値の積を求める前に作用の伝播をする所だけが違います。
query(l, r, τ):
if seg(τ) ∩ [l,r] = ∅:
return e # Mの単位元
if seg(τ) ⊆ [l,r]:
return v[τ]
pushdown(τ)
return query(l, r, lch(τ)) · query(l, r, rch(τ))
pushdown, apply をよく読むと、 vτ の内容は uτ にすでに適用されていることがわかるので、return v[τ] するときには pushdown(τ) を呼ぶ必要はありません。
query(l, r) をルートに対する更新 query(l, r, τ_root) として定義します。
実装に対応する数学的写像
pushdown, apply, query が表す数学的な写像を次のような記号で表します。ここはセグメント木の記事では少し適当にごまかしていた所ですね。
[[pushdown(τ)]]:Std(F,M)→Std(F,M)[[apply([l:r],f,τ)]]:Std(F,M)→Std(F,M)[[query([l:r],τ)]]:Std(F,M)→Std(F,M)×Mquery は潜在空間を更新するので値域は M にはなりません。関数型言語で副作用を扱うときの「Stateモナド」に相当します。
state,val は Std(F,M)×M の Std(F,M),M への射影とします。
局所条件を考える
遅延セグメント木の状態空間では、「値の適用は必要な時にする」スタンスなので、モノイドの配列 a∈Mn と木の状態が一対一に対応しないわけですが、実はセグメント木のような局所条件が存在します。遅延セグメント木の状態空間の元を (u,v)∈FTd×MTd で表します。
命題9. (局所条件)
h(τ)>0 を満たすノードについて、
vτ=uτ⋅(vlch(τ)⋅vrch(τ))が成り立つことを局所条件という。局所条件は初期状態で成立している。
- [[pushdown(τ)]] は局所条件を保つ。
- [[apply([l:r],f,τ)]] は局所条件を保つ。
- state∘[[query([l:r],τ)]] は局所条件を保つ。
証明.
関数の適用前の木を s, 適用後の木を s′ とする。葉でないノード σ を任意に取って
vσ(s′)=uσ(s′)⋅(vlch(σ)(s′)⋅vrch(σ)(s′))が成立することを示す。
- [[pushdown(τ)]] で変化するのは uτ,ulch(τ),urch(τ),vlch(τ),vrch(τ) であることと対称性より、 σ=τ,lch(τ) の場合を調べれば十分である。 σ=τ の場合は、
vτ(s′)=vτ(s)=uτ(s)⋅(vlch(τ)(s)⋅vrch(τ)(s))=(uτ(s)⋅vlch(τ)(s))⋅(uτ(s)⋅vrch(τ)(s))=id⋅(vlch(τ)(s′)⋅vrch(τ)(s′))で、 uτ(s′)=id より従う。
σ=lch(τ) の場合は、
vσ(s′)=uτ(s)⋅vσ(s)=uτ(s)⋅(uσ(s)⋅(vlch(σ)(s)⋅vrch(σ)(s)))=(uτ(s)∘uσ(s))⋅(vlch(σ)(s)⋅vrch(σ)(s))=uσ(s′)⋅(vlch(σ)(s′)⋅vrch(σ)(s′))より成り立つ。
- h(τ) の高さに関する帰納法で示す。 seg(τ)∩[l,r]=∅ の時は何も変化しないので自明。 seg(τ)⊆[l,r] の時は、1. と同様の議論で証明できるので、それ以外の部分被覆となる場合を考える。1. により pushdown で局所条件が保存されることがわかるので、s を pushdown 後の状態に取り替えてよい。
applyの影響範囲を考えると、 σ が τ もしくは τ の真の子孫の場合のみ考えれば良いが、 τ の真の子孫の場合は帰納法の仮定より成立しているから、 σ=τ の場合を考えれば十分である。pushdown 後の状態では uτ=id だから、lch, rchを更新する際の影響範囲から vτ(s′)=vlch(τ)(s′)⋅vrch(τ)(s′) と最後に更新していることから分かる。
- 木の状態が変わるのは途中で pushdown を呼ぶ時だけなので、1.より成り立つ。
以上により示された。■
命題9により、遅延セグメント木の潜在空間を
LazySegd(F,M)={(u,v)∈Std(F,M)∣vτ=uτ⋅(vlch(τ)⋅vrch(τ)),(∀τ,h(τ)>0)}⊆Std(F,M)に置き換えることができる。記号の濫用だが、
[[pushdown(τ)]]:LazySegd(F,M)→LazySegd(F,M)[[apply([l:r],f,τ)]]:LazySegd(F,M)→LazySegd(F,M)[[query([l:r],τ)]]:LazySegd(F,M)→LazySegd(F,M)×Mで表し、以後は局所条件が成り立った状態の木のみ扱う。
命題9の系として、eval に対しての局所条件を示すことができます。
系10. (evalの局所条件)
h(τ)>0 のとき、
- evalτ=evallch(τ)⋅evalrch(τ).
- evalτ=∏i∈seg(τ)evali.
証明.
evallch(τ)⋅evalrch(τ)=((Lτ∘uτ)⋅vlch(τ))⋅((Lτ∘uτ)⋅vrch(τ))=Lτ⋅(uτ⋅(vlch(τ)⋅vrch(τ)))=Lτ⋅vτ=evalτ
より従う。
-
- を使った帰納法で示せる。■
真の値が変わらないこと
遅延セグメント木では query で値を読み出す際にも上側で pushdown が呼び出され木の状態が裏で変化します。この時、 eval で読み出す値が変化しないことを示します。
命題11: pushdown は eval を保つ
τ∈Td を任意のノードとする。このとき、
eval∘[[pushdown(τ)]]=eval:LazySegd(F,M)→Mnが成り立つ。
証明. s=(u,v)∈LazySegd(F,M),s′=[[pushdown(τ)]](s) とする。
より広く、任意のノード σ∈Td に対して evalσ(s′)=evalσ(s) が成立することを証明する。
[[pushdown(τ)]] で変化するのは uτ,ulch(τ),urch(τ),vlch(τ),vrch(τ) だったことを思い出し、 σ と τ の位置関係で4通りに場合分けする。
ケースA: τ が σ の祖先でない( σ=τ かつ τ∈/{σ の祖先 } )
vσ は変化せず、 Lσ も σ の祖先の u の合成なので、 evalσ(s) は変化しない。
ケースB: σ=τ
[[pushdown(τ)]] では vτ は変化しない。根から τ の親までの作用を合成したものが Lτ なので、 uτ の更新は影響せず、 Lτ(s′)=Lτ(s) となることから従う。
ケースC: σ=lch(τ),rch(τ)
pushdownの定義より、
uτolduσnewvσnewuτnew:=uτ(s),:=uσ(s′)=uτold∘uσ(s),:=vσ(s′)=ϕ(uτold)(vσ(s)),:=uτ(s′)=idである。
Lσ(s)=Lτ(s)∘uτ(s)=Lτ(s)∘uτoldで、ケースBより Lτ(s′)=Lτ(s) だから、
evalσ(s)=Lσ(s)⋅vσ(s)=(Lτ(s)∘uτold)⋅vσ(s)=Lτ(s)⋅(uτold⋅vσ(s))となる。最後の変形で (g∘f)⋅x=g⋅(f⋅x) を使った。
一方、
evalσ(s′)=Lσ(s′)⋅vσnew=(Lτ(s′)∘id)⋅vσnew=Lτ(s)⋅(uτold⋅vσ(s))なので、evalσ(s′)=evalσ(s) となる。
ケースD: σ が lch(τ) または rch(τ) の真の子孫
対称性より、 σ が lch(τ) の真の子孫場合のみ示せば十分である。
vσ は変化しないので、 Lσ(s′)=Lσ(s) を示せば十分。 Lσ(s) の構成から、変化しない部分を省略して
Lσ(s)=(⋯((⋯∘uτ(s))∘ulch(τ)(s))∘⋯と表せ、同様に
Lσ(s′)=(⋯((⋯∘id)∘(uτ(s)∘ulch(τ)(s)))∘⋯であり、 F のモノイドとしての単位則、結合則を使うことで、これら二つが一致することがわかる。■
次に、 apply の正当性を示す、と行きたいところなのですが、普通に示そうとすると F が可換でなくては証明が回らないように見える部分が出てきます。
実は apply での実行中、再帰的に子の apply を呼び出す前に pushdown を呼んでいるのがミソです。遅延評価セグメント木 [いかたこのたこつぼ] で F が可換なら更新前の評価を省ける、と書いているのがまさにこれと対応します。
補題12
apply(l, r, f, τ) が呼び出された直後の木の状態を sτ とする。 この時、 Lτ(sτ)=id が成立する。
証明. root から τ への深さに関する帰納法で示す。
τ=root の時、定義より明らか。
τ=root のとき、 τ′=par(τ) とすると、帰納法の仮定より Lτ′=id 。apply(l, r, f, τ) は apply(l, r, f, τ') の実行中にのみ呼び出され、かつ pushdown(τ') の直後に呼び出されたと考えて良い。( τ=rch(τ′) の場合は、先に呼ばれる apply(l, r, f, lch(τ')) は τ の祖先の u,v を変化させない。) つまり、pushdown(τ') をして uτ′=id になった状態なので、
Lτ=Lτ′∘uτ′=id∘id=idより τ の場合も成り立つ。■
applyは、 配列側の「区間に一様作用」と一致することを示しましょう。
命題13 (applyの妥当性)
s∈LazySegd(F,M) を潜在空間の状態、 τ を Lτ(s)=id を満たすノードとする。 s′=[[apply([l:r],f,τ)]](s) とすると、
eval(s′)i={f⋅eval(s)ieval(s)ii∈seg(τ)∩[l,r]i∈/seg(τ)∩[l,r]証明.
h(τ) の高さに関する帰納法。
i∈/seg(τ) のとき、 eval(τ)i の計算に利用される Leafi までの経路は影響を受けない。よって、 i∈seg(τ) の場合を考えれば良い。
seg(τ)∩[l,r]=∅ のとき、 s′=s より明らか。
seg(τ)⊆[l,r] の時、 Lτ(s′)=Lτ(s)=id である。 τ の部分木の葉 Leafi に対して、 LLeafi を τ より上の部分と τ を含む τ より下の部分に分けると、 F の結合則を使って括弧の順番を入れ替えることで、
LLeafi(s)=Lτ(s)∘(uτ(s)∘B(s))と分解できる。ここで、 B は経路に存在する τ の子から、 par(Leafi)(s) までの u の合成。 Lτ(s)=id を使って、 LLeafi(s)=uτ(s)∘B(s).
同様に、
LLeafi(s′)=Lτ(s′)∘(uτ(s′)∘B(s′))=uτ(s′)∘B(s′)=(f∘uτ(s))∘B(s)=f∘LLeafi(s).vLeafi(s′)=vLeafi(s) だから、
eval(s′)i=LLeafi(s′)⋅vLeafi(s′)=(f∘LLeafi(s))⋅vLeafi(s)=f⋅(LLeafi(s)⋅vLeafi(s))=f⋅eval(s)iとなり従う。
seg(τ)∩[l,r]=∅,seg(τ)⊈[l,r] の時、 pushdown(τ),
apply(l, r, f, lch(τ)), apply(l, r, f, rch(τ)), v[τ] ← v[lch(τ)] · v[rch(τ)] が実行される。
命題11より [[pushdown(τ)]] は全てのノードの eval を保つ。 pushdown(τ) 後の状態を s~ とすると、uτ(s~)=id,Lτ(s~)=Lτ(s)=id より、
Llch(τ)(s~)=Lτ(s~)∘uτ(s~)=id∘id=id.
rch も同様。帰納法の仮定を高さ h(τ)−1 の lch(τ),rch(τ) に適用して、それぞれの再帰呼び出し後、その部分木の葉について主張が成りたつ。seg(τ)=seg(lch(τ))⊔seg(rch(τ))(非交和)なので、τ の部分木全体の葉について主張が成立する。 ■
命題13を根ノードに対して適用することで、apply により期待通りの適用ができることがわかります。
補題14
query(l, r, τ) が呼び出された直後の木の状態を sτ とする。 この時、 Lτ(sτ)=id が成立する。
証明. root から τ への深さに関する帰納法で示す。
τ=root の時、定義より明らか。
τ=root のとき、 τ′=par(τ) とすると、帰納法の仮定より Lτ′=id 。query(l, r, τ) は query(l, r, τ') の実行中にのみ呼び出され、かつ pushdown(τ') の直後に呼び出されたと考えて良い。( τ=rch(τ′) の場合は、先に呼ばれる query(l, r, lch(τ')) は τ の祖先の u,v を変化させない。) つまり、pushdown(τ') をして uτ′=id になった状態なので、
Lτ=Lτ′∘uτ′=id∘id=idより τ の場合も成り立つ。 ■
命題15 (queryの妥当性)
s∈LazySegd(F,M) を潜在空間の状態、 τ を Lτ(s)=id を満たすノードとする。
- (val∘[[query([l:r],τ)]])(s)=∏i∈seg(τ)∩[l:r]evali(s)
- (eval∘state∘[[query([l:r],τ)]])(s)=eval(s)
が成り立つ。
証明.
-
木の高さに関する帰納法。補題14により、途中で再起的に計算が呼び出される部分木の L は自明。
seg(τ)∩[l,r]=∅ のとき、両辺 e より明らか。
seg(τ)⊆[l,r] のときは、Lτ(s)=id より vτ=evalτ だから、系10 2. より従う。seg(τ)∩[l,r]=∅,seg(τ)⊈[l,r] のときは、pushdown により部分木の L が自明になるので帰納法の仮定が使えることから従う。
-
query 中に木の状態が変化するのは pushdown のみで、pushdown が eval を変化させないことから従う。 ■