Day 173-Q1 — 2026-10-09 | 端点を指定しないハミルトン路の数(3 記号のプラグ DP に「使った端点の数 k」を足す)

2026-10-09 赤色 Master (Phase 8+) ★★★★★★★★★ 高度 DP(焦点帯 M1)。Day172-Q2 は始点 S と終点 T が盤面に書かれていた。今回はどのマスが端点でもよい。各マスで「ここを端点にする」を選べるようにし、使った端点の数 $k\in\{0,1,2\}$ を状態に足す

問題

$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(3×3)の 20 本のうちの 1 本: 端点が中央と右上にある路 1 2 赤丸 = 端点(次数 1)。他の 7 マスは通過(次数 2) 状態 = (境界の記号列, 使った端点の数 k) キーは st << 2 | k の 1 つの整数 通過の遷移: k はそのまま(Day171-Q3 と同じ) 端点の遷移: k → k+1、k < 2 のときだけ  (Day172-Q2 で S・T のマスにだけあった遷移) ( と ) が出会う → 閉路なので禁止 3 と 3 が出会う → 路が完成 答え = 記号列すべて 0 かつ k = 2 の値

ヒント(クリックで段階的に開示)

ヒント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 解説

1Day172-Q2 との違いは「どこが端点か」を DP が選ぶこと

端点指定の版では S T のマスで端点の遷移、それ以外で通過の遷移と、マスごとに遷移が 1 種類だった。今回は各マスで両方の遷移を試す。何もしないと端点を 3 個以上作る経路も混ざるので、使った端点の数 $k$ を持って $k\le2$ に抑える。

2状態を 1 つの整数にまとめる

キー st << 2 | k。$k$ は 0〜2 なので 2 ビットで足りる。行末のずらしは記号列の部分だけ << 2 し、下の 2 ビット($k$)はそのまま残す。最初にタプル (st, k) で書いたが、整数にまとめても結果は同じ(DFS との照合 1477 盤面で確認)。

3閉路を作らない

通過の遷移で ( と ) が出会うと、その 2 本は既につながっているので閉路になる。閉路を許すと 1477 盤面中 489 盤面で答えが変わった(実測)。

4完成と終了

3 と 3 が通過マスで出会う、または端点マスに 3 が届くと路が 1 本完成する。そのあと通れるマスが残っていれば、そのマスは線を出すしかなく、最後に記号列 0 に戻れないので数えられない。最後に「記号列 0 かつ $k=2$」を読む。向きは区別しないので 2 で割る必要はない(DP は線の集合を数えている)。

5幅で制約が決まる

端点の位置が自由なので、同じ幅でも記号 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、例 2 8、例 3 40
  • 状態数の最大(実測): 幅 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 の確認

自己評価

自分の回答

気づき・メモ