在逐步构建有向图的同时更有效地计算每个依赖项的传递闭包

Posted

技术标签:

【中文标题】在逐步构建有向图的同时更有效地计算每个依赖项的传递闭包【英文标题】:More efficiently compute transitive closures of each dependents while incrementally building the directed graph 【发布时间】:2019-04-20 06:05:15 【问题描述】:

我需要回答这个问题:给定依赖关系图中的一个节点,将其依赖项按它们自己的传递依赖项分组,这些传递依赖项会受到特定起始节点的影响。

换句话说,给定依赖图中的一个节点,找到直接依赖集合的集合,这些集合传递地具有从该特定起始节点派生的公共依赖。

例如给出伪代码:

let a = 1
let b = 2
let c = a + b
let d = a + b
let e = a
let f = a + e
let g = c + d

你可以计算这个图:

如果我们使用a 作为起始节点,我们可以看到a 的从属节点,cd 都有g 的从属节点。而f 有一个ea 的依赖项。

注意ab 完全没有影响,因此在决定如何对a 的家属进行分组时不应考虑它。

使用a 作为起始节点,我们想要得到这个分组的依赖集:

groups = c, d, e, f

cd 具有直接或传递的下游关系,ef 也在一起。但是,例如,efcd 直接或间接(传递地)根本没有依赖(下游)关系。而且b 不是直接或间接地从a 派生而来,所以它对决定我们的分组应该没有任何影响。

还请记住,为简单起见,此图很小。可能是传递依赖项发生在子图的下方,比这个例子发生的更远。


我进行了大量的论文研究,确实有很多解决方案,但是它们不具备我正在寻找的性能特征。该图表是随着时间的推移逐步创建的,在每个阶段我都需要能够回答这个问题,因此每次遍历整个图表都会破坏交易。

我认为我有一个在我能找到的各种方法中没有提到的主要优势:我可以完全控制图形的创建,并且以反向拓扑顺序添加依赖项,因此图形被正确排序。考虑到这一点,我考虑了增量计算答案的明显解决方案(动态编程)。

我认为位掩码是一种快速存储和查找给定节点具有哪些依赖项的方法。当依赖项添加到节点时,我会更新该节点的掩码以包含该依赖项的位(它本身包括其依赖项等)

let maskCounter = 0;

class Node 
  constructor(name) 
    this.name = name;
    this.dependents = [];
    this.mask = 1 << maskCounter;
    maskCounter++;
  

  addDependent(dependent) 
    // Now our mask contains the bits representing all of
    // its direct and transitive dependents
    this.mask = this.mask | dependent.mask;

    // Need to see if this dependent has a transitive
    // dependent of its own that exists in one of the groups
    for (const group of this.dependents) 
      const result = group.mask & dependent.mask;

      if (result) 
        group.mask |= dependent.mask;
        group.values.push(dependent);
        return;
      
    

    // If reached, this dependent has no transitive dependents
    // of its own with any of this node's other dependents.
    // That's confusing, huh?
    this.dependents.push(
      mask: dependent.mask,
      values: [dependent]
    );
  

但是,需要以相反的顺序将依赖项添加到图表上,以便图表正确排序并且图表的顶部包含其所有依赖项的掩码。

const a = new Node('a');
const b = new Node('b');
const c = new Node('c');
const d = new Node('d');
const e = new Node('e');
const f = new Node('f');
const g = new Node('g');

b.addDependent(c);
b.addDependent(d);
c.addDependent(g);
d.addDependent(g);
e.addDependent(f);
a.addDependent(c);
a.addDependent(d);
a.addDependent(e);
a.addDependent(f);

位掩码逐渐看起来像这样:

b = b 00000010 | c 00000100
b = b 00000110 | d 00001000
c = c 00000100 | g 01000000
d = d 00001000 | g 01000000
e = e 00010000 | f 00100000
a = a 00000001 | c 01000100
a = a 01000101 | d 01001000
a = a 01001101 | e 00110000
a = a 01111101 | f 00100000
===========================
a = 01111101

最后a 有一个01111101 的掩码,每个位代表它的每个下游传递依赖项。请注意,倒数第二位没有翻转,这是b 的位,它根本不依赖于a

如果我们查看a.dependents 的结果值,我们会看到:

[
   values: [c, d], mask: 0b00110000 ,
   values: [e, f], mask: 0b01001100 
]

它提供了我们正在寻找的答案,最终是一组集合。 a.dependents.map(group =&gt; group.values)--这是一个数组又名列表,但为了简单起见,它被用作一个集合。

这是一个 JSBin:https://jsbin.com/jexofip/edit?js,console

这是可行的,而且 CPU 方面是可以接受的,因为我会经常需要知道分组的依赖项,但依赖项的更改频率要低得多。

为了简化演示,上面的示例使用 javascript,它使用 32 位有符号整数进行按位运算,因此我们只能创建 31 个唯一节点。我们可以使用任意精度整数(例如BigInt)来创建“无限”数量的节点,但问题在于内存使用情况。

因为每个节点都需要自己唯一的位翻转,我认为内存使用量是:

N * (N + 1) / 2 = bits      (where N = number of nodes)

e.g. 10,000 nodes is about 6.25 megabytes!

这不包括使用任意精度整数(或类似的自定义数据结构)的任何平台开销。

在我的用例中,通常有 10k+,实际上在某些情况下可能会达到 100k+(625 MB !!!),并且还可以无限期地创建新节点,使用无限量的内存,所以这个解决方案是不实用的,因为没有简单的方法来“垃圾收集”不再使用从图上掉线的节点的掩码位——这当然是可能的,但这是我想避免的传统 GC 问题可能。

旁注:根据图表的大小和深度,这实际上也可能效果不佳。尽管按位运算本身相对较快,但在图顶部的每个节点的 100,000 位 BigInt 上执行它却不是。所以完全重新思考我的方法也是受欢迎的。


最终,用内存换取 CPU 是通常的取舍,但我想知道我是否错过了另一种实现更好平衡或需要显着减少内存的方法?

可能还有其他我没有想到可以使用的独特考虑因素。

教我!

【问题讨论】:

这并没有明确定义你想要的——输出是输入的函数。从您的示例中并不明显,并且“通过自己的传递依赖对其依赖进行分组”不清楚。 @philipxy 对此感到抱歉。我对其进行了一些编辑以希望澄清。给定依赖图中的一个节点,将其依赖项按它们自己的传递依赖项分组。例如。这是示例[[b, c], [e, f] 中的两个组。我已经通过使用位掩码的示例代码实现了这一点,但是我的目标是找到一种不使用几乎一样多的内存并希望在速度上相似(或更好)的方法。这有意义吗? @philipxy 刚刚意识到我的回答与您提到的完全相同的短语不清楚。我不知道如何改写,我会考虑一下。谢谢! “依赖项以相反的拓扑顺序添加”和“此外,图也可以包含循环”一起没有意义。拓扑顺序仅在有向 acyclic 图中才有意义。在我们知道如何回答之前,我们需要一个理论上一致的图表规范。 您希望这张图有多稀疏或密集?相对于图更新,您需要在多少个节点上执行此依赖关系分组,以及多久执行一次?提前知道感兴趣的节点吗? 【参考方案1】:

您要分组的关系不是equivalence relation。例如,考虑这个依赖图:

这里,bc 有一个共同的从属,cd 也是如此,但是有bd 之间没有共同的依赖项。在这种情况下,您可能希望 bcd 在同一个组中。但是,这种情况会变得更加棘手:

这里,a 不依赖于 c,因此您可能需要 bd在不同的组中,现在您不需要关心 c。但是,在这种情况下,有一类算法会将 bd 组合在一起:维护所有节点分组的算法,并使用这是对新节点的直接后代进行分组的基础。

这样的算法使用disjoint-set structure 来有效地跟踪哪些节点已连接。在我的示例中,在处理 a 之前,算法将有节点 bcd ef 都在同一个集合中,因此它们将被组合在一起。

这是一个实现:

function find(node) 
  return node.parent == null ? node : (node.parent = find(node.parent));


function merge(a, b) 
  a = find(a);
  b = find(b);
  if (a.rank < b.rank) 
    a.parent = b;
   else 
    b.parent = a;
    if (a.rank == b.rank) 
      ++a.rank;
    
  


class Node 
  constructor(name, dependents) 
    this.name = name;
    this.parent = null;
    this.rank = 0;
    let depMap = new Map();
    for (let d of dependents) 
      let dset = find(d);
      if (!depMap.has(dset)) 
        depMap.set(dset, []);
      
      depMap.get(dset).push(d);
    
    output += name + ': ' + Array.from(depMap.values()).map(a => '' + a.join(', ') + '').join(', ') + '\n';
    for (let d of depMap.keys()) 
    // or: for (let d of dependents) 
      merge(this, d);
    
  

  toString() 
    return this.name;
  


let output = '';
const f = new Node('f', []);
const e = new Node('e', [f]);
const d = new Node('d', []);
const c = new Node('c', [d]);
const b = new Node('b', [d]);
const a = new Node('a', [b, c, e, f]);
document.getElementById('output').textContent = output;
&lt;pre id=output&gt;&lt;/pre&gt;

【讨论】:

感谢您的详尽回答!我想澄清一些事情以确保我们在同一页面上:在第一个示例中,我的代码将生成 [[b, c, d]] 全部组合在一起,这对于我的用例是正确的。在第二个示例中,我的代码将生成:[[b], [d]],这也是正确的,因为对a 的更改对c 没有影响,因此bda 没有传递关系- - 正如您所提到的,它们通常通过c 具有传递关系,但该关系不适用于此用例。 我认为这是一个非常有趣的观点,我没有在我的问题中指出,它不仅仅是任何传递关系,它只是那些会受到a 影响的关系。不确定这个术语。因此,考虑到这一点,您能否澄清您的评论“但是,这意味着维护迄今为止处理的所有顶点分组的算法将不起作用”? @jayphelps 我编辑了我的答案以使其更清楚。我的算法并不总是产生您需要的确切结果(有时它将节点放在同一组中,而它们应该位于不同的组中),但它比您的基于位掩码的算法效率更高,因此这可能是可以接受的权衡。 明白了。再次感谢!不幸的是,我认为这不是我的用例可接受的折衷方案。 非常高兴你提出了关于不相关子图的观点,我修改了我的示例以包含它。【参考方案2】:

将每个节点的“可到达”节点存储为位掩码并执行按位与当然在计算上听起来很难被击败。如果主要问题是高内存使用率,那么这可能被视为内存压缩问题。

如果位掩码非常稀疏(很多零),它们有可能会压缩到更小的尺寸。

我想你会想找到一个可以将位掩码解压缩为流的压缩库。这样,您可以在解压缩时执行按位与 - 允许您避免存储完全解压缩的位掩码。

【讨论】:

我最初想避免压缩,因为我天真地认为它会更慢(通常是解压缩),但我发现了 Roaring Bitmaps roaringbitmap.org 并进行了一些分析并发现它实际上 更快 在很多情况下,我有一个非常大的图表。大概是因为一个非常大的 BigInt 需要更多的内存分配,并且检查它们之间的交集必须检查每一位并且它们通常非常稀疏,而 Roaring Bitmaps 则不需要(复杂的实现,我建议阅读它)。很酷!谢谢!【参考方案3】:

如果是有向无环图,可以对节点执行topological sorting,这似乎是后续步骤的良好基础。拓扑排序本身可以有效地完成。受 FRP 启发的库中有一些实现,例如我的 crosslink 或 paldepind 的 flyd

另外,请查看this answer。

【讨论】:

感谢您的链接!我可能误读了,但看起来链接的答案实际上是我已经在做的事情,不是吗?我没有进行排序,因为该图已经按照正确的拓扑顺序逐步构建。 ... 看起来这个引用的方法也可以处理循环cs.hut.fi/~enu/thesis.html,基于 Tarjan 的强成分检测算法en.wikipedia.org/wiki/… @jayphelps 我不确定,看看上面与 Tarján 的链接中的时间和空间复杂度;当然 O(whatever) 不会使常数因素变得不重要

以上是关于在逐步构建有向图的同时更有效地计算每个依赖项的传递闭包的主要内容,如果未能解决你的问题,请参考以下文章

在 TeamCity 中构建特定 .NET Core 项目时引用传递依赖项的问题

如何有效地将子域名传递给 Laravel 中的控制器和视图

使用 Gradle 时如何排除传递依赖项的所有实例?

在 Javascript 中有效地逐步过滤大型数据集

如何更有效地计算 n 个字符串之间的不匹配分数?

使用 Typescript 和 Webpack 管理依赖项的 AngularJS