关于ruby:有向非循环图中更快的周期检测?

Faster cycle detection in a Directed Acyclic Graph?

我有一个Ruby1.9.3中的程序,它构造了一个RubyTree。我的数据最好描述为一个有向无环图(DAG);请注意,它不是一个多树。好吧,至少数据应该是一个DAG,尽管用户尽最大努力用坏数据屏蔽我的程序。

我通过解析XML文档动态构建DAG。XML文档没有显式地指定树结构,但提供了整数ID的交叉引用,这些ID在文档中的元素之间建立链接。

我需要确保rubytree不包含任何循环。源数据可能(错误地)有一个循环,如果有,我的程序需要知道它,而不是进入无限循环或崩溃。为了实现这一点,我将Ruby标准库的tsort模块混合到RubyTree的Tree::TreeNode类中。这使用Tarjan的算法在每次添加节点时对图执行拓扑排序。在拓扑排序过程中,如果检测到循环,则会引发异常——这正是我想要的。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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()返回之前清除循环。)

但是太慢了!仅此一个循环就需要半个多小时,而我的程序由其他几个步骤组成,这些步骤会占用他们自己的合理时间。但从江户记1(10)的结果来看,仅仅表演塔扬的表演就占了表演的大部分。我尝试在rubytree each实现中从数组切换到链表,但它只通过删除Array#concat调用的负载来减少大约1%的运行时。

我发现这篇由Tarjan撰写的很棒的论文,他发明了Ruby的TSort所依赖的强连接组件算法,而且增量循环检测似乎是一个活跃的研究领域。然而,论文中的对话水平明显超出了我的想象,我确信我缺乏将论文的发现翻译成Ruby代码的数学背景。不仅如此,而且通过阅读本文的评论部分,他们最努力的算法似乎有自己令人担忧的最坏情况运行时间,因此根据我的数据的具体情况,它甚至可能不会比我当前的方法更快。

我是在这里错过了一些愚蠢的东西,还是我最好把它放在脑力工作中分析Tarjan的论文,并尝试想出一个Ruby实现的算法?注意,我并不特别关心算法的拓扑排序方面;这是我真正想要的副作用。如果这棵树没有拓扑排序,但仍然保证没有循环,我会非常高兴的。

同样值得注意的是,在我的源数据中,周期是很少的。也就是说,循环可能由于数据输入过程中的手动错误而发生,但它们不会有意发生,并且应该始终向程序报告,以便它能够告诉我,这样我就可以用一个billcyclob来击败某人,因为他输入了错误的数据。另外,即使程序检测到一个特别讨厌的循环,它也必须继续运行下去,所以我不能把我的头埋在沙子里,希望不会有任何循环。

实际问题是什么?

根据一些人的要求,这里有一个演示,您可以在工作中查看这个问题。

安装稳定版本的Rubytree(我使用MRI 1.9.3)。然后比较输出


红宝石的树脂宝石似乎解决了我最严重的问题。我以前试过Grater,但是它不能加载,因为它与Ruby1.9.3不兼容,但是Plexus是Grater的一个分支,与1.9.3一起工作。

我的问题是,我使用的数据结构(rubytree)不是为处理循环而设计的,但是Plexus有向图实际上可以继续处理循环。API的设计考虑到了它们。

我使用的解决方案非常简单:基本上,既然我的图形数据结构不依赖于循环,我可以在图形生成例程的末尾调用Tarjan的算法——实际上,有一个很好的包装器acyclic?方法,但它只是在引擎盖下调用topsort(),并使用tarjan的stron实现拓扑排序gly连接的组件算法,很像Ruby的stdlib的TSort。不过,它使用自己的实现而不是TSort。我不确定为什么。

不幸的是,现在我面临的挑战是开发一个最小反馈弧集问题(最小Fas问题)的实现,这是NP困难的。最小的fas问题是必需的,因为我需要删除图中最少的插入弧数,使其成为非循环的。

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

我认为蛮力可能是解决最小fas的最佳方法:我不需要太聪明,因为我的数据集中任何scc中的节点数几乎都不会超过5或6。指数在5或6上是可以的。我严重怀疑我是否会有一个由数百个节点组成的SCC集,其中包含数十个不同的循环;这将是一个极其病态的最坏情况,我认为不会发生。不过,如果这样做了,运行时间将相当长。

基本上,我需要尝试删除图表弧的幂集,一次删除一个子集,子集按子集大小升序排序,然后"猜测并检查"图表是否仍然是循环的(Tarjan's),然后如果该幂集不固定循环,则将边添加回去。

如果边缘和节点的数量少于20个左右,这几乎是可以保证的,那么它不会占用大量的运行时间。

去掉迭代的tarjan肯定解决了我在快乐路径中的复杂性问题(没有循环或只是一个小循环),这是它给我最心痛的地方——而不是花25分钟来构建图表,它需要15秒。

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