有向无环图中更快的循环检测?

4
我有一个Ruby 1.9.3程序,构建了一个RubyTree。我的数据最好描述为一个有向无环图(DAG);请注意,它不是一棵多叉树。嗯,至少数据应该是一个DAG,尽管用户尝试使用错误的数据破坏我的程序。
我通过解析XML文档动态构建DAG。XML文档没有明确指定树结构,但提供整数ID的交叉引用,建立文档中元素之间的链接。
我需要确保RubyTree不包含任何循环。源数据可能(错误地)包含一个循环,如果有,我的程序需要意识到它,而不是进入无限循环或崩溃。目前为止,我将Ruby标准库的TSort模块混合到了RubyTree的Tree::TreeNode类中。这使用Tarjan算法在每次添加节点时对图进行拓扑排序。在拓扑排序期间,如果检测到循环,则会引发异常--正是我想要的。
例如:
module Tree
  class TreeNode
    include TSort

    def tsort_each_node(&block)
      self.each(&block)
    end

    def tsort_each_child(node, &block)
      node.get_children().each { |child| yield child }
    end

    def add(child, at_index = -1)
      #The standard RubyTree implementation of add goes here
      begin
        self.tsort()
      rescue TSort::Cyclic => exce
        self.remove!(child)
        raise exce
      end
      return child
    end
  end
end

我不得不修改其他一些方法。基本上,任何需要遍历树或子节点的方法都需要实现TSort,或者消除其对遍历的依赖(例如,我简化了Tree :: TreeNode#to_s()以返回Tree :: TreeNode#name)。
现在,我的程序在功能上是正确的。我进行了大量测试,结果正常:我只需要在代码中的适当位置拯救TSort :: Cyclic,如果我尝试添加导致循环的节点,则该节点将被删除,并且我可以记录问题以后处理(通过修复源数据)。
问题在于,在一个大小为75000的RubyTree上,边的数量非常接近于顶点数减1的情况下,迭代运行Tarjan算法会产生看起来相当二次的算法复杂度。Tarjan本身是O(|V| + |E|),在我的情况下大约是O(2 * |V|),但每次调用add()时,|V|增加1,因为我逐个建立节点构建图形。我不能简单地在最后调用Tarjan,因为我可能需要在读取-比较-添加循环期间遍历图形或其部分,并且任何遍历尝试都可能使程序挂起或崩溃,如果实际上有一个循环。(不用说,我的代码是单线程的;如果不是,我们将面临巨大的问题。就目前而言,我依赖于事实,即如果存在循环,则add()永远不会返回而会引发异常,即使存在循环,节点也会以清除循环的方式被删除,然后add()才会返回。) 但是它太慢了!仅对于这个循环,它花费了半个多小时,而我的程序包括几个其他步骤,它们也需要相应的时间。但就目前而言,仅执行Tarjan算法就占据了大部分性能,根据ruby-perf的结果判断。我尝试在RubyTree each实现中从数组切换到链表,但只通过删除许多Array#concat调用将运行时缩短了约1%。
我发现了this一篇由Tarjan发明强连通分量算法的论文,Ruby的TSort就依赖于此。而且,增量循环检测似乎是一个活跃的研究领域。但是,这篇论文的对话水平远超过我的理解能力,我相信我缺乏将论文中的发现转化为Ruby代码的数学背景。不仅如此,在阅读论文的“备注”部分时,他们最好的算法自身的最坏运行时间也令人担忧,因此根据我的数据具体情况,它甚至可能不比我当前的方法更快。
我是否遗漏了一些愚蠢的东西,或者我的最佳选择是付出精神努力来分析Tarjan的论文,并尝试想出其中一种算法的Ruby实现?请注意,我并不特别关心算法的拓扑排序方面;这是我真正想要的副产品。如果树没有进行拓扑排序,但仍保证没有循环,那么我会非常满意。
值得注意的是,我的源数据中循环出现的频率有点低。也就是说,由于数据输入过程中的手动错误,循环可能会发生,但它们永远不会是故意的,并且应该始终向程序报告,以便我可以用警棍狠狠地打那个输入错误数据的人。此外,即使检测到特别恶劣的循环,程序绝对不能停止运行,因此我不能只是把头埋在沙子里,希望没有任何循环发生。

实际问题是什么?

根据一些要求,这里有一个演示程序,您可以运行以查看问题的工作方式。

安装RubyTree的稳定版本(我使用MRI 1.9.3)。然后比较这两个程序的输出:

展示1:在打印“第三次”后永远挂起,主线程的CPU使用率达到100%

require 'tree'

a = Tree::TreeNode.new('a', nil)
b = Tree::TreeNode.new('b', nil)
c = Tree::TreeNode.new('c', nil)
a.add(b)
a.add(c)
puts "First time"
b.add(c)
puts "Second time"
b.add(a)
puts "Third time"
c.add(b)
puts "Fourth time"
c.add(a)
puts "Fifth time"
puts "Done"

展示2:一直执行并打印“Done”,结果没有循环

请注意,我通常会在rescue块中执行操作以记录发生的循环,并对创建这些循环的人类肆意抱怨。

require 'tree'
require 'tsort'

module Tree
  class TreeNode
    include TSort

    def tsort_each_node(&block)
      self.each(&block)
    end

    def tsort_each_child(node, &block)
      node.get_children().each { |child| yield child}
    end

    def to_s
      name
    end

    def get_children()
      return @children
    end

    def add(child, at_index = -1)
      unless child
        raise ArgumentError, "Attempting to add a nil node"  # Only handles the immediate child scenario
      end
      if self.equal?(child)
        raise TSort::Cyclic, "Cycle detected: [#{child.name}, #{child.name}]"
      end 

      # Lazy man's unique test, won't test if children of child are unique in this tree too.
      if @children_hash.include?(child.name)
        raise "Child #{child.name} already added!"
      end

      if insertion_range.include?(at_index)
        @children.insert(at_index, child)
      else
        raise "Attempting to insert a child at a non-existent location (#{at_index}) when only positions from #{insertion_range.min} to #{insertion_range.max} exist."
      end

      @children_hash[child.name] = child
      child.parent = self

      #CYCLE DETECTION - raises TSort::Cyclic if this caused a cycle
      begin
        self.tsort()
      rescue TSort::Cyclic => exce
        self.remove!(child)
        raise exce
      end
      return child
    end
  end
end

a = Tree::TreeNode.new('a', nil)
b = Tree::TreeNode.new('b', nil)
c = Tree::TreeNode.new('c', nil)
a.add(b)
a.add(c)
puts "First time"
b.add(c)
puts "Second time"
begin
  b.add(a)
rescue
end
puts "Third time"
begin
  c.add(b)
rescue
end
puts "Fourth time"
begin
  c.add(a)
rescue
end
puts "Fifth time"
puts "Done"

对我来说,目标是开发与展示2功能等效的代码,但可以更好地扩展到更大数量的顶点(我不预计会有超过10^6个顶点,在这种情况下,我可以接受在现代台式工作站上需要几分钟(“去喝杯咖啡”),但不能是几个小时或更长时间。)


  1. 你是否可以控制整数ID(例如通过指定格式),还是它们是任意分配的?
  2. 除了使用Tarjan的SCC/toposort算法之外,你尝试过其他的循环检测方法吗?你可能想尝试一下乌龟和兔子算法中的其中一种...
- user824425
1
看起来我真正需要的是Plexus - 一个分支的分支的端口 :-) 实际上,Plexus的分支历史本身就构成了一个有趣的图形:Plexus -> Graphy(多个分支)-> GRATR(多个分支)->(C++的精神端口)Boost Graphs Library。维护这些库的人们总是喜欢消失/停止维护它们,所以当需要它们的项目出现时,会有大量的分支被临时维护 :/ - allquixotic
@allquixotic,你能把它转化为一个答案吗?这样有相同问题的人就可以跟随你的脚步了 :) - user824425
在一个微小的附注中,如果你需要确实检查相同的对象,而不仅仅是具有相同名称的对象,则使用child.__id__(Fixnum)作为@children_hash的键,而不是child.name(String),可能会带来一些性能改进。抱歉,虽然没有尝试过实际测试。如果某种方式hash方法对于Fixnums比对于Strings更快,那可能会有所帮助。如果不是,比较相同的对象(字符串或Fixnums - 都无所谓)是很快的。如果我记得正确的话,那就是在找到潜在的相同键之后,Hash代码检查的第一件事情。 - moonfly
实际上,我刚刚进行了一个非常简单的实验,比较了hash = {}; hash[a] = true; t1 = Time.now; 10000.times { hash.include?(a) }; t2 = Time.now; t2-t1hash = {}; hash[a.__id__] = true; t1 = Time.now; 10000.times { hash.include?(a.__id__) }; t2 = Time.now; t2-t1,其中a =“a”。没有区别:(。如果您比较第一种方法在a ="a"a=1的性能,则有2x-1.5x的改进,但调用__id__会消耗所有差异。因此,请忽略我的先前评论。 - moonfly
显示剩余5条评论
1个回答

4

Plexus Ruby宝石似乎解决了我的大部分问题。我之前尝试使用GRATR,但它无法加载,因为它与Ruby 1.9.3不兼容,但是Plexus是GRATR的一个分支,可以与1.9.3一起使用。

我的问题是我正在使用一个不适合处理循环的数据结构(RubyTree),但是Plexus Digraph实际上可以继续处理循环。API是以它们为重点设计的。

我采用的解决方案非常简单:基本上,现在我的图形数据结构不会挂在循环上,我只需在图形构建例程结束时调用Tarjan算法--实际上,有一个很好的包装器acyclic?方法,但它只是在底层调用topsort(),并使用Tarjan的强连通分量算法实现拓扑排序,就像Ruby的stdlib的TSort一样。它确实使用自己的实现而不是TSort。我不确定为什么。

很不幸,我现在遇到了一个挑战,需要开发实现最小反馈弧集问题(minimum FAS问题),这是一个NP难问题。最小FAS问题是必需的,因为我需要删除图中最少数量的弧,使其成为无环图。

我的计划是从Plexus获取强连通分量列表,它是一个数组的数组;如果任何第二层的数组包含多个元素,则该数组描述了具有循环的元素,基于强连通分量的定义。然后,我必须(使用最小FAS或近似解)删除边和/或顶点,使图成为无环图,并迭代运行Tarjan,直到每个SCC子数组的长度为1为止。

我认为暴力破解可能是解决最小FAS问题的最佳方法:我不需要过于聪明,因为数据集中任何SCC中的节点数几乎永远不会超过5或6。指数级别的5或6是可以接受的。我非常怀疑我会有一个包含数百个节点和数十个不同循环的SCC集;那将是一个极端的病态最坏情况,我认为这种情况永远不会发生。如果真的发生了,运行时间会相当长。

基本上,我需要尝试删除图的弧的幂集,一次删除一个子集,以子集大小升序排序,并“猜测并检查”图是否仍然是循环的(Tarjan算法),然后添加边缘回来,如果该幂集不能修复循环。

如果边和节点的数量在20左右,这几乎是可以保证的,它不会花费太多运行时间。

删除迭代的Tarjan算法确实解决了我在快乐路径中(没有循环或只有1个微不足道的循环)的复杂性问题,这真的让我感到最头疼 - 而不是花费25分钟来构建图形,它只需要15秒钟。

教训是:如果你的程序运行缓慢,很可能是因为你做了很多不必要的工作。在我的情况下,不必要的工作是对图中每个新顶点执行Tarjan拓扑排序,这只是由于我最初选择模拟数据的库的实现细节所要求的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接