- Understanding Recursion, Tail Call and Trampoline Optimizations
- By Thiery Michel
- The Nuggets translation Project
- Permanent link to this article: github.com/xitu/gold-m…
- Translator: Jessica
- Proofreader: PingHGao, Chorer
Understand recursion, tail-call optimization, and trampoline function optimization
To understand recursion, you must first understand recursion. Just kidding, recursion is a programming trick that allows a function to loop through a function that calls itself without using for or while.
Example 1: Sum of integers
For example, suppose we want to find the sum of integers from 1 to I. The goal is to get the following result:
sumIntegers(1); / / 1
sumIntegers(3); // 1 + 2 + 3 = 6
sumIntegers(5); // 1 + 2 + 3 + 4 + 5 = 15
Copy the code
This is code implemented without recursion:
/ / loop
const sumIntegers = i= > {
let sum = 0; / / initialization
do { / / repeat
sum += i; / / operation
i --; / / the next step
} while(i > 0); // Loop stop conditions
return sum;
}
Copy the code
The code for recursion is as follows:
/ / loop
const sumIntegers = (i, sum = 0) = > { / / initialization
if (i === 0) { //
return sum; / / the result
}
return sumIntegers( / / repeat
i - 1./ / the next step
sum + i / / operation
);
}
// Even simpler implementation
const sumIntegers = i= > {
if (i === 0) {
return i;
}
return i + sumIntegers(i - 1);
}
Copy the code
That’s the basis of recursion.
Notice that there are no intermediate variables in the recursive version. It doesn’t use for or do… The while. Thus, it is declarative.
I can also tell you that the recursive version is actually slower than the circular version — at least in JavaScript. But recursion solves not a performance problem, but a expressiveness problem.
Example 2: Sum of array elements
Let’s try a slightly more complicated example, a function that adds up all the numbers in an array.
sumArrayItems([]); / / 0
sumArrayItems([1.1.1]); // 1 + 1 + 1 = 3
sumArrayItems([3.6.1]); // 3 + 6 + 1 = 10
/ / loop
const sumArrayItems = list= > {
let result = 0;
for (var i = 0; i++; i <= list.length) {
result += list[i];
}
return result;
}
Copy the code
As you can see, the circular version is imperative: you need to tell the program exactly what to do to get the sum of all the numbers. Here is the recursive version:
/ / recursion
const sumArrayItems = list= > {
switch(list.length) {
case 0:
return 0; // The sum of the empty array is 0
case 1:
return list[0]; // The sum of an array of elements, which is the unique element. # Obvious
default:
return list[0] + sumArrayItems(list.slice(1)); // Otherwise, the sum of the array is the sum of the first element + the rest.}}Copy the code
In the recursive version, instead of telling the program what to do, we introduced simple rules that define the sum of all the numbers in the array. This is much more interesting than the circular version.
If you’re a fan of functional programming, you might prefer the array.reduce () version:
// Reduce version const sumArrayItems = list => list.reduce((sum, item) => sum + item, 0);Copy the code
It’s shorter and it’s more intuitive. But that’s a topic for another article.
Example 3: Quicksort
Now, let’s look at another example. This time it’s a little more complicated: quicksort. Quicksort is one of the fastest algorithms for sorting arrays.
The sorting process of quicksort: Get the first element of an array, then divide the rest of the elements into arrays smaller than the first element and arrays larger than the first element. The first element retrieved is then placed between the two arrays and repeated for each delimited array.
To implement it recursively, we simply follow this definition:
const quickSort = array= > {
if (array.length <= 1) {
return array; // An array with one or fewer elements is already sorted
}
const [first, ...rest] = array;
// Then separate all elements larger and smaller than the first element
const smaller = [], bigger = [];
for (var i = 0; i < rest.length; i++) {
const value = rest[i];
if (value < first) { / / small
smaller.push(value);
} else { / / bigbigger.push(value); }}// The sorted array is
return [
...quickSort(smaller), // The sorted array of all elements less than or equal to the first element
first, // The first element. quickSort(bigger),// The sorted array of all elements greater than the first
];
};
Copy the code
Simple, elegant and declarative, we can read the definition of quicksort by reading the code.
Now imagine doing it in a loop. I’ll let you think about it, and you’ll find the answer at the end of this article.
Example 4: Get a leaf node of a tree
Recursion is useful when we need to deal with recursive data structures such as trees. A tree is an object with certain values and child attributes; Children in turn contain other trees or leaves (leaves refer to objects without children). Such as:
const tree = {
name: 'root'.children: [{name: 'subtree1'.children: [{name: 'child1' },
{ name: 'child2'},],}, {name: 'child3' },
{
name: 'subtree2'.children: [{name: 'child1'.children: [{name: 'child4' },
{ name: 'child5'},],}, {name: 'child6'}]}]};Copy the code
Suppose I need a function that takes a tree and returns an array of leaves (objects with no child nodes). The expected results are:
getLeaves(tree);
/*[ { name: 'child1' }, { name: 'child2' }, { name: 'child3' }, { name: 'child4' }, { name: 'child5' }, { name: 'child6' }, ]*/
Copy the code
Let’s try it the old-fashioned way, not recursively.
// For trees without nesting, this is a piece of cake
const getChildren = tree= > tree.children;
// For recursion at one level, it becomes:
const getChildren = tree= > {
const { children } = tree;
let result = [];
for (var i = 0; i++; i < children.length - 1) {
const child = children[i];
if (child.children) {
for (var j = 0; j++; j < child.children.length - 1) {
constgrandChild = child.children[j]; result.push(grandChild); }}else{ result.push(child); }}return result;
}
// For two layers:
const getChildren = tree= > {
const { children } = tree;
let result = [];
for (var i = 0; i++; i < children.length - 1) {
const child = children[i];
if (child.children) {
for (var j = 0; j++; j < child.children.length - 1) {
const grandChild = child.children[j];
if (grandChild.children) {
for (var k = 0; k++; j < grandChild.children.length - 1) {
constgrandGrandChild = grandChild.children[j]; result.push(grandGrandChild); }}else{ result.push(grandChild); }}}else{ result.push(child); }}return result;
}
Copy the code
Well, this is already a headache, and it’s just two levels of recursion. Think about how bad it would be to recurse to the third, fourth, and tenth levels.
And this is just some leaves; What if you want to convert the tree to an array and return it? To make matters worse, if you want to use this version of the loop, you must determine the maximum depth you want to support.
Now look at the recursive version:
const getLeaves = tree= > {
if(! tree.children) {If a tree has no children, its leaves are the tree itself.
return tree;
}
return tree.children Otherwise its leaves are the leaves of all its children.
.map(getLeaves) // In this step, we can nest arrays ([grandChild1, [grandChild1, grandChild2],... )
.reduce((acc, item) = > acc.concat(item), []); / / so we use the concat to connect to smooth the array [1, 2, 3]. The concat (4) = > [1, 2, 3, 4] and [1, 2, 3]. The concat ([4]) = > [1, 2, 3, 4]
}
Copy the code
That’s all, and it works at any level of recursion.
Disadvantages of recursion in JavaScript
Unfortunately, recursive functions have one big drawback: damned out-of-bounds errors.
Uncaught RangeError: Maximum call stack size exceeded
Copy the code
Like many languages, JavaScript keeps track of all function calls in the stack. There is a maximum stack size, beyond which a RangeError will be caused. In a loop nested call, the stack is cleared once the root function completes. But with recursion, the first function call does not end until all other calls have been resolved. So if we call too many, we’re going to get this error.
To solve the stack size problem, you can try to ensure that calculations do not approach the stack size limit. Depending on the platform, the limit seems to be around 10,000. So, we can still use recursion in JavaScript, just be careful.
If you can’t limit the size of recursion, there are two solutions: tail-call optimization and trampoline function optimization.
Tail-call optimization
All languages that rely heavily on recursion use this optimization, such as Haskell. Support for tail-call optimization in JavaScript is implemented in Node.js V6.
A tail-call is when the last statement of a function is a call to another function. The optimization is to have the tail-calling function replace the parent function in the stack. That way, recursive functions don’t add to the stack. Note that for this to work, the recursive call must be the last statement of the recursive function. So the return loop (..) ; Is a valid tail-call optimization, but return loop() + v; It isn’t.
Let’s optimize the summation example with a tail call:
const sum = (array, result = 0) = > {
if(! array.length) {return result;
}
const [first, ...rest] = array;
return sum(rest, first + result);
}
Copy the code
This allows the runtime engine to avoid call stack errors. Unfortunately, it no longer works in Node.js because support for tail-call optimization was removed in Node 8. Maybe it will in the future, but so far, it doesn’t exist.
Trampoline function optimization
Another solution is called the trampoline function. The idea is to use deferred computation to perform recursive calls later, one recursion at a time. Let’s look at an example:
const sum = (array) = > {
const loop = (array, result = 0) = >() = > {// Instead of executing immediately, the code returns a function to be executed later: it is lazy
if(! array.length) {return result;
}
const [first, ...rest] = array;
return loop(rest, first + result);
};
// When we execute this loop, all we get is a function that performs the first step, so there is no recursion.
let recursion = loop(array);
// As long as we get another function, there are other steps in the recursion
while (typeof recursion === 'function') {
recursion = recursion(); // We perform the current step and then retrieve the next one
}
// Return the result of the last recursion
return recursion;
}
Copy the code
It works, but there’s a big downside to this approach: it’s slow. For each recursion, a new function is created, and for large recursions, a large number of functions are created. This is very upsetting. Sure, we won’t get an error, but it will slow down (or even freeze) the function.
From recursion to iteration
If you end up with performance or maximum call stack size overruns, you can still convert the recursive version to the iterative version. Unfortunately, as you’ll see, iterative versions are often more complex.
Let’s take the implementation of getLeaves as an example and turn the recursive logic into iteration. I know the result. I’ve tried it before, and it sucks. Now let’s try it again, but this time recursively.
// Recursive version
const getLeaves = tree= > {
if(! tree.children) {If a tree has no children, its leaves are the tree itself.
return tree;
}
return tree.children Otherwise its leaves are the leaves of all its children.
.map(getLeaves) // In this step, we can nest arrays ([grandChild1, [grandChild1, grandChild2],... )
.reduce((acc, item) = > acc.concat(item), []); / / so we use the concat to connect to smooth the array [1, 2, 3]. The concat (4) = > [1, 2, 3, 4] and [1, 2, 3]. The concat ([4]) = > [1, 2, 3, 4]
}
Copy the code
First, we need to refactor the recursive function to get the accumulator parameter, which will be used to construct the result. It would be even shorter to write:
const getLeaves = (tree, result = []) = > {
if(! tree.children) {return [...result, tree];
}
return tree.children
.reduce((acc, subTree) = > getLeaves(subTree, acc), result);
}
Copy the code
Then, the trick here is to expand the recursive call onto the stack of remaining calculations. Initializes the result accumulator outside the recursion and pushes the arguments into the recursive function onto the stack. Finally, the stacked operations are unstacked to get the final result:
const getLeaves = tree= > {
const stack = [tree]; // Add the initial tree to the stack
const result = []; // Initialize the result accumulator
while (stack.length) { // As long as there is one item in the stack
const currentTree = stack.pop(); // Get the first item in the stack
if(! currentTree.children) {If a tree has no children, its leaves are the tree itself.
result.unshift(currentTree); // So add it to the result
continue; } stack.push(... currentTree.children);// Otherwise, all children are added to the stack for processing in the next iteration
}
return result;
}
Copy the code
This seems a bit difficult, so let’s do it again with quickSort. Here’s the recursive version:
const quickSort = array= > {
if (array.length <= 1) {
return array; // An array with one or fewer elements is already sorted
}
const [first, ...rest] = array;
// Then separate all elements larger and smaller than the first element
const smaller = [], bigger = [];
for (var i = 0; i < rest.length; i++) {
const value = rest[i];
if (value < first) { / / small
smaller.push(value);
} else { / / bigbigger.push(value); }}// The sorted array is
return [
...quickSort(smaller), // The sorted array of all elements less than or equal to the first element
first, // The first element. quickSort(bigger),// The sorted array of all elements greater than the first
];
};
Copy the code
const quickSort = (array, result = []) = > {
if (array.length <= 1) {
return result.concat(array); // An array with one or fewer elements is already sorted
}
const [first, ...rest] = array;
// Then separate all elements larger and smaller than the first element
const smaller = [], bigger = [];
for (var i = 0; i < rest.length; i++) {
const value = rest[i];
if (value < first) { / / small
smaller.push(value);
} else { / / bigbigger.push(value); }}// The sorted array is
return [
...quickSort(smaller, result), // The sorted array of all elements less than or equal to the first element
first, // The first element. quickSort(bigger, result),// The sorted array of all elements greater than the first
];
};
Copy the code
The stack is then used to store the array for sorting, unstacking it in each loop by applying the previous recursive logic.
const quickSort = (array) = > {
const stack = [array]; // We create an array stack to sort
const sorted = [];
// We traverse the stack until it is empty
while (stack.length) {
const currentArray = stack.pop(); // We take the last array in the stack
if (currentArray.length == 1) { // If there is only one element, we add it to the sort
sorted.push(currentArray[0]);
continue;
}
const [first, ...rest] = currentArray; // Otherwise we take the first element in the array
// Then separate all elements larger and smaller than the first element
const smaller = [], bigger = [];
for (var i = 0; i < rest.length; i++) {
const value = rest[i];
if (value < first) { / / small
smaller.push(value);
} else { / / bigbigger.push(value); }}if (bigger.length) {
stack.push(bigger); // We sort by adding larger elements to the stack
}
stack.push([first]); // We add the first element to the stack, and when it is unheaped, the larger elements will already be sorted
if (smaller.length) {
stack.push(smaller); // Finally, we add smaller elements to the stack to sort}}return sorted;
}
Copy the code
Look! So we have an iterative version of quicksort. But remember, this is just an optimization,
Immature optimization is the root of all evil — Donald Gartner
Therefore, do this only when you need it.
conclusion
I like recursion. It is more declarative than the iterative version, and the code is generally shorter. Recursion can easily implement complex logic. Despite the stack overflow issue, it is fine to use in JavaScript without abusing it. And if necessary, you can refactor the recursive function into an iterative version.
So in spite of its shortcomings, I strongly recommend it to you!
If you like this pattern, look at functional programming languages like Scala or Haskell. They love recursion too!
If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.