Emmanuel Akinfulubi

JavaScript: Math, 41–79

Arithmetic, rounding, primes, factors, conversions and geometry.

Exercises 41 to 79 of 114

41 Coprime numbers, and counting them

fair

Decide whether two numbers share any factor beyond 1. Then count how many numbers from 1 to n are coprime with n, and check that count against the product formula.

Stuck? Three hints, smallest first
  1. 1. Coprime means the GCD is exactly 1. Neither number has to be prime.
  2. 2. Consecutive integers are always coprime, so gcd(n, n + 1) is 1 for every n.
  3. 3. The count has a product form: take n and multiply by (p - 1) / p for each distinct prime p dividing it.

The answer

const gcd = (a, b) => {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b) [a, b] = [b, a % b];
  return a;
};

const coprime = (a, b) => gcd(a, b) === 1;

// Count them directly.
const totientByCounting = (n) => {
  let count = 0;
  for (let k = 1; k <= n; k++) if (coprime(k, n)) count++;
  return count;
};

// Or derive it from the distinct primes dividing n.
const distinctPrimes = (n) => {
  const out = [];
  for (let d = 2; d * d <= n; d++) {
    if (n % d !== 0) continue;
    out.push(d);
    while (n % d === 0) n /= d;
  }
  if (n > 1) out.push(n);
  return out;
};
const totient = (n) => distinctPrimes(n).reduce((acc, p) => (acc / p) * (p - 1), n);

const consecutive = Array.from({ length: 20 }, (_, i) => coprime(i + 1, i + 2));
const gapOfTwo = Array.from({ length: 8 }, (_, i) => coprime(i + 1, i + 3));

console.log('8 and 15 :', coprime(8, 15), '<- neither is prime');
console.log('8 and 12 :', coprime(8, 12));
console.log('totient of 10, by counting:', totientByCounting(10), 'by formula:', totient(10));
console.log('totient of 1000           :', totientByCounting(1000), totient(1000));
console.log('n with n+1               :', consecutive.every(Boolean));
console.log('n with n+2               :', gapOfTwo);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('8 and 15 are coprime even though neither is prime', coprime(8, 15), true);
test('8 and 12 are not, they share 4', [coprime(8, 12), gcd(8, 12)], [false, 4]);
test('1 is coprime with everything, including itself', [coprime(1, 9), coprime(1, 1)], [true, true]);
test('0 is coprime with 1 and nothing else', [coprime(0, 1), coprime(0, 5)], [true, false]);
test('consecutive integers are always coprime', consecutive.every(Boolean), true);
test('but n and n + 2 only when n is odd', gapOfTwo, [true, false, true, false, true, false, true, false]);
test('the totient of a prime is p - 1', [totientByCounting(13), totient(97)], [12, 96]);
test('counting and the product formula agree, including at 1', [1, 9, 10, 12, 36, 1000].map(totient), [1, 9, 10, 12, 36, 1000].map(totientByCounting));
test('and the formula stays exact at 1000: 400 coprimes', totient(1000), 400);

Coprimality is a statement about a pair, not about either number: 8 and 15 are coprime while both are composite, and 8 and 12 are not while neither is prime. The only test is gcd === 1.

The totient's two forms are worth holding together. Counting is obvious and costs a GCD per candidate, so totientByCounting(1e9) is out of reach. The product form n * prod((p - 1) / p) costs one factorisation, and the order matters for the same reason as in exercise 38: written as (acc / p) * (p - 1) the division is exact because p divides acc, while acc * (p - 1) / p would inflate the intermediate before shrinking it again.

42 Digit sum and digital root

easy

Add up a number's digits, then keep adding until one digit is left. Do it twice: once by string, once by arithmetic, and find the input where the string version gives NaN.

Stuck? Three hints, smallest first
  1. 1. The arithmetic version peels digits off with n % 10 and Math.floor(n / 10).
  2. 2. The digital root has a closed form for positive n: 1 + (n - 1) % 9.
  3. 3. String(1e21) is not 22 characters of digits.

The answer

const digitSumByString = (n) =>
  String(Math.abs(n)).split('').reduce((sum, ch) => sum + Number(ch), 0);

function digitSum(n) {
  n = Math.abs(n);
  let sum = 0;
  while (n > 0) {
    sum += n % 10;
    n = Math.floor(n / 10);
  }
  return sum;
}

const digitalRootByLoop = (n) => {
  n = Math.abs(n);
  while (n > 9) n = digitSum(n);
  return n;
};

// Same answer, no loop: the root is n mod 9, with 9 in place of 0.
const digitalRoot = (n) => (n === 0 ? 0 : 1 + ((Math.abs(n) - 1) % 9));

const chain = [9875, digitSum(9875), digitSum(29), digitSum(11)];

console.log('digit sum of 9875 :', digitSum(9875));
console.log('the chain to a root:', chain);
console.log('closed form        :', digitalRoot(9875));
console.log('String(1e21)       :', String(1e21));
console.log('by string          :', digitSumByString(1e21), '<- the e and the + are not digits');
console.log('by arithmetic      :', digitSum(1e21));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the digits of 9875 sum to 29', [digitSum(9875), digitSumByString(9875)], [29, 29]);
test('and folding again gives 11, then 2', chain, [9875, 29, 11, 2]);
test('so the digital root is 2', digitalRootByLoop(9875), 2);
test('the closed form agrees for every n up to 200', Array.from({ length: 200 }, (_, i) => i).every((n) => digitalRoot(n) === digitalRootByLoop(n)), true);
test('zero sums to zero, and the sign is ignored', [digitSum(0), digitSum(-45)], [0, 9]);
test('multiples of 9 have a root of 9, not 0', [9, 18, 99, 123456789].map(digitalRoot), [9, 9, 9, 9]);
test('String(1e21) is exponential notation', String(1e21), '1e+21');
test('so the string version returns NaN there', Number.isNaN(digitSumByString(1e21)), true);
test('while the arithmetic version still gets 1', digitSum(1e21), 1);

The digital root is congruence in disguise: adding digits does not change a number's remainder modulo 9, so folding repeatedly lands on that remainder, with 9 standing in for 0. Hence 1 + (n - 1) % 9, and hence the old trick of casting out nines to check a sum by hand.

The NaN is the trap. String(n) switches to exponential form at 1e21, so a digit walker built on strings meets 'e' and '+', Number('e') is NaN, and NaN propagates through the rest of the fold without an error. The arithmetic version has no such cliff, and if you genuinely need digits of enormous integers, BigInt(n).toString() never abbreviates.

43 Reversing the digits of a number

fair

Turn 12345 into 54321, keeping any minus sign in front. Then show two ways the operation loses information: with a trailing zero, and with a 17-digit input.

Stuck? Three hints, smallest first
  1. 1. Arithmetically: result = result * 10 + n % 10, then floor-divide n by 10.
  2. 2. By string: split, reverse, join, and convert back — remembering the sign is a character.
  3. 3. A 17-digit literal is already rounded before your function is called.

The answer

function reverseDigits(n) {
  const sign = Math.sign(n);
  let rest = Math.abs(n);
  let out = 0;
  while (rest > 0) {
    out = out * 10 + (rest % 10);
    rest = Math.floor(rest / 10);
  }
  return sign * out;
}

const reverseByString = (n) =>
  Math.sign(n) * Number(String(Math.abs(n)).split('').reverse().join(''));

const big = 12345678901234567;          // 17 digits, and already not what it says

console.log('12345 ->', reverseDigits(12345));
console.log('1200  ->', reverseDigits(1200), '<- the zeros are gone for good');
console.log('back  ->', reverseDigits(reverseDigits(1200)));
console.log('-123  ->', reverseDigits(-123));
console.log('the 17-digit literal:', big);
console.log('reversed            :', reverseDigits(big));
console.log('MAX_SAFE reversed   :', reverseDigits(Number.MAX_SAFE_INTEGER));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('12345 reverses to 54321, both ways', [reverseDigits(12345), reverseByString(12345)], [54321, 54321]);
test('the sign stays in front', reverseDigits(-123), -321);
test('0 reverses to 0, and a single digit to itself', [reverseDigits(0), reverseDigits(7)], [0, 7]);
test('trailing zeros vanish: 1200 becomes 21', reverseDigits(1200), 21);
test('so reversing twice does not return the original', reverseDigits(reverseDigits(1200)), 12);
test('a palindrome is the fixed point of this operation', [121, 1221, 9].every((n) => reverseDigits(n) === n), true);
test('the 17-digit literal was rounded before the call', big, 12345678901234568);
test('so its reversal starts from the rounded value', reverseDigits(big), 86543210987654320);
test('MAX_SAFE_INTEGER reverses within range, and exactly', [reverseDigits(Number.MAX_SAFE_INTEGER), Number.isSafeInteger(reverseDigits(Number.MAX_SAFE_INTEGER))], [1990474529917009, true]);

Reversal is not invertible, and the reason is not floating point: 1200 and 12 both reverse to 21, so information is destroyed by the operation itself. Any code that reverses, transforms, then reverses back has to keep the digit count separately.

The 17-digit case is a different failure and worth separating carefully. 12345678901234567 is not a safe integer, so the literal becomes 12345678901234568 at parse time — before reverseDigits runs. The function is correct; its input was already wrong. Assert on the literal itself, as above, and the cause is unmistakable. Past 2**53, digits belong in a string or a BigInt.

44 Palindromic numbers

easy

Decide whether a number reads the same backwards. Do it by string and by arithmetic, then find the largest palindrome that is the product of two three-digit numbers.

Stuck? Three hints, smallest first
  1. 1. The string version splits, reverses and compares. The arithmetic version rebuilds the number in reverse and compares.
  2. 2. A trailing zero disqualifies a number: 10 reversed is 1.
  3. 3. For the product search, start both loops at 100 and let the second start where the first is, so no pair is tried twice.

The answer

const isPalindrome = (n) => {
  const s = String(n);
  return s === [...s].reverse().join('');
};

// No strings: rebuild the number backwards and compare.
function isPalindromeByMaths(n) {
  if (n < 0) return false;
  let rest = n;
  let reversed = 0;
  while (rest > 0) {
    reversed = reversed * 10 + (rest % 10);
    rest = Math.floor(rest / 10);
  }
  return reversed === n;
}

function largestProductPalindrome(low, high) {
  let best = 0;
  let pair = null;
  for (let a = low; a <= high; a++) {
    for (let b = a; b <= high; b++) {
      const p = a * b;
      if (p > best && isPalindrome(p)) {
        best = p;
        pair = [a, b];
      }
    }
  }
  return { best, pair };
}

const twoDigit = largestProductPalindrome(10, 99);
const threeDigit = largestProductPalindrome(100, 999);

const below1000 = [];
for (let n = 0; n < 1000; n++) if (isPalindrome(n)) below1000.push(n);

console.log('121, 123, 1221 :', [121, 123, 1221].map(isPalindrome));
console.log('10             :', isPalindrome(10), '<- 10 reversed is 1');
console.log('-121           :', isPalindrome(-121), isPalindromeByMaths(-121));
console.log('two-digit best :', twoDigit);
console.log('three-digit    :', threeDigit);
console.log('585 in binary  :', (585).toString(2));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('121 and 1221 read the same both ways, 123 does not', [121, 1221, 123].map(isPalindrome), [true, true, false]);
test('single digits and 0 are palindromes, 10 is not', [0, 7, 10].map(isPalindrome), [true, true, false]);
test('both versions reject negatives, for different reasons', [isPalindrome(-121), isPalindromeByMaths(-121)], [false, false]);
test('and they agree on every number below 1000', Array.from({ length: 1000 }, (_, i) => i).every((n) => isPalindrome(n) === isPalindromeByMaths(n)), true);
test('there are nine two-digit palindromes', below1000.filter((n) => n >= 10 && n < 100), [11, 22, 33, 44, 55, 66, 77, 88, 99]);
test('and 109 palindromes below 1000 in total', below1000.length, 109);
test('the largest palindrome from two 2-digit numbers is 91 x 99', [twoDigit.best, twoDigit.pair], [9009, [91, 99]]);
test('and from two 3-digit numbers, 913 x 993', [threeDigit.best, threeDigit.pair], [906609, [913, 993]]);
test('585 is a palindrome in base 10 and in base 2', [isPalindrome(585), isPalindrome((585).toString(2))], [true, true]);

The string version is shorter and the arithmetic version is the one to reach for when the digits might not survive String — past 1e21 that switches to exponential form, as exercise 42 showed, and '1e+21' is not a palindrome for reasons that have nothing to do with the number.

Both versions reject negatives, which looks like agreement and is not. The arithmetic one refuses explicitly; the string one compares '-121' with '121-' and gets the right answer by accident. That distinction matters the moment you decide -121 should count, because only one of the two has a place to put the change. Note also that isPalindrome works unchanged on the binary string, since it only ever compares characters.

45 How many digits, and where log10 lies

fair

Count the digits of an integer three ways: with String, with Math.log10, and with a division loop. Then find an input where the log10 version is off by one, and one where the String version is off by seventeen.

Stuck? Three hints, smallest first
  1. 1. Math.floor(Math.log10(n)) + 1 is the closed form, and it depends on a logarithm being exact.
  2. 2. Try 999999999999999 — fifteen nines.
  3. 3. String switches to exponential notation at 1e21, and BigInt(n).toString() never does.

The answer

const digitsByString = (n) => String(Math.abs(n)).length;
const digitsByLog = (n) => Math.floor(Math.log10(Math.abs(n))) + 1;

function digitsByLoop(n) {
  n = Math.abs(Math.trunc(n));
  let count = 0;
  do {
    count++;
    n = Math.floor(n / 10);
  } while (n > 0);
  return count;                        // do..while, so 0 has one digit
}

const nines = 999999999999999;         // fifteen of them
const huge = 1e21;

let firstDisagreement = 'none below 10000';
for (let n = 1; n < 10000; n++) {
  if (digitsByString(n) !== digitsByLog(n) || digitsByString(n) !== digitsByLoop(n)) {
    firstDisagreement = n;
    break;
  }
}

console.log('digits of 42, 1000 :', [42, 1000].map(digitsByLoop));
console.log('all three agree to 10000?', firstDisagreement);
console.log('fifteen nines      :', nines);
console.log('log10 of it        :', Math.log10(nines), '<- rounded up to a whole number');
console.log('so log10 says      :', digitsByLog(nines), 'but there are', digitsByLoop(nines));
console.log('String(1e21)       :', String(huge), 'length', digitsByString(huge));
console.log('BigInt(1e21)       :', BigInt(huge).toString().length, 'digits');

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the loop counts 1, 2 and 4 digits, and gives 0 one digit', [7, 42, 1000, 0].map(digitsByLoop), [1, 2, 4, 1]);
test('all three methods agree on every number below 10000', firstDisagreement, 'none below 10000');
test('but Math.log10 of fifteen nines comes back as exactly 15', Math.log10(nines), 15);
test('so the closed form claims 16 digits where there are 15', [digitsByLog(nines), digitsByLoop(nines)], [16, 15]);
test('String gives up at 1e21 and reports five characters', [String(huge), digitsByString(huge)], ['1e+21', 5]);
test('while the true count is 22, which BigInt will tell you', BigInt(huge).toString().length, 22);
test('log10(0) is -Infinity, so the closed form has no answer for 0', digitsByLog(0) === -Infinity, true);
test('and a negative needs its sign stripped or log10 returns NaN', [Number.isNaN(Math.log10(-42)), digitsByLog(-42)], [true, 2]);
test('~~ truncates only inside 32 bits, so it cannot help here', [~~1e10, Math.trunc(1e10)], [1410065408, 10000000000]);

Math.floor(Math.log10(n)) + 1 is the answer everyone reaches for, and it is wrong for 999999999999999. The true logarithm is 14.999999999999999..., which is closer to 15 than to the largest double below 15, so it rounds up to exactly 15, the floor gives 15, and the count comes out one too many. One counterexample below 2 ** 53 is enough to disqualify the method for exact work.

String fails differently and more visibly: from 1e21 upwards it produces exponential notation, so the length is a fact about the notation rather than the number. The division loop has neither failure, and BigInt(n).toString().length is the version to use when the value came from outside your code. Note the do..while: a plain while (n > 0) would report zero digits for 0.

46 Harshad numbers

fair

A Harshad number is divisible by the sum of its own digits. List them up to 30, count how many there are up to 100, and show that no prime above 9 can be one.

Stuck? Three hints, smallest first
  1. 1. Reuse the digit sum from exercise 42, then test n % digitSum(n) === 0.
  2. 2. Every single-digit number qualifies, since n % n is 0.
  3. 3. For a prime p to be Harshad, its digit sum would have to be 1 or p itself.

The answer

function digitSum(n) {
  let rest = Math.abs(n);
  let sum = 0;
  while (rest > 0) {
    sum += rest % 10;
    rest = Math.floor(rest / 10);
  }
  return sum;
}

const isHarshad = (n) => n > 0 && n % digitSum(n) === 0;

const upTo30 = [];
for (let n = 1; n <= 30; n++) if (isHarshad(n)) upTo30.push(n);

const upTo100 = [];
for (let n = 1; n <= 100; n++) if (isHarshad(n)) upTo100.push(n);

const primes = [11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97];

let firstMiss = 0;
for (let n = 101; !firstMiss; n++) if (!isHarshad(n)) firstMiss = n;

console.log('Harshad up to 30 :', upTo30);
console.log('how many to 100  :', upTo100.length);
console.log('18 and 19        :', [18, 19].map((n) => [n, digitSum(n), isHarshad(n)]));
console.log('1729             :', digitSum(1729), 1729 / 19);
console.log('first miss > 100 :', firstMiss);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the Harshad numbers up to 30', upTo30, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 18, 20, 21, 24, 27, 30]);
test('18 qualifies because 9 divides it; 19 does not, since 10 does not', [isHarshad(18), isHarshad(19)], [true, false]);
test('every single-digit number qualifies trivially', [1, 2, 3, 4, 5, 6, 7, 8, 9].every(isHarshad), true);
test('so does every power of ten, whose digit sum is 1', [10, 100, 1000, 10000].every(isHarshad), true);
test('33 of the first hundred numbers are Harshad', upTo100.length, 33);
test('1729 is one: its digits sum to 19, and 19 x 91 is 1729', [digitSum(1729), isHarshad(1729), 19 * 91], [19, true, 1729]);
test('and 101 is the first failure above 100', firstMiss, 101);
test('110, 111 and 112 are three in a row', [110, 111, 112].every(isHarshad), true);
test('and no prime above 9 can ever be Harshad', primes.some(isHarshad), false);

The prime result is the one worth reasoning through rather than testing. If p is prime, the only numbers dividing it are 1 and p, so a Harshad prime would need a digit sum of 1 — which only powers of ten have — or a digit sum equal to p itself, which is impossible once p has two digits, because the digits of a number always sum to less than the number. The assertion above confirms the argument on the primes below 100; the argument is what tells you it holds for all of them.

Harshad numbers are dense at the bottom: 33 of the first hundred, including every one of the first ten. That density falls away slowly, and runs of consecutive Harshad numbers keep appearing — 110, 111, 112 is one. There is no run of more than 20 in a row anywhere, but that is a much harder result than anything here.

47 Perfect squares, tested in integers

fair

Decide whether an integer is a perfect square without trusting a float comparison. Then find how many of the first 200 integers fail the naive Math.sqrt(n) ** 2 === n test.

Stuck? Three hints, smallest first
  1. 1. Round the root to the nearest integer, then square it back and compare exactly.
  2. 2. Math.sqrt(n) ** 2 === n compares two irrational approximations and usually loses.
  3. 3. Cheap pre-filters exist: a square never ends in 2, 3, 7 or 8.

The answer

function isPerfectSquare(n) {
  if (!Number.isInteger(n) || n < 0) return false;
  const root = Math.round(Math.sqrt(n));
  return root * root === n;            // integer multiply, exact comparison
}

const roundTripFails = [];
for (let n = 0; n < 200; n++) if (Math.sqrt(n) ** 2 !== n) roundTripFails.push(n);

let allSquaresPass = true;
for (let k = 0; k <= 1000; k++) if (!isPerfectSquare(k * k)) allSquaresPass = false;

const lastSafeRoot = 94906265;         // 94906265 squared is still a safe integer
const lastDigits = [...new Set(Array.from({ length: 200 }, (_, i) => (i * i) % 10))].sort((a, b) => a - b);
const digitalRoots = [...new Set(Array.from({ length: 200 }, (_, i) => (i === 0 ? 0 : 1 + ((i * i - 1) % 9))))].sort((a, b) => a - b);

console.log('16, 25, 26     :', [16, 25, 26].map(isPerfectSquare));
console.log('failures of sqrt(n) ** 2 === n below 200:', roundTripFails.length);
console.log('first few      :', roundTripFails.slice(0, 6));
console.log('2 ** 53        :', Math.sqrt(2 ** 53), '<- not a whole root');
console.log('largest safe square:', lastSafeRoot * lastSafeRoot);
console.log('last digits of squares:', lastDigits);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('0, 1, 16, 144 and a million are perfect squares', [0, 1, 16, 144, 1000000].map(isPerfectSquare), [true, true, true, true, true]);
test('2, 26, a negative and a fraction are not', [2, 26, -4, 2.5].map(isPerfectSquare), [false, false, false, false]);
test('87 of the first 200 integers fail the sqrt round trip', roundTripFails.length, 87);
test('starting with 2, whose root squared misses by a bit', [roundTripFails[0], Math.sqrt(2) ** 2], [2, 2.0000000000000004]);
test('while rounding and squaring back is exact for every square up to a million', allSquaresPass, true);
test('2 ** 53 is not a perfect square, though its root looks close', [isPerfectSquare(2 ** 53), Math.sqrt(2 ** 53)], [false, 94906265.62425156]);
test('the largest square that is still a safe integer', [lastSafeRoot * lastSafeRoot, isPerfectSquare(9007199136250225)], [9007199136250225, true]);
test('a square never ends in 2, 3, 7 or 8', lastDigits, [0, 1, 4, 5, 6, 9]);
test('and its digital root is only ever 0, 1, 4, 7 or 9', digitalRoots, [0, 1, 4, 7, 9]);

Math.sqrt(n) ** 2 === n fails for 87 of the first 200 integers, which is the number to remember: the round trip is not occasionally lossy, it is lossy more often than not. Math.round then an integer multiply avoids the problem entirely, because the comparison happens between two exact integers rather than two approximations of an irrational.

The method holds up to root * root leaving safe-integer range, which is at 94906265 squared — just under 2 ** 53. Past that the squaring itself rounds and the test can no longer be trusted; that is BigInt territory. The two digit filters are worth knowing for bulk work: they reject 40% and 55% of candidates respectively with no root at all, and both follow from arithmetic modulo 10 and modulo 9. Exercise 35 has the third characterisation — a perfect square is exactly a number with an odd count of divisors.

48 Powers of two, and the limits of the bitwise trick

fair

Test whether a number is a power of two with n & (n - 1). Then find a number where that trick reports true and is wrong, fix it with Math.log2, and find a number where the fix is wrong too.

Stuck? Three hints, smallest first
  1. 1. A power of two has exactly one bit set, so subtracting 1 flips every bit below it and the AND is 0.
  2. 2. Every bitwise operator in JavaScript converts its operands to a signed 32-bit integer first.
  3. 3. Math.log2 returns a double, so a whole-looking result does not prove the input was a power of two.

The answer

const byBits = (n) => n > 0 && (n & (n - 1)) === 0;
const byLog = (n) => n > 0 && Number.isInteger(n) && Number.isInteger(Math.log2(n));

// Slow, and right: divide out every factor of 2 and see what is left.
function byHalving(n) {
  if (!Number.isInteger(n) || n < 1) return false;
  while (n % 2 === 0) n /= 2;
  return n === 1;
}

const beyond32 = 2 ** 32 + 1;          // 4294967297, plainly not a power of two
const odd49 = 2 ** 49 + 1;             // 562949953421313, also plainly not

console.log('small values   :', [1, 2, 3, 4, 1023, 1024].map(byBits));
console.log('2**32 + 1      :', byBits(beyond32), '<- wrong');
console.log('coerced to int32:', beyond32 | 0, 'and 3 * 2**32 becomes', (3 * 2 ** 32) | 0);
console.log('by log2        :', byLog(beyond32), '<- fixed');
console.log('2**49 + 1      :', byLog(odd49), '<- wrong again, log2 says', Math.log2(odd49));
console.log('by halving     :', [beyond32, odd49].map(byHalving));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the bitwise test is right below 2 ** 31', [1, 2, 3, 4, 1023, 1024].map(byBits), [true, true, false, true, false, true]);
test('it reports true for 2 ** 32 + 1 and for 3 * 2 ** 32', [byBits(beyond32), byBits(3 * 2 ** 32)], [true, true]);
test('because bitwise operators truncate to 32 bits first', [beyond32 | 0, (3 * 2 ** 32) | 0], [1, 0]);
test('a shift count is taken modulo 32, so 1 << 32 is 1, not 4294967296', [1 << 32, 1 << 31], [1, -2147483648]);
test('Math.log2 fixes both of those', [byLog(beyond32), byLog(3 * 2 ** 32)], [false, false]);
test('but it reports true for 2 ** 49 + 1, an odd number', [byLog(odd49), odd49 % 2], [true, 1]);
test('because log2 of it rounds to exactly 49', Math.log2(odd49), 49);
test('halving is the only one of the three that gets all of them right', [1, 1024, 2 ** 32, beyond32, 3 * 2 ** 32, odd49].map(byHalving), [true, true, true, false, false, false]);
test('n & (n - 1) clears the lowest set bit, n & -n isolates it', [12 & 11, 12 & -12], [8, 4]);

The trick itself is sound: one bit set means n - 1 flips everything below it and the AND is zero. What breaks is JavaScript's arithmetic. &, |, ^, <<, >> and ~ all convert to a signed 32-bit integer first, so 2 ** 32 + 1 arrives as 1, 1 & 0 is 0, and the answer is confidently wrong. Same reason ~~ is not a general truncation and 1 << 32 is 1.

The log2 repair is more interesting for failing later rather than not at all. Math.log2(2 ** 49 + 1) is 49 to the last bit a double can hold, so an integrality test on it cannot distinguish 2 ** 49 from its neighbour. Any predicate built on a logarithm inherits the resolution of the logarithm. The halving loop is the slowest and the only one that answers the question that was asked; BigInt with & is the fast alternative, since BigInt bitwise operators are not truncated to 32 bits.

49 Armstrong numbers

fair

An Armstrong number equals the sum of its digits each raised to the count of digits: 153 = 1³ + 5³ + 3³. Find all of them below 10000, show there are none with two digits, and find the three with five.

Stuck? Three hints, smallest first
  1. 1. The exponent is the number of digits, not a fixed 3.
  2. 2. Get the digit count once, then raise each digit to it.
  3. 3. The two-digit case can be argued rather than searched: no sum of two squared digits reaches a two-digit number that matches.

The answer

function isArmstrong(n) {
  const digits = String(n).split('').map(Number);
  const power = digits.length;
  const sum = digits.reduce((acc, d) => acc + d ** power, 0);
  return sum === n;
}

const below10000 = [];
for (let n = 0; n < 10000; n++) if (isArmstrong(n)) below10000.push(n);

const twoDigit = below10000.filter((n) => n >= 10 && n < 100);

const fiveDigit = [];
for (let n = 10000; n < 100000; n++) if (isArmstrong(n)) fiveDigit.push(n);

// The same digits with a fixed exponent of 3 answer a different question.
const cubeSum = (n) => String(n).split('').reduce((acc, c) => acc + Number(c) ** 3, 0);

console.log('153 as cubes  :', [1 ** 3, 5 ** 3, 3 ** 3], 'summing to', cubeSum(153));
console.log('below 10000   :', below10000);
console.log('two-digit ones:', twoDigit);
console.log('five-digit    :', fiveDigit);
console.log('9474 as fourth powers:', [9 ** 4, 4 ** 4, 7 ** 4, 4 ** 4].reduce((a, b) => a + b, 0));
console.log('but 9474 as cubes    :', cubeSum(9474));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('153 is the sum of the cubes of its digits', [cubeSum(153), isArmstrong(153)], [153, true]);
test('370, 371 and 407 are the other three-digit ones', [370, 371, 407].every(isArmstrong), true);
test('154 is not, being one away', [isArmstrong(154), cubeSum(154)], [false, 190]);
test('every Armstrong number below 10000', below10000, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 153, 370, 371, 407, 1634, 8208, 9474]);
test('every single digit qualifies, since d to the power 1 is d', [0, 1, 5, 9].every(isArmstrong), true);
test('and there are none at all with two digits', twoDigit, []);
test('9474 works because the exponent follows the digit count', [9 ** 4 + 4 ** 4 + 7 ** 4 + 4 ** 4, cubeSum(9474)], [9474, 1200]);
test('three five-digit ones exist', fiveDigit, [54748, 92727, 93084]);
test('and 9474 is not one of them under a fixed cube', isArmstrong(9474) && cubeSum(9474) !== 9474, true);

The exponent is the whole exercise. Fix it at 3 and you are asking a different question — the numbers equal to the sum of the cubes of their digits, which are 153, 370, 371 and 407 and nothing else. Let it follow the digit count and the family continues: 1634 and 8208 and 9474 with fourth powers, three more with fifth. It stops at 39 digits, because 9 ** k * k eventually falls behind 10 ** (k - 1).

The two-digit gap has a short proof. A two-digit number is 10a + b and the candidate sum is a² + b²; the largest that sum can be is 81 + 81 = 162, and checking the ninety cases is quick, but the useful observation is that a² + b² grows too slowly in a to keep up with 10a. The search above confirms the answer rather than establishing it.

50 Happy numbers

hard

Repeatedly replace a number by the sum of the squares of its digits. If it reaches 1 the number is happy; otherwise it falls into a cycle. Prove the loop always ends, and find where the unhappy ones go.

Stuck? Three hints, smallest first
  1. 1. Track what you have already seen, and stop when a value repeats.
  2. 2. Every unhappy number reaches the same eight-element cycle.
  3. 3. For any n of four digits or more the next value is far smaller, so the sequence cannot run away.

The answer

function squareDigits(n) {
  let rest = n;
  let sum = 0;
  while (rest > 0) {
    const d = rest % 10;
    sum += d * d;
    rest = Math.floor(rest / 10);
  }
  return sum;
}

function isHappy(n) {
  const seen = new Set();
  while (n !== 1 && !seen.has(n)) {
    seen.add(n);
    n = squareDigits(n);
  }
  return n === 1;
}

const chainFrom = (n, steps) => {
  const out = [n];
  for (let i = 0; i < steps && out.at(-1) !== 1; i++) out.push(squareDigits(out.at(-1)));
  return out;
};

const happyBelow50 = [];
for (let n = 1; n < 50; n++) if (isHappy(n)) happyBelow50.push(n);

const happyToHundred = [];
for (let n = 1; n <= 100; n++) if (isHappy(n)) happyToHundred.push(n);

// Every unhappy number below 500 passes through 4.
let allReachFour = true;
for (let n = 2; n < 500; n++) {
  if (isHappy(n)) continue;
  const seen = new Set();
  let m = n;
  let hitFour = false;
  while (!seen.has(m)) {
    seen.add(m);
    m = squareDigits(m);
    if (m === 4) hitFour = true;
  }
  if (!hitFour) allReachFour = false;
}

// Nothing can run away: four digits or more always shrinks.
let alwaysShrinks = true;
for (let n = 1000; n < 2000; n++) if (squareDigits(n) >= n) alwaysShrinks = false;

console.log('7 is happy   :', isHappy(7), chainFrom(7, 10));
console.log('4 is not     :', isHappy(4), chainFrom(4, 8));
console.log('happy below 50:', happyBelow50);
console.log('how many to 100:', happyToHundred.length);
console.log('squareDigits(9999):', squareDigits(9999), '<- far below 9999');

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('7 is happy, and 4 is not', [isHappy(7), isHappy(4)], [true, false]);
test('the chain from 7 reaches 1 in five steps', chainFrom(7, 10), [7, 49, 97, 130, 10, 1]);
test('the chain from 4 comes back to 4 after eight', chainFrom(4, 8), [4, 16, 37, 58, 89, 145, 42, 20, 4]);
test('the happy numbers below 50', happyBelow50, [1, 7, 10, 13, 19, 23, 28, 31, 32, 44, 49]);
test('20 of the first hundred are happy', happyToHundred.length, 20);
test('every power of ten is happy, trivially', [1, 10, 100, 1000].every(isHappy), true);
test('and every unhappy number below 500 passes through 4', allReachFour, true);
test('the loop always ends, because 4 digits or more always shrinks', [alwaysShrinks, squareDigits(9999)], [true, 324]);
test('so nothing above 1000 can escape upwards', squareDigits(1000) < 1000, true);

Termination is the part worth proving rather than assuming. The largest value squareDigits can return for a k-digit input is 81k, which for four digits is 324 — so anything of four digits or more shrinks immediately, and the sequence is trapped below 1000 forever after. A finite region plus a deterministic step means either 1 or a repeat, which is exactly what the Set detects.

The unhappy destination is always the same eight-element cycle, 4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4. That makes a shortcut possible: instead of remembering every value seen, test for n === 4. It is a real optimisation and a fragile one, because it encodes a fact about base 10 that the Set version does not need to know.

51 Triangular numbers, and series sums in closed form

fair

Sum 1 to n without a loop, then generalise to any arithmetic and geometric series. Test whether a number is triangular, and find where the closed form leaves exact-integer range.

Stuck? Three hints, smallest first
  1. 1. n * (n + 1) / 2, and the division is exact because one of the two is always even.
  2. 2. A number t is triangular exactly when 8t + 1 is a perfect square.
  3. 3. The geometric formula divides by r - 1, so r === 1 needs its own branch.

The answer

const triangular = (n) => (n * (n + 1)) / 2;

const triangularByLoop = (n) => {
  let sum = 0;
  for (let k = 1; k <= n; k++) sum += k;
  return sum;
};

const isPerfectSquare = (n) => {
  const r = Math.round(Math.sqrt(n));
  return r * r === n;
};
const isTriangular = (t) => t >= 0 && isPerfectSquare(8 * t + 1);

// n terms starting at a, step d.
const arithmeticSum = (a, d, n) => (n * (2 * a + (n - 1) * d)) / 2;

// n terms starting at a, ratio r. r === 1 is not a special case of the formula.
const geometricSum = (a, r, n) => (r === 1 ? a * n : (a * (r ** n - 1)) / (r - 1));

const geometricByLoop = (a, r, n) => {
  let sum = 0;
  let term = a;
  for (let k = 0; k < n; k++) {
    sum += term;
    term *= r;
  }
  return sum;
};

let towardsTwo = 0;
let term = 1;
for (let k = 0; k < 100; k++) {
  towardsTwo += term;
  term /= 2;
}

console.log('first eight    :', [0, 1, 2, 3, 4, 5, 6, 7].map(triangular));
console.log('T(100), T(1000):', triangular(100), triangular(1000));
console.log('T(2e8)         :', triangular(200000000), 'safe?', Number.isSafeInteger(triangular(200000000)));
console.log('3, 8, 13, ... x 10:', arithmeticSum(3, 5, 10));
console.log('1 + 2 + 4 + ... :', geometricSum(1, 2, 10));
console.log('1 + 1/2 + ...   :', geometricSum(1, 0.5, 10), 'and 100 terms give', towardsTwo);
console.log('r === 1 unguarded:', (5 * (1 ** 4 - 1)) / (1 - 1));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the first eight triangular numbers', [0, 1, 2, 3, 4, 5, 6, 7].map(triangular), [0, 1, 3, 6, 10, 15, 21, 28]);
test('T(100) is 5050 and T(1000) is 500500, matching the loop', [triangular(100), triangular(1000), triangularByLoop(1000)], [5050, 500500, 500500]);
test('8t + 1 being square identifies them', [[1, 3, 6, 10, 28].every(isTriangular), [2, 4, 5, 7, 29].some(isTriangular)], [true, false]);
test('an arithmetic series in closed form', [arithmeticSum(3, 5, 10), arithmeticSum(1, 1, 100)], [255, 5050]);
test('ten powers of two sum to 1023, one short of 1024', [geometricSum(1, 2, 10), 2 ** 10 - 1], [1023, 1023]);
test('halves are exact in binary, so the formula and the loop agree exactly', [geometricSum(1, 0.5, 10), geometricByLoop(1, 0.5, 10)], [1.998046875, 1.998046875]);
test('and 100 terms land exactly on 2, the limit', towardsTwo, 2);
test('a ratio of 1 would divide by zero, hence the branch', [geometricSum(5, 1, 4), Number.isNaN((5 * (1 ** 4 - 1)) / (1 - 1))], [20, true]);
test('T(2e8) is past 2 ** 53, so the closed form stops being exact', [triangular(200000000), Number.isSafeInteger(triangular(200000000))], [20000000100000000, false]);

n * (n + 1) / 2 is safe to write as a division rather than a multiplication by 0.5: consecutive integers include an even one, so the numerator is always even and the result is exact. That stops being the point at around n = 1.34e8, where the product itself passes 2 ** 53 and the answer is merely close.

Two things about the geometric form. The r === 1 branch is not defensive programming, it is a genuine hole in the formula: (r ** n - 1) / (r - 1) is 0 / 0 there. And the halves case is a reminder that floating point is not always lossy — 0.5, 0.25 and their sums are exact binary fractions, so the closed form and the term-by-term loop agree to the last bit, which they would not for a ratio of 1 / 3.

52 Fibonacci, iteratively and memoised

fair

Compute Fibonacci numbers with a loop, then count how many calls a naive recursion makes for fib(25) and how many a memoised one makes. Find the last term a double can hold exactly.

Stuck? Three hints, smallest first
  1. 1. Two variables and a swap are all the iterative version needs.
  2. 2. Naive recursion recomputes the same subtrees; the call count is 2 * fib(n + 1) - 1.
  3. 3. Lucas numbers use the same recurrence from different seeds: 2 and 1 instead of 0 and 1.

The answer

function fib(n) {
  let a = 0;
  let b = 1;
  for (let i = 0; i < n; i++) [a, b] = [b, a + b];
  return a;
}

// Same recurrence, exact, for checking where the double version drifts.
function fibExact(n) {
  let a = 0n;
  let b = 1n;
  for (let i = 0; i < n; i++) [a, b] = [b, a + b];
  return a;
}

let naiveCalls = 0;
function naiveFib(n) {
  naiveCalls++;
  return n < 2 ? n : naiveFib(n - 1) + naiveFib(n - 2);
}

let memoCalls = 0;
const memo = new Map();
function memoFib(n) {
  memoCalls++;
  if (n < 2) return n;
  if (memo.has(n)) return memo.get(n);
  const value = memoFib(n - 1) + memoFib(n - 2);
  memo.set(n, value);
  return value;
}

// Lucas: the same rule, seeded 2 and 1.
function lucas(n) {
  let a = 2;
  let b = 1;
  for (let i = 0; i < n; i++) [a, b] = [b, a + b];
  return a;
}

naiveCalls = 0;
const naive25 = naiveFib(25);
const naiveCost = naiveCalls;

memoCalls = 0;
const memo25 = memoFib(25);
const memoCost = memoCalls;

console.log('first twelve :', Array.from({ length: 12 }, (_, i) => fib(i)));
console.log('fib(50)      :', fib(50));
console.log('fib(78)      :', fib(78), 'safe?', Number.isSafeInteger(fib(78)));
console.log('fib(79)      :', fib(79), 'but exactly', fibExact(79).toString());
console.log('naive fib(25):', naive25, 'in', naiveCost, 'calls');
console.log('memoised     :', memo25, 'in', memoCost, 'calls');
console.log('lucas        :', Array.from({ length: 11 }, (_, i) => lucas(i)));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the first twelve Fibonacci numbers', Array.from({ length: 12 }, (_, i) => fib(i)), [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]);
test('fib(50) is 12586269025', fib(50), 12586269025);
test('fib(78) is the last one that is a safe integer', [fib(78), Number.isSafeInteger(fib(78)), Number.isSafeInteger(fib(79))], [8944394323791464, true, false]);
test('and fib(79) comes back one short of the true value', [fib(79), fibExact(79).toString()], [14472334024676220, '14472334024676221']);
test('the naive recursion makes 242785 calls for fib(25)', naiveCost, 242785);
test('which is exactly 2 * fib(26) - 1', 2 * fib(26) - 1, 242785);
test('memoising cuts that to 49, or 2n - 1', [memoCost, 2 * 25 - 1], [49, 49]);
test('the Lucas numbers, same rule from different seeds', Array.from({ length: 11 }, (_, i) => lucas(i)), [2, 1, 3, 4, 7, 11, 18, 29, 47, 76, 123]);
test('and L(n) is F(n-1) + F(n+1)', Array.from({ length: 12 }, (_, i) => i + 1).every((n) => lucas(n) === fib(n - 1) + fib(n + 1)), true);

242,785 calls to compute the 25th Fibonacci number, against 49 with a Map in front and 25 iterations with a loop. The exact figure 2 * fib(n + 1) - 1 is worth knowing because it makes the cost visible: the call count is itself Fibonacci, so it grows by a factor of about 1.618 per step. fib(50) naively would be about 40 billion calls.

The precision limit is fib(78). fib(79) is 14472334024676221, which is odd and past 2 ** 53, so the nearest double is the even number below it and the loop returns a value one too small — silently, with no flag and no exception. You cannot even write the true value as a literal to compare against, which is why the check above holds it as a BigInt and compares strings. Note that BigInt values must not go into test directly: JSON.stringify throws on them.

53 Collatz sequence length

hard

Count the terms in the Collatz sequence for n: halve it when even, triple and add one when odd, until it reaches 1. Find the longest chain starting below 1000, then use a cache to reach 10000.

Stuck? Three hints, smallest first
  1. 1. Count terms including both the start and the final 1, and say which you meant.
  2. 2. Chains overlap heavily, so a Map from value to remaining length pays for itself.
  3. 3. The chain for 27 goes far above 27 before coming down, so a cache keyed by value ends up larger than the range you scanned.

The answer

function terms(n) {
  let count = 1;                       // the starting value is a term
  while (n !== 1) {
    n = n % 2 === 0 ? n / 2 : 3 * n + 1;
    count++;
  }
  return count;
}

const sequence = (n) => {
  const out = [n];
  while (out.at(-1) !== 1) {
    const last = out.at(-1);
    out.push(last % 2 === 0 ? last / 2 : 3 * last + 1);
  }
  return out;
};

const peak = (n) => {
  let highest = n;
  while (n !== 1) {
    n = n % 2 === 0 ? n / 2 : 3 * n + 1;
    if (n > highest) highest = n;
  }
  return highest;
};

// Cached: walk up to a known value, then fill in the path on the way back.
const cache = new Map([[1, 1]]);
function cachedTerms(n) {
  const path = [];
  let m = n;
  while (!cache.has(m)) {
    path.push(m);
    m = m % 2 === 0 ? m / 2 : 3 * m + 1;
  }
  let length = cache.get(m);
  while (path.length) cache.set(path.pop(), ++length);
  return cache.get(n);
}

let bestBelow1000 = { n: 1, terms: 1 };
for (let n = 1; n < 1000; n++) {
  const t = terms(n);
  if (t > bestBelow1000.terms) bestBelow1000 = { n, terms: t };
}

let bestTo10000 = { n: 1, terms: 1 };
for (let n = 1; n <= 10000; n++) {
  const t = cachedTerms(n);
  if (t > bestTo10000.terms) bestTo10000 = { n, terms: t };
}

console.log('sequence for 6 :', sequence(6));
console.log('terms for 27   :', terms(27), 'peaking at', peak(27));
console.log('longest < 1000 :', bestBelow1000);
console.log('longest <= 1e4 :', bestTo10000);
console.log('cache entries  :', cache.size, 'for a range of 10000');

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('1 is a sequence of one term, and 2 of two', [terms(1), terms(2)], [1, 2]);
test('the sequence for 6', sequence(6), [6, 3, 10, 5, 16, 8, 4, 2, 1]);
test('27 takes 112 terms, which is 111 steps', [terms(27), sequence(27).length - 1], [112, 111]);
test('and climbs to 9232 on the way, 341 times its start', peak(27), 9232);
test('a power of two just halves, so 2 ** k takes k + 1 terms', [1, 2, 4, 8, 1024].map(terms), [1, 2, 3, 4, 11]);
test('the longest chain starting below 1000 belongs to 871', [bestBelow1000.n, bestBelow1000.terms], [871, 179]);
test('and up to 10000 it is 6171, with 262 terms', [bestTo10000.n, bestTo10000.terms], [6171, 262]);
test('the cached version agrees with the plain one', [1, 6, 27, 871, 9999].every((n) => cachedTerms(n) === terms(n)), true);
test('and its cache holds more values than the range scanned', cache.size > 10000, true);

Two things make this exercise more than a loop. The first is what you count: 27 has 112 terms and 111 steps, and published tables use both conventions, so a mismatch here is usually a definition disagreement rather than a bug. The second is that the sequence goes up. 27 reaches 9232 before it comes down, which is why the cache ends up with more than 10000 entries after scanning 10000 numbers, and why nobody has proved the process terminates for every input.

The cache is worth the two loops it takes. Chains merge quickly — every even number's chain is one step from n / 2's — so the walk up almost always meets a known value after a few steps, and the walk back fills in the path. Building it the obvious way, by recursing and memoising on the way out, risks a deep stack for a starting value whose chain climbs a long way.

54 Factorial, and where it stops being exact

hard

Compute n! iteratively. Then find the largest factorial that is a safe integer, the largest that is exactly representable at all, and the smallest that overflows to Infinity.

Stuck? Three hints, smallest first
  1. 1. A product of every integer up to n, starting from 1, so that 0! is 1.
  2. 2. "Safe" and "exact" are different: past 2 ** 53 only some integers are representable, and a factorial has many factors of 2.
  3. 3. A BigInt version alongside gives you something to compare against.

The answer

function factorial(n) {
  let product = 1;
  for (let k = 2; k <= n; k++) product *= k;
  return product;
}

function factorialExact(n) {
  let product = 1n;
  for (let k = 2n; k <= BigInt(n); k++) product *= k;
  return product;
}

const isExact = (n) => Number.isInteger(factorial(n)) && BigInt(factorial(n)) === factorialExact(n);

let lastSafe = 0;
while (Number.isSafeInteger(factorial(lastSafe + 1))) lastSafe++;

let lastExact = 0;
while (isExact(lastExact + 1)) lastExact++;

let lastFinite = 0;
while (Number.isFinite(factorial(lastFinite + 1))) lastFinite++;

console.log('0! to 5!    :', [0, 1, 2, 3, 4, 5].map(factorial));
console.log('10!         :', factorial(10));
console.log('last safe   :', lastSafe, factorial(lastSafe));
console.log('last exact  :', lastExact, factorial(lastExact));
console.log('23!         :', factorial(23), 'against', factorialExact(23).toString());
console.log('last finite :', lastFinite, factorial(lastFinite));
console.log('171!        :', factorial(171));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('0! and 1! are both 1, and 5! is 120', [factorial(0), factorial(1), factorial(5)], [1, 1, 120]);
test('10! is 3628800, and matches a reduce over 1..10', [factorial(10), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].reduce((a, b) => a * b, 1)], [3628800, 3628800]);
test('18! is the last factorial that is a safe integer', [lastSafe, factorial(18)], [18, 6402373705728000]);
test('19! is past MAX_SAFE_INTEGER yet still exactly representable', [Number.isSafeInteger(factorial(19)), isExact(19)], [false, true]);
test('because a factorial carries plenty of factors of 2', factorial(19) / 2 ** 16 === 1856156927625, true);
test('22! is the last one that is exact', [lastExact, isExact(22), isExact(23)], [22, true, false]);
test('23! comes back 1572864 too large', Number(BigInt(factorial(23)) - factorialExact(23)), 1572864);
test('170! is the last finite one, at about 7.26e306', [lastFinite, factorial(170)], [170, 7.257415615307994e306]);
test('171! overflows to Infinity, and ratios of it are NaN', [factorial(171) === Infinity, Number.isNaN(factorial(171) / factorial(171))], [true, true]);

Three different limits, and confusing them is the trap. Safe stops at 18!, where the value passes Number.MAX_SAFE_INTEGER and consecutive integers stop being distinguishable in general. Exact goes further, to 22!, because a factorial is divisible by a large power of two — 19! is 2 ** 16 times an odd number small enough to hold, so it lands precisely on a representable double. Finite reaches 170!, and 171! is Infinity.

23! is the interesting failure: no exception, no NaN, just an answer 1,572,864 too large. Any code that divides factorials — the naive binomial coefficient in exercise 55 is the obvious one — inherits that error and often amplifies it, and past 170 it inherits Infinity / Infinity instead, which at least announces itself as NaN. Factorials of any size belong in BigInt.

55 Combinations, permutations and a row of Pascal

hard

Compute nCr and nPr without building a factorial first. Show the factorial route failing on C(200, 100), then produce a row of Pascal's triangle two ways and find where they disagree.

Stuck? Three hints, smallest first
  1. 1. Multiply and divide alternately: r = r * (n - k + i) / i keeps every intermediate as a binomial coefficient.
  2. 2. C(n, k) === C(n, n - k), so replace k with the smaller of the two first.
  3. 3. A row can be built from C(n, k) for each k, or from the row above by adding neighbours.

The answer

const factorial = (n) => {
  let p = 1;
  for (let k = 2; k <= n; k++) p *= k;
  return p;
};

const chooseByFactorials = (n, k) => factorial(n) / (factorial(k) * factorial(n - k));

// Alternating multiply and divide: every intermediate is itself a C(m, i).
function choose(n, k) {
  if (k < 0 || k > n) return 0;
  k = Math.min(k, n - k);
  let result = 1;
  for (let i = 1; i <= k; i++) result = (result * (n - k + i)) / i;
  return result;
}

const permute = (n, k) => {
  if (k < 0 || k > n) return 0;
  let result = 1;
  for (let i = 0; i < k; i++) result *= n - i;
  return result;
};

// A row by running product, which drifts, ...
function rowByProduct(n) {
  const row = [1];
  for (let k = 0; k < n; k++) row.push((row[k] * (n - k)) / (k + 1));
  return row;
}

// ... and a row by addition, which does not.
function rowByAddition(n) {
  let row = [1];
  for (let i = 0; i < n; i++) {
    const next = [1];
    for (let j = 0; j < row.length - 1; j++) next.push(row[j] + row[j + 1]);
    next.push(1);
    row = next;
  }
  return row;
}

console.log('C(5, 2)        :', choose(5, 2));
console.log('C(52, 5)       :', choose(52, 5), 'P(52, 5):', permute(52, 5));
console.log('C(200, 100) via factorials:', chooseByFactorials(200, 100));
console.log('the same, multiplied out  :', choose(200, 100));
console.log('row 5          :', rowByProduct(5));
console.log('C(56, 28)      :', choose(56, 28));
console.log('row 56 at k=28 :', rowByProduct(56)[28], '<- one too many');
console.log('by addition    :', rowByAddition(56)[28]);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('C(5, 2) is 10, and the edges are 1', [choose(5, 2), choose(5, 0), choose(5, 5), choose(5, 6)], [10, 1, 1, 0]);
test('there are 2598960 five-card hands in a 52-card deck', choose(52, 5), 2598960);
test('ordered, that is 311875200, which is C times 5!', [permute(52, 5), choose(52, 5) * factorial(5)], [311875200, 311875200]);
test('C(n, k) equals C(n, n - k)', [0, 1, 2, 3].map((k) => choose(9, k) === choose(9, 9 - k)), [true, true, true, true]);
test('the factorial route gives NaN for C(200, 100), since 200! is Infinity', [Number.isNaN(chooseByFactorials(200, 100)), factorial(200) === Infinity], [true, true]);
test('multiplying and dividing alternately survives it', choose(200, 100), 9.054851465610333e58);
test('C(56, 28) is exact and still a safe integer', [choose(56, 28), Number.isSafeInteger(choose(56, 28))], [7648690600760440, true]);
test('but the running-product row gets that entry one too high', rowByProduct(56)[28], 7648690600760441);
test('while building the row by addition keeps it exact', [rowByProduct(5), rowByAddition(56)[28]], [[1, 5, 10, 10, 5, 1], 7648690600760440]);

C(200, 100) is about 9.05e58, comfortably inside a double's range, and the factorial route still returns NaN — because 200! is Infinity and Infinity / Infinity is undefined. The multiplicative loop never builds a value larger than the answer, so it returns something usable. Reducing k to min(k, n - k) first halves the iterations and lowers every intermediate, for free, by symmetry.

The two row builders disagree at row 56, and the reason is the order of operations rather than the size of the answer. C(56, 28) is 7648690600760440, still a safe integer; the running product row[k] * (n - k) / (k + 1) passes through intermediates near 2 ** 53 where a division rounds, and the error survives to the end as an off-by-one. Pascal's own rule — each entry is the sum of the two above it — uses nothing but integer addition, so it stays exact for as long as the entries themselves fit.

56 The mean, and the empty list

easy

Average a list of marks. Then say what your function returns for an empty list, and add three prices as floats and again as integer kobo.

Stuck? Three hints, smallest first
  1. 1. reduce with a starting value of 0 totals a list, and the total of nothing is 0.
  2. 2. The mean of an empty list is 0 / 0, and that is not 0.
  3. 3. The mean is not robust: one large value moves it a long way.

The answer

const sum = (xs) => xs.reduce((a, b) => a + b, 0);
const mean = (xs) => sum(xs) / xs.length;        // 0 / 0 when xs is empty

const marks = [72, 88, 61, 95, 79];

// The same three prices as floats, and as integer kobo.
const prices = [19.99, 25.5, 8.75];
const kobo = [1999, 2550, 875];

console.log('total          :', sum(marks));
console.log('mean           :', mean(marks));
console.log('mean of nothing:', mean([]), 'but the total of nothing is', sum([]));
console.log('with an outlier:', mean([...marks, 1000]));
console.log('float total    :', sum(prices));
console.log('kobo total     :', sum(kobo), '=', sum(kobo) / 100);
console.log('mean of tenths :', (0.1 + 0.2 + 0.3) / 3);

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the five marks total 395, so the mean is 79', [sum(marks), mean(marks)], [395, 79]);
test('the mean of an empty list is NaN, because it is 0 / 0', Number.isNaN(mean([])), true);
test('though the total of an empty list is a perfectly good 0', sum([]), 0);
test('one outlier drags the mean from 79 to 232.5', mean([...marks, 1000]), 232.5);
test('adding the prices as floats misses 54.24 by a hair', [sum(prices), sum(prices) === 54.24], [54.239999999999995, false]);
test('the same money in integer kobo adds up exactly', [sum(kobo), sum(kobo) / 100], [5424, 54.24]);
test('and the mean of 0.1, 0.2 and 0.3 is not 0.2', (0.1 + 0.2 + 0.3) / 3, 0.20000000000000004);
test('rounding to three places recovers the answer you wanted', Math.round(((0.1 + 0.2 + 0.3) / 3) * 1000) / 1000, 0.2);

Two traps, both about the edges rather than the arithmetic. The empty list gives NaN, and NaN spreads: one empty group in a report of averages turns a column into null once it is serialised to JSON. Decide what an empty group means — 0, null, or "no data" — and return that deliberately.

The other is that a mean is a sum, so it inherits every problem a sum has. Money added as 19.99 + 25.5 + 8.75 lands on 54.239999999999995, which prints as the wrong figure and compares unequal to 54.24. Add kobo, divide once at the end. Note that the divide can rescue a drifted sum by accident, which is why the assertion above checks the sum rather than the average.

57 The median, and the sort that ruins it

easy

Find the middle value of a list, averaging the two middle values when the count is even. Then show what [5, 40, 300, 1000].sort() does, and prove your function leaves the caller's array alone.

Stuck? Three hints, smallest first
  1. 1. sort with no comparator compares strings, so 1000 sorts before 5.
  2. 2. sort also mutates in place, so sort a copy.
  3. 3. For an even count there is no single middle: take the mean of the two.

The answer

function median(xs) {
  const sorted = xs.slice().sort((a, b) => a - b);   // slice, or you reorder the caller's array
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}

const marks = [72, 88, 61, 95, 79];
const wide = [5, 40, 300, 1000];
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;

const caller = [3, 1, 2];
const answer = median(caller);

console.log('default sort   :', wide.slice().sort(), '<- strings');
console.log('numeric sort   :', wide.slice().sort((a, b) => a - b));
console.log('median of marks:', median(marks), 'and the mean is', mean(marks));
console.log('median of wide :', median(wide));
console.log('with an outlier:', median([...marks, 1000]), 'against a mean of', mean([...marks, 1000]));
console.log('caller after   :', caller, 'answer', answer);

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a comparator-free sort orders 1000 before 5', wide.slice().sort(), [1000, 300, 40, 5]);
test('and (a, b) => a - b fixes it', wide.slice().sort((a, b) => a - b), [5, 40, 300, 1000]);
test('these five marks have a median of 79, the same as their mean', [median(marks), mean(marks)], [79, 79]);
test('an even count averages the middle pair', [median([1, 2, 3, 4]), median(wide)], [2.5, 170]);
test('one outlier moves the median 4.5 and the mean 153.5', [median([...marks, 1000]), mean([...marks, 1000])], [83.5, 232.5]);
test('the caller\'s array is left in its original order', caller, [3, 1, 2]);
test('a one-element list is its own median', median([42]), 42);
test('and an empty list gives NaN, since there is no middle', Number.isNaN(median([])), true);

Array.prototype.sort converts each element to a string and compares those, so [5, 40, 300, 1000] comes back as [1000, 300, 40, 5]. Every numeric sort needs (a, b) => a - b. It is also in-place, so xs.sort() inside a "read-only" statistics helper quietly reorders whatever the caller passed in; xs.slice() first, or xs.toSorted((a, b) => a - b) where it is available.

The median earns its keep on skewed data. Add a single 1000 to these marks and the median moves from 79 to 83.5 while the mean jumps to 232.5. Half the values have to move before the median does, which is exactly why incomes and house prices are reported as medians.

58 The mode, and what a tie means

fair

Find the most frequent value in a list. Return every value that ties for the top count, and nothing at all when no value repeats. Then show why the tally belongs in a Map.

Stuck? Three hints, smallest first
  1. 1. Tally into a Map: counts.set(x, (counts.get(x) ?? 0) + 1).
  2. 2. A plain object turns every key into a string, so the number 4 comes back as "4".
  3. 3. If the highest count is 1, nothing repeated, so there is no mode.

The answer

function tally(xs) {
  const counts = new Map();
  for (const x of xs) counts.set(x, (counts.get(x) ?? 0) + 1);
  return counts;
}

function modes(xs) {
  const counts = tally(xs);
  const top = Math.max(...counts.values());
  if (top === 1) return [];                       // nothing repeated
  return [...counts.keys()].filter((k) => counts.get(k) === top).sort((a, b) => a - b);
}

const sizes = [4, 7, 4, 9, 4, 7];
const tied = [1, 2, 2, 3, 3, 4];

// The same tally in a plain object, for comparison.
const asObject = {};
for (const x of [10, 2, 33, 2]) asObject[x] = (asObject[x] ?? 0) + 1;

const orders = ['yam', 'rice', 'yam'];

console.log('tally     :', [...tally(sizes)]);
console.log('mode      :', modes(sizes));
console.log('a tie     :', modes(tied));
console.log('no repeats:', modes([5, 6, 7]));
console.log('as object :', asObject, 'keys', Object.keys(asObject));
console.log('strings   :', [...tally(orders)]);

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('4 appears three times, so it is the only mode', modes(sizes), [4]);
test('2 and 3 both appear twice, and both are modes', modes(tied), [2, 3]);
test('a list with no repeats has no mode at all', modes([5, 6, 7]), []);
test('and neither has an empty list', modes([]), []);
test('a Map keeps the keys as numbers', [typeof [...tally(sizes).keys()][0], [...tally(sizes).keys()]], ['number', [4, 7, 9]]);
test('a plain object turns them into strings', [typeof Object.keys(asObject)[0], Object.keys(asObject)], ['string', ['2', '10', '33']]);
test('and reorders integer-like keys, losing the order they arrived in', Object.keys(asObject).join() === '10,2,33', false);
test('the tally works on anything comparable, not just numbers', [...tally(orders)], [['yam', 2], ['rice', 1]]);

The mode is the only average of the three that need not be a single number, and pretending otherwise is the trap. [1, 2, 2, 3, 3, 4] has two modes; a function returning one of them is picking arbitrarily, usually whichever the tally happened to see first. Return an array and let the caller decide. Deciding that "nothing repeated" means there is no mode is a choice too, and one worth stating.

Use a Map, not an object. Object keys are strings, so counts[4] and counts['4'] are the same slot and the mode comes back as '4' — which then fails === 4. Worse, integer-like keys are enumerated in ascending numeric order rather than insertion order, so the tally silently reorders your data. A Map keeps the key's type and its insertion order.

59 The range, and the minimum of nothing

easy

Find the smallest, the largest and the spread between them. Then check what Math.min() returns with no arguments, what one NaN in the list does, and use reduce on a list too long to spread.

Stuck? Three hints, smallest first
  1. 1. Math.min(...xs) spreads the list into arguments, and there is a limit to how many.
  2. 2. Math.min() with nothing to compare returns the identity for minimum, which is Infinity.
  3. 3. Math.min(1, NaN) is NaN: one bad reading poisons the answer.

The answer

const range = (xs) => Math.max(...xs) - Math.min(...xs);

// No spread, so no argument limit.
const minOf = (xs) => xs.reduce((a, b) => (b < a ? b : a), Infinity);
const maxOf = (xs) => xs.reduce((a, b) => (b > a ? b : a), -Infinity);

const marks = [72, 88, 61, 95, 79];
const big = Array.from({ length: 100000 }, (_, i) => (i * 7919) % 100000);

console.log('min, max, range:', Math.min(...marks), Math.max(...marks), range(marks));
console.log('reduce agrees  :', minOf(marks), maxOf(marks));
console.log('no arguments   :', Math.min(), Math.max());
console.log('so empty range :', range([]));
console.log('one NaN        :', Math.min(...[1, NaN, 3]));
console.log('100000 values  :', minOf(big), maxOf(big));
console.log('0 and -0       :', Math.min(0, -0), 'which is minus zero:', Object.is(Math.min(0, -0), -0));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the marks run from 61 to 95, a range of 34', [Math.min(...marks), Math.max(...marks), range(marks)], [61, 95, 34]);
test('reduce gives the same two numbers with no spread', [minOf(marks), maxOf(marks)], [61, 95]);
test('Math.min() is Infinity and Math.max() is -Infinity', [Math.min() === Infinity, Math.max() === -Infinity], [true, true]);
test('so the range of an empty list is -Infinity, not 0', range([]) === -Infinity, true);
test('a single NaN in the list poisons the minimum', Number.isNaN(Math.min(...[1, NaN, 3])), true);
test('reduce handles 100000 values, which a spread may refuse', [minOf(big), maxOf(big)], [0, 99999]);
test('Math.min(0, -0) returns minus zero, and Math.max returns plus', [Object.is(Math.min(0, -0), -0), Object.is(Math.max(0, -0), 0)], [true, true]);
test('a one-value list has a range of 0', range([42]), 0);

Math.min() returning Infinity is not a quirk, it is the identity element: the minimum of nothing has to be larger than everything for min(a, min()) to equal a. It does mean the range of an empty list is -Infinity, which no report wants to print, so guard the empty case yourself.

Math.min(...xs) passes every element as a separate argument. That is fine for a hundred values and fails for a hundred thousand — the engine's argument limit is not part of the language, so where exactly it breaks is not something to build on. reduce has no such ceiling. And note the NaN: Math.min does not skip it, so filter your readings before you summarise them.

60 Variance and standard deviation: divide by n, or by n - 1

fair

Compute the variance and standard deviation of a list both ways — dividing the squared deviations by n and by n - 1 — and say which is which. Then show the one-pass formula mean(x²) - mean(x)² failing on values that are large but close together.

Stuck? Three hints, smallest first
  1. 1. Sum the squared deviations from the mean, then choose your divisor: n for a whole population, n - 1 for a sample.
  2. 2. The standard deviation is the square root of the variance, so it is back in the original units.
  3. 3. The one-pass formula subtracts two nearly equal large numbers, and the digits that survive are the ones that were noise.

The answer

const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;

const squaredDeviations = (xs) => {
  const m = mean(xs);
  return xs.reduce((acc, x) => acc + (x - m) ** 2, 0);
};

const variance = (xs, sample = false) =>
  squaredDeviations(xs) / (xs.length - (sample ? 1 : 0));
const sd = (xs, sample = false) => Math.sqrt(variance(xs, sample));

// One pass, no stored mean, and no accuracy either.
const naiveVariance = (xs) => mean(xs.map((x) => x * x)) - mean(xs) ** 2;

const data = [2, 4, 4, 4, 5, 5, 7, 9];
const offset = [1e9 + 1, 1e9 + 2, 1e9 + 3];   // spread of 2, sitting a billion out

console.log('mean, sum of squares:', mean(data), squaredDeviations(data));
console.log('population var, sd  :', variance(data), sd(data));
console.log('sample var, sd      :', variance(data, true), sd(data, true));
console.log('offset two-pass     :', variance(offset));
console.log('offset one-pass     :', naiveVariance(offset), '<- every digit gone');
console.log('constant list       :', sd([5, 5, 5]), 'sample sd of one value:', variance([5], true));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the mean is 5 and the squared deviations sum to 32', [mean(data), squaredDeviations(data)], [5, 32]);
test('dividing by n gives the population variance 4 and sd 2', [variance(data), sd(data)], [4, 2]);
test('dividing by n - 1 gives the sample variance 32/7', variance(data, true), 4.571428571428571);
test('whose square root, the sample sd, is a little larger', [sd(data, true), sd(data, true) > sd(data)], [2.138089935299395, true]);
test('squaring the sd returns the variance, so the units line up', sd(data) ** 2, 4);
test('the two-pass variance of the offset list is 2/3', variance(offset) === 2 / 3, true);
test('the one-pass formula returns a flat 0 for the same list', naiveVariance(offset), 0);
test('a constant list has zero spread, and one sampled value has none to measure', [sd([5, 5, 5]), Number.isNaN(variance([5], true))], [0, true]);

The divisor is a modelling decision, not a detail. Divide by n when the list is the population — every student in the class, every order last month. Divide by n - 1 when it is a sample standing in for something larger, because deviations measured from the sample's own mean are systematically too small, and n - 1 corrects the bias. Here the same eight numbers give 4 or 4.571428571428571 depending on which question is being asked. Spreadsheets default to the sample form; Math gives you neither, so you choose.

mean(x²) - mean(x)² is algebraically identical and numerically hopeless. For values near a billion, mean(x²) is about 1e18 — past Number.MAX_SAFE_INTEGER, where consecutive integers are no longer distinguishable — so subtracting mean(x)² cancels every significant digit and returns 0. The two-pass version subtracts small deviations from a mean first, and gets 2/3 exactly. Where one pass is genuinely required, use Welford's update rather than this.

61 Percentiles, on a stated definition

hard

Compute the 30th percentile of [15, 20, 35, 40, 50] two ways: nearest rank, and linear interpolation between neighbours. Get different answers, then show both agree at the median and at the extremes.

Stuck? Three hints, smallest first
  1. 1. Nearest rank: sort, take Math.ceil(p / 100 * n) and index that, 1-based.
  2. 2. Interpolated: the position is (n - 1) * p / 100, which usually lands between two elements.
  3. 3. Every definition must agree at p=0, p=50 for an odd count, and p=100. They are free to differ everywhere else.

The answer

function nearestRank(xs, p) {
  const a = xs.slice().sort((x, y) => x - y);
  if (p <= 0) return a[0];
  const rank = Math.ceil((p / 100) * a.length);        // 1-based
  return a[Math.min(rank, a.length) - 1];
}

function interpolated(xs, p) {
  const a = xs.slice().sort((x, y) => x - y);
  const i = ((a.length - 1) * p) / 100;                // 0-based, fractional
  const lo = Math.floor(i);
  const hi = Math.ceil(i);
  return lo === hi ? a[lo] : a[lo] + (i - lo) * (a[hi] - a[lo]);
}

const scores = [15, 20, 35, 40, 50];
const both = (p) => [nearestRank(scores, p), interpolated(scores, p)];

const q1 = interpolated(scores, 25);
const q3 = interpolated(scores, 75);

console.log('p30 :', both(30), '<- the two definitions disagree');
console.log('p40 :', both(40));
console.log('p50 :', both(50), 'and the median is 35');
console.log('p0, p100:', both(0), both(100));
console.log('quartiles:', q1, q3, 'IQR', q3 - q1);
console.log('p35 :', interpolated(scores, 35));
console.log('even count, p50:', interpolated([1, 2, 3, 4], 50));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('at the 30th percentile nearest rank says 20 and interpolation says 23', both(30), [20, 23]);
test('and at the 40th they say 20 and 29', both(40), [20, 29]);
test('both agree at the median of an odd-length list', both(50), [35, 35]);
test('and at the two ends', [both(0), both(100)], [[15, 15], [50, 50]]);
test('the interpolated quartiles are 20 and 40, so the IQR is 20', [q1, q3, q3 - q1], [20, 40, 20]);
test('interpolation at p50 of an even count is the median again', interpolated([1, 2, 3, 4], 50), 2.5);
test('nearest rank always returns a value that was in the list', scores.includes(nearestRank(scores, 35)), true);
test('interpolation need not: p35 is 26, which is nobody\'s score', [interpolated(scores, 35), scores.includes(26)], [26, false]);
test('with one value, every percentile is that value', [nearestRank([42], 17), interpolated([42], 17)], [42, 42]);

There is no single definition of a percentile. One statistics text lists nine, and they disagree by a whole element on small samples: here the 30th percentile is 20 or 23 depending on which you picked. Neither is wrong; a function that does not say which it implements is. The three fixed points are p=0, p=100 and — for an odd count — p=50, and any two definitions that disagree there are not both percentiles.

The practical difference is whether the answer has to be a real observation. Nearest rank always returns one of your data points, which is what you want for "the 90th percentile response time was 412ms". Interpolation returns a number between two of them, which is what a box plot's quartiles need, and it is what PERCENTILE.INC in a spreadsheet and numpy.percentile do by default. Interpolated results are also plain floating-point arithmetic, so round before printing: the values here came out exact only because the gaps happened to be tidy.

62 The weighted mean

fair

A course grade is 10% coursework, 30% midterm and 60% final, and a student scored 75, 62 and 88. Work out the grade, keeping the weights as whole percentages, and compare it with the same sum done in decimal weights.

Stuck? Three hints, smallest first
  1. 1. Multiply each value by its weight, add, then divide by the total weight — not by the count.
  2. 2. Whole-number weights keep the numerator exact and leave a single division at the end.
  3. 3. Equal weights must reproduce the plain mean; if they do not, the divisor is wrong.

The answer

function weightedMean(xs, ws) {
  let top = 0;
  let bottom = 0;
  for (let i = 0; i < xs.length; i++) {
    top += xs[i] * ws[i];
    bottom += ws[i];
  }
  return top / bottom;                    // divide by the weight total, not the count
}

const scores = [75, 62, 88];
const percent = [10, 30, 60];             // whole numbers, summing to 100
const decimal = [0.1, 0.3, 0.6];          // the same weights, as fractions
const raw = [1, 3, 6];                    // unnormalised, and it makes no difference

const plain = scores.reduce((a, b) => a + b, 0) / scores.length;

console.log('numerator      :', 10 * 75 + 30 * 62 + 60 * 88, 'over 100');
console.log('weighted grade :', weightedMean(scores, percent));
console.log('decimal weights:', weightedMean(scores, decimal), '<- one bit low');
console.log('raw weights    :', weightedMean(scores, raw));
console.log('unweighted mean:', plain);
console.log('equal weights  :', weightedMean(scores, [1, 1, 1]));
console.log('zero weights   :', weightedMean(scores, [0, 0, 0]));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the weighted grade is 78.9, from 7890 over 100', [10 * 75 + 30 * 62 + 60 * 88, weightedMean(scores, percent)], [7890, 78.9]);
test('the decimal weights give 78.89999999999999 instead', weightedMean(scores, decimal), 78.89999999999999);
test('so the two routes are not equal, though both are "right"', weightedMean(scores, decimal) === weightedMean(scores, percent), false);
test('scaling every weight changes nothing, so 1:3:6 also gives 78.9', weightedMean(scores, raw), 78.9);
test('the unweighted mean is 75, so the weighting is worth 3.9 marks', [plain, weightedMean(scores, percent) - plain], [75, 3.9000000000000057]);
test('equal weights reproduce the plain mean exactly', weightedMean(scores, [1, 1, 1]), plain);
test('weights totalling zero give NaN, not a division by count', Number.isNaN(weightedMean(scores, [0, 0, 0])), true);
test('the result always sits between the smallest and largest value', [weightedMean(scores, percent) >= 62, weightedMean(scores, percent) <= 88], [true, true]);

The divisor is the sum of the weights, not the number of items. Get that right and normalisation stops mattering: 10:30:60, 0.1:0.3:0.6 and 1:3:6 are the same weighting, and all three give 78.9. Get it wrong and you produce 7890, which at least announces itself.

Keep weights as whole numbers when you can. 0.1 * 75 + 0.3 * 62 + 0.6 * 88 is 78.89999999999999, because 0.1, 0.3 and 0.6 are all approximations and their products drift; (10 * 75 + 30 * 62 + 60 * 88) / 100 keeps an exact integer numerator and rounds once, landing on 78.9. The difference is one bit, and one bit is enough to fail a === 78.9 check, print 78.89999999999999 on a transcript, or round down to a lower grade band. The gap of 3.9 in the fifth assertion shows the same thing from the other side: subtracting two values that are each nearest-double gives 3.9000000000000057, not 3.9.

63 Normalising a list to 0 and 1

fair

Rescale readings so the smallest becomes 0 and the largest becomes 1. Then rescale to an arbitrary range, invert the transform, and show what a list of identical readings does to it.

Stuck? Three hints, smallest first
  1. 1. (x - min) / (max - min) is the whole transform.
  2. 2. The two ends must come out as exactly 0 and exactly 1.
  3. 3. If every reading is the same, max - min is 0 and every result is 0 / 0.

The answer

function normalise(xs) {
  const lo = Math.min(...xs);
  const hi = Math.max(...xs);
  return xs.map((x) => (x - lo) / (hi - lo));       // 0 / 0 when hi === lo
}

const rescale = (xs, a, b) => normalise(xs).map((t) => a + t * (b - a));
const denormalise = (ts, lo, hi) => ts.map((t) => lo + t * (hi - lo));

const readings = [12, 40, 25, 60];
const flat = [7, 7, 7];

const unit = normalise(readings);
const round4 = (x) => Math.round(x * 10000) / 10000;

console.log('normalised :', unit);
console.log('to 4 places:', unit.map(round4));
console.log('ends       :', unit[0], unit[3]);
console.log('back again :', denormalise(unit, 12, 60));
console.log('to 20..80  :', rescale(readings, 20, 80));
console.log('as percent :', rescale(readings, 0, 100).map(round4));
console.log('flat list  :', normalise(flat));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the smallest reading becomes exactly 0 and the largest exactly 1', [unit[0], unit[3]], [0, 1]);
test('the two in between, to four places', unit.map(round4), [0, 0.5833, 0.2708, 1]);
test('the transform is order-preserving', [unit[3] > unit[1], unit[1] > unit[2], unit[2] > unit[0]], [true, true, true]);
test('and it inverts back to the original readings, exactly this time', denormalise(unit, 12, 60), readings);
test('rescaling to 20..80 is the same map with different ends', rescale(readings, 20, 80), [20, 55, 36.25, 80]);
test('rescaling to 0..100 gives percentages of the span', rescale(readings, 0, 100).map(round4), [0, 58.3333, 27.0833, 100]);
test('a list of identical readings divides by zero, so every result is NaN', normalise(flat).every(Number.isNaN), true);
test('which is not the same as every result being 0', normalise(flat)[0] === 0, false);

Min-max normalisation is one subtraction and one division, and the whole risk is in the denominator. Identical readings — a sensor that has not moved, a single row, a column of one repeated value — make max - min zero, and every output becomes NaN. That NaN then travels: into a chart as a missing bar, into JSON as null, into a model as a poisoned feature. Decide what a flat list means, usually 0 or 0.5, and return it explicitly.

The exact 0 and 1 at the ends are not luck: (lo - lo) is 0 and (hi - lo) / (hi - lo) is 1 for any finite values, so the endpoints are safe even when the middle is not. Round-tripping through denormalise happened to be exact here because 48 is a power of two times 3; with other spans it will be a bit out, so compare with a tolerance rather than ===.

64 Cumulative sums, and a running mean

fair

Turn a list into its running totals, and get the original back by differencing. Then run ten lots of 0.1 through it, and keep a running mean without keeping a running total.

Stuck? Three hints, smallest first
  1. 1. A closure over a total plus map gives the scan: xs.map((x) => (total += x)).
  2. 2. The last cumulative value is the grand total, and differencing undoes the whole thing.
  3. 3. Welford: m += (x - m) / (i + 1) updates a mean without ever holding a sum.

The answer

function cumulative(xs) {
  let total = 0;
  return xs.map((x) => (total += x));
}

const differences = (cs) => cs.map((c, i) => (i === 0 ? c : c - cs[i - 1]));

// A mean that never stores a total, so it cannot overflow one.
function runningMean(xs) {
  let m = 0;
  return xs.map((x, i) => (m += (x - m) / (i + 1)));
}

const sales = [5, 10, 15, 20];
const tenths = Array(10).fill(0.1);
const koboTenths = Array(10).fill(10);      // 10 kobo, ten times
const marks = [72, 88, 61, 95, 79];

const cum = cumulative(sales);

console.log('cumulative   :', cum);
console.log('differenced  :', differences(cum));
console.log('grand total  :', cum[cum.length - 1]);
console.log('ten tenths   :', cumulative(tenths));
console.log('in kobo      :', cumulative(koboTenths));
console.log('running mean :', runningMean(marks));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('four sales figures become their running totals', cum, [5, 15, 30, 50]);
test('the last entry is the grand total', cum[cum.length - 1], 50);
test('differencing gives the original list back', differences(cum), sales);
test('an empty list scans to an empty list', cumulative([]), []);
test('ten lots of 0.1 cumulate to 0.9999999999999999, not 1', cumulative(tenths)[9], 0.9999999999999999);
test('and the drift shows up as early as the third entry', cumulative(tenths)[2], 0.30000000000000004);
test('the same ten payments in kobo land on 100 exactly', cumulative(koboTenths)[9], 100);
test('a running mean ends on the plain mean, holding no total at all', [runningMean(marks)[4], marks.reduce((a, b) => a + b, 0) / 5], [79, 79]);
test('and its first entry is just the first value', runningMean(marks)[0], 72);

A cumulative sum is a scan rather than a fold: it keeps every intermediate. Written with map and a closed-over total it is one line, and it reads clearly, but note that the callback is not pure — reordering or parallelising it would give nonsense. Differencing is the exact inverse for integers, which makes it a cheap self-check on any pipeline that stores running totals.

Ten additions of 0.1 reach 0.9999999999999999. Each one rounds, and the errors accumulate rather than cancel, so a running balance built this way drifts steadily away from the figure a customer would compute by hand. Integer kobo lands on 100 exactly. Welford's running mean is the other lesson: m += (x - m) / (i + 1) holds no sum, so it cannot overflow one and it does not lose the low bits of a large total — the same idea that fixes the variance in exercise 60.

65 Any base, with toString(radix)

fair

Print 255 in binary, octal, hex and base 36. Then find out what a negative number gives, what a fraction gives, and what happens at radix 1 and 37.

Stuck? Three hints, smallest first
  1. 1. (255).toString(16), and the radix runs from 2 to 36.
  2. 2. Digits past 9 are the lowercase letters a to z, which is why 36 is the ceiling.
  3. 3. A negative gets a minus sign in front, not a two's complement pattern. >>> 0 gives you the pattern.

The answer

const n = 255;

let radixError = 'none';
try {
  n.toString(37);
} catch (e) {
  radixError = e.constructor.name;
}

const pattern = (-255 >>> 0).toString(2);     // the 32-bit two's complement bits
const tenth = (0.1).toString(2);

console.log('255 in 2, 8, 16, 36:', n.toString(2), n.toString(8), n.toString(16), n.toString(36));
console.log('default radix      :', n.toString(), 'same as String(n):', String(n));
console.log('-255 in binary     :', (-255).toString(2), '<- a sign, not two\'s complement');
console.log('the actual bits    :', pattern, 'which is', -255 >>> 0);
console.log('radix 37           :', radixError);
console.log('35 and 36 in base 36:', (35).toString(36), (36).toString(36));
console.log('0.1 in binary      :', tenth);
console.log('0.1 in hex         :', (0.1).toString(16));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('255 in binary, octal, hex and base 36', [n.toString(2), n.toString(8), n.toString(16), n.toString(36)], ['11111111', '377', 'ff', '73']);
test('with no argument the radix is 10, matching String(n)', [n.toString(), String(n)], ['255', '255']);
test('a negative gets a minus sign, not a two\'s complement pattern', (-255).toString(2), '-11111111');
test('the pattern needs >>> 0 first, and is 32 bits wide', [pattern, pattern.length], ['11111111111111111111111100000001', 32]);
test('radix 37 throws a RangeError, and so does radix 1', radixError, 'RangeError');
test('base 36 runs 0-9 then a-z, so 35 is z and 36 rolls over to 10', [(35).toString(36), (36).toString(36)], ['z', '10']);
test('a fraction gets a point and an expansion of the actual double', [tenth.slice(0, 10), tenth.length], ['0.00011001', 57]);
test('0.1 in hex ends in a, which is where the rounding went', (0.1).toString(16), '0.1999999999999a');

toString(radix) is the whole conversion, for 2 to 36 — ten digits plus twenty-six letters, and nothing left for a 37th. Anything outside that range is a RangeError, including 0 and 1.

Two results surprise people. A negative number is rendered as a minus sign followed by the magnitude's digits: (-255).toString(2) is '-11111111', not the two's complement bits a C programmer expects. Coerce with >>> 0 first to get the 32-bit pattern. And a fraction is expanded honestly — (0.1).toString(2) prints 53 significant bits of the double that 0.1 actually is, which is why it runs to 57 characters and why the hex form ends in a. That expansion is the same fact as exercise 11, written in a base that cannot hide it.

66 Reading a base back, and where the round trip loses

hard

Convert to a base by hand with repeated division, and check it against toString. Read it back with parseInt and a radix. Then find a base-36 string that parseInt cannot represent, and one that parseInt only half reads.

Stuck? Three hints, smallest first
  1. 1. Repeated division: the remainders are the digits, produced least significant first.
  2. 2. parseInt stops at the first character that is not a digit in that base — including the point.
  3. 3. Any result past Number.MAX_SAFE_INTEGER is a nearest-double, so the round trip cannot be exact.

The answer

const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz';

function toBase(n, b) {
  if (n === 0) return '0';
  let rest = n;
  let out = '';
  while (rest > 0) {
    out = DIGITS[rest % b] + out;          // remainders come out backwards
    rest = Math.floor(rest / b);
  }
  return out;
}

// parseInt stops at the point, so a fractional part needs its own loop.
function fromBase(s, b) {
  const [whole, frac = ''] = s.split('.');
  let value = parseInt(whole, b);
  let scale = 1 / b;
  for (const ch of frac) {
    value += parseInt(ch, b) * scale;
    scale /= b;
  }
  return value;
}

const values = [0, 1, 7, 255, 4095, 99999];
const bases = [2, 3, 8, 16, 36];
const agrees = values.every((n) => bases.every((b) => toBase(n, b) === n.toString(b)));
const roundTrips = values.every((n) => bases.every((b) => parseInt(n.toString(b), b) === n));

const tenZ = parseInt('zzzzzzzzzz', 36);
const twelveZ = parseInt('zzzzzzzzzzzz', 36);
const overshoot = Number(BigInt(twelveZ) - (36n ** 12n - 1n));

console.log('by hand vs toString:', toBase(255, 16), (255).toString(16), agrees);
console.log('round trip         :', roundTrips);
console.log('parseInt at a point:', parseInt('1010.11', 2), 'but fromBase gives', fromBase('1010.11', 2));
console.log('0.1 through base 2 :', fromBase((0.1).toString(2), 2));
console.log('ten z in base 36   :', tenZ, Number.isSafeInteger(tenZ));
console.log('twelve z           :', twelveZ, 'true value 4738381338321616895');
console.log('overshoot          :', overshoot);
console.log('0b prefix          :', parseInt('0b1010', 2), 'but Number gives', Number('0b1010'));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('repeated division agrees with toString across six values and five bases', agrees, true);
test('and parseInt with a radix reads every one of them back', roundTrips, true);
test('parseInt stops at the point, reading 1010 and dropping .11', parseInt('1010.11', 2), 10);
test('a fraction loop picks up the rest: 1010.11 is 10.75', fromBase('1010.11', 2), 10.75);
test('and 0.1 survives a trip through base 2 exactly', fromBase((0.1).toString(2), 2) === 0.1, true);
test('ten z in base 36 is 3656158440062975, still a safe integer', [tenZ, Number.isSafeInteger(tenZ)], [3656158440062975, true]);
test('twelve z is not, and comes back 1 too large', [Number.isSafeInteger(twelveZ), overshoot], [false, 1]);
test('parseInt does not know 0b, so it reads the 0 and stops', parseInt('0b1010', 2), 0);
test('while Number understands the prefix and gives 10', Number('0b1010'), 10);

The two directions are not symmetrical. toString(radix) handles fractions and signs; parseInt reads an integer and stops at the first character it cannot use, which includes the decimal point — so parseInt('1010.11', 2) is 10, silently. There is no parseFloat with a radix, so a fractional base needs the digit loop above, and the loop is exact in base 2 because a double already is a binary fraction: 0.1 round-trips to itself, bit for bit.

The base-36 case is the honest limit. Ten z's is 3656158440062975, inside Number.MAX_SAFE_INTEGER, and it round-trips. Twelve z's is 4738381338321616895, past it, and parseInt returns the nearest double — exactly 1 too large. No error, no NaN: an identifier read back from a short URL code would simply be the wrong identifier. Parse long base-36 strings digit by digit into a BigInt.

67 Binary, octal and hex literals, and padding the output

easy

Write 255 as a binary, an octal and a hex literal, and confirm they are all the same number. Then build a hex colour from the channels 30, 9 and 255, padding each to two characters.

Stuck? Three hints, smallest first
  1. 1. The prefixes are 0b, 0o and 0x, and underscores may be used as separators.
  2. 2. (10).toString(16) is one character, so padStart(2, "0") is not optional.
  3. 3. padStart pads up to a minimum length and never truncates.

The answer

const channels = [30, 9, 255];
const hex = (c) => c.toString(16).padStart(2, '0');
const colour = '#' + channels.map(hex).join('');
const unpadded = '#' + channels.map((c) => c.toString(16)).join('');

const byteBits = (n) => n.toString(2).padStart(8, '0');

console.log('0b11111111, 0o377, 0xff:', 0b11111111, 0o377, 0xff);
console.log('separators             :', 0b1010_1010, 1_000_000);
console.log('colour                 :', colour);
console.log('unpadded               :', unpadded, '<- five digits, a different colour');
console.log('uppercase              :', colour.toUpperCase());
console.log('bytes                  :', [5, 64, 255].map(byteBits));
console.log("Number('010')          :", Number('010'), "and parseInt('010', 8) is", parseInt('010', 8));
console.log('nine bits in an 8-pad  :', byteBits(511), byteBits(511).length);

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('all three literals are the number 255', [0b11111111, 0o377, 0xff], [255, 255, 255]);
test('and underscores are allowed anywhere between digits', [0b1010_1010, 1_000_000 === 1e6], [170, true]);
test('30, 9, 255 padded is the colour #1e09ff', colour, '#1e09ff');
test('unpadded, the 9 takes one character and the whole string is a digit short', [unpadded, unpadded.length], ['#1e9ff', 6]);
test('hex digits come out lowercase, so uppercase them yourself', [(255).toString(16), colour.toUpperCase()], ['ff', '#1E09FF']);
test('padStart to 8 gives a readable byte', [byteBits(5), byteBits(255)], ['00000101', '11111111']);
test('but it pads to a minimum and never truncates', [byteBits(511), byteBits(511).length], ['111111111', 9]);
test('a leading zero is decimal to Number, and octal only if you say so', [Number('010'), parseInt('010', 8)], [10, 8]);

The prefixes are notation, nothing more: 0xff and 255 are the same double, and toString(radix) is how you get back the other way. Numeric separators (0b1010_1010, 1_000_000) are permitted between digits and make masks and large constants readable; they are not allowed leading, trailing or doubled.

padStart is where colour code goes wrong. (30).toString(16) is '1e' but (9).toString(16) is '9', so joining unpadded channels produces a five-digit string that the browser parses as some other colour entirely — a bug that only appears once a channel drops below 16. Pad every channel. And note the second half of that assertion: padStart is a minimum, so a value that does not fit comes out longer, which is a silent overflow if you were assuming eight bits.

68 AND, OR, XOR and NOT

fair

Work out 12 & 10, 12 | 10, 12 ^ 10 and ~12 by hand, then check them. Use the operators to set, test and clear flags in a permissions byte, and show that & and && are unrelated.

Stuck? Three hints, smallest first
  1. 1. Line the numbers up in binary: 12 is 1100 and 10 is 1010.
  2. 2. ~x is -x - 1, because the bits include the sign.
  3. 3. Clear a flag with value & ~flag, and toggle it with value ^ flag.

The answer

const READ = 1;
const WRITE = 2;
const EXEC = 4;

const grant = (perms, flag) => perms | flag;
const has = (perms, flag) => (perms & flag) !== 0;
const revoke = (perms, flag) => perms & ~flag;
const toggle = (perms, flag) => perms ^ flag;

const readWrite = grant(grant(0, READ), WRITE);      // 3
const all = grant(readWrite, EXEC);                  // 7

// XOR is its own inverse, which is what makes the swap work.
let a = 9;
let b = 4;
a ^= b;
b ^= a;
a ^= b;

console.log('12 & 10, 12 | 10, 12 ^ 10:', 12 & 10, 12 | 10, 12 ^ 10);
console.log('in binary                :', (12 & 10).toString(2), (12 | 10).toString(2), (12 ^ 10).toString(2));
console.log('~12, ~0, ~-1             :', ~12, ~0, ~-1);
console.log('permissions              :', readWrite, all, all.toString(2));
console.log('has WRITE, has EXEC      :', has(readWrite, WRITE), has(readWrite, EXEC));
console.log('revoke WRITE from 7      :', revoke(all, WRITE));
console.log('swapped without a temp   :', a, b);
console.log('5 & 3 against 5 && 3     :', 5 & 3, 5 && 3);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('12 & 10 is 8, 12 | 10 is 14, 12 ^ 10 is 6', [12 & 10, 12 | 10, 12 ^ 10], [8, 14, 6]);
test('~12 is -13, and ~x is always -x - 1', [~12, [0, 1, 12, -7].every((x) => ~x === -x - 1)], [-13, true]);
test('~0 is -1 and ~-1 is 0, so NOT is not a boolean flip', [~0, ~-1], [-1, 0]);
test('READ | WRITE is 3, and adding EXEC gives 7', [readWrite, all], [3, 7]);
test('& tests a flag, and only the flag asked about', [has(readWrite, WRITE), has(readWrite, EXEC)], [true, false]);
test('& ~flag clears one bit and leaves the rest', [revoke(all, WRITE), revoke(all, WRITE).toString(2)], [5, '101']);
test('^ toggles, so applying it twice returns the original', [toggle(all, WRITE), toggle(toggle(all, WRITE), WRITE)], [5, 7]);
test('three XORs swap two variables with no temporary', [a, b], [4, 9]);
test('and & is not &&: one is arithmetic, the other picks an operand', [5 & 3, 5 && 3], [1, 3]);

Do these on paper once. 12 is 1100 and 10 is 1010, so AND keeps 1000, OR gives 1110 and XOR gives 0110 — the bits that differ. NOT is the one that looks wrong: ~12 is -13, because the result is a signed 32-bit integer and flipping every bit of n gives -n - 1. There is no "bitwise not" that returns 243 unless you mask it back down with & 0xff.

Flags are where this pays off: one integer holds thirty-one independent booleans, | sets, & tests, & ~ clears and ^ toggles. Two cautions. & is not && — 5 & 3 is 1 while 5 && 3 is 3 — and (perms & flag) is a number, so compare it to 0 rather than trusting truthiness in code someone else will read. And the XOR swap is a party trick, not an optimisation: it is slower than a temporary on any modern engine, and it silently zeroes the variable if you pass the same one twice.

69 Shifts, and the count taken modulo 32

hard

Use << and >> to multiply and divide by powers of two, then find the smallest multiplication they get wrong. Compare >> with >>> on a negative, and work out what 1 << 32 is.

Stuck? Three hints, smallest first
  1. 1. n << k is n * 2 ** k only while the answer fits in 32 signed bits.
  2. 2. The shift count is taken modulo 32, so a count of 32 shifts by nothing at all.
  3. 3. >> copies the sign bit inwards; >>> feeds in zeroes and so is never negative.

The answer

const shiftLeft = (n, k) => n << k;
const half = (n) => n >> 1;

console.log('5 << 1, 5 << 3   :', 5 << 1, 5 << 3);
console.log('40 >> 3, 40 >>> 3:', 40 >> 3, 40 >>> 3);
console.log('3 << 30          :', 3 << 30, 'but 3 * 2 ** 30 is', 3 * 2 ** 30);
console.log('1 << 31          :', 1 << 31, '<- the sign bit');
console.log('1 << 32, 1 << 33 :', 1 << 32, 1 << 33, '<- count taken mod 32');
console.log('-8 >> 1, -8 >>> 1:', -8 >> 1, -8 >>> 1);
console.log('-1 >>> 0         :', -1 >>> 0, 'and back:', (-1 >>> 0) | 0);
console.log('-7 >> 1          :', -7 >> 1, 'against trunc', Math.trunc(-7 / 2), 'and floor', Math.floor(-7 / 2));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('shifting left multiplies by a power of two: 10 and 40', [shiftLeft(5, 1), shiftLeft(5, 3)], [10, 40]);
test('and shifting right divides, with >> and >>> agreeing on positives', [40 >> 3, 40 >>> 3], [5, 5]);
test('3 << 30 overflows into the sign bit and comes back negative', [3 << 30, 3 * 2 ** 30], [-1073741824, 3221225472]);
test('1 << 31 is the most negative 32-bit integer', 1 << 31, -2147483648);
test('the count is taken modulo 32, so 1 << 32 is 1 and 1 << 33 is 2', [1 << 32, 1 << 33], [1, 2]);
test('>> keeps the sign while >>> reads the same bits as unsigned', [-8 >> 1, -8 >>> 1], [-4, 2147483644]);
test('so -1 >>> 0 is the full 32-bit pattern, and | 0 undoes it', [-1 >>> 0, (-1 >>> 0) | 0], [4294967295, -1]);
test('>> is a floor division of a negative, not a truncation', [-7 >> 1, Math.trunc(-7 / 2), Math.floor(-7 / 2)], [-4, -3, -4]);

Shifting is only multiplication and division while the result stays inside 32 signed bits, and the boundary arrives sooner than people expect: 3 << 30 is already wrong, at about 3.2 billion. Use * 2 ** k unless you are deliberately working on a 32-bit pattern, in which case say so in a comment.

Two behaviours to keep straight. The shift count is reduced modulo 32, so 1 << 32 is 1 rather than 4294967296 and there is no shift that clears a value to zero. And >> is an arithmetic shift, filling from the sign bit, while >>> fills with zeroes — which is why -8 >>> 1 is 2147483644 and why x >>> 0 is the idiom for reading a value as an unsigned 32-bit integer. Note also that >> floors rather than truncates, so -7 >> 1 is -4 where Math.trunc(-7 / 2) is -3; the two disagree on every odd negative.

70 Counting the set bits

hard

Count the 1 bits in a number with Kernighan's loop, x &= x - 1. Then run it on -1 and on 2 ** 53 - 1, compare it with counting characters in toString(2), and find where each one lies.

Stuck? Three hints, smallest first
  1. 1. x & (x - 1) clears the lowest set bit, so the loop runs once per bit set.
  2. 2. For a negative, toString(2) gives a minus sign and the magnitude, so counting characters answers a different question.
  3. 3. The loop is bitwise, so it only ever sees 32 bits. BigInt does not have that limit.

The answer

function popcount(n) {
  let x = n;
  let count = 0;
  while (x) {                          // each pass clears one bit, so at most 32 passes
    x &= x - 1;
    count++;
  }
  return count;
}

const byString = (n) => [...n.toString(2)].filter((c) => c === '1').length;
const unsigned = (n) => byString(n >>> 0);

function bigPopcount(n) {
  let x = BigInt(n);
  let count = 0;
  while (x) {
    x &= x - 1n;
    count++;
  }
  return count;
}

const hamming = (a, b) => popcount(a ^ b);
const wide = 2 ** 53 - 1;              // 53 bits set, all of them

console.log('0, 1, 7, 255, 256, 1023:', [0, 1, 7, 255, 256, 1023].map(popcount));
console.log('-1                     :', popcount(-1), 'bits set');
console.log('by string, -1          :', byString(-1), '<- counting the wrong thing');
console.log('after >>> 0            :', unsigned(-1));
console.log('2 ** 53 - 1            :', popcount(wide), 'by loop,', byString(wide), 'by string');
console.log('as BigInt              :', bigPopcount(wide));
console.log('hamming(1011, 1101)    :', hamming(0b1011, 0b1101));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the low numbers, counted', [0, 1, 7, 255, 256, 1023].map(popcount), [0, 1, 3, 8, 1, 10]);
test('-1 has all 32 bits set, and the loop still terminates', popcount(-1), 32);
test('counting characters in toString(2) says 1, because of the minus sign', [(-1).toString(2), byString(-1)], ['-1', 1]);
test('coercing with >>> 0 first fixes it', unsigned(-1), 32);
test('2 ** 53 - 1 really has 53 bits set, and the string method finds them', byString(wide), 53);
test('but the bitwise loop only ever sees 32 of them', popcount(wide), 32);
test('BigInt has no 32-bit limit, so the same loop is right there', bigPopcount(wide), 53);
test('and a 33-bit value is miscounted too: 2 ** 32 + 1 has two bits', [popcount(2 ** 32 + 1), byString(2 ** 32 + 1)], [1, 2]);
test('popcount of a XOR is the Hamming distance between two patterns', hamming(0b1011, 0b1101), 2);

x &= x - 1 clears the lowest set bit — subtracting 1 flips that bit to 0 and every bit below it to 1, and the AND keeps only what both have. So the loop runs exactly as many times as there are bits set, which for a 32-bit value is at most 32. That bound is also why it terminates for a negative: -1 is thirty-two 1s, and it takes thirty-two passes to walk them off.

Both methods are wrong somewhere, in opposite directions. The bitwise loop is blind past bit 31, so 2 ** 53 - 1 reports 32 instead of 53 and 2 ** 32 + 1 reports 1 instead of 2. The string method sees the whole number but is reading a rendered decimal-to-binary conversion, so a negative gives it a minus sign and it counts 1 for -1; n >>> 0 first, and it agrees. Above 32 bits, use BigInt: its bitwise operators are arbitrary width, so the identical loop is correct.

71 Why a bitwise operator hands back a different number

hard

Show what | 0 does to 2 ** 31, to 2 ** 32 + 5, to 1.9, to NaN and to '12abc'. Then write a 32-bit string hash, and show what leaving the | 0 out does to it.

Stuck? Three hints, smallest first
  1. 1. Every bitwise operand goes through ToInt32: modulo 2 ** 32, then anything at or above 2 ** 31 wraps negative.
  2. 2. Anything that cannot be converted to a number becomes 0, not NaN.
  3. 3. A hash loop that never truncates keeps growing, and past 2 ** 53 it stops being exact.

The answer

const int32 = (x) => x | 0;
const uint32 = (x) => x >>> 0;

// A 32-bit hash, kept 32-bit on purpose.
function hash32(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) {
    h = (h << 5) - h + s.charCodeAt(i);     // h * 31 + code
    h |= 0;                                 // back into a signed 32-bit integer
  }
  return h;
}

// The same loop with the truncation left out.
function hashDrifting(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i);
  return h;
}

const town = 'Abeokuta';

console.log('2 ** 31 | 0      :', int32(2 ** 31));
console.log('2 ** 32 + 5 | 0  :', int32(2 ** 32 + 5));
console.log('-1 >>> 0, back   :', uint32(-1), int32(uint32(-1)));
console.log('1.9 | 0, -1.9 | 0:', int32(1.9), int32(-1.9));
console.log('junk             :', [NaN, Infinity, '12abc', null, undefined].map(int32));
console.log('coercible        :', int32('12'), int32(true));
console.log('hash32           :', hash32(town), 'unsigned', uint32(hash32(town)));
console.log('without | 0      :', hashDrifting(town), 'which is', hashDrifting(town) - hash32(town), 'larger');
console.log('a negative hash  :', hash32('Ibadan'), '->', uint32(hash32('Ibadan')));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('2 ** 31 wraps to the most negative 32-bit integer', int32(2 ** 31), -2147483648);
test('and 2 ** 32 + 5 loses the whole high word, leaving 5', int32(2 ** 32 + 5), 5);
test('>>> 0 reads those bits as unsigned, and | 0 reads them back', [uint32(-1), int32(uint32(-1))], [4294967295, -1]);
test('| 0 truncates towards zero for values that do fit', [int32(1.9), int32(-1.9)], [1, -1]);
test('anything not convertible becomes 0, never NaN', [NaN, Infinity, '12abc', null, undefined].map(int32), [0, 0, 0, 0, 0]);
test('but a numeric string or a boolean is converted first', [int32('12'), int32(true)], [12, 1]);
test('the hash stays inside 32 bits, so it fits an Int32Array slot', [hash32(town), Math.abs(hash32(town)) <= 2 ** 31], [1391928450, true]);
test('without the | 0 the same loop drifts exactly 2 ** 32 higher', [hashDrifting(town), hashDrifting(town) - hash32(town)], [5686895746, 4294967296]);
test('and >>> 0 turns a negative hash into a usable bucket index', [hash32('Ibadan'), uint32(hash32('Ibadan'))], [-2111545271, 2183422025]);

Every bitwise operator converts its operands with ToInt32 first: take the number modulo 2 ** 32, then read the result as signed, so anything from 2 ** 31 up comes back negative. That single rule explains all of it — 2 ** 31 | 0 is -2147483648, 2 ** 32 + 5 | 0 is 5, and ~~ is not a general truncation. Values that cannot be converted become 0, which is why NaN | 0 and '12abc' | 0 both give 0 and why | 0 is a terrible way to validate input: every bad value looks like a legitimate zero.

The hash is the case where the truncation is the point. h |= 0 keeps the accumulator in range, so the function is closed over 32-bit integers and its output fits an Int32Array. Take it out and the value climbs — here to exactly 2 ** 32 more, since one carry escaped — and on a longer string it climbs past Number.MAX_SAFE_INTEGER, where additions stop being exact and the same input can hash differently depending on how it was built. Hash values are usually wanted as an index, so finish with >>> 0 to make them non-negative.

72 A rectangle: area, perimeter and the diagonal

easy

For a rectangle 7.5 by 4, give the area, the perimeter and the diagonal. Then double both sides and say what happens to each, compare it with the square of the same perimeter, and convert the area from square centimetres to square metres.

Stuck? Three hints, smallest first
  1. 1. The diagonal is Pythagoras on the two sides, so Math.hypot does it.
  2. 2. Doubling both sides does not double the area.
  3. 3. A length divides by 100 to go from centimetres to metres, so an area divides by 100 twice.

The answer

const area = (w, h) => w * h;
const perimeter = (w, h) => 2 * (w + h);
const diagonal = (w, h) => Math.hypot(w, h);

const w = 7.5;
const h = 4;

const side = perimeter(w, h) / 4;          // the square with the same perimeter

console.log('area, perimeter :', area(w, h), perimeter(w, h));
console.log('diagonal        :', diagonal(w, h), 'and 7.5 ** 2 + 4 ** 2 is', w ** 2 + h ** 2);
console.log('both sides x2   :', area(2 * w, 2 * h), perimeter(2 * w, 2 * h));
console.log('same perimeter  :', side, 'square area', area(side, side));
console.log('cm2 to m2       :', area(w, h) / 10000);
console.log('degenerate      :', area(w, 0), perimeter(w, 0));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('7.5 by 4 has an area of 30 and a perimeter of 23', [area(w, h), perimeter(w, h)], [30, 23]);
test('the diagonal is exactly 8.5, because 56.25 + 16 is 72.25', [diagonal(w, h), w ** 2 + h ** 2, 8.5 ** 2], [8.5, 72.25, 72.25]);
test('doubling both sides quadruples the area and doubles the perimeter', [area(2 * w, 2 * h), perimeter(2 * w, 2 * h)], [120, 46]);
test('a square of the same perimeter has the larger area', [side, area(side, side), area(side, side) > area(w, h)], [5.75, 33.0625, true]);
test('30 square centimetres is 0.003 square metres, dividing by 100 twice', area(w, h) / 10000, 0.003);
test('a zero side leaves no area but a real perimeter', [area(w, 0), perimeter(w, 0)], [0, 15]);
test('area and perimeter do not determine each other', [area(10, 1), perimeter(10, 1), area(4, 7), perimeter(4, 7)], [10, 22, 28, 22]);
test('and the diagonal never exceeds half the perimeter', diagonal(w, h) < perimeter(w, h) / 2, true);

The arithmetic is trivial and the units are where the mistakes live. Area scales with the square of a length change: doubling both sides multiplies the area by 4, and converting centimetres to metres divides the area by 10,000 rather than 100. The same applies to a tiling or pricing calculation done per square metre from measurements taken in centimetres.

Two facts worth carrying. Area and perimeter are independent — 10 by 1 and 4 by 7 share a perimeter of 22 and have areas of 10 and 28 — and among all rectangles with a given perimeter the square has the largest area, which is why 5.75 by 5.75 beats 7.5 by 4. And these numbers are exact doubles: 7.5, 4 and 8.5 all have short binary expansions, so nothing here needs rounding. That will not be true in the next exercise.

73 A circle, and what you are allowed to compare

fair

Give the area and circumference of a circle of radius 3, and a 60 degree sector of it. Then compute the same area with the multiplications in a different order, and show why you cannot assert either result against a decimal you typed out.

Stuck? Three hints, smallest first
  1. 1. Math.PI * r ** 2 and 2 * Math.PI * r, and neither is exact in binary.
  2. 2. Reordering a floating-point multiplication can change the last bit.
  3. 3. Compare a rounded value, or a difference against a tolerance, never the raw double against a decimal literal.

The answer

const area = (r) => Math.PI * r ** 2;
const circumference = (r) => 2 * Math.PI * r;
const round3 = (x) => Math.round(x * 1000) / 1000;

const r = 3;
const sectorArea = (r, deg) => (area(r) * deg) / 360;
const arcLength = (r, deg) => (circumference(r) * deg) / 360;

// The same product, associated differently.
const wide = 2.8;
const squareFirst = Math.PI * wide ** 2;
const stepwise = Math.PI * wide * wide;

console.log('area, circumference:', area(r), circumference(r));
console.log('to three places    :', round3(area(r)), round3(circumference(r)));
console.log('from the diameter  :', (Math.PI * 6 ** 2) / 4);
console.log('60 degree sector   :', sectorArea(r, 60), 'arc', arcLength(r, 60));
console.log('r = 2.8, two ways  :', squareFirst, stepwise);
console.log('difference         :', stepwise - squareFirst);
console.log('22 / 7 against PI  :', 22 / 7, Math.abs(22 / 7 - Math.PI));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the area is 28.274333882308138 and rounds to 28.274', [area(r), round3(area(r))], [28.274333882308138, 28.274]);
test('the circumference is 18.84955592153876, or 18.85 to two places', [circumference(r), Math.round(circumference(r) * 100) / 100], [18.84955592153876, 18.85]);
test('computing it from the diameter gives the identical double', (Math.PI * 6 ** 2) / 4 === area(r), true);
test('doubling the radius quadruples the area, exactly', area(6) === 4 * area(3), true);
test('a 60 degree sector is a sixth of the area, and its arc is pi', [sectorArea(r, 60), arcLength(r, 60) === Math.PI], [4.71238898038469, true]);
test('reordering the multiplication moves the last bit', [squareFirst === stepwise, stepwise - squareFirst], [false, 3.552713678800501e-15]);
test('so the honest comparison is a tolerance, not equality', Math.abs(stepwise - squareFirst) < 1e-12, true);
test('and 22 / 7 is not pi: it is out by 0.0013', [Math.abs(22 / 7 - Math.PI) < 0.002, 22 / 7 === Math.PI], [true, false]);

Math.PI is the nearest double to pi, so every circle result is approximate before you do anything to it. That is fine; what is not fine is comparing such a result to a decimal you typed. area(3) === 28.2743338823 is false, and so is area(3) === 28.274333882308 — the only literal that passes is the full 17-digit double. Round to the places you actually need, or compare a difference against a tolerance.

Order matters too. Math.PI * 2.8 ** 2 and Math.PI * 2.8 * 2.8 differ by about 3.6e-15, because each multiplication rounds and the roundings land differently. Floating-point multiplication is commutative but not associative, so two algebraically identical formulas can return different doubles — which is why a test suite that pins geometry results to === starts failing when someone harmlessly rewrites a formula. Some relations do survive exactly, like area(6) === 4 * area(3) here, but that is luck rather than a rule to depend on.

74 A triangle, by base and height and by Heron

fair

Find the area of a 3-4-5 triangle both ways: half base times height, and Heron's formula from the three sides. Then try Heron on sides that cannot form a triangle, and on a very thin one where it loses most of its accuracy.

Stuck? Three hints, smallest first
  1. 1. Heron: s = (a + b + c) / 2, then Math.sqrt(s * (s - a) * (s - b) * (s - c)).
  2. 2. If one side is longer than the other two together, the product under the root goes negative.
  3. 3. For a thin triangle, s - a subtracts two nearly equal numbers, and most of the digits cancel.

The answer

const byBaseHeight = (base, height) => 0.5 * base * height;

const heron = (a, b, c) => {
  const s = (a + b + c) / 2;
  return Math.sqrt(s * (s - a) * (s - b) * (s - c));
};

// Kahan's rearrangement: sort descending, then group so nothing nearly cancels.
function stableHeron(a, b, c) {
  const [x, y, z] = [a, b, c].sort((p, q) => q - p);
  return Math.sqrt((x + (y + z)) * (z - (x - y)) * (z + (x - y)) * (x + (y - z))) / 4;
}

// A needle: two sides of a billion, one of a hundred-thousandth.
const needle = [1e9, 1e9, 1e-5];
const trueArea = 0.5 * 1e-5 * 1e9;          // base times height, to the last digit
const relativeError = (heron(...needle) - trueArea) / trueArea;

console.log('base 4, height 3 :', byBaseHeight(4, 3), 'Heron on 3-4-5:', heron(3, 4, 5));
console.log('7-8-9            :', heron(7, 8, 9), stableHeron(7, 8, 9));
console.log('equilateral, 2   :', heron(2, 2, 2), 'which is sqrt(3):', Math.sqrt(3));
console.log('1-2-3, degenerate:', heron(1, 2, 3));
console.log('1-2-10, no such  :', heron(1, 2, 10));
console.log('needle, Heron    :', heron(...needle));
console.log('needle, stable   :', stableHeron(...needle), 'true', trueArea);
console.log('relative error   :', relativeError);

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('half base times height and Heron agree on the 3-4-5 triangle', [byBaseHeight(4, 3), heron(3, 4, 5)], [6, 6]);
test('and agree to the last bit on a 7-8-9 triangle', [heron(7, 8, 9), heron(7, 8, 9) === stableHeron(7, 8, 9)], [26.832815729997478, true]);
test('an equilateral triangle of side 2 has area exactly sqrt(3)', heron(2, 2, 2) === Math.sqrt(3), true);
test('1, 2, 3 is three collinear points, and Heron returns a clean 0', heron(1, 2, 3), 0);
test('1, 2, 10 breaks the triangle inequality, so the root goes negative', Number.isNaN(heron(1, 2, 10)), true);
test('on a needle triangle the true area is 5000 and Heron says 5006.79', [trueArea, heron(...needle)], [5000, 5006.7901611328125]);
test('that is 0.14 per cent out, from digits cancelling in s - a', Math.round(relativeError * 1e6) / 1e6, 0.001358);
test('the rearranged formula returns 5000 exactly', stableHeron(...needle), 5000);

Heron's formula is correct and, written the obvious way, numerically fragile. For a needle triangle the semi-perimeter s is almost exactly the longest side, so s - a subtracts two nearly equal numbers and most of the significant digits cancel; what is left is rounding noise, multiplied by the other three large factors. Here that puts the answer 0.14% out — not a rounding wobble in the last bit, a visibly wrong number.

Kahan's rearrangement sorts the sides descending and groups the factors as (a + (b + c))(c - (a - b))(c + (a - b))(a + (b - c)), which never subtracts two nearly equal large values, and it returns 5000 exactly. Same algebra, different arithmetic. The other two edges are worth knowing too: three collinear lengths give exactly 0, and lengths that violate the triangle inequality give a negative product and therefore NaN — so check for NaN rather than assuming a number came back.

75 The shoelace formula, checked against a trapezium

hard

Find the area of a trapezium with the (a + b) / 2 * h formula, then find the same area from its four corner coordinates with the shoelace formula. Show what the sign of the unabsolute result tells you, and what the formula does with a self-crossing polygon.

Stuck? Three hints, smallest first
  1. 1. Sum x1 * y2 - x2 * y1 around the polygon, wrapping the last vertex back to the first.
  2. 2. Halve it. The sign tells you the winding direction, so take the absolute value for an area.
  3. 3. The formula assumes the edges do not cross. Nothing in it detects that they do.

The answer

function signedArea(points) {
  let total = 0;
  for (let i = 0; i < points.length; i++) {
    const [x1, y1] = points[i];
    const [x2, y2] = points[(i + 1) % points.length];      // wraps to the first
    total += x1 * y2 - x2 * y1;
  }
  return total / 2;
}

const polygonArea = (points) => Math.abs(signedArea(points));

const trapeziumFormula = (a, b, h) => ((a + b) / 2) * h;

// Parallel sides 8 and 5, four units apart.
const trapezium = [[0, 0], [8, 0], [6, 4], [1, 4]];
const triangle = [[0, 0], [4, 0], [0, 3]];
const rectangle = [[0, 0], [7.5, 0], [7.5, 4], [0, 4]];
const pentagon = [[1, 1], [4, 1], [5, 3], [3, 5], [0, 3]];
const bowtie = [[0, 0], [1, 1], [1, 0], [0, 1]];

console.log('trapezium formula:', trapeziumFormula(8, 5, 4));
console.log('shoelace         :', polygonArea(trapezium));
console.log('signed, each way :', signedArea(trapezium), signedArea(trapezium.slice().reverse()));
console.log('triangle         :', polygonArea(triangle), 'Heron would give 6');
console.log('rectangle 7.5 x 4:', polygonArea(rectangle));
console.log('pentagon         :', polygonArea(pentagon));
console.log('collinear        :', polygonArea([[0, 0], [1, 1], [2, 2]]));
console.log('self-crossing    :', polygonArea(bowtie), '<- not a degenerate shape');

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the trapezium formula and the shoelace formula both give 26', [trapeziumFormula(8, 5, 4), polygonArea(trapezium)], [26, 26]);
test('the signed area is positive anticlockwise and negative clockwise', [signedArea(trapezium), signedArea(trapezium.slice().reverse())], [26, -26]);
test('a 3-4-5 triangle from its corners gives 6, agreeing with Heron', polygonArea(triangle), 6);
test('the 7.5 by 4 rectangle gives 30, agreeing with width times height', polygonArea(rectangle), 30);
test('an irregular pentagon comes out at 13', polygonArea(pentagon), 13);
test('three collinear points enclose nothing', polygonArea([[0, 0], [1, 1], [2, 2]]), 0);
test('a unit square gives 1 wherever it starts in the list', [polygonArea([[0, 0], [1, 0], [1, 1], [0, 1]]), polygonArea([[1, 1], [0, 1], [0, 0], [1, 0]])], [1, 1]);
test('but a self-crossing bowtie reports 0, and it has real area', polygonArea(bowtie), 0);

The shoelace formula turns any simple polygon's vertices into its area with one pass and no trigonometry: sum the cross products of consecutive vertices and halve. It is the general case that the rectangle, triangle and trapezium formulas are special cases of, which is what the cross-checks above are for — 26 from (8 + 5) / 2 * 4 and 26 from four coordinate pairs.

Two things it will not tell you. The sign is the winding direction, positive for anticlockwise in a normal y-up coordinate system, so dropping the Math.abs gives you an orientation test for free — but forgetting it gives you a negative area when someone lists the corners the other way round, which is how the bug usually arrives. And the derivation assumes the edges do not cross: the bowtie above has two triangles of area 1/4, and their opposite windings cancel to a confident 0. Nothing in the formula detects that, so validate the polygon or accept that the answer is only meaningful for a simple one.

76 A cube and a cuboid: volume, surface and the space diagonal

easy

For a cube of side 3 and a cuboid 2 by 3 by 6, give the volume, the total surface area and the corner-to-corner diagonal. Then double the cube's side and say what happens to each figure.

Stuck? Three hints, smallest first
  1. 1. A cuboid has three pairs of faces, so the surface is 2 * (wh + hd + wd).
  2. 2. The space diagonal is Pythagoras in three dimensions, which Math.hypot takes directly.
  3. 3. Volume scales with the cube of a length, surface with the square.

The answer

const cubeVolume = (s) => s ** 3;
const cubeSurface = (s) => 6 * s ** 2;

const cuboidVolume = (w, h, d) => w * h * d;
const cuboidSurface = (w, h, d) => 2 * (w * h + h * d + w * d);
const spaceDiagonal = (w, h, d) => Math.hypot(w, h, d);

const s = 3;

console.log('cube 3           :', cubeVolume(s), cubeSurface(s), spaceDiagonal(s, s, s));
console.log('that diagonal is :', s * Math.sqrt(3));
console.log('cuboid 2 x 3 x 6 :', cuboidVolume(2, 3, 6), cuboidSurface(2, 3, 6), spaceDiagonal(2, 3, 6));
console.log('side doubled     :', cubeVolume(6), cubeSurface(6));
console.log('ratios           :', cubeVolume(6) / cubeVolume(3), cubeSurface(6) / cubeSurface(3));
console.log('surface / volume :', cubeSurface(s) / cubeVolume(s), 'which is 6 / s');
console.log('unit cube        :', cubeVolume(1), cubeSurface(1));

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a cube of side 3 has volume 27 and surface 54', [cubeVolume(s), cubeSurface(s)], [27, 54]);
test('its space diagonal is s times root 3, to the last bit', [spaceDiagonal(s, s, s), spaceDiagonal(s, s, s) === s * Math.sqrt(3)], [5.196152422706632, true]);
test('the 2 by 3 by 6 cuboid holds 36 and has 72 of surface', [cuboidVolume(2, 3, 6), cuboidSurface(2, 3, 6)], [36, 72]);
test('and its diagonal is exactly 7, since 4 + 9 + 36 is 49', spaceDiagonal(2, 3, 6), 7);
test('doubling the side multiplies the volume by 8 and the surface by 4', [cubeVolume(6) / cubeVolume(3), cubeSurface(6) / cubeSurface(3)], [8, 4]);
test('so surface over volume is 6 / s, and it falls as the cube grows', [cubeSurface(s) / cubeVolume(s), 6 / s, cubeSurface(6) / cubeVolume(6)], [2, 2, 1]);
test('the ratio is 6 for a unit cube and 1 at side 6', [cubeSurface(1) / cubeVolume(1), cubeSurface(6) / cubeVolume(6)], [6, 1]);
test('a cube is the cuboid case where all three sides agree', [cuboidVolume(3, 3, 3), cuboidSurface(3, 3, 3)], [27, 54]);

The scaling law is the content here. Double a length and the surface goes up four times while the volume goes up eight, so the surface-to-volume ratio falls — 6 / s for a cube. That is why a small animal loses heat faster than a large one, why crushed ice melts faster than a block, and why cooling a data centre gets harder as the rack gets denser rather than easier.

The space diagonal is just Math.hypot with three arguments, and it is the number that decides whether a parcel fits in a box diagonally. Both of these examples come out exact: 2, 3, 6 is a Pythagorean quadruple, so the diagonal is precisely 7, and 3 * Math.sqrt(3) matches Math.hypot(3, 3, 3) bit for bit. Neither is guaranteed in general — Math.hypot scales its inputs before squaring to avoid overflow, so it can return a different last bit from a hand-written Math.sqrt of a sum.

77 A cylinder and a cone

fair

For radius 3 and height 10, give the cylinder's volume, its lateral surface and its total surface. Then do the cone: its volume, its slant height and its surface. Show that three cones fill the cylinder, and that where you put the / 3 changes the answer.

Stuck? Three hints, smallest first
  1. 1. A cylinder is pi * r ** 2 * h; a cone on the same base and height is a third of it.
  2. 2. The cone's slant height is the hypotenuse of r and h.
  3. 3. Dividing at the end and multiplying by 1 / 3 at the start are not the same floating-point operation.

The answer

const cylinderVolume = (r, h) => Math.PI * r ** 2 * h;
const cylinderLateral = (r, h) => 2 * Math.PI * r * h;
const cylinderSurface = (r, h) => 2 * Math.PI * r * (r + h);

const coneVolume = (r, h) => (Math.PI * r ** 2 * h) / 3;      // divide last
const coneVolumeThirdFirst = (r, h) => (1 / 3) * Math.PI * r ** 2 * h;
const slantHeight = (r, h) => Math.hypot(r, h);
const coneSurface = (r, h) => Math.PI * r * (r + slantHeight(r, h));

const r = 3;
const h = 10;

console.log('cylinder volume :', cylinderVolume(r, h));
console.log('lateral, total  :', cylinderLateral(r, h), cylinderSurface(r, h));
console.log('cone volume     :', coneVolume(r, h));
console.log('with 1/3 first  :', coneVolumeThirdFirst(r, h), '<- one bit lower');
console.log('three cones     :', 3 * coneVolume(r, h));
console.log('slant height    :', slantHeight(r, h), 'which is sqrt(109)');
console.log('cone surface    :', coneSurface(r, h));
console.log('radius doubled  :', cylinderVolume(6, h), 'over', cylinderVolume(3, h));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the cylinder holds 282.7433388230814 cubic units', cylinderVolume(r, h), 282.7433388230814);
test('its side unrolls to a rectangle of area 188.49555921538757', cylinderLateral(r, h), 188.49555921538757);
test('and the total surface adds two circles, giving 245.04422698000386', [cylinderSurface(r, h), cylinderSurface(r, h) === cylinderLateral(r, h) + 2 * Math.PI * r ** 2], [245.04422698000386, true]);
test('the cone on the same base is a third: 94.2477796076938', coneVolume(r, h), 94.2477796076938);
test('and three of them refill the cylinder exactly', 3 * coneVolume(r, h) === cylinderVolume(r, h), true);
test('but multiplying by 1 / 3 first lands one bit lower', [coneVolumeThirdFirst(r, h), coneVolumeThirdFirst(r, h) === coneVolume(r, h)], [94.24777960769379, false]);
test('the slant height is the hypotenuse of 3 and 10', [slantHeight(r, h), slantHeight(r, h) === Math.sqrt(109)], [10.44030650891055, true]);
test('the cone surface is pi r (r + l), which is 126.6719045711654', coneSurface(r, h), 126.6719045711654);
test('doubling the radius quadruples the volume, exactly here', cylinderVolume(6, h) === 4 * cylinderVolume(3, h), true);

The relationship worth remembering is that a cone is a third of the cylinder on the same base and height — and here 3 * coneVolume(3, 10) === cylinderVolume(3, 10) holds to the last bit, because dividing by 3 and multiplying by 3 undo each other on a double when no rounding was lost in between.

Which is exactly why (1 / 3) * Math.PI * r ** 2 * h is a bit lower: 1 / 3 is not representable, so that version rounds twice — once storing a third, once multiplying — where (Math.PI * r ** 2 * h) / 3 rounds once. Divide at the end. It is the same lesson as the weighted mean in exercise 62: fewer inexact operations, applied later. The surface formulas are worth writing factored, 2 pi r (r + h) and pi r (r + l), both because they are shorter and because they multiply once rather than twice.

78 A sphere, and two formulas that should agree

fair

Give the volume and surface area of a sphere of radius 6, writing the volume as (4 / 3) * pi * r ** 3 and again as 4 * pi * r ** 3 / 3. Then check three relationships that hold exactly in mathematics, and see which survive in floating point.

Stuck? Three hints, smallest first
  1. 1. Volume is 4 / 3 pi r ** 3 and surface is 4 pi r ** 2, so volume over surface is r / 3.
  2. 2. At r = 6 a hemisphere's volume and the sphere's surface area are the same number.
  3. 3. Archimedes: a sphere is two thirds of the cylinder that just contains it.

The answer

const volumeThirdFirst = (r) => (4 / 3) * Math.PI * r ** 3;
const volumeDivideLast = (r) => (4 * Math.PI * r ** 3) / 3;
const surface = (r) => 4 * Math.PI * r ** 2;
const hemisphereVolume = (r) => (2 / 3) * Math.PI * r ** 3;

const r = 6;

// The cylinder that just contains the sphere: same radius, height 2r.
const boundingCylinder = Math.PI * r ** 2 * (2 * r);

// dV/dr should be the surface area. Check it numerically.
const eps = 1e-6;
const derivative = (volumeThirdFirst(r + eps) - volumeThirdFirst(r - eps)) / (2 * eps);

console.log('volume, 4/3 first  :', volumeThirdFirst(r));
console.log('volume, divide last:', volumeDivideLast(r), '<- one bit higher');
console.log('surface            :', surface(r));
console.log('volume / surface   :', volumeThirdFirst(r) / surface(r), 'should be r / 3 =', r / 3);
console.log('hemisphere volume  :', hemisphereVolume(r), 'against the surface', surface(r));
console.log('difference         :', hemisphereVolume(r) - surface(r));
console.log('two thirds of cyl  :', (2 / 3) * boundingCylinder);
console.log('dV/dr              :', derivative);

Output


      
8 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 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the volume is 904.7786842338603 with the 4 / 3 written first', volumeThirdFirst(r), 904.7786842338603);
test('and 904.7786842338604 if you divide at the end instead', [volumeDivideLast(r), volumeDivideLast(r) === volumeThirdFirst(r)], [904.7786842338604, false]);
test('the surface area is 452.3893421169302', surface(r), 452.3893421169302);
test('volume over surface should be r / 3, and comes out just under 2', [volumeThirdFirst(r) / surface(r), volumeThirdFirst(r) / surface(r) === r / 3], [1.9999999999999998, false]);
test('at r = 6 the hemisphere volume equals the surface area, in mathematics', Math.abs(hemisphereVolume(r) - surface(r)) < 1e-12, true);
test('in doubles they differ by 5.7e-14, so === would say no', hemisphereVolume(r) === surface(r), false);
test('Archimedes survives exactly: the sphere is two thirds of its cylinder', volumeThirdFirst(r) === (2 / 3) * boundingCylinder, true);
test('and the rate of change of volume with radius is the surface area', Math.abs(derivative - surface(r)) < 1e-6, true);

Three identities, three different fates. V / A = r / 3 comes out 1.9999999999999998 instead of 2. The hemisphere volume and the surface area, equal at exactly r = 6, differ by 5.7e-14. And Archimedes' result — a sphere is two thirds of the cylinder that just contains it — happens to hold bit for bit. Nothing about the mathematics predicts which; it depends on where each expression rounds.

So the rule for geometry code is the rule for all floating point: never assert an identity with === unless you have checked that specific expression, and even then a harmless-looking rewrite can break it. Math.abs(a - b) < 1e-12 states what you actually mean. The numerical derivative is the same idea from the other side: (V(r + h) - V(r - h)) / 2h matches the surface area to about 5e-8 rather than exactly, and shrinking h further makes it worse, not better, because the subtraction in the numerator starts cancelling.

79 Distance, midpoint and slope

fair

For the points (2, 3) and (10, 9), give the distance, the midpoint and the slope, plus the slope of a line perpendicular to it. Then say what your slope function returns for a vertical line and for the same point twice, and test three points for collinearity in a way that survives floating point.

Stuck? Three hints, smallest first
  1. 1. Distance is Pythagoras on the two differences, and Math.hypot is the safe form.
  2. 2. A vertical line divides by zero, which is Infinity rather than an error.
  3. 3. Comparing two slopes for collinearity compares two divisions; the cross product compares no divisions at all.

The answer

const distance = (p, q) => Math.hypot(q[0] - p[0], q[1] - p[1]);
const midpoint = (p, q) => [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
const slope = (p, q) => (q[1] - p[1]) / (q[0] - p[0]);
const perpendicular = (m) => -1 / m;

// Collinear if the cross product of the two edge vectors is zero.
const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
const collinearByCross = (p, q, r) => cross(p, q, r) === 0;
const collinearBySlope = (p, q, r) => slope(p, q) === slope(q, r);

const P = [2, 3];
const Q = [10, 9];
const M = midpoint(P, Q);

// Three points on one line, with coordinates that are not exact doubles.
const A = [0, 0];
const B = [0.1, 0.5];
const C = [0.4, 2];

console.log('distance   :', distance(P, Q));
console.log('midpoint   :', M, 'equidistant:', distance(P, M), distance(M, Q));
console.log('slope, perp:', slope(P, Q), perpendicular(slope(P, Q)));
console.log('vertical   :', slope([2, 3], [2, 9]), 'and downwards', slope([2, 9], [2, 3]));
console.log('same point :', slope(P, P));
console.log('horizontal :', slope([2, 3], [10, 3]));
console.log('two slopes :', slope(A, B), slope(B, C));
console.log('by slope   :', collinearBySlope(A, B, C), 'by cross product:', collinearByCross(A, B, C));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('the distance is exactly 10, since the legs are 8 and 6', [distance(P, Q), Q[0] - P[0], Q[1] - P[1]], [10, 8, 6]);
test('the midpoint is (6, 6) and it is 5 from each end', [M, distance(P, M), distance(M, Q)], [[6, 6], 5, 5]);
test('the slope is 0.75, and a perpendicular has slope -4/3', [slope(P, Q), perpendicular(slope(P, Q))], [0.75, -1.3333333333333333]);
test('their product is -1, which is what perpendicular means', slope(P, Q) * perpendicular(slope(P, Q)), -1);
test('a vertical line has an infinite slope, up or down', [slope([2, 3], [2, 9]) === Infinity, slope([2, 9], [2, 3]) === -Infinity], [true, true]);
test('the same point twice gives NaN, since there is no line', Number.isNaN(slope(P, P)), true);
test('a horizontal line has slope 0, not minus zero', Object.is(slope([2, 3], [10, 3]), 0), true);
test('comparing slopes says these three collinear points are not', [slope(A, B), slope(B, C), collinearBySlope(A, B, C)], [5, 4.999999999999999, false]);
test('while the cross product, which never divides, gets it right', [cross(A, B, C), collinearByCross(A, B, C)], [0, true]);

Use Math.hypot rather than Math.sqrt(dx * dx + dy * dy): it scales the inputs first, so it survives coordinates large enough to overflow a square, as exercise 30 showed. The midpoint is a two-element average, and checking that it is equidistant from both ends is a cheap sanity test on any point-geometry code.

The slope is where the edges are. A vertical line divides by zero and gives Infinity, with the sign depending on which way you subtracted; the same point twice gives 0 / 0, which is NaN. Neither throws, so a slope-based routine can happily carry Infinity or NaN for several steps before producing a nonsense coordinate.

And the collinearity test is the one to take away. Comparing slope(A, B) === slope(B, C) compares two divisions, each rounded, so three genuinely collinear points come back as 5 and 4.999999999999999 and the answer is a confident false. The cross product (q - p) x (r - p) uses only subtraction and multiplication, so with these coordinates it lands on exactly 0. It is also faster, and it gives you the winding direction and the triangle's area for free.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.