問題
頂点 $1$ が地面に固定された、頂点数 $N$ の根付き木がある。頂点 $i\ (2\le i\le N)$ の親は $p_i$ で、辺 $(p_i, i)$ の色は文字列 $C$ の $i-1$ 文字目(B は青、R は赤)である。
Blue と Red が交互に手番を持つ。Blue は青い辺を 1 本、Red は赤い辺を 1 本選んで切る。切った辺より上(根から遠い側)の辺は地面から離れるので、色を問わずすべて取り除かれる。手番で切れる辺が無いプレイヤーの負けである。
この局面の値(必ず分母が 2 冪の有理数になる)の符号展開を出力し、2 行目に「Blue が先手のときの勝者」と「Red が先手のときの勝者」を空白区切りで出力せよ。
符号展開とは、0 から始めて「+ なら右の子、− なら左の子」と超現実数の木(誕生日の木)を下り、値に着くまでの道順を + と - の列で書いたものである。例: $1=$ +、$\frac12=$ +-、$\frac34=$ +-+、$-\frac14=$ -++。値が 0 なら 0 と出力する。
入力形式
N
p_2 p_3 ... p_N
C
制約
- $2\le N\le 2\times10^5$
- $1\le p_i<i$
- $C$ は
BとRからなる長さ $N-1$ の文字列
入出力例
入力例1
3
1 2
BR出力例1
+-
Blue Blue(茎 BR。値 $\frac12$)
入力例2
5
1 2 2 1
BRRB出力例2
++--
Blue Blue(値 $\frac54=2-\frac12-\frac14$。Day172-Q1 の入力例 2 と同じ木)
入力例3
7
1 2 3 4 5 1
BRRRRR出力例3
-+---
Red Red(左は茎 BRRRR(値 $\frac1{16}$、符号展開 +----)、右は赤い辺 1 本($-1$)。和 $-\frac{15}{16}$ の符号展開は -+---。長い方 +---- の先頭 2 文字だけが -+ に書き換わり、後ろの --- はそのまま残る。3 例とも全状態探索と一致)
---
背景と出典
- 符号展開(sign expansion): Conway の *On Numbers and Games* で、超現実数を + と − の列で表す方法。赤青 Hackenbush の茎(一本の鎖)は、根から順に青を +、赤を − と読んだ列がそのまま値の符号展開になる(Day171-Q1 の規則「最初の同色の並び ±k、その後は ±2^-i」は、この列を数に直す式)。本セッションでは原典を参照していない
- 辺を下に足す関数: Day172-Q1 で $f_B(x)=x+1\ (x\ge0)$、$\frac{x+k}{2^{k-1}}\ (x<0)$ を全状態探索で確かめた。これは「$x$ の符号展開の先頭に + を付ける」ことと同じである(Step 2 で示す)。値が $x$ の部分木は、符号展開が同じ茎と同じ値なので、その下に青い辺を足せば「青+その茎」の茎になる、と考えても分かる
- 本問は自作。Day172-Q1 の「前提が変わったら」と「発展1」($N=2\times10^5$ を符号展開で通す)をそのまま本問にした
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
Day172-Q1 の $f_B$ を、$x$ の符号展開で書き直してみる。$x=-1$(-)→ $\frac12$(+-)、$x=-2$(--)→ $\frac14$(+--)、$x=-\frac12$(-+)→ $\frac34$(+-+)。
ヒント2(アプローチ)
辺 1 本は「先頭に 1 文字」で $O(1)$。残る問題は頂点での和。$y$ の分母が $2^k$ なら、$x+y$ と $x$ の符号展開は「小数部の $k+2$ 文字目以降」が一致する。だから長い方 $x$ の先頭 $k+1$ 文字だけを数に直して $y$ を足し、符号展開に戻して差し替えればよい。
ヒント3(誘導)
- 符号展開を「先頭の同符号の並び(符号と長さ $r$)」と「小数部の列 $d_1d_2\dots$($d_j=\pm1$、値 $\pm2^{-j}$)」に分けて持つ。$r$ は数で持つ(整数部が大きい木で列を伸ばさないため)
- 小数部は逆順の bytearray(先頭が末尾)で持つと、先頭の書き換えが
delとextendで済む - 頂点では、子のうち小数部が一番長いものを土台にして、他の子を足す
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
TO_SIGN = bytes.maketrans(b'10', b'+-')
def canon(z, k):
# z / 2^k(k>0 なら z は奇数)の符号展開を (先頭の並びの符号, 並びの長さ, 小数部の符号列) で返す
# 小数部は '1'=+ / '0'=- の bytes で、位 1/2 が先頭
if k == 0:
return (1 if z >= 0 else -1), abs(z), b''
neg = z < 0
if neg:
z = -z
m = z >> k
f = z - (m << k) # 1..2^k-1 の奇数
digits = format((f - 1) >> 1, '0%db' % k).encode()
if neg:
return -1, m + 1, digits.translate(bytes.maketrans(b'01', b'10'))
return 1, m + 1, digits
def exact(o):
# 符号展開 o を (分子, 指数) に。小数部の長さ k が指数
sg, r, d = o
k = len(d)
if k == 0:
return sg * r, 0
pm = int(bytes(d[::-1]), 2)
return (sg * r << k) + 2 * pm - (1 << k) + 1, k
def add(x, y):
# x に y を足す(x を書き換える)。x の小数部が長いときは先頭 e+1 桁だけ作り直す
ya, k = exact(y)
sg, r, d = x
if len(d) <= k + 1:
xa, kx = exact(x)
K = max(kx, k)
z = (xa << (K - kx)) + (ya << (K - k))
if z == 0:
x[0], x[1], x[2] = 1, 0, bytearray()
return
t = min((z & -z).bit_length() - 1, K)
nsg, nr, nd = canon(z >> t, K - t)
x[0], x[1], x[2] = nsg, nr, bytearray(nd[::-1])
return
e = k
if e:
pm = int(bytes(d[-1:-e - 1:-1]), 2)
xe = (sg * r << e) + 2 * pm - (1 << e) + 1
else:
xe = sg * r
s = 1 if d[-e - 1] == 49 else -1 # 49 は b'1'(位 2^-(e+1) の符号)
z = 2 * xe + 2 * ya + s # 奇数。z / 2^(e+1) が新しい前半の値
del d[-e - 1:]
nsg, nr, nd = canon(z, e + 1)
d.extend(nd[::-1])
x[0], x[1] = nsg, nr
def push_edge(o, c):
# 辺を 1 本下に足す = 符号展開の先頭に c(+1 青 / -1 赤)を付ける
sg, r, d = o
if r == 0 or sg == c:
o[0] = c
o[1] = r + 1
else:
d.extend((b'1' if sg > 0 else b'0') * r)
o[0] = c
o[1] = 1
def main():
data = sys.stdin.buffer.read().split()
n = int(data[0])
par = [0, 0] + [int(x) for x in data[1:n]]
col = data[n] if n > 1 else b''
children = [[] for _ in range(n + 1)]
for v in range(2, n + 1):
children[par[v]].append(v)
order = [1]
for v in order:
order.extend(children[v])
val = [None] * (n + 1)
for v in reversed(order):
ch = children[v]
if not ch:
o = [1, 0, bytearray()]
else:
best = max(ch, key=lambda c: len(val[c][2]))
o = val[best]
for c in ch:
if c != best:
add(o, val[c])
val[c] = None
val[best] = None
if v != 1:
push_edge(o, 1 if col[v - 2] == 66 else -1)
val[v] = o
sg, r, d = val[1]
if r == 0:
print(0)
print("Red Blue")
return
s = (b'+' if sg > 0 else b'-') * r + bytes(d[::-1]).translate(TO_SIGN)
sys.stdout.write(s.decode() + '\n')
print("Blue Blue" if sg > 0 else "Red Red")
main()
計算量: 辺 1 本は $O(1)$(並びの符号が変わるときは並びの長さ $r$ ぶん小数部へ移すが、その文字数は以後の符号展開の長さとして残るので、全体で文字数の総和に抑えられる)。頂点での和は「短い方の小数部の長さ $+1$」に比例する。土台を小数部が最長の子にすると、足される側の長さはその部分木の辺数以下で、軽い子(部分木の大きさが親の半分以下)に $O(\log N)$ 回ずつ課金できるので、全体 $O(N\log N)$ 文字。$N=2\times10^5$ の 10 種の木で 0.26〜0.95 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 10〜18 での実測、起動込み)。
Step-by-Step 解説
1符号展開を数に直す
符号展開 $s_1s_2\cdots s_L$ の先頭の同符号の並びの長さを $r$、符号を $\sigma$ とすると、値は
$$x=\sigma r+\sum_{j=1}^{L-r}d_j2^{-j}\qquad(d_j=s_{r+j}\in\{+1,-1\})$$
例: +-+ は $r=1$、$d=(-1,+1)$ で $1-\frac12+\frac14=\frac34$。逆向き(数 → 符号展開)は、$z=a/2^k$($a$ 奇数、$k\ge1$)、$z>0$ なら $r=\lfloor z\rfloor+1$、小数部 $f=z-\lfloor z\rfloor$ について $d_j$ は「$(f\cdot2^k-1)/2$ の $k$ 桁の 2 進表記の $j$ 桁目が 1 なら +」。これで小数部はちょうど $k$ 文字になる。
2辺を下に足す = 先頭に 1 文字
$x$ の先頭に + を付けた列の値を、Day172-Q1 の $f_B$ と比べる。
- $x\ge0$(並びが + か空): 並びが 1 伸びるだけなので $x+1$。$f_B$ と一致
- $x<0$(並びが − で長さ $r$、小数部の値 $\delta\in[0,1)$): 新しい列は
+、-が $r$ 個、元の小数部。値は $1-\sum_{i=1}^{r}2^{-i}+2^{-r}\delta=2^{-r}(1+\delta)$。一方 $x=-r+\delta$ で、$\delta>0$ なら $k=r+1$、$f_B(x)=\frac{1+\delta}{2^r}$、$\delta=0$ なら $k=r+2$、$f_B(x)=2^{-r}$。一致する
赤い辺は − を付ける。実装では、並びの符号と付ける文字が逆のとき、古い並び $r$ 文字を小数部の先頭へ移す。
3和は先頭だけ書き換える
$y$ の小数部の長さを $k$($y$ の分母が $2^k$)、$x$ の小数部を $d_1\dots d_L$($L\ge k+1$)とする。
$$z_0=\Bigl(\sigma r+\sum_{j\le k}d_j2^{-j}\Bigr)+y+d_{k+1}2^{-(k+1)}$$
は分母がちょうど $2^{k+1}$ の数(整数でない)。整数でない数 $z_0$ の木の上の子は $z_0\pm2^{-(k+2)}$ で、その下も「1 段ごとに半分」で進む。$x+y=z_0+\sum_{j\ge k+2}d_j2^{-j}$ なので、$x+y$ の符号展開は「$z_0$ の符号展開(小数部 $k+1$ 文字)+ $d_{k+2}d_{k+3}\dots$」になる。書き換えるのは先頭 $k+1$ 文字と並びの長さだけ。$L\le k+1$ なら両方を数に直して足す(どちらも $O(k)$)。
4どの子を土台にするか
足し算の手間は足される側 $y$ の小数部の長さで決まる。だから小数部が一番長い子を土台にする。逆に「一番短い子を土台」にすると、毛虫(背骨+葉)で毎回長い背骨側を足すことになり、$N=2\times10^5$ で 27.5 s かかった(実測。本解は 0.68 s)。
5出力
根の値の符号展開をそのまま出す。勝者は値の符号(先頭の文字): + なら Blue、− なら Red、空(値 0)なら後手。出力される列は「その木と同じ値の茎」でもある。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 局面の全状態探索 | 指数 | 検算用のみ | 辺 12 本まで(500 局面) |
| 分子 / 2^指数 で持つ(Day172-Q1) | 最悪 $O(N^2/64)$ 語 | 不採用 | 交互の道で 4.43 s、深い木 3.52 s、二分割を繰り返す木 2.58 s($N=2\times10^5$、実測) |
| 符号展開で持つが、土台を最短の子にする | 最悪 $O(N^2)$ | 不採用 | 毛虫 27.5 s、深い木 26.9 s(実測) |
符号展開を Python の str で持ち、毎回 '+' + s で付ける | 辺ごとに $O(L)$ | 不採用 | 文字列は不変なので、交互の道で $O(N^2)$ 文字のコピー(本セッションでは未計測) |
| 符号展開+逆順 bytearray+最長の子を土台(本解) | $O(N\log N)$ 文字 | 採用 | 10 種の木で 0.26〜0.95 s |
前提が変わったら
- 値の 10 進の分数表記が必要なら、符号展開から分子を作ると $2^{2\times10^5}$ 級の整数の 10 進変換が要る。Python 3.12 以降は大きな整数の 10 進変換が準 2 次になったと記憶しているが、本セッションでは計測していない
- 緑の辺が入ると値は数にならない($*$ が出る)。符号展開では表せないので、本問の表現は使えない
- 木でなく閉路を含むグラフなら、辺の削除で上の部分が一度に落ちる構造が崩れ、部分木の値の和に分けられない
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 土台を先頭の子(や最短の子)にする | small-to-large を忘れる | 小数部が最長の子を土台に。逆にすると 27.5 s(実測) |
| 和で $x$ の先頭 $k$ 文字だけ書き換える | 1 文字足りない | $d_{k+1}$ まで含めて数に直し、分母を $2^{k+1}$ ちょうどにする。$k$ 文字だと $z_0$ が整数になりうる |
| 並びの長さ $r$ を列のまま持つ | 整数部も「文字」と思う | 赤い葉が 10^5 本並ぶ星だと、和のたびに長い列を作る。$r$ は数で持つ |
| 並びの符号と逆の文字を付けるとき、並びを捨てる | + を付けたら値が増えるだけと思う | 古い並び $r$ 文字は小数部の先頭に移る(Step 2) |
| 値 0 を先手勝ちにする | 数のゲームで 0 は後手勝ち | 値 0 なら Red Blue |
次のステップ
- 発展1: 各頂点の部分木の値の符号展開の長さ(誕生日)を全部出す。$O(N\log N)$ の範囲で収まるか
- 発展2: 辺を 1 本切った後の値を $Q$ 回聞かれる問題(切る位置ごとに根までの和を直す)
- 発展3: 原典(*On Numbers and Games* の符号展開、*Winning Ways* の Hackenbush の章)で、茎の値=符号展開の主張を確かめる(本セッションでは未確認)
検証ログ(本セッションで実行した数値)
- 全状態探索との照合(実測): 辺 1〜12 本のランダムな木(一般・深い木・「木とその色反転を根に並べた」値 0 狙い)500 局面で、符号展開と勝者が一致。不一致 0。内訳は値 0 が 88 局面、整数でない値が 265 局面
- Day172-Q1 の方式との照合(実測): 辺 1〜400 本のランダムな木 500 局面で一致。$N=5\times10^4$ で 10 種(ランダム、交互の道、色ランダムの道、深い木、毛虫、箒 400・2000、櫛 20・300、浅い木)と、二分割を繰り返す木・直前 50 頂点に付ける木の計 12 入力で出力が一致
- 実行時間(実測、$N=2\times10^5$、起動込み、本解/Day172-Q1 の方式): 交互の道 0.31/4.43 s(出力 20 万文字)、ランダム 0.95/0.83 s、深い木 0.40/3.52 s、毛虫 0.68/1.57 s、箒 400 0.41/0.35 s、櫛 20 0.34/0.35 s、櫛 300 0.40/0.35 s、浅い木 0.74/0.53 s、二分割を繰り返す木 0.26/2.58 s、直前 50 頂点 0.42/0.27 s。負荷平均 10〜18 の機械なので ±30% 程度ぶれる
- 土台の選び方の比較(実測): 最短の子を土台にすると毛虫 27.53 s、深い木 26.93 s、二分割を繰り返す木 0.70 s(出力は同じ)
- 扱っていない範囲: 10 進の分数表記での出力、緑の辺、閉路のあるグラフ、原典の確認