Day 174-Q2 — 2026-10-10 | 盤面を m 本の路で覆う数(端点の数ごとの係数を 1 つの整数に詰めたプラグ DP)

2026-10-10 赤色 Master (Phase 8+) ★★★★★★★★★ 高度DP(焦点帯 M1)。Day172-Q2 は端点を指定したハミルトン路、Day173-Q1 は端点を指定しないハミルトン路(端点の数 $k\le2$)を数えた。今回は路の本数 $m$ を 1〜$K$ まで全部数える。状態に端点の数を足すと遅くなるので、端点の数ごとの値を1 つの多倍長整数に詰める

問題

$H\times W$ の盤面があり、各マスは .(空き)か #(壁)である。上下左右に隣り合う空きマスの間の辺から何本かを選び、どの空きマスもちょうど 1 本の路に属するようにする(各空きマスにつながる選んだ辺は 2 本以下、閉路なし)。このとき空きマスは何本かの互いに素な路に分かれる。辺を 1 本も持たない空きマスは、それだけで 1 本の路と数える。

$m=1,2,\dots,K$ のそれぞれについて、路がちょうど $m$ 本になる辺の選び方の数を $998244353$ で割った余りを出力せよ。選んだ辺の集合が異なれば異なる選び方とする。

入力形式

H W K
S_1
...
S_H

制約

#条件
1$1\le H,W\le30$、$\min(H,W)\le6$
2$1\le K\le20$
3$S_i$ は . と # からなる長さ $W$ の文字列

入出力例

入力例1

2 2 4
..
..

出力例1

4 6 4 1

($m=2$ は「辺 2 本の 2 通り」と「3 マスの路+1 マスの 4 通り」で 6)

入力例2

3 3 5
...
.#.
...

出力例2

8 28 56 70 56

(空きマスは長さ 8 の輪。輪から $m$ 本の辺を外すと $m$ 本の路になるので $\binom8m$)

入力例3

2 3 3
...
...

出力例3

8 25 33

(3 例とも辺集合の全探索と一致)

背景と出典

  • プラグ DP(Day171-Q3・Day172-Q2・Day173-Q1): 1 マスずつ処理し、処理済みと未処理の境界(プロファイル)を横切る線を「プラグ」として持つ。線の両端が境界に出ていれば括弧 ( )、片方が既に路の端点で終わっていれば記号 3
  • 本問の数え上げは「各成分が路の全域部分グラフ(線形森)を、成分数ごとに数える」こと。成分数 = 空きマス数 − 辺数なので、辺数ごとに数えても同じ。本問は端点の数 $e$(路 1 本につき 2、1 マスの路も 2)で数える
  • 状態に $e$ を足す素直な方法は、Day173-Q1 のようにキーを st << 6 | e にする。$K=10$ だと同じプロファイルが最大 21 通りの $e$ に分かれ、括弧の相方探しも 21 回繰り返す。本問は同じプロファイルの $e=0..2K$ の値を 80 ビットずつ 1 つの整数に詰め、端点が 1 つ増える遷移を「80 ビット左シフト」に置き換える

本問は自作。$n\times n$ の $m=1$ の値は Day173-Q1 の端点自由ハミルトン路の数 1, 4, 20, 276, 4324, 229348, 13535280 と一致した(本セッションで実行)。

概念図

2×3 を m = 2 本の路で覆う一例と、処理途中の境界を横切るプラグ 端点(●)は 4 個 → e = 4 = 2m 1 マスだけの路は端点 2 個と数える プラグ(境界を横切る線)の 4 種類 0なし 1 (線の左端。相方 ) と同じ線 2 )線の右端。相方 ( と同じ線 3もう一方の端が路の端点で確定済み 値の持ち方: 同じ状態の e = 0..2K を 1 つの整数に c_0 c_1 c_2 c_3 c_4 c_5 各区画 80 ビット。端点が 1 つ増える遷移 = 80 ビット左シフト 6×30, K=10: キーに e を含める 3.71 s → 詰める 0.70 s

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

ヒント1(方向性)

Day173-Q1 のプラグ DP(括弧 2 種+端点につながる線 3)で、端点の数の上限を 2 から $2K$ に上げればよい。最後に $e=2m$ の値を読む。

ヒント2(アプローチ)

マス $(i,j)$ に入ってくる線(左 $a$、上 $b$)で場合分けする。

  • 線なし: 新しい線の両端を下と右へ(( ))/ここを端点にして下か右へ 3 を出す($e+1$)/1 マスだけの路($e+2$)
  • 1 本だけ: 下か右へ延ばす/ここで終える($e+1$)。3 なら路が完成、( ) なら相方の括弧が 3 になる
  • 2 本: ( と ) の組(同じ線の両端)は閉路なので禁止。3 と 3 は路が完成。3 と括弧は相方が 3 に。(( )) )( は Day171-Q3 と同じつなぎ替え
ヒント3(誘導)
  • 値を dict[状態] = 詰めた整数 で持つ。$e$ が $d$ 増える遷移は (val << (80*d)) & mask($e>2K$ の部分は切り捨て)
  • 1 行の終わりまで mod を取らず、行の頭で各 80 ビットの区画を取り出して mod を取る。区画は桁あふれしない(Step 4)

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys

MOD = 998244353
S = 80                                          # 1 つの係数に使うビット数(行の終わりまで mod を取らない)


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 = int(data[0]), int(data[1]), int(data[2])
    g = data[3:3 + h]
    if w > h:                                   # プロファイルの長さを短い辺にする
        g = [''.join(g[i][j] for i in range(h)) for j in range(w)]
        h, w = w, h
    slots = 2 * k + 1                           # 端点の数 e = 0..2k の係数を 1 つの整数に詰める
    mask = (1 << (S * slots)) - 1
    free = [[c == '.' for c in row] + [False] for row in g] + [[False] * (w + 1)]

    def reduce(v):
        r = 0
        for t in range(slots):
            r |= (((v >> (S * t)) & ((1 << S) - 1)) % MOD) << (S * t)
        return r

    # プラグ: 0 なし / 1 '(' / 2 ')' / 3 端点につながる線。位置 j は 2j ビット目
    cur = {0: 1}
    for i in range(h):
        cur = {st << 2: reduce(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
                v1 = (val << S) & mask          # 端点が 1 つ増える
                if a == 0 and b == 0:
                    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)) & mask)   # 1 マスだけの路
                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) + v1
                else:
                    if a == 1 and b == 2:
                        continue                # 同じ線の両端どうし: 閉路になるので禁止
                    if a == 3 and b == 3:
                        s2 = base               # 路が 1 本完成
                    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:                       # a == 2 and b == 2
                        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))
    print(' '.join(str((v >> (S * (2 * m))) & ((1 << S) - 1)) for m in range(1, k + 1)))


main()

計算量: プロファイルの状態数を $S_w$(幅 5・6・7 で最大 264・858・2860、実測)とすると、マスごとに状態数ぶんの遷移と、括弧の相方探し $O(w)$、詰めた整数の加算 $O(K)$ ワード。全体 $O(HW\cdot S_w\cdot(w+K))$。$6\times30$、$K=20$ で 0.59〜0.74 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 2〜3 での実測、起動込み)。

Step-by-Step 解説

1短い辺をプロファイルに

$W>H$ なら転置する。状態は 4 進で $w+1$ 個のプラグ(0 なし/1 (/2 )/3 端点につながる線)。行の頭で、右端のプラグが 0 の状態だけを 2 ビット左へずらす。

2端点の数え方

端点は「線がそのマスで終わる」たびに 1 つ数える。3 の線を作ったとき(始点)に $+1$、線を終えたとき(終点)に $+1$、1 マスの路は $+2$。3 と 3 が出会うマスは路の途中なので数えない。最後にすべてのプラグが 0 で $e=2m$ なら、路がちょうど $m$ 本。

3括弧の相方探し

相方は 3 を飛ばして括弧の深さを数える。( を終えると、その線のもう一方の端(相方の ))は「端点につながる線」になるので 3 に書き換える。(( の合流では 2 本目の ( の相方が ( に、)) では 1 本目の ) の相方が ) になる(Day171-Q3 と同じ)。

4係数を 1 つの整数に詰める

同じプロファイルの $e=0..2K$ の値をまとめて 1 つの整数 $\sum_e c_e 2^{80e}$ で持つ。遷移は「整数の足し算」と「左シフト+マスク」だけになり、相方探しはプロファイル 1 つにつき 1 回で済む。1 マス進むごとに、全状態の区画の総和は高々 4 倍(1 つの状態から出る遷移は 4 本以下)。幅 7 で 1 行 $4^7=2^{14}$ 倍、状態数 $<2^{12}$、元の値 $<2^{30}$ なので区画は $2^{56}$ 未満に収まり、80 ビットで足りる。

5素直な方法との差

キーを st << 6 | e にした版と出力が一致したうえで、$6\times30$、$K=10$ で 3.71 s → 0.70 s、$7\times30$ で 15.46 s → 2.08 s(実測)。

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

案計算量採否理由
辺集合の全探索$O(2^{E})$不採用検証用。$4\times4$(24 辺)まで
キーに $e$ を含める(Day173-Q1 の拡張)$O(HW S_w K w)$不採用正しいが、$6\times30$ で 3.71 s、$7\times30$ で 15.46 s(実測)
値を長さ $2K+1$ のリストで持つ同上(定数が大)不採用Python のリスト加算はループになり、整数 1 回の加算より遅い(本セッションでは未計測)
路の本数ではなく辺数で数え、最後に $m=\#\text{空き}-\#\text{辺}$ に読み替える同じ不採用正しいが、辺数は最大 $HW$ まで伸びるので区画数が増える。端点の数なら $2K$ で打ち切れる
端点の数ごとの値を 1 つの整数に詰める(本解)$O(HW S_w (w+K))$採用$6\times30$ で 0.59〜0.74 s

前提が変わったら:

  • $\min(H,W)=7$ なら $7\times30$ で 2.08 s(実測)。状態数 2860 を減らすには、括弧列を最小表現の番号付けにして状態を配列化する必要がある
  • $K$ が $HW$ 程度(全部の本数)なら、区画数が増えて整数が長くなる。辺数で数える方が素直
  • 路ではなく閉路も許す(2-因子+路)なら、( ) の合流を許す版になる。その場合は閉路の数も数えたくなるので、別の係数が要る

よくあるミス

ミス原因正しい書き方
( と ) の合流を許すDay171-Q3 の閉路の DP をそのまま使う400 盤面中 187 盤面で誤答(実測)。路だけを数えるなら禁止
1 マスだけの路を忘れる「路は辺を持つ」と思い込む400 盤面中 357 盤面で誤答(実測)。$e+2$ の遷移を足す
3 と 3 の合流でも端点を数える端点を「出会い」で数える端点は線の始点と終点でだけ数える
括弧の相方探しで 3 を括弧として数える3 は括弧の対応に入らない3 は飛ばして深さを数える
詰めた整数を毎マス mod する区画ごとに取り出すので遅い行の頭で 1 回だけ。桁あふれしない理由は Step 4

次のステップ

  • 発展1: 括弧列の最小表現で状態を番号付けし、$\min(H,W)=8$ まで広げる
  • 発展2: 路と閉路の両方を許す「次数 2 以下の全域部分グラフ」を、路の本数と閉路の本数の 2 変数で数える
  • 発展3: 端点の位置に条件をつける(例: 端点はすべて盤面の外周にある)

検証ログ(本セッションで実行した数値)

  • 正解との照合(実測): $H\le4$, $W\le5$($HW\le16$)、壁の確率 0/0.1/0.25、$K\le10$ のランダム盤面で、辺集合の全探索(次数 2 以下・閉路なしの辺集合を数え、成分数 = マス数 − 辺数)と一致。キーに $e$ を含めた版で 800 盤面、本解(詰めた整数)で 400 盤面、不一致 0
  • 誤った方法(実測、400 盤面): ( ) の合流を許すと 187 盤面、1 マスの路を数えないと 357 盤面で誤答
  • 既知の値との一致(実測): $n\times n$ の $m=1$ が $n=1..7$ で 1, 4, 20, 276, 4324, 229348, 13535280(Day173-Q1 の出力と一致)。入力例 2 の輪は $\binom8m$ と一致
  • 実行時間(実測、起動込み、本解/キーに $e$ を含めた版): $5\times30$, $K=10$: 0.32 s/0.92 s、$6\times30$, $K=10$: 0.70 s/3.71 s、$6\times30$, $K=20$: 0.59〜0.74 s/7.67 s、$7\times30$: 2.08 s/15.46 s、$8\times10$: 2.38 s/15.72 s。$30\times6$ で壁 10%: 0.24 s。両版の出力は 5 盤面すべてで一致
  • 状態数(実測): 幅 5・6・7 でプロファイル最大 264・858・2860 個($K$ によらない)
  • 扱っていない範囲: 幅 7 以上、閉路を含む数え上げ、状態の配列化

自己評価

自分の回答

気づき・メモ