有向无环图的 Transitive Reduction 算法

最近搞了一个类似的需求还蛮有意思的,由于开始样例没有准备充分,导致设计算法的时候思路走偏了,结果写完了才发现问题。赶完需求之后觉得这个算法应该有业界统一的方案,所以整理记录一下。

1. 问题描述

删除有向无环图中的跨层冗余依赖关系,可用于依赖关系处理中对非必要依赖关系的简化。对于 Transitive Reduction 的专业解释,可以参考链接[1],给出简单示例如下:

示例

  • G1 为原始图,A -> B 和 C,B -> C,从该依赖关系可看出,A 直接依赖了其后代 B 的后代,这里可以 A -> C 的依赖给删掉。
  • G2 为删除依赖关系后的图。

2. 问题分析

下面将针对 G3 所示的图给出两个算法,直接法和拓扑排序法。

示例

2.1 直接法

一种比较暴力且容易想到的想法是,找出每一个节点的非直接依赖节点,遍历每个节点的直接节点,判断节点的非直接节点和直接节点是否有重合,若有,则删除重合项。

在图 G3 中,按照上述分析,可以该 DAG 做如下处理:

  • 计算每个节点的非直接依赖节点,得到 A: {C, E}, B: {E}, C: {} D: {E} E: {}
  • 遍历每个节点的直接节点,得到 A: {B, C, D}, B: {C}, C: {E}, D:{C, E}
  • 求每个节点的非直接依赖节点和依赖节点的交集,得到 A: {C}, D: {E},即为需要移除的边

在该方案中,不难发现,在计算每个节点的非直接依赖节点时,我们可以借助缓存对算法作出优化,比如某个节点 D 同时是 A 和 B 的非直接依赖,若是可以把以 A 为根节点的子树缓存下来,那么在后续的计算中只需计算一次即可,无需每次都从头遍历一遍。

2.2 拓扑排序法

该方法是在 stackoverflow 上面一个关于该算法的回答上找到了一个评论,由于无法下载到原始论文[3],所以把这篇博客[2]看了一遍,大致整理一下思路写在这里。

对于该问题,一种可行的思路(方案一)如下:

  • 遍历 DAG 中的每一条边,start -> end
  • 判断是否存在一条路径从 start 到 end,而非直接走 start -> end 这条边。例如存在一个 middle 节点,满足start -> middle -> end
  • 若存在 2 的情况,则将当前边 start -> end 从 DAG 中删掉

通过以上描述,我们可以看出思路虽然简单,但是寻找两个节点之间是否存在一条至少含有 1 个节点的路径还是有难度的。若单纯采用暴力来求解,时间复杂度会比较高(O(E*(V+E))),即每个节点开始遍历一遍来判断路径是否存在。这里不难看出,针对同样的路径,我们可能会遍历很多次。

那么考虑是否有方案尽可能少地遍历呢?

对 “尽可能地少遍历” 这个目标,一个可能的方案是引入缓存,空间换时间。遍历时记录节点的可达关系,在后续节点计算时,判断与已有缓存是否有关系,若有关系,则根据缓存运算无需重新计算一次,若无关,则重新计算。该方案操作较为复杂,且容易出错,暂不考虑。

我们再回看一下这个方案,遍历图中的每一条边,这导致每次只有 start 和 end 节点这两个信息,需要通过二次遍历来判断其间接依赖关系。为了减少这些二次遍历,可考虑先对图做拓扑排序,然后基于拓扑排序和图中实际存在的边来做边的遍历,这样最大限度保留了边的依赖关系。此时,(方案二)可以修改为如下方式:

  • 获取拓扑排序列表
  • 创建新 DAG 用于存储结果
  • 针对拓扑排序中的每两个节点之间的边,判断原 DAG 中是否存在
  • 若存在则进一步判断两点之间是否存在其他路径,若不存在,则添加到新 DAG 中
  • 新 DAG 即为所求

在方案二中,看起来并没有减少多少工作量,甚至还多计算了一次拓扑排序。其实我们可以优化判断两点之间是否存在其他路径这一流程,借助拓扑排序(顺序:入度为 0 的点到出度为 0 的点)和标记位来简化操作,无需从某个节点开始二次遍历。
在遍历某个节点 i([1, n-1])的其他关联 j([0, i - 1])节点的过程中,为 j 节点设置标记位,表示是否存在一条从 j 到 i 的路径,若存在则置为 True,不存在则置为 False。这样可以利用前面缓存的思路,遍历到边 [j, i] 时,若标记为 True,即存在一条从 j 到 i 的路径,则可将所有 [x, j] 边的 x 都置为 True,因为 j 到 i 可达,x 到 j 可达,那么 x 到 i 也可达。
基于这个思路,每次在某个节点首次标记为 True 的时候,即为首次判断该边在原 DAG 中存在时,将该边添加到新 DAG 中。该首次标记的过程,即为直接依赖产生的过程,后续再出现标记时,则为通过其他点的非直接依赖,而这些也不必保留。由此,可得到最终方案(方案三)的大致流程如下。

  • 获取拓扑排序序列
  • 针对拓扑排序中的每两个节点之间的边,i 从 [1, n-1],j 从 [i - 1, 0],判断原 DAG 中是否存在
  • 若存在,则判断标记位是否为 False,若是则将当前边添加到新 DAG 中,并将标记位置为 True
  • 判断标记位是否为 True,若是则标记以 j 为结束节点的相关节点为 True,直至结束

下面以一个具体例子讲解一下算法流程:

在这里插入图片描述

初次迭代时,i 从排序列表的第二个节点开始,j 从第一个节点开始,由于 A->B 在图中,且 A 的标记位为 False,将 A->B 添加到新 DAG 中,并将 A 的标记位置为 True。由于 A 的标记位为 True,因此根据边的关系将以 A 为终点的其他节点(无)标记位置为 True。

二次迭代时,重置前 i - 1 个标记位,i 走到 D 节点,j 先走到 B 节点,此时由于 B->D 不在图中,因此无需修改标记位。由于 B 的标记位为 False,因此不会进入修改关联节点的步骤。j 继而走到 A 节点,由于 A->D 在图中,且 A 的标记位 False,因此需将 A->D 添加到新 DAG 中,并将 A 的标记位置为 True,同时将以 A 为终点的其他节点(无)标记为 True。

三次迭代时,重置前 i - 1 个标记位,i 走到 C 节点,j 先走到 D 节点,此时由于 D->C 在图中且 D 标记位为 False,因此将其添加到新 DAG 中,修改 D 的标记位为 True,修改 D 关联的其他节点(A)标记为 True。j 走到 B 节点,此时由于 B->C 在图中且 B 标记位为 False,因此将其添加到 DAG 中,修改 B 的标记位为 True,同时将以 B 为终点的其他节点(A)标记为 True。

四次迭代就不再赘述了,原理和前三次相同。

在上述算法流程中,j 的遍历顺序是否能修改成正序,为什么?
不能,考虑拓扑排序的特性,逆序意味着从距离 i 节点相对最近或者相对平级的节点开始遍历。这样在修改直接关联节点时,会按照较长的一条依赖关系来改。若按照正序的话,可能会把本来需要删除的边额外添加进去,使得结果不正确。如第 1 节中的 G1 所示,若改为正序,则可以看到算法流程如下所示:

在这里插入图片描述

3. 算法实现

3.1 直接法

def remove_redundant_edge_by_set(graph: Dict[str, set]) -> Dict[str, set]:
    undirect_nodes = {}
    for node, real_nodes in graph.items():
        current_undirect_nodes = set()
        for rn in real_nodes:
            queue = [rn]
            while len(queue) > 0:
                current = queue.pop()
                for next in graph[current]:
                    current_undirect_nodes.add(next)
                    queue.append(next)

        undirect_nodes[node] = current_undirect_nodes

    remove_edge = set()
    for node, edges in graph.items():
        current_remove_edge = set(edges).intersection(undirect_nodes[node])
        if len(current_remove_edge) > 0:
            remove_edge |= set((node, x) for x in current_remove_edge)

    new_graph = copy.deepcopy(graph)
    for start, end in remove_edge:
        new_graph[start].remove(end)

    return new_graph

3.2 拓扑排序法

def remove_redundant_edge_by_sort(graph: Dict[str, set]) -> Dict[str, set]:
    visited = {}
    topo_list = []

    def dfs(graph, node):
        dfs[node] = 1
        topo_list.append(node)

        for next in graph[node]:
            if visited[next] == 0:
                dfs(graph, next)

    index_of_topo = {x: index for index, x in enumerate(topo_list)}

    exist_flag = [False for x in graph]
    new_edge_set = set()
    for i in range(1, len(topo_list)):
        if len(graph[topo_list[i]]) == 0:
            continue

        for j in range(i - 1):
            exist_flag[j] = False

        for j in range(i - 1, -1, -1):
            if topo_list[i] in graph[topo_list[j]]:
                if not exist_flag[j]:
                    new_edge_set.add((topo_list[j], topo_list[i]))
                    exist_flag[j] = True

            if exist_flag[j]:
                for start, end in new_edge_set:
                    if end == topo_list[j]:
                        exist_flag[index_of_topo[start]] = True

    new_graph = {}
    for start, end in new_edge_set:
        if start not in new_graph:
            new_graph[start] = set()

        if end not in new_graph:
            new_graph[end] = set()

        new_graph[start].add(end)

    return new_graph

其中,首先用 dfs 获取拓扑排序列表,在此尝试过 networkx,发现在首次运行时性能较差,因此简单写了一个获取拓扑排序的步骤,为了可以清晰看到执行步骤就不单独抽取函数了。

4. 算法性能

针对上述代码做了一个简单的测试,首先按照一定规则生成一组不同节点个数的图(包含固定个数个冗余边),分别调用两种方法并计算其运行时间,可得到其运行结果如下图所示:

在这里插入图片描述

图中横坐标表示节点个数,纵坐标表示运行时间。
从图中可看出,set 方式随着问题规模的增大,呈指数级增长,而 sort 方式则呈线性增长,大致符合 O(V + E) 的规律。

5. 三方库中的实现

在 python 的 networkx 这个库中有这个方法,且可以直接调用,以下是源码,我们来简单分析以下整个流程。

def transitive_reduction(G):
    """ Returns transitive reduction of a directed graph

    The transitive reduction of G = (V,E) is a graph G- = (V,E-) such that
    for all v,w in V there is an edge (v,w) in E- if and only if (v,w) is
    in E and there is no path from v to w in G with length greater than 1.

    Parameters
    ----------
    G : NetworkX DiGraph
        A directed acyclic graph (DAG)

    Returns
    -------
    NetworkX DiGraph
        The transitive reduction of `G`

    Raises
    ------
    NetworkXError
        If `G` is not a directed acyclic graph (DAG) transitive reduction is
        not uniquely defined and a :exc:`NetworkXError` exception is raised.

    References
    ----------
    https://en.wikipedia.org/wiki/Transitive_reduction

    """
    if not is_directed_acyclic_graph(G):
        msg = "Directed Acyclic Graph required for transitive_reduction"
        raise nx.NetworkXError(msg)
    TR = nx.DiGraph()
    TR.add_nodes_from(G.nodes())
    descendants = {}
    # count before removing set stored in descendants
    check_count = dict(G.in_degree)
    for u in G:
        u_nbrs = set(G[u])
        for v in G[u]:
            if v in u_nbrs:
                if v not in descendants:
                    descendants[v] = {y for x, y in nx.dfs_edges(G, v)}
                u_nbrs -= descendants[v]
            check_count[v] -= 1
            if check_count[v] == 0:
                del descendants[v]
        TR.add_edges_from((u, v) for v in u_nbrs)
    return TR

在上述算法中,首先对传入参数做了校验,接着计算出每个节点的入度,然后遍历每个节点,计算方式和 set 方式类似,依次计算每个 node 节点的所有直接依赖节点和间接依赖节点,并作差集,利用入度来控制不会把依赖节点减至 0。整个算法的核心实现逻辑和 set 方式类似,只是看起来有多余的遍历,set 方式是一次计算,多次使用,而这个算法看起来有很多冗余的计算和遍历,时间耗时也会增加。
下面是添加了该算法之后的测试数据,随着数据规模的增大,该算法的性能也在急剧恶化。

在这里插入图片描述

所以呢,如果追求性能,且对数据规模要求比较大的情况下,建议还是选择 sort 方式,如果只是快速实现验证效果,还是建议 networkx 挺好的,用起来简单粗暴,代码也不几行,就是性能差一点。

参考链接

[1] wiki
[2] 拓扑排序法实现 —— Github
[3] A. V. Aho, M. R. Garey, and J. D. Ullman, “The transitive reduction of a directed graph”, SIAM J. Computing, Vol. 1, No. 2, June 1972

  • 13
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值