Day 175-Q2 — 2026-10-11 | 次数 2 以下の全域部分グラフを「閉路の数 × 路の数」で数える(2 変数の係数を 1 つの整数に詰めたプラグ DP)

2026-10-11 赤色 Master (Phase 8+) ★★★★★★★★★ 高度DP(焦点帯 M1)。Day174-Q2 は「路だけ」を路の本数ごとに数え、閉路(( と ) の合流)を禁止した。今回は閉路も許し、**閉路の数 $c$ と路の数 $m$ の 2 変数**で数える。係数は $(C+1)(2K+1)$ 個に増え、Day174-Q2 の詰め方をそのまま使うと 2 つの落とし穴(区画のはみ出し、mod を取る手間)に当たる

問題

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

$0\le c\le C$、$0\le m\le K$ のそれぞれについて、閉路がちょうど $c$ 個・路がちょうど $m$ 本になる辺の選び方の数を $998244353$ で割った余りを出力せよ。

入力形式

H W K C
S_1
...
S_H

出力形式

$C+1$ 行。$c$ 行目($c=0,1,\dots,C$)に、$m=0,1,\dots,K$ の値を空白区切りで出力する。

制約

  • $1\le H,W\le30$、$\min(H,W)\le6$
  • $1\le K\le10$、$1\le C\le10$
  • $S_i$ は . と # からなる長さ $W$ の文字列

入出力例

入力例1

2 2 2 1
..
..

出力例1

0 4 6
1 0 0

($c=0$ は Day174-Q2 と同じ路だけの数。$c=1$, $m=0$ は 4 辺全部の輪 1 通り)

入力例2

3 3 2 1
...
.#.
...

出力例2

0 8 28
1 0 0

(空きマスは長さ 8 の輪。輪全体が閉路 1 個、そこから $m$ 本の辺を外すと $m$ 本の路で $\binom8m$)

入力例3

2 3 3 1
...
...

出力例3

0 8 25 33
1 2 2 0

($c=1$: 6 マスの輪が $m=0$ で 1 通り、左右どちらかの $2\times2$ の輪+残り 2 マス(つなぐ/つながない)が $m=1,2$ で 2 通りずつ。3 例とも辺集合の全探索と一致)

---

背景と出典

  • プラグ DP(Day171-Q3・Day172-Q2・Day173-Q1・Day174-Q2): 1 マスずつ処理し、処理済みと未処理の境界を横切る線を「プラグ」として持つ。両端が境界に出ている線は括弧 ( )、片方が既に路の端点で終わっている線は記号 3
  • 本問の数え上げは、グラフ理論でいう「最大次数 2 の全域部分グラフ」を成分の形で分けたもの。$m=0$ の列は2-因子(全頂点が閉路に乗る)を閉路の数ごとに数えたものになる。統計力学の $O(n)$ ループ模型の分配関数(閉路に重み $n$)に近い形だと私は理解している(本セッションでは文献を参照していない)
  • 検算の柱: 閉路と路の内訳を忘れると、「次数 2 以下の辺集合の総数」は連結性を持たない単純な DP(プラグは辺の有無だけ)で数えられる。$C,K$ を十分大きくした本解の全係数の和がこれと一致することを $7\times8$ などで確かめた
  • 本問は自作。Day174-Q2 の「発展2」をそのまま本問にした

概念図

入力例 3(2×3)の閉路 1 個の配置と、係数を詰める区画(c 行 × e 列)m=0(6 マスの輪)m=1(輪+路)m=2(輪+1 マス×2)右側の 2×2 の輪も同じく m=1, 2 で 1 通りずつ → c=1 の行は 1 2 2 0c=0e=0e=1e=2e=3e=4e=5e=6c=1e=0e=1e=2e=3e=4e=5e=6c=2e=0e=1e=2e=3e=4e=5e=6区画番号 = c·(2K+1) + e(ここでは K=3)。端点 +1 は 1 区画、閉路 +1 は 1 行ぶん左シフト赤枠 e=2K に端点 +1 のシフトをかけると、次の行の e=0 に入り込む → keep1 で消す(忘れると 400 盤面中 72 盤面で誤答)

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

ヒント1(方向性)

Day174-Q2 との違いは 1 か所だけ: 左から (、上から ) が来たマス(同じ線の両端)。Day174-Q2 では禁止したが、本問では閉路が 1 つ閉じる遷移として許す。

ヒント2(アプローチ)

係数を「区画番号 $= c\cdot(2K+1)+e$」($e$ は端点の数)で 1 つの整数に詰める。端点が増えたら 80 ビット、閉路が増えたら $80(2K+1)$ ビット左シフト。ただし $e$ が $2K$ を超えた分は**次の $c$ の区画にはみ出す**ので、シフトのあとに消す。

ヒント3(誘導)
  • 端点 +1 は (val << 80) & keep1、+2 は (val << 160) & keep2。keep1 は各 $c$ の $e=0$ の区画を 0 にしたマスク(シフト後に $e=0$ に来るのは前の $c$ の $e=2K$ から来た値)
  • 区画は $(C+1)(2K+1)=231$ 個まで増える。mod を取るときに区画を 1 つずつシフトで取り出すと区画数の 2 乗かかるので、to_bytes で一度にバイト列にして 10 バイトずつ切る
  • mod は毎行でなく、桁あふれしない範囲で数行に 1 回(Step 5)

模範解答 (Python)

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

MOD = 998244353
S = 80                                          # 1 つの係数に使うビット数


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, c = int(data[0]), int(data[1]), int(data[2]), int(data[3])
    g = data[4:4 + h]
    if w > h:                                   # プロファイルの長さを短い辺にする
        g = [''.join(g[i][j] for i in range(h)) for j in range(w)]
        h, w = w, h
    E = 2 * k + 1                               # 端点の数 e = 0..2k
    slots = E * (c + 1)                         # 区画番号 = 閉路の数 * E + 端点の数
    mask = (1 << (S * slots)) - 1
    CYC = S * E                                 # 閉路が 1 つ増える = この幅だけ左シフト
    # 端点が d 増えたとき e > 2k になった区画は、次の閉路の数の区画へはみ出すので消す
    keep1 = sum(((1 << S) - 1) << (S * (cc * E + e)) for cc in range(c + 1) for e in range(1, E))
    keep2 = sum(((1 << S) - 1) << (S * (cc * E + e)) for cc in range(c + 1) for e in range(2, E))
    free = [[ch == '.' for ch in row] + [False] for row in g] + [[False] * (w + 1)]

    B = S // 8                                  # 1 区画のバイト数
    nb = B * slots
    frm = int.from_bytes

    def reduce(v):
        # 区画ごとに mod。シフトで 1 区画ずつ取り出すと区画数の 2 乗かかるので、バイト列で切り出す
        bs = v.to_bytes(nb, 'little')
        return frm(b''.join((frm(bs[t:t + B], 'little') % MOD).to_bytes(B, 'little')
                            for t in range(0, nb, B)), 'little')

    # 状態数 < 2^12(幅 7 以下)、mod 直後の区画 < 2^30、1 マスで総和は高々 4 倍 → 30+12+2*マス数 < 80
    per = max(1, (S - 1 - 30 - 12) // (2 * w))
    cur = {0: 1}
    for i in range(h):
        if i % per == 0:                        # per 行ごとにだけ mod を取る(Step 5 の上限)
            cur = {st << 2: reduce(v) for st, v in cur.items() if st >> (2 * w) == 0}
        else:
            cur = {st << 2: 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
                if a == 0 and b == 0:
                    v1 = (val << S) & keep1
                    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)) & keep2)
                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) + ((val << S) & keep1)
                else:
                    if a == 1 and b == 2:       # 同じ線の両端: 閉路が 1 つ閉じる
                        nxt[base] = get(base, 0) + ((val << CYC) & mask)
                        continue
                    if a == 3 and b == 3:
                        s2 = base
                    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:
                        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))
    m1 = (1 << S) - 1
    out = []
    for cc in range(c + 1):
        out.append(' '.join(str((v >> (S * (cc * E + 2 * m))) & m1) for m in range(k + 1)))
    print('\n'.join(out))


main()

計算量: プロファイルの状態数を $S_w$(幅 6 で 858、Day174-Q2 の実測)とすると、マスごとに状態数ぶんの遷移、括弧の相方探し $O(w)$、詰めた整数の加算 $O(CK)$ ワード。mod は $\lfloor37/(2w)\rfloor$ 行ごとに $O(S_w\cdot CK)$。全体 $O(HW\cdot S_w\cdot(w+CK))$。$6\times30$、$K=C=10$ で 1.04 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 11〜13 での実測、起動込み)。

Step-by-Step 解説

1閉路が閉じる遷移

左のプラグ $a$ と上のプラグ $b$ が ( と ) の組なら、それは同じ線の両端(隣り合う括弧は必ず対応する)。このマスでつなぐと閉路が 1 つ完成する。新しい状態は 2 つのプラグを消したもので、係数は $c+1$ の区画へ移す。他の遷移はすべて Day174-Q2 と同じ。

22 変数を 1 つの整数に

区画 $t=c(2K+1)+e$ に係数を置き、整数 $\sum_t v_t2^{80t}$ で持つ。

  • 端点 +1: << 80、+2(1 マスだけの路): << 160
  • 閉路 +1: << 80*(2K+1)。$c>C$ の分は全体のマスクで消える

3区画のはみ出し

$e=2K$ の値に端点 +1 のシフトをかけると、その値は $(c+1, e=0)$ の区画に入る。1 変数の Day174-Q2 では「全体のマスク」で消えたが、2 変数では途中の区画に残ってしまう。シフトのあとに keep1(各 $c$ の $e=0$ を除くマスク)を掛ける。これを忘れた版は 400 盤面中 72 盤面で誤答した(実測)。

4mod をバイト列で取る

区画を (v >> 80t) & mask で 1 つずつ取り出すと、毎回 $O(\text{区画数})$ 語のシフトがかかり、1 状態で区画数の 2 乗になる。v.to_bytes で一度にバイト列にして 10 バイトずつ int.from_bytes すれば区画数に比例する。$6\times30$、$K=C=10$ で mod 部分が全体の 77% を占めていたのが、4.40 s → 2.25 s(実測)。

5mod を数行に 1 回

mod の直後は各区画 $<2^{30}$。1 マス進むと全状態の区画の総和は高々 4 倍(1 つの状態から出る遷移は 4 本以下。閉路が閉じる遷移は 1 本)。状態数 $<2^{12}$(幅 7 以下)なので、$t$ マス後の 1 区画は $2^{30+12+2t}$ 未満。$80$ ビットに収めるには $t\le18$、つまり $\lfloor37/(2w)\rfloor$ 行(幅 6 で 3 行)ごとに mod を取れば足りる。2.25 s → 1.04 s(実測)。

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

案計算量採否理由
辺集合の全探索$O(2^{E})$不採用検証用。$HW\le12$(17 辺)まで
閉路の数はキーに入れ、端点の数だけ詰める状態数が $C+1$ 倍不採用正しいが、$6\times30$, $K=C=10$ で 5.08〜6.95 s(mod を毎行シフトで取る版どうしの比較では、詰める版 4.40 s)
2 変数を詰め、mod を毎行シフトで取る同上不採用4.40 s。mod が 77%
2 変数を詰め、mod をバイト列で数行に 1 回(本解)$O(HW S_w(w+CK))$採用1.04 s
閉路の数を変数 $x$ の値として $C+1$ 点で評価し、補間で戻す評価点ごとに 1 回不採用閉路は最大 $HW/4=45$ 個あり、$c\le C$ で打ち切れないので 46 点要る(本セッションでは未実装)

前提が変わったら

  • **$\min(H,W)=7$** なら $7\times30$, $K=C=10$ で 5.50 s(実測)。状態の配列化(括弧列の最小表現の番号付け)が要る
  • **閉路に重み $n$ を付けた総和だけ**($O(n)$ ループ模型のように)が欲しいなら、閉路の変数は詰めずに、閉じる遷移で係数を $n$ 倍するだけでよい。区画は端点の分だけになる
  • **$K$ や $C$ が上限なし**(全係数)なら、区画数が $HW$ に比例して増える。辺数で数える方が素直

よくあるミス

ミス原因正しい書き方
端点 +1 のシフトで全体のマスクだけ掛ける1 変数版の書き方のまま400 盤面中 72 盤面で誤答(実測)。各 $c$ の $e=0$ を消すマスクを掛ける
( ) の合流を禁止したままDay174-Q2 のコードをそのまま使う400 盤面中 186 盤面で誤答(実測)。閉路が閉じる遷移を足す
) ( の合流を閉路と数える括弧の向きを取り違える)( は別々の線をつなぐだけ(閉路にならない)
区画をシフトで 1 つずつ取り出して mod区画数の 2 乗to_bytes で一度に切り出す
mod の間隔を幅によらず固定する上限の式を立てない間隔は $\lfloor37/(2w)\rfloor$ 行。幅 7 で 2 行、幅 6 で 3 行

次のステップ

  • 発展1: 閉路に重み $n$ を付けた総和($O(n)$ ループ模型)を、$n$ を変えて $6\times30$ で計算する
  • 発展2: 全係数($C,K$ 上限なし)を辺数と閉路数の 2 変数で数え、上の「前提が変わったら」の比較を実測する
  • 発展3: 2-因子の数($m=0$ の列の和)を、FKT のような行列式で数えられるかを調べる(平面グラフの完全マッチングとの関係)

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

  • 辺集合の全探索との照合(実測): $HW\le12$、壁の確率 0/0.1/0.25、$1\le K\le8$、$1\le C\le4$ のランダム盤面 460 盤面(本解の最終版で 60、mod の取り方を変える前の版で 400)。不一致 0。閉路を含む係数が 0 でない盤面は 400 盤面中 179
  • 連結を見ない DP との照合(実測): 全係数の和($K=HW$, $C=HW/4+1$)が「次数 2 以下の辺集合の総数」と一致。$5\times5$: 931128917、$6\times6$: 373806047、$4\times9$: 121809626、$7\times8$: 369220801、$6\times9$ 壁 15%: 872918986(すべて mod 998244353)
  • 誤った方法(実測、400 盤面): はみ出しを消さない 72 盤面、( ) の合流を禁止 186 盤面で誤答
  • 実行時間(実測、起動込み、$K=C=10$): $6\times30$ 1.04 s、$30\times6$ 壁 10% 0.48 s、$5\times30$ 0.30 s、$6\times30$($K=C=5$)0.52 s、$7\times30$ 5.50 s。改良の段階: mod を毎行シフト 4.40 s → バイト列 2.25 s → 3 行に 1 回 1.04 s($6\times30$)。閉路の数をキーに入れる版(mod は毎行シフト)は $6\times30$ で 5.08〜6.95 s、出力は 5 盤面で一致
  • 計測時の負荷: 負荷平均 11〜18(一時 95)。同じ入力でも ±30% 程度ぶれる
  • 扱っていない範囲: 幅 7 以上の高速化、状態の配列化、閉路に重みを付けた総和

自己評価

自分の回答

気づき・メモ