問題
$H$ 行 $W$ 列の盤面があり、各マスは .(通れる)か #(壁)である。
通れるマスをすべてちょうど 1 回ずつ通り、上下左右に隣り合うマスへ移動しながら出発したマスに戻る閉路(ハミルトン閉路)の個数を $998244353$ で割った余りを求めよ。向きと出発点の違いは区別しない(同じマスの集合を同じ順に結ぶ閉路は 1 個と数える)。通れるマスが 1 つも無いときは $0$ とする。
入力形式
H W
G_1
G_2
...
G_H
制約
| # | 条件 |
|---|---|
| 1 | $1\le H,W\le30$ |
| 2 | $\min(H,W)\le10$ |
| 3 | $G_i$ は . と # からなる長さ $W$ の文字列 |
入出力例
入力例1
4 4
....
....
....
....出力例1
6入力例2
3 3
...
...
...出力例2
0(盤面を市松模様に塗ると、閉路は白と黒を交互に通るので、通れるマスが奇数個なら 0)
入力例3
5 6
#.....
......
......
....#.
......出力例3
24(3 例とも全探索で確認)
背景と出典
境界を越える線の状態を持って 1 マスずつ進む DP は「プラグ DP」「broken profile DP」と呼ばれ、中国の競技プログラミングでは 2008 年の陈丹琦の論文「基于连通性状态压缩的动态规划问题」で体系化された、と私は記憶している(本セッションでは原典を参照していない)。正方形の盤面のハミルトン閉路の数は OEIS A003763 として $1, 6, 1072, 4638576, 467260456608, 1076226888605605706$($2\times2$ 〜 $12\times12$)と私は記憶しており、本解(法を取らない版)の出力はこの 6 つと一致した(OEIS 本体は本セッションでは未参照)。
括弧列で持つ理由: 処理済みの領域と未処理の領域の境界(輪郭)を左から順に見ると、境界を越える線の端は、処理済みの側で 2 つずつ道でつながっている。平面上の線は交差しないので、つながっている対は入れ子か横並びになり、**左側の端を (、右側の端を )** と書けば正しい括弧列になる。端の数は最大 $W+1$、括弧列の個数は幅 10 で 2120 通りしか現れなかった(実測)。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
左上から右下へ 1 マスずつ決めていく。各マスは「上から入る線」「左から入る線」の有無を受け取り、「下へ出す線」「右へ出す線」の有無を決める。通れるマスでは入る線と出る線の合計がちょうど 2 本。
ヒント2(アプローチ)
線の有無だけでは、2 本の線が出会ったときに閉路が閉じるのかどうか分からない。境界の線の端に「同じ道でつながっている相手」の情報を持たせる。交差しないので、相手の情報は括弧(( と ))で足りる。状態は長さ $W+1$ の列で、各位置は 0(線なし)・1(()・2())。2 ビットずつ整数に詰めて辞書のキーにする。
ヒント3(誘導)
上 $u$ と左 $l$ がどちらもあるときの 4 通り:
- $l=($、$u=)$: 互いが相手 → 閉路が閉じる。最後の通れるマスでだけ数える
- $l=($、$u=($: $u$ の相手の
)を右側で探して(に変える - $l=)$、$u=)$: $l$ の相手の
(を左側で探して)に変える - $l=)$、$u=($: そのままつなぐ(両者の相手どうしが新しい対になる)
列を幅の狭い向きにそろえる($W>H$ なら転置する)と、状態の数が抑えられる。
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
MOD = 998244353
def main():
data = sys.stdin.read().split()
h, w = int(data[0]), int(data[1])
g = data[2:2 + h]
if w > h: # 狭い方を幅にする(状態数は幅で決まる)
g = [''.join(g[i][j] for i in range(h)) for j in range(w)]
h, w = w, h
cells = [(i, j) for i in range(h) for j in range(w) if g[i][j] == '.']
if not cells:
print(0)
return
last = cells[-1]
# 状態: 幅 w+1 個の差し口。0=なし 1=左括弧 2=右括弧。2 ビットずつ整数に詰める
# 位置 j は「セル (i,j) の上から入る線」、位置 w は処理中セルの左から入る線
cur = {0: 1}
ans = 0
for i in range(h):
# 行の頭: 左からの線は無いはず(位置 w が 0 のものだけ残す)
cur = {s: c for s, c in cur.items() if (s >> (2 * w)) & 3 == 0}
for j in range(w):
nxt = {}
sh_u = 2 * j
sh_l = 2 * w
free = g[i][j] == '.'
down_ok = i + 1 < h and g[i + 1][j] == '.'
right_ok = j + 1 < w and g[i][j + 1] == '.'
is_last = (i, j) == last
for s, c in cur.items():
up = (s >> sh_u) & 3
lf = (s >> sh_l) & 3
base = s & ~((3 << sh_u) | (3 << sh_l))
if not free:
if up == 0 and lf == 0:
nxt[base] = nxt.get(base, 0) + c
continue
if up == 0 and lf == 0:
# 新しい角: 下と右へ出す(左括弧が下、右括弧が右)
if down_ok and right_ok:
t = base | (1 << sh_u) | (2 << sh_l)
nxt[t] = nxt.get(t, 0) + c
elif up == 0 or lf == 0:
v = up | lf
if down_ok:
t = base | (v << sh_u)
nxt[t] = nxt.get(t, 0) + c
if right_ok:
t = base | (v << sh_l)
nxt[t] = nxt.get(t, 0) + c
else:
# 2 本が合流する
if lf == 1 and up == 2:
# 閉路が閉じる: 最後のセルで他に差し口が無いときだけ
if is_last and base == 0:
ans += c
continue
if lf == 1 and up == 1:
# 2 つの左括弧: 上の線の相手(右括弧)を左括弧に変える
t = base
depth = 0
for k in range(j + 1, w):
x = (t >> (2 * k)) & 3
if x == 1:
depth += 1
elif x == 2:
if depth == 0:
t ^= (3 << (2 * k)) # 2 -> 1
break
depth -= 1
elif lf == 2 and up == 2:
# 2 つの右括弧: 左の線の相手(左括弧)を右括弧に変える
t = base
depth = 0
for k in range(j - 1, -1, -1):
x = (t >> (2 * k)) & 3
if x == 2:
depth += 1
elif x == 1:
if depth == 0:
t ^= (3 << (2 * k)) # 1 -> 2
break
depth -= 1
else:
# lf == 2, up == 1: 2 つの弧をつなぐだけ
t = base
nxt[t] = nxt.get(t, 0) + c
cur = {k: v % MOD for k, v in nxt.items()}
print(ans % MOD)
main()
計算量: 状態数を $S$(幅 $W$ の括弧列の個数、幅 10 で最大 2120)とすると、1 マスあたり $O(S\cdot W)$(相手の括弧を探す走査が $O(W)$)。全体で $O(HW\cdot S\cdot W)$。$10\times30$ で 0.27 s、$10\times10$ で 0.09 s、$8\times50$(制約外)で 0.09 s(Python 3.14.7、M1 Mac、負荷平均 5〜6 のときの実測)。
Step-by-Step 解説
1 マス $(i,j)$ を処理する直前の輪郭は、左から「下へ出た線 $0..j-1$」「左から入る線」「上から入る線 $j..W-1$」と並ぶ。コードでは位置 $0..W-1$ を各列の縦の線、位置 $W$ を左から入る線に割り当てている。並びの順は「位置 $0..j-1$、位置 $W$、位置 $j..W-1$」と読む。
- 0 本: 新しい角を作る。下へ
(、右へ)を出す(下へ出る線が輪郭上で左にあるから)。下と右のどちらかが壁なら作れない - 1 本: その線を下か右へ曲げずに/曲げて出す。括弧の種類はそのまま(輪郭上の順番が変わらないので、相手との関係も変わらない)
ヒント3 の 4 通り。(( の場合、$u$ の相手の ) は $u$ より右で、深さ 0 で最初に出会う )。$l$ の相手の ) はさらに右にあるので、合流後は「$u$ の相手」と「$l$ の相手」が新しい対になり、左にある「$u$ の相手」が ( になる。)) は左右を反転した同じ話。
() の合流で閉路が閉じる。途中で閉じると、残りのマスを別の閉路で覆うことになり、ハミルトン閉路ではなくなる。最後の通れるマスでは、下や右へ出る線は作れない(その先に通れるマスが無い)ので、輪郭に他の線は残っていない。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| DFS でハミルトン閉路を全列挙 | 指数(答えの個数以上) | 検算用のみ | $H\le5$、$W\le6$ のランダム 400 件の正解に使用 |
| 線の有無だけの 0/1 プロファイル DP | $O(HW\cdot2^W)$ | 不採用 | 閉路が 1 つか複数かを区別できず、閉路の被覆(2 因子)を数えてしまう |
| 最小表現(各線に連結成分の番号を振る) | 状態数は同程度 | 不採用 | 一般の連結性(線が交差しうる・枝分かれ)にも使えるが、正規化(番号の振り直し)が毎回要る。閉路だけなら括弧列で足りる |
| 括弧列のプラグ DP(本解) | $O(HW\cdot S\cdot W)$ | 採用 | 400 件一致、$2\times2$〜$12\times12$ の既知の値と一致 |
| 転置せずに $H\times W$ のまま | 幅が 30 になりうる | 不採用 | $6\times14$ を転置しないと最大状態 93165・1.35 s、転置すると 59・0.04 s(実測)。$6\times20$ を転置しないと CPU 10 分で終わらず打ち切った |
前提が変わったら:
- ハミルトン「路」(始点と終点が別)なら、端点で線が 1 本だけ出る「独立した線」の種類(3 つ目の記号)を状態に足す
- 全マスでなく「閉路を 1 つ」(通らないマスがあってよい)なら、通れるマスで「線 0 本」を許し、閉じた瞬間に他の線が無ければ答えに足す(途中で閉じてもよい)
- 幅が 12 を超えると状態数が数万を超え、Python の辞書では重くなる。状態の列挙を先に済ませて番号で配列を引く、遷移表を前計算するなどが要る
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
どのマスでも () の合流で答えに足す | 閉路が閉じたら終わりだと思う | 最後の通れるマスだけ(400 件中 149 件誤答、実測) |
((・)) の合流で相手の括弧を付け替えない | 括弧の意味を「つながりの相手」ではなく向きだと思う | 相手を深さ付きで探して付け替える(400 件中 32 件誤答、実測) |
| 「最後のマスで、他に線が無い」を確かめ忘れる | 他の線が残ると思う | 実害なし: 下と右の通れるマスにしか線を出さないので、最後のマスでは他の線が残らない(確認を外しても 400 件中 0 件の差、実測)。ただし入れておくのが安全 |
| 行の変わり目で左の線を捨てない | 右端から右へ出た線が残る | 右端では右へ出さない(right_ok)ので残らないが、行の頭で位置 $W$ が 0 のものだけ残すと安全 |
| 幅の広い向きのまま回す | 状態数が幅で決まることを忘れる | 狭い方を幅に転置する |
次のステップ
- 発展1: ハミルトン路(始点・終点を指定)の数を、括弧+独立した線の 3 記号のプラグ DP で数え、全探索で確かめる
- 発展2: 「通らないマスがあってよい閉路 1 つ」の数(1 つの単純閉路の数え上げ)
- 発展3: OEIS A003763 と陈丹琦の論文の原典を確かめる(本セッションでは未確認)
検証ログ(本セッションで実行した数値)
- 全探索との照合(実測): $H\le5$、$W\le6$、壁の割合 0〜30% のランダム 400 件で不一致 0(答えが 0 でないもの 86 件)。全探索は 1 つの通れるマスから DFS で全経路をたどり、向きの 2 通りで割る
- 既知の値との照合(実測): 法を取らない版で正方形 $2\times2$ 〜 $12\times12$ が 1, 6, 1072, 4638576, 467260456608, 1076226888605605706(私の記憶する OEIS A003763 の値と一致)。$11\times11$ は 0(マス数が奇数)
- 誤った方法(実測): 同じ 400 件で、どこでも閉じる 149 件・括弧を付け替えない 32 件誤答。「他に線が無い」の確認を外しても 0 件(理由は上の表)
- 状態数(実測): 幅 10 の最大は 2120、幅 6 は 59。転置しない $6\times14$(幅 14)は 93165
- 入力例(実測): 例 1
6、例 20、例 324(全探索と一致) - 実行時間(実測、負荷平均 5〜6): $10\times10$ 0.09 s、$10\times20$ 0.18 s、$10\times30$ 0.26 s、$8\times50$ 0.08 s。法を取らない版の $12\times12$ は 0.51 s。負荷平均 36 のときは $10\times30$ で 0.93 s
- 扱っていない範囲: ハミルトン路、通らないマスのある閉路、幅 12 超の高速化、原典の確認