In one of the previous assignments, we learned how to traverse a directed acyclic graph.
However, what if we have a directed cyclic or undirected graph? Can we do the same thing as before? Let's see.
// Implement a function `dfs` that accepts two arguments: an adjacency
// list representing an undirected graph, and a starting vertex (source).
// The function should print the vertices in preorder depth-first
// traversal order.
function dfs(adjList, source) {
// implementation goes here
}
const adjList = new Map();
adjList.set(1, [2]);
adjList.set(2, [1, 3]);
adjList.set(3, [2]);
dfs(adjList, 1); // 1, 2, 3
Let's start by looking at what would happen if we used the recursive dfs
function from the Graph Traversal Part I assignment:
function dfs(adjList, source) {
console.log(source);
let neighbors = adjList.get(source);
for (let neighbor of neighbors) {
dfs(adjList, neighbor);
}
}
Let's do a step-by-step visual walkthrough using the graph below as an example:
Step 1: In the first call to dfs
, we pass adjList
and 1
as arguments, logging the value 1
. With each call to dfs
, we get a new list of neighbors we need to process. To help keep track of our progress, we'll write the neighbors next to our call stack in orange. Once we finish processing all of the neighbors for a given call, we've finished that call and can pop it from the stack.
For vertex 1
, we have vertex 2
as our neighbor that needs processing. We invoke dfs
recursively, passing adjList
and 2
, the first neighbor. This adds another call to the stack.
Step 2: In our new invocation of dfs
, we've passed adjList
and 2
as arguments, logging the value 2
. After that, we call dfs
recursively again, passing adjList
and the first neighbor vertex of 2
, vertex 1
.
Step 3: In the third call to dfs
, we pass adjList
and 1
as arguments, logging the value 1
. Then, we call dfs
again, passing adjList
and the first neighbor vertex of 1
, which is vertex 2
.
Are you seeing the problem? We are continuously alternating between calls to dfs
with the second argument being 1
and 2
. This back-and-forth traversal will ultimately cause a stack overflow.
So what is the solution? We need a way to track the vertices we've visited, and one convenient way to do this is by using a set.
We'll create a set called visited
to track the visited vertices, allowing us to check in constant time whether a vertex has already been visited. If it has, we won't call the dfs
function with that vertex as the second argument again.
Let's do another walkthrough, now with a set:
Step 1: In the first call to dfs
, we pass adjList
and 1
as arguments, logging the value 1
. Then, we add vertex 1
to a set visited
and call the dfs
function, passing adjList
and the first neighbor vertex of 1
, which is 2
.
Step 2: In the second call to dfs
, we log the value 2
and add vertex 2
to the set visited
. After that, since vertex 1
is the first neighbor but is already in the set, we skip it and call dfs
passing adjList
and the second neighbor of vertex 2
, which is vertex 3
.
Step 3: In the third call to dfs
, we log the value 3
and add it to the visited
set. After that, since vertex 2
is the first neighbor but is already in the set, we skip it. There aren't any more neighbors, so this function returns undefined
and we pop it from the stack.
Step 4: We are back in the second function call. We already checked the first neighbor, so we can continue on to neighbor 3
. 3
is present in visited
, meaning that all of our neighbors have been processed, and we can pop this function call from the stack.
Step 5: Finally, back in the first function call, we have visited the only neighbor, so our function returns undefined
and we can pop it from the stack.
function dfs(adjList, source, visited = new Set()) {
console.log(source);
visited.add(source);
let neighbors = adjList.get(source);
for (let neighbor of neighbors) {
if (!visited.has(neighbor)) {
dfs(adjList, neighbor, visited);
}
}
}
Try implementing a solution using a stack data structure and a visited
set.
function dfs(adjList, source) {
let stack = [source];
let visited = new Set([source]);
while (stack.length !== 0) {
let current = stack.pop();
console.log(current);
let neighbors = adjList.get(current);
for (let neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
stack.push(neighbor);
}
}
}
}
Now, try refactoring the breadth-first search solution using a visited
set on your own.
// Implement a function `bfs` that accepts two arguments: the adjacency list
// representing an undirected graph and a starting vertex (source).
// The function should print the vertices in breadth-first
// traversal order.
function bfs(adjList, source) {
// implementation goes here
}
const adjList = new Map();
adjList.set(1, [2, 3]);
adjList.set(2, [1, 4]);
adjList.set(3, [1, 4, 5]);
adjList.set(4, [2, 3]);
adjList.set(5, [3, 6]);
adjList.set(6, [5]);
console.log(bfs(adjList, 1)); // 1, 2, 3, 4, 5, 6 or 1, 3, 2, 5, 4, 6
function bfs(adjList, source) {
let queue = [source];
let visited = new Set([source]);
while (queue.length !== 0) {
let current = queue.shift();
console.log(current);
let neighbors = adjList.get(current);
for (let neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
}