Breadth First Search algorithm

*Prerequisite: Graph data structure.

This tutorial is a part of the Data Structures and Algorithms class:

Part 1 (09:37):

Part 2 (08:01):

Part 3 (10:59):

What is Breadth First Search algorithm?

  • A graph: holds the relationship between the nodes. (ex: relationship between airports, ...)
  • A node: holds data that describe something in real life. (ex: airport area, number of docks, ...)
  • Purpose of a graph algorithm: is to visit the nodes in a particular pattern, useful way.
  • Breadth First Search: search as broad as you can first. Then level by level.

Breadth First Search

Breadth First Search simplest code implementation

  • Breadth First Search (BFS): is a graph algorithm. Pattern of node visitation: level by level (onion).
  • BFS: visit all neighbor nodes before moving on to the next set of nodes. level by level (onion layers). Level 1: 1 move, level 2: 2 moves, level 3: 3 moves, ...
  • BFS usefulness: shortest path (equal edge), problem that visiting nodes layer after layer is useful.
  • Always use Queue to implement BFS algorithm.

bfs simple

lecture-26/breadth-first-search-pure.js
// Breadth First Search implementation in purest form

// relationship between nodes
const graph5 = [
  [1, 2], // SFO, index 0
  [0, 2, 3], // ORD, index 1
  [0, 1, 3, 4], // DEN, index 2
  [1, 2, 4], // JFK, index 3
  [2, 3], // IAH, index 4
]

// nodes' information
const idxToAirport = {
  '0': { name: 'SFO' },
  '1': { name: 'ORD' },
  '2': { name: 'DEN' },
  '3': { name: 'JFK' },
  '4': { name: 'IAH' }
}

const breadthFirstSearchPure = (initNodeIdx) => {
  const result = []
  const seen = new Set()
  const queue = [initNodeIdx] // REMEMBER to use a proper queue data structure IRL

  console.log('Starting airport: ', idxToAirport[initNodeIdx].name)

  while(queue.length > 0) {

    const curNodeIdx = queue.shift() // dequeue

    // if we've never seen this node before, process,
    // otherwise, ignore the node
    if(!seen.has(curNodeIdx)) {

      seen.add(curNodeIdx) // add this node to seen list

      const curNodeNeighbors = graph5[curNodeIdx] // get the neighbors
      curNodeNeighbors.forEach(nodeIdx =>
        !seen.has(nodeIdx) && queue.push(nodeIdx)) // enqueue neighbors
  
      result.push(idxToAirport[curNodeIdx].name) // save current node to result

      console.log('result: ', result)
    }
  }
}

breadthFirstSearchPure(0) // result: [ 'SFO', 'ORD', 'DEN', 'JFK', 'IAH' ]
$ node breadth-first-search-pure.js
Starting airport:  SFO
result:  [ 'SFO' ]
result:  [ 'SFO', 'ORD' ]
result:  [ 'SFO', 'ORD', 'DEN' ]
result:  [ 'SFO', 'ORD', 'DEN', 'JFK' ]
result:  [ 'SFO', 'ORD', 'DEN', 'JFK', 'IAH' ]

Typical problems solved by Breadth First Search algorithm

Identify the nodes, construction the graph, apply BFS node discovery pattern (level by level) to see if it solve your problem:

  1. Shortest path from point A to point B (ONLY if edges weight are equal -> BFS).
  2. Shortest path from point A to point B (if edges weight are NOT equal -> Bellman-Ford).
  3. Path finding: is there a possible path between two nodes? (BFS + DFS)
  4. Find common friends in social network.
  5. Network router: best package routing.
  6. Web crawler strategy.
  7. Graph intensive: cycle detection, is a graph bipartite, ...

Breadth First Search example 1: search with graph

network delay time

lecture-26/network-delay-time.js
// network delay time: https://leetcode.com/problems/network-delay-time/

var networkDelayTime = function(times, n, k) {

  // initialize graph with empty arrays
  const graph = []
  for(let i = 0; i < n; i++) { graph.push([]) }
  console.log('graph:', graph)

  // build adjacency list graph with `times` data
  for(let i = 0; i < times.length; i++) {
    const nodeIdx = times[i][0] - 1
    const neighborIdx = times[i][1] - 1
    const travelTime = times[i][2]
    graph[nodeIdx].push([neighborIdx, travelTime])
  }
  console.log('graph:', graph)
  

  const initNodeIdx = k - 1 // our graph is 0-indexed from given 1-indexed data
  const layerEnd = '|' // layer separator in the queue
  const queue = [initNodeIdx, layerEnd] // initialize the queue
  const seen = new Set([initNodeIdx]) // keep track of seen nodes
  let paths = [[0, initNodeIdx]] // the first num (0) is total travel time
  

  while(queue.length > 0) {
    // console.log('queue: ', queue)

    const curNodeIdx = queue.shift() // dequeue

    // add layer separator `|` whenever a layer finishes
    if(curNodeIdx === layerEnd && queue.length > 0) {
      queue.push(layerEnd)
      continue
    }

    // break out of the loop when there's no data left in the queue
    if(curNodeIdx === layerEnd && queue.length === 0) {
      break
    }

    const curNode = graph[curNodeIdx] // get current node neighbors
    const tempPaths = [] // update paths

    // for every neighbor
    for(let i = 0; i < curNode.length; i++) {
      const [neighborNodeIdx, neighborTravelTime] = curNode[i]

      // if this neighbor hasn't been seen
      if(!seen.has(neighborNodeIdx)) {
        seen.add(neighborNodeIdx) // add to seen list
        queue.push(neighborNodeIdx) // add to the queue

        // update path with this new neighor + travel time to this neighbor
        for(let j = 0; j < paths.length; j++) {
          if (paths[j][paths[j].length - 1] === curNodeIdx) {
            const updatePath = paths[j].concat(neighborNodeIdx)
            updatePath[0] += neighborTravelTime
            tempPaths.push(updatePath)
          } else {
            tempPaths.push(paths[j])
          }
        }
      }
    }
    
    tempPaths.length !== 0 && (paths = tempPaths)
    console.log('paths: ', paths)
  }

  // if we don't see all the nodes -> can't reach to every node -> return -1
  if(seen.size !== n) {
    return -1
  } else {
    let max = 0
    for(let i = 0; i < paths.length; i++) {
      if(paths[i][0] > max) max = paths[i][0]
    }
    return max
  }
}

console.log(networkDelayTime([[2,1,1],[2,3,1],[3,4,1]], 4, 2)) // 2
// console.log(networkDelayTime([[1,2,1],[2,3,2],[1,3,4]],3,1)) // 4
$ node network-delay-time.js
graph: [ [], [], [], [] ]
graph: [ [], [ [ 0, 1 ], [ 2, 1 ] ], [ [ 3, 1 ] ], [] ]
paths:  [ [ 1, 1, 0 ], [ 1, 1, 2 ] ]
paths:  [ [ 1, 1, 0 ], [ 1, 1, 2 ] ]
paths:  [ [ 1, 1, 0 ], [ 2, 1, 2, 3 ] ]
paths:  [ [ 1, 1, 0 ], [ 2, 1, 2, 3 ] ]
2

Breadth First Search example 2: search with matrix

  • The first path that reaches 77 is the shortest path. Why? because it takes the same number of moves (equal edges) for any other paths.

bfs on matrix

lecture-26/shortest-path-matrix.js
// BFS shortest path on matrix

const shortestPath = (map, start, end) => {
  
  matprint(map)
  console.log('starting position:', start)
  console.log('target position:', end)

  const queue = [start]
  const seen = new Set()
  let paths = [['' + start[0] + start[1]]] // initial start at `00`, [['00']]

  while(queue.length > 0) {

    console.log('queue:', queue)
    // console.log('paths:', paths)

    const [row, col] = queue.shift() // dequeue

    console.log('current node (row, col):', row, col)

    const strRowCol = '' + row + col // `00` etc.

    if(!seen.has(strRowCol)) { // if we've never seen this node before, process

      seen.add(strRowCol) // mark this node as seen
      
      const tempPaths = [] // path + new neighbor node will be temp saved here
      const seenNoNewNeiboPaths = new Set() // keep track of path has no new neighbor

      // list of 4 neighbors' indexes: left, right, up, down
      const neighborIdxes = [[row+1, col], [row-1, col], [row, col+1], [row, col-1]]

      // for each neighbor
      for(let i = 0; i < neighborIdxes.length; i++) {

        const [neighborRow, neighborCol] = neighborIdxes[i]
        const strNeighborRowCol = '' + neighborRow + neighborCol // `01` etc.

        if(
          (neighborRow < 8) && // if neighbor index is not out of
          (neighborRow > -1) && // matrix (0 <= index <= 7)
          (neighborCol < 8) &&
          (neighborCol > -1) &&
          map[neighborRow][neighborCol] !== 1 && // and movable to this neighbor
          !seen.has(strNeighborRowCol) // and we've never seen that neighbor before
        ) {

          queue.push([neighborRow, neighborCol]) // enqueue neighbor node

          // update path with this neighor. AKA paths [[00]] -> paths [[00, 01]]
          for(let j = 0; j < paths.length; j++) {

            if (paths[j][paths[j].length - 1] === strRowCol) { // continue path
              const updatePath = paths[j].concat(strNeighborRowCol)
              tempPaths.push(updatePath)

              if(strNeighborRowCol === '77') { // first path reaches target
                matprint(map, updatePath)
                return updatePath // return this path and exit
              }
            } else { // only save to temp path once, path with no new neighbor
              !seenNoNewNeiboPaths.has(paths[j][paths[j].length - 1]) &&
                tempPaths.push(paths[j])
              seenNoNewNeiboPaths.add(paths[j][paths[j].length - 1])
            }
          }
         
        }
      }

      // update paths with tempPaths
      tempPaths.length !== 0 && (paths = tempPaths)
    }

    console.log('======================= new queue item ====')
  }

  // console.log('paths: ', paths)

  // for(let i = 0; i < paths.length; i++) {
  //   matprint(map, paths[i])
  //   console.log('=====================================')
  // }
}

const map = [
  [0, 0, 0, 0, 1, 0, 1, 0],
  [1, 0, 1, 0, 0, 0, 0, 0],
  [0, 0, 0, 1, 1, 1, 0, 0],
  [0, 0, 0, 0, 0, 1, 0, 1],
  [0, 1, 1, 1, 0, 1, 0, 0],
  [0, 0, 0, 0, 0, 1, 0, 1],
  [0, 1, 1, 1, 1, 1, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0],
]

console.log(shortestPath(map, [0,0], [7,7]))




/**
 * Pretty print a matrix helper function
 * https://gist.github.com/lbn/3d6963731261f76330af
 * @param {*} mat 
 */
function matprint(mat, path = []) {
  let shape = [mat.length, mat[0].length];
  function col(mat, i) {
      return mat.map(row => row[i]);
  }
  let colMaxes = [];
  for (let i = 0; i < shape[1]; i++) {
      colMaxes.push(Math.max.apply(null, col(mat, i).map(n => n.toString().length)));
  }

  mat.forEach((row, rowIdx) => {
    console.log.apply(null, row.map((val, j) => {
      if (val === 0) val = '-';
      if(path.length != 0) {
        if(path.includes('' + rowIdx + j)) val = '*'
      }
      if (rowIdx === 0 && j == 0) val = 'S';
      if (rowIdx === 7 && j == 7) val = '⭐';
      return new Array(colMaxes[j]-val.toString().length+1).join(" ") + val.toString() + "  ";
    }));
  });
}
$ node shortest-path-matrix.js
S   -   -   -   1   -   1   -  
1   -   1   -   -   -   -   -  
-   -   -   1   1   1   -   -  
-   -   -   -   -   1   -   1  
-   1   1   1   -   1   -   -  
-   -   -   -   -   1   -   1  
-   1   1   1   1   1   -   -  
-   -   -   -   -   -   -   ⭐  
starting position: [ 0, 0 ]
target position: [ 7, 7 ]
queue: [ [ 0, 0 ] ]
current node (row, col): 0 0
======================= new queue item ====
queue: [ [ 0, 1 ] ]
current node (row, col): 0 1

...

======================= new queue item ====
queue: [ [ 7, 6 ], [ 6, 7 ], [ 7, 5 ] ]
current node (row, col): 7 6
S   *   *   *   1   -   1   -  
1   -   1   *   *   *   *   -  
-   -   -   1   1   1   *   -  
-   -   -   -   -   1   *   1  
-   1   1   1   -   1   *   -  
-   -   -   -   -   1   *   1  
-   1   1   1   1   1   *   -  
-   -   -   -   -   -   *   ⭐  
[
  '00', '01', '02', '03',
  '13', '14', '15', '16',
  '26', '36', '46', '56',
  '66', '76', '77'
]
  1. Build graph & nodes data from given data.
  2. Shortest path with equal edge weight -> BFS.
  3. Examine the data with BFS pattern (level by level) to see if it solves your problem.

Summary

Prob. class P. sub-class Input characteristics Algorithm Time
Complexity
(almost) ANY problem class array, matrix, ... Brute Force O(n) & up
Can try with any problem type - pass split test
- array, string or graph, ...
- divide, solve, combine gives a possible solution
Divide and Conquer O(nlogn)
- accessing 2 data points at a time is useful
- closing in the pointers proven to give a correct answer
- usually array or string
Two Pointers O(n)
1. Maximization
2. Minimization
array, string or graph, ... Greedy choice,
no guarantee for most optimal solution
varies
3. Find all options array, matrix, ... Brute Force O(n) & up
4. Find most optimal solution
5. Find a truth search for a number - sorted array
or balanced BST
Binary Search O(logn)
accessing a few consecutive data points at the same time is necessary/useful - usually array or string Slding Window O(n)
- shortest path, equal edge weight
- node visitation layer by layer
- graph or matrix Breadth First Search O(n)

Real life interview questions

  1. How does Breadth First Search solve problems?
  2. What kind of problems can typically be solved by Breadth First Search?
  3. Recalculate the Complexity of the three problems above.
  4. Redo the three problems above. You should be able to quickly explain how the algorithm works and implement each of them within 15 mins.
  5. From BFS matrix example above, construct a graph with adjacency list, then starting from 00, print layer by layer with | as layer separator.
  6. (Easy) Leetcode #733. Flood Fill
  7. (Medium) Leetcode #695. Max Area of Island
  8. (Medium) Leetcode #200. Number of Islands
  9. (Medium) Leetcode #1267. Count Servers that Communicate
  10. (Medium) Leetcode #529. Minesweeper
  11. (Medium) Leetcode #743. Network Delay Time
  12. More on Leetcode #breadth-first-search.