Избыточное ребро и обнаружение циклов
Обнаружьте ребро, образующее цикл в неориентированном графе: выполняйте объединение для каждого ребра и проверяйте, соединены ли уже его вершины
«Избыточное ребро и обнаружение циклов» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 3 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.
Что такое избыточное ребро
В задаче «Избыточное соединение» (LeetCode 684) дано дерево из n вершин и одно дополнительное ребро, образующее ровно один цикл. Ваша задача — найти ребро, удаление которого восстановит дерево. Если возможны несколько ответов, верните последнее ребро во входном списке.
Дерево с n вершинами имеет ровно n-1 рёбер, является связным и не содержит циклов. Добавление ещё одного ребра создаёт ровно один цикл. Добавленное (избыточное) ребро соединяет две вершины, которые уже находились в одной компоненте, — это классический сценарий обнаружения циклов с помощью 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')Обнаружение циклов с помощью DSU
DSU естественным образом обнаруживает циклы: перед добавлением ребра (u, v) проверьте, выполняется ли find(u) == find(v). Если у них общий корень, они уже соединены — добавление этого ребра создаёт цикл. Это и есть избыточное ребро.
Этот подход работает для неориентированных графов. Для каждого ребра мы либо успешно выполняем union двух компонент (цикла пока нет), либо обнаруживаем, что обе его конечные вершины уже находятся в одной компоненте (цикл найден). Временная сложность составляет O(n × alpha(n)), то есть практически 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]Пошаговый разбор алгоритма
Рассмотрим [[1,2],[1,3],[2,3]] пошагово. Изначально каждая вершина является отдельной компонентой: {1}, {2}, {3}.
- Ребро [1,2]: find(1)=1, find(2)=2, вершины различны — выполняем union. Компоненты: {1,2}, {3}
- Ребро [1,3]: find(1)=root, find(3)=3, вершины различны — выполняем union. Компоненты: {1,2,3}
- Ребро [2,3]: find(2)=root, find(3)=root — один и тот же корень! Цикл обнаружен. Верните [2,3].
Алгоритм обрабатывает рёбра по порядку и возвращает первое ребро, замыкающее цикл. Поскольку по условию есть только одно дополнительное ребро, это всегда правильное избыточное ребро.
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)Обнаружение циклов в неориентированных графах с помощью DFS
Альтернативой DSU для обнаружения циклов в неориентированных графах является DFS с отслеживанием родителя. Во время DFS, если мы достигаем уже посещённой вершины, которая не является непосредственным родителем текущей вершины, значит, найдено обратное ребро — признак цикла.
Однако подход с DFS требует времени O(V + E) и сообщает о наличии цикла, но не позволяет легко определить, какое именно ребро является избыточным. DSU предпочтительнее в задачах, где требуется найти конкретное избыточное ребро, поскольку оно естественным образом обнаруживается при неудачном выполнении union.
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]])) # FalseОбнаружение циклов в ориентированных графах
Для ориентированных графов обнаружение циклов с помощью DSU напрямую не работает, поскольку рёбра имеют направление. Вместо этого используйте трёхцветную маркировку DFS: белый цвет означает, что вершина не посещена, серый — что она находится в текущем пути DFS, чёрный — что она полностью обработана. Обратное ребро к серой вершине указывает на цикл.
В неориентированном графе любое обратное ребро означает цикл. В ориентированном графе поперечное ребро к чёрной вершине не образует цикл — только обратные рёбра к серым вершинам указывают на него. Это различие имеет решающее значение и проверяется в задачах о расписании курсов.
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]])) # FalseИзбыточное соединение II: вариант для ориентированных графов
LeetCode 685 расширяет задачу до ориентированных графов, в которых у каждой вершины ровно один родитель (образуется корневое дерево с одним дополнительным ребром). Возможны два случая: у вершины два родителя (входящая степень равна 2) или существует цикл, но ни у одной вершины нет двух родителей.
Сначала решение ищет вершины с входящей степенью 2. Если такая вершина найдена, ответом должно быть одно из двух входящих в неё рёбер. Затем обнаружение циклов с помощью DSU определяет, какое из двух возможных рёбер нужно удалить. Такой двухэтапный подход корректно обрабатывает все случаи.
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]Проверка корректности графа после удаления ребра
После нахождения избыточного ребра можно проверить результат, убедившись, что после его удаления остаётся корректное дерево: ровно n-1 рёбер, все вершины связны и циклов нет. В рамках этой задачи DSU естественным образом гарантирует это: если вернуть ребро, для которого операция union завершилась неудачей, после его удаления останутся ровно n-1 рёбер, успешно объединённых в остовное дерево.
Именно поэтому DSU так хорошо подходит для этой задачи: успешные операции union постепенно строят дерево, а неудачная операция union определяет единственное ребро, которое в него не входит.
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 removalАнализ временной и пространственной сложности
Решение задачи об избыточном ребре на основе DSU обрабатывает каждое из n рёбер ровно один раз, а амортизированная стоимость каждой операции union/find составляет O(alpha(n)). Общая временная сложность: O(n × alpha(n)), то есть фактически O(n).
Пространственная сложность составляет O(n) для массивов родителей и рангов. Это оптимальный результат: как минимум необходимо прочитать все n рёбер и хранить некоторое состояние для каждой вершины. Для сравнения, наивный подход, запускающий DFS после добавления каждого ребра, требует O(n²) времени и 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).')Граничный случай: петля
Петля [u, u] немедленно создаёт цикл, поскольку обе конечные вершины являются одной и той же вершиной. В DSU find(u) == find(u) всегда истинно, поэтому операция union сразу завершается неудачей, а [u, u] возвращается как избыточное ребро.
В большинстве условий задачи петли запрещены, но надёжный код должен обрабатывать и их. Реализация DSU естественным образом справляется с этим без специального случая: проверка цикла if find(u) == find(v) обнаруживает его до попытки выполнить union. Всегда проверяйте решение на граничных входных данных, например на петлях в графе из одной вершины и на входных данных минимального размера.
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]Обобщение обнаружения циклов для разных алгоритмов
Циклы могут обнаруживать разные алгоритмы, каждый из которых подходит для своего сценария:
- DSU: неориентированные графы, поступление рёбер онлайн, O(alpha(n)) на ребро — лучший выбор для подсчёта циклов или поиска избыточного ребра
- DFS с отслеживанием родителя: неориентированные графы, все рёбра известны заранее, O(V+E) — лучший выбор, если нужен путь цикла
- Трёхцветный DFS: ориентированные графы, обнаружение обратных рёбер, O(V+E) — лучший выбор для задач о расписании курсов и топологической сортировки
- Топологическая сортировка (алгоритм Кана): ориентированные графы, обнаружение цикла по оставшимся вершинам с ненулевой входящей степенью — лучший выбор, если также нужен порядок вершин
# 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')Полное решение с учётом граничных случаев
Ниже приведено решение задачи «Избыточное соединение», готовое для промышленного использования и обрабатывающее все граничные случаи: вершины с индексацией с 1, ровно одно избыточное ребро и гарантию, что после его удаления останется корректное дерево. В нём используется оптимальный DSU с сокращением пути вдвое и union по рангу.
После отправки решения рассмотрите дополнительный вопрос: что произойдёт, если в графе может быть несколько избыточных рёбер? Тогда нужно отслеживать все рёбра, замыкающие цикл, и вернуть последнее из них во входных данных — та же жадная стратегия по-прежнему работает, поскольку DSU обрабатывает рёбра по порядку.
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))Быстрая проверка
Проверьте понимание концепций из курса «Структуры данных и алгоритмы — подготовка к собеседованиям по программированию», рассмотренных в этом уроке.
Итоги урока
В этом уроке вы узнали, что избыточное соединение — это ребро, соединяющее две уже связанные вершины в неориентированном графе, DSU обнаруживает его, проверяя find(u) == find(v) перед выполнением union и возвращая это ребро, а для обнаружения циклов в ориентированных графах вместо DSU требуется трёхцветный DFS или алгоритм Кана. Далее мы применим DSU к задаче об объединении учетных записей, где адреса электронной почты являются вершинами, а общие адреса между учетными записями запускают операции union.
Часто задаваемые вопросы
Урок «Избыточное ребро и обнаружение циклов» бесплатный?
Да — полный текст урока «Избыточное ребро и обнаружение циклов» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Избыточное ребро и обнаружение циклов»?
Обнаружьте ребро, образующее цикл в неориентированном графе: выполняйте объединение для каждого ребра и проверяйте, соединены ли уже его вершины Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать Coding Interview Prep?
Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 3 из 4.
Сколько времени занимает урок «Избыточное ребро и обнаружение циклов»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке Coding Interview Prep?
Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- DSU со сжатием путей
- Объединение по рангу и оценка обратной функции Аккермана
- Избыточное ребро и обнаружение циклов
- Объединение аккаунтов и компоненты связности