問題
$H$ 行 $W$ 列の盤面がある。各マスは .(通れる)、#(壁)、S(始点)、T(終点)のいずれかで、S と T はちょうど 1 つずつある。
S から出発し、上下左右に隣り合うマスへ移動しながら、壁以外のすべてのマスをちょうど 1 回ずつ通って T で終わる経路(ハミルトン路)の数を $998244353$ で割った余りを求めよ。
入力形式
H W
G_1
G_2
...
G_H
制約
| # | 条件 |
|---|---|
| 1 | $1\le H,W$、$H\times W\le 300$、$\min(H,W)\le 10$ |
| 2 | $G_i$ は . # S T からなる長さ $W$ の文字列。S と T はちょうど 1 つずつ |
入出力例
入力例1
3 3
S..
...
..T出力例1
2(左上から右下へ。「行ごとに折り返す」と「列ごとに折り返す」の 2 通り)
入力例2
3 4
S..T
....
....出力例2
4入力例3
4 5
T....
...#.
.....
S...#出力例3
4(壁があっても同じ DP で扱える。3 例とも DFS の全探索と一致)
背景と出典
盤面の閉路・路の数え上げを「境界を横切る線のつながり方」で状態に持つ方法は、プラグ DP(broken profile DP、連結性 DP)と呼ばれる。線の対応を括弧列で表す方法は、日本の競技プログラミングでは「括弧列による連結性の表現」として知られている、と私は記憶している(本セッションでは原典を確認していない)。
閉路(Day171-Q3)では、境界を横切る線は必ず 2 本ずつ対になり、左の端を (、右の端を ) と書けば括弧列になった。路では、S と T から出た線は相手がいない。そこで記号 3(「端点につながる線」)を足し、各位置を 0・(・)・3 の 4 値(2 ビット)で持つ。
本問は自作。正方形の対角の角を結ぶ場合の答え $3\times3,5\times5,7\times7,9\times9$ で $2,104,111712,2688307514$ を得た。これは OEIS A001184 として私が記憶している値と一致する(OEIS は本セッションでは参照していない)。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
1 マスずつ左上から処理し、「処理済みのマスとまだのマスの境界」を横切る線だけを状態に持つ。境界は $W+1$ か所。通常のマスは線が 2 本(次数 2)、S と T は 1 本(次数 1)。
ヒント2(アプローチ)
閉路では線は対になるので括弧列で表せた。路では端点から出た線に相手がいない。記号を 1 つ増やして「この線をたどると端点に行く」を表す。括弧の線を端点で止めると、その相手の括弧が「端点につながる線」に変わる。
ヒント3(誘導)
左の線 $L$ と上の線 $U$ の組で場合分けする。
| $L,U$ | 通常マス | 端点マス |
|---|---|---|
| 0,0 | ( を下・) を右に出す | 3 を下か右に 1 本出す |
| 片方 $x$ | $x$ を下か右へ延ばす | $x=3$ なら路の完成、括弧なら相手を 3 に |
) ( | つなぐだけ | 不可(次数 2 になる) |
( ( / ) ) | 相手の括弧を付け替え | 不可 |
( ) | 閉路になるので禁止 | 不可 |
3 と括弧 | 括弧の相手を 3 に | 不可 |
3 3 | 路の完成 | 不可 |
模範解答 (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 main(mod=MOD):
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
dp = {0: 1}
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, v):
ndp[s] = (ndp.get(s, 0) + v) % mod if mod else ndp.get(s, 0) + v
for st, v in dp.items():
L = (st >> 2 * j) & 3 # 左から入る線(位置 j)
U = (st >> 2 * (j + 1)) & 3 # 上から入る線(位置 j+1)
base = setp(setp(st, j, 0), j + 1, 0)
if c == '#':
if L == 0 and U == 0:
add(st, v)
continue
if c in 'ST': # 端点: 次数 1
if L and U:
continue
if L == 0 and U == 0: # ここから線を 1 本出す(記号 3 = 端点につながる線)
if down_ok:
add(setp(base, j, 3), v)
if right_ok:
add(setp(base, j + 1, 3), v)
continue
x, pos = (L, j) if L else (U, j + 1)
if x == 3: # 反対の端点からの線が届いた = 路が完成
add(base, v)
else: # 括弧の線を端点で止めると、相手の括弧が端点につながる線になる
add(setp(base, match(st, pos, w), 3), v)
continue
# 通常のマス: 次数 2
if L == 0 and U == 0:
if down_ok and right_ok:
add(setp(setp(base, j, 1), j + 1, 2), v)
elif L == 0 or U == 0:
x = L | U
if down_ok:
add(setp(base, j, x), v)
if right_ok:
add(setp(base, j + 1, x), v)
else:
if L == 1 and U == 2: # 閉路ができる: ハミルトン路では禁止
continue
if L == 2 and U == 1: # ')(' はそのままつながる
add(base, v)
elif L == 1 and U == 1: # '((' : 上の線の相手 ')' が '(' になる
add(setp(base, match(st, j + 1, w), 1), v)
elif L == 2 and U == 2: # '))' : 左の線の相手 '(' が ')' になる
add(setp(base, match(st, j, w), 2), v)
elif L == 3 and U == 3: # S 側と T 側の線がつながる = 路が完成
add(base, v)
else: # 端点の線と括弧: 括弧の相手が端点の線になる
b, pos = (U, j + 1) if L == 3 else (L, j)
add(setp(base, match(st, pos, w), 3), v)
dp = ndp
dp = {st << 2: v for st, v in dp.items() if (st >> 2 * w) & 3 == 0} # 行末: 右端の線は 0 のはず
return dp.get(0, 0)
if __name__ == "__main__":
print(main())
計算量: 幅 $w=\min(H,W)$ の状態数を $S_w$ として $O(HW\cdot S_w\cdot w)$(括弧の対応探しが $O(w)$)。$S_w$ は実測で幅 8 が最大 513、幅 10 が最大 3333。$10\times30$ で 0.56〜1.03 s、$10\times20$ で 0.37〜0.60 s(Python 3.14.7、M1 MacBook Pro、負荷平均 3〜16 での実測)。
Step-by-Step 解説
左上から 1 マスずつ処理する。処理済みと未処理の境界には $W+1$ か所の「線が横切りうる場所」がある。位置 $j$ がいま処理するマスの左、位置 $j+1$ が上。経路の途中の形は気にせず、「境界を横切る線どうしが、処理済みの側でどうつながっているか」だけを覚える。
処理済みの側で 2 本の線がつながっているとき、左の端を (、右の端を ) とする。線は交差しないので、これは正しい括弧列になる。(( が合流すると、上の線の相手の ) が ( に変わる(Day171-Q3 と同じ)。
S や T から出た線は、処理済みの側で端点につながっていて、相手の線がいない。これを 3 と書く。括弧の線が端点マスで止まると、その相手の括弧は「たどると端点に行く線」になるので 3 に書き換える。3 は同時に最大 2 本(S 側と T 側)。
通常マスで ( と ) が出会うと、その 2 本は既につながっているので閉路ができる。ハミルトン路は 1 本の路なので禁止。3 と 3 が出会う(または端点マスに 3 が届く)と S から T までの路が完成する。完成後に残りのマスがあれば、そのマスは線を出すしかなく、最後に状態 0 に戻れないので自然に数えられない。
状態数は幅で決まる。$8\times16$ の盤面を転置せずに幅 16 で処理すると、状態は最大 1152167 個・129.57 s。転置して幅 8 にすると最大 901 個・0.26 s(実測)。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| DFS の全探索 | 指数 | 検算用のみ | 30 マス程度まで。1250 盤面の照合に使用 |
| 連結成分の番号を最小表現で持つ(括弧を使わない) | 状態の正規化が毎回 $O(w)$ | 不採用 | 括弧列の方が状態の符号化が 2 ビット×位置で済み、Day171-Q3 の実装をそのまま拡張できる |
| 端点を「仮の 1 マス」で外とつないで閉路に帰着 | 盤面の外に通路が要る | 不採用 | 端点が盤面の内側にあると外への通路が他のマスと交差し、帰着できない(私の判断。実装・計測はしていない) |
括弧 2 種+端点の線 3(本解) | $O(HW S_w w)$ | 採用 | $10\times30$ で 0.56〜1.03 s |
前提が変わったら:
- 端点を指定しない(どこから始まりどこで終わってもよい)なら、各マスで「ここを端点にする」選択を許し、
3が 2 本使われたかを状態に 2 ビット足す。状態数は増えるが同じ枠組みで書ける - 幅が 12 以上なら、状態数が数万〜数十万になり、Python の辞書では遅い。状態を整数配列に詰めてハッシュを自作するか、C++ にする
- 数ではなく 1 本の経路を出力するなら、DP を逆にたどる復元が要る。辞書を各マスごとに保存するとメモリが $O(HW S_w)$ になる
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
( ) の合流を許す | 閉路の問題(Day171-Q3)では最後のマスで閉じてよかった | 路では常に禁止。許すと路+別の閉路の組まで数え、484 盤面中 53 盤面で誤答(実測) |
| 端点の線を括弧と同じに扱う | 相手がいない線を表せない | 記号 3 を足す。括弧を端点で止めたら相手を 3 に |
| 端点マスで $L,U$ の両方から線を受ける | 次数 1 を忘れる | if L and U: continue |
| 転置しない | 盤面をそのままの向きで処理 | $8\times16$ を幅 16 で処理すると 129.57 s(実測)。幅 8 なら 0.26 s |
| 市松模様の偶奇を確かめずに答えが 0 で驚く | S と T の色の条件 | マス数が偶数なら S と T は異なる色、奇数なら同じ色(多い方の色)でないと 0。入力例 1 の $4\times4$ 版(対角)は 0 |
次のステップ
発展1: 端点を指定しないハミルトン路の数(各マスが端点になりうる)
- 発展2: 盤面を $k$ 本の互いに素な路で覆う数(
3の本数を状態に持つ) - 発展3: 原典(括弧列によるプラグ DP の解説)と OEIS A001184 を確認する(本セッションでは未確認)
検証ログ(本セッションで実行した数値)
DFS 全探索との照合(実測): $H,W\le5$ のランダム盤面(壁 0〜35%、S T は任意の位置)766 盤面、30 マス以下の盤面 484 盤面、計 1250 盤面で不一致 0(答えが 0 でないもの 314)
- 正方形の角どうし(実測): 左上→右下は $n=3,5,7,9$ で $2,104,111712,2688307514$(偶数の $n$ は 0)。左上→左下は $n=2..10$ で $1,2,8,86,1770,88418,8934966,2087813834,1013346943033$。このうち $n\le6$ は DFS と一致。左上→左下は OEIS A000532 だと記憶していたが、記憶していた $n=5$ の値 52 が DFS の 86 と食い違ったので、照合には使わなかった
- 誤った方法(実測):
()の合流を許す版は 484 盤面中 53 盤面で誤答 - 入力例(実測): 例 1
2、例 24、例 34(DFS と一致) - 実行時間(実測、負荷平均 3〜16): 幅 10 で $H=20$ 0.37〜0.60 s・$H=30$ 0.56〜1.03 s(最大状態数 3333)、幅 9 で $H=30$ 0.49 s、幅 8 で $H=30$ 0.24〜1.63 s(負荷の高い時間帯で振れた。最大状態数 513)。$8\times16$ を転置しない版は 129.57 s(状態 1152167)、転置すると 0.26 s(状態 901)
- 扱っていない範囲: 端点の指定なし、複数の路での被覆、経路の復元、幅 11 以上、原典と OEIS の確認