Day 169-Q4 — 2026-10-04 | 行と列のレーザーで全部の的を消す(二部グラフの最小頂点被覆を König の定理で復元する)

2026-10-04 赤色 Master (Phase 8+) ★★★★★★★★★ グラフ・フロー・マッチング(焦点帯 M2)。最小頂点被覆の大きさは最大マッチングと等しい(König)。この問題はどの行・列を選ぶかまで出させる。未マッチの行から交互路で届く集合 $Z$ を取り、「$Z$ に入らない行」と「$Z$ に入った列」を答える

問題

$H$ 行 $W$ 列の盤面に、的が $N$ 個ある。$i$ 個目の的はマス $(r_i,c_i)$ にある(同じマスに 2 個以上は無い)。

1 回のレーザーで、ある 1 行の的を全部、またはある 1 列の的を全部消せる。すべての的を消すのに必要なレーザーの最小回数 $K$ と、そのときに撃つ行・列を 1 組出力せよ。

入力形式

H W N
r_1 c_1
...
r_N c_N

出力形式: 1 行目に $K$。続く $K$ 行に、行を撃つなら R r、列を撃つなら C c。最小回数を達成する組なら、どれを出力してもよい(順序も任意)。

制約

#条件
1$1\le H,W\le10^5$
2$1\le N\le2\times10^5$
3$1\le r_i\le H$、$1\le c_i\le W$、$(r_i,c_i)$ はすべて異なる
4入力はすべて整数

入出力例

入力例1

3 3 4
1 1
1 3
2 2
3 2

出力例1

2
R 1
C 2

(行 1 で $(1,1),(1,3)$、列 2 で $(2,2),(3,2)$。1 回では足りない)

入力例2

2 4 5
1 1
1 2
1 3
1 4
2 1

出力例2

2
R 1
R 2

(R 1 と C 1 でも正解。どちらも 2 回)

背景と出典

行を左側、列を右側の頂点にし、的 $(r,c)$ を辺 $r-c$ にすると、「すべての辺の端点のどちらかを選ぶ」最小頂点被覆になる。二部グラフでは最小頂点被覆の大きさが最大マッチングの大きさに等しい(König の定理、1931 年。Egerváry も同時期に独立に示した、と私は理解している。原典は本セッションでは未確認)。

大きさだけなら最大マッチングを出せば終わる。この問題の中核は被覆そのものの復元で、手順は König の定理の構成的な証明そのもの。

  1. 最大マッチング $M$ を作る(Hopcroft–Karp、$O(E\sqrt V)$。Hopcroft・Karp 1973、原典未確認)
  2. 未マッチの行から、「行→(マッチしていない辺)→列→(マッチ辺)→行」と交互にたどって届く頂点の集合 $Z$ を取る
  3. 被覆 $=(\text{行}\setminus Z)\cup(\text{列}\cap Z)$

正しさは「$|$被覆$|=|M|$ かつ全辺を覆う」の 2 点で、弱双対性(被覆はどのマッチングより小さくならない)から最小になる。検証ではこの 2 点を大入力でも機械的に確かめた。

概念図

入力例 1: 行 = 左、列 = 右、的 = 辺。太線 = 最大マッチング、色付き = 未マッチの行 R3 から交互路で届く ZR1R2R3C1C2C3未マッチ交互路: R3 →(非マッチ辺) C2 →(マッチ辺) R2Z = {R3, R2, C2}被覆 = (Z でない行) ∪ (Z の列) = {R1} ∪ {C2} → 2 回覆われない辺は「行∈Z かつ 列∉Z」だけ。行∈Z なら全辺の列も Z に入るので 0 本赤枠 = 撃つ行・列。|被覆| = |マッチング| = 2 なので弱双対性から最小

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

ヒント1(方向性)

「各的について、その行か列の少なくとも一方を撃つ」。行と列を頂点、的を辺にした二部グラフで、すべての辺に触れる頂点の最小集合を探している。貪欲(的の多い行・列から撃つ)は最小にならない。

ヒント2(アプローチ)

最小頂点被覆の大きさ = 最大マッチングの大きさ。マッチングの各辺は少なくとも 1 頂点を被覆に含むので、被覆 $\ge$ マッチングは明らか。等号を実現する被覆を、最大マッチングから作る。

ヒント3(誘導)

最大マッチングを作った後、未マッチの行すべてを始点に BFS する。行からはどの辺でも列へ進み(マッチ辺は行の相手の列なので、未マッチ行からは出ない)、列からはマッチ辺だけで行へ戻る。届いた行の集合 $Z_L$、届いた列の集合 $Z_R$ について、答えは「$Z_L$ に入らない行(辺を持つもの)」と「$Z_R$ の列」。

模範解答 (Python)

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


def main():
    data = sys.stdin.buffer.read().split()
    h, w, n = int(data[0]), int(data[1]), int(data[2])
    adj = [[] for _ in range(h + 1)]           # 行 r -> 点のある列
    for i in range(n):
        r = int(data[3 + 2 * i]); c = int(data[4 + 2 * i])
        adj[r].append(c)
    rows = [r for r in range(1, h + 1) if adj[r]]
    INF = 1 << 30
    match_r = [0] * (h + 1)                     # 行 -> 列(0 は未マッチ)
    match_c = [0] * (w + 1)                     # 列 -> 行
    dist = [0] * (h + 1)

    # Hopcroft–Karp: 最短の増加路だけを層ごとにまとめて流す
    while True:
        q = deque()
        for r in rows:
            if match_r[r] == 0:
                dist[r] = 0
                q.append(r)
            else:
                dist[r] = INF
        found = False
        while q:
            r = q.popleft()
            for c in adj[r]:
                r2 = match_c[c]
                if r2 == 0:
                    found = True
                elif dist[r2] == INF:
                    dist[r2] = dist[r] + 1
                    q.append(r2)
        if not found:
            break
        it = [0] * (h + 1)
        for s in rows:
            if match_r[s]:
                continue
            # 反復版 DFS で層を 1 つずつ下る増加路を探す
            stack = [s]
            while stack:
                r = stack[-1]
                advanced = False
                while it[r] < len(adj[r]):
                    c = adj[r][it[r]]
                    r2 = match_c[c]
                    if r2 == 0:
                        # 増加路が見つかった: スタック上の行を順に付け替える
                        for k in range(len(stack) - 1, -1, -1):
                            rr = stack[k]
                            cc = adj[rr][it[rr]]
                            match_r[rr] = cc
                            match_c[cc] = rr
                        stack = []
                        advanced = True
                        break
                    if dist[r2] == dist[r] + 1:
                        stack.append(r2)
                        advanced = True
                        break
                    it[r] += 1
                if not advanced:
                    dist[r] = INF            # この行からは増加路が無い
                    stack.pop()
                    if stack:
                        it[stack[-1]] += 1

    # König: 未マッチの行から交互路(行→非マッチ辺→列→マッチ辺→行)で届く集合 Z
    vis_r = [False] * (h + 1)
    vis_c = [False] * (w + 1)
    q = deque()
    for r in rows:
        if match_r[r] == 0:
            vis_r[r] = True
            q.append(r)
    while q:
        r = q.popleft()
        for c in adj[r]:
            if not vis_c[c]:
                vis_c[c] = True
                r2 = match_c[c]           # Z の列は必ずマッチ済み(でなければ増加路がある)
                if not vis_r[r2]:
                    vis_r[r2] = True
                    q.append(r2)
    # 最小頂点被覆 = (Z に入らない行) ∪ (Z に入った列)
    ans = [f"R {r}" for r in rows if not vis_r[r]] + [f"C {c}" for c in range(1, w + 1) if vis_c[c]]
    sys.stdout.write(str(len(ans)) + "\n" + "\n".join(ans) + "\n")


main()

計算量: Hopcroft–Karp が $O(N\sqrt{H+W})$、$Z$ の BFS が $O(N+H+W)$。実測(Python 3.14.7、M1 Mac)で $H=W=10^5,N=2\times10^5$ の一様乱数が 1.00 s(別の回は 2.85 s。機械の負荷で揺れた)、行 $r$ に列 $r,r+1$ を置いた鎖状の入力が 0.23 s、450×450 に密集させた入力が 0.15 s。

Step-by-Step 解説

1二部グラフにする

行は左、列は右。的は辺。撃つ行・列が頂点被覆。的のない行・列は被覆に入れる必要が無いので、辺を持つ行だけを rows に持つ。

2なぜ $Z$ の列はすべてマッチ済みか

$Z$ の列 $c$ が未マッチなら、未マッチの行からそこまでの交互路が増加路になり、マッチングが最大であることに反する。だからコード中の r2 = match_c[c] は必ず 0 でない。

3なぜ全辺を覆うか

辺 $(r,c)$ が覆われないのは「$r\in Z$ かつ $c\notin Z$」のときだけ。$r\in Z$ なら BFS で $r$ から全部の辺を見るので $c\in Z$ になる。よって覆われない辺は無い。

4なぜ大きさがマッチングと等しいか

マッチ辺 $(r,c)$ について、$c\in Z$ なら $r\in Z$(マッチ辺で戻る)なので $r$ は被覆に入らず $c$ だけが入る。$c\notin Z$ なら $r\notin Z$($r\in Z$ なら $c\in Z$ になるから)で $r$ だけが入る。未マッチの行は $Z$ に入るので被覆に入らず、$Z$ の列は全部マッチ済み。したがって被覆の各頂点がちょうど 1 本のマッチ辺に対応し、$|$被覆$|=|M|$。

5左右を取り違えた版

「$Z$ に入った行」と「$Z$ に入らない列」を答えると、乱数 500 件中 411 件で被覆にならないか最小でない(実測)。式を覚えるのでなく、Step 3 の「覆われない辺は $r\in Z,c\notin Z$ だけ」から毎回組み立てると間違えない。

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

案計算量採否理由
行の部分集合の全探索$O(2^HN)$検算用のみ$H\le10$ の乱数 500 件で最小回数を照合
貪欲(的の多い行・列から撃つ)$O(N\log N)$不採用500 件中 37 件で最小にならない(実測)
Kuhn 法(増加路を 1 本ずつ DFS)$O(NH)$不採用最悪 $2\times10^{10}$。乱数入力では速いことが多いが、保証が無い
Hopcroft–Karp + König の復元(本解)$O(N\sqrt V)$採用500 件全部で被覆が正しく最小。大入力 3 種で「被覆が全的を覆う」「被覆の大きさ = マッチングの大きさ」を確認
最大流(Dinic)で最小カットを取る$O(N\sqrt V)$不採用単位容量の二部グラフでは Hopcroft–Karp と同じ計算量。残余グラフの $S$ 側到達集合が本解の $Z$ に当たる。実装量が増えるだけ

前提が変わったら:

  • 行・列ごとに撃つ費用が違う(重み付き)なら König は使えない。$S\to$行(費用)、行$\to$列($\infty$)、列$\to T$(費用)の最小カットにする
  • 斜め方向のレーザーも撃てるなら三部以上になり、頂点被覆は NP 困難(一般グラフ)。制約を見直す
  • 最大マッチングの大きさだけを問うなら、復元の BFS は不要

よくあるミス

ミス原因正しい書き方
被覆を「$Z$ の行」と「$Z$ でない列」にする左右の取り違え「$Z$ でない行」と「$Z$ の列」(411/500 誤答)
マッチ済みの行を全部撃つマッチングの各辺の片方なら何でもよいと思う辺ごとに $Z$ で左右を選ぶ(212/500 で覆われない的が残る)
交互路で列から非マッチ辺でも戻るBFS を普通のグラフ探索にする列からはマッチ辺だけで行へ
貪欲に多い順で撃つ局所最適37/500 で最小にならない
再帰 DFS の Kuhn/HK深さ $10^5$ で再帰上限反復版の DFS

次のステップ

  • 発展1: 最大独立集合(撃たれずに残せる「行・列」の最大集合)を同じ $Z$ から出す(補集合)
  • 発展2: Dilworth の定理: DAG の最小鎖被覆を二部マッチングに帰着し、鎖そのものを復元する
  • 発展3: 重み付き版を最小カットで解き、残余グラフの到達集合から撃つ行・列を復元する

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

  • 全探索との照合(実測): $H,W\le10$、的の数 $\le30$ の乱数 500 件で、出力が「全的を覆う」かつ「回数が全探索の最小に等しい」を全件満たした
  • 大入力(実測): 一様乱数($K=78302$)、鎖状($K=100000$)、450×450 密集($K=450$)の 3 種で、出力が全的を覆い、かつ $K$ が Hopcroft–Karp のマッチングの大きさと一致(弱双対性より最小)
  • 誤った方法(実測): 左右を取り違えた被覆は 500 件中 411 件で不正解、マッチ済みの行を全部撃つと 212 件で的が残る、貪欲は 37 件で最小にならない
  • 入力例(実測): 例 1 2 / R 1 / C 2、例 2 2 / R 1 / R 2
  • 実行時間(実測): 一様乱数 1.00 s(負荷の高い回は 2.85 s)、鎖状 0.23 s、密集 0.15 s
  • 扱っていない範囲: Hopcroft–Karp の最悪ケースを狙った入力での測定、重み付き版、König・Egerváry・Hopcroft–Karp の原典

自己評価

自分の回答

気づき・メモ