What is the difference between returning a function call vs only calling the function again during recursion?

Viewed 195

I am trying to implement a DFS and I do not understand the difference between calling a function inside itself (recursion) and returning the function call (also recursion?)
Snippet 1: Returning a function call (Wrong answer)
In this case, the code is not backtracking correctly.

const graph = {
    1: [2, 3],
    2: [4, 5],
    3: [1],
    4: [2, 6],
    5: [2, 6],
    6: [4, 5]
}

let visited = [];
const dfs = (node) => {
    if (visited.includes(node))
        return;
    console.log(node);
    visited.push(node);
    for (let i = 0; i < graph[node].length; i++) {
        if (!visited.includes(graph[node][i]))
            return dfs(graph[node][i])
    }
}

dfs(1);

Snippet 2: Only calling the function (Correct answer)
Seems to work okay

const graph = {
    1: [2, 3],
    2: [4, 5],
    3: [1],
    4: [2, 6],
    5: [2, 6],
    6: [4, 5]
}

let visited = [];
const dfs = (node) => {
    if (visited.includes(node))
        return;
    console.log(node);
    visited.push(node);
    for (let i = 0; i < graph[node].length; i++) {
        if (!visited.includes(graph[node][i]))
             dfs(graph[node][i])
    }
}

dfs(1);

What is the difference between the two? (I thought they'd be the same)
Is this some language specific (JS) thing or am I misunderstanding recursion?

4 Answers

When you return the "function call", you actually return the value that the function that is called yields. When you simply call a function recursively without returning it, you don't do anything with the return value. They are both cases of recursion, and they would work similarly when NOT in a loop.

In this case, since you are using the return value of the function within your for loop, once dfs(graph[node][i]) runs for the first time, and the function finishes executing by returning a value (or just finishes executing, like in this case) and exits the stack call, the for loop ends, and the function execution stops too.

You will experience fewer headaches if you write functions that avoid mutating external state and instead operate on the supplied arguments. Below we write dfs with three parameters

  1. t - the input tree, or graph
  2. i - the id to start the traversal
  3. s - the set of visited nodes, defaults to a new Set

function* dfs (t, i, s = new Set)
{ if (s.has(i)) return
  s.add(i)
  yield i
  for (const v of t[i] ?? [])
    yield* dfs(t, v, s)
}

const graph =
  { 1: [2, 3]
  , 2: [4, 5]
  , 3: [1]
  , 4: [2, 6]
  , 5: [2, 6]
  , 6: [4, 5]
  }
  
for (const node of dfs(graph, 1))
  console.log(node)

1
2
4
6
5
3

remarks

1. Your original dfs function has a console.log side effect — that is to say the main effect of our function is to traverse the graph and as a side (second) effect, it prints the nodes in the console. Separating these two effects is beneficial as it allows us to use the dfs function for any operation we wish to perform on the nodes, not only printing to the console -

dfs(1)  // <- traverse + console.log

Using a generator allows us to easily separate the depth-first traversal from the console printing -

for (const node of dfs(graph, 1)) // <- traverse effect
  console.log(node)               // <- print effect

The separation of effects makes it possible to reuse dfs in any way we need. Perhaps we don't want to print all of the nodes and instead collect them in an array to send them elsewhere -

const a = []
for (const node of dfs(graph, 1)) // <- traverse effect
  a.push(node)                    // <- array push effect
return a                          // <- return result

2. When we loop using an ordinary for statement, it requires intermediate state and more syntax boilerplate -

for (let i = 0; i < graph[node].length; i++)
  if (!visited.includes(graph[node][i]))
    dfs(graph[node][i])

Using for..of syntax (not to be confused with for..in) allows us to focus on the parts that matter. This does the exact same thing as the for loop above -

for (const child of graph[node])
  if (!visited.includes(child))
    dfs(child)

3. And using an Array to capture visited nodes is somewhat inefficient as Array#includes is a O(n) process -

const visited = []    // undefined
visited.push("x")     // 1
visited.includes("x") // true

Using a Set works almost identically, however it provides instant O(1) lookups -

const s = new Set   // undefined
s.add("x")          // Set { "x" }
s.has("x")          // true

Others have explained why return is short-circuiting your process.

But I would suggest that the main issue is that you are not really using that recursive function to return anything, but only relying on the the side effect (of printing to the console) inside the function. If you really want a traversal of your graph, it would be cleaner to write a function which returned an ordered collection of the nodes. The answer from Thankyou gives you one such function, using generator functions, as well as some valuable advice.

Here's another approach, which turns your (connected) graph into an array:

const dft = (graph, node, visited = new Set([node])) => [
  node,
  ... (graph [node] .flatMap (
    (n) => visited .has (n) ? [] : dft (graph, n, visited .add (n))
  )),
]

const graph = {1: [2, 3], 2: [4, 5], 3: [1], 4: [2, 6], 5: [2, 6], 6: [4, 5]}

console .log (dft (graph, 1)) //~> [1, 2, 4, 6, 5, 3]

We also use a Set rather than an array to track the visited status of nodes. We first visit the node supplied, and then for each node it connects to, we recursively visit that node if we haven't already marked it as visited. (I call this dft as it's a depth-first traversal, not a depth-first search.)

But please do carefully read the advice in Thankyou's answer. It's quite valuable.

In programming terms a recursive function can be defined as a routine that calls itself directly or indirectly.So in your example both would be considered recursion.

But when considering the fact that the recursion principle is based on the fact that a bigger problem is solved by re-using the solution of subset problem, then we would need those subset results to compute the big result. Without that return you will only get an undefined which is not helping you to solve your problem.

A quite easy example would be the factorial where fact(n) = n * fact(n-1)

function fact (n) {
 if (n===0 || n===1) return 1;
 else return n*fact(n-1);
}

As you can see on this example, fact(4) = 4 * fact(3) without the return, it will be undefined.

P.S: In your example not calling a return might work simply because we are not re-using the result of the subset

Related