問題
$H$ 行 $W$ 列の盤面がある。各マスは .(通れる)か #(壁)である。
上下左右に隣り合うマスへ移動しながら、壁以外のすべてのマスをちょうど 1 回ずつ通る経路(ハミルトン路)の数を $998244353$ で割った余りを求めよ。始点と終点はどのマスでもよい。向きは区別しない(逆にたどった路は同じ 1 本と数える)。通れるマスが 1 つだけのときは、長さ 0 の路 1 本と数える。
入力形式
H W
G_1
G_2
...
G_H
制約
| # | 条件 |
|---|---|
| 1 | $1\le H,W$、$H\times W\le 240$、$\min(H,W)\le 8$ |
| 2 | $G_i$ は . と # からなる長さ $W$ の文字列。. は 1 つ以上 |
入出力例
入力例1
3 3
...
...
...出力例1
20入力例2
2 3
...
...出力例2
8入力例3
4 4
....
.#..
....
....出力例3
40(3 例とも DFS の全探索と一致。$n\times n$ の盤面では $n=1..8$ で $1,4,20,276,4324,229348,13535280,3023313284$)
背景と出典
境界を横切る線を括弧列で持つプラグ DP は Day171-Q3(閉路)、端点の線を記号 3 で持つ版は Day172-Q2(端点指定の路)で扱った。本問はその続きで、端点を DP の中で選ばせる。
$n\times n$ の答え $1,4,20,276,4324,229348,13535280$ は、OEIS A096969(格子グラフのハミルトン路の数、向きを区別しない)として私が記憶している値と一致する(OEIS は本セッションでは参照していない。$n\le5$ は DFS と照合済み、$n=6,7,8$ は本解のみ)。
本問は自作。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
Day172-Q2 の DP では、S と T のマスだけが次数 1 だった。今回はどのマスも「通過(次数 2)」か「端点(次数 1)」のどちらかになれる。ただし端点は全体でちょうど 2 個。
ヒント2(アプローチ)
状態を「境界の記号列」と「これまでに使った端点の数 $k$」の組にする。各マスで、通過の遷移(Day171-Q3 と同じ)に加えて、$k<2$ のときだけ端点マスの遷移(Day172-Q2 と同じ)を足し、$k$ を 1 増やす。
ヒント3(誘導)
- 答えは最後の状態「記号列が全部 0、かつ $k=2$」の値
- 端点マスの遷移は $k\to k+1$、通過の遷移は $k$ のまま
- 状態のキーは
st << 2 | kの 1 つの整数にすると、タプルより辞書が速い - 行末で記号列を 1 つずらすとき、$k$ の 2 ビットは動かさない
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
MOD = 998244353
def match(st, j, w):
# 位置 j の括弧('('=1 / ')'=2)と対になる括弧の位置を返す
d = 0
if (st >> 2 * j) & 3 == 1:
for i in range(j, w + 1):
c = (st >> 2 * i) & 3
if c == 1:
d += 1
elif c == 2:
d -= 1
if d == 0:
return i
else:
for i in range(j, -1, -1):
c = (st >> 2 * i) & 3
if c == 2:
d += 1
elif c == 1:
d -= 1
if d == 0:
return i
raise AssertionError
def setp(st, i, c):
return st & ~(3 << 2 * i) | (c << 2 * i)
def solve(h, w, g, mod=MOD):
if w > h: # 狭い方を幅にする
g = [''.join(g[i][j] for i in range(h)) for j in range(w)]
h, w = w, h
if sum(row.count('.') for row in g) == 1: # 1 マスだけなら長さ 0 の路が 1 本
return 1
dp = {0: 1} # キー = 境界の状態 * 4 + 使った端点の数 k
for i in range(h):
for j in range(w):
c = g[i][j]
down_ok = i + 1 < h and g[i + 1][j] == '.'
right_ok = j + 1 < w and g[i][j + 1] == '.'
ndp = {}
def add(s, k, v):
key = s << 2 | k
ndp[key] = (ndp.get(key, 0) + v) % mod
for key, v in dp.items():
st, k = key >> 2, key & 3
L = (st >> 2 * j) & 3 # 左から入る線
U = (st >> 2 * (j + 1)) & 3 # 上から入る線
base = setp(setp(st, j, 0), j + 1, 0)
if c == '#':
if L == 0 and U == 0:
add(st, k, v)
continue
# (a) このマスを端点にする(次数 1)。端点は全体で 2 個まで
if k < 2 and not (L and U):
if L == 0 and U == 0:
if down_ok:
add(setp(base, j, 3), k + 1, v)
if right_ok:
add(setp(base, j + 1, 3), k + 1, v)
else:
x, pos = (L, j) if L else (U, j + 1)
if x == 3: # もう一方の端点からの線が届いた = 路が完成
add(base, k + 1, v)
else: # 括弧を止めると相手が「端点につながる線」になる
add(setp(base, match(st, pos, w), 3), k + 1, v)
# (b) 通常の通過マス(次数 2)
if L == 0 and U == 0:
if down_ok and right_ok:
add(setp(setp(base, j, 1), j + 1, 2), k, v)
elif L == 0 or U == 0:
x = L | U
if down_ok:
add(setp(base, j, x), k, v)
if right_ok:
add(setp(base, j + 1, x), k, v)
else:
if L == 1 and U == 2: # 閉路になる: 禁止
continue
if L == 2 and U == 1:
add(base, k, v)
elif L == 1 and U == 1:
add(setp(base, match(st, j + 1, w), 1), k, v)
elif L == 2 and U == 2:
add(setp(base, match(st, j, w), 2), k, v)
elif L == 3 and U == 3: # 両端点の線がつながる = 路が完成
add(base, k, v)
else: # 端点の線と括弧: 括弧の相手が端点の線になる
pos = j + 1 if L == 3 else j
add(setp(base, match(st, pos, w), 3), k, v)
dp = ndp
# 行末: 右端の線は 0 のはず。記号列を 1 つずらし、k の 2 ビットはそのまま
dp = {(key >> 2) << 4 | (key & 3): v for key, v in dp.items() if (key >> 2 * w + 2) & 3 == 0}
return dp.get(2, 0) # 記号列が全部 0 で k = 2
def main():
data = sys.stdin.read().split()
h, w = int(data[0]), int(data[1])
print(solve(h, w, data[2:2 + h]))
if __name__ == "__main__":
main()
計算量: 幅 $w=\min(H,W)$ の状態数を $S_w$ として $O(HW\cdot S_w\cdot w)$。$S_w$ の実測の最大は幅 7 で 1770、幅 8 で 5548、幅 9 で 15188、幅 10 で 48100。端点指定の Day172-Q2(幅 8 で 513、幅 10 で 3333)より 約 10〜14 倍多い。$8\times30$ で 1.44〜2.43 s(Python 3.14.7、M1 MacBook Pro、負荷平均 15〜65 の混んだ時間帯での実測)。
Step-by-Step 解説
端点指定の版では S T のマスで端点の遷移、それ以外で通過の遷移と、マスごとに遷移が 1 種類だった。今回は各マスで両方の遷移を試す。何もしないと端点を 3 個以上作る経路も混ざるので、使った端点の数 $k$ を持って $k\le2$ に抑える。
キー st << 2 | k。$k$ は 0〜2 なので 2 ビットで足りる。行末のずらしは記号列の部分だけ << 2 し、下の 2 ビット($k$)はそのまま残す。最初にタプル (st, k) で書いたが、整数にまとめても結果は同じ(DFS との照合 1477 盤面で確認)。
通過の遷移で ( と ) が出会うと、その 2 本は既につながっているので閉路になる。閉路を許すと 1477 盤面中 489 盤面で答えが変わった(実測)。
3 と 3 が通過マスで出会う、または端点マスに 3 が届くと路が 1 本完成する。そのあと通れるマスが残っていれば、そのマスは線を出すしかなく、最後に記号列 0 に戻れないので数えられない。最後に「記号列 0 かつ $k=2$」を読む。向きは区別しないので 2 で割る必要はない(DP は線の集合を数えている)。
端点の位置が自由なので、同じ幅でも記号 3 の置き場所が増え、状態は Day172-Q2 の約 10 倍。幅 10 で $10\times20$ を解くと 21.53〜373.49 s(負荷で大きく振れた)。そこで本問は $\min(H,W)\le8$ にした。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 端点の組 $(s,t)$ を全部試して Day172-Q2 を呼ぶ | $O((HW)^2)$ 回の DP | 不採用 | $240$ マスで約 $2.9\times10^4$ 組。1 回 0.3 s でも数時間かかる(見積もり。実装・計測はしていない) |
| DFS の全探索(両向きを数えて 2 で割る) | 指数 | 検算用のみ | 25 マスまで。1477 盤面の照合に使用 |
| 連結成分の番号を最小表現で持つ | 正規化が毎回 $O(w)$ | 不採用 | 括弧列+3 の方が Day171-Q3・Day172-Q2 の実装をそのまま延ばせる |
括弧 2 種+3+端点の数 $k$(本解) | $O(HW S_w w)$ | 採用 | $8\times30$ で 1.44〜2.43 s |
前提が変わったら:
- 向きを区別する(有向の路を数える)なら、答えを 2 倍する。ただし通れるマスが 1 つのときは 1 のまま
- 幅 10 まで必要なら、状態数 48100 を Python の辞書で回すのは遅い($10\times20$ で 20 s 超、実測)。C++ にするか、状態を配列に詰めてハッシュを自作する
- 端点の数を 2 から $2m$ に増やす($m$ 本の互いに素な路で覆う)なら、$k$ の上限を $2m$ にし、完成した路の数も状態に持つ。
3が 2 本以上同時に境界にある場合、どの3とどの3が同じ路かは区別しなくてよいかを先に確かめる(本セッションでは未検討)
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
( ) の合流を許す | 閉路の問題では最後のマスで閉じてよかった | 路では常に禁止。許すと 1477 盤面中 489 盤面で誤答(実測) |
| $k$ を持たない | どのマスも端点になれるので、端点 3 個以上の「路の集まり」まで数える | $k\le2$ を状態に持ち、最後に $k=2$ を読む |
| 行末のずらしで $k$ まで動かす | キーを st << 2 | k にまとめたのを忘れる | (key >> 2) << 4 | (key & 3) |
| DFS の答えを 2 で割らずに比べる | DFS は始点ごとに数えるので 1 本を両向きで 2 回数える | DFS 側だけ 2 で割る。通れるマスが 1 つのときは 1 |
| 転置しない | 盤面をそのままの向きで処理 | 狭い方を幅にする。$8\times30$ を転置すると 1.44 s(実測) |
次のステップ
- 発展1: 盤面を $m$ 本の互いに素な路で覆う数(端点 $2m$ 個)
- 発展2: 「必ず通る辺」「通ってはいけない辺」を指定したハミルトン路(遷移で辺の有無を固定する)
- 発展3: OEIS A096969 と照合する(本セッションでは未確認)。$n=6,7,8$ の値は本解のみの計算
検証ログ(本セッションで実行した数値)
- DFS 全探索との照合(実測): $H,W\le5$ のランダム盤面(壁 0〜35%)1477 盤面で不一致 0(答えが 0 でないもの 1080)
- 誤った方法(実測):
()の合流を許す版は 1477 盤面中 489 盤面で誤答 - $n\times n$(実測): $n=1..8$ で $1,4,20,276,4324,229348,13535280,3023313284$。$n\le5$ は DFS と一致
- 入力例(実測): 例 1
20、例 28、例 340 - 状態数の最大(実測): 幅 7 で 1770、幅 8 で 5548、幅 9 で 15188、幅 10 で 48100
- 実行時間(実測、負荷平均 15〜65): $30\times7$ 0.48〜0.70 s、$30\times8$ 1.69〜2.43 s、$8\times30$(転置)1.44 s、$30\times9$ 10.13〜13.35 s、$20\times10$ 21.53〜373.49 s
- 扱っていない範囲: 幅 9 以上、有向の数え上げ、複数の路での被覆、経路の復元、OEIS の確認