# NP問題の検証器の実装例class NPVerifier: """NP問題の検証器コレクション""" @staticmethod def verify_sat(formula, assignment): """ SAT(充足可能性問題)の検証器 formula: CNF形式の論理式 [[1, -2, 3], [-1, 3], ...] 正の数 = 変数、負の数 = 否定 assignment: {変数番号: True/False} 検証の計算量: O(n × m)(n=変数数、m=節数) → 多項式時間で検証可能 → NPに属する """ for clause in formula: satisfied = False for literal in clause: var = abs(literal) value = assignment.get(var, False) if literal > 0 and value: satisfied = True break if literal < 0 and not value: satisfied = True break if not satisfied: return False return True @staticmethod def verify_hamiltonian_cycle(graph, cycle): """ ハミルトン閉路の検証器 graph: 隣接リスト {頂点: [隣接頂点, ...]} cycle: 頂点のリスト 検証: O(V) """ n = len(graph) # 全頂点を1回ずつ訪問しているか if len(cycle) != n: return False if set(cycle) != set(graph.keys()): return False # 各辺が存在するか for i in range(n): u = cycle[i] v = cycle[(i + 1) % n] if v not in graph[u]: return False return True @staticmethod def verify_graph_coloring(graph, coloring, k): """ k-彩色の検証器 graph: 隣接リスト coloring: {頂点: 色} k: 使用可能な色の数 検証: O(V + E) """ # 色の数がk以下か if len(set(coloring.values())) > k: return False # 全頂点に色が割り当てられているか if set(coloring.keys()) != set(graph.keys()): return False # 隣接する頂点が同じ色でないか for u in graph: for v in graph[u]: if coloring[u] == coloring[v]: return False return True @staticmethod def verify_subset_sum(numbers, target, subset): """ 部分集合和問題の検証器 numbers: 数の集合 target: 目標の和 subset: 選んだ要素のインデックス集合 検証: O(n) """ # subsetがnumbersの部分集合か if not all(0 <= i < len(numbers) for i in subset): return False # 和がtargetに等しいか return sum(numbers[i] for i in subset) == target @staticmethod def verify_clique(graph, clique, k): """ k-クリークの検証器 graph: 隣接リスト clique: 頂点の集合 k: クリークの大きさ 検証: O(k²) """ if len(clique) != k: return False # 全ペアが辺で結ばれているか clique_list = list(clique) for i in range(len(clique_list)): for j in range(i + 1, len(clique_list)): u, v = clique_list[i], clique_list[j] if v not in graph.get(u, []): return False return True# 検証の実演verifier = NPVerifier()# SAT の検証formula = [[1, -2, 3], [-1, 3], [2, -3]] # (x₁ ∨ ¬x₂ ∨ x₃) ∧ ...assignment = {1: True, 2: False, 3: True}print(f"SAT検証: {verifier.verify_sat(formula, assignment)}") # True# 部分集合和の検証numbers = [3, 7, 1, 8, 4]target = 12subset = {0, 2, 3} # 3 + 1 + 8 = 12print(f"部分集合和検証: {verifier.verify_subset_sum(numbers, target, subset)}")
2.3 NP完全とNP困難
NP完全(NP-Complete):
NPの中で「最も難しい」問題のクラス
形式的定義:
L が NP完全 ⟺
1. L ∈ NP
2. 任意の L' ∈ NP に対して L' ≤ₚ L
(全てのNP問題がLに多項式時間帰着可能)
1つのNP完全問題が多項式時間で解ければ:
→ 全てのNP問題が多項式時間で解ける → P = NP
NP困難(NP-Hard):
L が NP困難 ⟺
任意の L' ∈ NP に対して L' ≤ₚ L
NP完全 = NP ∩ NP困難
# 確率的アルゴリズムの例import random# RP の例: Miller-Rabin素数判定def miller_rabin(n, k=20): """ Miller-Rabin素数判定 RPに属する: - n が合成数 → 確率 ≥ 1 - 4^{-k} で「合成数」と答える - n が素数 → 確率 1 で「素数」と答える """ if n < 2: return False if n < 4: return True if n % 2 == 0: return False # n - 1 = 2^r × d (dは奇数) r, d = 0, n - 1 while d % 2 == 0: r += 1 d //= 2 for _ in range(k): a = random.randrange(2, n - 1) x = pow(a, d, n) if x == 1 or x == n - 1: continue for _ in range(r - 1): x = pow(x, 2, n) if x == n - 1: break else: return False # 合成数 return True # おそらく素数# BPP の例: ランダム化最小カット(Kargerのアルゴリズム)def karger_min_cut(graph, n): """ Kargerのランダム化最小カットアルゴリズム BPPに属する 1回の実行で最小カットを見つける確率: ≥ 2/n² n²ln(n) 回繰り返せば高確率で正解 """ import copy # 辺の収縮 vertices = list(range(n)) edges = copy.deepcopy(graph) # (u, v) のリスト # Union-Find parent = list(range(n)) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(x, y): px, py = find(x), find(y) parent[px] = py remaining = n while remaining > 2: # ランダムに辺を選んで収縮 while True: u, v = random.choice(edges) if find(u) != find(v): break union(u, v) remaining -= 1 # 残った2つのスーパー頂点間の辺を数える cut_size = 0 for u, v in edges: if find(u) != find(v): cut_size += 1 return cut_size# ZPP の例: ランダム化クイックソートdef randomized_quicksort(arr): """ ランダム化クイックソート ZPPに属する: 常に正しい結果、期待実行時間 O(n log n) 最悪の場合 O(n²) だが確率は非常に低い """ if len(arr) <= 1: return arr pivot = random.choice(arr) left = [x for x in arr if x < pivot] middle = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return randomized_quicksort(left) + middle + randomized_quicksort(right)
# FPTアルゴリズムの例# 頂点被覆のFPTアルゴリズム(分岐限定法)def vertex_cover_fpt(graph, k): """ 頂点被覆のFPTアルゴリズム 時間計算量: O(2^k × (V + E)) アイデア: 辺(u,v)を1つ選ぶ → uを被覆に入れるか、vを入れるかの2択 → 深さkの二分木を探索 """ def solve(edges, cover, remaining_k): # 辺がなくなったら成功 if not edges: return cover # 予算がなくなったら失敗 if remaining_k == 0: return None # 辺を1つ選ぶ u, v = next(iter(edges)) # 選択1: uを被覆に入れる new_edges_u = {(a, b) for (a, b) in edges if a != u and b != u} result = solve(new_edges_u, cover | {u}, remaining_k - 1) if result is not None: return result # 選択2: vを被覆に入れる new_edges_v = {(a, b) for (a, b) in edges if a != v and b != v} result = solve(new_edges_v, cover | {v}, remaining_k - 1) if result is not None: return result return None # サイズk以下の頂点被覆は存在しない # 辺の集合を構築 edges = set() for u in graph: for v in graph[u]: if u < v: edges.add((u, v)) return solve(edges, set(), k)# Color Coding: k-パス問題のFPTアルゴリズムdef color_coding_k_path(graph, n, k): """ k-パス問題: グラフに長さkのパスが存在するか? FPTアルゴリズム: O(2^k × E) (ランダム化) アイデア: 1. 各頂点にランダムに k 色を割り当て 2. 全ての色が使われるカラフルなパスをDPで見つける 3. 成功確率 ≥ e^{-k} → O(e^k) 回繰り返せば高確率で見つかる """ for trial in range(int(2.72 ** k) * 2): # e^k × 2 回試行 # ランダムな彩色 colors = [random.randint(0, k - 1) for _ in range(n)] # DP: dp[v][S] = 頂点vで終わり、色集合Sを使うカラフルなパスが存在するか dp = [[False] * (1 << k) for _ in range(n)] for v in range(n): dp[v][1 << colors[v]] = True for S in range(1, 1 << k): for v in range(n): if not dp[v][S]: continue for u in graph.get(v, []): c = colors[u] if not (S & (1 << c)): # 新しい色 dp[u][S | (1 << c)] = True # 全色を使うパスが見つかったか? full_set = (1 << k) - 1 for v in range(n): if dp[v][full_set]: return True return False
問題: 独立集合問題が頂点被覆問題に帰着できることを示せ。
定義:
- 独立集合: どの2頂点も辺で結ばれていない頂点の部分集合
- 頂点被覆: 全ての辺の少なくとも一端を含む頂点の部分集合
証明:
Sが独立集合 ⟺ V \ S が頂点被覆
(→) Sが独立集合とする。
任意の辺 (u,v) について、u,v の少なくとも一方は S に属さない
(両方属していたら独立でない)。
よって少なくとも一方は V \ S に属する → V \ S は頂点被覆。
(←) V \ S が頂点被覆とする。
S の任意の2頂点 u,v について、(u,v) は辺でない
(辺であれば u,v ∈ S なので V \ S に含まれず被覆にならない)。
よって S は独立集合。
帰着: サイズkの独立集合が存在 ⟺ サイズ(n-k)の頂点被覆が存在
この帰着は O(1) 時間 → 多項式時間帰着 ∎
演習2: 近似アルゴリズム(応用)
"""演習: 貪欲法による集合被覆の近似アルゴリズムを実装し、近似比を実測せよ。"""import randomdef generate_set_cover_instance(n, m, density=0.3): """集合被覆のランダムインスタンス生成""" universe = set(range(n)) sets = {} for i in range(m): size = max(1, int(n * density * random.random())) sets[i] = set(random.sample(list(universe), min(size, n))) # 全要素が被覆可能であることを保証 for elem in universe: random_set = random.choice(list(sets.keys())) sets[random_set].add(elem) return universe, setsdef optimal_set_cover(universe, sets): """厳密解(小規模用、指数時間)""" n = len(sets) keys = list(sets.keys()) best = None for mask in range(1, 1 << n): covered = set() selected = [] for i in range(n): if mask & (1 << i): covered |= sets[keys[i]] selected.append(keys[i]) if covered >= universe: if best is None or len(selected) < len(best): best = selected return best# 実験for n in [10, 15, 20]: total_ratio = 0 trials = 50 for _ in range(trials): universe, sets = generate_set_cover_instance(n, n * 2) greedy = greedy_set_cover(universe, sets) optimal = optimal_set_cover(universe, sets) ratio = len(greedy) / len(optimal) total_ratio += ratio avg_ratio = total_ratio / trials print(f"n={n}: 平均近似比 = {avg_ratio:.3f} (理論上界: {math.log(n):.3f})")
演習3: SAT ソルバー(実装)
"""演習: DPLLアルゴリズム(SATソルバーの基礎)を実装せよ。"""def dpll(clauses, assignment=None): """ DPLLアルゴリズム — SAT問題の標準的な解法 最悪の場合 O(2^n) だが、実用上は非常に効率的 現代のSATソルバー(MiniSat, Z3等)の基礎 """ if assignment is None: assignment = {} # 充足チェック clauses = simplify(clauses, assignment) # 空の節がある → 充足不可能 if any(len(c) == 0 for c in clauses): return None # 全ての節が除去された → 充足可能 if len(clauses) == 0: return assignment # ユニット伝播(Unit Propagation) unit_clauses = [c for c in clauses if len(c) == 1] while unit_clauses: literal = unit_clauses[0][0] var = abs(literal) value = literal > 0 assignment[var] = value clauses = simplify(clauses, {var: value}) if any(len(c) == 0 for c in clauses): return None if len(clauses) == 0: return assignment unit_clauses = [c for c in clauses if len(c) == 1] # 純リテラル除去(Pure Literal Elimination) all_literals = set() for clause in clauses: for lit in clause: all_literals.add(lit) for lit in list(all_literals): if -lit not in all_literals: var = abs(lit) assignment[var] = (lit > 0) clauses = simplify(clauses, {var: lit > 0}) if len(clauses) == 0: return assignment # 分岐(Branching) var = abs(clauses[0][0]) # True を試す result = dpll(clauses, {**assignment, var: True}) if result is not None: return result # False を試す result = dpll(clauses, {**assignment, var: False}) return resultdef simplify(clauses, assignment): """割り当てに基づいて節を簡約化""" new_clauses = [] for clause in clauses: new_clause = [] satisfied = False for lit in clause: var = abs(lit) if var in assignment: if (lit > 0) == assignment[var]: satisfied = True break # リテラルがFalseの場合は節から除去 else: new_clause.append(lit) if not satisfied: new_clauses.append(new_clause) return new_clauses