Card 1 / 10
Return n!, the product 1 * 2 * ... * n. By definition factorial(0) is 1.
n |
result | exercises |
|---|---|---|
0 |
1 |
base case โ the empty product is 1 |
1 |
1 |
a single factor |
5 |
120 |
1 * 2 * 3 * 4 * 5 |
10 |
3628800 |
how fast the value grows |
Start a running total at 1. That value already answers n = 0 and n = 1 on its own, so neither needs a special case โ begin multiplying at 2.
The reference solution loops: let result = 1; then for (let i = 2; i <= n; i++) result *= i;, where *= multiplies the variable by the right-hand side in place. The recursive form n * factorial(n - 1) stopping at factorial(0) === 1 is an equally correct alternative; it just costs one call frame per factor.
function factorial(n: number): number {
// Return n! = 1 * 2 * ... * n. factorial(0) is 1.
let result = 1;
for (let i = 2; i <= n; i++) result *= i;
return result;
}Card 2 / 10
Return the nth Fibonacci number, 0-indexed: each value is the sum of the two before it, starting 0, 1, 1, 2, 3, 5, 8. Memoize so large n stays fast.
n |
result | exercises |
|---|---|---|
0 |
0 |
base case |
1 |
1 |
the second base case |
10 |
55 |
a typical value |
20 |
6765 |
where an unmemoized version starts to crawl |
Plain fib(n - 1) + fib(n - 2) recursion recomputes the same subproblems over and over, so its cost grows exponentially in n. Record each result the first time it is computed and the work collapses to one computation per distinct n.
new Map<number, number>() is a keyed store: memo.has(k) asks whether a key was recorded, memo.get(k) reads it back, memo.set(k, v) writes it. Wrap the recursion in an inner helper that returns k directly when k < 2, returns the cached value when has says there is one, and otherwise computes, stores, then returns. get is typed number | undefined, so memo.get(k)! โ the non-null assertion โ tells TypeScript the value is there because has already confirmed it.
function fib(n: number): number {
// Return the nth Fibonacci number (0-indexed): 0, 1, 1, 2, 3, 5, ... Use memoization.
const memo = new Map<number, number>();
const go = (k: number): number => {
if (k < 2) return k;
if (memo.has(k)) return memo.get(k)!;
const v = go(k - 1) + go(k - 2);
memo.set(k, v);
return v;
};
return go(n);
}Card 3 / 10
Fully flatten an arbitrarily nested array into a single flat array, however deep the nesting goes.
arr |
result | exercises |
|---|---|---|
[1, [2, [3, [4]]]] |
[1, 2, 3, 4] |
nesting several levels deep |
[[1], [2], [3]] |
[1, 2, 3] |
every element wrapped |
[1, 2, 3] |
[1, 2, 3] |
already flat |
[] |
[] |
base case โ nothing to flatten |
arr is typed unknown[] because any element may be a plain value or another array, and the type parameter T names the element type of the flat array you hand back.
Array.isArray(value) returns true exactly when a value is an array. That is the test that decides whether an element goes straight into the output or has to be flattened first.
Collect into a local out array. For an array element, flatten it and spread the pieces in with out.push(...flattenDeep<T>(item)) โ the ... passes each element of the returned array as its own argument, so nothing nested survives. Otherwise out.push(item as T).
function flattenDeep<T>(arr: unknown[]): T[] {
// Fully flatten an arbitrarily nested array into a flat array.
const out: T[] = [];
for (const item of arr) {
if (Array.isArray(item)) out.push(...flattenDeep<T>(item));
else out.push(item as T);
}
return out;
}Card 4 / 10
Build an array of numbers from start up to (but not including) end, moving by step. A positive step counts up and a negative one counts down; step defaults to 1 when the caller leaves it out.
start |
end |
step |
result | exercises |
|---|---|---|---|---|
0 |
5 |
1 |
[0, 1, 2, 3, 4] |
end is excluded |
0 |
10 |
2 |
[0, 2, 4, 6, 8] |
a step wider than 1 |
5 |
0 |
-1 |
[5, 4, 3, 2, 1] |
counting down |
0 |
0 |
1 |
[] |
base case โ start already meets end |
The comparison that ends the loop flips with the sign of step: counting up you continue while i < end, counting down while i > end. A step of 0 would satisfy neither and loop forever, so it has to produce the empty array instead.
Branch on the sign first, then loop: if (step > 0) for (let i = start; i < end; i += step) out.push(i); and the mirror image with i > end under else if (step < 0). i += step adds and reassigns, so the counting-down case walks backwards with no extra arithmetic, and a zero step falls through both branches to the empty result.
function range(start: number, end: number, step: number = 1): number[] {
// Build an array from start up to (but not including) end, moving by step.
// A positive step counts up; a negative step counts down.
const out: number[] = [];
if (step > 0) {
for (let i = start; i < end; i += step) out.push(i);
} else if (step < 0) {
for (let i = start; i > end; i += step) out.push(i);
}
return out;
}Card 5 / 10
Sum every number found in an arbitrarily nested array. An element may be a number or another array, nested to any depth; anything else is ignored.
arr |
result | exercises |
|---|---|---|
[1, [2, 3], [[4], 5]] |
15 |
numbers and arrays mixed at several depths |
[[[1]]] |
1 |
one value buried three levels down |
[1, 2, 3] |
6 |
already flat |
[] |
0 |
base case โ an empty array sums to 0 |
Keep a running total and walk the elements once. Each element is either a number to add or an array whose own total you need โ and that second case is the very same problem, one level down.
Two guards do the work. Array.isArray(item) is true exactly for arrays, so recurse and add what comes back. typeof item === "number" reports an element's runtime type as a string, so add the value only when that string is "number" and skip everything else.
function sumNested(arr: unknown[]): number {
// Sum every number found in an arbitrarily nested array. Non-numbers besides arrays are ignored.
let total = 0;
for (const item of arr) {
if (Array.isArray(item)) total += sumNested(item);
else if (typeof item === "number") total += item;
}
return total;
}Card 6 / 10
Return base raised to exp, for exp >= 0, computed recursively. Anything to the power 0 is 1.
base |
exp |
result | exercises |
|---|---|---|---|
2 |
0 |
1 |
base case โ any base to the 0 is 1 |
3 |
1 |
3 |
a single factor |
2 |
10 |
1024 |
repeated doubling |
5 |
3 |
125 |
a base other than 2 |
base to the exp is one multiplication away from base to the exp - 1. Peeling a single factor off leaves the same problem with a smaller exponent, and the exponent dropping by 1 every call is what guarantees the recursion reaches its base case.
Two lines: if (exp === 0) return 1; then return base * power(base, exp - 1);. That is exp multiplications and exp nested calls. Halving the exponent instead (exponentiation by squaring) is asymptotically faster, but the linear peel is what this card teaches.
function power(base: number, exp: number): number {
// Return base raised to exp (exp >= 0), computed recursively. Anything to the 0 is 1.
if (exp === 0) return 1;
return base * power(base, exp - 1);
}Card 7 / 10
Return the largest number that divides both a and b without a remainder, using Euclid's algorithm. Both inputs are non-negative.
a |
b |
result | exercises |
|---|---|---|---|
12 |
8 |
4 |
a shared factor |
48 |
36 |
12 |
a larger shared factor |
17 |
5 |
1 |
coprime โ only 1 divides both |
10 |
0 |
10 |
base case โ every number divides 0 |
Euclid's observation: anything that divides both a and b also divides a % b, so gcd(a, b) and gcd(b, a % b) are the same value. a % b is the remainder left after dividing a by b, and it is always smaller than b, so repeating the swap drives the second argument down to 0.
One line: return b === 0 ? a : gcd(b, a % b);. The conditional expression cond ? x : y evaluates to x when cond holds and to y otherwise โ here it returns the base-case answer a the moment b reaches 0.
function gcd(a: number, b: number): number {
// Return the greatest common divisor of a and b using Euclid's algorithm.
return b === 0 ? a : gcd(b, a % b);
}Card 8 / 10
Count the steps needed to reach 1: if the number is even, halve it; if it is odd, triple it and add 1. collatzSteps(1) is 0 โ it is already there.
n |
result | exercises |
|---|---|---|
1 |
0 |
base case โ no steps needed |
2 |
1 |
a single halving |
6 |
8 |
a chain that climbs before it falls |
7 |
16 |
a longer chain |
n % 2 === 0 tests for evenness: % gives the remainder after division, so an even number leaves 0. Each round replaces n with its successor and adds one to a counter, and the process stops once n is 1.
The reference solution is a while loop rather than recursion, because the number of steps is not known in advance: while (n !== 1) { n = n % 2 === 0 ? n / 2 : 3 * n + 1; steps++; }. Halving an even number is exact, so plain / 2 stays a whole number and no Math.floor is needed.
function collatzSteps(n: number): number {
// Count how many steps it takes to reach 1: halve when even, else 3n + 1. collatzSteps(1) is 0.
let steps = 0;
while (n !== 1) {
n = n % 2 === 0 ? n / 2 : 3 * n + 1;
steps++;
}
return steps;
}Card 9 / 10
Return the sum of the decimal digits of a non-negative integer, computed recursively.
n |
result | exercises |
|---|---|---|
0 |
0 |
base case โ a single digit is its own sum |
5 |
5 |
still one digit |
1234 |
10 |
1 + 2 + 3 + 4 |
9999 |
36 |
a repeated digit |
Two pieces of arithmetic split a number apart: n % 10 is the remainder after dividing by 10, which is the last digit, and Math.floor(n / 10) rounds the quotient down to a whole number, which is everything except the last digit.
if (n < 10) return n; is the base case โ a one-digit number is already its own digit sum. Otherwise return (n % 10) + sumDigits(Math.floor(n / 10));, which removes one digit per call, so n always shrinks toward that base case.
function sumDigits(n: number): number {
// Return the sum of the decimal digits of a non-negative integer, computed recursively.
if (n < 10) return n;
return (n % 10) + sumDigits(Math.floor(n / 10));
}Card 10 / 10
Return the binary representation of a non-negative integer as a string of 0s and 1s, with no leading zeros.
n |
result | exercises |
|---|---|---|
0 |
"0" |
base case โ the one input with no bits to emit |
1 |
"1" |
a single bit |
5 |
"101" |
4 + 1 |
255 |
"11111111" |
every bit set |
n % 2 is the lowest binary digit and Math.floor(n / 2) drops it by rounding the halved value down to a whole number. The bits therefore come out lowest-first, so prepend each one to the string you are building instead of appending. (n % 2) + s concatenates rather than adds, because s is a string and the number is converted to join it.
The reference solution is a loop, not recursion: if (n === 0) return "0"; first, since the loop below emits nothing at all for 0, then while (n > 0) { s = (n % 2) + s; n = Math.floor(n / 2); }. TypeScript's built-in n.toString(2) does the whole job in one call โ the argument is the radix, the base to print in โ but writing the conversion out by hand is the point here.
function toBinary(n: number): string {
// Return the binary representation of a non-negative integer as a string. toBinary(0) is "0".
if (n === 0) return "0";
let s = "";
while (n > 0) {
s = (n % 2) + s;
n = Math.floor(n / 2);
}
return s;
}