Как я могу удалить циклы из моего ориентированного графа? Это большой граф (более 100 тысяч узлов и более 200 тысяч ребер), поэтому метод должен быть эффективным. Мне нужно сделать орграф ацикличным, чтобы использовать такие функции, как networkx.topological_generations.
Я пробовал методы, в которых я неоднократно генерирую циклы и удаляю последнее ребро на каждом циклическом пути, но после более чем 10 часов работы без завершения я посчитал это неудачной попыткой.
неудачная попытка (никогда не законченная; неэффективная)
def remove_cycles_from_G(G: nx.DiGraph):
search_for_cycles = True
while search_for_cycles:
for cycle_path in nx.simple_cycles(G):
try:
G.remove_edge(cycle_path[-1], cycle_path[0])
except nx.NetworkXError:
# edge has already been disjointed by a previous edge removal.
# Restart cycle generator.
search_for_cycles = (
False # Temporary condition which will be reversed.
)
break
search_for_cycles = not (search_for_cycles)
Я также разработал более сложный эвристический подход, основанный на демонстрациях в этом проекте, но даже этот метод не работает с орграфом такого размера (после часа работы моя память была исчерпана).
Я понимаю, что определение наименьшего количества ребер, которые нужно удалить, чтобы сделать орграф ациклическим, является NP-сложной проблемой (проблема набора дуг обратной связи), но я не обязательно пытаюсь найти наименьшее количество ребер, чтобы сделать орграф ациклическим, я просто хочу быстрого и эффективного подхода.
Вот пример networkx DiGraph с множеством циклов. Моя ситуация включает в себя еще больше, но это демонстрирует суть:
import networkx as nx
import random
def induce_cycles(g: nx.DiGraph, cycles) -> None:
cycles_added = 0
while cycles_added < cycles:
node = random.choice(list(g))
non_parent_ancestors = nx.ancestors(g, node).difference(g.predecessors(node))
if non_parent_ancestors:
g.add_edge(node, random.choice(list(non_parent_ancestors)))
cycles_added += 1
g = nx.balanced_tree(3, 6, create_using=nx.DiGraph())
induce_cycles(g, len(g.edges()) * 5)
# Efficiently remove cycles from g...
(Дискуссия, которая привела нас сюда, была ожесточенной. Я оставляю только хорошие моменты для всех, кому этот ответ понадобится в будущем.)
Здесь другой подход. Я пытаюсь имитировать топологическую сортировку, ЗА ИСКЛЮЧЕНИЕМ того, что я использую подход, заключающийся в том, чтобы всегда находить следующий лучший вариант.
from sortedcontainers import SortedSet
def topological_remove_cycles (g):
# Dictionary of sets of incoming edges. We want to pick nodes with few of them.
incoming = {}
for node in g.nodes:
incoming[node] = set()
for node in g.nodes():
for next_node in g.neighbors(node):
incoming[next_node].add(node)
# Sorted set of (count_incoming, -count_outgoing, node) triplets.
# The first item in the set will have as few incoming nodes as it can, and as many outgoing.
# In other words, it is a greedy choice for a good node to get rid of cycles.
todo = SortedSet()
for node, to_node in incoming.items():
todo.add((len(to_node), -len(g.adj[node]), node))
# And now let's start removing cycles.
while 0 < len(todo):
# Get the best node.
_, _, node = todo.pop(0)
to_node = incoming[node]
for prev_node in to_node:
# Each of these edges is in, or comes from, a cycle.
if prev_node != node:
# Do bookkeeping in todo.
len_in = len(incoming[prev_node])
len_out = len(g.adj[prev_node])
todo.remove((len_in, -len_out, prev_node))
todo.add((len_in, -len_out + 1, prev_node))
g.remove_edge(prev_node, node)
for next_node in g.neighbors(node):
# Do bookkeeping in todo for forgetting about node.
len_in = len(incoming[next_node])
len_out = len(g.adj[next_node])
todo.remove((len_in, -len_out, next_node))
todo.add((len_in - 1, -len_out, next_node))
incoming[next_node].remove(node)
# And now we've guaranteed that node is cycle free and gone from our bookkeeping.
И, конечно же, давайте проведем небольшой тест, в котором предыдущий код дал сбой.
data = {0: [1, 2, 11], 1: [3, 4, 10], 2: [5, 6], 3: [7, 8, 6, 9], 4: [9, 10], 5: [11, 12, 3], 6: [13, 14, 10, 5, 7], 7: [1], 8: [7, 14], 9: [10], 10: [14], 11: [10], 12: [], 13: [], 14: [1, 2]}
g = nx.from_dict_of_lists(data, create_using=nx.DiGraph())
print(nx.to_dict_of_lists(g))
print(g)
print("Before removing, there are " + str(len(list(nx.simple_cycles(g)))) + " cycles")
topological_remove_cycles(g)
print(g)
print("After removing, there are " + str(len(list(nx.simple_cycles(g)))) + " cycles")
print(nx.to_dict_of_lists(g))
И более масштабный тест. Обратите внимание, что он настолько хорош в распутывании вещей, что обычно находит способ удалить меньше ребер, чем было добавлено при создании циклов!
g = nx.balanced_tree(3, 6, create_using=nx.DiGraph())
induce_cycles(g, 100)
print(g)
topological_remove_cycles(g)
print(g)
print("After removing, there are " + str(len(list(nx.simple_cycles(g)))) + " cycles")
@russhoppa Теперь есть исправленная версия, которая должна работать намного лучше. Он проходит O(n log(n))
. Но за это вы получаете жадную попытку найти небольшой минимальный набор дуг обратной связи.
Очень впечатляюще. Спасибо, что собрали это вместе. Использование этого типа SortedSet великолепно. Я не знал об этой библиотеке.
@russhoppa Да, это здорово. Мне нужно перестать использовать более медленные способы сделать это. Кстати, я избавился от некоторых прошлых неприятностей из поста. Просто оставляю полезный ответ на будущее.
Спасибо, выглядит великолепно. И я прошу прощения за это. У меня есть один вопрос о вашем методе, который я не могу понять. Где вы пишете комментарий: # Each of these edges is in, or comes from, a cycle.
как вы можете предположить, что узлы, отсортированные с наименьшим количеством предшественников и наибольшим количеством последователей, находятся в цикле? Тот факт, что у узла мало предшественников и много преемников, еще не означает, что он является частью цикла, не так ли? Или это просто наивное предположение об алгоритме, которое в большинстве случаев оказывается верным?
@russhoppa Не наивный. :) Если есть какой-либо узел, который не поддерживается циклом, то есть узел без входящих ребер. Это будет выбрано в первую очередь. Таким образом, мы можем получать входящие ребра на нашем узле только тогда, когда все поддерживается циклами.
После того, как я это написал, я задумался о работе с мин входящих и исходящих. Это будет лучше, чем это, но за счет некоторой сложности. Но я уже это написал и не знаю, какие улучшения это принесет.
Я понимаю. Это эффективный вид.
Спасибо за демонстрацию этого метода. Но
fast_remove_cycles
работает не во всех случаях. Попробуйте случай сinduce_cycles(g, 100)
(или даже большее количество циклов, если ваш компьютер справится с этим), и вы увидите, что после его запуска еще остались циклы. Я пытался просмотреть ваш код, чтобы выяснить причину, но не могу определить, в чем может быть проблема.