問題
$N$個の真偽変数 $x_1,\dots,x_N$ に対し、$M$個の節(2リテラルのOR)が与えられる。リテラルは非0整数$v$($1\le|v|\le N$)で表し、$v>0$なら$x_v$が真、$v<0$なら$x_{-v}$が偽を意味する。すべての節を同時に満たす割り当てが存在するか判定し、存在するならYesとその割り当てを、存在しないならNoを出力せよ。
入力形式
N M
a_1 b_1
...
a_M b_M
制約
$1 \le N \le 100000$
$1 \le M \le 200000$
$a_k, b_k$ は非0整数
$1 \le |a_k|, |b_k| \le N$
入出力例
入力例1
3 3
1 2
-1 3
-2 -3
出力例1
Yes
1 0 1
解は一意とは限らず他の正しい割り当ても正解
入力例2
1 2
1 1
-1 -1
出力例2
No
x1が真であることと偽であることを同時に強制し矛盾
概念図: 含意グラフとSCC
ヒント(段階的開示)
ヒント1: 方向性
節$(a\lor b)$は「aが偽ならbは真でなければならない」「bが偽ならaは真でなければならない」という含意(implication)に言い換えられる。全節についてこの言い換えを行うと、リテラル同士の含意関係を表す有向グラフができる。
ヒント2: アプローチ
各変数$x_i$に「真」「偽」の2ノードを用意し(全体で2Nノード)、節$(a\lor b)$について$\lnot a\to b$と$\lnot b\to a$の2辺を張る。$x_i$真ノードと$x_i$偽ノードが同じ強連結成分(SCC)に含まれたら矛盾で充足不可能。すべての変数でこれが起きなければ充足可能。
ヒント3: 誘導(コード骨格)
SCC分解には反復版のTarjanを使う(再帰深度制限の回避)。TarjanはSCCを逆位相順に番号付けるため「x_i真のSCC番号 < x_i偽のSCC番号」ならx_iを真に割り当てる。
# clause (a or b): not(a) -> b, not(b) -> a
adj[neg(node(a))].append(node(b))
adj[neg(node(b))].append(node(a))
模範解答 (Python)
import sys
def solve():
data = sys.stdin.buffer.read().split()
idx = 0
N = int(data[idx]); idx += 1
M = int(data[idx]); idx += 1
def node_id(v):
if v > 0:
return 2 * (v - 1)
else:
return 2 * (-v - 1) + 1
def neg_node(node):
return node ^ 1
n_nodes = 2 * N
adj = [[] for _ in range(n_nodes)]
for _ in range(M):
a = int(data[idx]); idx += 1
b = int(data[idx]); idx += 1
na, nb = node_id(a), node_id(b)
adj[neg_node(na)].append(nb)
adj[neg_node(nb)].append(na)
# --- 反復版 Tarjan SCC ---
index_counter = 0
dfs_stack = []
lowlink = [0] * n_nodes
index = [-1] * n_nodes
on_stack = [False] * n_nodes
comp = [-1] * n_nodes
comp_count = 0
for start in range(n_nodes):
if index[start] != -1:
continue
work = [(start, 0)]
while work:
v, pi = work[-1]
if pi == 0:
index[v] = lowlink[v] = index_counter
index_counter += 1
dfs_stack.append(v)
on_stack[v] = True
advanced = False
while pi < len(adj[v]):
w = adj[v][pi]
pi += 1
if index[w] == -1:
work[-1] = (v, pi)
work.append((w, 0))
advanced = True
break
elif on_stack[w]:
if index[w] < lowlink[v]:
lowlink[v] = index[w]
if advanced:
continue
work[-1] = (v, pi)
work.pop()
if work:
pv = work[-1][0]
if lowlink[v] < lowlink[pv]:
lowlink[pv] = lowlink[v]
if lowlink[v] == index[v]:
while True:
w = dfs_stack.pop()
on_stack[w] = False
comp[w] = comp_count
if w == v:
break
comp_count += 1
ok = True
for i in range(N):
if comp[2 * i] == comp[2 * i + 1]:
ok = False
break
if not ok:
print("No")
return
assign = [1 if comp[2 * i] < comp[2 * i + 1] else 0 for i in range(N)]
print("Yes")
print(' '.join(map(str, assign)))
solve()
計算量: 含意グラフ構築O(N+M)、反復Tarjan SCC O(N+M)。全体O(N+M)。入力例1・2の両方を手動でグラフ構築しSCCを追跡してYes/Noの判定が一致することを確認済み。出力された割り当てをすべての節に代入して実際に真になることも検算した。
Step-by-Step 解説
1節から含意への言い換え
「節を満たすこと ⟺ 含意グラフをたどってすべての含意が矛盾なく成り立つこと」に帰着できる。
「節を満たすこと ⟺ 含意グラフをたどってすべての含意が矛盾なく成り立つこと」に帰着できる。
2なぜSCCが同じだと矛盾か
x_i真とx_i偽が同じSCCにあると「x_iが真だと仮定すると含意により偽が導かれ、逆も真」という循環矛盾を意味する。
x_i真とx_i偽が同じSCCにあると「x_iが真だと仮定すると含意により偽が導かれ、逆も真」という循環矛盾を意味する。
3反復Tarjanの実装ポイント
ノード数最大2×10^5のため再帰DFSではPythonのデフォルト再帰制限にすぐ達する。(頂点,次に見る隣接リストの添字)の組をスタックに積みwhileループで擬似再帰を模倣する。
ノード数最大2×10^5のため再帰DFSではPythonのデフォルト再帰制限にすぐ達する。(頂点,次に見る隣接リストの添字)の組をスタックに積みwhileループで擬似再帰を模倣する。
4SCC番号から割り当てを復元するルール
TarjanはSCCをシンク側から先に番号付ける。x_i真のSCC番号がx_i偽より小さいならx_i=真を選ぶことで矛盾なく含意が成立する(節(x1∨x1)単体での検算で確認済み)。
TarjanはSCCをシンク側から先に番号付ける。x_i真のSCC番号がx_i偽より小さいならx_i=真を選ぶことで矛盾なく含意が成立する(節(x1∨x1)単体での検算で確認済み)。
5正しさの検証
入力例1・2を手動でグラフ構築しYes/Noの判定を確認。出力割り当てをすべての節に代入し真になることも検算した。
入力例1・2を手動でグラフ構築しYes/Noの判定を確認。出力割り当てをすべての節に代入し真になることも検算した。
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 節(a∨b)に対しa→b, b→aのように直接辺を張ってしまう | 含意の対偶(否定を経由)変換を忘れる | 正しくは¬a→bと¬b→a |
| SCC番号の大小関係を逆にして割り当てる | Tarjanが位相順・逆位相順どちらで番号付けるか記憶が曖昧 | 小さな具体例で検算しルールの向きを確認してから実装する |
| 再帰DFSでSCCを実装しRecursionErrorになる | Nが大きい場合の再帰深度制限を見落とす | 反復(スタックベース)のTarjanを使う |
| 出力を唯一の正解だと思い込む | 2-SATは複数の有効な割り当てを持つのが一般的 | すべての節を満たすかどうかで自己検証する |
次のステップ
- 発展: 2-SATは区間スケジューリングの排他制約やグラフの2彩色判定など様々な「AかBのどちらか一方」制約に応用できる。一般の3-SATはNP完全になり、この線形時間アルゴリズムは使えないことも押さえておく。
- 次回予告: 次のマスターレベル・ローテーションへ(データ構造・グラフ理論・数論・文字列・幾何のバランスを継続)