Recursion
When coding, one of the things we can often use is recursion. I’ve mentioned before fibonacci numbers, but there are quite a few algorithms that require recursion. One of them is QuickSort, but we’ll find a bunch of them in here.
Recursion means defining a problem in terms of itself. Now, if you’re like me, you probably have no idea what that means.
So, let’s use an example to explore it:
const recursion = () => recursion();
recursion();
If you’d run to above program, you’ll get an error saying
Uncaught RangeError: Maximum call stack size exceeded
But that’s okay! Because it’s pretty much the same as calling an infinite loop, except individual calls are added onto Call Stack.
At some point, the computer decides that this is getting anywhere and it’s wasting resources, so it’s gonna kill the program. That’s not exactly what happens, as call stack has a limit and it’s reached, but this is enough explanation to give an idea.
To deal with this, we’d need to define a so-called base case.
Base case in recursion is the point when your code hits the bottom. Consider the following code:
const fib = (n) => {
return fib(n - 1) + fib(n - 2)
}
In the above fibonacci calculator, there is no base case. There is no point when we say “Hey, stop right here, this is enough”. Without base case, our program would keep running until we received the error above. So, let’s fix that!
We know that for the 0th and 1st fibonacci number, we need to return the number 1. So, let’s do that!
const fib = (n) => {
if (n === 0 || n === 1) return 1;
return fib(n - 1) + fib(n - 2)
}
The first line of code inside the function is the base case. It is the point where you no longer call recursion. If you don’t define base case, you’ll kill your program.
So, with that in our mind, we can now use recursion. Some every day example could be flattening an array:
const flatten = (arr: number[], result = []) => {
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) flatten(arr[i], result);
else result.push(arr[i]);
}
return result;
};
const arr = [1, 2, 3, [4, 5, 6], 7, 8, 9, [10, [11, [12], 13, [14, 15]], 16]];
const result = []
flatten(arr, result)
console.log(result) // [1,2,3,4,5,...,16]
With the above code, we’re able to flatten an array using recursion! The base case is when Array.isArray == false
. If the element is not an array,
we’ll not call the flatten
function and just perform the operations.
One thing to note is that we can use recursion everywhere. Is it a good idea? Probably not. But it certainly is an idea.
This section is mostly to understand that we can do that. Consider that you have a task to get a sum of letters in an array. Well, what you’d probably do is:
const arr = ['ab', 'c', 'def', 'ghij']
const getNumberOfChars = (arr) => arr.reduce((x,y) => x + y.length, 0)
console.log(getNumberOfChars(arr)) // 10
Now, how would we do that with recursion? Well, we need to work with the strings from the inside!
const getNumberOfChars = (stringOrArray) => {
if (stringOrArray.length === 0) return 0;
return stringOrArray[0].length + one(stringOrArray.slice(1))
}
Is it simpler than above? No. But we can use it. The benefit of this is that sometimes, it’s easier to write recursive code for algorithms, sometimes it is not, but you can use it for almost anything.
To showcase one where it’s actually way easier to use recursion, let’s consider the following setup:
Have a grid of rows and columns Write a function that accepts number of rows and number of columns Function calculates the number of paths from top-left to bottom-right The only permitted moves are left and right
Now, to write this code without recursion, let’s do just that:
But when you think about it a little more, you’ll find that the “have a grid” means nothing. It’s actually just a clever wordplay. You’d probably end up with something like:
function getPaths(cols,rows,result = 0) {
const arr = Array.from({ length: cols }, _ => Array.from({ length: rows}).fill(false));
if (cols == 1 && rows == 1) {
result++ // increment when bottom case is it
return results;
}
if (cols > 1) result = five(cols - 1, rows, result);
if (rows > 1) result = five(cols, rows - 1, result);
return result;
}
The reason why the array doesn’t make sense there is:
Eventually, after inspecting it, you’ll find out 2 things:
So, you’d get somewhere like:
function getPaths(cols,rows,) {
let res = 0;
if (cols == 1 && rows == 1) {
return 1;
}
if (cols > 1) res += five(cols - 1, rows);
if (rows > 1) res += five(cols, rows - 1);
return res;
}
So that looks better. But now we’ll notice a strange thing! Because the moment you hit an edge (col = 1 OR row = 1), it’s the same as if both hit the edge (you got bottom right). Why? Well, because at that point, there’s only one way forward
So, finally, the initial condition will be cols == 1 || rows == 1
. Which is the opposite of the next 2 checks. So, we’ll simplify it to:
function getPaths(cols, rows) {
let res = 0;
if (cols == 1 || rows == 1) {
return 1;
}
res += five(cols - 1, rows);
res += five(cols, rows - 1);
return res;
}
And finally, now we can see what’s up. We can completely remove the res
.
function getPaths(cols, rows) {
if (rows === 1 || cols === 1) return 1;
return five2(rows - 1, cols) + five2(rows, cols - 1);
}
function getPaths(cols,rows) {
if (rows === 1 || cols === 1) return 1;
return five2(rows - 1, cols) + five2(rows, cols - 1)
}
Now, go back up and see what changed. Instead of a long function, we’ve managed to simplify it to 2 lines of code by throwing away the unneeded stuff.
And this is where recursion especially shines. Either traversing or clever play with indices.
So, we’ve seen a bunch of examples. Recursion can be really useful and potentially fast. But let’s rewind a bit.
I’ve mentioned fibonacci sequence before. Let’s revisit that code:
const fib = (n) => {
if (n === 0 || n === 1) return 1;
return fib(n - 1) + fib(n - 2)
}
A simple function that adds two numbers, right? Now, let’s look at the complexity. Consider you pass the number 5 to it.
Great. We got it. But let’s try to put it into a single line!
You get the idea. Upon inspection, we’ll find that we are calling a lot of unnecessary functions - or at least those we’ve already called.
To make this faster, we’ll use a technique called memoization. Basically, it’s caching already calculated results. Consider the following:
const cache = {};
const fib = (n) => {
if (cache[n]) return cache[n];
if (n === 0 || n === 1) {
cache[n] = 1
return 1;
}
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]
}
Then to simplify it:
const cache = {};
const fib = (n) => {
if (n === 0 || n === 1) return 1
if (cache[n]) return cache[n]
return cache[n] = fib(n - 1) + fib(n - 2)
}
Now, here we’ve actually used 2 things so far! A hash map and recursion! And we’ve sped it up a lot! Because now, once it’s already calculated any fibonacci number below, it’ll be cached and all subsequent calls will be fast!
So, we understand recursion now. We also understand memoization. Now, let’s try to speed some things up!
In this part, we’ll go through 2 parts:
So, let’s get started with it!
I’m going to start with Quickselect, because it’s easier. Now, why would we need it?
We’ve already talked about sorted and unsorted arrays. Imagine you need to retrieve the 7th lowest number in an array. Well, that’s easy!
Now, let me first establish some terminology, because this is not searching for 7th lowest element, but rather selecting 7th lowest element.
You could argue that selecting is a form of search. That would be true with indexes. To give another example:
Now, in this case, they’d both be doing the same thing, but only because the item is same.
If that wasn’t sufficient, I invite you to read through this SO post
And without further ado, let’s get to it.
As mentioned, we want to select nth element from an array. To do so, we’ll be using 2 things:
The partition part will be used also in quicksort, so do try to keep up! In the simplest terms, it does the following:
Now, once you’ve done that, you should have the element on its position within the array. So, if the first element you pull is the 14th lowest element, then it will be on the 14th position. If it’s 100th lowest element, it will be on 100th position.
And since you know that the lower elements are on the left and larger on the right, you know which way to go. So, you then perform the same code
So this is kinda similar with binary search in a way. So, if we reiterate on the 2 steps before:
But how do we put smaller elements on the left and larger on the right?
Now, consider that we have an array [7,10,4,2,3,20,15]
and we’re looking for the 2nd smallest element
In there, if we pivot around the number 15
, we’ll find that:
Now, that’s good, we got 15
to its correct position. But we had to swap only 2 numbers. Now, let’s do it again:
[7,10,4,2,3,15,20]
3
is our pivot numberSo, let’s apply it:
After doing that, we’re left with 2, 3, 4, 7, 10
. The entire array now is 2,3,4,7,10,15,20
.
So, a little recap in here:
So, let’s write code for the partitioning!
function partition(arr, leftPointer = 0, rightPointer = arr.length - 1) {
const pivot = arr[rightPointer];
let pivotIndex = leftPointer;
for (let i = 0; i < arr.length; i++) {
if (arr[i] <= pivot) {
console.log(i)
console.log(arr)
swapArrayElements(arr, pivotIndex, i)
pivotIndex++
}
}
return pivotIndex
}
In the above code, we’ve done the following:
If we run the function once for [7,10,4,2,3]
, the result will be 2
.
2, 3, 4, 7, 10
So, how do we combine this with the selection part? Well, we need to do it multiple times. So let’s do that!
const quickSelect = (arr, nthLowestNumber, leftPointer = 0, rightPointer = arr.length - 1) => {
if (leftPointer === rightPointer) return arr[leftPointer];
const pivotIndex = partition(arr, leftPointer, rightPointer)
if (pivotIndex === nthLowestNumber) return nthLowestNumber;
if (nthLowestNumber > pivotIndex) return quickSelect(arr, nthLowestNumber, pivotIndex + 1, rightPointer)
if (nthLowestNumber < pivotIndex) return quickSelect(arr, nthLowestNumber, leftPointer, pivotIndex - 1)
return arr[pivotIndex];
}
function partition(arr, leftPointer, rightPointer) {
const pivot = arr[rightPointer];
let pivotIndex = leftPointer;
for (let i = leftPointer; i < arr.length; i++) {
if (arr[i] <= pivot) {
swapArrayElements(arr, pivotIndex, i)
pivotIndex++
}
}
return pivotIndex - 1 // <--- NOTE THIS
}
In the quickselect now, we basically do:
pivotIndex + 1
until end
start
to pivotIndex - 1
Note that I’ve mentioned before the pivotIndex
in partition was the position. To retrieve the index, we’ll remove it once again
for
loop:// previously
if (arr[i] <= pivot) {
swapArrayElements(arr, pivotIndex, i)
pivotIndex++
}
// later
if (arr[i] <= pivot) {
swapArrayElements(arr, pivotIndex, i)
}
if (arr[i] < pivot) {
pivotIndex++
}
And that’s it! That’s basically partition and quick select!
As I mentioned, both quicksort and quickselect use partitions. If you understood quickselect, you’ll surely understand quicksort. Because the only difference where you call it.
So, quicksort would use exactly the same code as quickselect, except the “selecting” function would be sort:
function quickSort(array, leftPointer, rightPointer) {
if (rightPointer - leftPointer <= 0) return
const pivotIndex = partition(array, 0, rightPointer);
quickSort(array, leftPointer, pivotIndex - 1);
quickSort(array, pivotIndex + 1, rightPointer);
}
And that’s it! You’ve now learned quicksort and quickselect! Congratulations!
That was a wild ride! In this case, I’ve gone through a sorting and selecting algorithm.
While sorting is a lot done under the hood by the languages we use, it’s still nice to take a look at how it’s actually done. It gives us more knowledge about train of thought and how we can leverage it.