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

时间:2014-10-07 22:15:08

标签: ruby algorithm cycle digraphs

我在Ruby 1.9.3中有一个构建RubyTree的程序。我的数据最好被描述为Directed Acyclic Graph(DAG);请注意,是一个多树。好吧,至少数据 应该是DAG,尽管用户'最好的努力,用糟糕的数据来阻止我的程序。

我通过解析XML文档动态构建DAG。 XML文档没有明确指定树结构,但确实提供了整数ID的交叉引用,这些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,因为在read-compare-add循环期间我可能需要遍历图形或部分图形,并且任何遍历尝试都可能会挂起程序或崩溃如果实际上有一个循环。 (不言而喻,我的代码是单线程的;如果它不是,我们就会遇到一个大问题。就目前而言,我依赖add()永不返回的事实如果有一个循环,例外,即使 一个循环,节点也会被删除,以便在add()返回之前清除循环。)

但它太慢了!它只花了半个多小时才进行这一循环,我的程序包含了其他一些自己公平分享的步骤时间但就目前情况而言,从ruby-perf的结果来看,仅仅表演塔里扬正在吃掉大部分表演。我尝试在RubyTree each实现中从数组切换到链表,但它只通过删除Array#concat次调用来减少运行时间约1%。

我发现 Tarjan发明了this令人敬畏的论文,他发明了Ruby TSort依赖的强连接组件算法,似乎增量循环检测是一个活跃的研究领域。然而,论文中的对话水平远远高于我的头脑,我很确定我缺乏将论文的发现转化为Ruby代码的数学背景。不仅如此,而且通过阅读论文的备注部分,似乎他们的最佳工作算法具有相当令人担忧的最坏情况运行时间,因此它甚至可能不比我当前的方法更快,这取决于我的具体情况数据

我在这里错过了一些愚蠢的东西,或者我最好还是花时间精心分析Tarjan的论文并尝试提出其中一种算法的Ruby实现?请注意,我并不特别关心算法的拓扑排序方面;这是我真正想要的副作用。如果树没有进行拓扑排序,但仍然保证没有循环,我会非常高兴。

另外值得注意的是,源数据中的周期有点罕见。也就是说,周期可能由于数据输入过程中的手动错误而发生,但它们永远不会故意发生,应该始终报告给程序,以便它可以告诉我,所以我可以击败某人有一个比利俱乐部的头,用于输入错误的数据。此外,即使它检测到一个特别恶劣的循环,程序也绝对必须继续进行,所以我不能只是把头埋在沙子里,希望不会有任何循环


实际问题是什么?

根据某些人的要求,您可以通过此演示来查看工作中的问题。

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

图表1:在#34;第三次"之后,主线程上100%CPU使用率永远挂起打印

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:一直走完并打印"完成",结果没有周期

请注意,我通常会在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 个答案:

答案 0 :(得分:3)

Ruby的Plexus gem似乎解决了我最糟糕的问题。我之前尝试过GRATR,但它不会加载,因为它与Ruby 1.9.3不兼容,但Plexus是GRATR的一个分支,与1.9.3一起工作。

我的问题是我使用的数据结构(RubyTree)并未设计用于处理周期,但Plexus Digraph实际上可以继续使用周期。 API的设计考虑到了它们。

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

不幸的是,现在我遇到了开发minimum feedback arc set problem(最低FAS问题)的实施的挑战,这是NP难的。最小的FAS问题是必需的,因为我需要删除图中最少侵入的弧数,使其成为非循环。

我现在的计划是从Plexus获取强连接组件列表,Plexus是一个数组数组;如果任何二级数组包含多个元素,则该数组根据强连接组件的定义描述具有循环的元素。然后我必须(使用最小FAS或近似值)去除边和/或顶点以使图形非循环,并迭代运行Tarjan,直到每个SCC子阵列的长度为1。

我认为蛮力可能是解决最低FAS的最佳方法:我不需要太聪明,因为我的数据集中任何SCC中的节点数量几乎都不会超过,比如说,5或6指数在5或6是好的。我非常怀疑我将拥有一个由数百个不同周期组成的数百个节点的SCC集合;这将是一个极端病态的最坏情况,我认为永远不会发生。但如果确实如此,那么运行时间会很长。

基本上我需要尝试去除图形弧的幂集,一次一个子集,子集集按子集大小递增排序,并且"猜测并检查"图表是否仍然是循环的(Tarjan' s),如果该功率集没有修复周期,则添加边缘。

如果边缘和节点的数量小于20,这几乎可以保证,它将不会花费大量的运行时间。

删除迭代Tarjan确实解决了我在快乐路径中的复杂性问题(没有循环或只是一个简单的循环),这实际上是它给了我最大的胃灼热 - 而不是花费25分钟来构建图表需要15秒。

获得的经验:如果你的课程很慢,可能是因为你做了很多不必要的工作。在我的例子中,不必要的工作是在每个向图表添加新顶点时执行Tarjan的拓扑排序,这只是因为我最初选择建模的库的实现细节而需要我的数据。