简体   繁体   中英

Detect cycle in directed Graph

Recently had to detect recursions in a directed graph in code logic. My nodejs implementation feels complex and I am now wondering:

  • Are there any problems with the code?
  • Can we simplify it / make it more readable?
const checkCyclic = (graph) => {
  const nodes = new Set(Object.keys(graph));
  const searchCycle = (trace, node) => {
    const cycleStartIdx = trace.indexOf(node);
    if (cycleStartIdx !== -1) {
      throw new Error(`Cycle detected: ${trace
        .slice(cycleStartIdx).concat(node).join(' <- ')}`);
    }
    if (nodes.delete(node) === true) {
      const nextTrace = trace.concat(node);
      graph[node].forEach((nextNode) => searchCycle(nextTrace, nextNode));
    }
  };
  while (nodes.size !== 0) {
    searchCycle([], nodes.values().next().value);
  }
};

checkCyclic({
  p1: ['p3'],
  p2: ['p1'],
  p3: ['p2']
});
// => Recursion detected: p1 <- p3 <- p2 <- p1

checkCyclic({
  p0: ['p1'],
  p1: ['p3'],
  p2: ['p1'],
  p3: ['p2']
});
// => Recursion detected: p1 <- p3 <- p2 <- p1

checkCyclic({
  p0: ['p0']
});
// => Cycle detected: p0 <- p0

For the curious, this is being used in promise-pool-ext , which also contains tests.

Thank you very much for your feedback!


Edit: Played around and did the iterative implementation (looks even uglier!)

module.exports = (G) => {
  const pending = new Set(Object.keys(G));
  while (pending.size !== 0) {
    const trace = [pending.values().next().value];
    const parentIdx = [0];
    pending.delete(trace[0]);

    while (trace.length !== 0) {
      const c = trace.length - 1;
      const parent = G[trace[c]][parentIdx[c]];
      if (parent !== undefined) {
        if (trace.includes(parent)) {
          throw new Error(`Cycle detected: ${trace
            .slice(trace.indexOf(parent)).concat(parent).join(' <- ')}`);
        }

        parentIdx[c] += 1;
        if (pending.delete(parent)) {
          trace.push(parent);
          parentIdx.push(0);
        }
      } else {
        trace.pop();
        parentIdx.pop();
      }
    }
  }
};

I usually prefer iterative to recursive, but in this case it might not worth the readability trade-off. Any idea how to improve this implementation?

We may shorten it a bit:

 function getCycle (G, n, path) { if (path.includes(n)) { throw `cycle ${path.slice(path.indexOf(n)).concat(n).join('<-')}` } path.push(n) return G[n].forEach(next => getCycle(G, next, path.slice(0))) } function validate (G) { Object.keys(G).forEach(n => getCycle(G, n, [])) } validate({ p1:['p2','p3','p4'], p2:['p3'], p3:['p0'], p0:[], p4:[] }) console.log('ok') validate({ p1:['p2','p3','p4'], p2:['p3'], p3:['p0'], p0:[], p4:['p1'] })

Now this is not the most efficient since we:

  • find on path which is an array and not a set (idem O(k) instead of O(1))
  • do revisit vertices even though they have already been visited

Below a slightly more optimized version at the sake of readability?

 function getCycle (G, n, path, visited) { if (path.has(n)) { const v = [...path] throw `cycle ${v.slice(v.indexOf(n)).concat(n).join('<-')}` } visited.add(n) path.add(n) return G[n].forEach(next => getCycle(G, next, new Set(path), visited)) } function validate (G) { const visited = new Set() Object.keys(G).forEach(n => { if (visited.has(n)) return getCycle(G, n, new Set(), visited) }) } validate({ p1:['p2','p3','p4'], p2:['p3'], p3:['p0'], p0:[], p4:[] }) console.log('ok') validate({ p1:['p2','p3','p4'], p2:['p3'], p3:['p0'], p0:[], p4:['p1'] })


Regarding perfs, I have (cheaply) tried to reproduce and compare algos on the same graph G (generated by random-dag) with 50 nodes.

They seem to be equivalent.

 function checkCyclic (G) { const pending = new Set(Object.keys(G)); while (pending.size !== 0) { const trace = [pending.values().next().value]; const parentIdx = [0]; pending.delete(trace[0]); while (trace.length !== 0) { const lastIdx = trace.length - 1; const parent = G[trace[lastIdx]][parentIdx[lastIdx]]; if (parent === undefined) { trace.pop(); parentIdx.pop(); } else { if (trace.includes(parent)) { throw new Error(`cycle ${trace .slice(trace.indexOf(parent)).concat(parent).join('<-')}`); } parentIdx[lastIdx] += 1; if (pending.delete(parent)) { trace.push(parent); parentIdx.push(0); } } } } }; function grodzi1(G) { function getCycle (G, n, path) { if (path.includes(n)) { throw `cycle ${path.slice(path.indexOf(n)).concat(n).join('<-')}` } path.push(n) return G[n].forEach(next => getCycle(G, next, path.slice(0))) } Object.keys(G).forEach(n => getCycle(G, n, [])) } function grodzi2(G) { function getCycle (G, n, path, visited) { if (path.has(n)) { const v = [...path] throw `cycle ${v.slice(v.indexOf(n)).concat(n).join('<-')}` } visited.add(n) path.add(n) return G[n].forEach(next => getCycle(G, next, new Set(path), visited)) } const visited = new Set() Object.keys(G).forEach(n => { if (visited.has(n)) return getCycle(G, n, new Set(), visited) }) } // avoid copying the set function grodziNoCopy(G) { function getCycle (G, n, path, visited) { if (path.has(n)) { const v = [...path] throw `cycle ${v.slice(v.indexOf(n)).concat(n).join('<-')}` } visited.add(n) path.add(n) return G[n].forEach(next => { getCycle(G, next, path, visited) path.delete(next) }) } const visited = new Set() Object.keys(G).forEach(n => { if (visited.has(n)) return getCycle(G, n, new Set(), visited) }) } // avoid visiting the already visited set of nodes function grodziStopVisit(G) { function getCycle (G, n, path, visited) { if (path.has(n)) { const v = [...path] throw `cycle ${v.slice(v.indexOf(n)).concat(n).join('<-')}` } if (visited.has(n)) return visited.add(n) path.add(n) return G[n].forEach(next => { getCycle(G, next, path, visited) path.delete(next) }) } const visited = new Set() Object.keys(G).forEach(n => { if (visited.has(n)) return getCycle(G, n, new Set(), visited) }) } // same but iterative function grodziIter(G) { function dfs (G, n, visited) { let stack = [{ path: [], n }] let x while (x = stack.pop()) { const {n, path} = x if (path.includes(n)) { const v = [...path] throw `cycle ${v.slice(v.indexOf(n)).concat(n).join('<-')}` } if (visited.has(n)) continue visited.add(n) path.push(n) G[n].forEach(next => stack.push({ path: path.slice(0), n: next })) } } const visited = new Set() Object.keys(G).forEach(n => visited.has(n) || dfs(G, n, visited)) } const G = {"0":["5","6","12","15","18","30","31","32","33","35","39","41","52","54"],"1":["12","17","29","30","34","35","38","39","40","43","53"],"2":["5","7","12","13","14","15","16","19","21","31","35","36","37","40","41","53"],"3":["14","16","15","30","32","40","52","55"],"4":["5","6","13","15","17","18","32","35","40","41","42","51"],"5":["16","15","30","33","52","53","55"],"6":["11","16","18","33","36","37","42","51","53"],"7":["14","15","16","22","30","33","35","36","39","41","43","49","53","54","55"],"8":["31","36","41","51"],"9":["18","30","36","37","39","40","50","52"],"10":["15","17","18","19","31","32","33","35","37","40","41","48","54","55"],"11":["15","17","19","31","32","35","38","41","40","43","48","52"],"12":["17","21","32","33","35","52","54","55"],"13":["18","19","20","29","33","35","36","38","41","43","52"],"14":["16","17","19","35","39","55"],"15":["20","22","30","33","35","38","39","41","42","43","49","50","54"],"16":["20","32","34","36","37","39","40","42","44","53"],"17":["28","31","36","35","38","41","43","44","48"],"18":["19","31","34","36","35","38","41","49","52","53","55"],"19":["29","36","48","51"],"20":["29","32","33","36","37","49"],"21":["30","31","33","34","35","36","39","48"],"22":["30","31","32","34","36","37","41","43","48"],"23":["33","34","35","36","37","40","44","50"],"24":["28","34","35","36","38","41","42","48","52"],"25":["28","29","31","32","36","41","43","53"],"26":["29","35","37","38","39","41","43","50"],"27":["31","35","36","37","41","42","48","51","53"],"28":["35","37","38","40","41","50","55"],"29":["38","39","40","42","44","51","54"],"30":["37","38","40","41","42","43","49","50","53"],"31":["36","39","40","50","52","54"],"32":["37","38","39","41","44","48","49","52","55"],"33":["41","40","42","44","52","53"],"34":["35","36","41","42","49","52","54"],"35":["44","55"],"36":["41","50","52","53","54"],"37":["52","55"],"38":["55"],"39":["40","41","51"],"40":["48","49","52"],"41":["49","52","53"],"42":["53"],"43":["48","50","52","55"],"44":["48","52","54"],"45":["49","53","54"],"46":["49","50","52"],"47":["48","50","52","53","55"],"48":[],"49":[],"50":[],"51":[],"52":[],"53":[],"54":[],"55":[]} function bench (fn, label) { console.time(label) for (let idx = 0; idx < 50; idx += 1) { fn(G) } console.timeEnd(label) } function shouldThrow (...fns) { const cyc = {"p1":["p2","p3","p4"],"p2":["p3"],"p3":["p0"],"p0":[],"p4":["p1"]} fns.forEach(fn => { let ok = false try { fn(cyc) } catch (e) { ok = e.toString().includes('cycle p1<-p4<-p1') if(!ok){ throw new Error('failzed ', e) } } if (!ok){ throw 'should have thrown' } }) } shouldThrow(checkCyclic, grodzi1, grodzi2, grodziNoCopy, grodziStopVisit, grodziIter) for(let i = 0; i < 3; ++i) { bench(checkCyclic, 'cyclic') bench(grodzi1, 'grodzi1') bench(grodzi2, 'grodzi2') bench(grodziNoCopy, 'grodziNoCopy') bench(grodziStopVisit, 'grodziStopVisit') bench(grodziIter, 'grodziIter') console.log('next') }

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM