有向无环图

时间:2016-03-06 17:07:57

标签: javascript node.js algorithm graph

所以在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迭代,但我认为记忆数千个阵列的内存消耗将使这相形见绌,这似乎是主要问题。我对Hackerrank的“Abort Called”和“Runtime Error”进行了11次失败的测试(与“Timeout”相反)。还尝试使用“union”来“bitSets”,但由于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帖子指向拓扑排序,但据我所知,拓扑排序仍然会超过上述情况,因此不是一个可行的解决方案。如果我误解了,请赐教。提前感谢您的时间。

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

1 个答案:

答案 0 :(得分:2)

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

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

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

的所有节点

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

后继者(n)成为可从 n 到达的所有节点,祖先(n)是可以到达 n的所有节点。 我们可以使用以下观察来大幅减少搜索空间:

  • 如果从 n 可到达的节点数小于 | V | / 2 那么后继者(n)中的任何节点都不能有更大的数字
  • 如果从 n 可到达的节点数大于或等于 | V | / 2 那么祖先(n)中的所有节点都会有更大的数字

我们如何使用它?

  1. 创建图表时,还要创建转置图表。这意味着当存储边a-> b时,您将b-> a存储在转置图中。
  2. 创建一个数组,用于存储要忽略的节点,并使用false
  3. 对其进行初始化
  4. 实现基于DFS的函数,该函数确定给定节点是否具有多个可到达节点>= |V| / 2(见下文)
  5. 在该函数中,忽略标记为忽略的节点
  6. 如果节点 n 节点数小于 | V | / 2 ,将后继者(n)中的所有节点标记为忽略
  7. 否则计算祖先(n)中的所有节点并将其标记为忽略
  8. 使用迭代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秒,但这有点像欺骗^^