問題
$H\times W$ の盤面があり、各マスは .(空き)か #(壁)である。上下左右に隣り合う空きマスの間の辺から何本かを選び、どの空きマスにつながる選んだ辺も 2 本以下になるようにする。このとき空きマスは、いくつかの閉路といくつかの路に分かれる。辺を 1 本も持たない空きマスは、それだけで 1 本の路と数える。
$0\le c\le C$、$0\le m\le K$ のそれぞれについて、閉路がちょうど $c$ 個・路がちょうど $m$ 本になる辺の選び方の数を $998244353$ で割った余りを出力せよ。
入力形式
H W K C
S_1
...
S_H
出力形式
$C+1$ 行。$c$ 行目($c=0,1,\dots,C$)に、$m=0,1,\dots,K$ の値を空白区切りで出力する。
制約
- $1\le H,W\le30$、$\min(H,W)\le6$
- $1\le K\le10$、$1\le C\le10$
- $S_i$ は
.と#からなる長さ $W$ の文字列
入出力例
入力例1
2 2 2 1
..
..出力例1
0 4 6
1 0 0($c=0$ は Day174-Q2 と同じ路だけの数。$c=1$, $m=0$ は 4 辺全部の輪 1 通り)
入力例2
3 3 2 1
...
.#.
...出力例2
0 8 28
1 0 0(空きマスは長さ 8 の輪。輪全体が閉路 1 個、そこから $m$ 本の辺を外すと $m$ 本の路で $\binom8m$)
入力例3
2 3 3 1
...
...出力例3
0 8 25 33
1 2 2 0($c=1$: 6 マスの輪が $m=0$ で 1 通り、左右どちらかの $2\times2$ の輪+残り 2 マス(つなぐ/つながない)が $m=1,2$ で 2 通りずつ。3 例とも辺集合の全探索と一致)
---
背景と出典
- プラグ DP(Day171-Q3・Day172-Q2・Day173-Q1・Day174-Q2): 1 マスずつ処理し、処理済みと未処理の境界を横切る線を「プラグ」として持つ。両端が境界に出ている線は括弧
()、片方が既に路の端点で終わっている線は記号 3 - 本問の数え上げは、グラフ理論でいう「最大次数 2 の全域部分グラフ」を成分の形で分けたもの。$m=0$ の列は2-因子(全頂点が閉路に乗る)を閉路の数ごとに数えたものになる。統計力学の $O(n)$ ループ模型の分配関数(閉路に重み $n$)に近い形だと私は理解している(本セッションでは文献を参照していない)
- 検算の柱: 閉路と路の内訳を忘れると、「次数 2 以下の辺集合の総数」は連結性を持たない単純な DP(プラグは辺の有無だけ)で数えられる。$C,K$ を十分大きくした本解の全係数の和がこれと一致することを $7\times8$ などで確かめた
- 本問は自作。Day174-Q2 の「発展2」をそのまま本問にした
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
Day174-Q2 との違いは 1 か所だけ: 左から (、上から ) が来たマス(同じ線の両端)。Day174-Q2 では禁止したが、本問では閉路が 1 つ閉じる遷移として許す。
ヒント2(アプローチ)
係数を「区画番号 $= c\cdot(2K+1)+e$」($e$ は端点の数)で 1 つの整数に詰める。端点が増えたら 80 ビット、閉路が増えたら $80(2K+1)$ ビット左シフト。ただし $e$ が $2K$ を超えた分は**次の $c$ の区画にはみ出す**ので、シフトのあとに消す。
ヒント3(誘導)
- 端点 +1 は
(val << 80) & keep1、+2 は(val << 160) & keep2。keep1は各 $c$ の $e=0$ の区画を 0 にしたマスク(シフト後に $e=0$ に来るのは前の $c$ の $e=2K$ から来た値) - 区画は $(C+1)(2K+1)=231$ 個まで増える。mod を取るときに区画を 1 つずつシフトで取り出すと区画数の 2 乗かかるので、
to_bytesで一度にバイト列にして 10 バイトずつ切る - mod は毎行でなく、桁あふれしない範囲で数行に 1 回(Step 5)
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
MOD = 998244353
S = 80 # 1 つの係数に使うビット数
def partner(st, pos, ty):
# 位置 pos の括弧 ty(1='(' / 2=')')の相方の位置。3 は括弧として数えない
d = 0
step = 1 if ty == 1 else -1
p = pos
while True:
t = (st >> (2 * p)) & 3
if t == ty:
d += 1
elif t == 3 - ty:
d -= 1
if d == 0:
return p
p += step
def main():
data = sys.stdin.read().split()
h, w, k, c = int(data[0]), int(data[1]), int(data[2]), int(data[3])
g = data[4:4 + h]
if w > h: # プロファイルの長さを短い辺にする
g = [''.join(g[i][j] for i in range(h)) for j in range(w)]
h, w = w, h
E = 2 * k + 1 # 端点の数 e = 0..2k
slots = E * (c + 1) # 区画番号 = 閉路の数 * E + 端点の数
mask = (1 << (S * slots)) - 1
CYC = S * E # 閉路が 1 つ増える = この幅だけ左シフト
# 端点が d 増えたとき e > 2k になった区画は、次の閉路の数の区画へはみ出すので消す
keep1 = sum(((1 << S) - 1) << (S * (cc * E + e)) for cc in range(c + 1) for e in range(1, E))
keep2 = sum(((1 << S) - 1) << (S * (cc * E + e)) for cc in range(c + 1) for e in range(2, E))
free = [[ch == '.' for ch in row] + [False] for row in g] + [[False] * (w + 1)]
B = S // 8 # 1 区画のバイト数
nb = B * slots
frm = int.from_bytes
def reduce(v):
# 区画ごとに mod。シフトで 1 区画ずつ取り出すと区画数の 2 乗かかるので、バイト列で切り出す
bs = v.to_bytes(nb, 'little')
return frm(b''.join((frm(bs[t:t + B], 'little') % MOD).to_bytes(B, 'little')
for t in range(0, nb, B)), 'little')
# 状態数 < 2^12(幅 7 以下)、mod 直後の区画 < 2^30、1 マスで総和は高々 4 倍 → 30+12+2*マス数 < 80
per = max(1, (S - 1 - 30 - 12) // (2 * w))
cur = {0: 1}
for i in range(h):
if i % per == 0: # per 行ごとにだけ mod を取る(Step 5 の上限)
cur = {st << 2: reduce(v) for st, v in cur.items() if st >> (2 * w) == 0}
else:
cur = {st << 2: v for st, v in cur.items() if st >> (2 * w) == 0}
for j in range(w):
nxt = {}
get = nxt.get
fr = free[i][j]
down_ok = free[i + 1][j]
right_ok = free[i][j + 1]
sj = 2 * j
sj1 = sj + 2
clr = ~((3 << sj) | (3 << sj1))
for st, val in cur.items():
a = (st >> sj) & 3
b = (st >> sj1) & 3
base = st & clr
if not fr:
if a == 0 and b == 0:
nxt[st] = get(st, 0) + val
continue
if a == 0 and b == 0:
v1 = (val << S) & keep1
if down_ok and right_ok:
s2 = base | (1 << sj) | (2 << sj1)
nxt[s2] = get(s2, 0) + val
if down_ok:
s2 = base | (3 << sj)
nxt[s2] = get(s2, 0) + v1
if right_ok:
s2 = base | (3 << sj1)
nxt[s2] = get(s2, 0) + v1
nxt[base] = get(base, 0) + ((val << (2 * S)) & keep2)
elif a == 0 or b == 0:
x = a | b
if down_ok:
s2 = base | (x << sj)
nxt[s2] = get(s2, 0) + val
if right_ok:
s2 = base | (x << sj1)
nxt[s2] = get(s2, 0) + val
if x == 3:
s2 = base
else:
q = partner(st, j if a else j + 1, x)
s2 = base | (3 << (2 * q))
nxt[s2] = get(s2, 0) + ((val << S) & keep1)
else:
if a == 1 and b == 2: # 同じ線の両端: 閉路が 1 つ閉じる
nxt[base] = get(base, 0) + ((val << CYC) & mask)
continue
if a == 3 and b == 3:
s2 = base
elif a == 3 or b == 3:
y, pos = (b, j + 1) if a == 3 else (a, j)
q = partner(st, pos, y)
s2 = (base & ~(3 << (2 * q))) | (3 << (2 * q))
elif a == 2 and b == 1:
s2 = base
elif a == 1 and b == 1:
q = partner(st, j + 1, 1)
s2 = (base & ~(3 << (2 * q))) | (1 << (2 * q))
else:
q = partner(st, j, 2)
s2 = (base & ~(3 << (2 * q))) | (2 << (2 * q))
nxt[s2] = get(s2, 0) + val
cur = nxt
v = reduce(cur.get(0, 0))
m1 = (1 << S) - 1
out = []
for cc in range(c + 1):
out.append(' '.join(str((v >> (S * (cc * E + 2 * m))) & m1) for m in range(k + 1)))
print('\n'.join(out))
main()
計算量: プロファイルの状態数を $S_w$(幅 6 で 858、Day174-Q2 の実測)とすると、マスごとに状態数ぶんの遷移、括弧の相方探し $O(w)$、詰めた整数の加算 $O(CK)$ ワード。mod は $\lfloor37/(2w)\rfloor$ 行ごとに $O(S_w\cdot CK)$。全体 $O(HW\cdot S_w\cdot(w+CK))$。$6\times30$、$K=C=10$ で 1.04 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 11〜13 での実測、起動込み)。
Step-by-Step 解説
1閉路が閉じる遷移
左のプラグ $a$ と上のプラグ $b$ が ( と ) の組なら、それは同じ線の両端(隣り合う括弧は必ず対応する)。このマスでつなぐと閉路が 1 つ完成する。新しい状態は 2 つのプラグを消したもので、係数は $c+1$ の区画へ移す。他の遷移はすべて Day174-Q2 と同じ。
22 変数を 1 つの整数に
区画 $t=c(2K+1)+e$ に係数を置き、整数 $\sum_t v_t2^{80t}$ で持つ。
- 端点 +1:
<< 80、+2(1 マスだけの路):<< 160 - 閉路 +1:
<< 80*(2K+1)。$c>C$ の分は全体のマスクで消える
3区画のはみ出し
$e=2K$ の値に端点 +1 のシフトをかけると、その値は $(c+1, e=0)$ の区画に入る。1 変数の Day174-Q2 では「全体のマスク」で消えたが、2 変数では途中の区画に残ってしまう。シフトのあとに keep1(各 $c$ の $e=0$ を除くマスク)を掛ける。これを忘れた版は 400 盤面中 72 盤面で誤答した(実測)。
4mod をバイト列で取る
区画を (v >> 80t) & mask で 1 つずつ取り出すと、毎回 $O(\text{区画数})$ 語のシフトがかかり、1 状態で区画数の 2 乗になる。v.to_bytes で一度にバイト列にして 10 バイトずつ int.from_bytes すれば区画数に比例する。$6\times30$、$K=C=10$ で mod 部分が全体の 77% を占めていたのが、4.40 s → 2.25 s(実測)。
5mod を数行に 1 回
mod の直後は各区画 $<2^{30}$。1 マス進むと全状態の区画の総和は高々 4 倍(1 つの状態から出る遷移は 4 本以下。閉路が閉じる遷移は 1 本)。状態数 $<2^{12}$(幅 7 以下)なので、$t$ マス後の 1 区画は $2^{30+12+2t}$ 未満。$80$ ビットに収めるには $t\le18$、つまり $\lfloor37/(2w)\rfloor$ 行(幅 6 で 3 行)ごとに mod を取れば足りる。2.25 s → 1.04 s(実測)。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 辺集合の全探索 | $O(2^{E})$ | 不採用 | 検証用。$HW\le12$(17 辺)まで |
| 閉路の数はキーに入れ、端点の数だけ詰める | 状態数が $C+1$ 倍 | 不採用 | 正しいが、$6\times30$, $K=C=10$ で 5.08〜6.95 s(mod を毎行シフトで取る版どうしの比較では、詰める版 4.40 s) |
| 2 変数を詰め、mod を毎行シフトで取る | 同上 | 不採用 | 4.40 s。mod が 77% |
| 2 変数を詰め、mod をバイト列で数行に 1 回(本解) | $O(HW S_w(w+CK))$ | 採用 | 1.04 s |
| 閉路の数を変数 $x$ の値として $C+1$ 点で評価し、補間で戻す | 評価点ごとに 1 回 | 不採用 | 閉路は最大 $HW/4=45$ 個あり、$c\le C$ で打ち切れないので 46 点要る(本セッションでは未実装) |
前提が変わったら
- **$\min(H,W)=7$** なら $7\times30$, $K=C=10$ で 5.50 s(実測)。状態の配列化(括弧列の最小表現の番号付け)が要る
- **閉路に重み $n$ を付けた総和だけ**($O(n)$ ループ模型のように)が欲しいなら、閉路の変数は詰めずに、閉じる遷移で係数を $n$ 倍するだけでよい。区画は端点の分だけになる
- **$K$ や $C$ が上限なし**(全係数)なら、区画数が $HW$ に比例して増える。辺数で数える方が素直
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 端点 +1 のシフトで全体のマスクだけ掛ける | 1 変数版の書き方のまま | 400 盤面中 72 盤面で誤答(実測)。各 $c$ の $e=0$ を消すマスクを掛ける |
( ) の合流を禁止したまま | Day174-Q2 のコードをそのまま使う | 400 盤面中 186 盤面で誤答(実測)。閉路が閉じる遷移を足す |
) ( の合流を閉路と数える | 括弧の向きを取り違える | )( は別々の線をつなぐだけ(閉路にならない) |
| 区画をシフトで 1 つずつ取り出して mod | 区画数の 2 乗 | to_bytes で一度に切り出す |
| mod の間隔を幅によらず固定する | 上限の式を立てない | 間隔は $\lfloor37/(2w)\rfloor$ 行。幅 7 で 2 行、幅 6 で 3 行 |
次のステップ
- 発展1: 閉路に重み $n$ を付けた総和($O(n)$ ループ模型)を、$n$ を変えて $6\times30$ で計算する
- 発展2: 全係数($C,K$ 上限なし)を辺数と閉路数の 2 変数で数え、上の「前提が変わったら」の比較を実測する
- 発展3: 2-因子の数($m=0$ の列の和)を、FKT のような行列式で数えられるかを調べる(平面グラフの完全マッチングとの関係)
検証ログ(本セッションで実行した数値)
- 辺集合の全探索との照合(実測): $HW\le12$、壁の確率 0/0.1/0.25、$1\le K\le8$、$1\le C\le4$ のランダム盤面 460 盤面(本解の最終版で 60、mod の取り方を変える前の版で 400)。不一致 0。閉路を含む係数が 0 でない盤面は 400 盤面中 179
- 連結を見ない DP との照合(実測): 全係数の和($K=HW$, $C=HW/4+1$)が「次数 2 以下の辺集合の総数」と一致。$5\times5$: 931128917、$6\times6$: 373806047、$4\times9$: 121809626、$7\times8$: 369220801、$6\times9$ 壁 15%: 872918986(すべて mod 998244353)
- 誤った方法(実測、400 盤面): はみ出しを消さない 72 盤面、
()の合流を禁止 186 盤面で誤答 - 実行時間(実測、起動込み、$K=C=10$): $6\times30$ 1.04 s、$30\times6$ 壁 10% 0.48 s、$5\times30$ 0.30 s、$6\times30$($K=C=5$)0.52 s、$7\times30$ 5.50 s。改良の段階: mod を毎行シフト 4.40 s → バイト列 2.25 s → 3 行に 1 回 1.04 s($6\times30$)。閉路の数をキーに入れる版(mod は毎行シフト)は $6\times30$ で 5.08〜6.95 s、出力は 5 盤面で一致
- 計測時の負荷: 負荷平均 11〜18(一時 95)。同じ入力でも ±30% 程度ぶれる
- 扱っていない範囲: 幅 7 以上の高速化、状態の配列化、閉路に重みを付けた総和