JavaScript: Recursion
Base cases, self-calls, and when to use a loop instead.
13 exercises
1 Factorial of a number
easyMultiply every whole number from 1 up to n. So 5! is 5 × 4 × 3 × 2 × 1, which is 120. Then find where the answer stops being trustworthy.
Stuck? Three hints, smallest first
- 1. The base case is
n <= 1returning1, notn === 1, because0!is also 1. - 2. A negative input has no factorial. Refuse it rather than recursing forever.
- 3. Factorials outgrow
Numberfast.BigIntis the fix, and it needs thensuffix on literals.
function factorial(n) {
if (!Number.isInteger(n) || n < 0) throw new RangeError('factorial needs a whole number 0 or above');
if (n <= 1) return 1; // covers 0! and 1!, both of which are 1
return n * factorial(n - 1);
}
// The loop version. Same answers, no stack frames.
function factorialLoop(n) {
let total = 1;
for (let i = 2; i <= n; i++) total *= i;
return total;
}
// Exact for any size, because BigInt does not round.
function factorialBig(n) {
return n <= 1n ? 1n : n * factorialBig(n - 1n);
}
const refused = (() => { try { factorial(-1); return 'no error'; } catch (e) { return e.constructor.name; } })();
console.log('0! =', factorial(0), ' 5! =', factorial(5), ' 10! =', factorial(10));
console.log('18! =', factorial(18), '<- still a safe integer');
console.log('19! =', factorial(19), '<- past 2^53, so ±1 stops meaning anything');
console.log('20! + 1 === 20! ?', factorial(20) + 1 === factorial(20));
console.log('23! as a Number:', factorial(23));
console.log('23! as a BigInt:', factorialBig(23n).toString(), '<- the true answer');
console.log('negative input :', refused);
Output
12 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('0! is 1', factorial(0), 1);
test('1! is 1', factorial(1), 1);
test('5! is 120', factorial(5), 120);
test('10! is 3628800', factorial(10), 3628800);
test('the loop agrees for 0 to 15', Array.from({ length: 16 }, (_, i) => factorial(i) === factorialLoop(i)).every(Boolean), true);
test('a negative input is refused', refused, 'RangeError');
test('18! is the last safe integer factorial', Number.isSafeInteger(factorial(18)), true);
test('19! is already unsafe', Number.isSafeInteger(factorial(19)), false);
test('so adding 1 to 20! changes nothing', factorial(20) + 1 === factorial(20), true);
test('22! is still exactly representable', BigInt(factorial(22)) === factorialBig(22n), true);
test('23! is the first one that is simply wrong', BigInt(factorial(23)) === factorialBig(23n), false);
test('BigInt gets 23! right', factorialBig(23n).toString(), '25852016738884976640000');
Two different limits are at work, and mixing them up is common. Number.MAX_SAFE_INTEGER is 2^53 - 1, and 18! is the last factorial below it — past that, + 1 silently does nothing. But exact representability reaches further: a factorial has many factors of 2, so it ends in many binary zeros, and 22! is still stored exactly. 23! is the first factorial a Number gets wrong. Neither number is guessable; both assertions above check it.
Answers JS Recursion 1 in the original set.
2 Greatest common divisor
easyFind the largest number that divides two numbers with no remainder, using recursion.
Stuck? Three hints, smallest first
- 1. Euclid: the GCD of
aandbequals the GCD ofbanda % b. - 2. The base case is
b === 0, and the answer is thena. - 3. This is tail recursion, so the loop version is a two-line rewrite.
function gcd(a, b) {
return b === 0 ? Math.abs(a) : gcd(b, a % b);
}
// The same steps as a loop. Nothing is lost, because the recursive call is the
// last thing that happens (tail recursion).
function gcdLoop(a, b) {
while (b !== 0) [a, b] = [b, a % b];
return Math.abs(a);
}
// GCD gives you LCM for free. Divide first, so the product cannot overflow.
const lcm = (a, b) => Math.abs(a / gcd(a, b) * b);
// Extends to a whole list by folding.
const gcdAll = (nums) => nums.reduce(gcd);
for (const [a, b] of [[48, 18], [17, 5], [270, 192], [0, 5]]) {
console.log(`gcd(${a}, ${b}) =`, gcd(a, b), ' lcm =', lcm(a, b));
}
console.log('gcd of the whole list:', gcdAll([24, 36, 60]));
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a shared factor is found', gcd(48, 18), 6);
test('coprime numbers give 1', gcd(17, 5), 1);
test('a bigger pair', gcd(270, 192), 6);
test('order does not matter', gcd(18, 48), gcd(48, 18));
test('gcd with 0 is the other number', gcd(0, 5), 5);
test('and the other way round', gcd(5, 0), 5);
test('negatives give a positive divisor', gcd(-48, 18), 6);
test('the loop agrees', gcdLoop(270, 192), gcd(270, 192));
test('lcm of 4 and 6 is 12', lcm(4, 6), 12);
test('gcd times lcm equals the product', gcd(48, 18) * lcm(48, 18), 48 * 18);
test('a whole list folds', gcdAll([24, 36, 60]), 12);
gcd(a, b) === gcd(b, a % b) shrinks the problem fast — gcd(270, 192) finishes in five steps. The Math.abs matters because % in JavaScript keeps the sign of the left operand, so -48 % 18 is -12 and a negative could fall out of the base case. In lcm, dividing before multiplying keeps the intermediate value small; a * b / gcd gives the same answer but can overflow on the way.
Answers JS Recursion 2 in the original set.
3 The integers between two numbers
easyReturn the whole numbers strictly between x and y. The source is explicit: range(2, 9) gives [3, 4, 5, 6, 7, 8].
Stuck? Three hints, smallest first
- 1. Read the expected output carefully: both ends are left out. That is unusual.
- 2. The base case returns an empty array, and spreading an empty array adds nothing.
- 3.
[x + 1, ...range(x + 1, y)]builds it from the front.
// Both ends excluded: range(2, 9) is [3..8], matching the source's example.
function range(x, y) {
if (x + 1 >= y) return []; // nothing in between
return [x + 1, ...range(x + 1, y)];
}
// The inclusive version most other languages mean by "range".
const rangeInclusive = (x, y) => (x > y ? [] : [x, ...rangeInclusive(x + 1, y)]);
// No recursion at all, for comparison.
const rangeBuiltIn = (x, y) => Array.from({ length: Math.max(0, y - x - 1) }, (_, i) => x + 1 + i);
console.log('range(2, 9) =', range(2, 9));
console.log('rangeInclusive(2, 9) =', rangeInclusive(2, 9));
console.log('range(2, 3) =', range(2, 3), '<- adjacent, so nothing between');
console.log('range(9, 2) =', range(9, 2), '<- backwards, so nothing at all');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's example", range(2, 9), [3, 4, 5, 6, 7, 8]);
test('neither end is included', range(2, 9).includes(2) || range(2, 9).includes(9), false);
test('adjacent numbers give nothing', range(2, 3), []);
test('equal numbers give nothing', range(5, 5), []);
test('backwards gives nothing, not an endless loop', range(9, 2), []);
test('one number between', range(4, 6), [5]);
test('the built-in version agrees', rangeBuiltIn(2, 9), range(2, 9));
test('the inclusive version keeps both ends', rangeInclusive(2, 9), [2, 3, 4, 5, 6, 7, 8, 9]);
test('and is two longer', rangeInclusive(2, 9).length - range(2, 9).length, 2);
The interesting part of this one is reading the specification instead of the name. "Range" almost everywhere else includes at least the start — Python's range(2, 9) is 2..8 — but the source's stated output excludes both ends, so that is what the assertions check. The x + 1 >= y guard is also doing double duty: it ends the recursion normally and it stops a backwards call from recursing forever.
Answers JS Recursion 3 in the original set.
4 Sum of an array
easyAdd up an array of numbers recursively. Then look at what the tidy one-liner costs.
Stuck? Three hints, smallest first
- 1. The base case is the empty array returning 0, which makes every other case work.
- 2.
[first, ...rest]is elegant but copies the array at every level. - 3. Passing an index instead of a smaller array does no copying at all.
// The version everyone writes first. Readable, and quietly O(n^2) on memory:
// each call allocates a fresh `rest` array.
function sumCopying(values) {
if (values.length === 0) return 0; // empty is the real base case
const [first, ...rest] = values;
return first + sumCopying(rest);
}
// Same recursion, no copying: walk an index instead of shrinking the array.
function sumIndexed(values, i = 0) {
if (i >= values.length) return 0;
return values[i] + sumIndexed(values, i + 1);
}
// Count the arrays each version allocates, to make the difference visible.
let copies = 0;
function sumCounted(values) {
if (values.length === 0) return 0;
const [first, ...rest] = values;
copies++;
return first + sumCounted(rest);
}
sumCounted([1, 2, 3, 4, 5, 6]);
const nums = [1, 2, 3, 4, 5, 6];
console.log('sumCopying :', sumCopying(nums));
console.log('sumIndexed :', sumIndexed(nums));
console.log('reduce :', nums.reduce((a, b) => a + b, 0));
console.log('arrays allocated for 6 items:', copies);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's example sums to 21", sumCopying(nums), 21);
test('the indexed version agrees', sumIndexed(nums), 21);
test('reduce agrees', nums.reduce((a, b) => a + b, 0), 21);
test('an empty array sums to 0', sumCopying([]), 0);
test('one element', sumCopying([7]), 7);
test('negatives subtract', sumCopying([5, -3, 1]), 3);
test('the copying version allocates one array per element', copies, 6);
test('the input is not modified', nums, [1, 2, 3, 4, 5, 6]);
test('decimals warn you about floating point', sumCopying([0.1, 0.2]) === 0.3, false);
reduce((a, b) => a + b, 0) is the answer you would ship. The recursion is worth writing once because it shows where the base case belongs: on the empty array, not on the one-element array. Get that right and the empty input works for free. The last assertion is a reminder that carries across the whole course — 0.1 + 0.2 is not 0.3 in any language using binary floating point, so money belongs in kobo, not naira.
Answers JS Recursion 4 in the original set.
5 Raise a number to a power
fairCompute base ** exponent by recursion. Then halve the work using the fact that x^8 is (x^4)^2.
Stuck? Three hints, smallest first
- 1. Anything to the power 0 is 1, and that is your base case.
- 2. A negative exponent is
1 / power(base, -exponent). - 3. For the fast version: if the exponent is even, square the half-power; if odd, peel one factor off.
// Straightforward: n multiplications, so n stack frames.
function power(base, exp) {
if (exp === 0) return 1;
if (exp < 0) return 1 / power(base, -exp);
return base * power(base, exp - 1);
}
// Exponentiation by squaring: about log2(n) steps instead of n.
function powerFast(base, exp) {
if (exp === 0) return 1;
if (exp < 0) return 1 / powerFast(base, -exp);
const half = powerFast(base, Math.floor(exp / 2));
return exp % 2 === 0 ? half * half : half * half * base;
}
// Count the calls so the difference is not just a claim.
let slow = 0;
let fast = 0;
const countSlow = (b, e) => { slow++; return e === 0 ? 1 : b * countSlow(b, e - 1); };
const countFast = (b, e) => {
fast++;
if (e === 0) return 1;
const h = countFast(b, Math.floor(e / 2));
return e % 2 === 0 ? h * h : h * h * b;
};
countSlow(2, 1024);
countFast(2, 1024);
console.log("8^2 =", power(8, 2), " (the source's example)");
console.log('2^10 =', power(2, 10));
console.log('2^-2 =', power(2, -2));
console.log('calls for 2^1024 — plain:', slow, ' by squaring:', fast);
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's example: 8 squared is 64", power(8, 2), 64);
test('2^10 is 1024', power(2, 10), 1024);
test('anything to the 0 is 1', power(7, 0), 1);
test('JavaScript agrees that 0^0 is 1', power(0, 0), 0 ** 0);
test('to the power 1 is itself', power(9, 1), 9);
test('a negative exponent inverts', power(2, -2), 0.25);
test('a negative base with an odd power stays negative', power(-3, 3), -27);
test('and turns positive on an even power', power(-3, 2), 9);
test('the fast version agrees everywhere', [[8, 2], [2, 10], [7, 0], [2, -2], [-3, 3], [5, 7]].every(([b, e]) => powerFast(b, e) === power(b, e)), true);
test('and matches the ** operator', powerFast(2, 20), 2 ** 20);
test('squaring needs far fewer calls', fast < slow / 50, true);
Squaring turns 1025 calls into 12, and the assertion above proves the ratio rather than asserting it in prose. The exp % 2 split is the whole trick: an even exponent is one squaring of the half, an odd one is that plus a single leftover factor. In real code you would write base ** exp, but this pattern reappears whenever squaring is cheap and multiplying is not — modular exponentiation in cryptography is exactly this function.
Answers JS Recursion 5 in the original set.
6 The first n Fibonacci numbers
fairReturn the first n numbers of the sequence 0, 1, 1, 2, 3, 5, 8, 13… Then measure how badly the obvious recursion behaves.
Stuck? Three hints, smallest first
- 1. Two base cases:
fib(0)is 0 andfib(1)is 1. - 2. Calling
fibonce per position recomputes the same values over and over. - 3. A
Mapof already-computed answers turns exponential time into linear.
// The textbook definition. Also the textbook example of accidental waste:
// fib(n - 1) and fib(n - 2) recompute overlapping work.
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
// Remember what you already worked out.
function fibMemo(n, seen = new Map()) {
if (n < 2) return n;
if (seen.has(n)) return seen.get(n);
const value = fibMemo(n - 1, seen) + fibMemo(n - 2, seen);
seen.set(n, value);
return value;
}
const firstN = (n) => Array.from({ length: n }, (_, i) => fibMemo(i));
// Count the calls each way.
let plain = 0;
let memo = 0;
const countPlain = (n) => { plain++; return n < 2 ? n : countPlain(n - 1) + countPlain(n - 2); };
const countMemo = (n, seen = new Map()) => {
memo++;
if (n < 2) return n;
if (seen.has(n)) return seen.get(n);
const v = countMemo(n - 1, seen) + countMemo(n - 2, seen);
seen.set(n, v);
return v;
};
countPlain(25);
countMemo(25);
console.log('first 10 :', firstN(10));
console.log('fib(25) :', fib(25));
console.log('calls for fib(25) — plain:', plain, ' memoised:', memo);
console.log('fib(90) :', fibMemo(90), '<- already past safe integers');
Output
12 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the first ten', firstN(10), [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
test('the first one is just [0]', firstN(1), [0]);
test('asking for none gives an empty list', firstN(0), []);
test('fib(0) is 0', fib(0), 0);
test('fib(1) is 1', fib(1), 1);
test('fib(10) is 55', fib(10), 55);
test('memoised agrees for 0 to 25', Array.from({ length: 26 }, (_, i) => fib(i) === fibMemo(i)).every(Boolean), true);
test('each number is the sum of the two before it', firstN(20).slice(2).every((v, i) => v === firstN(20)[i] + firstN(20)[i + 1]), true);
test('the plain version makes 242785 calls for n=25', plain, 242785);
test('which is exactly 2*fib(26) - 1', plain, 2 * fibMemo(26) - 1);
test('memoised needs 49', memo, 49);
test('fib(90) is past safe integers, so treat it with suspicion', Number.isSafeInteger(fibMemo(90)), false);
The call count is not approximately exponential, it is exactly 2 × fib(n+1) − 1, and the assertion checks that identity rather than describing it. For n = 25 that is 242,785 calls to produce a number you could reach in 25 steps; at n = 40 it is over 300 million. Memoising is a one-line change and the assertion shows it drops to 49 calls. This is the cheapest possible demonstration of why "it terminates" and "it is usable" are different claims.
Answers JS Recursion 6 in the original set.
7 Even or odd, recursively
fairDecide whether a number is even without using %, by having "is it even" and "is it odd" call each other. Then work out why you should not ship it.
Stuck? Three hints, smallest first
- 1.
isEven(0)is true; otherwise the answer isisOdd(n - 1). - 2. Take the absolute value first, or negative inputs count downwards forever.
- 3. Every step down is a stack frame, so a large number will overflow the stack.
// Mutual recursion: each function's base case is the other's exit.
function isEven(n) {
n = Math.abs(n);
return n === 0 ? true : isOdd(n - 1);
}
function isOdd(n) {
n = Math.abs(n);
return n === 0 ? false : isEven(n - 1);
}
// What you would actually write. Constant time, no stack, no limit.
const isEvenFast = (n) => n % 2 === 0;
// Prove the limit rather than assert it in a comment.
const overflowed = (() => {
try { isEven(1e6); return 'no error'; } catch (e) { return e.constructor.name; }
})();
console.log('isEven(10) :', isEven(10));
console.log('isOdd(7) :', isOdd(7));
console.log('isEven(0) :', isEven(0));
console.log('isEven(1e6):', overflowed, '<- one stack frame per step down');
console.log('isEvenFast(1e6):', isEvenFast(1e6), '<- no recursion, no limit');
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('10 is even', isEven(10), true);
test('7 is not', isEven(7), false);
test('0 is even', isEven(0), true);
test('1 is odd', isOdd(1), true);
test('7 is odd', isOdd(7), true);
test('even and odd always disagree', Array.from({ length: 30 }, (_, i) => isEven(i) !== isOdd(i)).every(Boolean), true);
test('negatives follow the same rule', isEven(-4), true);
test('and -7 is odd', isOdd(-7), true);
test('it agrees with % for 0 to 60', Array.from({ length: 61 }, (_, i) => isEven(i) === isEvenFast(i)).every(Boolean), true);
test('a million overflows the stack', overflowed, 'RangeError');
test('the % version handles it instantly', isEvenFast(1e6), true);
Worth being blunt: this is a bad way to test for evenness. n % 2 === 0 is one machine instruction; the recursion is a million function calls that ends in a RangeError, and the assertion above catches that error to prove it. The exercise is still worth doing, because mutual recursion — two functions whose base cases are each other's — is a real pattern. It turns up in parsers, where "parse an expression" and "parse a term" call back and forth. Just not here.
Answers JS Recursion 7 in the original set.
8 Binary search over a shrinking slice
fairSearch a sorted array by cutting it in half and recursing into the half that could contain the target. The source's sample is [0,1,2,3,4,5,6] searching for 5.
Stuck? Three hints, smallest first
- 1. Cut with
slice, and remember that a slice loses the original indexes. - 2. The source's sample array has value equal to index at every position, so it cannot tell you which of the two is being returned.
- 3. Return the value found, or
null. Exercise 12 returns the index instead.
// Recursion by slicing. Simple to read, and it copies at every level.
function search(sorted, target) {
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
if (sorted[mid] === target) return sorted[mid];
return sorted[mid] < target
? search(sorted.slice(mid + 1), target) // right half
: search(sorted.slice(0, mid), target); // left half
}
// Count the arrays allocated, since that is the cost being hidden.
let slices = 0;
function searchCounted(sorted, target) {
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
if (sorted[mid] === target) return sorted[mid];
slices++;
return sorted[mid] < target
? searchCounted(sorted.slice(mid + 1), target)
: searchCounted(sorted.slice(0, mid), target);
}
const sample = [0, 1, 2, 3, 4, 5, 6];
searchCounted(sample, 6);
// Why the source's sample is ambiguous: here value and index are the same number.
const shifted = [10, 20, 30, 40, 50, 60, 70];
console.log("search(sample, 5) =", search(sample, 5), "<- the source's expected '5'");
console.log('but in this array value and index differ:');
console.log('search(shifted, 50) =', search(shifted, 50), '(index would be 4)');
console.log('missing target :', search(sample, 99));
console.log('arrays copied looking for 6:', slices);
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's example finds 5", search(sample, 5), 5);
test('the first element', search(sample, 0), 0);
test('the last element', search(sample, 6), 6);
test('a missing target gives null', search(sample, 99), null);
test('an empty array gives null', search([], 1), null);
test('one element, found', search([42], 42), 42);
test('one element, missed', search([42], 1), null);
test('it returns the value, not the index', search(shifted, 50), 50);
test('every element of a bigger array is findable', [1, 3, 5, 7, 9, 11, 13, 15].every((v, _, a) => search(a, v) === v), true);
test('slicing really does copy', slices > 0, true);
test('the input is untouched', sample, [0, 1, 2, 3, 4, 5, 6]);
The source's sample array is [0,1,2,3,4,5,6] and it expects 5 — but in that array the value at index 5 is 5, so the example cannot tell you whether the answer is the value or the position. That is why the assertions here also run against [10,20,…], where the two differ. Slicing is the readable way to recurse and it copies an array per level; exercise 12 does the same search with index bounds and no copying at all.
Answers JS Recursion 8 in the original set.
9 Merge sort
hardSort by splitting the array in half, sorting each half, and merging the two sorted halves. The source's sample is [34,7,23,32,5,62] giving [5,7,23,32,34,62].
Stuck? Three hints, smallest first
- 1. An array of 0 or 1 items is already sorted. That is the base case.
- 2. The merge step takes whichever front item is smaller, until one side runs out.
- 3. Use
<=when comparing during the merge, and the sort becomes stable.
function mergeSort(values, compare = (a, b) => a - b) {
if (values.length <= 1) return values.slice(); // copy, so the input is safe
const mid = Math.floor(values.length / 2);
const left = mergeSort(values.slice(0, mid), compare);
const right = mergeSort(values.slice(mid), compare);
return merge(left, right, compare);
}
function merge(left, right, compare) {
const out = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
// <= , not < : ties take from the LEFT, which is what makes it stable.
out.push(compare(left[i], right[j]) <= 0 ? left[i++] : right[j++]);
}
// One side is empty now; whatever is left is already in order.
while (i < left.length) out.push(left[i++]);
while (j < right.length) out.push(right[j++]);
return out;
}
const sample = [34, 7, 23, 32, 5, 62];
// Stability: equal keys must keep their original order.
const bookings = [
{ name: 'Ada', seats: 2 },
{ name: 'Bode', seats: 1 },
{ name: 'Chidi', seats: 2 },
{ name: 'Dele', seats: 1 },
];
const bySeats = mergeSort(bookings, (a, b) => a.seats - b.seats);
console.log("the source's sample:", mergeSort(sample));
console.log('input afterwards :', sample, '<- unchanged');
console.log('stable by seats :', bySeats.map((b) => b.name));
console.log("sort() on numbers :", [10, 9, 100].slice().sort(), '<- compares as TEXT');
Output
12 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's sample sorts", mergeSort(sample), [5, 7, 23, 32, 34, 62]);
test('the input is not modified', sample, [34, 7, 23, 32, 5, 62]);
test('an empty array', mergeSort([]), []);
test('one element', mergeSort([1]), [1]);
test('already sorted stays sorted', mergeSort([1, 2, 3]), [1, 2, 3]);
test('reversed gets fixed', mergeSort([3, 2, 1]), [1, 2, 3]);
test('duplicates are all kept', mergeSort([2, 1, 2, 1]), [1, 1, 2, 2]);
test('negatives sort correctly', mergeSort([3, -1, 0, -5]), [-5, -1, 0, 3]);
test('it agrees with a numeric sort()', mergeSort([10, 9, 100, 1]), [10, 9, 100, 1].slice().sort((a, b) => a - b));
test('and shows why sort() needs a comparator', [10, 9, 100, 1].slice().sort(), [1, 10, 100, 9]);
test('equal keys keep their original order', bySeats.map((b) => b.name), ['Bode', 'Dele', 'Ada', 'Chidi']);
test('a custom comparator can reverse it', mergeSort(sample, (a, b) => b - a), [62, 34, 32, 23, 7, 5]);
Merge sort is O(n log n) whatever the input, which quicksort cannot promise. Two details decide correctness. values.slice() in the base case means the original array is never written to — the assertion checks that. And <= 0 in the merge is what makes it stable: on a tie the left item goes first, so items that were already in a certain order stay that way. That is why sorting by seats leaves Bode before Dele. Note also that the plain sort() assertion is not a joke — with no comparator it converts to text, so 100 sorts before 9.
Answers JS Recursion 9 in the original set.
10 Palindrome check by recursion
easyDecide whether a word reads the same backwards, recursively. The source's data: "madam" true, "abdb" false, "ab" false, "a" true.
Stuck? Three hints, smallest first
- 1. Compare the first and last characters, then recurse on what is between them.
- 2. A string of 0 or 1 characters is a palindrome, so that is your base case.
- 3. For real sentences you must strip punctuation and case first, which is a separate step.
function isPalindrome(text) {
if (text.length <= 1) return true; // '' and 'a'
if (text[0] !== text[text.length - 1]) return false;
return isPalindrome(text.slice(1, -1)); // drop both ends
}
// The version that copes with a real sentence: normalise, then check.
const clean = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
const isPalindromeSentence = (text) => isPalindrome(clean(text));
// A completely different method, as a cross-check.
const byReversal = (text) => text === [...text].reverse().join('');
for (const w of ['madam', 'abdb', 'ab', 'a', '']) {
console.log(JSON.stringify(w).padEnd(8), '->', isPalindrome(w));
}
console.log('A man, a plan, a canal - Panama ->', isPalindromeSentence('A man, a plan, a canal - Panama'));
console.log('cleaned:', clean('A man, a plan, a canal - Panama'));
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("'madam' is a palindrome", isPalindrome('madam'), true);
test("'abdb' is not", isPalindrome('abdb'), false);
test("'ab' is not", isPalindrome('ab'), false);
test("'a' is", isPalindrome('a'), true);
test("'' is, by convention", isPalindrome(''), true);
test('an even-length one works too', isPalindrome('abba'), true);
test('the reversal method agrees on all of them', ['madam', 'abdb', 'ab', 'a', '', 'abba', 'racecar'].every((w) => isPalindrome(w) === byReversal(w)), true);
test('case matters to the plain version', isPalindrome('Madam'), false);
test('the sentence version ignores case and punctuation', isPalindromeSentence('A man, a plan, a canal - Panama'), true);
test('11/11/11 11:11 also passes once cleaned', isPalindromeSentence('11/11/11 11:11'), true);
test('and a real sentence still fails', isPalindromeSentence('Lagos to Abuja'), false);
slice(1, -1) removes both ends in one step, which is what makes the recursion two lines. The case assertion is the part people skip: 'Madam' is not a palindrome by strict character comparison, so normalising is a deliberate decision rather than an accident. Note also that [...text] is used instead of text.split('') in the cross-check, because spreading iterates by code point and keeps characters outside the basic range in one piece.
Answers JS Recursion 10 in the original set.
11 Binary to decimal
fairConvert a string of 1s and 0s to a number, recursively. Then compare your answer with parseInt on bad input.
Stuck? Three hints, smallest first
- 1. Work from the right: the last digit is worth 1, and everything before it is worth twice as much.
- 2. Or from the left:
value = value * 2 + digitat each step. - 3. Reject anything that is not a 1 or a 0 before converting, because the built-ins do not agree on what to do.
function binaryToDecimal(bits) {
if (!/^[01]+$/.test(bits)) throw new SyntaxError('binary digits only');
return convert(bits);
}
// From the right: last digit is the 1s place, the rest is worth double.
function convert(bits) {
if (bits.length === 0) return 0;
const last = Number(bits[bits.length - 1]);
return last + 2 * convert(bits.slice(0, -1));
}
// From the left, which needs no slicing arithmetic at the end.
const fromLeft = (bits, acc = 0) =>
bits.length === 0 ? acc : fromLeft(bits.slice(1), acc * 2 + Number(bits[0]));
const rejected = (() => {
try { binaryToDecimal('1102'); return 'no error'; } catch (e) { return e.constructor.name; }
})();
console.log("'1101' ->", binaryToDecimal('1101'));
console.log("'11111111' ->", binaryToDecimal('11111111'));
console.log('--- what the built-ins do with the invalid "1102" ---');
console.log("parseInt('1102', 2) =", parseInt('1102', 2), '<- silently stops at the 2 and returns 6');
console.log("Number('0b1102') =", Number('0b1102'), '<- NaN, which at least tells you');
console.log('ours =', rejected, '<- refuses outright');
Output
12 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("'0' is 0", binaryToDecimal('0'), 0);
test("'1' is 1", binaryToDecimal('1'), 1);
test("'1101' is 13", binaryToDecimal('1101'), 13);
test("'10000' is 16", binaryToDecimal('10000'), 16);
test("'11111111' is 255", binaryToDecimal('11111111'), 255);
test('leading zeros do not matter', binaryToDecimal('0001101'), 13);
test('the left-to-right version agrees', ['0', '1', '1101', '10000', '11111111'].every((b) => fromLeft(b) === binaryToDecimal(b)), true);
test('it agrees with parseInt on valid input', binaryToDecimal('1101'), parseInt('1101', 2));
test('and with toString(2) going back', (13).toString(2), '1101');
test('invalid input is refused', rejected, 'SyntaxError');
test('whereas parseInt quietly returns 6 for "1102"', parseInt('1102', 2), 6);
test('and Number gives NaN', Number.isNaN(Number('0b1102')), true);
The conversion is the easy half. The useful half is the disagreement on bad input: parseInt('1102', 2) reads 110, stops at the 2, and hands back 6 with no complaint — a wrong number that looks like a right one. Number('0b1102') at least gives NaN. Validating with /^[01]+$/ first costs one line and removes the whole class of problem. This is the general shape of most parseInt bugs: it is happy to parse a prefix.
Answers JS Recursion 11 in the original set.
12 Recursive binary search returning an index
fairSame search as exercise 8, but return the position and do no copying. The source's data: searching for 6 gives 4, and searching for 16 gives −1.
Stuck? Three hints, smallest first
- 1. Pass
lowandhighinstead of slicing, so the indexes stay meaningful. - 2. The empty case is
low > high, and it returns-1. - 3. Give the bounds default values so the caller does not have to supply them.
function binarySearch(sorted, target, low = 0, high = sorted.length - 1) {
if (low > high) return -1; // the range is empty: not here
const mid = low + Math.floor((high - low) / 2);
if (sorted[mid] === target) return mid;
return sorted[mid] < target
? binarySearch(sorted, target, mid + 1, high)
: binarySearch(sorted, target, low, mid - 1);
}
// Count the calls, to show it is log2(n) and not n.
let calls = 0;
function counted(sorted, target, low = 0, high = sorted.length - 1) {
calls++;
if (low > high) return -1;
const mid = low + Math.floor((high - low) / 2);
if (sorted[mid] === target) return mid;
return sorted[mid] < target
? counted(sorted, target, mid + 1, high)
: counted(sorted, target, low, mid - 1);
}
const data = [1, 2, 3, 5, 6, 7, 10, 11, 14, 15, 17, 19, 20, 22, 23];
counted(data, 23);
console.log("looking for 6 ->", binarySearch(data, 6), "(the source expects 4)");
console.log('looking for 16 ->', binarySearch(data, 16), '(the source expects -1)');
console.log('15 items, calls to find the last one:', calls);
console.log('a linear scan would need:', data.length);
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's first case", binarySearch(data, 6), 4);
test("the source's second case", binarySearch(data, 16), -1);
test('the first element', binarySearch(data, 1), 0);
test('the last element', binarySearch(data, 23), 14);
test('below the range', binarySearch(data, 0), -1);
test('above the range', binarySearch(data, 99), -1);
test('an empty array', binarySearch([], 1), -1);
test('every element is found at its real index', data.every((v, i) => binarySearch(data, v) === i), true);
test('it agrees with indexOf throughout', data.every((v) => binarySearch(data, v) === data.indexOf(v)), true);
test('15 items need at most 4 comparisons', calls <= 4, true);
test('the array is never copied or changed', data.length, 15);
Carrying low and high instead of slicing is the difference between O(log n) extra memory and O(n log n). It also fixes the flaw in exercise 8: because no slice is taken, mid is a real index into the original array, so it can be returned directly. low + Math.floor((high - low) / 2) rather than (low + high) / 2 is a habit borrowed from languages with fixed-width integers, where the sum can overflow. It cannot here, and it costs nothing.
Answers JS Recursion 12 in the original set.
13 Every letter combination for a string of digits
hardEach digit stands for a group of letters. Produce every combination, one letter taken from each digit's group. The source's data: "12" gives ["ad","ae","af","bd","be","bf","cd","ce","cf"], and "9" gives ["y","z"].
Stuck? Three hints, smallest first
- 1. Work out the mapping from the expected output, not from the wording. Check what
1and9must map to. - 2. The base case is the empty string, and it needs care: returning an empty array is not the same as returning an array holding one empty string.
- 3. For each letter of the first digit, prefix it onto every combination of the rest.
// The mapping is derived from the source's own expected output, which does NOT
// match a phone keypad. "12" -> "ad".. means 1 is 'abc' and 2 is 'def', and
// "9" -> ["y","z"] means 9 is 'yz'. That is the alphabet cut into threes:
// 1 abc, 2 def, 3 ghi, 4 jkl, 5 mno, 6 pqr, 7 stu, 8 vwx, 9 yz.
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
const lettersFor = (digit) => ALPHABET.slice((Number(digit) - 1) * 3, Number(digit) * 3);
function combinations(digits) {
if (digits === '') return []; // no digits, no combinations
const heads = lettersFor(digits[0]).split('');
const tails = combinations(digits.slice(1));
// Last digit: the letters themselves are the answers.
if (tails.length === 0) return heads;
return heads.flatMap((h) => tails.map((t) => h + t));
}
// The real phone keypad, for contrast, since the prompt's wording implies it.
const KEYPAD = { 2: 'abc', 3: 'def', 4: 'ghi', 5: 'jkl', 6: 'mno', 7: 'pqrs', 8: 'tuv', 9: 'wxyz' };
const keypadCombos = (digits) => {
if (digits === '') return [];
const heads = KEYPAD[digits[0]].split('');
const tails = keypadCombos(digits.slice(1));
return tails.length === 0 ? heads : heads.flatMap((h) => tails.map((t) => h + t));
};
console.log("mapping: 1 ->", lettersFor('1'), ' 2 ->', lettersFor('2'), ' 9 ->', lettersFor('9'));
console.log('combinations("12") =', combinations('12'));
console.log('combinations("9") =', combinations('9'));
console.log('how many for "123":', combinations('123').length, '= 3 x 3 x 3');
console.log('on a real keypad, "23" would be:', keypadCombos('23'));
Output
13 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 13 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("the source's first case", combinations('12'), ['ad', 'ae', 'af', 'bd', 'be', 'bf', 'cd', 'ce', 'cf']);
test("the source's second case", combinations('9'), ['y', 'z']);
test('the mapping that produces it: 1 is abc', lettersFor('1'), 'abc');
test('2 is def', lettersFor('2'), 'def');
test('9 is the two left over', lettersFor('9'), 'yz');
test('no digits gives no combinations', combinations(''), []);
test('one digit gives its letters', combinations('3'), ['g', 'h', 'i']);
test('the count multiplies out', combinations('123').length, 27);
test('and with the short group', combinations('129').length, 18);
test('every result is as long as the input', combinations('123').every((c) => c.length === 3), true);
test('there are no duplicates', new Set(combinations('123')).size, 27);
test("a real keypad would give something different for '2'", keypadCombos('2'), ['a', 'b', 'c']);
test('which is not what the source asks for', combinations('2'), ['d', 'e', 'f']);
This one is really a lesson in reading a specification. The prompt says "digits ranging from 2 to 9", which is phone-keypad language, but the stated output uses digit 1 and maps 9 to just yz — that is the alphabet cut into groups of three, not a keypad. When the prose and the test data disagree, the test data is the specification, because it is the thing that can be checked. Both mappings are implemented above so the difference is visible, and the assertions pin down the source's version.
The base case is the other trap. Returning [] for the empty string means the last digit has to be handled specially. Returning [''] instead makes the general case work all the way down, but then combinations('') answers [''] rather than []. Neither is wrong; you just have to pick one and test it.
Answers JS Recursion 13 in the original set.
The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.