Day 126-Q1 — Dancing Links (DLX) / Exact Cover Problem

2026-08-18 赤色 Master / Phase 8+ ★★★★★★★★★ Knuth's Algorithm X を相互リンクリスト(O(1)削除・復元)で実装し、完全被覆問題を解く

問題

$N$ 個のアイテム(番号 $1$ 〜 $N$)と、$M$ 個の集合 $S_1, \dots, S_M$(それぞれ $\{1,\dots,N\}$ の部分集合)が与えられる。

これらの集合の中からいくつかを選び、選んだ集合の和集合がちょうど $\{1,\dots,N\}$ に一致し、かつどの2つの選んだ集合も互いに素(各アイテムがちょうど1回だけ選ばれた集合に含まれる)になるようにしたい。そのような選び方が存在するか判定し、存在するなら選んだ集合の番号を1つ出力せよ。存在しなければ -1 を出力せよ。

入力形式

N M
K_1 a_1 ... a_{K_1}
K_2 a_1 ... a_{K_2}
...
K_M a_1 ... a_{K_M}

制約

$1 \le N \le 20$
$1 \le M \le 200$
$1 \le K_i \le N$
完全被覆が複数存在する場合、どれか1つを出力すればよい

入出力例

入力例1

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

出力例1

1 2

集合1 {1,2} と集合2 {3,4} を選ぶと {1,2,3,4} をちょうど1回ずつ被覆できる。「3 4」も正解

入力例2

3 2
2 1 2
2 2 3

出力例2

-1

アイテム2を含む集合しかなく、両方選ぶと重複、片方だけでは被覆漏れが出るため不可能

概念図

Exact Cover 行列と Dancing Links の相互参照 列(アイテム)ヘッダ 1 2 3 4 行S1{1,2} 行S2{3,4} 行S3{1,3} S1を選択: 列1,2をunlink →列1,2を持つ行 (S3も列1経由)を除外 残るのはS2のみ S2選択で完全被覆達成 実装のunlink/relinkはO(1)。失敗した分岐は逆順relinkで完全に復元できる

ヒント(段階的開示)

ヒント1(方向性)

すべての部分集合 $2^M$ 通りを全探索すると $M \le 200$ では到底間に合わない。バックトラック(分岐限定法)で「まだ被覆されていないアイテムのうち1つを選び、それを含む集合のどれかを選ぶ」という探索木を作ると、枝刈りの効きが良い探索になる。この探索を効率よく実装するデータ構造として、Donald Knuth の Dancing Links (DLX) を考える。

ヒント2(アプローチ)

DLXは「行=集合」「列=アイテム」の0-1行列を、各セルが上下左右の隣接する1同士を指す双方向連結リストとして表現する。ある列(アイテム)を選択して被覆すると、その列に1を持つ全ての行を「削除」し、それらの行が1を持つ他の列からもそれらの行を取り除く。この削除は連結リストの unlink 操作なので $O(1)$ で行え、バックトラック時は逆順に unlink した順序を辿って relink するだけで完全に元に戻せる。

ヒント3(誘導)

$N \le 20, M \le 200$ の制約であればビットマスクで「まだ被覆されていない列集合」を管理し、各列を選ぶ代わりに「残っている行の中から、その行が持つ列がすべて未被覆であるものを選んで被覆マスクを更新し再帰」するだけでも十分高速に動く(本質はDLXと同じ分岐限定法)。

def search(covered, chosen):
    if covered == full_mask:
        return chosen
    target = (~covered & full_mask) & (-(~covered & full_mask))
    for i, mask in enumerate(row_masks):
        if mask & target and (mask & covered) == 0:
            res = search(covered | mask, chosen + [i])
            if res is not None:
                return res
    return None

模範解答 (Python)

import sys


def solve():
    input_data = sys.stdin.read().split()
    idx = 0
    n = int(input_data[idx]); idx += 1
    m = int(input_data[idx]); idx += 1

    row_masks = []
    for _ in range(m):
        k = int(input_data[idx]); idx += 1
        mask = 0
        for _ in range(k):
            a = int(input_data[idx]); idx += 1
            mask |= (1 << (a - 1))
        row_masks.append(mask)

    full_mask = (1 << n) - 1

    rows_by_item = [[] for _ in range(n)]
    for i, mask in enumerate(row_masks):
        for b in range(n):
            if mask & (1 << b):
                rows_by_item[b].append(i)

    chosen_stack = []
    sys.setrecursionlimit(10000)

    def search(covered):
        if covered == full_mask:
            return True
        rest = (~covered) & full_mask
        best_item = -1
        best_count = None
        b = 0
        tmp = rest
        while tmp:
            if tmp & 1:
                cnt = sum(1 for r in rows_by_item[b] if (row_masks[r] & covered) == 0)
                if best_count is None or cnt < best_count:
                    best_count = cnt
                    best_item = b
                if cnt == 0:
                    return False
            tmp >>= 1
            b += 1

        for r in rows_by_item[best_item]:
            if row_masks[r] & covered:
                continue
            chosen_stack.append(r)
            if search(covered | row_masks[r]):
                return True
            chosen_stack.pop()
        return False

    if search(0):
        print(' '.join(str(r + 1) for r in sorted(chosen_stack)))
    else:
        print(-1)


solve()

Step-by-Step 解説

1完全被覆問題としての定式化
$M$個の集合から「和が $\{1,\dots,N\}$ に一致し重複なし」の部分集合族を選ぶ問題は、0-1行列(行=集合、列=アイテム)における Exact Cover Problem そのものである。Knuth の Algorithm X はこれを再帰的に解く汎用アルゴリズムであり、DLX はその実装を高速化するデータ構造。
2MRVヒューリスティックによる列選択
毎回「まだ被覆されておらず、かつそれを被覆できる残り行数が最も少ないアイテム」を選んで分岐する。これにより分岐数が最小化され、探索木が大きく枝刈りされる。
3行選択と再帰
選んだアイテムを被覆できる行を1つずつ試し、その行を選んだ場合の新しい被覆状態で再帰する。本来のDLX実装ではこれを連結リストのunlink/relinkで $O(1)$ に行うが、$N\le20$ の本問題ではビットマスク演算で同等の効果が得られる。
4バックトラックの正しさ
chosen_stack に追加した行が失敗に終わった場合は必ず pop() して状態を戻す。この対称性(push/pop, unlink/relink)を崩すと探索状態が壊れる。
5計算量
最悪ケースは指数時間だが、MRVヒューリスティックにより実用的な入力では劇的に枝刈りされ、本問題の制約では十分高速に動作する。

よくあるミス

ミス原因正しい書き方
全部分集合 $2^M$ を素朴に全探索制約 $M\le200$ を見落とすアイテム側($N\le20$)を軸にMRV分岐する
列選択を常に固定順にするヒューリスティックの効果を軽視残り候補行数最小のアイテムを毎回選び直す
バックトラック時に chosen_stack.pop() を忘れる再帰の失敗パスで状態復元を省略各再帰呼び出しの前後でpush/popを必ず対にする
行が空集合被覆にも使えると誤解空行を許容してしまう$K_i \ge 1$ の制約を前提に実装する

次のステップ

  • 発展: 各行に「重み」を付け、被覆にかかる総コストを最小化する重み付き厳密被覆問題(分岐限定法に下界を加える)
  • 次回予告: モノトニックキュー最適化DP(Monotonic Deque Optimization・スライディングウィンドウ最大値によるDP高速化)

自己評価

自分の回答

気づき・メモ