Day 171-Q3 — 2026-10-06 | 盤面のハミルトン閉路の数(括弧列で連結を持つプラグ DP。2 本の線が出会ったときに相手の括弧を付け替える)

2026-10-06 赤色 Master (Phase 8+) ★★★★★★★★★ 高度 DP(焦点帯 M1)。Day167-Q2 のドミノ敷き詰めは「境界を越える線があるか」の 0/1 だけで状態が閉じた。閉路はどの線とどの線が同じ道でつながっているかまで持たないと、閉じたときに「1 つの大きな閉路」か「小さな閉路が 2 つ」かを区別できない。線は交差しないので、つながり方は括弧列で書ける

問題

$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 通りしか現れなかった(実測)。

概念図

4×4 盤面でマス (2,2) を処理する直前。処理済み(濃い色)と未処理の境界を越える線の端に括弧を付ける (()) 輪郭を左から読むと ( ( ) ) — 入れ子のつながり マス (2,2) に入る線: 上 = ")"(赤の弧)、左 = なし → 下か右へ曲げて出す。括弧の種類はそのまま 2 本が出会うとき(左 l・上 u) l=( u=) : 互いが相手 → 閉路が閉じる(最後のマスだけ) l=( u=( : u の相手の ) を右で探して ( に変える l=) u=) : l の相手の ( を左で探して ) に変える l=) u=( : そのままつなぐ 状態 = 長さ W+1 の 0/1/2 の列(2 ビットずつ詰めた整数) 幅 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状態の並び

1 マス $(i,j)$ を処理する直前の輪郭は、左から「下へ出た線 $0..j-1$」「左から入る線」「上から入る線 $j..W-1$」と並ぶ。コードでは位置 $0..W-1$ を各列の縦の線、位置 $W$ を左から入る線に割り当てている。並びの順は「位置 $0..j-1$、位置 $W$、位置 $j..W-1$」と読む。

2線が 0 本・1 本のとき
  • 0 本: 新しい角を作る。下へ (、右へ ) を出す(下へ出る線が輪郭上で左にあるから)。下と右のどちらかが壁なら作れない
  • 1 本: その線を下か右へ曲げずに/曲げて出す。括弧の種類はそのまま(輪郭上の順番が変わらないので、相手との関係も変わらない)
3線が 2 本のとき(合流)

ヒント3 の 4 通り。(( の場合、$u$ の相手の ) は $u$ より右で、深さ 0 で最初に出会う )。$l$ の相手の ) はさらに右にあるので、合流後は「$u$ の相手」と「$l$ の相手」が新しい対になり、左にある「$u$ の相手」が ( になる。)) は左右を反転した同じ話。

4閉路を閉じてよいのは最後の 1 回だけ

() の合流で閉路が閉じる。途中で閉じると、残りのマスを別の閉路で覆うことになり、ハミルトン閉路ではなくなる。最後の通れるマスでは、下や右へ出る線は作れない(その先に通れるマスが無い)ので、輪郭に他の線は残っていない。

迷った他案と採らなかった理由

案計算量採否理由
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、例 2 0、例 3 24(全探索と一致)
  • 実行時間(実測、負荷平均 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 超の高速化、原典の確認

自己評価

自分の回答

気づき・メモ