【问题标题】:Reachability Count for all nodes in a Directed Acyclic Graph有向无环图中所有节点的可达性计数
【发布时间】:2016-03-06 17:07:56
【问题描述】:

因此,在 Hackerrank 的一个名为“无环图”的编程竞赛中出现了这个挑战,它基本上归结为计算“有向无环图”中每个节点可到达的节点数。例如,假设您有这样的图表:

[ 1 ] ---->[ 2 ]--->[ 4 ]--->[ 5 ]
[ 3 ] ------/

可达性计数(包括原始节点):

Node 1: 4
Node 2: 3
Node 3: 4
Node 4: 2
Node 5: 1

我的方法是带有记忆的“深度优先”遍历。环顾四周,但似乎运行时间无法进一步改善,因为在这样的情况下会发生过度计数:

[ 1 ] ---->[ 2 ]--->[ 4 ]--->[ 5 ]
[ 3 ] ------/--------/

第三个节点将计算第四个节点,即使第二个节点已经计算了第四个节点。更糟糕的是,我只在 JavaScript 中解决这些挑战。它是我的主要语言,我从突破它的界限中获得了快感。排行榜上还没有人用 JavaScript 解决过这个问题,但我认为这是可能的。比赛结束后,我成功通过了 24 个测试用例中的 13 个,代码如下:

function Solution( graph, nodes ) {

    var memory = new Array( nodes + 1 )
      , result = 0;

    graph.forEach( ( a, v ) => DepthFirstSearch( graph, v, memory ) );

    // challenge asks for an output variation, but the accurate 
    // reachability count of every node will be contained in "d.length".
    memory.forEach( ( d, i ) => { if ( i && ( 2 * d.length ) >= nodes ) result++; } );

    return result;
}

function DepthFirstSearch( graph, v, memory ) {

    if ( memory[ v ] ) return memory[ v ];

    var descendants = new Uint16Array( [ v ] );

    graph[ v ].forEach( u => {

        descendants = MergeTypedArrays( 
            DepthFirstSearch( graph, u, memory ),  
            descendants
        );
    } );
                                           // make elements unique
                                           // to avoid over counting
    return memory[ v ] = Uint16Array.from( new Set( descendants ) ); 
}

function MergeTypedArrays(a, b) {

    var c = new a.constructor( a.length + b.length );

    c.set( a );
    c.set( b, a.length );

    return c;
}

// adjacency list
var graph = [ 
    [],    // 0
    [ 2 ], // 1
    [ 4 ], // 2
    [ 2 ], // 3
    [ 5 ], // 4
    []     // 5
];

var nodes = 5;

Solution( graph, nodes );

所有大于 50kb 的输入都失败,可能是具有大量节点和边的输入(即 50,000 个节点和 40,000 个边)。由于无法识别或构思出更快、内存效率更高的算法,我完全不知道接下来要尝试什么。考虑过使 DFS 迭代,但我认为记忆数千个数组的内存消耗会相形见绌,这似乎是主要问题。对于 11 个失败的测试(与“超时”相反),我在 Hackerrank 上收到“调用中止”和“运行时错误”。还尝试了“bitSets”和“union”,但内存消耗变得更糟,因为 bitSets 数组需要足够大以存储最多 50,000 个数字。

约束:

1 ≤ n,m ≤ 5×10^4
1 ≤ a(i),b(i) ≤ n and a(i) ≠ b(i)
It is guaranteed that graph G does not contain cycles.

只是想明确一点,我不会因为通过所有测试而获得任何分数,因为这个挑战是锁定的,这是为了教育目的,主要是优化。我知道指向拓扑排序的相关SO帖子,但据我了解,拓扑排序仍然会过度依赖上述情况,因此不是一个可行的解决方案。如果我误解了,请赐教。提前感谢您的宝贵时间。

问题:如何进一步优化?有没有更有效的方法?

【问题讨论】:

  • 你有更多关于测试用例的信息吗?他们有什么时间/记忆限制?老实说,50k 节点 40k 边是一个很小的图,即使是糟糕的实现也不应该有问题。
  • @MartinS 是的,JavaScript 最大执行时间为 10 秒,最大内存为 512mb。我只是在本地运行您的版本;堆栈溢出时的递归错误和迭代错误需要约 13 分钟 this 测试用例(第一个数字是节点数,旁边的数字是边数,这两个数字下方的行是实际的边缘)。挑战成功率为7.87%。我认为这比我们意识到的要多一些。我一直回到记忆,因为它做了很多重复的工作。
  • 这些约束中的a and b是什么1 ≤ n,m ≤ 5×10^41 ≤ a(i),b(i) ≤ n and a(i) ≠ b(i)
  • @MartinS 很抱歉没有指定,“a”和“b”指的是边缘。为了消除更多歧义,here 是挑战描述的屏幕截图。
  • 谢谢你,这很有帮助:)

标签: javascript node.js algorithm graph


【解决方案1】:

深度优先搜索 (DFS) 是解决此问题的一种好方法。另一种方法是广度优先搜索(BFS),它也可以并行运行并且可以很好地优化 - 但所有这些都以更高的代码复杂性为代价。所以我的建议是坚持使用 DFS。

首先我要道歉,但我的 JavaScript 技能不是很好(即它们不存在),所以我下面的解决方案是使用 Java,但这些想法应该很容易移植。

您最初的问题缺少一个非常重要的细节:我们只需要找到可到达节点数大于或等于|V| / 2的所有节点

为什么这很重要?计算每个节点的可达节点数量是昂贵的,因为我们必须从图中的每个节点开始执行 DFS 或 BFS。但是如果我们只需要找到具有上述属性的节点,那就容易多了。

successors(n)为从n可达的所有节点,ancestor(n)为所有可达n的节点。 我们可以使用以下观察来大幅减少搜索空间:

  • 如果从n 可达的节点数小于|V| / 2 那么successors(n)中没有节点可以有更大的数字
  • 如果从 n 可达的节点数大于或等于 |V| / 2 那么ancestors(n)中的所有节点都会有一个更大的数字

我们如何使用它?

  1. 创建图形时,还要创建转置图形。这意味着当存储边 a->b 时,您将 b->a 存储在转置图中。
  2. 创建一个数组来存储要忽略的节点,用false初始化它
  3. 实现一个基于 DFS 的函数,用于确定给定节点是否有多个可达节点 >= |V| / 2(见下文)
  4. 在该函数中,忽略标记为已忽略的节点
  5. 如果节点 n 的节点数小于 |V| /2,将successors(n)中的所有节点标记为忽略
  6. 否则计算 ancestors(n) 中的所有节点并将它们标记为已忽略

使用迭代 DFS 的解决方案

public int countReachable(int root, boolean[] visited, boolean[] ignored, Graph graph) {
    if (ignored[root]) {
        return 0;
    }

    Stack<Integer> stack = new Stack<>();
    stack.push(root);

    int count = 0;
    while (stack.empty() == false) {
        int node = stack.pop();
        if (visited[node] == false) {
            count++;
            visited[node] = true;
            for (int neighbor : graph.getNeighbors(node)) {
                if (visited[neighbor] == false) {
                    stack.push(neighbor);
                }
            }
        }
    }
    if (count * 2 >= graph.numNodes()) {
        return markAndCountAncestors(root, visited, ignored, graph);
    } else {
        return markSuccessors(root, visited, ignored, graph);
    }
}

标记祖先的功能

这只是另一个 DFS,但使用了转置图。请注意,我们可以重用 visited 数组,因为我们将使用的所有值都是 false,因为这是一个无环图。

public int markAndCountAncestors(int root, boolean[] visited, boolean[] ignored, Graph graph) {   
    Stack<Integer> stack = new Stack<>();
    stack.push(root);
    visited[root] = false;

    int count = 0;
    while (stack.empty() == false) {
        int node = stack.pop();
        if (visited[node] == false && ignored[node] == false) {
            count++;
            visited[node] = true;
            ignored[node] = true;
            for (int neighbor : graph.transposed.getNeighbors(node)) {
                if (visited[neighbor] == false && ignored[node] == false) {
                    stack.push(neighbor);
                }
            }
        }
    }
    return count;
}

标记继任者的功能

请注意,我们已经有了后继节点,因为它们只是我们将 visited 设置为 true 的节点。

public int markSuccessors(int root, boolean[] visited, boolean[] ignored, Graph graph) {
    for(int node = 0; node < graph.numNodes(); node++) {
        if (visited[node)) {
            ignored[node] = true;
        }
    }
    return 0;
}

计算结果的函数

public void solve(Graph graph) {
    int count = 0;
    boolean[] visited = new boolean[graph.numNodes()];
    boolean[] ignored = new boolean[graph.numNodes()];
    for (int node = 0; node < graph.numNodes(); node++) {
        Arrays.fill(visited, false); // reset visited array
        count += countReachable(node, visited, ignored, graph);
    }
    System.out.println("Result: " + count);
}

在您发布的大型测试用例中,这对我来说运行时间为 7.5 秒。如果你颠倒迭代顺序(即在solve 中你从最大的节点 id 开始)它会下降到 4 秒,但这有点像作弊^^

【讨论】:

  • 感谢马丁斯的指点。您的方法实际上是我首先尝试的方法,我的原始代码看起来几乎相同。它没有锻炼得太好。它实际上比我提供的代码慢(对于两个测试用例),并且仅通过 13 个用例时出错(通过与我的算法相同的测试用例,无法处理更大的输入)。我只是转向我提出的尝试缓存的方法。我认为您的迭代代码中可能存在一些逻辑错误。如果不进行修改,我无法使其适用于其他测试用例,它对于循环用例而言过于重要。不管怎样,我很感激。
  • @marcbraulio 我刚刚看到你必须在检查你是否已经访问过节点之后移动count++; - 对此感到抱歉。
  • 不用担心 :) 您还必须将 visited[node] = false; 更改为 visited[node] = true;。如果没有这种改变,它仍然会通过我提出的测试用例,但不会通过循环用例。签出this 测试用例,你会明白我在说什么(第一个数字是节点数,旁边的数字是边数,这两个数字下面的线是实际边)。它应该输出Node 1: 3, Node 2: 4, Node 3: 2, Node 4: 7, Node 5: 6, Node 6: 8, Node 7: 10, Node 8: 5, Node 9: 1, Node 10: 9
  • @marcbraulio 我使用了正确的代码,但不知何故在这里发布了错误^^我更新了我的答案,因为评论太长了
  • 没问题,我很感激。所以你告诉我你在 Java 中的迭代 DFS 在 2 毫秒内运行了 this 测试用例?根据我对臭名昭著的“N-Queens”问题所做的基准测试,JavaScript(Node.js - V8)仅比 C 慢三倍左右。是的,我使用的是我认为是“邻接表”的东西" 来表示我的图表(在我上面的代码中描述)。我读到它是最有效的图形数据结构之一,你推荐另一种方式吗?
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-04-10
相关资源
最近更新 更多