JavaScript: Function
Arguments, closures, currying and higher-order functions.
29 exercises
1 Reverse a number
easy32243 should give 34223. Decide what happens to a negative number and to trailing zeros.
Stuck? Three hints, smallest first
- 1. Convert to a string, reverse the characters, convert back.
- 2.
Number(‘00123’)is123, so reversing32100loses the zeros. - 3. Keep the sign aside and reverse only the digits.
function reverseNumber(n) {
if (!Number.isFinite(n)) return NaN;
const sign = Math.sign(n); // keep the sign out of it
const digits = [...String(Math.abs(n))].reverse().join('');
return sign * Number(digits);
}
// Arithmetic, no strings. Same answer, and it shows what is really happening.
function reverseByMath(n) {
const sign = Math.sign(n);
let left = Math.abs(n);
let out = 0;
while (left > 0) {
out = out * 10 + (left % 10);
left = Math.floor(left / 10);
}
return sign * out;
}
for (const n of [32243, 1, 0, -456, 32100, 120, 1000]) {
console.log(String(n).padEnd(8), '->', reverseNumber(n));
}
console.log('--- trailing zeros cannot survive ---');
console.log("reverse 32100 gives 121 as digits '00123', and Number('00123') is", Number('00123'));
console.log('so the leading zeros are dropped, which is arithmetic, not a bug');
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 example", reverseNumber(32243), 34223);
test('a single digit is itself', reverseNumber(1), 1);
test('zero is zero', reverseNumber(0), 0);
test('a negative number keeps its sign', reverseNumber(-456), -654);
test('trailing zeros disappear', reverseNumber(32100), 123);
test('because a number cannot hold leading zeros', Number('00123'), 123);
test('a round number becomes a single digit', reverseNumber(1000), 1);
test('the arithmetic version agrees everywhere', [32243, 1, 0, -456, 32100, 120, 1000].every((n) => reverseNumber(n) === reverseByMath(n)), true);
test('reversing twice gets back to the start, when there are no trailing zeros', reverseNumber(reverseNumber(32243)), 32243);
test('but not when there were', reverseNumber(reverseNumber(32100)) === 32100, false);
test('the result is a number, not a string', typeof reverseNumber(32243), 'number');
test('a non-finite input gives NaN', Number.isNaN(reverseNumber(Infinity)), true);
The interesting part is what the type forces. A number cannot store a leading zero, so 32100 reverses to 123 and there is no way around it — the assertion checks Number('00123') to make the reason visible. If those zeros matter, you are reversing a string, not a number, and should return one.
Handling the sign separately is what stops -456 becoming the string '654-'. The arithmetic version is worth reading once: out * 10 + (left % 10) peels a digit off one end and pushes it onto the other, which is the same idea without going through text.
Answers JS Function 1 in the original set.
2 Check a palindrome
easyTest whether a string reads the same backwards. 'nurses run' should count, which tells you what to do about spaces.
Stuck? Three hints, smallest first
- 1. The source’s own example has a space in it, so cleaning is part of the task.
- 2. Lower-case the text and remove anything that is not a letter or digit.
- 3. Compare against the reverse, or walk in from both ends, which stops early.
const clean = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
// The readable version.
const isPalindrome = (text) => {
const c = clean(text);
return c === [...c].reverse().join('');
};
// Two pointers. Same answer, and it stops at the first mismatch instead of
// building a whole reversed copy.
function isPalindromeTwoPointer(text) {
const c = clean(text);
let comparisons = 0;
for (let i = 0, j = c.length - 1; i < j; i++, j--) {
comparisons++;
if (c[i] !== c[j]) return { result: false, comparisons };
}
return { result: true, comparisons };
}
const cases = ['madam', 'nurses run', 'A man, a plan, a canal - Panama', 'racecar',
'hello', 'ab', 'a', '', '11/11/11 11:11', 'Was it a car or a cat I saw?'];
for (const t of cases) {
console.log(JSON.stringify(t).slice(0, 34).padEnd(36), isPalindrome(t));
}
console.log('comparisons for "hello" :', isPalindromeTwoPointer('hello').comparisons, '<- stopped at the first mismatch');
console.log('comparisons for "racecar":', isPalindromeTwoPointer('racecar').comparisons);
Output
15 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 15 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("the source's 'nurses run' works, so spaces are ignored", isPalindrome('nurses run'), true);
test('punctuation and case are ignored too', isPalindrome('A man, a plan, a canal - Panama'), true);
test('a question mark does not stop it', isPalindrome('Was it a car or a cat I saw?'), true);
test('digits and punctuation together', isPalindrome('11/11/11 11:11'), true);
test("'hello' is not", isPalindrome('hello'), false);
test('two different letters are not', isPalindrome('ab'), false);
test('a single letter is', isPalindrome('a'), true);
test('the empty string is, by convention', isPalindrome(''), true);
test('cleaning strips the space', clean('nurses run'), 'nursesrun');
test('and lower-cases', clean('Madam'), 'madam');
test('the two-pointer version agrees everywhere', cases.every((t) => isPalindromeTwoPointer(t).result === isPalindrome(t)), true);
test('it stops at the first mismatch', isPalindromeTwoPointer('hello').comparisons, 1);
test('and only needs half the length when it matches', isPalindromeTwoPointer('racecar').comparisons, 3);
test('so a long non-palindrome costs one comparison', isPalindromeTwoPointer('zebra' + 'a'.repeat(1000)).comparisons, 1);
Recursion 10 solves this by recursion; the point here is the shape of the function. The source's own example is 'nurses run', which has a space, so normalising is not an optional extra — it is the specification. Reading that out of the example rather than the prose is the skill.
The two-pointer version is the one to remember. It walks in from both ends and returns at the first mismatch, so a thousand-character non-palindrome costs one comparison rather than building a reversed copy of the whole string — the last assertion checks exactly that. It also needs only length / 2 comparisons when the answer is yes.
Answers JS Function 2 in the original set.
3 All combinations of a string
fair'dog' should give d,do,dog,o,og,g. Work out from that ordering what is being generated.
Stuck? Three hints, smallest first
- 1. Read the expected output: grouped by starting position, growing in length.
- 2. Those are contiguous substrings.
‘dg’is absent, so gaps are not allowed. - 3. There are exactly
n(n+1)/2of them.
function combinations(text) {
const out = [];
for (let start = 0; start < text.length; start++) {
for (let end = start + 1; end <= text.length; end++) {
out.push(text.slice(start, end));
}
}
return out;
}
// The source prints them comma-separated, which is where its example comes from.
const asList = (text) => combinations(text).join(',');
// Only the distinct ones, which differs as soon as a letter repeats.
const distinct = (text) => [...new Set(combinations(text))];
console.log("combinations('dog') :", combinations('dog'));
console.log("as the source shows :", asList('dog'));
console.log("combinations('aa') :", combinations('aa'), '<- "a" twice, by position');
console.log("distinct('aa') :", distinct('aa'));
console.log('count for a 5-letter word:', combinations('house').length, '= 5 x 6 / 2');
Output
14 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 14 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 exact output", asList('dog'), 'd,do,dog,o,og,g');
test('as an array', combinations('dog'), ['d', 'do', 'dog', 'o', 'og', 'g']);
test('six of them', combinations('dog').length, 6);
test('which is n(n+1)/2', combinations('dog').length, (3 * 4) / 2);
test('and for a longer word', combinations('house').length, (5 * 6) / 2);
test('one letter gives one', combinations('a'), ['a']);
test('the empty string gives none', combinations(''), []);
test('the whole string is always included', combinations('dog').includes('dog'), true);
test('gaps are not allowed', combinations('dog').includes('dg'), false);
test('every result is a real slice of the input', combinations('dog').every((s) => 'dog'.includes(s)), true);
test('repeated letters give repeated results', combinations('aa'), ['a', 'aa', 'a']);
test('because these are positions, not values', combinations('aa').length, 3);
test('deduplicating changes the count', distinct('aa').length, 2);
test('but not for a word with no repeats', distinct('dog').length, combinations('dog').length);
Object 7 asks for the same thing and the source calls it "subsets" there. It is neither a subset nor a combination in the mathematical sense: those allow gaps, and 'dg' is absent from the expected output. What is being asked for is every contiguous substring, which is why two nested loops suffice and why there are exactly n(n+1)/2 of them.
The 'aa' assertions are the detail worth noticing. It produces ['a', 'aa', 'a'] — 'a' twice, because these are positions rather than values. Whether you want the duplicates depends on the question, so distinct is there too and the difference is asserted rather than assumed.
Answers JS Function 3 in the original set.
4 Sort a string’s letters
easy'webmaster' should give 'abeemrstw'. Assume no punctuation or digits.
Stuck? Three hints, smallest first
- 1. Spread into characters,
sort(), thenjoin. - 2.
split(‘’)breaks characters outside the basic range in half;[...text]does not. - 3. Default
sort()compares by code unit, so every capital sorts before every lower-case letter.
const sortLetters = (text) => [...text].sort().join('');
// Case-insensitive, and locale-aware, which is usually what a person expects.
const sortLettersAlphabetically = (text) =>
[...text].sort((a, b) => a.localeCompare(b, 'en')).join('');
console.log("sortLetters('webmaster') =", sortLetters('webmaster'));
console.log('the source expects = abeemrstw');
console.log("sortLetters('WebMaster') =", sortLetters('WebMaster'), '<- capitals first');
console.log('alphabetical instead =', sortLettersAlphabetically('WebMaster'));
console.log('--- why split("") is the wrong tool ---');
const emoji = 'b\u{1F600}a';
console.log("[...text] length :", [...emoji].length);
console.log("split('') length :", emoji.split('').length, '<- the emoji became two halves');
console.log('sorted with spread:', JSON.stringify([...emoji].sort().join('')));
console.log("sorted with split :", JSON.stringify(emoji.split('').sort().join('')), '<- halves separated, now broken');
Output
18 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 18 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", sortLetters('webmaster'), 'abeemrstw');
test('a single letter', sortLetters('a'), 'a');
test('an empty string', sortLetters(''), '');
test('already sorted stays put', sortLetters('abc'), 'abc');
test('reversed gets fixed', sortLetters('cba'), 'abc');
test('repeated letters are all kept', sortLetters('aab'), 'aab');
test('the length never changes', sortLetters('webmaster').length, 'webmaster'.length);
test('and the same letters come back', sortLetters('webmaster').split('').sort().join(''), 'webmaster'.split('').sort().join(''));
test('capitals sort before lower case by code unit', sortLetters('bA'), 'Ab');
test('while the locale sort groups them', sortLettersAlphabetically('bA'), 'Ab');
test('and orders a mixed word alphabetically', sortLettersAlphabetically('WebMaster'), 'abeeMrstW');
test('spreading gives three characters', [...'b\u{1F600}a'].length, 3);
test('while split("") gives four pieces', 'b\u{1F600}a'.split('').length, 4);
test('because the emoji was cut into two halves', 'b\u{1F600}a'.split('')[1].codePointAt(0) >= 0xD800, true);
test('each half is a lone surrogate, not a character', 'b\u{1F600}a'.split('')[1].codePointAt(0) <= 0xDFFF, true);
test('spreading produces no lone surrogates', [...'b\u{1F600}a'].every((c) => c.codePointAt(0) < 0xD800 || c.codePointAt(0) > 0xDFFF), true);
test('so the sorted result has the right number of characters', [...[...'b\u{1F600}a'].sort().join('')].length, 3);
test('whereas the split version silently has four', ['b\u{1F600}a'.split('').sort().join('')].map((r) => r.length)[0], 4);
[...text] rather than text.split('') is the habit worth forming. split('') cuts by UTF-16 code unit, so a character outside the basic range is torn into two halves — and once you sort, those halves end up in different places and the character is destroyed. The assertions check an emoji surviving one and not the other.
The other half is what "alphabetical" means. Default sort() compares code units, so 'bA' becomes 'Ab' because A is 65 and b is 98. That happens to match the source's expected output for 'webmaster', which is all lower case. For mixed case you almost certainly want localeCompare with an explicit locale — and the locale is not optional, or the result depends on the machine.
Answers JS Function 4 in the original set.
5 Capitalise the first letter of each word
easy'the quick brown fox' should give 'The Quick Brown Fox'. The source's expected output has a trailing space; decide whether you want that.
Stuck? Three hints, smallest first
- 1. Split on spaces, capitalise each piece, join back.
- 2. A regex with a capture group does it in one pass:
replace(/\b\w/g, ...). - 3. Real title case leaves small words like "of" and "the" lower case, except at the ends.
// Every word, no exceptions. Note the rest is lower-cased, so 'fOX' is fixed too.
const capitaliseWords = (text) =>
text.split(' ').map((w) => (w === '' ? w : w[0].toUpperCase() + w.slice(1).toLowerCase())).join(' ');
// One pass with a regex. \b is a word boundary, so it catches after a hyphen too.
const capitaliseByRegex = (text) => text.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
// Real title case: small words stay lower, but never the first or last word.
const SMALL = new Set(['a', 'an', 'the', 'of', 'to', 'and', 'or', 'in', 'on', 'at', 'for']);
function titleCase(text) {
const words = text.toLowerCase().split(' ').filter(Boolean);
return words
.map((w, i) =>
i !== 0 && i !== words.length - 1 && SMALL.has(w) ? w : w[0].toUpperCase() + w.slice(1))
.join(' ');
}
console.log("capitaliseWords :", JSON.stringify(capitaliseWords('the quick brown fox')));
console.log('the source shows:', JSON.stringify('The Quick Brown Fox '), '<- with a trailing space');
console.log('capitaliseByRegex:', JSON.stringify(capitaliseByRegex('the quick brown fox')));
console.log('a hyphenated name:', capitaliseByRegex('mary-jane watson'));
console.log('with map instead :', capitaliseWords('mary-jane watson'), '<- the hyphen is not a boundary here');
console.log('real title case :', titleCase('the lord of the rings'));
console.log('and at the ends :', titleCase('a tale of two cities and'));
Output
15 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 15 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('each word is capitalised', capitaliseWords('the quick brown fox'), 'The Quick Brown Fox');
test('with no trailing space, unlike the source', capitaliseWords('the quick brown fox').endsWith(' '), false);
test('shouting is normalised', capitaliseWords('THE QUICK fOX'), 'The Quick Fox');
test('a single word', capitaliseWords('lagos'), 'Lagos');
test('an empty string', capitaliseWords(''), '');
test('already capitalised stays that way', capitaliseWords('Lagos Abuja'), 'Lagos Abuja');
test('the regex version agrees on plain words', capitaliseByRegex('the quick brown fox'), 'The Quick Brown Fox');
test('but it also capitalises after a hyphen', capitaliseByRegex('mary-jane watson'), 'Mary-Jane Watson');
test('whereas the split version does not', capitaliseWords('mary-jane watson'), 'Mary-jane Watson');
test('which is a real difference, not a bug in either', capitaliseByRegex('mary-jane watson') === capitaliseWords('mary-jane watson'), false);
test('title case keeps small words lower', titleCase('the lord of the rings'), 'The Lord of the Rings');
test('but capitalises the first word even if small', titleCase('the rings'), 'The Rings');
test('and the last word too', titleCase('a tale of two cities and'), 'A Tale of Two Cities And');
test('a single small word is still capitalised', titleCase('the'), 'The');
test('extra spaces are collapsed by the title-case version', titleCase('the lord'), 'The Lord');
The source's expected output is 'The Quick Brown Fox ' with a trailing space, which is an artefact of building the result by appending word + ' ' in a loop. join(' ') avoids it, and the assertion checks there is no trailing space — worth being deliberate about, since a trailing space survives into your database and breaks comparisons later.
The two implementations genuinely differ, and the assertion proves it: w treats a hyphen as a word boundary, so 'mary-jane' becomes 'Mary-Jane', while splitting on spaces gives 'Mary-jane'. Neither is wrong; for names the hyphenated version is usually what people want.
titleCase is what you would want for a heading. The rule that small words stay lower except at either end is the part people leave out, and it is why 'The Rings' and '…Cities And' are both asserted.
Answers JS Function 5 in the original set.
6 Find the longest word
easy'Web Development Tutorial' should give 'Development'. Decide what to do when two words tie.
Stuck? Three hints, smallest first
- 1. Split into words, then reduce keeping the longest so far.
- 2.
>versus>=in the comparison decides whether the first or last of a tie wins. - 3. Split on
/\s+/after trimming, so runs of spaces do not create empty words.
function longestWord(text) {
const words = text.trim().split(/\s+/).filter(Boolean);
// > , not >= : the FIRST of a tie wins. Make the choice on purpose.
return words.reduce((longest, w) => (w.length > longest.length ? w : longest), '');
}
// The other choice, for comparison.
const lastLongest = (text) =>
text.trim().split(/\s+/).filter(Boolean).reduce((l, w) => (w.length >= l.length ? w : l), '');
// Every word tied for longest, which is often what you actually wanted.
function allLongest(text) {
const words = text.trim().split(/\s+/).filter(Boolean);
const max = Math.max(0, ...words.map((w) => w.length));
return words.filter((w) => w.length === max);
}
console.log("longestWord('Web Development Tutorial') =", longestWord('Web Development Tutorial'));
console.log('a tie, first wins :', longestWord('Lagos Abuja'), ' last wins:', lastLongest('Lagos Abuja'));
console.log('all of them :', allLongest('Lagos Abuja Kano'));
console.log('extra spaces :', longestWord(' Web Development '));
console.log('empty string :', JSON.stringify(longestWord('')));
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 example", longestWord('Web Development Tutorial'), 'Development');
test('a single word is the longest', longestWord('Lagos'), 'Lagos');
test('an empty string gives an empty string', longestWord(''), '');
test('a string of spaces does too', longestWord(' '), '');
test('runs of spaces do not create empty words', longestWord(' Web Development '), 'Development');
test('a tie goes to the first, with >', longestWord('Lagos Abuja'), 'Lagos');
test('and to the last, with >=', lastLongest('Lagos Abuja'), 'Abuja');
test('which is the whole reason to choose deliberately', longestWord('Lagos Abuja') === lastLongest('Lagos Abuja'), false);
test('every tied word can be returned instead', allLongest('Lagos Abuja Kano'), ['Lagos', 'Abuja']);
test('and a clear winner comes back alone', allLongest('Web Development Tutorial'), ['Development']);
test('an empty string gives no longest words', allLongest(''), []);
test('punctuation counts as part of the word', longestWord('hi there!'), 'there!');
test('unless you strip it first', longestWord('hi there!'.replace(/[^\w\s]/g, '')), 'there');
> versus >= is the entire content of this exercise. With > the first of a tie wins, with >= the last does, and the assertion showing the two disagree on 'Lagos Abuja' is there so the choice cannot be accidental. The same one-character decision appears in every "find the best" reduce you will write.
Often neither is right and you want all the winners, which is why allLongest is here. Note Math.max(0, ...words.map(...)) — the 0 guards the empty case, since Math.max() with no arguments returns -Infinity.
Splitting on /s+/ after trim() handles runs of spaces. And punctuation counts as part of a word unless you remove it, which the last two assertions make explicit rather than leaving as a surprise.
Answers JS Function 6 in the original set.
7 Count the vowels
easy'The quick brown fox' should give 5. The source is explicit that y does not count.
Stuck? Three hints, smallest first
- 1.
match(/[aeiou]/gi)returnsnullwhen there are none, not an empty array. - 2. The
iflag handles capitals, soAcounts as well asa. - 3. Accented vowels are not in
[aeiou], socafécounts two, not three.
const countVowels = (text) => (text.match(/[aeiou]/gi) ?? []).length;
// No regex, for comparison.
const countByLoop = (text) => {
let n = 0;
for (const c of text.toLowerCase()) if ('aeiou'.includes(c)) n++;
return n;
};
// Accented vowels included, by stripping the accents first.
const countVowelsAccented = (text) =>
(text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[aeiou]/gi) ?? []).length;
console.log("countVowels('The quick brown fox') =", countVowels('The quick brown fox'));
console.log('the source expects = 5');
console.log("y is not counted: 'rhythm' ->", countVowels('rhythm'));
console.log("'AEIOU' ->", countVowels('AEIOU'), '(the i flag)');
console.log("--- accents ---");
console.log("'caf\u00e9' plain:", countVowels('caf\u00e9'), ' accent-aware:', countVowelsAccented('caf\u00e9'), '<- the \u00e9 is missed');
console.log("'\u00c9tienne' plain:", countVowels('\u00c9tienne'), ' accent-aware:', countVowelsAccented('\u00c9tienne'));
console.log("'na\u00efve' plain:", countVowels('na\u00efve'), ' accent-aware:', countVowelsAccented('na\u00efve'));
console.log("match on no vowels returns", 'rhythm'.match(/[aeiou]/gi), '<- null, not []');
Output
17 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 17 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", countVowels('The quick brown fox'), 5);
test('capitals count', countVowels('AEIOU'), 5);
test('and lower case', countVowels('aeiou'), 5);
test('y is not a vowel here', countVowels('rhythm'), 0);
test('nor in "y" alone', countVowels('y'), 0);
test('an empty string counts 0', countVowels(''), 0);
test('a string with no vowels does not crash', countVowels('xyz'), 0);
test('because match returns null there', 'xyz'.match(/[aeiou]/gi), null);
test('the loop version agrees', ['The quick brown fox', 'rhythm', 'AEIOU', ''].every((t) => countVowels(t) === countByLoop(t)), true);
test('caf\u00e9 has two vowels to a person', countVowelsAccented('caf\u00e9'), 2);
test('but [aeiou] finds only one, missing the \u00e9', countVowels('caf\u00e9'), 1);
test('so the two disagree', countVowels('caf\u00e9') === countVowelsAccented('caf\u00e9'), false);
test('na\u00efve has three vowels', countVowelsAccented('na\u00efve'), 3);
test('and [aeiou] finds two', countVowels('na\u00efve'), 2);
test('an accented capital counts once accents are stripped', countVowelsAccented('\u00c9tienne'), 4);
test('while [aeiou] misses the \u00c9', countVowels('\u00c9tienne'), 3);
test('spaces and punctuation are ignored either way', countVowels('a e, i!'), 3);
Two recurring details. match with the g flag returns null rather than an empty array when nothing matches, so ?? [] before .length is not optional — the assertion checks the null directly so the reason is on the page.
And [aeiou] is an English assumption. café counts two vowels by that rule and three by any reasonable human reading, which the assertions state as a disagreement rather than picking a side. The fix is normalize('NFD') to split each accented character into a base letter plus a combining mark, then remove the marks — the same normalisation trick that turns up whenever text has to be compared or measured.
Answers JS Function 7 in the original set.
8 Check a prime number, recursively
fairDecide whether a number is prime, using recursion. A prime is a whole number above 1 with no divisors except 1 and itself.
Stuck? Three hints, smallest first
- 1. Only test divisors up to the square root: a factor above it has a partner below it.
- 2. Carry the current divisor as a parameter with a default, so the caller passes one argument.
- 3. 1 is not prime, 2 is, and every even number above 2 is not.
/* The divisor is a parameter with a default, which is how a recursive helper
stays a one-argument function to its caller. */
function isPrime(n, divisor = 2) {
if (!Number.isInteger(n) || n < 2) return false; // 0, 1, negatives, decimals
if (n === 2) return true;
if (n % 2 === 0) return false; // every other even number
if (divisor * divisor > n) return true; // no factor found below sqrt(n)
if (n % divisor === 0) return false;
return isPrime(n, divisor + (divisor === 2 ? 1 : 2)); // then only odd divisors
}
// A loop, to show recursion buys nothing here.
function isPrimeLoop(n) {
if (!Number.isInteger(n) || n < 2) return false;
if (n % 2 === 0) return n === 2;
for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
return true;
}
// Count the divisions, to make the square-root limit concrete.
let checks = 0;
function counted(n, d = 3) {
if (n < 2) return false;
if (n % 2 === 0) return n === 2;
checks++;
if (d * d > n) return true;
if (n % d === 0) return false;
return counted(n, d + 2);
}
counted(9973);
const primesUnder30 = Array.from({ length: 30 }, (_, i) => i).filter((n) => isPrime(n));
console.log('primes under 30:', primesUnder30);
console.log('9973 is prime :', isPrime(9973));
console.log('9975 is prime :', isPrime(9975));
console.log('divisions to prove 9973 prime:', checks, '<- not 9971, because of the sqrt limit');
console.log('sqrt(9973) is about', Math.sqrt(9973).toFixed(1));
Output
16 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 16 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('2 is prime', isPrime(2), true);
test('3 is prime', isPrime(3), true);
test('4 is not', isPrime(4), false);
test('1 is not prime', isPrime(1), false);
test('0 is not', isPrime(0), false);
test('a negative number is not', isPrime(-7), false);
test('a decimal is not', isPrime(7.5), false);
test('the primes under 30', primesUnder30, [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]);
test('a large prime', isPrime(9973), true);
test('and a nearby composite', isPrime(9975), false);
test('9 is not prime, which a sloppy version gets wrong', isPrime(9), false);
test('nor is 25', isPrime(25), false);
test('nor 49', isPrime(49), false);
test('the loop version agrees for 0 to 100', Array.from({ length: 101 }, (_, i) => isPrime(i) === isPrimeLoop(i)).every(Boolean), true);
test('the square-root limit keeps the work small', checks < 60, true);
test('far below testing every number up to n', checks < 9973 / 100, true);
Two things make this efficient rather than merely correct. Stopping at divisor * divisor > n works because factors come in pairs: if n = a × b and a is above √n, then b is below it, so you would already have found b. The assertion shows proving 9973 prime takes under 60 divisions instead of nearly ten thousand. And divisor * divisor > n is preferred to divisor > Math.sqrt(n) because it avoids a floating-point square root and any rounding at the boundary.
Passing the divisor as a defaulted parameter is the pattern worth taking from this section: the recursion needs state, the caller should not have to know about it, so give it a default. Note also that recursion buys nothing here — the loop is the same algorithm without the stack frames. The assertions include 9, 25 and 49 because a version that forgets to test odd divisors gets all three wrong.
Answers JS Function 8 in the original set.
9 Return the type of an argument
fairReturn the type of whatever is passed in. The source says typeof has six possible results; check that.
Stuck? Three hints, smallest first
- 1.
typeofnow has eight results, not six. Two were added to the language later. - 2.
typeof nullis‘object’, which is a permanent bug. - 3. For a useful answer,
Object.prototype.toStringdistinguishes arrays, dates and null.
const typeOf = (value) => typeof value;
// A more useful answer: the internal tag, which typeof cannot give you.
const preciseType = (value) => Object.prototype.toString.call(value).slice(8, -1);
const samples = [{}, true, () => {}, 1, 'text', undefined, 1n, Symbol('s'),
null, [], new Date(0), /x/, new Map(), NaN];
const seen = new Set(samples.map(typeOf));
for (const v of samples) {
const shown = typeof v === 'symbol' ? 'Symbol(s)' : typeof v === 'bigint' ? '1n' : JSON.stringify(v) ?? String(v);
console.log(String(shown).padEnd(12), 'typeof:', typeOf(v).padEnd(10), 'precise:', preciseType(v));
}
console.log('--- the source says six. the real list is: ---');
console.log([...seen].sort().join(', '));
console.log('count:', seen.size);
Output
20 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 20 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('an object', typeOf({}), 'object');
test('a boolean', typeOf(true), 'boolean');
test('a function', typeOf(() => {}), 'function');
test('a number', typeOf(1), 'number');
test('a string', typeOf('text'), 'string');
test('undefined', typeOf(undefined), 'undefined');
test('bigint, which the source does not list', typeOf(1n), 'bigint');
test('symbol, which it does not either', typeOf(Symbol('s')), 'symbol');
test('so there are eight results, not six', new Set(['object', 'boolean', 'function', 'number', 'string', 'undefined', 'bigint', 'symbol']).size, 8);
test('null reports as object, which is a 1995 bug', typeOf(null), 'object');
test('an array also reports as object', typeOf([]), 'object');
test('so does a date', typeOf(new Date(0)), 'object');
test('and NaN reports as a number', typeOf(NaN), 'number');
test('the precise version separates null out', preciseType(null), 'Null');
test('and arrays', preciseType([]), 'Array');
test('and dates', preciseType(new Date(0)), 'Date');
test('and regular expressions', preciseType(/x/), 'RegExp');
test('and Maps', preciseType(new Map()), 'Map');
test('while still calling a plain object an Object', preciseType({}), 'Object');
test('Array.isArray is the reliable array test', [Array.isArray([]), Array.isArray({})], [true, false]);
The source is out of date, and the assertion counting eight rather than six is the exercise. symbol arrived with ES6 and bigint later still, so any list written from an older reference is short by two.
typeof is also coarse in a way that matters. It reports 'object' for null, for arrays, for dates and for Maps, which is almost never the answer you wanted. typeof null === 'object' is a genuine bug in the first implementation; fixing it was proposed and rejected because too much code depends on it, so it is permanent.
Object.prototype.toString.call(v).slice(8, -1) gives 'Null', 'Array', 'Date', 'RegExp', 'Map' and so on. It is the one check that is never fooled, and it is worth keeping as a two-line helper. For arrays specifically, Array.isArray is clearer and works across realms.
Answers JS Function 9 in the original set.
10 Build an identity matrix
easyReturn the n by n identity matrix: ones down the diagonal, zeros everywhere else.
Stuck? Three hints, smallest first
- 1. The value at row
i, columnjis1wheni === j. - 2.
Array(n).fill(row)puts the same row object in every position. - 3.
Array.from({ length: n }, ...)calls the function once per row, giving distinct rows.
// Array.from calls the mapper per row, so each row is a separate array.
const identity = (n) =>
Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
// The trap: fill puts the SAME array in every row.
const broken = (n) => {
const row = new Array(n).fill(0);
const matrix = new Array(n).fill(row); // every row is the identical object
return matrix.map((r, i) => { r[i] = 1; return r; });
};
const show = (m) => m.map((r) => r.join(' ')).join('\n');
console.log('identity(3):');
console.log(show(identity(3)));
console.log('identity(1):', JSON.stringify(identity(1)));
console.log('identity(0):', JSON.stringify(identity(0)));
console.log('--- the fill trap ---');
console.log('broken(3):');
console.log(show(broken(3)), '<- every row is the same array, so all diagonals got set');
const m = identity(3);
m[0][0] = 99;
Output
15 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 15 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a 3x3 identity', identity(3), [[1, 0, 0], [0, 1, 0], [0, 0, 1]]);
test('a 1x1 identity', identity(1), [[1]]);
test('a 0x0 identity is empty', identity(0), []);
test('a 4x4 has four rows', identity(4).length, 4);
test('each of four columns', identity(4).every((r) => r.length === 4), true);
test('the diagonal is all ones', identity(5).every((r, i) => r[i] === 1), true);
test('and everything else is zero', identity(5).every((r, i) => r.every((v, j) => (i === j ? v === 1 : v === 0))), true);
test('the total of every entry is n', identity(6).flat().reduce((a, b) => a + b, 0), 6);
test('the rows are distinct objects', identity(3)[0] === identity(3)[1], false);
test('so changing one row leaves the others alone', (() => { const x = identity(3); x[0][0] = 99; return x[1][1]; })(), 1);
test('and the change is visible in its own row', m[0], [99, 0, 0]);
test('while fill shares one row between all of them', (() => { const b = broken(3); return b[0] === b[1]; })(), true);
test('which corrupts the result', broken(3)[0], [1, 1, 1]);
test('so every row reads the same', (() => { const b = broken(3); return b[0] === b[2]; })(), true);
test('instead of the identity matrix', same(broken(3), identity(3)), false);
Array(n).fill(row) is the trap, and it is worth meeting once. fill stores the same reference in every position, so all n rows are one array wearing n hats — setting row[i] = 1 for each i sets every diagonal position on the single shared row, and the assertion shows [1, 1, 1] coming out.
Array.from({ length: n }, mapper) calls the mapper once per index, so each row is a fresh array. The assertion that identity(3)[0] !== identity(3)[1] is checking exactly that. The same trap catches fill({}), fill([]) and any new Array(n).fill(someObject) — anywhere you meant "n separate things" and got "one thing, n times".
Answers JS Function 10 in the original set.
11 Second lowest and second greatest
fair[1,2,3,4,5] should give 2 and 4. Decide what duplicates mean, and what happens when there is no second value.
Stuck? Three hints, smallest first
- 1. Sorting is the simple route, but
sort()mutates and compares as text by default. - 2. Duplicates are the real question: in
[1,1,2], is the second lowest 1 or 2? - 3. A list with fewer than two distinct values has no answer. Say so rather than returning
undefined.
// Distinct values, which is almost always what "second lowest" means.
function secondValues(numbers) {
const distinct = [...new Set(numbers)].sort((a, b) => a - b); // copy, numeric compare
if (distinct.length < 2) return null;
return { secondLowest: distinct[1], secondGreatest: distinct.at(-2) };
}
// By position instead, counting duplicates as separate entries.
function secondByPosition(numbers) {
if (numbers.length < 2) return null;
const sorted = [...numbers].sort((a, b) => a - b);
return { secondLowest: sorted[1], secondGreatest: sorted.at(-2) };
}
// One pass, no sorting. O(n) rather than O(n log n).
function secondLowestOnePass(numbers) {
let lowest = Infinity;
let second = Infinity;
for (const n of numbers) {
if (n < lowest) { second = lowest; lowest = n; }
else if (n > lowest && n < second) second = n;
}
return second === Infinity ? null : second;
}
const sample = [1, 2, 3, 4, 5];
console.log("the source's sample:", secondValues(sample));
console.log('with duplicates [1,1,2,2,3]:');
console.log(' distinct :', secondValues([1, 1, 2, 2, 3]));
console.log(' by position:', secondByPosition([1, 1, 2, 2, 3]), '<- second lowest is 1');
console.log('all the same [5,5,5]:', secondValues([5, 5, 5]));
console.log('one element :', secondValues([1]));
console.log('sort() as text would give:', [10, 9, 100].slice().sort());
Output
16 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 16 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", secondValues(sample), { secondLowest: 2, secondGreatest: 4 });
test('two elements', secondValues([1, 2]), { secondLowest: 2, secondGreatest: 1 });
test('negatives sort correctly', secondValues([-5, -1, 0, 3]), { secondLowest: -1, secondGreatest: 0 });
test('a numeric comparator is needed', secondValues([10, 9, 100, 1]), { secondLowest: 9, secondGreatest: 10 });
test('without one, sort compares as text', [10, 9, 100].slice().sort(), [10, 100, 9]);
test('duplicates are collapsed by default', secondValues([1, 1, 2, 2, 3]), { secondLowest: 2, secondGreatest: 2 });
test('while by position they are not', secondByPosition([1, 1, 2, 2, 3]).secondLowest, 1);
test('so the two answers differ', secondValues([1, 1, 2, 2, 3]).secondLowest === secondByPosition([1, 1, 2, 2, 3]).secondLowest, false);
test('all-identical values have no second', secondValues([5, 5, 5]), null);
test('one element has no second', secondValues([1]), null);
test('an empty list has no second', secondValues([]), null);
test('the input is not modified', (() => { const a = [3, 1, 2]; secondValues(a); return a; })(), [3, 1, 2]);
test('the one-pass version agrees on the second lowest', secondLowestOnePass(sample), 2);
test('and on a list with duplicates', secondLowestOnePass([1, 1, 2]), 2);
test('and reports null when there is none', secondLowestOnePass([5, 5]), null);
test('it agrees with the sorting version generally', [[1, 2, 3], [5, 1, 9, 1], [-2, 0], [7]].every((a) => secondLowestOnePass(a) === (secondValues(a)?.secondLowest ?? null)), true);
The specification gap is duplicates. In [1, 1, 2], "second lowest" is either 1 (the second element in sorted order) or 2 (the second distinct value), and the source's example cannot tell you which because it has none. Both are implemented and the assertion showing them disagree is the honest answer — pick one and pin it down with a test.
Two mechanical points. [...numbers].sort(...) copies first, because sort mutates; and it needs the (a, b) => a - b comparator, because the default converts to text and puts 100 before 9. Both are asserted.
secondLowestOnePass is worth the extra lines when the list is large: one pass instead of a sort. The n > lowest condition in the else-if is what makes it skip duplicates rather than counting them.
Answers JS Function 11 in the original set.
12 Is the number perfect?
fairA perfect number equals the sum of its proper divisors. 6 is the first, because 1 + 2 + 3 = 6. Then 28, 496 and 8128.
Stuck? Three hints, smallest first
- 1. Proper divisors exclude the number itself.
- 2. You only need to test up to the square root: each divisor found gives its partner.
- 3. When
i * i === n, addionce, not twice.
// The direct version: test every number below n.
function isPerfectSimple(n) {
if (!Number.isInteger(n) || n < 2) return false;
let sum = 0;
for (let i = 1; i < n; i++) if (n % i === 0) sum += i;
return sum === n;
}
/* Faster: divisors come in pairs, so stop at the square root and add both.
Two details: start the sum at 1 and the loop at 2, so n itself is excluded;
and when i is exactly the square root, add it once. */
function isPerfect(n) {
if (!Number.isInteger(n) || n < 2) return false;
let sum = 1; // 1 is always a proper divisor
for (let i = 2; i * i <= n; i++) {
if (n % i !== 0) continue;
sum += i;
const partner = n / i;
if (partner !== i) sum += partner; // not twice for a perfect square
}
return sum === n;
}
const properDivisors = (n) => Array.from({ length: n - 1 }, (_, i) => i + 1).filter((d) => n % d === 0);
const found = [];
for (let n = 2; n <= 10000; n++) if (isPerfect(n)) found.push(n);
console.log('perfect numbers up to 10000:', found);
console.log('6 =', properDivisors(6).join(' + '));
console.log('28 =', properDivisors(28).join(' + '));
console.log('496 divisors:', properDivisors(496).join(', '));
console.log('--- the square-root detail ---');
console.log('divisors of 16 found by pairing: 1, 2, 8, 4 — and 4 only once, since 4 x 4 = 16');
console.log('sum for 16 is', properDivisors(16).reduce((a, b) => a + b, 0), 'so 16 is not perfect');
Output
15 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 15 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('6 is perfect', isPerfect(6), true);
test('and its divisors add up', properDivisors(6).reduce((a, b) => a + b, 0), 6);
test('28 is perfect', isPerfect(28), true);
test('496 is perfect', isPerfect(496), true);
test('8128 is perfect', isPerfect(8128), true);
test('those four are the only ones up to 10000', found, [6, 28, 496, 8128]);
test('1 is not perfect', isPerfect(1), false);
test('0 is not', isPerfect(0), false);
test('a negative number is not', isPerfect(-6), false);
test('a decimal is not', isPerfect(6.5), false);
test('12 is not, its divisors sum to 16', isPerfect(12), false);
test('and 16 is not, being a perfect square', isPerfect(16), false);
test('which is where the double-counting bug would show', properDivisors(16).reduce((a, b) => a + b, 0), 15);
test('the simple version agrees for 1 to 500', Array.from({ length: 500 }, (_, i) => isPerfect(i + 1) === isPerfectSimple(i + 1)).every(Boolean), true);
test('every perfect number found is confirmed by listing its divisors', found.every((n) => properDivisors(n).reduce((a, b) => a + b, 0) === n), true);
Pairing divisors is the same square-root idea as the prime check, with one extra wrinkle: when i * i === n, the divisor and its partner are the same number and adding both counts it twice. For 16 that turns a sum of 15 into 19 and reports 16 as perfect. The assertion checking properDivisors(16) sums to 15 is what pins that down, and 16 is in the test set for exactly that reason.
Starting sum at 1 and the loop at 2 is how n itself stays out of the total — the divisor pair of 1 is n, which is not a proper divisor.
All four perfect numbers under 10,000 are found and each is independently confirmed by listing its divisors, so the fast version is checked against a definition rather than against my expectations.
Answers JS Function 12 in the original set.
13 Compute the factors of a number
easyList all the factors of a positive integer, in order.
Stuck? Three hints, smallest first
- 1. Pair them: when
idividesn, so doesn / i. - 2. That finds them out of order, so sort numerically at the end.
- 3. A perfect square has an odd number of factors, because one pairs with itself.
// Pairing, so the loop runs to sqrt(n) rather than n.
function factors(n) {
if (!Number.isInteger(n) || n < 1) return [];
const found = new Set();
for (let i = 1; i * i <= n; i++) {
if (n % i !== 0) continue;
found.add(i);
found.add(n / i); // the Set handles the sqrt case for free
}
return [...found].sort((a, b) => a - b);
}
const primeFactors = (n) => {
const out = [];
let left = n;
for (let d = 2; d * d <= left; d++) {
while (left % d === 0) { out.push(d); left /= d; }
}
if (left > 1) out.push(left);
return out;
};
for (const n of [12, 28, 36, 13, 1, 100]) {
console.log(String(n).padEnd(5), 'factors:', factors(n).join(', '), ' | prime factors:', primeFactors(n).join(' x '));
}
console.log('--- a perfect square has an odd count ---');
console.log('36 has', factors(36).length, 'factors, because 6 pairs with itself');
console.log('35 has', factors(35).length);
Output
19 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 19 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the factors of 12', factors(12), [1, 2, 3, 4, 6, 12]);
test('of 28', factors(28), [1, 2, 4, 7, 14, 28]);
test('of a prime, just 1 and itself', factors(13), [1, 13]);
test('of 1, just 1', factors(1), [1]);
test('of 100', factors(100), [1, 2, 4, 5, 10, 20, 25, 50, 100]);
test('they come out sorted', factors(36).every((f, i, a) => i === 0 || f > a[i - 1]), true);
test('1 is always a factor', factors(97)[0], 1);
test('and n itself is always the last', factors(97).at(-1), 97);
test('every factor really divides n', factors(36).every((f) => 36 % f === 0), true);
test('and nothing is missing', factors(36).length, Array.from({ length: 36 }, (_, i) => i + 1).filter((d) => 36 % d === 0).length);
test('a perfect square has an odd number of factors', factors(36).length % 2, 1);
test('while other numbers have an even number', factors(35).length % 2, 0);
test('zero has no factors by this definition', factors(0), []);
test('nor does a negative number', factors(-12), []);
test('nor a decimal', factors(6.5), []);
test('the prime factors multiply back to n', primeFactors(12).reduce((a, b) => a * b, 1), 12);
test('and for a larger number', primeFactors(360).reduce((a, b) => a * b, 1), 360);
test('a prime has itself as its only prime factor', primeFactors(13), [13]);
test('12 is 2 x 2 x 3', primeFactors(12), [2, 2, 3]);
The Set earns its place here: pairing finds i and n / i together, and for a perfect square those are the same number, so a plain array would contain 6 twice for n = 36. The Set handles it with no special case, and the assertion that a perfect square has an odd number of factors is the check that it worked.
Pairing also means the loop runs to √n instead of n, at the cost of finding the factors out of order — hence the numeric sort at the end, which needs the comparator like every numeric sort.
primeFactors is a different question worth having beside it: the factors of 12 are 1, 2, 3, 4, 6, 12, while its prime factorisation is 2 × 2 × 3. The assertion multiplying the prime factors back to n is a good self-check, since it fails if any factor is missed or repeated wrongly.
Answers JS Function 13 in the original set.
14 Break an amount into coins
fairamountToCoins(46, [25, 10, 5, 2, 1]) should give 25, 10, 10, 1. Then find the coin set where this approach fails.
Stuck? Three hints, smallest first
- 1. Greedy: take the largest coin that fits, repeatedly.
- 2. That is only guaranteed optimal for some coin systems, including ordinary currency.
- 3. Sort the coins descending first, so the caller does not have to.
// Greedy: always take the biggest coin that fits.
function amountToCoins(amount, coins) {
if (!Number.isInteger(amount) || amount < 0) return null;
const sorted = [...coins].sort((a, b) => b - a); // descending, and a copy
const out = [];
let left = amount;
for (const coin of sorted) {
while (left >= coin) { out.push(coin); left -= coin; }
}
return left === 0 ? out : { incomplete: out, remaining: left };
}
// The genuinely optimal answer, by dynamic programming.
function fewestCoins(amount, coins) {
const best = Array(amount + 1).fill(null);
best[0] = [];
for (let target = 1; target <= amount; target++) {
for (const coin of coins) {
if (coin > target || best[target - coin] === null) continue;
const candidate = [...best[target - coin], coin];
if (best[target] === null || candidate.length < best[target].length) best[target] = candidate;
}
}
return best[amount];
}
const normal = [25, 10, 5, 2, 1];
console.log('amountToCoins(46, [25,10,5,2,1]) =', amountToCoins(46, normal).join(', '));
console.log("the source expects = 25, 10, 10, 1");
console.log('sum check:', amountToCoins(46, normal).reduce((a, b) => a + b, 0));
console.log('--- where greedy fails ---');
const awkward = [25, 10, 4];
console.log('greedy for 41 with [25,10,4]:', JSON.stringify(amountToCoins(41, awkward)));
console.log('<- it took 25, 10 and 4, then had 2 left and no coin small enough');
console.log('optimal for 41 :', fewestCoins(41, awkward).join(' + '), '=', fewestCoins(41, awkward).reduce((a, b) => a + b, 0));
Output
18 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 18 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", amountToCoins(46, normal), [25, 10, 10, 1]);
test('and it adds up', amountToCoins(46, normal).reduce((a, b) => a + b, 0), 46);
test('an exact coin gives one coin', amountToCoins(25, normal), [25]);
test('zero gives no coins', amountToCoins(0, normal), []);
test('one gives the smallest coin', amountToCoins(1, normal), [1]);
test('coins come out largest first', amountToCoins(46, normal).every((c, i, a) => i === 0 || c <= a[i - 1]), true);
test('an unsorted coin list still works', amountToCoins(46, [1, 25, 2, 10, 5]), [25, 10, 10, 1]);
test('the caller\u2019s list is not reordered', (() => { const c = [1, 25, 5]; amountToCoins(30, c); return c; })(), [1, 25, 5]);
test('a negative amount is refused', amountToCoins(-5, normal), null);
test('a decimal amount is refused', amountToCoins(5.5, normal), null);
test('greedy fails on an awkward coin set', Array.isArray(amountToCoins(41, awkward)), false);
test('and reports what it could not make', amountToCoins(41, awkward).remaining, 2);
test('the optimal version does make 41', fewestCoins(41, awkward).reduce((a, b) => a + b, 0), 41);
test('using five coins', fewestCoins(41, awkward).length, 5);
test('namely 25 and four 4s', fewestCoins(41, awkward).slice().sort((a, b) => a - b), [4, 4, 4, 4, 25]);
test('which greedy never found', Array.isArray(amountToCoins(41, awkward)), false);
test('on a normal coin set the two agree', fewestCoins(46, normal).length, amountToCoins(46, normal).length);
test('and for many amounts', Array.from({ length: 60 }, (_, i) => i + 1).every((n) => fewestCoins(n, normal).length === amountToCoins(n, normal).length), true);
Greedy is the obvious approach and it is not always right. With coins [25, 10, 4] and an amount of 41, greedy takes 25, then 10, and is left needing 6 — which it cannot make. The assertions show it returning an incomplete result with 6 remaining, while the optimal answer is 4 + 4 + 4 + 25. This is why the function reports failure rather than silently returning coins that do not add up.
Greedy is optimal for what are called canonical coin systems, which includes every real currency — the last assertion checks the two agree for every amount from 1 to 60 with ordinary coins. So in practice greedy is fine; the point is knowing the condition rather than assuming it.
fewestCoins builds up the best answer for every amount from 0 to the target, reusing the smaller results. That is dynamic programming, and it is the standard fix when a greedy choice can paint you into a corner.
Answers JS Function 14 in the original set.
15 Compute b to the power n
easyRaise a base to an exponent, taking both from the caller. Handle a zero and a negative exponent.
Stuck? Three hints, smallest first
- 1. Anything to the power 0 is 1, including
0 ** 0in JavaScript. - 2. A negative exponent is
1 / power(b, -n). - 3. Exponentiation by squaring turns n multiplications into about log2(n).
function power(base, exponent) {
if (!Number.isInteger(exponent)) return base ** exponent; // fall back for fractions
if (exponent === 0) return 1;
if (exponent < 0) return 1 / power(base, -exponent);
// By squaring: x^8 is (x^4)^2, so the exponent halves each step.
const half = power(base, Math.floor(exponent / 2));
return exponent % 2 === 0 ? half * half : half * half * base;
}
// The straightforward loop, for comparison.
const powerLoop = (base, exponent) => {
if (exponent < 0) return 1 / powerLoop(base, -exponent);
let out = 1;
for (let i = 0; i < exponent; i++) out *= base;
return out;
};
let steps = 0;
const counted = (b, e) => { steps++; if (e === 0) return 1; const h = counted(b, Math.floor(e / 2)); return e % 2 === 0 ? h * h : h * h * b; };
counted(2, 1024);
for (const [b, e] of [[8, 2], [2, 10], [5, 0], [2, -3], [-3, 3], [-3, 2], [0, 0]]) {
console.log(`${b} ^ ${e}`.padEnd(9), '=', power(b, e));
}
console.log('calls for 2^1024 by squaring:', steps, 'instead of 1025');
Output
16 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 16 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 kind of example: 8 squared", power(8, 2), 64);
test('2 to the 10th', power(2, 10), 1024);
test('anything to the 0 is 1', power(5, 0), 1);
test('including 0 to the 0, in JavaScript', power(0, 0), 0 ** 0);
test('to the power 1 is itself', power(9, 1), 9);
test('a negative exponent inverts', power(2, -3), 0.125);
test('a negative base with an odd exponent stays negative', power(-3, 3), -27);
test('and turns positive on an even one', power(-3, 2), 9);
test('0 to a positive power is 0', power(0, 5), 0);
test('1 to any power is 1', power(1, 99), 1);
test('it matches the ** operator', power(2, 20), 2 ** 20);
test('and Math.pow', power(3, 7), Math.pow(3, 7));
test('the loop version agrees', [[8, 2], [2, 10], [5, 0], [2, -3], [-3, 3]].every(([b, e]) => power(b, e) === powerLoop(b, e)), true);
test('squaring needs far fewer steps', steps <= 12, true);
test('rather than one per exponent', steps < 1025 / 50, true);
test('a fractional exponent falls back to **', power(9, 0.5), 3);
Exponentiation by squaring is the content. x^8 is (x^4)^2, so each step halves the exponent and 1025 multiplications become 12 — the assertion checks the count rather than describing it. The exponent % 2 split is the whole mechanism: an even exponent is one squaring of the half, an odd one is that plus a single leftover factor.
In real code write base ** exponent. The pattern still matters, though: modular exponentiation in cryptography is this exact function, where the numbers are far too large to multiply one at a time.
0 ** 0 being 1 is a convention, not a mathematical fact — it is genuinely undefined in analysis, and JavaScript, C and Python all choose 1. The assertion compares against the operator rather than hard-coding it, so it documents the language's choice instead of asserting a truth.
Answers JS Function 15 in the original set.
16 Extract the unique characters
easy"thequickbrownfoxjumpsoverthelazydog" should give "thequickbrownfxjmpsvlazydg" — first occurrences only, in order.
Stuck? Three hints, smallest first
- 1.
new Set(text)keeps insertion order and drops repeats. - 2. Spread it back into an array and
join. - 3. A Set iterates by code point, so it handles characters outside the basic range.
const unique = (text) => [...new Set(text)].join('');
// Before Set existed, and it shows what "first occurrence" means.
const uniqueByIndex = (text) => [...text].filter((c, i) => text.indexOf(c) === i).join('');
const pangram = 'thequickbrownfoxjumpsoverthelazydog';
console.log('input :', pangram);
console.log('unique:', unique(pangram));
console.log('source: thequickbrownfxjmpsvlazydg');
console.log('length :', unique(pangram).length, 'from', pangram.length);
console.log('is it a pangram?', unique(pangram).length === 26);
console.log('--- order is preserved ---');
console.log("unique('banana') =", unique('banana'), '<- b, a, n in first-seen order');
console.log("unique('BbAa') =", unique('BbAa'), '<- case-sensitive');
Output
15 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 15 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 exact output", unique(pangram), 'thequickbrownfxjmpsvlazydg');
test('26 distinct letters, so it is a pangram', unique(pangram).length, 26);
test('first occurrences, in order', unique('banana'), 'ban');
test('a string with no repeats is unchanged', unique('abc'), 'abc');
test('an empty string stays empty', unique(''), '');
test('one character', unique('a'), 'a');
test('all the same character gives one', unique('aaaa'), 'a');
test('it is case-sensitive', unique('BbAa'), 'BbAa');
test('spaces count as characters', unique('a a b'), 'a b');
test('the index version agrees', ['banana', pangram, '', 'aaaa', 'BbAa'].every((t) => unique(t) === uniqueByIndex(t)), true);
test('every character in the result appeared in the input', [...unique(pangram)].every((c) => pangram.includes(c)), true);
test('and every input character appears in the result', [...pangram].every((c) => unique(pangram).includes(c)), true);
test('a Set iterates by code point, so there are two distinct characters', [...unique('a\u{1F600}a')].length, 2);
test('though the string is three code units long', unique('a\u{1F600}a').length, 3);
test('and the emoji survives intact', unique('a\u{1F600}a').includes('\u{1F600}'), true);
[...new Set(text)].join('') is the answer, and the reason it is the right answer is that a Set preserves insertion order. That is what makes "first occurrences, in order" fall out for free — the 'banana' assertion giving 'ban' is checking that guarantee.
The index-based version is worth seeing because it spells out the definition: keep a character only if this is the first position it appears at. It is O(n²) where the Set is O(n), but it makes the intent readable.
Iterating a string through a Set also walks code points rather than code units, so an emoji stays in one piece — the last assertions check that. split('') would have torn it in half.
Answers JS Function 16 in the original set.
17 Count each letter’s occurrences
easyReport how many times each letter appears in a string.
Stuck? Three hints, smallest first
- 1. Build an object keyed by character, incrementing as you go.
- 2.
counts[c] = (counts[c] ?? 0) + 1handles the first occurrence. - 3. A plain object inherits keys like
toString;Object.create(null)or aMapdoes not.
function letterCounts(text) {
// Object.create(null) has no prototype, so a character sequence like
// "constructor" cannot collide with an inherited property.
const counts = Object.create(null);
for (const c of text) counts[c] = (counts[c] ?? 0) + 1;
return counts;
}
// A Map, which keeps insertion order and any key type.
function letterCountsMap(text) {
const counts = new Map();
for (const c of text) counts.set(c, (counts.get(c) ?? 0) + 1);
return counts;
}
// Letters only, case-folded, which is usually the actual question.
const lettersOnly = (text) => letterCounts(text.toLowerCase().replace(/[^a-z]/g, ''));
const sorted = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'en'));
console.log("letterCounts('banana') =", { ...letterCounts('banana') });
console.log("lettersOnly('Hello, World!') =", { ...lettersOnly('Hello, World!') });
console.log('most common first:', sorted(lettersOnly('Hello, World!')).slice(0, 3));
console.log('--- why Object.create(null) ---');
const plain = {};
console.log("a plain object already 'has' toString:", typeof plain.toString);
console.log('so counting the word "toString" would start from a function, not 0');
const bare = letterCounts('toString');
console.log('with a bare object it counts properly:', { ...bare });
Output
17 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 17 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('banana', { ...letterCounts('banana') }, { b: 1, a: 3, n: 2 });
test('the counts add up to the length', Object.values(letterCounts('banana')).reduce((a, b) => a + b, 0), 6);
test('an empty string gives no counts', { ...letterCounts('') }, {});
test('one character', { ...letterCounts('a') }, { a: 1 });
test('it is case-sensitive by default', { ...letterCounts('aA') }, { a: 1, A: 1 });
test('while the letters-only version folds case', { ...lettersOnly('aA') }, { a: 2 });
test('and drops punctuation', { ...lettersOnly('a!a') }, { a: 2 });
test('spaces are counted by the plain version', letterCounts('a a')[' '], 1);
test('but not by the letters-only one', lettersOnly('a a')[' '], undefined);
test('every distinct character gets a key', Object.keys(letterCounts('banana')).length, 3);
test('the Map version agrees', [...letterCountsMap('banana')], [['b', 1], ['a', 3], ['n', 2]]);
test('and keeps first-seen order', [...letterCountsMap('banana').keys()], ['b', 'a', 'n']);
test('a plain object would inherit toString', typeof {}.toString, 'function');
test('so a bare object is used instead', letterCounts('toString').t, 2);
test('and its prototype really is null', Object.getPrototypeOf(letterCounts('x')), null);
test('sorting by count gives the most common first', sorted(lettersOnly('Hello, World!'))[0], ['l', 3]);
test('most common in banana', sorted(letterCounts('banana'))[0], ['a', 3]);
counts[c] = (counts[c] ?? 0) + 1 is the shape to memorise for any tally. Using ?? rather than || matters in general, though not here — || would also replace a legitimate 0, and counts start at 1 so it happens to be safe.
Object.create(null) is the detail worth taking seriously. A plain {} inherits toString, constructor, valueOf and more, so a tally keyed by arbitrary text can collide with an inherited property. The assertions confirm the prototype is null and that counting the word 'toString' works. With user-supplied keys this stops being theoretical, and __proto__ as a key on a plain object is a known source of bugs.
A Map is the other good answer: it keeps insertion order for every key type, has no inherited keys at all, and reports size directly.
Answers JS Function 17 in the original set.
18 Binary search in an array
fairSearch a sorted array by halving the range. Return the index, and handle the case where the array is not sorted.
Stuck? Three hints, smallest first
- 1. The array must already be sorted, or the answer is silently wrong.
- 2.
while (low <= high), not<, or the one-element range is skipped. - 3. A comparator parameter lets it search objects, not only numbers.
function binarySearch(sorted, target, compare = (a, b) => a - b) {
let low = 0;
let high = sorted.length - 1;
while (low <= high) { // <= : the one-element range matters
const mid = low + Math.floor((high - low) / 2);
const order = compare(sorted[mid], target);
if (order === 0) return mid;
if (order < 0) low = mid + 1;
else high = mid - 1;
}
return -1;
}
// Refuse unsorted input rather than returning a wrong answer quietly.
const isSorted = (a, compare = (x, y) => x - y) => a.every((v, i) => i === 0 || compare(a[i - 1], v) <= 0);
function safeBinarySearch(sorted, target, compare) {
if (!isSorted(sorted, compare)) throw new TypeError('binary search needs a sorted array');
return binarySearch(sorted, target, compare);
}
const numbers = [1, 3, 5, 7, 9, 11, 13];
const buses = [
{ plate: 'ABJ-772-KM', seats: 30 },
{ plate: 'ENU-318-PH', seats: 7 },
{ plate: 'LAG-441-XA', seats: 14 },
];
const byPlate = (a, b) => a.plate.localeCompare(typeof b === 'string' ? b : b.plate, 'en');
for (const t of [1, 7, 13, 8]) console.log('looking for', String(t).padEnd(3), '->', binarySearch(numbers, t));
console.log('searching objects by plate:', binarySearch(buses, 'ENU-318-PH', byPlate));
console.log('--- unsorted input ---');
const unsorted = [9, 1, 7, 3];
console.log('plain search on unsorted for 9:', binarySearch(unsorted, 9), '(9 is really at index 0)');
console.log('and for 7 it happens to be right:', binarySearch(unsorted, 7), '<- luck, not correctness');
const refused = (() => { try { safeBinarySearch(unsorted, 7); return 'no error'; } catch (e) { return e.constructor.name; } })();
console.log('safe version:', refused);
Output
21 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 21 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the first element', binarySearch(numbers, 1), 0);
test('the middle', binarySearch(numbers, 7), 3);
test('the last', binarySearch(numbers, 13), 6);
test('an absent value gives -1', binarySearch(numbers, 8), -1);
test('below the range', binarySearch(numbers, 0), -1);
test('above the range', binarySearch(numbers, 99), -1);
test('an empty array', binarySearch([], 1), -1);
test('a single element, found', binarySearch([42], 42), 0);
test('a single element, missed', binarySearch([42], 1), -1);
test('every element is found at its own index', numbers.every((v, i) => binarySearch(numbers, v) === i), true);
test('it agrees with indexOf throughout', numbers.every((v) => binarySearch(numbers, v) === numbers.indexOf(v)), true);
test('a comparator lets it search objects', binarySearch(buses, 'ENU-318-PH', byPlate), 1);
test('and miss cleanly', binarySearch(buses, 'KAN-905-BD', byPlate), -1);
test('unsorted input gives a WRONG answer, not an error', binarySearch(unsorted, 9), -1);
test('even though 9 is right there at index 0', unsorted.indexOf(9), 0);
test('and on other targets it can be right by luck', binarySearch(unsorted, 7), unsorted.indexOf(7));
test('which is what makes the bug hard to spot', binarySearch(unsorted, 9) === unsorted.indexOf(9), false);
test('which is why the safe version refuses it', refused, 'TypeError');
test('the sorted check accepts sorted input', isSorted(numbers), true);
test('and rejects unsorted', isSorted(unsorted), false);
test('duplicates are still sorted', isSorted([1, 1, 2]), true);
Searching 2 covers the algorithm; the angle here is the function's interface. A compare parameter with a default turns a numbers-only search into one that works on objects — the assertion searching buses by plate uses the same function body.
The other addition is refusing bad input. Binary search on an unsorted array does not throw; it returns a confidently wrong index, and the assertion showing it disagreeing with indexOf is the proof. safeBinarySearch checks first and throws, which costs O(n) and so defeats the point if you call it in a loop — the honest position is to validate once at the boundary where the data arrives, not on every search.
Answers JS Function 18 in the original set.
19 Elements larger than a number
easyReturn the array elements greater than a given number.
Stuck? Three hints, smallest first
- 1.
filteris the whole answer. - 2. Decide whether "larger than" includes equal. Name the function so it says.
- 3.
filterreturns a new array; the original is untouched.
const largerThan = (numbers, limit) => numbers.filter((n) => n > limit);
const atLeast = (numbers, limit) => numbers.filter((n) => n >= limit);
// Taking the test as a parameter, which is what filter itself does.
const where = (numbers, passes) => numbers.filter(passes);
const values = [1, 5, 12, 3, 20, 5, -4];
console.log('larger than 5 :', largerThan(values, 5));
console.log('at least 5 :', atLeast(values, 5), '<- includes the fives');
console.log('input :', values, '<- unchanged');
console.log('below zero :', where(values, (n) => n < 0));
console.log('even :', where(values, (n) => n % 2 === 0));
console.log('--- filter never coerces, so a string does not compare numerically ---');
console.log("['10', 9] larger than 5:", largerThan(['10', 9], 5), '<- string "10" > 5 is true by coercion');
Output
18 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 18 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('elements above 5', largerThan(values, 5), [12, 20]);
test('at least 5 includes the fives', atLeast(values, 5), [5, 12, 20, 5]);
test('and so has more entries', atLeast(values, 5).length > largerThan(values, 5).length, true);
test('the original array is untouched', values, [1, 5, 12, 3, 20, 5, -4]);
test('a limit above everything gives nothing', largerThan(values, 100), []);
test('a limit below everything gives all of it', largerThan(values, -100).length, values.length);
test('an empty array gives an empty array', largerThan([], 5), []);
test('negatives compare correctly', largerThan(values, -5), [1, 5, 12, 3, 20, 5, -4]);
test('because -4 is greater than -5', -4 > -5, true);
test('while a limit of -4 excludes it', largerThan(values, -4), [1, 5, 12, 3, 20, 5]);
test('a new array is returned, not the same one', largerThan(values, -100) === values, false);
test('duplicates are all kept', largerThan([6, 6, 6], 5), [6, 6, 6]);
test('the predicate version can do anything', where(values, (n) => n < 0), [-4]);
test('including a completely different test', where(values, (n) => n % 2 === 0), [12, 20, -4]);
test('filter keeps order', largerThan([20, 1, 12], 5), [20, 12]);
test('numeric strings are coerced by >', largerThan(['10', 9], 5), ['10', 9]);
test('so filtering mixed types compares loosely', '10' > 5, true);
test('which is worth converting first to avoid', largerThan(['10', 9].map(Number), 5), [10, 9]);
One line of filter, so the value is in the edges. Naming matters: "larger than" is ambiguous about equality in English and not at all ambiguous in code, so largerThan and atLeast are separate functions rather than a boolean flag. The assertion that one returns more entries than the other keeps them honest.
filter always returns a new array, so the input is safe — asserted, because it is the property that lets you chain these calls without thinking.
The last few assertions are the trap: '10' > 5 is true, because > coerces the string to a number. So filtering an array of mixed strings and numbers appears to work and then compares '9' and '10' as text in some other operation. Convert at the boundary with .map(Number).
Answers JS Function 19 in the original set.
20 Generate a random ID string
fairBuild a random string of a given length from a supplied character list. Then decide whether Math.random is good enough.
Stuck? Three hints, smallest first
- 1.
Math.floor(Math.random() * chars.length)picks an index. - 2. Make the randomness a parameter, so a test can supply a known sequence.
- 3.
Math.randomis not cryptographically secure.crypto.getRandomValuesis.
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// random is a parameter with a default: the page gets real randomness, a test
// supplies a sequence it controls.
function randomId(length, chars = ALPHABET, random = Math.random) {
if (!Number.isInteger(length) || length < 0) throw new RangeError('length must be a whole number, 0 or above');
if (chars.length === 0) throw new RangeError('need at least one character to choose from');
let out = '';
for (let i = 0; i < length; i++) out += chars[Math.floor(random() * chars.length)];
return out;
}
// For anything security-related. Available in browsers and in Node.
function secureId(length, chars = ALPHABET) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
// The modulo introduces a slight bias unless chars.length divides 256. Noted,
// not hidden: for an id it is immaterial, for a key it would not be.
return [...bytes].map((b) => chars[b % chars.length]).join('');
}
// A fixed sequence stands in for randomness.
let i = 0;
const fixed = () => [0, 0.5, 0.99][i++ % 3];
console.log('with a fixed sequence:', randomId(6, ALPHABET, fixed));
console.log('again, same sequence :', (() => { i = 0; return randomId(6, ALPHABET, fixed); })());
console.log('really random :', randomId(10));
console.log('secure :', secureId(10));
console.log('digits only :', randomId(8, '0123456789'));
console.log('--- floor, not round ---');
console.log('Math.round(0.995 * 62) =', Math.round(0.995 * 62), '<- index 62 of 62 does not exist');
console.log('Math.floor(0.995 * 62) =', Math.floor(0.995 * 62), '<- always in range');
Output
21 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 21 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the length is respected', randomId(10).length, 10);
test('a length of 0 gives an empty string', randomId(0), '');
test('every character comes from the alphabet', [...randomId(200)].every((c) => ALPHABET.includes(c)), true);
test('a custom character list is used', [...randomId(50, '0123456789')].every((c) => '0123456789'.includes(c)), true);
test('a one-character list repeats it', randomId(5, 'x'), 'xxxxx');
test('a fixed sequence gives a predictable result', (() => { i = 0; return randomId(3, 'abc', fixed); })(), 'abc');
test('and repeats exactly', (() => { i = 0; const a = randomId(4, 'abc', fixed); i = 0; const b = randomId(4, 'abc', fixed); return a === b; })(), true);
test('0 picks the first character', randomId(1, 'abc', () => 0), 'a');
test('0.5 picks the middle', randomId(1, 'abc', () => 0.5), 'b');
test('0.99 picks the last', randomId(1, 'abc', () => 0.99), 'c');
test('floor keeps the index in range', Math.floor(0.999999 * 62), 61);
test('round would go past the end', Math.round(0.995 * 62), 62);
test('while floor stays inside it', Math.floor(0.995 * 62), 61);
test('and that character does not exist', ALPHABET[62], undefined);
test('a negative length is refused', (() => { try { randomId(-1); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'RangeError');
test('a fractional length is refused', (() => { try { randomId(2.5); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'RangeError');
test('an empty character list is refused', (() => { try { randomId(5, ''); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'RangeError');
test('two calls almost never match', randomId(20) === randomId(20), false);
test('the secure version has the right length', secureId(16).length, 16);
test('and uses the alphabet too', [...secureId(100)].every((c) => ALPHABET.includes(c)), true);
test('62 characters is under 256, so the byte modulo is slightly biased', 256 % ALPHABET.length !== 0, true);
Injecting random as a parameter is the technique that makes randomness testable: the page passes nothing and gets Math.random, and the assertions pass () => 0.5 and check an exact result. Without it you can only assert vague properties.
Math.floor, never Math.round: with 62 characters, round(0.99 * 62) is 62 and ALPHABET[62] is undefined, which the assertions pin down. That off-by-one silently puts "undefined" in the middle of your id.
The important caveat is that Math.random is not cryptographically secure — it is fast, seeded, and its output is predictable given enough samples. Fine for a DOM element id or a cache key. Not fine for a session token, a password reset link, or anything an attacker benefits from guessing; use crypto.getRandomValues, or crypto.randomUUID() if a UUID will do. The b % chars.length bias is noted in the comment rather than hidden: with 62 characters and 256 byte values it is real but immaterial for an id, and for key material you would reject out-of-range bytes instead.
Answers JS Function 20 in the original set.
21 Subsets of a fixed length
hard[1, 2, 3] with length 2 should give [[1,2],[1,3],[2,3]]. Order within each subset follows the input; subsets are not repeated.
Stuck? Three hints, smallest first
- 1. These are combinations, not permutations:
[1,2]and[2,1]are the same subset. - 2. Recurse: either take the first element and choose
k-1more, or skip it and choosek. - 3. The count is
n! / (k!(n-k)!), which is the binomial coefficient.
/* Two base cases carry the whole function:
k === 0 one way to choose nothing: the empty subset
k > remaining no way to choose that many */
function combinations(items, k) {
if (!Number.isInteger(k) || k < 0) return [];
if (k === 0) return [[]];
if (k > items.length) return [];
const [first, ...rest] = items;
// Take the first, then choose k-1 from the rest; or skip it and choose k.
const withFirst = combinations(rest, k - 1).map((c) => [first, ...c]);
const withoutFirst = combinations(rest, k);
return [...withFirst, ...withoutFirst];
}
// Permutations, for contrast: order matters, so there are more of them.
function permutations(items, k) {
if (k === 0) return [[]];
return items.flatMap((item, i) =>
permutations([...items.slice(0, i), ...items.slice(i + 1)], k - 1).map((p) => [item, ...p]));
}
const choose = (n, k) => {
let out = 1;
for (let i = 1; i <= k; i++) out = (out * (n - k + i)) / i;
return Math.round(out);
};
console.log('combinations([1,2,3], 2) =', JSON.stringify(combinations([1, 2, 3], 2)));
console.log('the source expects = [[1,2],[1,3],[2,3]]');
console.log('permutations([1,2,3], 2) =', JSON.stringify(permutations([1, 2, 3], 2)), '<- order matters, so six');
console.log('combinations of 5 choose 3:', combinations([1, 2, 3, 4, 5], 3).length, '= C(5,3) =', choose(5, 3));
console.log('every subset of [1,2,3]:', [0, 1, 2, 3].map((k) => JSON.stringify(combinations([1, 2, 3], k))).join(' '));
Output
19 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 19 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", combinations([1, 2, 3], 2), [[1, 2], [1, 3], [2, 3]]);
test('three of them', combinations([1, 2, 3], 2).length, 3);
test('which is C(3,2)', combinations([1, 2, 3], 2).length, choose(3, 2));
test('choosing 0 gives one empty subset', combinations([1, 2, 3], 0), [[]]);
test('choosing all gives one subset, the whole list', combinations([1, 2, 3], 3), [[1, 2, 3]]);
test('choosing more than there are gives none', combinations([1, 2], 3), []);
test('choosing 1 gives each element alone', combinations([1, 2, 3], 1), [[1], [2], [3]]);
test('from an empty list, only the empty subset', combinations([], 0), [[]]);
test('and nothing if you want any', combinations([], 1), []);
test('a negative length gives nothing', combinations([1, 2], -1), []);
test('C(5,3) is 10', combinations([1, 2, 3, 4, 5], 3).length, 10);
test('and C(6,3) is 20', combinations([1, 2, 3, 4, 5, 6], 3).length, 20);
test('every subset has the requested length', combinations([1, 2, 3, 4, 5], 3).every((c) => c.length === 3), true);
test('order inside each subset follows the input', combinations([3, 1, 2], 2), [[3, 1], [3, 2], [1, 2]]);
test('no subset is repeated', new Set(combinations([1, 2, 3, 4], 2).map(String)).size, 6);
test('permutations of the same choice are more numerous', permutations([1, 2, 3], 2).length, 6);
test('exactly k! times as many', permutations([1, 2, 3], 2).length / combinations([1, 2, 3], 2).length, 2);
test('and for k = 3', permutations([1, 2, 3], 3).length / combinations([1, 2, 3], 3).length, 6);
test('the total over all k is 2^n', [0, 1, 2, 3].reduce((n, k) => n + combinations([1, 2, 3], k).length, 0), 2 ** 3);
The recursion is the classic take-it-or-leave-it split: for each element, either include it and choose k-1 more from what remains, or exclude it and choose k. Both base cases are load-bearing, and the first is the one people get wrong — k === 0 returns [[]], an array containing one empty subset, not []. Return [] there and the whole function produces nothing, because there is nothing for the map to build on.
Combinations against permutations is the distinction worth being sure about: [1,2] and [2,1] are the same subset and different orderings. The assertion that permutations are exactly k! times as many is a good structural check on both functions at once.
Summing the counts over every k gives 2^n, which is the last assertion — a nice independent confirmation, since every element is either in a subset or not.
Answers JS Function 21 in the original set.
22 Count one letter in a string
easy('w3resource.com', 'o') should give 2. Take two arguments and validate the second.
Stuck? Three hints, smallest first
- 1.
split(letter).length - 1is the shortest way, and it is a little sly. - 2. A regex needs escaping if the letter could be
.or*. - 3. Decide whether the search is case-sensitive, and say so in the name or a parameter.
// Explicit and obvious.
function countLetter(text, letter, { caseSensitive = true } = {}) {
if (typeof letter !== 'string' || [...letter].length !== 1) {
throw new TypeError('the second argument must be a single character');
}
const haystack = caseSensitive ? text : text.toLowerCase();
const needle = caseSensitive ? letter : letter.toLowerCase();
let n = 0;
for (const c of haystack) if (c === needle) n++;
return n;
}
// The one-liner. n occurrences produce n+1 pieces.
const countBySplit = (text, letter) => text.split(letter).length - 1;
// With a regex, which needs the letter escaped or '.' matches everything.
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const countByRegex = (text, letter) =>
(text.match(new RegExp(escapeRegExp(letter), 'g')) ?? []).length;
console.log("countLetter('w3resource.com', 'o') =", countLetter('w3resource.com', 'o'));
console.log("the source expects = 2");
console.log("case-insensitive 'O':", countLetter('w3resource.com', 'O', { caseSensitive: false }));
console.log("case-sensitive 'O' :", countLetter('w3resource.com', 'O'));
console.log('--- escaping matters ---');
console.log("countByRegex('a.b.c', '.') =", countByRegex('a.b.c', '.'), '<- escaped, so it counts dots');
console.log("without escaping, '.' would match every character:", ('a.b.c'.match(/./g) ?? []).length);
console.log("countBySplit('a.b.c', '.') =", countBySplit('a.b.c', '.'));
Output
17 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 17 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", countLetter('w3resource.com', 'o'), 2);
test('a letter that is not there gives 0', countLetter('w3resource.com', 'z'), 0);
test('an empty string gives 0', countLetter('', 'a'), 0);
test('every character counted', countLetter('aaa', 'a'), 3);
test('it is case-sensitive by default', countLetter('w3resource.com', 'O'), 0);
test('and case-insensitive on request', countLetter('w3resource.com', 'O', { caseSensitive: false }), 2);
test('a dot is counted literally', countLetter('a.b.c', '.'), 2);
test('a space can be counted', countLetter('a b c', ' '), 2);
test('the split version agrees', countBySplit('w3resource.com', 'o'), 2);
test('and the regex version', countByRegex('w3resource.com', 'o'), 2);
test('all three agree on a dot', [countLetter('a.b.c', '.'), countBySplit('a.b.c', '.'), countByRegex('a.b.c', '.')], [2, 2, 2]);
test('without escaping, a dot would match everything', ('a.b.c'.match(/./g) ?? []).length, 5);
test('a two-character needle is refused', (() => { try { countLetter('abc', 'ab'); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'TypeError');
test('an empty needle is refused', (() => { try { countLetter('abc', ''); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'TypeError');
test('a non-string needle is refused', (() => { try { countLetter('abc', 5); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'TypeError');
test('an emoji counts as one character', countLetter('a\u{1F600}b\u{1F600}', '\u{1F600}'), 2);
test('because for..of walks code points', [...'a\u{1F600}b'].length, 3);
Three ways, and each teaches something. split(letter).length - 1 works because n occurrences cut the string into n+1 pieces — neat, and it reads as a trick rather than as intent.
The regex version needs escapeRegExp, and the assertion showing an unescaped . matching all five characters is why. Any time you build a regex from a value you did not write, escape it; otherwise a user typing . or * changes what your pattern means.
Validating the second argument is the part the exercise title invites you to skip. A "letter" that is two characters long, empty, or not a string should be refused loudly rather than producing a plausible number. Note [...letter].length !== 1 rather than letter.length !== 1, so an emoji counts as one character — the last assertions check that for..of and spreading both walk code points.
Answers JS Function 22 in the original set.
23 The first non-repeated character
fair'abacddbec' should give 'e'. Find the first character that appears exactly once.
Stuck? Three hints, smallest first
- 1. Two passes: count everything, then walk the string again looking for a count of 1.
- 2. One pass cannot work, because a character early on may repeat later.
- 3. Return something definite when every character repeats.
/* Two passes is not laziness, it is necessary: while walking the string the first
time you cannot know whether an early character repeats later on. */
function firstNonRepeated(text) {
const counts = new Map();
for (const c of text) counts.set(c, (counts.get(c) ?? 0) + 1);
for (const c of text) if (counts.get(c) === 1) return c;
return null;
}
// A tempting one-pass version, to show that it is wrong.
function brokenOnePass(text) {
const seen = new Set();
for (const c of text) {
if (!seen.has(c)) return c; // "first not yet seen" is a different question
seen.add(c);
}
return null;
}
// Using the Map's own order instead of walking the string again.
const byMapOrder = (text) => {
const counts = new Map();
for (const c of text) counts.set(c, (counts.get(c) ?? 0) + 1);
for (const [c, n] of counts) if (n === 1) return c;
return null;
};
const cases = ['abacddbec', 'aabbcc', 'abc', 'a', '', 'aabbc', 'swiss'];
for (const t of cases) {
console.log(JSON.stringify(t).padEnd(13), '->', JSON.stringify(firstNonRepeated(t)));
}
console.log('--- why one pass fails ---');
console.log("brokenOnePass('aab') =", JSON.stringify(brokenOnePass('aab')), "<- 'a' repeats, so the answer should be 'b'");
console.log("firstNonRepeated('aab') =", JSON.stringify(firstNonRepeated('aab')));
Output
16 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 16 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", firstNonRepeated('abacddbec'), 'e');
test('all repeated gives null', firstNonRepeated('aabbcc'), null);
test('no repeats gives the first character', firstNonRepeated('abc'), 'a');
test('a single character', firstNonRepeated('a'), 'a');
test('an empty string gives null', firstNonRepeated(''), null);
test('the last character can be the answer', firstNonRepeated('aabbc'), 'c');
test("'swiss' gives w", firstNonRepeated('swiss'), 'w');
test('the one-pass version gets it wrong', brokenOnePass('aab'), 'a');
test('where the right answer is b', firstNonRepeated('aab'), 'b');
test('so they disagree', brokenOnePass('aab') === firstNonRepeated('aab'), false);
test('the Map-order version agrees on every case', cases.every((t) => byMapOrder(t) === firstNonRepeated(t)), true);
test('because a Map keeps insertion order', [...new Map([['b', 1], ['a', 1]]).keys()], ['b', 'a']);
test('the result really appears once', (() => { const c = firstNonRepeated('abacddbec'); return [...'abacddbec'].filter((x) => x === c).length; })(), 1);
test('and nothing before it does', 'abacddbec'.slice(0, 'abacddbec'.indexOf('e')).split('').every((c) => 'abacddbec'.split(c).length - 1 > 1), true);
test('it is case-sensitive', firstNonRepeated('aA'), 'a');
test('spaces count as characters', firstNonRepeated('aa b'), ' ');
Two passes are required, and the broken one-pass version is there to show why: "the first character not seen so far" is a different question from "the first character that appears exactly once". For 'aab' the first returns 'a' and the second returns 'b', and the assertion that they disagree is the point.
A Map rather than an object gives insertion order over any key type, which is why byMapOrder can skip the second walk of the string and iterate the counts instead — it agrees on every case. A plain object would mostly work too, but its integer-like keys get reordered, so a string of digits would break it.
Answers JS Function 23 in the original set.
24 Bubble sort
fairSort by swapping neighbours. The source's expected output for its sample is descending, so read the data rather than the name.
Stuck? Three hints, smallest first
- 1. Compare the sample output with the sample input: the largest value comes first.
- 2. A comparator parameter lets one function do both directions.
- 3. Stop early when a pass makes no swaps.
/* One function, either direction, by taking a comparator. The source's sample
output is descending, which is what its own data shows. */
function bubbleSort(input, compare = (a, b) => a - b) {
const values = [...input]; // never reorder the caller's array
let passes = 0;
for (let end = values.length - 1; end > 0; end--) {
let swapped = false;
for (let i = 0; i < end; i++) {
if (compare(values[i], values[i + 1]) > 0) {
[values[i], values[i + 1]] = [values[i + 1], values[i]];
swapped = true;
}
}
passes++;
if (!swapped) break; // already sorted
}
return { values, passes };
}
const ascending = (a) => bubbleSort(a).values;
const descending = (a) => bubbleSort(a, (x, y) => y - x).values;
const sample = [12, 345, 4, 546, 122, 84, 98, 64, 9, 1, 3223, 455, 23, 234, 213];
console.log('input :', sample.join(', '));
console.log('descending:', descending(sample).join(', '));
console.log("the source: 3223, 546, 455, 345, 234, 213, 122, 98, 84, 64, 23, 12, 9, 4, 1");
console.log('ascending :', ascending(sample).join(', '));
console.log('input afterwards:', sample.length, 'items, unchanged');
console.log('passes for sorted input :', bubbleSort([1, 2, 3, 4, 5]).passes);
console.log('passes for reversed input:', bubbleSort([5, 4, 3, 2, 1]).passes);
Output
16 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 16 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 expected output is descending", descending(sample).join(', '), '3223, 546, 455, 345, 234, 213, 122, 98, 84, 64, 23, 12, 9, 4, 1');
test('ascending is the exact reverse', ascending(sample), descending(sample).slice().reverse());
test('the input is not modified', sample[0], 12);
test('and keeps its length', sample.length, 15);
test('it agrees with a numeric sort', ascending(sample), [...sample].sort((a, b) => a - b));
test('an empty array', ascending([]), []);
test('one element', ascending([1]), [1]);
test('already sorted stays sorted', ascending([1, 2, 3]), [1, 2, 3]);
test('reversed gets fixed', ascending([3, 2, 1]), [1, 2, 3]);
test('duplicates are kept', ascending([2, 1, 2, 1]), [1, 1, 2, 2]);
test('negatives sort correctly', ascending([3, -1, 0, -5]), [-5, -1, 0, 3]);
test('a sorted input needs a single pass', bubbleSort([1, 2, 3, 4, 5]).passes, 1);
test('a reversed input needs n-1', bubbleSort([5, 4, 3, 2, 1]).passes, 4);
test('so the early exit saves real work', bubbleSort([1, 2, 3, 4, 5]).passes < bubbleSort([5, 4, 3, 2, 1]).passes, true);
test('a comparator can sort text', bubbleSort(['c', 'a', 'b'], (a, b) => a.localeCompare(b, 'en')).values, ['a', 'b', 'c']);
test('and objects by a field', bubbleSort([{ n: 2 }, { n: 1 }], (a, b) => a.n - b.n).values.map((o) => o.n), [1, 2]);
Object 6 covers the algorithm. The angle here is the interface: one compare parameter replaces two functions, and the assertions use it to sort numbers ascending, numbers descending, strings and objects with the same body. The source's own sample output is descending — worth noticing, since the prose says nothing about direction and the data says everything.
Two habits carried over: copy the input with [...input] so the caller's array is not reordered, and keep the swapped flag so an already-sorted array costs one pass instead of n−1. Bubble sort is still O(n²) and you should still use sort().
Answers JS Function 24 in the original set.
25 The longest country name
easy["Australia", "Germany", "United States of America"] should give "United States of America".
Stuck? Three hints, smallest first
- 1.
reducekeeping the longest so far is the direct route. - 2. Spaces count towards the length, which is why the three-word name wins.
- 3. Decide the tie rule and the empty-list result before writing the reduce.
function longestCountryName(names) {
if (!Array.isArray(names) || names.length === 0) return null;
return names.reduce((longest, name) => (name.length > longest.length ? name : longest));
}
// Every name tied for longest.
function allLongest(names) {
if (!names.length) return [];
const max = Math.max(...names.map((n) => n.length));
return names.filter((n) => n.length === max);
}
const sample = ['Australia', 'Germany', 'United States of America'];
const withTie = ['Chad', 'Cuba', 'Fiji'];
console.log('longest:', longestCountryName(sample));
console.log('lengths:', sample.map((n) => `${n}=${n.length}`).join(', '));
console.log('a tie :', longestCountryName(withTie), '<- the first');
console.log('all tied:', allLongest(withTie));
console.log('empty :', longestCountryName([]));
console.log('--- length counts spaces ---');
console.log("'United States of America'.length =", 'United States of America'.length);
console.log('without spaces =', 'United States of America'.replace(/ /g, '').length);
Output
16 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 16 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", longestCountryName(sample), 'United States of America');
test('one name is the longest', longestCountryName(['Nigeria']), 'Nigeria');
test('an empty list gives null', longestCountryName([]), null);
test('a non-array gives null', longestCountryName('Nigeria'), null);
test('a tie goes to the first', longestCountryName(withTie), 'Chad');
test('all tied names can be returned', allLongest(withTie), ['Chad', 'Cuba', 'Fiji']);
test('and a clear winner comes back alone', allLongest(sample), ['United States of America']);
test('spaces count towards the length', 'United States of America'.length, 24);
test('so ignoring them would change the answer', 'United States of America'.replace(/ /g, '').length, 21);
test('the returned name is one of the inputs', sample.includes(longestCountryName(sample)), true);
test('and no other name is longer', sample.every((n) => n.length <= longestCountryName(sample).length), true);
test('an empty string in the list does not win', longestCountryName(['', 'Chad']), 'Chad');
test('a list of only empty strings returns one', longestCountryName(['', '']), '');
test('accented names are measured by code unit', 'C\u00f4te d\u2019Ivoire'.length, 13);
test('which matches the character count here', [...'C\u00f4te d\u2019Ivoire'].length, 13);
test('so the two agree while every character fits in one code unit', 'C\u00f4te d\u2019Ivoire'.length === [...'C\u00f4te d\u2019Ivoire'].length, true);
Function 6 is the same reduce over words in a sentence; this is over a list that arrives already split, which is the easier half. Note the reduce here has no initial value, so it starts from the first element — that is why the empty-list guard has to come first, since reduce on an empty array with no initial value throws a TypeError.
.length counts UTF-16 code units, which for these names equals the character count — the last two assertions check that for Côte d'Ivoire. It would stop being true for a name containing a character outside the basic range, where [...name].length is the measure a person would agree with.
Answers JS Function 25 in the original set.
26 Longest substring without repeating characters
hardFind the longest run in a string where no character repeats.
Stuck? Three hints, smallest first
- 1. A sliding window: grow the right edge, and pull the left edge in when a repeat appears.
- 2. Remember the last index of each character so the left edge can jump, not creep.
- 3. The left edge must never move backwards, or the window becomes invalid.
/* Sliding window in one pass. lastSeen holds the most recent index of each
character, so when a repeat turns up the left edge jumps straight past it. */
function longestUnique(text) {
const lastSeen = new Map();
let start = 0;
let best = { start: 0, length: 0 };
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (lastSeen.has(c) && lastSeen.get(c) >= start) {
start = lastSeen.get(c) + 1; // >= start: never move the edge back
}
lastSeen.set(c, i);
if (i - start + 1 > best.length) best = { start, length: i - start + 1 };
}
return text.slice(best.start, best.start + best.length);
}
// Brute force, as an independent check. O(n^3), fine for short strings.
function longestUniqueBrute(text) {
let best = '';
for (let i = 0; i < text.length; i++) {
for (let j = i + 1; j <= text.length; j++) {
const piece = text.slice(i, j);
if (new Set(piece).size === piece.length && piece.length > best.length) best = piece;
}
}
return best;
}
const cases = ['abcabcbb', 'bbbbb', 'pwwkew', 'abcdef', '', 'a', 'au', 'dvdf', 'tmmzuxt'];
for (const t of cases) {
console.log(JSON.stringify(t).padEnd(11), '->', JSON.stringify(longestUnique(t)), 'length', longestUnique(t).length);
}
console.log("--- 'dvdf' is the case that catches a creeping left edge ---");
console.log("the answer is 'vdf', length 3, not 'df'");
console.log("--- 'tmmzuxt' needs the >= start guard ---");
console.log("without it the final t would drag the edge back to index 1");
Output
16 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 16 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test("'abcabcbb' gives abc", longestUnique('abcabcbb'), 'abc');
test('all the same gives one character', longestUnique('bbbbb'), 'b');
test("'pwwkew' gives wke", longestUnique('pwwkew'), 'wke');
test('no repeats gives the whole string', longestUnique('abcdef'), 'abcdef');
test('an empty string gives an empty string', longestUnique(''), '');
test('one character', longestUnique('a'), 'a');
test('two different characters', longestUnique('au'), 'au');
test("'dvdf' gives vdf, not df", longestUnique('dvdf'), 'vdf');
test("'tmmzuxt' gives mzuxt", longestUnique('tmmzuxt'), 'mzuxt');
test('the brute-force version agrees on every case', cases.every((t) => longestUnique(t).length === longestUniqueBrute(t).length), true);
test('the result really has no repeats', new Set(longestUnique('abcabcbb')).size, longestUnique('abcabcbb').length);
test('and that holds for every case', cases.every((t) => { const r = longestUnique(t); return new Set(r).size === r.length; }), true);
test('the result is a contiguous slice of the input', cases.every((t) => t.includes(longestUnique(t))), true);
test('spaces count as characters', longestUnique('a b a'), 'a b');
test('it is case-sensitive', longestUnique('aA'), 'aA');
test('a long run is found', longestUnique('ab' + 'cdefghij' + 'ab'), 'abcdefghij');
The sliding window is the technique: one pass, with two edges. The right edge always advances; the left edge jumps to just past the previous occurrence of whatever repeated. Remembering the last index of each character is what allows the jump — moving the left edge one step at a time would make this O(n²).
Two test strings earn their place. 'dvdf' catches a version whose left edge creeps instead of jumping — the answer is 'vdf'. And 'tmmzuxt' is why the condition is lastSeen.get(c) >= start and not just lastSeen.has(c): the final t was last seen at index 0, which is already behind the window, so without the guard the left edge would be dragged backwards and the window would contain a repeat.
Cross-checking against a brute-force version on every case is the cheap way to be confident, since the two share no reasoning at all.
Answers JS Function 26 in the original set.
27 Longest palindromic substring
hardFind the longest palindrome inside a string. 'bananas' gives 'anana'. 'abracadabra' has two three-letter answers, so ties must be decided.
Stuck? Three hints, smallest first
- 1. Expand around each centre. There are
2n-1centres, notn. - 2. Even-length palindromes have their centre between two characters.
- 3. The source itself notes the answer is not unique; pick the first and say so.
/* Expand around centres. A string of length n has 2n-1 centres: n single
characters, and n-1 gaps between them for even-length palindromes. */
function longestPalindrome(text) {
if (text.length < 2) return text;
let best = text.slice(0, 1);
const expand = (left, right) => {
while (left >= 0 && right < text.length && text[left] === text[right]) { left--; right++; }
return text.slice(left + 1, right); // one step too far, so step back
};
for (let i = 0; i < text.length; i++) {
const odd = expand(i, i); // centred on a character
const even = expand(i, i + 1); // centred between two
for (const candidate of [odd, even]) {
if (candidate.length > best.length) best = candidate; // > , so the FIRST tie wins
}
}
return best;
}
// Every maximal palindrome, since the source mentions wanting them all.
function allLongestPalindromes(text) {
const best = longestPalindrome(text).length;
const found = new Set();
for (let i = 0; i < text.length; i++) {
for (let j = i + best; j <= text.length; j++) {
const piece = text.slice(i, j);
if (piece.length === best && piece === [...piece].reverse().join('')) found.add(piece);
}
}
return [...found];
}
const isPal = (s) => s === [...s].reverse().join('');
for (const t of ['bananas', 'abracadabra', 'babad', 'cbbd', 'a', '', 'abc', 'aaaa', 'forgeeksskeegfor']) {
console.log(JSON.stringify(t).padEnd(19), '->', JSON.stringify(longestPalindrome(t)));
}
console.log("all three-letter answers in 'abracadabra':", allLongestPalindromes('abracadabra'));
console.log('centres for a 5-character string:', 2 * 5 - 1);
Output
17 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 17 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: bananas gives anana", longestPalindrome('bananas'), 'anana');
test('an even-length palindrome is found', longestPalindrome('cbbd'), 'bb');
test('which needs the between-characters centres', longestPalindrome('abba'), 'abba');
test("'babad' gives the first of two", longestPalindrome('babad'), 'bab');
test("'abracadabra' gives aca, the first three-letter one", longestPalindrome('abracadabra'), 'aca');
test('and the source names both candidates', allLongestPalindromes('abracadabra').sort(), ['aca', 'ada']);
test('a single character', longestPalindrome('a'), 'a');
test('an empty string', longestPalindrome(''), '');
test('no palindrome longer than one character', longestPalindrome('abc'), 'a');
test('all the same character is one palindrome', longestPalindrome('aaaa'), 'aaaa');
test('a long one inside a longer string', longestPalindrome('forgeeksskeegfor'), 'geeksskeeg');
test('the result really is a palindrome', isPal(longestPalindrome('bananas')), true);
test('and that holds for every case', ['bananas', 'abracadabra', 'babad', 'cbbd', 'a', 'abc', 'aaaa'].every((t) => isPal(longestPalindrome(t))), true);
test('the result is a contiguous slice', ['bananas', 'abracadabra', 'forgeeksskeegfor'].every((t) => t.includes(longestPalindrome(t))), true);
test('there are 2n-1 centres to check', 2 * 5 - 1, 9);
test('an odd-length answer comes from a character centre', longestPalindrome('aba').length % 2, 1);
test('and an even-length one from a gap', longestPalindrome('abba').length % 2, 0);
The count of centres is the thing to get right: 2n−1, not n. Every single character is a centre for odd-length palindromes, and every gap between two characters is a centre for even-length ones. Check only the characters and you never find 'bb' or 'abba' — both are asserted for that reason.
expand stops when the characters stop matching, by which point left and right have each gone one step too far, hence slice(left + 1, right). That off-by-one is the usual bug in this function.
The source itself points out the answer is not unique: 'abracadabra' contains both 'aca' and 'ada'. > rather than >= in the comparison makes the first win, and allLongestPalindromes returns both so the choice is visible rather than accidental.
Answers JS Function 27 in the original set.
28 Pass a function as a parameter
fairPass one function into another and call it. Then look at what makes that useful.
Stuck? Three hints, smallest first
- 1. Pass the function itself, not a call:
run(double), notrun(double()). - 2. A function that takes or returns a function is called higher-order.
- 3.
...argslets a wrapper forward whatever it is given.
// The plainest version.
const apply = (fn, value) => fn(value);
const double = (n) => n * 2;
const shout = (s) => s.toUpperCase();
// Forwarding any number of arguments, which is what a wrapper needs.
const callWith = (fn, ...args) => fn(...args);
// Returning a function: this is where higher-order functions earn their keep.
const twice = (fn) => (x) => fn(fn(x));
const compose = (...fns) => (x) => fns.reduceRight((value, fn) => fn(value), x);
// A decorator: same behaviour, plus a record of the calls.
function logged(fn) {
const calls = [];
const wrapped = (...args) => {
const result = fn(...args);
calls.push({ args, result });
return result;
};
wrapped.calls = calls;
return wrapped;
}
// Memoising: same idea, and it is why passing functions around matters.
function memoise(fn) {
const cache = new Map();
let misses = 0;
const wrapped = (n) => {
if (cache.has(n)) return cache.get(n);
misses++;
const value = fn(n);
cache.set(n, value);
return value;
};
wrapped.misses = () => misses;
return wrapped;
}
console.log('apply(double, 5) =', apply(double, 5));
console.log('apply(shout, "lagos") =', apply(shout, 'lagos'));
console.log('callWith(Math.max, 1, 9, 4) =', callWith(Math.max, 1, 9, 4));
console.log('twice(double)(5) =', twice(double)(5));
console.log('compose(double, double)(5) =', compose(double, double)(5));
const loggedDouble = logged(double);
loggedDouble(2); loggedDouble(3);
console.log('logged calls:', loggedDouble.calls);
const slowSquare = memoise((n) => n * n);
slowSquare(4); slowSquare(4); slowSquare(5);
console.log('memoised: 3 calls,', slowSquare.misses(), 'actual computations');
console.log('--- the classic mistake ---');
console.log('passing double(5) instead of double gives', typeof double(5), '- a number, not a function');
Output
19 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 19 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a function can be passed and called', apply(double, 5), 10);
test('and a different one', apply(shout, 'lagos'), 'LAGOS');
test('several arguments can be forwarded', callWith(Math.max, 1, 9, 4), 9);
test('with none at all', callWith(() => 'ok'), 'ok');
test('a function is a value, so typeof says function', typeof double, 'function');
test('while calling it gives a number', typeof double(5), 'number');
test('a function returning a function applies it twice', twice(double)(5), 20);
test('and three times if nested', twice(twice(double))(1), 16);
test('compose runs right to left', compose(double, (n) => n + 1)(5), 12);
test('which is not the same as left to right', compose((n) => n + 1, double)(5), 11);
test('the logged wrapper returns the same answers', loggedDouble(10), 20);
test('and recorded every call', loggedDouble.calls.length, 3);
test('with the arguments', loggedDouble.calls[0].args, [2]);
test('and the results', loggedDouble.calls[0].result, 4);
test('memoising returns the same values', slowSquare(4), 16);
test('but computes each input only once', slowSquare.misses(), 2);
test('even after repeated calls', (() => { slowSquare(4); slowSquare(5); return slowSquare.misses(); })(), 2);
test('a built-in method can be passed too', [1, 2, 3].map(String), ['1', '2', '3']);
test('though a method losing its receiver is a real hazard', (() => { const f = [1, 2].join; try { return f(','); } catch (e) { return e.constructor.name; } })(), 'TypeError');
The mechanical answer is one line. What makes it worth an exercise is everything built on it: map, filter, reduce, every event listener, every callback. Functions being values is the feature.
logged and memoise are the same shape — take a function, return a function with the same behaviour plus something extra. That is a decorator, and the assertion showing slowSquare computed only two distinct inputs across four calls is the payoff.
Two traps in the assertions. compose runs right to left, and the two orderings give different answers, so the direction is a decision to document. And the last one: pulling a method off an object with const f = [1, 2].join loses the receiver, so calling f(',') throws — pass (...a) => arr.join(...a) or use .bind(arr). This is the most common way passing a function goes wrong in practice.
Answers JS Function 28 in the original set.
29 Get a function’s name
fairRead the name of a function. Then find the cases where the name is empty or not what you expected.
Stuck? Three hints, smallest first
- 1.
fn.nameis the property. It is read-only in practice. - 2. An arrow function assigned to a variable takes the variable’s name.
- 3. A truly anonymous function passed directly as an argument has an empty name.
const nameOf = (fn) => {
if (typeof fn !== 'function') throw new TypeError('not a function');
return fn.name;
};
function declared() {}
const arrow = () => {};
const assignedAnon = function () {};
const named = function theRealName() {};
const { method } = { method() {} };
class Bus { static go() {} board() {} }
const bound = declared.bind(null);
// The name is inferred from the assignment, so passing a function literal
// straight into a call gives it nothing to infer from.
let nameOfLiteral = null;
(function (fn) { nameOfLiteral = fn.name; })(function () {});
const rows = [
['function declared() {}', nameOf(declared)],
['const arrow = () => {}', nameOf(arrow)],
['const assignedAnon = function () {}', nameOf(assignedAnon)],
['const named = function theRealName() {}', nameOf(named)],
['{ method() {} }', nameOf(method)],
['class Bus', nameOf(Bus)],
['Bus.go (static)', nameOf(Bus.go)],
['Bus.prototype.board', nameOf(Bus.prototype.board)],
['declared.bind(null)', nameOf(bound)],
['a literal passed as an argument', nameOfLiteral],
];
for (const [what, name] of rows) console.log(what.padEnd(40), JSON.stringify(name));
Output
20 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 20 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a declared function knows its name', nameOf(declared), 'declared');
test('an arrow takes the variable name', nameOf(arrow), 'arrow');
test('so does an anonymous function expression', nameOf(assignedAnon), 'assignedAnon');
test('a named function expression keeps its own name', nameOf(named), 'theRealName');
test('not the variable it was assigned to', nameOf(named) === 'named', false);
test('a shorthand method takes the key', nameOf(method), 'method');
test('a class is a function with a name', nameOf(Bus), 'Bus');
test('and typeof confirms it', typeof Bus, 'function');
test('a static method is named', nameOf(Bus.go), 'go');
test('and a prototype method', nameOf(Bus.prototype.board), 'board');
test('binding prefixes the name', nameOf(bound), 'bound declared');
test('a literal passed straight as an argument has NO name', nameOfLiteral, '');
test('because there was nothing to infer it from', nameOfLiteral.length, 0);
test('a non-function is refused', (() => { try { nameOf(42); return 'no error'; } catch (e) { return e.constructor.name; } })(), 'TypeError');
test('the name is not writable by assignment', (() => { const f = () => {}; try { f.name = 'other'; } catch {} return f.name; })(), 'f');
test('but it can be redefined deliberately', (() => { const f = () => {}; Object.defineProperty(f, 'name', { value: 'renamed' }); return f.name; })(), 'renamed');
test('a built-in reports its own name', Math.max.name, 'max');
test('and length is the declared parameter count', ((a, b) => a + b).length, 2);
test('which ignores a rest parameter', ((a, ...rest) => a).length, 1);
test('and stops at the first default', ((a, b = 1, c) => a).length, 1);
fn.name is mostly inferred rather than intrinsic. An arrow function has no name of its own, so the engine takes it from the variable it is assigned to — which is why arrow.name is 'arrow'. Assign the same function elsewhere and the name does not follow.
Two results are worth remembering. A named function expression keeps its own name, not the variable's, so const named = function theRealName() {} reports 'theRealName'. And a function literal passed directly as an argument has an empty name, because there is no assignment to infer from — which is exactly the case where you most want a name, in a stack trace. That is a real argument for naming your callbacks.
bind prefixes 'bound ', so a bound function's name is 'bound declared'. And the assertions on length are a related surprise: it counts parameters before the first default or rest parameter, so (a, b = 1, c) => … reports 1. Neither name nor length is reliable enough to build logic on; both are for diagnostics.
Answers JS Function 29 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.