C - 洞窟探検 / Cave Exploration Editorial by admin
gemini-3.5-flash-thinking概要
この問題は、与えられた有向グラフ上を特定のルールに従って探索したとき、最終的に一度も訪れなかった部屋を特定する問題です。探索の挙動は深さ優先探索(DFS)に非常に酷似しており、ルールの通りに正しくシミュレーションを行うことで解くことができます。
考察
ルールの整理
問題文のルールを整理すると、以下のようになります。 - 現在の部屋 \(u\) から出る通路(辺)を、優先度順に 1 つずつ確認する。 - 通路の先にある部屋 \(v\) について: - すでに訪問済みなら、スキップする。 - 未訪問だが、照明の色が同じ(\(C_u = C_v\))なら、スキップする。 - 未訪問で、照明の色が異なる(\(C_u \neq C_v\))なら、部屋 \(v\) を訪問済みにして \(v\) へ移動する。 - すべての通路を確認しても移動先がない場合、直前にいた部屋に戻る(バックトラック)。 - バックトラックした際、「中断していた通路の確認を、中断した箇所の次の通路から再開する」。
高速化の鍵:「確認の再開」
素朴にシミュレーションを行う際、バックトラックして戻ってきたときに「その部屋から出る通路を最初からスキャンし直す」という実装をしてしまうと、同じ通路を何度も無駄に確認することになり、最悪の場合に実行時間制限(TLE)となってしまいます。
しかし、ルール 4 にある「中断した箇所の次の通路から再開する」という記述に注目すると、各部屋から出る通路は、探索全体を通してそれぞれ高々 1 回しか確認されないことが分かります。 したがって、各部屋 \(u\) について「次に確認すべき通路のインデックス」を保持しておき、一度確認した通路は二度と確認しないようにポインタを進めていくことで、無駄な重複走査を避けることができます。
これは、通常の DFS やオイラー路を求めるアルゴリズムなどで使われる「辺の走査ポインタを戻さない」テクニックと同じです。これを用いることで、全体の探索を \(O(N + M)\) の線形時間で行うことができます。
アルゴリズム
スタックを用いた非再帰の深さ優先探索(DFS)により、探索をシミュレーションします。
初期化:
- 各部屋が訪問済みかどうかを記録する配列
visitedを用意し、すべてFalseで初期化します。ただし、部屋 \(1\) は開始時に訪問済み(True)とします。 - 各部屋 \(u\) について、次に確認すべき通路のインデックスを記録する配列
edge_idxを \(0\) で初期化します。 - 現在の探索経路(通ってきた部屋の履歴)を管理するスタック
stackを用意し、初期状態として[1]を入れます。
- 各部屋が訪問済みかどうかを記録する配列
シミュレーション:
stackが空になるまで、以下の処理を繰り返します。- スタックの末尾にある要素(現在の部屋 \(u\))を取得します(pop はせず、見るだけ)。
- \(u\) から出る通路のリスト
adj[u]において、現在指しているインデックスedge_idx[u]がリストのサイズ未満である場合:- 通路の先の部屋 \(v = \text{adj}[u][\text{edge\_idx}[u]]\) を取得し、
edge_idx[u]を \(1\) 進めます。 - \(v\) がすでに訪問済み、または \(C_u = C_v\) であれば、何もせず次のループへ進みます(スキップ)。
- \(C_u \neq C_v\) であれば、\(v\) を訪問済み(
visited[v] = True)にし、スタックに \(v\) を追加(stack.append(v))して、次の部屋 \(v\) の探索へ移行します。
- 通路の先の部屋 \(v = \text{adj}[u][\text{edge\_idx}[u]]\) を取得し、
edge_idx[u]が通路のサイズに達している(探索し尽くした)場合:- スタックから \(u\) を取り除きます(
stack.pop())。これにより、直前にいた部屋(スタックの新たな末尾)にバックトラックします。
- スタックから \(u\) を取り除きます(
出力:
- 探索終了後、
visitedがFalseのままの部屋を小さい順に集め、存在すればそれらを出力し、存在しなければCOMPLETEと出力します。
- 探索終了後、
計算量
- 時間計算量: \(O(N + M)\)
各部屋はスタックに高々 \(1\) 回しか追加されず、各通路も
edge_idxによって高々 \(1\) 回しか評価されません。よって、グラフの規模に対して線形時間で動作します。 - 空間計算量: \(O(N + M)\)
グラフを表現する隣接リスト
adjに \(O(N + M)\)、スタックや訪問状態の管理に \(O(N)\) のメモリを使用します。
実装のポイント
非再帰(スタック)による実装: Python などの言語では、再帰関数を用いて DFS を実装すると、再帰の深さ制限(デフォルトでは \(1000\) 程度)に達してランタイムエラー(RecursionError)になることがあります。また、再帰呼び出しのオーバーヘッドも無視できません。 今回のようにスタックを用いたループによるシミュレーションを行うことで、再帰制限を気にする必要がなくなり、高速かつ安全に動作させることができます。
edge_idxによる状態管理:edge_idx[u]を用いて「どこまで通路を調べたか」を部屋ごとに独立して保持することが、この問題を \(O(N + M)\) で解くための最も重要なポイントです。ソースコード
import sys
def solve():
# 入力を一括で読み込む
input_data = sys.stdin.read().split()
if not input_data:
return
N = int(input_data[0])
M = int(input_data[1])
# 各部屋の照明の色(1-indexed)
C = [0] + [int(x) for x in input_data[2:2+N]]
# 隣接リストの構築
adj = [[] for _ in range(N + 1)]
idx = 2 + N
for _ in range(M):
u = int(input_data[idx])
v = int(input_data[idx+1])
adj[u].append(v)
idx += 2
# 探索用配列
visited = [False] * (N + 1)
visited[1] = True
stack = [1]
edge_idx = [0] * (N + 1)
# DFSシミュレーション
while stack:
u = stack[-1]
u_edges = adj[u]
if edge_idx[u] < len(u_edges):
v = u_edges[edge_idx[u]]
edge_idx[u] += 1
if visited[v]:
continue
if C[v] == C[u]:
continue
visited[v] = True
stack.append(v)
else:
stack.pop()
# 一度も訪れなかった部屋の抽出
unvisited = [i for i in range(1, N + 1) if not visited[i]]
if not unvisited:
print("COMPLETE")
else:
print(*(unvisited))
if __name__ == '__main__':
solve()
この解説は gemini-3.5-flash-thinking によって生成されました。