AtCoder Xmas Contest 2019 K: Set of Trees 木の順序を保存する解
3年越しにこの問題を知って今更解いてみたらどの解法とも微妙に異なっていたので記念カキコ.問題はこれ:
やってることは hos.lyric さんや snuke さんの記事で解説された解答と同じく,木に番号を割り付けているだけだが,この番号割り付けが木の順序(順序数としてのGrundy数の順序)を保存するのが多分新しいと思う.
tl; dr
Haskell解:
Rust解:
順序数,つらみ
hos.lyric さんが本問の解説で超限 Grundy 数について解説しているが,これを前提にする.
何をしたいかと言うと,各木のGrundy数
を求めたいのだが,各
とか書く,ということなので,結局順序数の順序比較をしないといけないことになる.
Cantor標準形の順序比較は列
本当に無理だろうか?
「順序保存ハッシュ」,あるいは「背の順位」
アイデア
普通に考えて,大小比較のたびにCantor標準形の深く深く,木の深く深くまで再帰で分け入っていくのは無駄である.もっと楽に比較ができるようにしたい.そもそも,「超限順序数」だの
したいことは次の通りだ.集合
さて,各頂点に対してはこの背の順位を記録することにすれば,任意の2頂点の比較が定数時間でできる.あとは,この背の順位を求めて各頂点に記録するアルゴリズムがあれば,なんとかなりそうだ.
具体例
それには,単純に木の葉の方から順に背の順位をつけていけば良い.例えば,こんな木があったとする:
- 頂点0
- 頂点1: 頂点0の子
- 頂点2: 頂点0の子
- 頂点3: 頂点2の子
- 頂点4: 頂点0の子
- 頂点5: 頂点4の子
- 頂点6: 頂点4の子
すると,まず葉に背の順位0がつく:
- 頂点0: ?
- 頂点1: 0
- 頂点2: ?
- 頂点3: 0
- 頂点4: ?
- 頂点5: 0
- 頂点6: 0
頂点2と頂点4の子は既に背の順位がついている.これらを(大きい順に)列にする.
- 頂点0: ?
- 頂点1: 0
- 頂点2: ? -
(0) - 頂点3: 0
- 頂点4: ? -
(0,0) - 頂点5: 0
- 頂点6: 0
ここで,Cantor標準形
同士を比較するとき,列
- 頂点0: ?
- 頂点1: 0
- 頂点2: 1 -
(0) - 頂点3: 0
- 頂点4: ? -
(0,0) - 頂点5: 0
- 頂点6: 0
まだ番号がついてないもののうち一番小さいのは頂点4だ:
- 頂点0: ?
- 頂点1: 0
- 頂点2: 1 -
(0) - 頂点3: 0
- 頂点4: 2 -
(0,0) - 頂点5: 0
- 頂点6: 0
そうすると頂点0に対応する列が決まる:
- 頂点0: ? -
(2,1,0) - 頂点1: 0
- 頂点2: 1 -
(0) - 頂点3: 0
- 頂点4: 2 -
(0,0) - 頂点5: 0
- 頂点6: 0
列が決まっており,背の順位が決まっていないものの中で最小なものは頂点0なので,頂点0に背の順位をつける:
- 頂点0: 3 -
(2,1,0) - 頂点1: 0
- 頂点2: 1 -
(0) - 頂点3: 0
- 頂点4: 2 -
(0,0) - 頂点5: 0
- 頂点6: 0
出来上がり.一応だが,葉には空列が対応しており,空列が辞書式順序で最小の列であることを補足しておく.
- 頂点0: 3 -
(2,1,0) - 頂点1: 0 -
() - 頂点2: 1 -
(0) - 頂点3: 0 -
()
- 頂点3: 0 -
- 頂点4: 2 -
(0,0) - 頂点5: 0 -
() - 頂点6: 0 -
()
- 頂点5: 0 -
- 頂点1: 0 -
疑似コード
これをPython風疑似コードにするとこうなる:
# 入力の整数配列
ps := input()
# 前に適当な数を付け足して1-originにする
ps := [-1] + ps
# n = N+1
n := ps.length()
# 整数列(辞書式順序つき)からなる優先度付きキュー
pqueue := PriorityQueue.empty()
# 各整数列に対応する頂点番号を記録するmap
map := Map.empty()
# 整数配列: 各頂点の部分木に対応する背の順位を記録
refs := [-1] * n
# 各背の順位に対応する順序数をCantor標準形で表示
dict := (empty dynamic array)
# begin with empty sequence
pqueue.push([])
for each vertex i >= 0:
if iが子(ps[j]=iなるj)を持たない:
if mapがキー[]を持つ:
map.update([], map.get([]) + [i])
else:
map.push([], [i])
while pqueue is not empty:
xs := pqueue.pop_min()
dict.push(xs)
for each i in map[xs]:
ref[i] := dict.length() - 1
if i > 0:
refchild
:= [ ref[j] for j s.t. ps[j] = ps[i] ]
if refchildが負の数を持たない:
refchild.sort()
if mapがキーrefchildを持つ:
old := map.get(refchild)
map.update(refchild, old + [i])
else:
map.push(refchild, [i])
pqueue.push(refchild)
さて,これで,任意の2頂点i
,j
の部分木を比較するには,背の順位 refs[i]
と refs[j]
を比較すればいいことになった.だからCantor標準形の基底ごとに係数のXORをとることも当然できて,ニム和が取れるようになった.これで解けるはずだ.
本当に解けているのか?
これはTLE解だ
優先度付きキューに列を無遠慮に放り込んでいるが,これが地味に厄介だ.なぜなら列二つの辞書式順序の比較は,列の(小さい方の)サイズに比例した時間がかかる.そのうえ,優先度付きキューは pop_min
や push
のたびに毎回サイズのlogに比例する回数の比較を実行する.キューに入っている列たちのうちの最長の長さを pop_min
や push
のたびに
だからこの解法も普通に
トカゲの尻尾切り,あるいはsuffixたちへの背の順位の付番
係数付き Cantor 標準形
さて,この節では,ちょっと都合が悪いので Cantor 標準形には退場していただき,係数付き Cantor 標準形:
(ただし
に関する辞書式順序を考えれば実行できる.
アイデア
さっき背の順位の話をした時と同じ話をする.比較のたびに列の末尾の方まで深く潜っていくのは時間の無駄である.もっと楽に比較ができるようにしたい.辞書式順序で二つの列
の比較をしたいというとき,
の背の順位があったらどうだろう? この二つの背の順位を比較すれば良い.すると,
の背の順位を考えるときに,
という
の辞書式順序を参照して付番することにする.これを実現するアルゴリズムを作りたい.
具体例
今回も具体例でアルゴリズムを表示したい.
- 頂点0
- 頂点1: 頂点0の子
- 頂点2: 頂点1の子
- 頂点3: 頂点2の子
- 頂点4: 頂点1の子
- 頂点5: 頂点1の子
- 頂点2: 頂点1の子
- 頂点6: 頂点0の子
- 頂点7: 頂点6の子
- 頂点8: 頂点7の子
- 頂点9: 頂点6の子
- 頂点7: 頂点6の子
- 頂点1: 頂点0の子
という木を考えよう.今回は,各頂点には最初から
- 頂点0:
?0 - 頂点1:
?0 - 頂点2:
?0 - 頂点3:
?0
- 頂点3:
- 頂点4:
?0 - 頂点5:
?0
- 頂点2:
- 頂点6:
?0 - 頂点7:
?0 - 頂点8:
?0
- 頂点8:
- 頂点9:
?0
- 頂点7:
- 頂点1:
"?" は頂点の部分木全体の背の順位が確定していないことを示す.とりあえず葉の背の順位を確定させる:
- 頂点0:
?0 - 頂点1:
?0 - 頂点2:
?0 - 頂点3:
!0
- 頂点3:
- 頂点4:
!0 - 頂点5:
!0
- 頂点2:
- 頂点6:
?0 - 頂点7:
?0 - 頂点8:
!0
- 頂点8:
- 頂点9:
!0
- 頂点7:
- 頂点1:
- 頂点0:
?0 - 頂点1:
?(0,2,0) - 頂点2:
?(0,1,0) - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
?(0,1,0) - 頂点7:
?(0,1,0) - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
葉たちの報告は済んだので,
- 頂点0:
?0 - 頂点1:
?2 - 頂点2:
?1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
?1 - 頂点7:
?1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
頂点2と7は未報告の子を持っていないので,背の順位が正しく確定している.確定マークをつけよう:
- 頂点0:
?0 - 頂点1:
?2 - 頂点2:
!1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
?1 - 頂点7:
!1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
- 頂点0:
?0 - 頂点1:
?(1,1,2) - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
?(1,1,1) - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
三つ組の小さい方から背の順位をつける.
- 頂点0:
?0 - 頂点1:
?4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
?3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
頂点1も6も未報告の子を持たないので,確定:
- 頂点0:
?0 - 頂点1:
!4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
!3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
報告待ちの
- 頂点0:
?(3,1,0) - 頂点1:
!4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
まだ使っていない背の順位をつける:
- 頂点0:
?5 - 頂点1:
!4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
- 頂点0:
?(4,1,5) - 頂点1:
4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
もう一回背の順位をつける:
- 頂点0:
?6 - 頂点1:
4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
頂点0に未報告の子はない.確定.報告すべき親もいないので,これで完成:
- 頂点0:
6 - 頂点1:
4 - 頂点2:
1 - 頂点3:
0
- 頂点3:
- 頂点4:
0 - 頂点5:
0
- 頂点2:
- 頂点6:
3 - 頂点7:
1 - 頂点8:
0
- 頂点8:
- 頂点9:
0
- 頂点7:
- 頂点1:
ここまでの道中で,こういう「辞書」ができた:
背の順位 | 三つ組 |
---|---|
0 | なし |
1 | |
2 | |
3 | |
4 | |
5 | |
6 |
実装
上の通りに背の順位をつけていく操作は,頂点に (報告してきた子の背の順位, 報告した子の数, 報告を受ける前の自分の背の順位)
という三つ組を作る.親たちが作った三つ組たちをまとめて配列に突っ込み,ソートする.そうすれば,報告を受けた親たちに,ソート済み配列の中で若い順から新しい背の順位を与えることができるわけだ.親たちに背の順位を与えると同時に,もし未報告の子がいなければ,その親を報告が必要な子としてキューに突っ込む.これで全ての頂点に背の順位がつく.
背の順位と,上で作ったような「辞書」さえあれば,「各木が持つGrundy数の指数付きCantor標準形において,指数を背の順位に置き換えたもの」がごく簡単に計算できる.あとは存分に XOR を取れば ニム和 が計算できる.この解答の時間計算量は,ソートがボトルネックになって最悪
Rust と Haskell でこの方針での解答を書いた.Rust の方は結構頑張ってコメントをつけたので,疑似コードをつけないのを許してほしい:
Haskell解:
Rust解:
最後に
というわけでちょっとした変種でした. なんかこれって 「
みたいなことを言っている気がしますが,論文とかあるんでしょうか?
参考記事
問題:
解説記事:
-
ここから構成を見ていくように,これから木/順序数につけていく番号は内部構造だけを参照して「切り刻んで混ぜ(hash)」てつくるような番号ではないので,「ハッシュ」という呼び方は全くふさわしくない. ↩︎
Discussion