Conexão redundante e detecção de ciclos
Detecte a aresta que cria um ciclo em um grafo não direcionado aplicando união a cada aresta e verificando se dois nós já estão conectados.
Conexão redundante e detecção de ciclos é uma aula grátis de DSA Interview Prep no CoddyKit. Esta é a aula 3 de 4. Você pode ler a aula completa abaixo gratuitamente — depois pratica ao vivo no navegador com um editor de código integrado e um tutor de IA 24/7. Faz parte do caminho de aprendizado de DSA Interview Prep, e seu progresso é sincronizado entre a web e o app CoddyKit. O curso de DSA Interview Prep inclui 4 aulas no total.
O que é uma conexão redundante?
O problema da Conexão redundante (LeetCode 684) fornece uma árvore com n nós e uma aresta extra, formando exatamente um ciclo. Sua tarefa é encontrar a aresta que, quando removida, restaura a árvore. Se houver várias respostas possíveis, retorne a última na lista de entrada.
Uma árvore com n nós tem exatamente n-1 arestas, é conexa e não possui ciclos. Adicionar mais uma aresta cria exatamente um ciclo. A aresta adicionada (redundante) conecta dois nós que já estavam no mesmo componente — um cenário clássico de detecção de ciclos com DSU.
# Example
# n=5, edges = [[1,2],[1,3],[2,3],[2,4],[3,5]]
# Adding edge [2,3] creates cycle 1-2-3-1
# So [2,3] is the redundant connection
# Key insight: process edges one by one with DSU
# The FIRST edge where both endpoints are already connected is the redundant one
print('Tree property: n nodes, n-1 edges, no cycles')
print('Adding 1 edge: n nodes, n edges, exactly 1 cycle')
print('DSU approach: find the edge that connects already-connected nodes')Detecção de ciclos com DSU
DSU detecta ciclos naturalmente: antes de adicionar uma aresta (u, v), verifique se find(u) == find(v). Se os nós compartilharem uma raiz, já estarão conectados — adicionar essa aresta criará um ciclo. Essa é a aresta redundante.
Esta abordagem funciona para grafos não direcionados. Para cada aresta, fazemos union dos dois componentes com sucesso (ainda não há ciclo) ou detectamos que ambas as extremidades já estão no mesmo componente (ciclo encontrado). A complexidade de tempo é O(n × alpha(n)), praticamente O(n).
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1)) # 1-indexed
rank = [0] * (n + 1)
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False # same component => cycle found
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
edges = [[1,2],[1,3],[2,3],[2,4],[3,5]]
print(find_redundant_connection(edges)) # [2, 3]Percorrendo o algoritmo passo a passo
Vamos percorrer [[1,2],[1,3],[2,3]] passo a passo. Inicialmente, cada nó é seu próprio componente: {1}, {2}, {3}.
- Aresta [1,2]: find(1)=1, find(2)=2, diferentes — faça union entre eles. Componentes: {1,2}, {3}
- Aresta [1,3]: find(1)=raiz, find(3)=3, diferentes — faça union entre eles. Componentes: {1,2,3}
- Aresta [2,3]: find(2)=raiz, find(3)=raiz — mesma raiz! Ciclo detectado. Retorne [2,3].
O algoritmo processa as arestas na ordem e retorna a primeira aresta que completa um ciclo. Como o problema garante que existe apenas uma aresta extra, esta é sempre a aresta redundante correta.
def find_redundant_trace(edges):
parent = list(range(len(edges) + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for u, v in edges:
pu, pv = find(u), find(v)
print(f'Edge ({u},{v}): find({u})={pu}, find({v})={pv}', end=' => ')
if pu == pv:
print('CYCLE DETECTED!')
return [u, v]
parent[pv] = pu
print('merged')
return []
result = find_redundant_trace([[1,2],[1,3],[2,3]])
print('Redundant edge:', result)Detecção de ciclos em grafos não direcionados com DFS
Uma alternativa ao DSU para detecção de ciclos em grafos não direcionados é o DFS com rastreamento do pai. Durante o DFS, se chegarmos a um nó que já foi visitado e que não é o pai direto do nó atual, encontramos uma aresta de retorno — o que indica um ciclo.
No entanto, a abordagem com DFS exige tempo O(V + E) e informa se existe um ciclo, mas não identifica facilmente qual aresta específica é redundante. DSU é preferível para problemas que pedem a identificação da aresta redundante específica, porque ela é encontrada naturalmente quando a operação union falha.
from collections import defaultdict
def has_cycle_dfs(n, edges):
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 == parent:
continue # skip the edge we came from
if nb in visited:
return True # back edge => cycle
if dfs(nb, node):
return True
return False
for node in range(1, n + 1):
if node not in visited:
if dfs(node, -1):
return True
return False
print(has_cycle_dfs(3, [[1,2],[1,3],[2,3]])) # True
print(has_cycle_dfs(3, [[1,2],[1,3]])) # FalseDetecção de ciclos em grafos direcionados
Para grafos direcionados, a detecção de ciclos com DSU não funciona diretamente, pois as arestas têm direção. Em vez disso, use DFS com marcação em três cores: branco (não visitado), cinza (no caminho atual do DFS) e preto (totalmente processado). Uma aresta de retorno para um nó cinza indica um ciclo.
Em um grafo não direcionado, qualquer aresta de retorno significa um ciclo. Em um grafo direcionado, uma aresta cruzada para um nó preto não representa um ciclo — apenas arestas de retorno para nós cinza representam ciclos. Essa distinção é crucial e é avaliada em problemas de programação de cursos.
def has_cycle_directed(n, edges):
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
# 0=white(unvisited), 1=grey(in stack), 2=black(done)
color = [0] * (n + 1)
def dfs(node):
color[node] = 1 # grey: currently visiting
for nb in graph[node]:
if color[nb] == 1:
return True # back edge to grey node => cycle
if color[nb] == 0:
if dfs(nb):
return True
color[node] = 2 # black: fully processed
return False
for node in range(1, n + 1):
if color[node] == 0:
if dfs(node):
return True
return False
from collections import defaultdict
print(has_cycle_directed(3, [[1,2],[2,3],[3,1]])) # True: 1->2->3->1
print(has_cycle_directed(3, [[1,2],[1,3],[2,3]])) # FalseConexão redundante II: variante para grafos direcionados
LeetCode 685 amplia o problema para grafos direcionados nos quais cada nó tem exatamente um pai (formando uma árvore enraizada com uma aresta extra). Surgem dois casos: ou um nó tem dois pais (grau de entrada 2), ou existe um ciclo sem que nenhum nó tenha dois pais.
A solução primeiro verifica se há nós com grau de entrada 2. Se encontrar um, uma de suas duas arestas de entrada deverá ser a resposta. Em seguida, a detecção de ciclos com DSU determina qual das duas arestas candidatas deve ser removida. Esta abordagem em duas fases trata todos os casos corretamente.
def find_redundant_directed(edges):
n = len(edges)
parent_map = {} # node -> its parent in the input
candidate1 = candidate2 = None
for u, v in edges:
if v in parent_map: # v already has a parent
candidate1 = [parent_map[v], v] # earlier edge
candidate2 = [u, v] # later edge
else:
parent_map[v] = u
# DSU cycle detection, skipping candidate2 if it exists
dsu = list(range(n + 1))
def find(x):
while dsu[x] != x: dsu[x] = dsu[dsu[x]]; x = dsu[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py: return False
dsu[px] = py; return True
for u, v in edges:
if candidate2 and [u, v] == candidate2: continue # skip candidate2
if not union(u, v): # cycle found without candidate2
return candidate1 if candidate1 else [u, v]
return candidate2 # no cycle when excluding candidate2 => candidate2 is redundant
print(find_redundant_directed([[1,2],[1,3],[2,3]])) # [2,3]
print(find_redundant_directed([[1,2],[2,3],[3,4],[4,1],[1,5]])) # [4,1]Validade do grafo após a remoção de uma aresta
Depois de identificar a aresta redundante, podemos verificar o resultado confirmando que removê-la deixa uma árvore válida: exatamente n-1 arestas, todos os nós conectados e nenhum ciclo. Para os fins do problema de entrevista, DSU garante isso naturalmente — se retornarmos a aresta cuja operação union falhou, sua remoção nos deixará exatamente com as n-1 arestas que foram unidas com sucesso, formando uma árvore geradora.
Essa garantia explica por que DSU é tão adequado para este problema: as operações union bem-sucedidas constroem a árvore incrementalmente, e a operação union que falha identifica a única aresta que não pertence a ela.
def verify_tree(n, edges, removed_edge):
parent = list(range(n + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
components = n
for u, v in edges:
if [u, v] == removed_edge:
continue # skip the removed edge
pu, pv = find(u), find(v)
if pu == pv:
print('CYCLE DETECTED after removal! Wrong answer.')
return False
parent[pv] = pu
components -= 1
if components != 1:
print(f'Graph not connected ({components} components). Wrong answer.')
return False
print('Valid tree after removing edge:', removed_edge)
return True
edges = [[1,2],[1,3],[2,3]]
verify_tree(3, edges, [2,3])
verify_tree(3, edges, [1,2]) # wrong removalAnálise da complexidade de tempo e espaço
A solução da conexão redundante baseada em DSU processa cada uma das n arestas exatamente uma vez, e cada operação union/find custa O(alpha(n)) amortizado. Tempo total: O(n × alpha(n)), efetivamente O(n).
A complexidade de espaço é O(n) para os vetores de pai e classificação. Isso é ótimo — no mínimo, é necessário ler todas as n arestas e armazenar algum estado para cada nó. Compare isso com uma abordagem ingênua que executa DFS após cada inserção de aresta: tempo O(n²) e espaço O(n + E).
# Summary of complexities
complexity = {
'Naive (DFS after each edge)': {'time': 'O(n^2)', 'space': 'O(n)'},
'DSU (path compression + rank)': {'time': 'O(n * alpha(n))', 'space': 'O(n)'},
'Sorting + DSU (Kruskal style)': {'time': 'O(n log n)', 'space': 'O(n)'},
}
for approach, costs in complexity.items():
print(f'{approach}:')
print(f' Time: {costs["time"]}')
print(f' Space: {costs["space"]}')
print()
print('alpha(n) <= 4 for all practical n, so DSU is effectively O(n).')Caso extremo: laço em si mesmo
Uma aresta de laço em si mesmo [u, u] cria imediatamente um ciclo, pois ambas as extremidades são o mesmo nó. Em DSU, find(u) == find(u) é sempre verdadeiro, portanto a operação union falha imediatamente e [u, u] é retornada como a aresta redundante.
A maioria das restrições dos problemas garante que não existam laços em si mesmos, mas um código robusto deve tratá-los. A implementação de DSU lida naturalmente com isso sem nenhum caso especial — a verificação de ciclo if find(u) == find(v) o detecta antes que qualquer operação union seja tentada. Verifique sempre com entradas de casos extremos, como laços em um único nó e entradas de tamanho mínimo.
def find_redundant_robust(edges):
n = len(edges)
parent = list(range(n + 1))
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
for u, v in edges:
pu, pv = find(u), find(v)
if pu == pv:
return [u, v] # handles self-loops too: u==v => pu==pv always
parent[pv] = pu
return []
# Self-loop test
print(find_redundant_robust([[1,2],[2,2]])) # [2,2] self-loop
# Minimum tree test
print(find_redundant_robust([[1,2],[2,3],[1,3]])) # [1,3]
# Standard test
print(find_redundant_robust([[1,2],[1,3],[2,3],[2,4],[3,5]])) # [2,3]Generalizando a detecção de ciclos entre algoritmos
Vários algoritmos detectam ciclos, cada um adequado a cenários diferentes:
- DSU: grafos não direcionados, chegada on-line de arestas, O(alpha(n)) por aresta — melhor para contar ciclos ou encontrar a aresta redundante
- DFS com rastreamento do pai: grafos não direcionados, todas as arestas conhecidas desde o início, O(V+E) — melhor quando você precisa do caminho do ciclo
- DFS de três cores: grafos direcionados, detecção de arestas de retorno, O(V+E) — melhor para problemas de programação de cursos e ordenação topológica
- sort topológico (de Kahn): grafos direcionados, detecta ciclos por meio de nós restantes com grau de entrada diferente de zero — melhor quando você também precisa da ordenação
# When to use which cycle-detection method:
# Problem type => preferred algorithm
problems = [
('Redundant Connection (undirected)', 'DSU'),
('Course Schedule (directed)', 'DFS three-color or Kahn topological sort'),
('Detect cycle in undirected graph', 'DFS with parent tracking or DSU'),
('Find cycle members in directed graph', 'DFS three-color + backtrack'),
('Online graph edges with cycle check', 'DSU'),
('Minimum spanning tree validity', 'DSU (Kruskal)'),
]
for problem, solution in problems:
print(f'{problem}\n => {solution}\n')Solução completa com casos extremos
Aqui está uma solução de qualidade para produção para Conexão redundante que trata todos os casos extremos: nós indexados a partir de 1, exatamente uma aresta redundante e a garantia de que removê-la deixa uma árvore válida. Ela usa o DSU ideal, com redução pela metade dos caminhos e union por classificação.
Depois de enviar sua solução, tente a pergunta complementar: e se o grafo pudesse ter várias arestas redundantes? Seria necessário acompanhar todas as arestas que completam um ciclo e retornar a última na entrada — a mesma estratégia gulosa ainda funcionaria, porque DSU processa as arestas na ordem.
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1))
rank = [0] * (n + 1)
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False
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]
return [] # should never reach here given valid input
test_cases = [
[[1,2],[1,3],[2,3]],
[[1,2],[2,3],[3,4],[1,4],[1,5]],
[[1,2],[1,3],[2,3],[2,4],[3,5]],
]
for tc in test_cases:
print(find_redundant_connection(tc))Verificação rápida
Teste sua compreensão dos conceitos de Estruturas de Dados & Algoritmos — Preparação para Entrevistas de Programação desta lição.
Recapitulação da lição
Nesta lição, você aprendeu que: uma conexão redundante é uma aresta que conecta dois nós já conectados em um grafo não direcionado, DSU detecta isso verificando find(u) == find(v) antes de union e retornando essa aresta e grafos direcionados exigem DFS de três cores ou o algoritmo de Kahn, em vez de DSU, para a detecção de ciclos. A seguir, aplicaremos DSU ao problema de mesclagem de contas, no qual os e-mails são os nós e e-mails compartilhados entre contas acionam operações union.
Perguntas Frequentes
A aula “Conexão redundante e detecção de ciclos” é grátis?
Sim — o texto completo de “Conexão redundante e detecção de ciclos” é grátis para ler aqui na web. Para praticá-la interativamente (um editor de código integrado e um tutor de IA 24/7) e desbloquear o restante do curso de DSA Interview Prep, atualize para CoddyKit PRO. O curso de DSA Interview Prep inclui 4 aulas no total.
O que vou aprender em “Conexão redundante e detecção de ciclos”?
Detecte a aresta que cria um ciclo em um grafo não direcionado aplicando união a cada aresta e verificando se dois nós já estão conectados. Você pratica DSA Interview Prep com código prático que executa diretamente no navegador, e um tutor de IA 24/7 responde suas dúvidas enquanto trabalha na aula.
Preciso ter experiência prévia para começar DSA Interview Prep?
Nenhuma experiência prévia é necessária. DSA Interview Prep no CoddyKit é estruturado para alunos iniciantes até avançados, então você pode começar aqui ou desde o início e aprender no seu ritmo. Esta é a aula 3 de 4.
Quanto tempo leva a aula “Conexão redundante e detecção de ciclos”?
A maioria das aulas CoddyKit leva cerca de 5–10 minutos. Cada uma é compacta e interativa, então você faz progresso constante e retoma exatamente de onde parou entre web e app.
Posso escrever e executar código nesta aula de DSA Interview Prep?
Sim. Cada aula de DSA Interview Prep inclui um editor de código integrado, então você escreve e executa código real direto no navegador e recebe feedback de IA instantaneamente — nenhuma configuração local necessária.
Todas as aulas deste curso
- DSU com compressão de caminhos
- União por classificação e limite do inverso de Ackermann
- Conexão redundante e detecção de ciclos
- Fusão de contas e componentes conexos