javascriptrecursiongraph-theoryadjacency-listsubgraph

How to write JS recursive function to trace sub-graphs, with adjacency list, for a list of initial nodes


Aim:

The aim is to develop a function that quickly traces sub-graphs for a list of nodes, given an existing adjacency list implementation, and returns a list of connected ID's in addition to the original list

Problem

I am finding it hard to understand what to return, and how/where to add the found ID's to the original list. The real issue is deciding:

  1. what the function returns,
  2. how to handle the two cases, recurse further or not

I have a skeleton code, but am starting at it not sure how to finish it off. Can you help please?

Adjacency List Class

Copied from this reference


//------------------------------------------
// Adjaceny List Class
//-------------------------------------------
class Graph {
  
  constructor(){
    this.adjacencyList = new Map();
  }

  addNode(node){
    this.adjacencyList.set(node, new Set());
  }

  addEdge(node1, node2){
    this.adjacencyList.get(node1).set(node2);
  }

  getNeighboors(node){
    return this.adjacencyList.get(node);
  }

  hasEdge(node1, node2){
    return this.adjacencyList.get(node1).has(node2);
  }

}

My half-finished skeleton

can anyone show me what to do with this please so it returns just the list of nodes in the sub-graphs, including the original ID's? Thanks

function traceSubGraph(ID_list, adjacency) {
    let layer_IDs = []
    for (let ID in ID_list) {
        layer_IDs = adjacency.getNeighboors(ID);        
        if (layer_IDs.length) {
            // add new valid nodes to original list of nodes
            ID_list.concat(layer_IDs);
            // keep recursing, but how to add the new values?
            return traceSubGraph(layer_IDs, adjacency)            
        } 
        else {
          // stop recursing, what to do here?
        }
    }
  // what to return? ID_list, but where to add the new found nodes?
}

My Code to Initialise and Drive the Skeleton

function processGraph(graph) {
      // 1. Setup variables and define promotable types
      const prom_types = [
        'incident',
        'task',
        'impact',
        'event',
        'sighting',
      ];
      var returnGraph = {};
      var nodes = graph.nodes;
      var edges = graph.edges;
      var scratch_nodes = [];
      var scratch_edges = [];
      var promotable_nodes = [];
      var prom_IDs = [];
      var collect_IDs = [];
      var promotable_edges = [];
      var adjacency = new Graph();
      // 2. Fill adjacency list with nodes, and get any top level promotables
      for (let node in nodes) {
        adjacency.addNode(node['id']);
        if (node['type'] in prom_types) {
          prom_IDs.push(node.id);
        }
      }  
      // 3. Fill adjacency list with edges
      for (let edge in edges) {
        adjacency.addEdge(edge['source'], edge['target']);
      }
      
      // 4. Now trace out the promotable
      collect_IDs = traceSubGraph(prom_IDs, adjacency);
}

I had worked out that peering through the nodes and edges array was not fast, and then just manually using if/else to go down 3 or 4 layers was not good either. I then found this adjacency list class online. I have managed to fill it relatively easily, but it is using it to trace the subgraph that has me bedevilled.

Particularly the fact that we are iterating through a list, when we decide whether to recurse or not, makes it seem difficult for me as a noob.

I have tried looking at it for hours, and searching the web for tutorials on recursing, but i am still unsure as to the best approach.


Solution

  • fix Graph

    The nested Map should be a Set because there is no value being associated with the keys. addEdge would break under certain conditions. getNeighboors would return undefined in some cases. addNode wasn't needed for the purposes of this answer -

    class Graph {
      constructor() {
        this.t = new Map()
      }
      addEdge(node1, node2) {
        const s = this.t.get(node1)
        if (s == null)
          this.t.set(node1, new Set([node2])) // Set, not Map
        else
          s.add(node2)                        // .add, not .set
      }
      getAdjacencies(node) {
        return this.t.get(node) ?? new Set()
      }
    }
    

    Let's construct a concrete graph to use for the rest of our answer. Next time you will provide this in your question -

    const g = new Graph()
    g.addEdge("A", "B")
    g.addEdge("B", "C")
    g.addEdge("B", "D")
    g.addEdge("D", "E")
    g.addEdge("P", "Q")
    g.addEdge("Q", "P")
    g.addEdge("W", "X")
    g.addEdge("W", "Y")
    g.addEdge("X", "Z")
    g.addEdge("Y", "Z")
    g.addEdge("Z", "W")
    

    single entry point

    First we will write dir to traverse a single node entry point of our graph. I'm calling it dir because it generates paths to our nodes, much like listing the contents of a directory in a file system -

    class Graph {
      //...
      *dir(node, path = Array(), visited = new Set()) {
        yield [...path, node]
        path.push(node)
        visited.add(node)
        for (const adj of this.getAdjacencies(node)) {
          if (!visited.has(adj)) {
            yield* this.dir(adj, path, visited)
          }
        }
        path.pop()
      }
    }
    

    Each path is a sequence of nodes, we can visualize them as familiar string paths by joining the segments with a separator, / -

    for (const path of g.dir("A")) {
      console.log(path.join("/"))
    }
    
    A
    A/B
    A/B/C
    A/B/D
    A/B/D/E
    

    But more importantly we can manipulate these paths to extract the data we care about for a particular traversal. In this example, we find all unique nodes in the subgraph of A by selecting the basename of each path -

    for (const path of g.dir("A")) {
      console.log(path.at(-1)) // basename
    }
    
    A
    B
    C
    D
    E
    

    multiple entry points

    Currently dir only accepts a single node entry point. To complete the rest of your problem, we can write dirs that accepts an array of nodes representing multiple entry points to the graph -

    class Graph {
      //...
      *dirs(nodes) {
        for (const node of nodes) {
          yield* this.dir(node)
        }
      }
    }
    

    Now we call dirs with an array of entry points -

    for (const path of g.dirs(["B", "W"])) {
      console.log(path.join("/"))
    }
    
    B
    B/C
    B/D
    B/D/E
    W
    W/X
    W/X/Z
    W/Y
    

    Or again using path manipulation to collect all nodes reachable from B and W -

    console.log(Array.from(g.dirs(["B", "W"]), path => path.at(-1)))
    
    [ "B", "C", "D", "E", "W", "X", "Z", "Y" ]
    

    remarks

    Hopefully you can see writing dir in this way means we offload the hard choices to users of our program. Instead of trying to predict the needs of the traversal consumer, dir produces a path to each reachable node. Now each program that needs to traverse the graph can make distinct choices based on that program's unique context.

    And maybe you noticed in the dirs example that we only produced one path to Z even though multiple paths existed. This is a concern I will address in my next answer to this question.

    demo

    class Graph {
      constructor() {
        this.t = new Map()
      }
      addEdge(node1, node2) {
        const s = this.t.get(node1)
        if (s == null) this.t.set(node1, new Set([node2]))
        else s.add(node2)
      }
      getAdjacencies(node) {
        return this.t.get(node) ?? new Set()
      }
      *dir(node, path = Array(), visited = new Set()) {
        yield [...path, node]
        path.push(node)
        visited.add(node)
        for (const adj of this.getAdjacencies(node)) {
          if (!visited.has(adj)) {
            yield* this.dir(adj, path, visited)
          }
        }
        path.pop()
      }
      *dirs(nodes) {
        for (const node of nodes) {
          yield* this.dir(node)
        }
      }
    }
    
    const g = new Graph()
    g.addEdge("A", "B"); g.addEdge("B", "C"); g.addEdge("B", "D"); g.addEdge("D", "E"); g.addEdge("P", "Q"); g.addEdge("Q", "P"); g.addEdge("W", "X"); g.addEdge("W", "Y"); g.addEdge("X", "Z"); g.addEdge("Y", "Z"); g.addEdge("Z", "W")
    
    for (const path of g.dir("A")) {
      console.log(path.join("/"))
    }
    
    for (const path of g.dir("A")) {
      console.log(path.at(-1))
    }
    
    for (const path of g.dirs(["B", "W"])) {
      console.log(path.join("/"))
    }
    
    console.log(Array.from(g.dirs(["B", "W"]), path => path.at(-1)))
    .as-console-wrapper { min-height: 100%; top: 0 }

    typescript

    type Node = string
    
    class Graph {
      t: Map<Node, Set<Node>>
      constructor() {
        this.t = new Map()
      }
      addEdge(node1: Node, node2: Node): void {
        const s = this.t.get(node1)
        if (s == null) this.t.set(node1, new Set([node2]))
        else s.add(node2)
      }
      getAdjacencies(node: Node): Set<Node> {
        return this.t.get(node) ?? new Set()
      }
      *dir(
        node: Node,
        path = Array<Node>(),
        visited = new Set<Node>(),
      ): Generator<Node[]> {
        yield [...path, node]
        path.push(node)
        visited.add(node)
        for (const adj of this.getAdjacencies(node)) {
          if (!visited.has(adj)) {
            yield* this.dir(adj, path, visited)
          }
        }
        path.pop()
      }
      *dirs(nodes: Node[]): Generator<Node[]> {
        for (const node of nodes) {
          yield* this.dir(node)
        }
      }
    }