0Pricing
Coding Interview Prep · Lezione

Rilevamento dei cicli nei grafi diretti e non diretti

Rilevi i cicli nei grafi non diretti tracciando i genitori e nei grafi diretti con la codifica a colori DFS (stato visitato a tre colori: bianco/grigio/nero)

Rilevamento dei cicli nei grafi diretti e non diretti è una lezione Coding Interview Prep gratuita su CoddyKit. Questa è la lezione 4 di 4. Puoi leggere la lezione completa qui gratuitamente — poi esercitati direttamente nel browser con un editor di codice integrato e un tutor IA disponibile 24/7. Fa parte del percorso di apprendimento Coding Interview Prep, e i tuoi progressi si sincronizzano tra il web e l'app CoddyKit. Il corso Coding Interview Prep include 4 lezioni in totale.

Perché è importante individuare i cicli

In un grafo, un ciclo è un cammino che inizia e termina nello stesso nodo. L'individuazione dei cicli è fondamentale in molti algoritmi: l'ordinamento topologico non funziona sui grafi ciclici, la risoluzione delle dipendenze deve rilevare le dipendenze circolari e il rilevamento dei deadlock nella pianificazione del sistema operativo richiede di trovare i cicli nei grafi di allocazione delle risorse. L'approccio è diverso per i grafi non diretti e diretti: richiedono algoritmi fondamentalmente diversi.

from collections import defaultdict

# Undirected cycle: A-B-C-A (triangle)
undirected = defaultdict(list)
for u, v in [('A','B'),('B','C'),('C','A')]:
    undirected[u].append(v)
    undirected[v].append(u)

# Directed cycle: A->B->C->A
directed = defaultdict(list)
for u, v in [('A','B'),('B','C'),('C','A')]:
    directed[u].append(v)  # one direction only

# Key difference:
# Undirected: edge A-B appears as both A->B and B->A
# Must track parent to distinguish cycle from back-edge to parent
print('Undirected and directed cycles need different detection')

Individuazione dei cicli nei grafi non diretti con DFS

In un grafo non diretto, esiste un ciclo se la DFS visita un nodo che si trova già nel percorso corrente, non semplicemente tra quelli visitati. La difficoltà consiste nel fatto che ogni arco compare in entrambe le direzioni: quando si visita un nodo figlio, la sua lista di vicini include il nodo corrente, cioè il padre. È quindi necessario tracciare il padre di ogni nodo per evitare di segnalare erroneamente come ciclo l'arco che torna al padre. Se si incontra un nodo visitato che non è il padre, è stato trovato un ciclo.

def has_cycle_undirected(n, edges):
    from collections import defaultdict
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)

    visited = set()

    def dfs(node, parent):
        visited.add(node)
        for nb in graph[node]:
            if nb not in visited:
                if dfs(nb, node):  # recurse with current as parent
                    return True
            elif nb != parent:     # visited and not parent = CYCLE
                return True
        return False

    for node in range(n):
        if node not in visited:
            if dfs(node, -1):  # -1 = no parent for root
                return True
    return False

print(has_cycle_undirected(4, [(0,1),(1,2),(2,3),(3,1)]))  # True
print(has_cycle_undirected(3, [(0,1),(1,2)]))               # False

Ciclo in un grafo non diretto con BFS

L'individuazione dei cicli con BFS in un grafo non diretto traccia anch'essa il padre di ogni nodo visitato. Durante l'elaborazione dei vicini di un nodo, se un vicino è già stato visitato e non è il padre del nodo corrente, esiste un ciclo. Si utilizza un dizionario per memorizzare i padri. Questo approccio, con complessità O(V + E), evita il problema del limite di ricorsione ed è l'alternativa iterativa preferibile per i grafi di grandi dimensioni.

from collections import deque, defaultdict

def has_cycle_bfs_undirected(n, edges):
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)

    visited = set()

    for start in range(n):
        if start in visited:
            continue
        visited.add(start)
        parent = {start: -1}
        queue = deque([start])
        while queue:
            node = queue.popleft()
            for nb in graph[node]:
                if nb not in visited:
                    visited.add(nb)
                    parent[nb] = node
                    queue.append(nb)
                elif parent[node] != nb:  # visited and not parent = CYCLE
                    return True
    return False

print(has_cycle_bfs_undirected(4, [(0,1),(1,2),(2,0)]))  # True

Cicli nei grafi diretti: perché il tracciamento del padre non basta

In un grafo diretto, il tracciamento del padre non è sufficiente. Si considerino A→C e B→C: il nodo C ha due 'padri', ma non c'è alcun ciclo. L'approccio corretto utilizza la colorazione a tre stati: bianco (non visitato), grigio (nel percorso/stack DFS corrente) e nero (elaborato completamente). Esiste un ciclo se durante la DFS si incontra un nodo grigio: significa che è stato trovato un arco all'indietro verso un antenato nel percorso corrente.

# Three-state DFS coloring:
# WHITE (0): not yet visited
# GRAY  (1): currently being visited (in DFS stack)
# BLACK (2): fully visited (all descendants processed)

# Why parent fails for directed graphs:
# A -> C  (no cycle)
# B -> C  (no cycle)
# If we DFS from A, mark C gray
# Then DFS from B finds C is gray -- but this is NOT a cycle!
# C is gray from A's path, not B's path.
# Parent tracking only works when the back-edge goes to the IMMEDIATE parent.
print('Directed graph: use 3-state coloring (white/gray/black)')

Individuazione dei cicli nei grafi diretti con DFS a tre stati

Si utilizza un array state[] con i valori 0 (bianco/non visitato), 1 (grigio/nello stack) e 2 (nero/completato). Si avvia la DFS, contrassegnando il nodo come grigio all'ingresso e come nero all'uscita. Se la DFS raggiunge un nodo grigio, è stato trovato un arco all'indietro e quindi esiste un ciclo. Se raggiunge un nodo nero, quel percorso è già stato esplorato completamente e non contiene cicli, quindi lo si ignora.

def has_cycle_directed(n, edges):
    from collections import defaultdict
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)

    state = [0] * n  # 0=white, 1=gray, 2=black

    def dfs(node):
        state[node] = 1  # mark gray (in stack)
        for nb in graph[node]:
            if state[nb] == 1:  # gray = back edge = CYCLE
                return True
            if state[nb] == 0:  # white = unvisited
                if dfs(nb):
                    return True
        state[node] = 2  # mark black (fully processed)
        return False

    for node in range(n):
        if state[node] == 0:
            if dfs(node):
                return True
    return False

print(has_cycle_directed(4, [(0,1),(1,2),(2,0),(2,3)]))  # True (0->1->2->0)
print(has_cycle_directed(3, [(0,1),(1,2)]))               # False

Course Schedule: ciclo in un DAG

Course Schedule (LeetCode #207) chiede se sia possibile completare tutti i corsi date le rispettive propedeuticità. Si modellano i corsi come nodi e le propedeuticità come archi diretti. È possibile completare tutti i corsi se e solo se il grafo è un DAG (senza cicli). Si utilizza l'individuazione dei cicli con DFS a tre stati: se viene trovato un ciclo, si restituisce False; altrimenti si restituisce True.

from collections import defaultdict

def can_finish(num_courses, prerequisites):
    graph = defaultdict(list)
    for a, b in prerequisites:
        graph[b].append(a)  # b is prerequisite for a: b -> a

    state = [0] * num_courses

    def dfs(course):
        if state[course] == 1: return False  # cycle!
        if state[course] == 2: return True   # already verified
        state[course] = 1  # mark as in-progress
        for next_course in graph[course]:
            if not dfs(next_course):
                return False
        state[course] = 2  # mark as done
        return True

    return all(dfs(i) for i in range(num_courses) if state[i] == 0)

print(can_finish(2, [[1,0]]))        # True: take 0 then 1
print(can_finish(2, [[1,0],[0,1]]))  # False: circular dependency

Individuazione dei cicli con l'algoritmo di Kahn (BFS)

Un approccio alternativo per individuare i cicli nei grafi diretti utilizza l'ordinamento topologico BFS di Kahn. Si calcola il grado entrante di ogni nodo. Si inseriscono in una coda i nodi con grado entrante 0. Per ciascuno di essi, si decrementa il grado entrante dei vicini e si inseriscono in coda quelli che raggiungono 0. Se il numero di nodi elaborati è uguale a V, non ci sono cicli; altrimenti esiste un ciclo, poiché i nodi non elaborati formano cicli. Questo approccio, con complessità O(V + E), è intuitivo e più facile da ricordare rispetto alla DFS a tre stati.

from collections import defaultdict, deque

def has_cycle_kahn(n, edges):
    graph = defaultdict(list)
    in_degree = [0] * n
    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1

    # Start with all zero in-degree nodes
    queue = deque(i for i in range(n) if in_degree[i] == 0)
    processed = 0
    while queue:
        node = queue.popleft()
        processed += 1
        for nb in graph[node]:
            in_degree[nb] -= 1
            if in_degree[nb] == 0:
                queue.append(nb)

    return processed != n  # if not all processed, cycle exists

print(has_cycle_kahn(4, [(0,1),(1,2),(2,0),(2,3)]))  # True
print(has_cycle_kahn(3, [(0,1),(1,2)]))               # False

Individuare il ciclo: raccogliere i nodi del ciclo

A volte è necessario identificare quali nodi fanno parte di un ciclo, non solo rilevarne l'esistenza. Durante una DFS a tre stati, quando viene trovato un arco all'indietro, si percorre a ritroso lo stack delle chiamate, o uno stack del percorso, per raccogliere tutti i nodi compresi tra l'antenato e il nodo corrente. Uno stack del percorso mantenuto insieme all'array degli stati rappresenta il percorso DFS corrente e consente di ricostruire il ciclo in O(cycle_length).

def find_cycle_nodes(n, edges):
    from collections import defaultdict
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)

    state = [0] * n
    path = []  # current DFS path
    cycle = []

    def dfs(node):
        state[node] = 1
        path.append(node)
        for nb in graph[node]:
            if state[nb] == 1:  # back edge -> found cycle
                start = path.index(nb)
                cycle.extend(path[start:])
                return True
            if state[nb] == 0 and dfs(nb):
                return True
        path.pop()
        state[node] = 2
        return False

    for i in range(n):
        if state[i] == 0 and dfs(i):
            break
    return cycle

print(find_cycle_nodes(4, [(0,1),(1,2),(2,0),(2,3)]))  # [0, 1, 2]

Individuazione degli stati eventualmente sicuri

Find Eventual Safe States (LeetCode #802) chiede quali nodi conducano infine a un nodo terminale, cioè senza archi uscenti, senza rimanere bloccati in un ciclo. Un nodo è 'sicuro' se tutti i percorsi che partono da esso conducono a nodi terminali. Si utilizza una DFS a tre stati: i nodi neri, elaborati completamente senza rilevare cicli, sono sicuri. I nodi che fanno parte di un ciclo o conducono a un ciclo non sono sicuri.

def eventual_safe_nodes(graph):
    n = len(graph)
    state = [0] * n  # 0=unvisited, 1=visiting, 2=safe

    def dfs(node):
        if state[node] == 1:  # currently visiting = cycle
            return False
        if state[node] == 2:  # already verified safe
            return True
        state[node] = 1  # mark as visiting
        for nb in graph[node]:
            if not dfs(nb):
                return False  # leads to cycle, not safe
        state[node] = 2  # mark as safe
        return True

    return [i for i in range(n) if dfs(i)]

# [[1,2],[2,3],[5],[0],[5],[],[]] means:
# 0->[1,2], 1->[2,3], 2->[5], 3->[0] (cycle!), 4->[5], 5->[], 6->[]
print(eventual_safe_nodes([[1,2],[2,3],[5],[0],[5],[],[]]))
# [2, 4, 5, 6]

Arco ridondante in un grafo non diretto

Redundant Connection (LeetCode #684) individua l'arco che crea un ciclo quando viene aggiunto a un grafo non diretto altrimenti aciclico. Sebbene sia possibile risolvere il problema con l'individuazione dei cicli tramite DFS, la soluzione più semplice utilizza Union-Find (DSU): si elaborano gli archi uno alla volta; se gli estremi sono già connessi, cioè appartengono alla stessa componente, l'arco corrente crea un ciclo ed è la risposta. DSU offre una complessità O(alpha(n)) per operazione, quindi effettivamente O(1).

def find_redundant_connection(edges):
    n = len(edges)
    parent = list(range(n + 1))
    rank = [0] * (n + 1)

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])  # path compression
        return parent[x]

    def union(x, y):
        px, py = find(x), find(y)
        if px == py:
            return False  # already connected = cycle!
        if rank[px] < rank[py]: px, py = py, px
        parent[py] = px
        if rank[px] == rank[py]: rank[px] += 1
        return True

    for u, v in edges:
        if not union(u, v):
            return [u, v]  # this edge creates the cycle
    return []

print(find_redundant_connection([[1,2],[1,3],[2,3]]))  # [2,3]
print(find_redundant_connection([[1,2],[2,3],[3,4],[1,4],[1,5]]))  # [1,4]

Riepilogo: strategie per l'individuazione dei cicli

Per riepilogare gli strumenti per l'individuazione dei cicli: per i grafi non diretti, si utilizza la DFS con tracciamento del padre oppure Union-Find. Per i grafi diretti, si utilizza la DFS a tre stati (bianco/grigio/nero) oppure l'ordinamento topologico BFS di Kahn. Si scelga Union-Find quando gli archi vengono aggiunti uno alla volta (online). Si scelga Kahn quando serve anche l'ordine topologico. Si scelga la DFS a tre stati quando è necessario identificare i nodi appartenenti al ciclo specifico. Nei colloqui tecnici, si distingua sempre tra grafi diretti e non diretti quando si parla di individuazione dei cicli.

# Cycle detection summary:
# Graph type  | Algorithm            | Complexity
# ------------|----------------------|-----------
# Undirected  | DFS + parent track   | O(V + E)
# Undirected  | Union-Find (DSU)     | O(E * alpha(V))
# Directed    | DFS 3-state (W/G/B)  | O(V + E)
# Directed    | Kahn's BFS topo sort | O(V + E)

# When to choose:
# Online (edges added one at a time): Union-Find
# Need topological order too: Kahn's BFS
# Need cycle nodes identified: 3-state DFS with path stack
# Simple existence check: any of the above
print('Always clarify directed vs undirected before coding')

Verifica rapida

Verifichi la Sua comprensione dei concetti di Data Structures & Algorithms — Coding Interview Prep trattati in questa lezione.

Riepilogo della lezione

In questa lezione ha imparato a individuare i cicli nei grafi non diretti con la DFS e il tracciamento del padre, i cicli nei grafi diretti con la colorazione a tre stati bianco/grigio/nero, l'alternativa BFS di Kahn per i grafi diretti e applicazioni come Course Schedule, Redundant Connection e Find Eventual Safe States. Ora approfondiremo i fondamenti della programmazione dinamica.

Domande Frequenti

La lezione «Rilevamento dei cicli nei grafi diretti e non diretti» è gratuita?

Sì — il testo completo di «Rilevamento dei cicli nei grafi diretti e non diretti» è gratuito qui sul web. Per esercitarvi in modo interattivo (un editor di codice integrato e un tutor IA 24/7) e sbloccare il resto del corso Coding Interview Prep, passa a CoddyKit PRO. Il corso Coding Interview Prep include 4 lezioni in totale.

Cosa imparerò in «Rilevamento dei cicli nei grafi diretti e non diretti»?

Rilevi i cicli nei grafi non diretti tracciando i genitori e nei grafi diretti con la codifica a colori DFS (stato visitato a tre colori: bianco/grigio/nero) Eserciti Coding Interview Prep con codice pratico che esegui direttamente nel browser, e un tutor IA 24/7 risponde alle tue domande mentre lavori sulla lezione.

Ho bisogno di esperienza per iniziare Coding Interview Prep?

Non è richiesta alcuna esperienza precedente. Coding Interview Prep su CoddyKit è strutturato per principianti e studenti avanzati, quindi puoi iniziare da qui o dall'inizio e procedere al tuo ritmo. Questa è la lezione 4 di 4.

Quanto tempo richiede la lezione «Rilevamento dei cicli nei grafi diretti e non diretti»?

La maggior parte delle lezioni CoddyKit richiede circa 5–10 minuti. Ogni lezione è breve e interattiva, quindi fai progressi costanti e riprendi esattamente da dove hai lasciato su web e app.

Posso scrivere ed eseguire codice in questa lezione Coding Interview Prep?

Sì. Ogni lezione Coding Interview Prep include un editor di codice integrato, quindi scrivi ed esegui codice reale direttamente nel tuo browser e ricevi feedback istantaneo dall'IA — nessuna configurazione locale necessaria.

Tutte le lezioni di questo corso

  1. Rappresentazioni dei grafi e configurazione dei percorsi
  2. BFS: percorso più breve e visita per livelli
  3. DFS: componenti connesse e flood fill
  4. Rilevamento dei cicli nei grafi diretti e non diretti
← Torna a Coding Interview Prep