Emmanuel Akinfulubi

JavaScript: Math, 80–109

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

Exercises 80 to 109 of 114

80 Degrees, radians, and the trigonometry of known angles

fair

Convert between degrees and radians, then evaluate sine, cosine and tangent at 30, 45, 60 and 90 degrees and compare each with the exact value you learned. Find out what Math.sin(Math.PI) and Math.tan(Math.PI / 2) return.

Stuck? Three hints, smallest first
  1. 1. A full turn is 2 * Math.PI radians, so degrees times Math.PI / 180 gives radians.
  2. 2. Math.sin(rad(30)) is not 0.5, because rad(30) is not exactly a sixth of pi.
  3. 3. Math.tan(Math.PI / 2) has no reason to be Infinity: the argument is not exactly a quarter turn.

The answer

const rad = (deg) => (deg * Math.PI) / 180;
const deg = (r) => (r * 180) / Math.PI;
const round12 = (x) => Math.round(x * 1e12) / 1e12;

const angles = [0, 30, 45, 60, 90, 180, 360];

console.log('30 degrees in radians:', rad(30), 'and Math.PI / 6 is', Math.PI / 6);
console.log('sin 30               :', Math.sin(rad(30)), '<- not 0.5');
console.log('cos 60               :', Math.cos(rad(60)), '<- not 0.5 either');
console.log('tan 45               :', Math.tan(rad(45)), '<- not 1');
console.log('rounded to 12 places :', [Math.sin(rad(30)), Math.cos(rad(60)), Math.tan(rad(45))].map(round12));
console.log('sin 90, cos 90       :', Math.sin(rad(90)), Math.cos(rad(90)));
console.log('sin PI               :', Math.sin(Math.PI));
console.log('tan PI / 2           :', Math.tan(Math.PI / 2), 'finite:', Number.isFinite(Math.tan(Math.PI / 2)));
console.log('round trip           :', angles.map((d) => deg(rad(d))));

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('30 degrees is exactly Math.PI / 6', rad(30) === Math.PI / 6, true);
test('but its sine is 0.49999999999999994, not 0.5', [Math.sin(rad(30)), Math.sin(rad(30)) === 0.5], [0.49999999999999994, false]);
test('cos 60 misses on the other side, and tan 45 is a bit under 1', [Math.cos(rad(60)), Math.tan(rad(45))], [0.5000000000000001, 0.9999999999999999]);
test('rounding to twelve places gives back the values you learned', [Math.sin(rad(30)), Math.cos(rad(60)), Math.tan(rad(45))].map(round12), [0.5, 0.5, 1]);
test('sin 90 is exactly 1, while cos 90 is 6.1e-17 rather than 0', [Math.sin(rad(90)), Math.cos(rad(90))], [1, 6.123233995736766e-17]);
test('sin of Math.PI is 1.2e-16, because Math.PI is not pi', Math.sin(Math.PI), 1.2246467991473532e-16);
test('tan of Math.PI / 2 is an enormous finite number, not Infinity', [Math.tan(Math.PI / 2), Number.isFinite(Math.tan(Math.PI / 2))], [16331239353195370, true]);
test('the round trip is exact at 45, 90, 180 and 360 but not at 30', angles.map((d) => deg(rad(d)) === d), [true, false, true, false, true, true, true]);
test('and sin squared plus cos squared is 1 at 37 degrees anyway', Math.sin(rad(37)) ** 2 + Math.cos(rad(37)) ** 2, 1);

Every trigonometric function in JavaScript takes radians, and Math.PI is a double, so the special angles are not special any more. rad(30) is the nearest double to a sixth of pi, its sine is 0.49999999999999994, and Math.cos(rad(60)) misses in the other direction at 0.5000000000000001. Anything comparing a trigonometric result to 0.5, 1 or 0 needs rounding or a tolerance. Note that rad(30) === Math.PI / 6 does hold, so the loss is in the sine rather than the conversion.

Math.tan(Math.PI / 2) is the one that surprises people: 16331239353195370, a perfectly finite number. The true tangent of a quarter turn is undefined, but Math.PI / 2 is a hair away from a quarter turn, and the tangent of a hair away is merely enormous. So there is no argument for which Math.tan returns Infinity, and code guarding a vertical asymptote with === Infinity never fires. Guard on magnitude instead. Same reason Math.sin(Math.PI) is 1.2e-16 rather than 0, and why the degree round trip loses 30 and 60 while keeping 45, 90 and 180.

81 Per cent is a remainder, not a modulo

easy

Work out what % gives for every sign combination of 7 and 3, then write a mod(a, n) that always lands in the range 0 to n - 1. Use it to answer a clock question: what hour is three hours before one o'clock?

Stuck? Three hints, smallest first
  1. 1. The result of a % n takes the sign of a, never of n.
  2. 2. Adding n once and taking % again fixes a single negative remainder.
  3. 3. Also check 7 % 0 and -6 % 3. Neither does what the name suggests.

The answer

// JavaScript has no modulo operator. % is a truncated remainder: it is
// whatever is left after integer division that rounds TOWARDS ZERO.
const remainder = (a, n) => a % n;

// A real modulo is never negative for positive n. Two % calls get there.
const mod = (a, n) => ((a % n) + n) % n;

// Twelve-hour clock. Hour 0 is really hour 12.
const hoursBefore = (hour, back) => mod(hour - back, 12) || 12;

console.log('7 % 3, -7 % 3   :', 7 % 3, -7 % 3);
console.log('7 % -3, -7 % -3 :', 7 % -3, -7 % -3);
console.log('-6 % 3          :', -6 % 3, 'is it minus zero?', Object.is(-6 % 3, -0));
console.log('5.5 % 2         :', 5.5 % 2, '<- % is not integers-only');
console.log('7 % 0           :', 7 % 0);
console.log('Infinity % 3    :', Infinity % 3, 'and 3 % Infinity:', 3 % Infinity);
console.log('mod(-7, 3)      :', mod(-7, 3), 'against -7 % 3 =', -7 % 3);
console.log('mod(-1, 12)     :', mod(-1, 12), '<- one hour before midnight');
console.log('three before one:', hoursBefore(1, 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('7 % 3 is 1 and -7 % 3 is -1, so the sign follows the dividend', [7 % 3, -7 % 3], [1, -1]);
test('flipping the divisor sign changes nothing about the answer sign', [7 % -3, -7 % -3], [1, -1]);
test('an exact negative multiple leaves minus zero, which prints as 0', Object.is(-6 % 3, -0), true);
test('% is defined on non-integers too, so 5.5 % 2 is 1.5', 5.5 % 2, 1.5);
test('a zero divisor gives NaN rather than throwing', Number.isNaN(7 % 0), true);
test('Infinity % 3 is NaN, while 3 % Infinity is simply 3', [Number.isNaN(Infinity % 3), 3 % Infinity], [true, 3]);
test('the two-step mod lands inside the range for negatives', [mod(-7, 3), mod(-1, 12), mod(-13, 5)], [2, 11, 2]);
test('and it clears the minus zero on the way through', Object.is(mod(-6, 3), 0), true);
test('so three hours before one is ten, and the clock wraps past 12', [hoursBefore(1, 3), hoursBefore(3, 3), hoursBefore(12, 25)], [10, 12, 11]);

% is a remainder, and the sign it carries is the sign of the left operand. -7 % 3 is -1, not 2. That is correct arithmetic — -7 = 3 * (-2) + (-1) — but it is not what anyone wanting a wrap-around index means. Indexing an array with i % length works fine until i goes negative, and then it returns undefined silently.

((a % n) + n) % n is the fix. The second % is not redundant: it handles the case where the first remainder was already non-negative, so the result stays in range instead of becoming n too large. It also quietly repairs -0, because (-0 + 3) % 3 is 0.

Two edges worth knowing. 7 % 0 is NaN, which means a divide-by-zero in modular code does not throw; it poisons every later arithmetic step instead. And -6 % 3 is -0, which passes === 0, prints as 0, and stringifies as 0, so only Object.is can see it.

82 Integer division four ways, and where one of them lies

fair

Divide 7 by 3 and -7 by 3 using Math.trunc, Math.floor, | 0 and >> 0. Say which of the four pairs with % and which pairs with a true modulo, then find an input where the bitwise versions return the wrong answer.

Stuck? Three hints, smallest first
  1. 1. Math.trunc cuts towards zero; Math.floor always goes down.
  2. 2. Quotient times divisor plus remainder should give back the dividend. Try both quotients.
  3. 3. The bitwise operators convert their operand to a signed 32-bit integer first.

The answer

const mod = (a, n) => ((a % n) + n) % n;

const idivTrunc = (a, b) => Math.trunc(a / b);
const idivFloor = (a, b) => Math.floor(a / b);
const idivBits = (a, b) => (a / b) | 0;
const idivShift = (a, b) => (a / b) >> 0;

console.log('7 / 3  :', idivTrunc(7, 3), idivFloor(7, 3), idivBits(7, 3), idivShift(7, 3));
console.log('-7 / 3 :', idivTrunc(-7, 3), idivFloor(-7, 3), idivBits(-7, 3), idivShift(-7, 3));
console.log('trunc pairs with %      :', idivTrunc(-7, 3) * 3 + (-7 % 3));
console.log('floor pairs with mod    :', idivFloor(-7, 3) * 3 + mod(-7, 3));
console.log('trunc(-0.5), floor(-0.5):', Math.trunc(-0.5), Math.floor(-0.5));
console.log('3000000000 | 0          :', 3000000000 | 0, 'against', Math.trunc(3000000000));
console.log('1e10 | 0                :', 1e10 | 0);
console.log('NaN | 0, Infinity | 0   :', NaN | 0, Infinity | 0);

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('for positives all four agree that 7 over 3 is 2', [idivTrunc(7, 3), idivFloor(7, 3), idivBits(7, 3), idivShift(7, 3)], [2, 2, 2, 2]);
test('for negatives floor goes down to -3 while the other three stop at -2', [idivTrunc(-7, 3), idivFloor(-7, 3), idivBits(-7, 3), idivShift(-7, 3)], [-2, -3, -2, -2]);
test('trunc is the partner of %: quotient times divisor plus remainder is -7', idivTrunc(-7, 3) * 3 + (-7 % 3), -7);
test('floor is the partner of the two-step mod, and reaches -7 the other way', idivFloor(-7, 3) * 3 + mod(-7, 3), -7);
test('Math.trunc(-0.5) is minus zero while Math.floor(-0.5) is -1', [Object.is(Math.trunc(-0.5), -0), Math.floor(-0.5)], [true, -1]);
test('| 0 folds anything past 32 bits, so three billion comes back negative', [3000000000 | 0, Math.trunc(3000000000)], [-1294967296, 3000000000]);
test('ten billion becomes 1410065408, with no warning at all', [1e10 | 0, Math.trunc(1e10)], [1410065408, 10000000000]);
test('| 0 also swallows NaN and Infinity, turning both into 0', [NaN | 0, Infinity | 0], [0, 0]);
test('so the bitwise trick is only honest inside the signed 32-bit range', [Math.trunc(2147483647.9) | 0, 2147483648 | 0], [2147483647, -2147483648]);

Four ways to divide and keep the whole part, and they split into two camps on negatives. Math.floor rounds towards minus infinity, so -7 / 3 is -3. The other three round towards zero and give -2. Neither is wrong; they are two different definitions of integer division, and each has its own matching remainder. Math.trunc goes with %, Math.floor goes with ((a % n) + n) % n. Mixing a floor quotient with a % remainder is how off-by-one bugs get into date and pagination code.

| 0 and >> 0 look like a free truncation and they are fast, but every bitwise operator in JavaScript first converts its operand to a signed 32-bit integer. Above 2147483647 the value wraps: three billion becomes -1294967296, ten billion becomes 1410065408, and nothing tells you. NaN | 0 and Infinity | 0 are both 0, so the trick also hides exactly the values you most want to catch. Use Math.trunc unless you have already proved the range.

83 Greatest common divisor and lowest common multiple

easy

Write gcd by Euclid's algorithm and lcm in terms of it, make both survive zeros and negatives, use them to reduce 84/126 to its lowest terms, and then try the pair 123456789 and 987654321 to see whether a double can still hold the answer.

Stuck? Three hints, smallest first
  1. 1. Euclid: replace the pair (a, b) with (b, a % b) until b is 0.
  2. 2. gcd(a, b) * lcm(a, b) is a * b, which gives you lcm for free.
  3. 3. Divide before you multiply. a * b can leave the safe integer range even when the answer would fit.

The answer

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

// Divide first: a * b overflows the safe range long before the lcm does.
const lcm = (a, b) => (a === 0 || b === 0 ? 0 : Math.abs(a / gcd(a, b)) * Math.abs(b));
const lcmNaive = (a, b) => Math.abs(a * b) / gcd(a, b);

const reduceFraction = (n, d) => {
  const g = gcd(n, d);
  return [n / g, d / g];
};

// BigInt versions, for when the answer is bigger than a double can hold exactly.
const gcdBig = (a, b) => {
  if (a < 0n) a = -a;
  if (b < 0n) b = -b;
  while (b) [a, b] = [b, a % b];
  return a;
};
const lcmBig = (a, b) => (a / gcdBig(a, b)) * b;

console.log('gcd(48, 18)      :', gcd(48, 18));
console.log('gcd with 0 or -  :', gcd(0, 5), gcd(5, 0), gcd(0, 0), gcd(-48, 18));
console.log('lcm(4, 6)        :', lcm(4, 6), 'lcm(21, 6):', lcm(21, 6));
console.log('gcd * lcm = a * b:', gcd(21, 6) * lcm(21, 6), 21 * 6);
console.log('84 / 126 reduces :', reduceFraction(84, 126));
console.log('lists            :', [12, 18, 24].reduce(gcd), [4, 6, 10].reduce(lcm));
console.log('big pair, double :', lcm(123456789, 987654321), lcmNaive(123456789, 987654321));
console.log('big pair, BigInt :', String(lcmBig(123456789n, 987654321n)));

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('gcd(48, 18) is 6, and two coprime numbers share only 1', [gcd(48, 18), gcd(17, 5), gcd(35, 64)], [6, 1, 1]);
test('Euclid handles zeros and negatives once you take absolutes', [gcd(0, 5), gcd(5, 0), gcd(0, 0), gcd(-48, 18)], [5, 5, 0, 6]);
test('lcm(4, 6) is 12 and lcm(21, 6) is 42', [lcm(4, 6), lcm(21, 6)], [12, 42]);
test('gcd times lcm gives back the product, which is why one defines the other', gcd(21, 6) * lcm(21, 6) === 21 * 6, true);
test('the guard matters: the unguarded lcm of 0 and 0 is NaN', [lcm(0, 5), lcm(0, 0), Number.isNaN(lcmNaive(0, 0))], [0, 0, true]);
test('84 / 126 reduces to 2 / 3 by dividing both by the gcd', reduceFraction(84, 126), [2, 3]);
test('reduce over a list works because both are associative', [[12, 18, 24].reduce(gcd), [4, 6, 10].reduce(lcm)], [6, 60]);
test('for 123456789 and 987654321 both double routes give the same wrong answer', [lcm(123456789, 987654321), lcmNaive(123456789, 987654321)], [13548070123626140, 13548070123626140]);
test('the true lcm ends in 141, and only BigInt says so', String(lcmBig(123456789n, 987654321n)), '13548070123626141');

Euclid's algorithm is three lines and it is still the fastest thing anyone knows for this. Each step replaces (a, b) with (b, a % b), which shrinks the pair quickly — gcd(123456789, 987654321) settles in a handful of iterations. Taking absolute values first makes it total: negatives work, one zero returns the other operand, and gcd(0, 0) is 0 by convention.

lcm is where the bug lives. Written as a * b / gcd(a, b) it computes a product that may be far larger than the answer, and once that product passes Number.MAX_SAFE_INTEGER the division cannot recover the lost bits. Dividing first — a / gcd(a, b) * b — pushes the limit out, and it costs nothing.

It does not push it out far enough here. The true lcm of 123456789 and 987654321 is 13548070123626141, which is above 2 ** 53, so both double versions return 13548070123626140 and agree with each other while being wrong. That agreement is the trap: two implementations matching is not evidence when both share the same representation. BigInt gets the last digit right because it never rounds.

84 Bases, both directions

fair

Print 255 in hex, binary, octal and base 36, then read those strings back with parseInt. Find out what parseInt does with a string it only partly understands, what (-255).toString(16) looks like, and what happens if you ask for base 1.

Stuck? Three hints, smallest first
  1. 1. Number.prototype.toString takes an optional radix from 2 to 36.
  2. 2. parseInt takes the radix as its second argument, and reads as far as it can.
  3. 3. parseInt('1e3') and Number('1e3') do not agree.

The answer

const radixResult = (r) => {
  try {
    return (255).toString(r);
  } catch (error) {
    return error.constructor.name;
  }
};

const toBinary8 = (n) => n.toString(2).padStart(8, '0');
const roundTrip = (n, radix) => parseInt(n.toString(radix), radix);

console.log('255 in four bases:', (255).toString(16), (255).toString(2), (255).toString(8), (255).toString(36));
console.log('-255 in hex      :', (-255).toString(16), '<- sign and magnitude');
console.log('parseInt with 16 :', parseInt('ff', 16), parseInt('0xff', 16), parseInt('0xff'));
console.log('parseInt bare ff :', parseInt('ff'));
console.log('partly readable  :', parseInt('102', 2), parseInt('12abc', 16), parseInt('  42abc'));
console.log('1e3 two ways     :', parseInt('1e3'), Number('1e3'));
console.log('bad radices      :', radixResult(1), radixResult(37));
console.log('binary byte      :', toBinary8(5), toBinary8(200));
console.log('0.1 in binary    :', (0.1).toString(2), 'length', (0.1).toString(2).length);

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('255 is ff, 11111111, 377 and 73 in bases 16, 2, 8 and 36', [(255).toString(16), (255).toString(2), (255).toString(8), (255).toString(36)], ['ff', '11111111', '377', '73']);
test('a negative gets a minus sign, not a two-complement bit pattern', (-255).toString(16), '-ff');
test('parseInt wants the radix: bare ff is NaN, but 0xff is read as hex anyway', [parseInt('ff', 16), Number.isNaN(parseInt('ff')), parseInt('0xff')], [255, true, 255]);
test('parseInt stops at the first character the radix cannot use', [parseInt('102', 2), parseInt('12abc', 16), parseInt('  42abc')], [2, 76476, 42]);
test('which is why parseInt is not Number: 1e3 reads as just 1', [parseInt('1e3'), Number('1e3')], [1, 1000]);
test('a radix outside 2 to 36 is a RangeError, not a NaN', [radixResult(1), radixResult(37), radixResult(16)], ['RangeError', 'RangeError', 'ff']);
test('padStart turns a binary string into a fixed-width byte', [toBinary8(5), toBinary8(200)], ['00000101', '11001000']);
test('the integer round trip through base 7 is exact', [(12345).toString(7), roundTrip(12345, 7)], ['50664', 12345]);
test('0.1 in binary repeats until the double runs out, 57 characters in', [(0.1).toString(2).slice(0, 22), (0.1).toString(2).length], ['0.00011001100110011001', 57]);

toString(radix) and parseInt(string, radix) are inverses for integers, and the round trip through any base from 2 to 36 is exact. Outside that range toString throws a RangeError, which is worth knowing because it is one of the few places in the Math area that throws instead of returning NaN.

parseInt is the loose one. It skips leading whitespace, reads as many valid digits as it can, and returns what it got: parseInt('102', 2) is 2, because 2 is not a binary digit and it stops. It also understands a 0x prefix with no radix at all. Most importantly it has no idea about exponents, so parseInt('1e3') is 1 while Number('1e3') is 1000. Reach for Number when the whole string must be a number, and parseInt only when a prefix is genuinely what you want.

(0.1).toString(2) is the best one-line proof of why decimal fractions misbehave. It prints 0.0001100110011001100110011001100110011001100110011001101 — a repeating pattern that gets cut off and rounded up at the end. There is no bug anywhere in the arithmetic; the value simply was never 0.1.

85 Bitwise operators, and the 32-bit wall behind them

fair

Apply & | ^ ~ << >> >>> to small numbers and check each result by hand. Then show that 1 << 31 is negative, that 1 << 32 is 1, and write a power-of-two test and a bit counter that both use this deliberately rather than by accident.

Stuck? Three hints, smallest first
  1. 1. ~n is -(n + 1). Every bitwise operand becomes a signed 32-bit integer first.
  2. 2. >>> is the only one that produces an unsigned result, so -1 >>> 0 is large and positive.
  3. 3. A power of two has one bit set, so n & (n - 1) clears it and leaves 0.

The answer

// A shift count is taken modulo 32, which is why 1 << 32 is 1 again.
const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0;

const popcount = (n) => {
  let bits = n >>> 0;               // unsigned, so negatives count all 32
  let count = 0;
  while (bits) {
    count += bits & 1;
    bits >>>= 1;
  }
  return count;
};

const xorSwap = (a, b) => {
  a ^= b;
  b ^= a;
  a ^= b;
  return [a, b];
};

// Permission flags, the one place bitwise code reads well.
const READ = 1;
const WRITE = 2;
const ADMIN = 4;
const grant = READ | ADMIN;
const can = (flags, flag) => (flags & flag) !== 0;

console.log('5 & 3, 5 | 3, 5 ^ 3 :', 5 & 3, 5 | 3, 5 ^ 3);
console.log('~5, 5 << 1, 5 >> 1  :', ~5, 5 << 1, 5 >> 1);
console.log('-1 >> 0 vs -1 >>> 0 :', -1 >> 0, -1 >>> 0);
console.log('1 << 30, 31, 32     :', 1 << 30, 1 << 31, 1 << 32);
console.log('2 ** 31 | 0         :', 2 ** 31 | 0, 'and 2 ** 32 | 0:', 2 ** 32 | 0);
console.log('~~3.7, ~~-3.7       :', ~~3.7, ~~-3.7);
console.log('powers of two       :', [1, 2, 3, 4, 1024, 0, -4].map(isPowerOfTwo));
console.log('popcount            :', popcount(255), popcount(1024), popcount(-1));
console.log('grant               :', grant, can(grant, READ), can(grant, WRITE), can(grant, ADMIN));

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 three pairwise operators on 5 and 3 give 1, 7 and 6', [5 & 3, 5 | 3, 5 ^ 3], [1, 7, 6]);
test('~n is -(n + 1), and ~~x truncates towards zero', [~5, ~~3.7, ~~-3.7], [-6, 3, -3]);
test('>> keeps the sign bit, >>> does not, so -1 becomes 4294967295', [-1 >> 0, -1 >>> 0], [-1, 4294967295]);
test('shifting left doubles until bit 31, then it goes negative and wraps', [1 << 30, 1 << 31, 1 << 32], [1073741824, -2147483648, 1]);
test('the same wall shows up in | 0: 2 ** 31 is negative and 2 ** 32 is zero', [2 ** 31 | 0, 2 ** 32 | 0], [-2147483648, 0]);
test('n & (n - 1) clears the lowest bit, so the test needs the n > 0 guard', [1, 2, 3, 4, 1024, 0, -4].map(isPowerOfTwo), [true, true, false, true, true, false, false]);
test('popcount over the unsigned view: 255 has 8 bits and -1 has all 32', [popcount(255), popcount(1024), popcount(-1)], [8, 1, 32]);
test('three xors swap two numbers with no third variable', xorSwap(9, 4), [4, 9]);
test('flags read well: read and admin granted, write withheld', [grant, can(grant, READ), can(grant, WRITE), can(grant, ADMIN)], [5, true, false, true]);

Every bitwise operator converts its operands to a signed 32-bit integer, does the work, and converts back to a double. That conversion is the whole story. 1 << 31 sets the sign bit, so it reads as -2147483648. 1 << 32 is 1, because the shift count is taken modulo 32 and a shift of 0 does nothing. -1 >>> 0 is 4294967295, the same 32 bits reinterpreted as unsigned.

None of that is a defect, but it means bitwise code silently caps out at about two billion. popcount above uses >>> 0 on purpose, so a negative input is counted over its two's complement form and popcount(-1) is 32 rather than an infinite loop. isPowerOfTwo needs the n > 0 guard because 0 & -1 is 0 and would otherwise call zero a power of two.

The xor swap is a party trick, not advice: [a, b] = [b, a] is clearer and no slower. Flags are the case where bitwise code earns its place — a single number carries a set, | adds, & tests, and everything fits in one word.

86 Number.EPSILON, and three ways to compare floats

hard

Find out what Number.EPSILON actually is, then write an absolute comparison, a relative one and a fixed-tolerance one. Show a sum where 0.1 + 0.2 misses 0.3, a sum where the same two additions land exactly right, and a pair of values a billion times larger where the absolute test stops working.

Stuck? Three hints, smallest first
  1. 1. Number.EPSILON is the gap between 1 and the next double, which is 2 ** -52.
  2. 2. That gap grows with the magnitude of the numbers, so a fixed threshold cannot be right everywhere.
  3. 3. Scale the tolerance by the larger operand and the same test works at both sizes.

The answer

const closeAbsolute = (a, b) => Math.abs(a - b) <= Number.EPSILON;
const closeRelative = (a, b) => Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b));
const closeFixed = (a, b) => Math.abs(a - b) < 1e-9;

// The same arithmetic, a billion times larger.
const scaledA = (0.1 + 0.2) * 1e9;
const scaledB = 1e9 * 0.3;

console.log('EPSILON            :', Number.EPSILON, 'is 2 ** -52:', Number.EPSILON === 2 ** -52);
console.log('0.1 + 0.2          :', 0.1 + 0.2, 'off by', Math.abs(0.1 + 0.2 - 0.3));
console.log('4500 + 0.1 + 0.2   :', 4500 + 0.1 + 0.2, 'exactly 4500.3?', 4500 + 0.1 + 0.2 === 4500.3);
console.log('1 + EPSILON / 2    :', 1 + Number.EPSILON / 2, 'still 1?', 1 + Number.EPSILON / 2 === 1);
console.log('scaled pair        :', scaledA, scaledB, 'gap', Math.abs(scaledA - scaledB));
console.log('absolute test      :', closeAbsolute(0.1 + 0.2, 0.3), closeAbsolute(scaledA, scaledB));
console.log('relative test      :', closeRelative(0.1 + 0.2, 0.3), closeRelative(scaledA, scaledB));
console.log('fixed 1e-9 test    :', closeFixed(1e-10, 2e-10), '<- calls these equal');
console.log('round then compare :', Number((0.1 + 0.2).toFixed(10)));

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('Number.EPSILON is 2 ** -52, the distance from 1 to the next double', [Number.EPSILON, Number.EPSILON === 2 ** -52], [2.220446049250313e-16, true]);
test('0.1 + 0.2 lands one step above 0.3, missing by 5.55e-17', [0.1 + 0.2, Math.abs(0.1 + 0.2 - 0.3)], [0.30000000000000004, 5.551115123125783e-17]);
test('float error is not universal: 4500 + 0.1 + 0.2 is exactly 4500.3', 4500 + 0.1 + 0.2 === 4500.3, true);
test('epsilon is the gap at 1, so half of it disappears and all of it does not', [1 + Number.EPSILON / 2 === 1, 1 + Number.EPSILON === 1], [true, false]);
test('a billion times larger, the same error is 5.96e-8 and dwarfs epsilon', [Math.abs(scaledA - scaledB), closeAbsolute(scaledA, scaledB)], [5.960464477539063e-8, false]);
test('the relative test passes at both magnitudes, which is the point of it', [closeRelative(0.1 + 0.2, 0.3), closeRelative(scaledA, scaledB)], [true, true]);
test('it still says zero equals zero, and that zero is not the smallest double', [closeRelative(0, 0), closeRelative(0, Number.MIN_VALUE)], [true, false]);
test('a fixed 1e-9 tolerance fails the other way, calling 1e-10 and 2e-10 equal', [closeFixed(1e-10, 2e-10), closeFixed(1e12, 1e12 + 1)], [true, false]);
test('rounding both sides to a stated number of places is the pragmatic answer', [Number((0.1 + 0.2).toFixed(10)), Number((0.1 + 0.2).toFixed(10)) === 0.3], [0.3, true]);

Number.EPSILON is not "the smallest error a double can have". It is one specific number, 2 ** -52, the gap between 1 and the next representable double. Near 1 it is exactly the right tolerance. Near a billion the gap between neighbouring doubles is about 1.2e-7, so Math.abs(a - b) <= Number.EPSILON is asking for something the format cannot deliver, and the test always fails.

Scaling the tolerance by Math.max(Math.abs(a), Math.abs(b)) fixes that, and it keeps the one case a purely relative test would break on: two zeros, where both sides are 0 and the comparison holds. A fixed tolerance like 1e-9 is the mirror-image mistake — fine at everyday sizes, far too loose for values around 1e-10, far too tight for values around 1e12.

The middle assertion is there as a corrective. It is easy to come away from 0.1 + 0.2 !== 0.3 believing that decimal arithmetic in doubles is always wrong. It is not: 4500 + 0.1 + 0.2 === 4500.3 is true, because at that magnitude the rounding happens to land on the same double the literal 4500.3 denotes. Whether a given expression is exact is a fact about that expression, so run it before you claim it.

87 toFixed rounds the double it was handed

fair

Round 1.005, 2.675 and 8.575 to two places with toFixed and explain the results. Then compare toFixed, Math.round and a hand-written banker's rounding on the halves from -2.5 to 3.5, and show which of the three keeps a sum unchanged.

Stuck? Three hints, smallest first
  1. 1. toPrecision(20) shows what the literal 1.005 really is.
  2. 2. toFixed rounds halves away from zero; Math.round rounds them towards positive infinity.
  3. 3. Banker's rounding sends a tie to whichever neighbour is even.

The answer

const add = (a, b) => a + b;
const halves = [0.5, 1.5, 2.5, 3.5, -0.5, -1.5, -2.5];

// Round half to even, the accounting default.
const roundHalfEven = (x) => {
  const down = Math.floor(x);
  const frac = x - down;
  if (frac > 0.5) return down + 1;
  if (frac < 0.5) return down;
  return down % 2 === 0 ? down : down + 1;
};

console.log('typeof toFixed     :', typeof (1.5).toFixed(2), (1.5).toFixed(2));
console.log('1.005 to 2 places  :', (1.005).toFixed(2), 'because it is', (1.005).toPrecision(20));
console.log('2.675 and 8.575    :', (2.675).toFixed(2), (8.575).toFixed(2));
console.log('real halves        :', halves.map((x) => x.toFixed(0)));
console.log('Math.round         :', halves.map(Math.round));
console.log('half to even       :', halves.map(roundHalfEven));
console.log('sums               :', [0.5, 1.5, 2.5, 3.5].map(Math.round).reduce(add, 0), [0.5, 1.5, 2.5, 3.5].map(roundHalfEven).reduce(add, 0), 0.5 + 1.5 + 2.5 + 3.5);
console.log('minus zero, 1e21   :', (-0).toFixed(2), (1e21).toFixed(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('toFixed returns a string, and pads to the width you asked for', [typeof (1.5).toFixed(2), (1.5).toFixed(2)], ['string', '1.50']);
test('1.005 to two places is 1.00, because the stored value is below the half', [(1.005).toFixed(2), (1.005).toPrecision(20)], ['1.00', '1.0049999999999998934']);
test('2.675 and 8.575 are stored low in the same way, so both round down', [(2.675).toFixed(2), (8.575).toFixed(2)], ['2.67', '8.57']);
test('where the half is a real half, toFixed rounds away from zero on both sides', halves.map((x) => x.toFixed(0)), ['1', '2', '3', '4', '-1', '-2', '-3']);
test('Math.round rounds ties up instead, which makes -0.5 into minus zero', [Math.round(0.5), Math.round(2.5), Object.is(Math.round(-0.5), -0), Math.round(-1.5), Math.round(-2.5)], [1, 3, true, -1, -2]);
test('banker rounding sends every tie to the even neighbour, in both signs', halves.map(roundHalfEven), [0, 2, 2, 4, 0, -2, -2]);
test('and that is why accounting uses it: the total survives rounding', [[0.5, 1.5, 2.5, 3.5].map(Math.round).reduce(add, 0), [0.5, 1.5, 2.5, 3.5].map(roundHalfEven).reduce(add, 0), 0.5 + 1.5 + 2.5 + 3.5], [10, 8, 8]);
test('above 1e21 toFixed gives up and hands back exponential notation', (1e21).toFixed(2), '1e+21');
test('and minus zero loses its sign on the way to a string', [(-0).toFixed(2), Object.is(-0, 0)], ['0.00', false]);

The specification for toFixed says: pick the integer n for which n / 10 ** f is closest to the value, and on a tie pick the larger magnitude. That is round-half-away-from-zero, so (0.5).toFixed(0) is '1' and (-0.5).toFixed(0) is '-1'. Math.round is different: it rounds ties towards positive infinity, so Math.round(-0.5) is -0 and Math.round(-1.5) is -1. Two rounding rules in the same language, disagreeing on every negative tie.

(1.005).toFixed(2) returning '1.00' is not a bug in either rule. The literal 1.005 is stored as 1.0049999999999998934, which is genuinely below the halfway point, so rounding down is correct. toFixed rounds the double it was handed, and that double was never 1.005. The same applies to 2.675 and 8.575. No rounding mode can fix this, because the information was lost when the literal was parsed.

Banker's rounding exists for the last assertion. Rounding 0.5, 1.5, 2.5 and 3.5 up gives 10 where the true total is 8: a consistent upward bias of half a unit per tie, which on a ledger of a million rows is real money. Sending ties to the even neighbour cancels the bias and the total comes out at 8. If you are rounding naira, round to even, or better, hold kobo as integers and never round at all.

88 Where MAX_SAFE_INTEGER stops being a suggestion

fair

Show that 2 ** 53 and 2 ** 53 + 1 are the same number, that the literal 9007199254740993 cannot be written at all, and that reading a 16-digit id out of text or JSON loses its last digit. Then do the same arithmetic with BigInt and see what mixing the two does.

Stuck? Three hints, smallest first
  1. 1. Number.MAX_SAFE_INTEGER is 2 ** 53 - 1. Above it, only even integers exist for a while.
  2. 2. Number.isInteger and Number.isSafeInteger disagree, and the second is the one you want.
  3. 3. A BigInt has to be read back with String: JSON.stringify throws on one.

The answer

const mixError = () => {
  try {
    return 1n + 1;
  } catch (error) {
    return error.constructor.name;
  }
};

const stringifyError = () => {
  try {
    return JSON.stringify({ id: 1n });
  } catch (error) {
    return error.constructor.name;
  }
};

console.log('MAX_SAFE_INTEGER   :', Number.MAX_SAFE_INTEGER, '= 2 ** 53 - 1:', Number.MAX_SAFE_INTEGER === 2 ** 53 - 1);
console.log('2 ** 53 + 1        :', 2 ** 53 + 1, 'same as 2 ** 53?', 2 ** 53 === 2 ** 53 + 1);
console.log('the literal        :', 9007199254740993);
console.log('isInteger vs safe  :', Number.isInteger(2 ** 53), Number.isSafeInteger(2 ** 53));
console.log('parseInt of an id  :', parseInt('9007199254740993'));
console.log('through JSON       :', JSON.parse('{"id":9007199254740993}').id);
console.log('as a JSON string   :', JSON.parse('{"id":"9007199254740993"}').id);
console.log('BigInt             :', String(9007199254740993n + 2n), String(2n ** 64n));
console.log('1n + 1             :', mixError(), 'JSON.stringify(1n):', stringifyError());
console.log('past MAX_VALUE     :', Number.MAX_VALUE * 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('MAX_SAFE_INTEGER is 2 ** 53 - 1, the last integer with a neighbour', [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER === 2 ** 53 - 1], [9007199254740991, true]);
test('one step past it, adding 1 does nothing, but adding 2 still works', [2 ** 53 === 2 ** 53 + 1, 2 ** 53 + 2 === 2 ** 53 + 1], [true, false]);
test('so the literal 9007199254740993 cannot even be written down', [9007199254740993, 9007199254740993 === 9007199254740992], [9007199254740992, true]);
test('isSafeInteger draws the line where isInteger will not', [Number.isSafeInteger(2 ** 53 - 1), Number.isSafeInteger(2 ** 53), Number.isInteger(2 ** 53)], [true, false, true]);
test('parsing a 16-digit id from text quietly loses the last digit', [parseInt('9007199254740993'), String(BigInt('9007199254740993'))], [9007199254740992, '9007199254740993']);
test('a JSON round trip loses it too, unless the id travelled as a string', [JSON.parse('{"id":9007199254740993}').id, JSON.parse('{"id":"9007199254740993"}').id], [9007199254740992, '9007199254740993']);
test('BigInt keeps every digit, and is read back with String', [String(9007199254740993n + 2n), String(2n ** 64n)], ['9007199254740995', '18446744073709551616']);
test('mixing a BigInt with a Number throws, and JSON refuses one outright', [mixError(), stringifyError()], ['TypeError', 'TypeError']);
test('and far above all this there is only Infinity, still with no error', [Number.MAX_VALUE * 2 === Infinity, Number.MAX_VALUE], [true, 1.7976931348623157e+308]);

A double has 53 bits of significand, so every integer up to 2 ** 53 - 1 has an exact representation and a distinct neighbour. Past that, the spacing becomes 2, then 4, then 8. 2 ** 53 + 1 rounds back down to 2 ** 53, which is why 2 ** 53 === 2 ** 53 + 1 is true while 2 ** 53 + 2 is a different number. Number.isInteger(2 ** 53) is also true — it is an integer, just not a safe one — so isSafeInteger is the check that means what you want.

The practical damage is in ids. Snowflake ids, database bigints and payment references routinely run to 17 or 19 digits, and every route into JavaScript rounds them: the literal, parseInt, Number, and JSON.parse. Nothing warns. Two different records can arrive with the same id and the bug looks like a duplicate in the database. The fix is at the boundary: send the id as a JSON string, and keep it a string or a BigInt.

BigInt is exact and deliberately unfriendly about it. 1n + 1 is a TypeError rather than a silent widening, and JSON.stringify throws on a BigInt instead of writing a number it cannot read back. Both refusals are on your side. It also means every assertion about a BigInt in this course goes through String, because the test helper compares with JSON.stringify.

89 Factorials, and the three places a double gives out

hard

Write factorial with a loop and again with recursion, then find the last factorial below Number.MAX_SAFE_INTEGER, the last one that is still exactly representable as a double, and the last one that is finite at all. Then write the BigInt version and read 25! back in full.

Stuck? Three hints, smallest first
  1. 1. Two different limits: past MAX_SAFE_INTEGER a value can still be exact if its trailing bits are zero.
  2. 2. A factorial has a lot of factors of 2, which is why exactness survives further than safety does.
  3. 3. BigInt(x) === bigVersion(n) is how you ask whether the double was exact.

The answer

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

const factorialRec = (n) => (n <= 1 ? 1 : n * factorialRec(n - 1));

const factorialBig = (n) => {
  let result = 1n;
  for (let i = 2n; i <= BigInt(n); i++) result *= i;
  return result;
};

// The empty loop returns 1 for any n below 2, including nonsense. Say so.
const factorialGuarded = (n) => (Number.isInteger(n) && n >= 0 ? factorial(n) : NaN);

const exactAt = (n) => BigInt(factorial(n)) === factorialBig(n);

console.log('18!, 19!        :', factorial(18), factorial(19));
console.log('safe?           :', Number.isSafeInteger(factorial(18)), Number.isSafeInteger(factorial(19)));
console.log('exact 19 to 23  :', [19, 20, 21, 22, 23].map(exactAt));
console.log('23! as a double :', String(BigInt(factorial(23))));
console.log('23! really      :', String(factorialBig(23)));
console.log('170!, 171!      :', factorial(170), factorial(171));
console.log('25! in BigInt   :', String(factorialBig(25)));
console.log('unguarded edges :', factorial(-3), factorial(2.5));
console.log('20! / 18!       :', factorial(20) / factorial(18));

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('18! is the last factorial below MAX_SAFE_INTEGER; 19! is already past it', [factorial(18), Number.isSafeInteger(factorial(18)), Number.isSafeInteger(factorial(19))], [6402373705728000, true, false]);
test('19! through 22! are unsafe but still exactly representable', [19, 20, 21, 22].map(exactAt), [true, true, true, true]);
test('23! is the first factorial a double gets wrong', [exactAt(23), String(BigInt(factorial(23))), String(factorialBig(23))], [false, '25852016738884978212864', '25852016738884976640000']);
test('170! is the largest finite one, and 171! is Infinity', [factorial(170), factorial(171) === Infinity], [7.257415615307994e+306, true]);
test('the BigInt version keeps all 26 digits of 25!', String(factorialBig(25)), '15511210043330985984000000');
test('0! and 1! are both 1, which the empty loop gives you for nothing', [factorial(0), factorial(1), String(factorialBig(0))], [1, 1, '1']);
test('but the empty loop also answers -3 and 2.5, so a guard is not optional', [factorial(-3), factorial(2.5), Number.isNaN(factorialGuarded(-3)), Number.isNaN(factorialGuarded(2.5))], [1, 2, true, true]);
test('the recursive version agrees exactly, including where the double is unsafe', [factorialRec(10), factorialRec(20) === factorial(20)], [3628800, true]);
test('and 20! divided by 18! is 380, which is 19 times 20, to the last bit', [factorial(20) / factorial(18), factorial(20) / factorial(18) === 19 * 20], [380, true]);

There are three separate ceilings here and they are usually confused with each other. 18! is the last factorial below Number.MAX_SAFE_INTEGER. 22! is the last one that is still exactly representable as a double — a factorial has a great many factors of 2, so its low bits are already zero and the format has room for them even past the safe range. 170! is the last one that is finite; 171! overflows to Infinity. Between 19! and 22! a double is unsafe and exact at the same time, which is not a contradiction: unsafe means some integer near it is unrepresentable, not that this one is wrong.

23! is where the arithmetic actually breaks, and it breaks quietly. The double reads 25852016738884978212864 against a true value of 25852016738884976640000, so the answer is wrong from the 17th digit with no flag raised anywhere. If a factorial is going anywhere near 20, use BigInt.

The guard is the other lesson. for (let i = 2; i <= n; i++) simply never runs for n = -3, so the function returns 1 — a confident, wrong, positive answer. For 2.5 it runs once and returns 2. Neither input is a factorial, and the loop has no way to say so; only an explicit check does.

90 Permutations and combinations without losing the integer

hard

Compute P(n, r) and C(n, k) without ever building a factorial. Check them against the two numbers everyone knows — 2598960 five-card hands and 13983816 six-from-49 lottery tickets — then show that the factorial route makes C(30, 15) a non-integer, and go to BigInt for C(100, 50).

Stuck? Three hints, smallest first
  1. 1. P(n, r) is n * (n - 1) * ... for r terms. No division needed.
  2. 2. For C(n, k), multiply by one factor and divide by one factor in turn, so the running value stays an integer.
  3. 3. C(n, k) is C(n, n - k), so replace k with the smaller of the two first.

The answer

const add = (a, b) => a + b;

const permutations = (n, r) => {
  let result = 1;
  for (let i = 0; i < r; i++) result *= n - i;
  return result;
};

// Multiply then divide in step: the running value is a binomial coefficient at
// every stage, so it is always an integer and never grows past the answer.
const combinations = (n, k) => {
  const m = Math.min(k, n - k);
  let result = 1;
  for (let i = 1; i <= m; i++) result = (result * (n - m + i)) / i;
  return result;
};

const factorial = (n) => {
  let result = 1;
  for (let i = 2; i <= n; i++) result *= i;
  return result;
};
const combinationsByFactorial = (n, k) => factorial(n) / (factorial(k) * factorial(n - k));

const factorialBig = (n) => {
  let result = 1n;
  for (let i = 2n; i <= n; i++) result *= i;
  return result;
};
const combinationsBig = (n, k) => factorialBig(n) / (factorialBig(k) * factorialBig(n - k));

const pascalRow = (n) => Array.from({ length: n + 1 }, (unused, k) => combinations(n, k));

console.log('P(5, 3), P(30, 5)  :', permutations(5, 3), permutations(30, 5));
console.log('C(5, 3), C(52, 5)  :', combinations(5, 3), combinations(52, 5));
console.log('C(49, 6)           :', combinations(49, 6));
console.log('C(30, 15) two ways :', combinationsByFactorial(30, 15), combinations(30, 15));
console.log('integer?           :', Number.isInteger(combinationsByFactorial(30, 15)), Number.isInteger(combinations(30, 15)));
console.log('C(60, 30) printed  :', combinations(60, 30), 'exact value', String(BigInt(combinations(60, 30))));
console.log('C(100, 50) double  :', String(BigInt(combinations(100, 50))));
console.log('C(100, 50) BigInt  :', String(combinationsBig(100n, 50n)));
console.log('Pascal row 6       :', pascalRow(6), 'sums to', pascalRow(6).reduce(add, 0));

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('P(5, 3) is 60 and P(30, 5) is 17100720, with no division anywhere', [permutations(5, 3), permutations(30, 5)], [60, 17100720]);
test('C(5, 3) is 10, and a five-card hand from 52 has 2598960 possibilities', [combinations(5, 3), combinations(52, 5)], [10, 2598960]);
test('a six-from-49 lottery ticket is one of 13983816', combinations(49, 6), 13983816);
test('the factorial route makes C(30, 15) a non-integer, which cannot be right', [combinationsByFactorial(30, 15), Number.isInteger(combinationsByFactorial(30, 15))], [155117519.99999997, false]);
test('multiplying and dividing in step keeps it exact', [combinations(30, 15), Number.isInteger(combinations(30, 15))], [155117520, true]);
test('C(n, k) equals C(n, n - k), and a Pascal row sums to 2 ** n', [combinations(52, 47) === combinations(52, 5), pascalRow(6), pascalRow(6).reduce(add, 0)], [true, [1, 6, 15, 20, 15, 6, 1], 64]);
test('C(60, 30) prints as ...420 but the double holds ...424, and that is exact', [combinations(60, 30), String(BigInt(combinations(60, 30))), String(combinationsBig(60n, 30n))], [118264581564861420, '118264581564861424', '118264581564861424']);
test('C(100, 50) is past exact, and the double is out by twenty-six thousand million', [BigInt(combinations(100, 50)) === combinationsBig(100n, 50n), String(BigInt(combinations(100, 50)))], [false, '100891344545564219663900999680']);
test('BigInt gets all thirty digits of C(100, 50)', [String(combinationsBig(100n, 50n)), String(combinationsBig(100n, 50n)).length], ['100891344545564193334812497256', 30]);

Never compute n! / (k! * (n - k)!) literally. Both factorials explode long before the answer does, and once they pass the exact range the division cannot undo the rounding — C(30, 15) comes back as 155117519.99999997, a count of things that is not a whole number. The in-step version multiplies by (n - m + i) and divides by i on each pass, and the running value is itself a binomial coefficient every time round, so it is always an integer and never exceeds the final answer.

Replacing k with Math.min(k, n - k) halves the work in the worst case and costs one line. It is free because C(n, k) === C(n, n - k), which is also the symmetry you can see in any row of Pascal's triangle.

C(60, 30) is the subtle one. JavaScript prints it as 118264581564861420, and the exact value is 118264581564861424 — but those are the same double. Above 2 ** 53 the printer emits the shortest decimal that round-trips, and the shortest one here ends in a zero. So an unsafe integer is not automatically a wrong one, and BigInt(x) is the way to ask what a double really holds. At C(100, 50) the double genuinely is wrong, from the eighteenth digit onwards, and only BigInt has the answer.

91 Digit sums, digital roots and casting out nines

easy

Sum the digits of a number two ways, by string and by arithmetic, and find an input where the string version fails. Then reduce a number to a single digit by repeated summing, and replace that loop with a one-line formula.

Stuck? Three hints, smallest first
  1. 1. x % 10 gives the last digit and Math.floor(x / 10) drops it.
  2. 2. Above 1e20 a number no longer prints as a row of digits.
  3. 3. A digit sum leaves the remainder mod 9 unchanged, which is the whole trick.

The answer

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

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

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

// Casting out nines: 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 agreeUpTo = (limit) => {
  for (let n = 0; n <= limit; n++) if (digitalRootByLoop(n) !== digitalRoot(n)) return false;
  return true;
};

const persistence = (n) => {
  let x = Math.abs(n);
  let steps = 0;
  while (x > 9) {
    x = digitSum(x);
    steps++;
  }
  return steps;
};

const reverseDigits = (n) => {
  let x = Math.abs(n);
  let out = 0;
  while (x > 0) {
    out = out * 10 + (x % 10);
    x = Math.floor(x / 10);
  }
  return n < 0 ? -out : out;
};
const isPalindrome = (n) => n >= 0 && reverseDigits(n) === n;

console.log('digit sums       :', [0, 9, 18, 493193, 12345].map(digitSum));
console.log('by string        :', [0, 9, 18, 493193, 12345].map(digitSumByString));
console.log('1e21 prints as   :', String(1e21), '-> by string:', digitSumByString(1e21), 'by arithmetic:', digitSum(1e21));
console.log('roots by loop    :', [0, 9, 18, 493193, 12345, 999999999].map(digitalRootByLoop));
console.log('roots by formula :', [0, 9, 18, 493193, 12345, 999999999].map(digitalRoot));
console.log('27 and 28 mod 9  :', digitalRoot(27), 27 % 9, digitalRoot(28), 28 % 9);
console.log('persistence      :', persistence(9), persistence(199), persistence(999999999999));
console.log('reversed         :', reverseDigits(12345), reverseDigits(-120));
console.log('palindromes      :', [121, 12321, 123, 10].map(isPalindrome));

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 digit sums of 0, 9, 18, 493193 and 12345', [0, 9, 18, 493193, 12345].map(digitSum), [0, 9, 9, 29, 15]);
test('the string version agrees, for numbers that print as a row of digits', [0, 9, 18, 493193, 12345].map(digitSumByString), [0, 9, 9, 29, 15]);
test('1e21 prints in exponential form, so the string version returns NaN', [String(1e21), Number.isNaN(digitSumByString(1e21)), digitSum(1e21)], ['1e+21', true, 1]);
test('the digital root is the digit sum applied until one digit is left', [0, 9, 18, 493193, 12345, 999999999].map(digitalRootByLoop), [0, 9, 9, 2, 6, 9]);
test('and 1 + (n - 1) % 9 gives the same answer for every n up to 2000', agreeUpTo(2000), true);
test('so the root is n mod 9, except that a multiple of 9 has root 9 not 0', [digitalRoot(27), 27 % 9, digitalRoot(28), 28 % 9], [9, 0, 1, 1]);
test('casting out nines: divisibility by 9 shows up in the digit sum', [[126, 999, 1000].map((n) => digitSum(n) % 9 === 0), [126, 999, 1000].map((n) => n % 9 === 0)], [[true, true, false], [true, true, false]]);
test('persistence counts the rounds, and a trillion nines needs only two', [persistence(9), persistence(199), persistence(999999999999)], [0, 3, 2]);
test('reversing drops trailing zeros, so 120 comes back as 21', [reverseDigits(12345), reverseDigits(-120), [121, 12321, 123, 10].map(isPalindrome)], [54321, -21, [true, true, false, false]]);

Two ways to get at digits, and they fail differently. The string version reads well and works up to about 1e20; above that String(1e21) is '1e+21', Number('e') is NaN, and the whole sum becomes NaN. The arithmetic version — x % 10 then Math.floor(x / 10) — has no such cliff, and it is faster because it allocates nothing.

The digital root is a shortcut hiding in plain sight. Summing digits does not change a number's remainder mod 9, because 10 is 1 more than 9 and every power of 10 is therefore 1 mod 9. So repeatedly summing must land on the remainder mod 9 — with the single adjustment that a multiple of 9 lands on 9 rather than 0. 1 + ((n - 1) % 9) encodes that adjustment, and it agrees with the loop for every value tested. Note it needs the n === 0 case handled separately, since 1 + ((-1) % 9) would be 0 by accident rather than by design.

This is the old accountant's check called casting out nines: if the digit sums of two numbers do not agree with the digit sum of their product, the multiplication is wrong. It cannot prove an answer right, since one in nine wrong answers passes, but it is very cheap.

92 Roman numerals, out and back

fair

Convert a number to a Roman numeral and back. Prove the round trip holds for every value from 1 to 3999, then show that your reader happily accepts IIII and IC, and write the validator that catches them.

Stuck? Three hints, smallest first
  1. 1. Put the four subtractive pairs — CM, CD, XC, XL, IX, IV — into the value table and greedy works.
  2. 2. Reading back: a letter smaller than the one after it counts as negative.
  3. 3. To check a numeral is well formed, render its value again and compare the strings.

The answer

const NUMERALS = [
  [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
  [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
  [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];
const PLAIN = [[1000, 'M'], [500, 'D'], [100, 'C'], [50, 'L'], [10, 'X'], [5, 'V'], [1, 'I']];

const greedy = (table) => (n) => {
  let out = '';
  let left = n;
  for (const [value, letters] of table) {
    while (left >= value) {
      out += letters;
      left -= value;
    }
  }
  return out;
};

const toRoman = greedy(NUMERALS);
const toRomanPlain = greedy(PLAIN);

const LETTER = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };

const fromRoman = (s) => {
  let total = 0;
  for (let i = 0; i < s.length; i++) {
    const here = LETTER[s[i]];
    const next = i + 1 < s.length ? LETTER[s[i + 1]] : 0;
    total += here < next ? -here : here;
  }
  return total;
};

const isCanonical = (s) => s.length > 0 && toRoman(fromRoman(s)) === s;

const roundTripHolds = (limit) => {
  for (let n = 1; n <= limit; n++) if (fromRoman(toRoman(n)) !== n) return false;
  return true;
};

const longestUnder4000 = (() => {
  let best = '';
  for (let n = 1; n < 4000; n++) {
    const r = toRoman(n);
    if (r.length > best.length) best = r;
  }
  return best;
})();

console.log('small numbers :', [1, 4, 9, 14, 40, 90, 400].map(toRoman));
console.log('1994, 3999    :', toRoman(1994), toRoman(3999));
console.log('without pairs :', toRomanPlain(9), toRomanPlain(1994));
console.log('read back     :', fromRoman('MCMXCIV'), fromRoman('MMMCMXCIX'));
console.log('nonsense      :', fromRoman('IIII'), fromRoman('IC'));
console.log('canonical?    :', isCanonical('MCMXCIV'), isCanonical('IIII'), isCanonical('IC'));
console.log('zero, negative:', JSON.stringify(toRoman(0)), JSON.stringify(toRoman(-5)));
console.log('longest       :', longestUnder4000, longestUnder4000.length, 'which is', fromRoman(longestUnder4000));

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 small numbers, including the four subtractive pairs', [1, 4, 9, 14, 40, 90, 400].map(toRoman), ['I', 'IV', 'IX', 'XIV', 'XL', 'XC', 'CD']);
test('1994, 2024 and the largest classical numeral', [toRoman(1994), toRoman(2024), toRoman(3999)], ['MCMXCIV', 'MMXXIV', 'MMMCMXCIX']);
test('drop the pairs and greedy still terminates, it just reads like a tally', [toRomanPlain(9), toRomanPlain(1994)], ['VIIII', 'MDCCCCLXXXXIIII']);
test('reading back works by comparing each letter with the one after it', [fromRoman('MCMXCIV'), fromRoman('MMMCMXCIX')], [1994, 3999]);
test('and the round trip holds for every number from 1 to 3999', roundTripHolds(3999), true);
test('but the reader accepts what Rome would not: IIII is 4 and IC is 99', [fromRoman('IIII'), fromRoman('IC')], [4, 99]);
test('so validating means rendering the value again and comparing strings', [isCanonical('MCMXCIV'), isCanonical('IIII'), isCanonical('IC')], [true, false, false]);
test('zero and negatives have no numeral, and the loop returns an empty string', [toRoman(0), toRoman(-5)], ['', '']);
test('the longest numeral below 4000 belongs to 3888, at fifteen letters', [longestUnder4000, longestUnder4000.length, fromRoman(longestUnder4000)], ['MMMDCCCLXXXVIII', 15, 3888]);

Writing a numeral is a greedy algorithm, and greedy is provably correct here for one reason: the subtractive pairs are in the table. With CM, CD, XC, XL, IX and IV listed as values of their own, always taking the largest that fits can never paint you into a corner. Drop them and the algorithm still works, it just produces MDCCCCLXXXXIIII for 1994 — which is a legal tally, and is what the Romans themselves often carved.

Reading a numeral is the mirror image: scan left to right, and any letter smaller than its right-hand neighbour counts negative. Three lines, no table of pairs needed. The catch is that it is a reader, not a parser — it assigns a number to any string of those seven letters, so IIII is 4 and IC is 99. Neither is a well-formed numeral.

The validator is the idea worth keeping beyond Roman numerals. When you have a canonical writer and a lenient reader, write(read(s)) === s is a complete well-formedness check for free, and it stays correct as the writer changes. The same pattern validates ISO dates, normalised URLs and JSON round trips.

93 Mean, median and mode

fair

Compute all three averages of [12, 7, 3, 7, 9, 12, 15, 7]. Handle an even-length median, more than one mode, and an empty array. Then show why sort() with no comparator is the wrong tool, and what it does to the array you passed in.

Stuck? Three hints, smallest first
  1. 1. The default sort compares strings, so 100 comes before 9.
  2. 2. sort sorts in place. Copy first with [...xs].
  3. 3. A dataset can have several modes, or one per value, which means it has none worth reporting.

The answer

const add = (a, b) => a + b;

const mean = (xs) => xs.reduce(add, 0) / xs.length;

const median = (xs) => {
  const sorted = [...xs].sort((a, b) => a - b);
  const mid = sorted.length >> 1;
  return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};

const modes = (xs) => {
  const counts = new Map();
  for (const x of xs) counts.set(x, (counts.get(x) ?? 0) + 1);
  const top = Math.max(...counts.values());
  return [...counts.keys()].filter((k) => counts.get(k) === top).sort((a, b) => a - b);
};

const scores = [12, 7, 3, 7, 9, 12, 15, 7];
const withOutlier = [12, 7, 3, 7, 9, 12, 1500, 7];

// Proof that sort mutates.
const untouched = [10, 9, 100, 1];
const copySorted = [...untouched].sort((a, b) => a - b);
const inPlace = [10, 9, 100, 1];
inPlace.sort((a, b) => a - b);

console.log('mean, median, modes :', mean(scores), median(scores), modes(scores));
console.log('odd length median   :', median([5, 1, 3]));
console.log('two modes           :', modes([1, 2, 2, 3, 3, 4]), 'all distinct:', modes([1, 2, 3]));
console.log('default sort        :', [10, 9, 100, 1].sort());
console.log('after copying       :', untouched, copySorted);
console.log('after sorting in place:', inPlace);
console.log('empty               :', mean([]), median([]));
console.log('one outlier         :', mean(withOutlier), median(withOutlier));
console.log('is the median in the data?', scores.includes(median(scores)));

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 mean is 9, the median 8, and the only mode is 7', [mean(scores), median(scores), modes(scores)], [9, 8, [7]]);
test('an odd-length set has a real middle element', [median([5, 1, 3]), median([5, 1, 3, 9, 2])], [3, 3]);
test('a set can have two modes, or one per value, which means none', [modes([1, 2, 2, 3, 3, 4]), modes([1, 2, 3])], [[2, 3], [1, 2, 3]]);
test('the default sort compares strings, so 100 lands before 9', [[10, 9, 100, 1].sort(), [10, 9, 100, 1].sort((a, b) => a - b)], [[1, 10, 100, 9], [1, 9, 10, 100]]);
test('sort mutates: copying leaves the original alone, sorting in place does not', [untouched, copySorted, inPlace], [[10, 9, 100, 1], [1, 9, 10, 100], [1, 9, 10, 100]]);
test('an empty set gives NaN from both, since 0 / 0 and undefined arithmetic', [Number.isNaN(mean([])), Number.isNaN(median([]))], [true, true]);
test('one outlier moves the mean to 194.625 and leaves the median at 8', [mean(withOutlier), median(withOutlier)], [194.625, 8]);
test('the mean here is exactly 9 because the total 72 divides by 8', [scores.reduce(add, 0), mean(scores) === 9], [72, true]);
test('an even-length median need not be a value that appears in the data', [median(scores), scores.includes(median(scores))], [8, false]);

Two traps, both in sort. Called with no comparator it converts every element to a string and compares those, so [10, 9, 100, 1] becomes [1, 10, 100, 9]. And it sorts in place, so a median function that sorts its argument quietly reorders the caller's array — which is how a "read-only" statistics helper ends up changing the data behind a chart. [...xs].sort((a, b) => a - b) fixes both in one line.

The three averages answer three different questions. The mean uses every value, so a single 1500 in an eight-item set drags it from 9 to 194.625 while the median does not move at all. That robustness is why incomes and house prices are reported as medians. The median of an even-length set is a value that may not appear in the data — 8 is not one of the scores — which matters if you were planning to look it up rather than report it.

Mode is the awkward one, because it is not a function to a single value. [1, 2, 2, 3, 3, 4] has two modes, and [1, 2, 3] has three, which is really a way of saying it has none. Returning an array is the honest signature; returning the first-found maximum hides the ambiguity and makes the answer depend on iteration order.

94 Variance and standard deviation, and the formula that collapses

hard

Compute the population and sample standard deviation of [2, 4, 4, 4, 5, 5, 7, 9]. Then implement the one-pass formula E[x squared] - E[x] squared, add a billion to every value, and see what each version says the variance is now.

Stuck? Three hints, smallest first
  1. 1. Population variance divides by n, sample variance by n - 1.
  2. 2. Adding a constant to every value cannot change the spread. Use that as a test.
  3. 3. The one-pass formula subtracts two huge, nearly equal numbers. That is where it dies.

The answer

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

// Two passes: find the mean, then sum the squared deviations from it.
const populationVariance = (xs) => {
  const m = mean(xs);
  return xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length;
};
const sampleVariance = (xs) => {
  const m = mean(xs);
  return xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1);
};

// One pass, textbook, and dangerous.
const onePassVariance = (xs) => {
  const n = xs.length;
  const sum = xs.reduce(add, 0);
  const sumSquares = xs.reduce((s, x) => s + x * x, 0);
  return sumSquares / n - (sum / n) ** 2;
};

// One pass, done properly: update the mean as you go.
const welfordVariance = (xs) => {
  let n = 0;
  let m = 0;
  let m2 = 0;
  for (const x of xs) {
    n++;
    const delta = x - m;
    m += delta / n;
    m2 += delta * (x - m);
  }
  return m2 / n;
};

const sample = [2, 4, 4, 4, 5, 5, 7, 9];
const shifted = sample.map((x) => x + 1e9);
const scaled = sample.map((x) => x * 10);

console.log('mean                  :', mean(sample));
console.log('population var, sd    :', populationVariance(sample), Math.sqrt(populationVariance(sample)));
console.log('sample var, sd        :', sampleVariance(sample), Math.sqrt(sampleVariance(sample)));
console.log('one pass, small data  :', onePassVariance(sample), 'Welford:', welfordVariance(sample));
console.log('shifted by 1e9, two-pass:', populationVariance(shifted));
console.log('shifted, one pass     :', onePassVariance(shifted), '<- the spread did not change');
console.log('shifted, Welford      :', welfordVariance(shifted));
console.log('scaled by 10          :', populationVariance(scaled));
console.log('a single value        :', populationVariance([5]), sampleVariance([5]));

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 mean is 5, the population variance 4, and the sd exactly 2', [mean(sample), populationVariance(sample), Math.sqrt(populationVariance(sample))], [5, 4, 2]);
test('dividing by n - 1 instead gives 32 / 7 and an sd of 2.138...', [sampleVariance(sample), sampleVariance(sample) === 32 / 7, Math.sqrt(sampleVariance(sample))], [4.571428571428571, true, 2.138089935299395]);
test('the sample figure is always the larger of the two', sampleVariance(sample) > populationVariance(sample), true);
test('on this data the one-pass formula and Welford both agree with two passes', [onePassVariance(sample), welfordVariance(sample)], [4, 4]);
test('adding a billion to every value cannot change the spread, and does not', populationVariance(shifted), 4);
test('but the one-pass formula returns exactly zero, having cancelled itself away', [onePassVariance(shifted), Object.is(onePassVariance(shifted), 0)], [0, true]);
test('Welford survives the shift, though not to the last bit', [welfordVariance(shifted), Math.abs(welfordVariance(shifted) - 4) < 1e-7], [3.999999977648258, true]);
test('scaling by 10 multiplies the variance by 100 and the sd by 10', [populationVariance(scaled), Math.sqrt(populationVariance(scaled))], [400, 20]);
test('one value has population variance 0 and no sample variance at all', [populationVariance([5]), Number.isNaN(sampleVariance([5]))], [0, true]);

E[x squared] - E[x] squared is algebraically identical to the two-pass formula and it is the version in most textbooks, because it needs one pass and two running totals. On this data it gives 4, same as everything else. Add a billion to every value — which changes nothing about the spread — and it returns exactly 0. The mean of the squares is about 1.000000010000000e+18 and the square of the mean is about the same, and subtracting two numbers that agree in their first sixteen digits leaves nothing but rounding noise. This is catastrophic cancellation, and it is the single most common numerical bug in real analytics code.

Welford's method is the one-pass version that works: keep a running mean and a running sum of squared deviations from that mean, and never form a large intermediate. It gives 3.999999977648258 on the shifted data — not exact, but wrong in the eighth digit rather than being wrong by 100%. Two passes over an array you already hold in memory is exact and simpler; Welford is for streams you only see once.

The n against n - 1 choice is statistics, not arithmetic. Divide by n when the numbers are the whole population; divide by n - 1 when they are a sample and you are estimating a wider population's spread, because the sample mean sits closer to the sample than the true mean does and the correction compensates. It follows that sample variance is always the larger, and that a single observation has no sample variance at all — 0 / 0 is NaN, which is the right answer rather than an error.

95 Compound interest, and where the kobo goes

fair

Take a deposit of 250,000 naira at 12% a year for five years. Give the simple-interest total, then compound annually, monthly, daily and continuously. Then hold the balance in integer kobo, compound it month by month rounding each time, and compare with the closed-form answer.

Stuck? Three hints, smallest first
  1. 1. The closed form is P * (1 + r / n) ** (n * t), with n compounding periods a year.
  2. 2. The continuous limit is P * Math.exp(r * t), and nothing discrete can beat it.
  3. 3. Rounding to whole kobo every month is not the same as rounding once at the end.

The answer

const compound = (principal, rate, perYear, years) =>
  principal * (1 + rate / perYear) ** (perYear * years);

const simple = (principal, rate, years) => principal * (1 + rate * years);
const continuous = (principal, rate, years) => principal * Math.exp(rate * years);

// Effective annual rate: what 12% nominal, compounded monthly, really pays.
const effectiveAnnual = (rate, perYear) => (1 + rate / perYear) ** perYear - 1;

// The same five years in integer kobo, rounded to a whole kobo every month.
const monthByMonthKobo = (startKobo, rate, months) => {
  let balance = startKobo;
  for (let m = 0; m < months; m++) balance = Math.round(balance * (1 + rate / 12));
  return balance;
};
const closedFormKobo = (startKobo, rate, months) =>
  Math.round(startKobo * (1 + rate / 12) ** months);

const naira = 250000;
const kobo = naira * 100;
const rate = 0.12;

console.log('simple, 5 years   :', simple(naira, rate, 5));
console.log('annual            :', compound(naira, rate, 1, 5), '->', compound(naira, rate, 1, 5).toFixed(2));
console.log('monthly           :', compound(naira, rate, 12, 5), '->', compound(naira, rate, 12, 5).toFixed(2));
console.log('daily             :', compound(naira, rate, 365, 5));
console.log('continuous        :', continuous(naira, rate, 5));
console.log('effective annual  :', effectiveAnnual(rate, 12));
console.log('kobo, closed form :', closedFormKobo(kobo, rate, 60));
console.log('kobo, month by month:', monthByMonthKobo(kobo, rate, 60));
console.log('difference        :', monthByMonthKobo(kobo, rate, 60) - closedFormKobo(kobo, rate, 60), 'kobo');
console.log('doubling time     :', Math.log(2) / Math.log(1 + rate), 'rule of 72 says', 72 / 12);

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('simple interest gives exactly 400000 naira, with no rounding anywhere', simple(naira, rate, 5), 400000);
test('compounding once a year gives 440585.4208000002, which is 440585.42', [compound(naira, rate, 1, 5), compound(naira, rate, 1, 5).toFixed(2)], [440585.4208000002, '440585.42']);
test('monthly beats annual, daily beats monthly, and none of them beat continuous', [compound(naira, rate, 12, 5), compound(naira, rate, 365, 5) > compound(naira, rate, 12, 5), continuous(naira, rate, 5) > compound(naira, rate, 365, 5)], [454174.1746410228, true, true]);
test('12% nominal compounded monthly is really 12.6825% a year', Number(effectiveAnnual(rate, 12).toFixed(6)), 0.126825);
test('in kobo, the closed form comes to 45417417 kobo', closedFormKobo(kobo, rate, 60), 45417417);
test('rounding every month instead gives 45417418, one kobo more', [monthByMonthKobo(kobo, rate, 60), monthByMonthKobo(kobo, rate, 60) - closedFormKobo(kobo, rate, 60)], [45417418, 1]);
test('both are whole kobo, which is the only property a ledger cares about', [Number.isInteger(closedFormKobo(kobo, rate, 60)), Number.isInteger(monthByMonthKobo(kobo, rate, 60))], [true, true]);
test('at 12% the balance doubles in 6.12 years, near enough the rule of 72', [Number((Math.log(2) / Math.log(1 + rate)).toFixed(6)), 72 / 12], [6.116255, 6]);
test('and compounding annually beats simple interest by over 40000 naira', Math.round(compound(naira, rate, 1, 5) - simple(naira, rate, 5)), 40585);

The shape of the formula is the lesson: P * (1 + r / n) ** (n * t). Raise the number of compounding periods and the total rises, but with sharply diminishing returns — annual 440,585.42, monthly 454,174.17, daily 455,484.78, and continuous 455,529.70, which is the ceiling that P * e ** (r * t) defines. A bank quoting "12% a year, compounded monthly" is paying an effective 12.6825%, and the effective annual rate is the only number worth comparing between products.

The kobo part is the part that causes arguments. Rounding to a whole kobo every month gives 45,417,418 kobo; applying the closed form once and rounding at the end gives 45,417,417. One kobo. Neither is a bug — they are two different products, and which one you implement is a policy decision that belongs in writing, not in whichever line of code happened to get written first. Real ledgers round every period, because each period's interest is actually credited.

Note that the money is held as integer kobo, not as a decimal number of naira. That is the rule from exercise 15 and it does not stop applying just because the arithmetic got more interesting: keep the smallest unit as an integer, round explicitly at defined moments, and never let a fractional kobo exist between two lines of code.

96 Percentage change, and why up then down does not come back

fair

Write percentChange(from, to) and use it on 4000 to 5000 and back. Show that a 20% rise followed by a 20% fall does not return to the start, work out what fall does undo a 25% rise, and say what your function returns when the starting value is zero or negative.

Stuck? Three hints, smallest first
  1. 1. The change is (to - from) / from, and the denominator is where the trouble is.
  2. 2. To undo a rise of p per cent you need a fall of p / (100 + p) of the new value.
  3. 3. A negative base flips the sign of the answer, so a rise can be reported as a fall.

The answer

const percentChange = (from, to) => ((to - from) / from) * 100;

// The fall that exactly undoes a given rise, as a percentage.
const reverseChange = (p) => 100 / (1 + p / 100) - 100;

// VAT in Nigeria is 7.5%. Held in kobo, it stays an integer.
const vatKobo = (k) => Math.round(k * 0.075);

console.log('4000 -> 5000, and back:', percentChange(4000, 5000), percentChange(5000, 4000));
console.log('up 20 then down 20    :', 4000 * 1.2 * 0.8, 'started at 4000');
console.log('to undo +25 you need  :', reverseChange(25), 'and to undo -20:', reverseChange(-20));
console.log('two 10% rises         :', 1.1 * 1.1, 'against 1.21');
console.log('20% off then 10% off  :', 1 - 0.8 * 0.9, 'against 0.3');
console.log('VAT on 12500 naira    :', 12500 * 0.075, 'total', 12500 * 1.075);
console.log('backing VAT out again :', 13437.5 / 1.075);
console.log('from zero             :', percentChange(0, 5), 'and zero to zero:', percentChange(0, 0));
console.log('from -100 to -50      :', percentChange(-100, -50), '<- that is a rise');
console.log('VAT in kobo           :', vatKobo(1250000), 'total', 1250000 + vatKobo(1250000));

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('4000 up to 5000 is a 25% rise, and back down is a 20% fall', [percentChange(4000, 5000), percentChange(5000, 4000)], [25, -20]);
test('so 20% up then 20% down lands on 3840, not 4000, and exactly so', [4000 * 1.2 * 0.8, 4000 * 1.2 * 0.8 === 3840], [3840, true]);
test('the fall that undoes a 25% rise is 20%, and the rise that undoes it is 25%', [reverseChange(25), reverseChange(-20)], [-20, 25]);
test('two 10% rises are not one 21% rise once doubles are involved', [1.1 * 1.1, 1.1 * 1.1 === 1.21], [1.2100000000000002, false]);
test('and 20% off then 10% off is 28% off, which is not 0.28 either', [1 - 0.8 * 0.9, 1 - 0.8 * 0.9 === 0.28], [0.2799999999999999, false]);
test('7.5% VAT on 12500 naira is 937.5, and dividing by 1.075 gets it back exactly', [12500 * 0.075, 12500 * 1.075, 13437.5 / 1.075], [937.5, 13437.5, 12500]);
test('a base of zero gives Infinity, and zero to zero gives NaN', [percentChange(0, 5) === Infinity, Number.isNaN(percentChange(0, 0))], [true, true]);
test('a negative base inverts the reading: -100 rising to -50 reports as -50%', percentChange(-100, -50), -50);
test('in kobo the VAT is a whole number and the total never grows a fraction', [vatKobo(1250000), 1250000 + vatKobo(1250000), Number.isInteger(vatKobo(1250000))], [93750, 1343750, true]);

Percentages are not additive and they are not symmetric, because each one is measured against a different base. 4000 to 5000 is +25%, 5000 back to 4000 is -20%, and the pair of them is a round trip. A 20% rise then a 20% fall leaves you at 96% of where you started — 3840 on 4000, and that particular arithmetic happens to be exact in doubles. Chained discounts work the same way: 20% off then 10% off is 28% off, never 30%.

The float behaviour is worth checking rather than assuming. 4000 * 1.2 * 0.8 === 3840 is true. 1.1 * 1.1 === 1.21 is false. Same kind of expression, opposite outcome, and there is no rule of thumb that predicts which — only running it tells you. Which is the argument for keeping money in integer kobo: vatKobo returns a whole number and the total cannot drift.

The denominator is the real hazard. A base of zero gives Infinity, and zero to zero gives NaN; neither throws, so both flow into a dashboard as a blank or a nonsense figure. A negative base is worse, because it produces a plausible-looking number with the wrong sign: a loss shrinking from -100 to -50 is unambiguously an improvement, and the formula reports -50%. Any percentage-change code that can see negative or zero bases needs to say what it means, in words, before it computes anything.

97 Heron's formula, and the triangle that breaks it

hard

Compute a triangle's area from its three sides with Heron's formula. Check it on 3-4-5 and 13-14-15, then try the sides 100000, 99999.99979 and 0.00029 — a needle — and compare Heron with Kahan's rearrangement of the same formula. Say what each returns for a degenerate and an impossible triangle.

Stuck? Three hints, smallest first
  1. 1. Heron: with s the half-perimeter, the area is sqrt(s (s-a) (s-b) (s-c)).
  2. 2. For a needle, s - a is a tiny difference of two nearly equal numbers. That is where the digits go.
  3. 3. Kahan sorts the sides descending and groups them as (a+(b+c))(c-(a-b))(c+(a-b))(a+(b-c)), divided by 4.

The answer

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

// Kahan's rearrangement. Same algebra, different grouping, and the sort matters.
const kahan = (a, b, c) => {
  const [x, y, z] = [a, b, c].slice().sort((p, q) => q - p);
  return Math.sqrt((x + (y + z)) * (z - (x - y)) * (z + (x - y)) * (x + (y - z))) / 4;
};

const isTriangle = (a, b, c) =>
  a > 0 && b > 0 && c > 0 && a + b > c && a + c > b && b + c > a;

const needle = [100000, 99999.99979, 0.00029];

// Computed offline in exact rational arithmetic, for the triangle whose sides
// are the doubles nearest those three decimals: 10.0000000770210383232996...
const exactNeedleArea = 10.000000077021038;

console.log('3-4-5      :', heron(3, 4, 5), kahan(3, 4, 5));
console.log('13-14-15   :', heron(13, 14, 15), kahan(13, 14, 15));
console.log('equilateral:', heron(2, 2, 2), 'against sqrt(3)', Math.sqrt(3));
console.log('needle, Heron:', heron(needle[0], needle[1], needle[2]));
console.log('needle, Kahan:', kahan(needle[0], needle[1], needle[2]));
console.log('the exact area:', exactNeedleArea);
console.log('Heron is out by:', Math.abs(heron(needle[0], needle[1], needle[2]) - exactNeedleArea));
console.log('degenerate 1-2-3:', heron(1, 2, 3), kahan(1, 2, 3));
console.log('impossible 1-2-10:', heron(1, 2, 10), kahan(1, 2, 10));
console.log('valid?     :', isTriangle(3, 4, 5), isTriangle(1, 2, 3), isTriangle(1, 2, 10));

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 3-4-5 triangle has area 6 and the 13-14-15 has area 84', [heron(3, 4, 5), heron(13, 14, 15)], [6, 84]);
test('an equilateral triangle of side 2 has area sqrt(3), to the last bit', [heron(2, 2, 2), heron(2, 2, 2) === Math.sqrt(3)], [1.7320508075688772, true]);
test('Kahan agrees on every well-shaped triangle, so it costs nothing to use', [kahan(3, 4, 5), kahan(13, 14, 15), kahan(2, 2, 2) === heron(2, 2, 2)], [6, 84, true]);
test('on the needle the two disagree from the eighth significant digit', [heron(needle[0], needle[1], needle[2]), kahan(needle[0], needle[1], needle[2])], [9.999999809638329, 10.000000077021038]);
test('and Kahan is the one telling the truth, exactly', kahan(needle[0], needle[1], needle[2]) === exactNeedleArea, true);
test('Heron is out by 2.67e-7, which is a relative error of 2.7e-8', [Math.abs(heron(needle[0], needle[1], needle[2]) - exactNeedleArea) > 1e-7, Math.abs(kahan(needle[0], needle[1], needle[2]) - exactNeedleArea) === 0], [true, true]);
test('a degenerate triangle, where two sides just meet the third, has area 0', [heron(1, 2, 3), kahan(1, 2, 3)], [0, 0]);
test('an impossible triangle gives NaN from both, since the product goes negative', [Number.isNaN(heron(1, 2, 10)), Number.isNaN(kahan(1, 2, 10))], [true, true]);
test('so validity is a separate check, and the needle does pass it', [isTriangle(3, 4, 5), isTriangle(1, 2, 3), isTriangle(1, 2, 10), isTriangle(needle[0], needle[1], needle[2])], [true, false, false, true]);

Heron's formula is exact algebra and unstable arithmetic. For a needle triangle — one very long side and two others that nearly equal it — the half-perimeter s is almost exactly a, so s - a is a difference of two numbers that agree in their first eleven digits. Most of the significant digits cancel away, and the survivors are then multiplied and square-rooted. Heron returns 9.999999809638329 where the true area is 10.000000077021038: about eight good digits lost out of sixteen.

Kahan's version computes the same product, grouped so that each factor is formed from small differences of similar magnitude rather than from one catastrophic subtraction. It returns the correctly rounded answer, bit for bit. The descending sort is not decoration — the grouping (z - (x - y)) only behaves if x is the longest side. And on well-shaped triangles both formulas agree exactly, so there is no reason to keep the fragile one.

The edges are worth knowing too. A degenerate triangle gives exactly 0 from both, because one factor is exactly 0. An impossible one gives NaN, since a negative product goes into Math.sqrt. That NaN is not a substitute for validation: it arrives silently, propagates through every later sum, and by the time it surfaces you have lost the input that caused it. Check the triangle inequality first, and note that the needle passes — it is a real triangle, just a hard one.

98 The shoelace formula, and what its sign is for

fair

Find the area of a polygon from its vertices with the shoelace formula. Do a square, a triangle, an L-shape and a five-sided figure, and say what the sign of the result tells you. Then translate one polygon a long way from the origin and see whether the area survives.

Stuck? Three hints, smallest first
  1. 1. Sum x1 * y2 - x2 * y1 over every edge, wrapping the last vertex to the first, and halve it.
  2. 2. Reverse the vertex order and the answer changes sign but not magnitude.
  3. 3. Every term is a product of coordinates, so shifting the polygon makes those products huge and nearly equal.

The answer

const shoelace = (points) => {
  let sum = 0;
  for (let i = 0; i < points.length; i++) {
    const [x1, y1] = points[i];
    const [x2, y2] = points[(i + 1) % points.length];
    sum += x1 * y2 - x2 * y1;
  }
  return sum / 2;
};

const area = (points) => Math.abs(shoelace(points));
const isCounterClockwise = (points) => shoelace(points) > 0;
const translate = (points, dx, dy) => points.map(([x, y]) => [x + dx, y + dy]);
const perimeter = (points) =>
  points.reduce((s, p, i) => {
    const q = points[(i + 1) % points.length];
    return s + Math.hypot(q[0] - p[0], q[1] - p[1]);
  }, 0);

const square = [[0, 0], [4, 0], [4, 4], [0, 4]];
const triangle = [[0, 0], [4, 0], [0, 3]];
const lShape = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 5], [0, 5]];
const pentagon = [[3, 4], [5, 11], [12, 8], [9, 5], [5, 6]];
const hexagon = Array.from({ length: 6 }, (unused, i) => [
  Math.cos((i * Math.PI) / 3),
  Math.sin((i * Math.PI) / 3),
]);

console.log('square, both windings:', shoelace(square), shoelace([...square].reverse()));
console.log('triangle             :', shoelace(triangle), 'base times height over 2 =', (4 * 3) / 2);
console.log('L-shape              :', shoelace(lShape), 'perimeter', perimeter(lShape));
console.log('pentagon             :', shoelace(pentagon), '<- negative, so clockwise');
console.log('unit hexagon         :', shoelace(hexagon), 'against 3 root 3 over 2 =', (3 * Math.sqrt(3)) / 2);
console.log('repeating the first point:', shoelace([...square, [0, 0]]));
console.log('three collinear points   :', shoelace([[0, 0], [1, 1], [2, 2]]));
console.log('pentagon moved by 1000   :', shoelace(translate(pentagon, 1000, 1000)));
console.log('pentagon moved by 1e9    :', shoelace(translate(pentagon, 1e9, 1e9)), '<- was -30');

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('a 4 by 4 square has signed area 16 one way round and -16 the other', [shoelace(square), shoelace([...square].reverse())], [16, -16]);
test('so the sign is the winding direction and the magnitude is the area', [isCounterClockwise(square), isCounterClockwise(pentagon), area(pentagon)], [true, false, 30]);
test('the triangle agrees with base times height over two', [shoelace(triangle), shoelace(triangle) === (4 * 3) / 2], [6, true]);
test('a concave L-shape needs no decomposition: the formula just works', [shoelace(lShape), perimeter(lShape)], [18, 22]);
test('a unit-radius hexagon comes out at 3 root 3 over 2, exactly', [shoelace(hexagon), shoelace(hexagon) === (3 * Math.sqrt(3)) / 2], [2.598076211353316, true]);
test('repeating the first vertex at the end adds a zero term and changes nothing', shoelace([...square, [0, 0]]), 16);
test('three collinear points enclose nothing, and give a clean zero', [shoelace([[0, 0], [1, 1], [2, 2]]), Object.is(shoelace([[0, 0], [1, 1], [2, 2]]), 0)], [0, true]);
test('moving the pentagon a thousand units away leaves the area untouched', shoelace(translate(pentagon, 1000, 1000)), -30);
test('moving it a billion units away destroys it: -30 becomes -128', shoelace(translate(pentagon, 1e9, 1e9)), -128);

The shoelace formula is the cross-product idea from exercise 79 summed round a loop, and it has two properties worth more than the area itself. It handles concave polygons with no special casing — the L-shape needs no decomposition into rectangles. And its sign is the winding direction: positive for counter-clockwise, negative for clockwise. That sign is how you normalise polygon orientation, decide which side of an edge is inside, and detect a self-inverted ring in geographic data.

Because the wrap uses (i + 1) % points.length, an explicitly closed ring with the first vertex repeated at the end contributes an extra zero term and the answer is unchanged. That is worth knowing, because GeoJSON rings are closed and most textbook examples are not.

The last assertion is the one to remember. Every term is x1 * y2 - x2 * y1, so translating the polygon far from the origin makes each product enormous while their difference stays small — cancellation again, the same failure as the one-pass variance in exercise 94. A pentagon of area 30 sitting at a billion units out computes as 128. Real coordinate data lives in exactly that regime: UTM eastings and web-mercator metres are seven and eight digits. The fix is one line — subtract the first vertex from all of them before summing, so the arithmetic happens near the origin.

99 Unit conversion, and which round trips survive

easy

Convert kilometres to miles, Celsius to Fahrenheit and kilograms to pounds, each with its exact defined factor. Then convert back and count how many values come home unchanged. Find the temperature that is the same on both scales.

Stuck? Three hints, smallest first
  1. 1. A mile is exactly 1.609344 km and a pound is exactly 0.45359237 kg. Both are definitions.
  2. 2. c * 9 / 5 + 32 going out, (f - 32) * 5 / 9 coming back.
  3. 3. Dividing and then multiplying by the same factor is two roundings, and they do not always cancel.

The answer

const MILE_IN_KM = 1.609344;         // exact, by international agreement
const POUND_IN_KG = 0.45359237;      // also exact

const kmToMiles = (km) => km / MILE_IN_KM;
const milesToKm = (mi) => mi * MILE_IN_KM;
const kgToPounds = (kg) => kg / POUND_IN_KG;
const cToF = (c) => (c * 9) / 5 + 32;
const fToC = (f) => ((f - 32) * 5) / 9;
const cToK = (c) => c + 273.15;
const kToC = (k) => k - 273.15;

const failingCelsius = (lo, hi) => {
  const out = [];
  for (let c = lo; c <= hi; c++) if (fToC(cToF(c)) !== c) out.push(c);
  return out;
};

console.log('one mile          :', milesToKm(1), 'km, exactly');
console.log('100 km            :', kmToMiles(100), 'miles, back to', milesToKm(kmToMiles(100)));
console.log('7 km              :', kmToMiles(7), 'miles, back to', milesToKm(kmToMiles(7)));
console.log('a marathon        :', kmToMiles(42.195), 'miles');
console.log('body temperature  :', cToF(37), 'F, and back', fToC(98.6));
console.log('the crossing point:', cToF(-40));
console.log('0.1 C out and back:', fToC(cToF(0.1)));
console.log('integer C failures:', failingCelsius(-50, 50));
console.log('kelvin round trip :', kToC(cToK(25)), kToC(cToK(25)) === 25);
console.log('70 kg             :', kgToPounds(70), 'pounds');

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('a mile is exactly 1.609344 km, because that is how a mile is defined', [milesToKm(1), milesToKm(1) === MILE_IN_KM], [1.609344, true]);
test('100 km is 62.13711922373339 miles, and that value does round trip', [kmToMiles(100), milesToKm(kmToMiles(100)) === 100], [62.13711922373339, true]);
test('7 km does not: it comes back as 6.999999999999999', [milesToKm(kmToMiles(7)), milesToKm(kmToMiles(7)) === 7], [6.999999999999999, false]);
test('a marathon is 26.218757456454306 miles, and that one survives the trip', [kmToMiles(42.195), milesToKm(kmToMiles(42.195)) === 42.195], [26.218757456454306, true]);
test('the two temperature scales cross at -40, and exactly there', [cToF(-40), cToF(-40) === -40], [-40, true]);
test('37 Celsius is 98.6 Fahrenheit and 98.6 is 37, both exactly', [cToF(37), fToC(98.6)], [98.6, 37]);
test('but 0.1 Celsius comes home as 0.09999999999999984', [fToC(cToF(0.1)), fToC(cToF(0.1)) === 0.1], [0.09999999999999984, false]);
test('18 of the 101 integer Celsius values from -50 to 50 fail the round trip', [failingCelsius(-50, 50).length, failingCelsius(-50, 50).slice(0, 5)], [18, [-7, -3, -2, -1, 1]]);
test('kelvin is a pure offset so it round trips, and 70 kg is 154.32 pounds', [kToC(cToK(25)) === 25, Number(kgToPounds(70).toFixed(2))], [true, 154.32]);

Use the defined factors. A mile is exactly 1.609344 km and a pound is exactly 0.45359237 kg — those are definitions agreed in 1959, not measurements, so 1.6 and 2.2 are not approximations of anything, they are just wrong. Writing the exact value costs nothing and removes a whole class of "why is my total off by 60 metres" question.

Even with exact factors, a conversion followed by its inverse is two roundings and they do not always cancel. 100 km and a full marathon both come home unchanged; 7 km comes back as 6.999999999999999. Eighteen of the 101 integer Celsius values between -50 and 50 fail the same way, and 0.1 C returns as 0.09999999999999984. There is no pattern you can reason about in advance — which is why a unit-conversion test suite should compare with a tolerance, or round to the precision the domain actually uses, and never with ===.

Two nice edges. -40 is the fixed point of the Celsius-Fahrenheit pair, and it lands exactly, because -40 * 9 / 5 is exact and so is the rest. And Kelvin is a pure offset rather than a scaling, so c + 273.15 - 273.15 returns the original: addition and subtraction of the same constant undo each other far more reliably than multiplication and division do.

100 Prime factorisation, and what the exponents tell you

fair

Factorise a number by trial division, group the result into base-and-exponent pairs, and use those exponents to count the divisors and total them without listing any. Then check the hardest small case for trial division: the square of a four-digit prime.

Stuck? Three hints, smallest first
  1. 1. Divide out each candidate completely before moving on, so every factor you push is prime.
  2. 2. Stop when d * d > x. Whatever is left above 1 is the final prime factor.
  3. 3. From 2**3 * 3**2 * 5 the divisor count is 4 * 3 * 2.

The answer

const add = (a, b) => a + b;

const factorise = (n) => {
  const factors = [];
  let x = n;
  for (let d = 2; d * d <= x; d++) {
    while (x % d === 0) {
      factors.push(d);
      x /= d;
    }
  }
  if (x > 1) factors.push(x);
  return factors;
};

const grouped = (n) => {
  const pairs = [];
  for (const p of factorise(n)) {
    const last = pairs[pairs.length - 1];
    if (last && last[0] === p) last[1]++;
    else pairs.push([p, 1]);
  }
  return pairs;
};

// Both of these read the answer off the exponents, listing nothing.
const divisorCount = (n) => grouped(n).reduce((c, pair) => c * (pair[1] + 1), 1);
const divisorSum = (n) =>
  grouped(n).reduce((s, pair) => s * ((pair[0] ** (pair[1] + 1) - 1) / (pair[0] - 1)), 1);

const divisors = (n) => {
  const out = [];
  for (let d = 1; d * d <= n; d++) {
    if (n % d === 0) {
      out.push(d);
      if (d !== n / d) out.push(n / d);
    }
  }
  return out.sort((a, b) => a - b);
};

const rebuilds = (n) => factorise(n).reduce((p, q) => p * q, 1) === n;

console.log('360        :', factorise(360));
console.log('97, 1, 2   :', factorise(97), factorise(1), factorise(2));
console.log('1024, 13195:', factorise(1024).length, 'twos and', factorise(13195));
console.log('grouped    :', grouped(360), grouped(5040));
console.log('9973 * 9973:', factorise(9973 * 9973), 'which is', 9973 * 9973);
console.log('divisors of 360 :', divisors(360));
console.log('count, from pairs:', divisorCount(360), 'from the list:', divisors(360).length);
console.log('sum, from pairs  :', divisorSum(360), 'from the list:', divisors(360).reduce(add, 0));
console.log('zero and negative:', factorise(0), factorise(-12), '<- the loop never starts');

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('360 is three twos, two threes and a five', factorise(360), [2, 2, 2, 3, 3, 5]);
test('a prime factorises to itself, and 1 to an empty list', [factorise(97), factorise(1), factorise(2)], [[97], [], [2]]);
test('1024 is ten twos, and 13195 is 5 times 7 times 13 times 29', [factorise(1024).length, factorise(13195)], [10, [5, 7, 13, 29]]);
test('grouping gives base and exponent pairs', [grouped(360), grouped(5040)], [[[2, 3], [3, 2], [5, 1]], [[2, 4], [3, 2], [5, 1], [7, 1]]]);
test('the factors multiply back to the number every time', [360, 97, 1024, 13195, 9973 * 9973].map(rebuilds), [true, true, true, true, true]);
test('the square of a four-digit prime is the slowest small case, and still works', [factorise(9973 * 9973), 9973 * 9973], [[9973, 9973], 99460729]);
test('360 has 24 divisors, which the exponents give without listing any', [divisorCount(360), divisors(360).length], [24, 24]);
test('and they sum to 1170, again read straight off the exponents', [divisorSum(360), divisors(360).reduce(add, 0)], [1170, 1170]);
test('exactly half the divisors are at or below the square root, which is why trial division stops there', [divisors(360).filter((d) => d * d <= 360).length, divisors(360).length / 2], [12, 12]);

Trial division is correct here for a reason that is easy to miss: because each candidate d is divided out completely before d increases, any d that still divides x must be prime — all its smaller prime factors are already gone. So there is no need to test primality inside the loop.

The stopping condition d * d <= x uses the shrinking x, not the original n, which is what makes it fast: after dividing out the small primes the bound collapses. And it must be paired with the if (x > 1) line afterwards, because the last remaining factor is above the square root of what is left. Drop that line and every prime returns an empty list.

The exponents are worth more than the factor list. A number p**a * q**b has (a + 1) * (b + 1) divisors, because a divisor picks an independent exponent for each prime, and their total is the product of the geometric sums. Both are computed here from three pairs rather than by enumerating 24 divisors — the same reasoning scales to numbers far too large to list. Note the guard that is missing: 0 and negatives fall straight through and return an empty list, which is the loop being honest about a question it was never asked.

101 Perfect numbers, and the pairs that point at each other

fair

Sum the proper divisors of a number and use that to classify it as perfect, abundant or deficient. Find every perfect number below ten thousand, then find the amicable pairs below 1300 — two numbers where each is the sum of the other's proper divisors.

Stuck? Three hints, smallest first
  1. 1. Proper divisors exclude the number itself, so start the total at 1 and skip the pair n / 1.
  2. 2. Loop d while d * d <= n and add both d and n / d, minding the case where they are equal.
  3. 3. Euclid: 2 ** (p - 1) * (2 ** p - 1) is perfect whenever the second factor is prime.

The answer

// Sum of proper divisors. Starts at 1 and never adds n itself.
const aliquotSum = (n) => {
  if (n < 2) return 0;
  let total = 1;
  for (let d = 2; d * d <= n; d++) {
    if (n % d === 0) {
      total += d;
      const pair = n / d;
      if (pair !== d) total += pair;
    }
  }
  return total;
};

const classify = (n) => {
  const s = aliquotSum(n);
  return s === n ? 'perfect' : s > n ? 'abundant' : 'deficient';
};

const isAmicablePair = (a, b) => a !== b && aliquotSum(a) === b && aliquotSum(b) === a;

const perfectUpTo = (limit) => {
  const out = [];
  for (let n = 2; n <= limit; n++) if (aliquotSum(n) === n) out.push(n);
  return out;
};

const amicableUnder = (limit) => {
  const out = [];
  for (let a = 2; a < limit; a++) {
    const b = aliquotSum(a);
    if (b > a && b < limit && aliquotSum(b) === a) out.push([a, b]);
  }
  return out;
};

const euclidPerfect = (p) => 2 ** (p - 1) * (2 ** p - 1);

// Computed once: each of these walks a lot of divisors.
const perfects = perfectUpTo(10000);
const amicable = amicableUnder(1300);

console.log('aliquot sums   :', [1, 6, 8, 12, 28, 97].map(aliquotSum));
console.log('classified     :', [1, 6, 8, 12, 28, 97].map(classify));
console.log('perfect <= 1e4 :', perfects);
console.log('220 and 284    :', aliquotSum(220), aliquotSum(284));
console.log('amicable < 1300:', amicable);
console.log('Euclid-Euler   :', [2, 3, 5, 7, 13].map(euclidPerfect));
console.log('the fifth one  :', euclidPerfect(13), 'is perfect:', aliquotSum(33550336) === 33550336);

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('there are four perfect numbers below ten thousand', perfects, [6, 28, 496, 8128]);
test('each one is the sum of its own proper divisors, which is the definition', [6, 28, 496, 8128].map(aliquotSum), [6, 28, 496, 8128]);
test('12 is abundant, 8 is deficient, and a prime is the most deficient there is', [aliquotSum(12), aliquotSum(8), aliquotSum(97), [12, 8, 97].map(classify)], [16, 7, 1, ['abundant', 'deficient', 'deficient']]);
test('220 and 284 each sum to the other, which is what amicable means', [aliquotSum(220), aliquotSum(284)], [284, 220]);
test('and they are the smallest such pair, with 1184 and 1210 next', amicable, [[220, 284], [1184, 1210]]);
test('Euclid and Euler build perfect numbers from Mersenne primes', [2, 3, 5, 7].map(euclidPerfect), [6, 28, 496, 8128]);
test('p = 13 gives the fifth one at once, and it checks out', [euclidPerfect(13), aliquotSum(33550336) === 33550336], [33550336, true]);
test('a perfect number is not amicable with itself: the pair must be distinct', [isAmicablePair(6, 6), isAmicablePair(220, 284)], [false, true]);
test('1 has no proper divisors at all, so the guard has to return 0', [aliquotSum(1), classify(1)], [0, 'deficient']);

aliquotSum is the divisor loop from exercise 100 with two adjustments, and both are easy to get wrong. It starts the total at 1 and begins d at 2, because the pair (1, n) would otherwise contribute n itself. And when d * d === n the two halves of the pair are the same number, so adding both would double-count the square root — that is the if (pair !== d) line, and without it every perfect square is misclassified.

Searching to ten thousand finds exactly four perfect numbers: 6, 28, 496, 8128. That is not a coincidence of the range — they thin out savagely. Euclid proved that 2 ** (p - 1) * (2 ** p - 1) is perfect whenever 2 ** p - 1 is prime, and Euler proved every even perfect number has that form. So finding the next one means finding the next Mersenne prime, which is why only 52 are known. Whether an odd perfect number exists is still open after two thousand years.

Amicable pairs are the same idea applied to two numbers. The search has one detail worth noting: the b > a test both halves the work and stops each pair being reported twice, and it also excludes a === b, which would be a perfect number rather than an amicable pair. Naming that distinction in the predicate is cheaper than explaining it later.

102 The Collatz chain, and what a step cap is for

easy

Build the Collatz sequence for a number: halve it if even, otherwise triple it and add one, until it reaches 1. Report the chain for 6, the step count and peak for 27, and find the longest chain for any start below 1000. Then write the version that gives up after a fixed number of steps and say why.

Stuck? Three hints, smallest first
  1. 1. One line does the step: x % 2 === 0 ? x / 2 : 3 * x + 1.
  2. 2. 27 takes far more steps than its size suggests, and rises above 9000 on the way.
  3. 3. Nobody has proved every chain terminates, so any loop over it needs an escape.

The answer

const collatz = (n) => {
  const seq = [n];
  let x = n;
  while (x !== 1) {
    x = x % 2 === 0 ? x / 2 : 3 * x + 1;
    seq.push(x);
  }
  return seq;
};

const steps = (n) => collatz(n).length - 1;
const peak = (n) => collatz(n).reduce((m, x) => (x > m ? x : m), 0);

// The honest version: bounded, and it reports whether it finished.
const reachesOneWithin = (n, cap) => {
  let x = n;
  let used = 0;
  while (x !== 1 && used < cap) {
    x = x % 2 === 0 ? x / 2 : 3 * x + 1;
    used++;
  }
  return x === 1;
};

const allTerminate = (limit, cap) => {
  for (let n = 1; n <= limit; n++) if (!reachesOneWithin(n, cap)) return false;
  return true;
};

const longestUnder = (limit) => {
  let bestN = 1;
  let bestSteps = -1;
  for (let n = 1; n < limit; n++) {
    const s = steps(n);
    if (s > bestSteps) {
      bestSteps = s;
      bestN = n;
    }
  }
  return [bestN, bestSteps];
};

const longest = longestUnder(1000);

console.log('from 6        :', collatz(6));
console.log('from 1        :', collatz(1), '<- the loop never runs');
console.log('27            :', steps(27), 'steps, peaking at', peak(27));
console.log('97            :', steps(97), 'steps, peaking at', peak(97));
console.log('7             :', steps(7), 'steps, peaking at', peak(7));
console.log('powers of two :', steps(1), steps(2), steps(1024));
console.log('longest < 1000:', longest);
console.log('capped at 500 :', allTerminate(200, 500), 'capped at 50:', allTerminate(200, 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('the chain from 6 reaches 1 in eight steps', [collatz(6), steps(6)], [[6, 3, 10, 5, 16, 8, 4, 2, 1], 8]);
test('27 is the famous small one: 111 steps and a peak of 9232', [steps(27), peak(27)], [111, 9232]);
test('a power of two falls straight down, one step per bit', [steps(1), steps(2), steps(1024)], [0, 1, 10]);
test('7 takes 16 steps and climbs to 52 first, from a start of 7', [steps(7), peak(7)], [16, 52]);
test('871 has the longest chain below 1000, at 178 steps', longest, [871, 178]);
test('the chain from 1 is just [1], since the loop condition is false at once', collatz(1), [1]);
test('97 reaches the same peak as 27 by a longer road', [steps(97), peak(97)], [118, 9232]);
test('every start up to 200 terminates within 500 steps, and not within 50', [allTerminate(200, 500), allTerminate(200, 50)], [true, false]);
test('3n + 1 on an odd n is always even, so every rise is followed by a fall', [3 * 27 + 1, (3 * 27 + 1) % 2, (3 * 871 + 1) % 2], [82, 0, 0]);

The rule is two lines and the behaviour is wild. 27 takes 111 steps and climbs to 9232 before coming down; 97 takes 118 steps and reaches the same peak. A power of two, by contrast, falls straight to 1 in exactly as many steps as it has bits. There is no relationship between the size of the start and the length of the chain, which is what makes the problem interesting and what makes the search for the longest chain below 1000 a genuine search rather than a formula.

The step cap is the point of the exercise. The Collatz conjecture — that every positive integer reaches 1 — is unproven. So while (x !== 1) is a loop with no proof of termination, and writing it that way in production code means shipping a possible infinite loop on the strength of a conjecture. reachesOneWithin costs one counter and returns a fact you can act on. Asserting that the cap really bites — 200 starts all finish within 500 steps but not within 50 — is what stops the guard being decoration.

One small note on the arithmetic: 3 * n + 1 for odd n is always even, so a rise is always immediately followed by at least one halving. That is why the sequence does not simply run away, and it is the observation every attempted proof starts from. It is also worth knowing that the peak can be far above the start, so a version of this working in 32-bit integers would overflow long before a version working in doubles does.

103 Fibonacci by iteration, and the exact moment it goes wrong

hard

Generate Fibonacci numbers with a loop and two variables. Find the last one that is a safe integer, show what the next one comes out as against its true value, and check Binet's closed form against the loop to see where it starts disagreeing. Then take the BigInt version out to F(300).

Stuck? Three hints, smallest first
  1. 1. Two variables and a swap. No array, no recursion.
  2. 2. The safe integer limit is 2 ** 53 - 1, and Fibonacci passes it somewhere in the high seventies.
  3. 3. Binet: Math.round(PHI ** n / Math.sqrt(5)). It is exact for a while and then it is not.

The answer

const add = (a, b) => a + b;

const fib = (n) => {
  let a = 0;
  let b = 1;
  for (let i = 0; i < n; i++) {
    const next = a + b;
    a = b;
    b = next;
  }
  return a;
};

const fibBig = (n) => {
  let a = 0n;
  let b = 1n;
  for (let i = 0; i < n; i++) {
    const next = a + b;
    a = b;
    b = next;
  }
  return a;
};

const PHI = (1 + Math.sqrt(5)) / 2;
const binet = (n) => Math.round(PHI ** n / Math.sqrt(5));

const firstTen = Array.from({ length: 10 }, (unused, i) => fib(i));

console.log('the first ten  :', firstTen);
console.log('F(10), F(30)   :', fib(10), fib(30), 'F(50):', fib(50));
console.log('F(78)          :', fib(78), 'safe:', Number.isSafeInteger(fib(78)));
console.log('F(79) by loop  :', fib(79), 'safe:', Number.isSafeInteger(fib(79)));
console.log('F(79) truly    :', String(fibBig(79)));
console.log('Binet at 70, 71:', binet(70), binet(71), 'against', fib(70), fib(71));
console.log('F(300)         :', String(fibBig(300)));
console.log('ratio to PHI   :', fib(30) / fib(29), fib(50) / fib(49), PHI);
console.log('the identity   :', fib(10) ** 2 + fib(11) ** 2, 'should be F(21) =', fib(21));

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 ten, counting F(0) as 0', firstTen, [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
test('F(10) is 55, F(30) is 832040 and F(50) is 12586269025', [fib(10), fib(30), fib(50)], [55, 832040, 12586269025]);
test('F(78) is the last Fibonacci number that is a safe integer', [fib(78), Number.isSafeInteger(fib(78)), Number.isSafeInteger(fib(79))], [8944394323791464, true, false]);
test('and F(79) is where the loop goes wrong: it ends in 220, not 221', [fib(79), String(fibBig(79))], [14472334024676220, '14472334024676221']);
test('Binet agrees with the loop up to F(70) and misses F(71) by one', [binet(70) === fib(70), binet(71), fib(71)], [true, 308061521170130, 308061521170129]);
test('the BigInt version reaches F(300), all sixty-three digits of it', [String(fibBig(300)).length, String(fibBig(300)).slice(0, 10)], [63, '2222322446']);
test('consecutive ratios close on the golden ratio, and by F(50) land on it exactly', [Math.abs(fib(30) / fib(29) - PHI) < 1e-11, fib(50) / fib(49) === PHI], [true, true]);
test('every third Fibonacci number is even, starting from F(0)', Array.from({ length: 9 }, (unused, i) => fib(i) % 2 === 0), [true, false, false, true, false, false, true, false, false]);
test('and F(n) squared plus F(n+1) squared is F(2n+1), while everything is safe', [fib(10) ** 2 + fib(11) ** 2, fib(21)], [10946, 10946]);

Two variables and a loop is the whole algorithm, and it is linear. The naive recursion fib(n - 1) + fib(n - 2) recomputes the same subproblems exponentially many times and is unusable past about 40; there is nothing to memoise here because iteration already visits each value once.

The interesting part is where it stops being right. F(78) is 8944394323791464, safely inside the exact integer range. F(79) should be 14472334024676221 and the loop returns 14472334024676220 — off by one, silently, because the addition landed one step past 2 ** 53 where only even integers exist. Every value after that is wrong too, and nothing in the output looks unusual. BigInt has no such wall: fibBig(300) is a 63-digit integer and every digit of it is right.

Binet's closed form is the cautionary tale. Math.round(PHI ** n / Math.sqrt(5)) is exact for a surprisingly long time — every value up to F(70) — and then quietly starts being off by one at F(71), a full eight terms before the integer range runs out. The error is in PHI itself: it is an irrational number stored in 53 bits, and raising it to the 71st power amplifies that tiny error past the point where rounding can hide it. A formula that is right for seventy cases and wrong for the seventy-first is far more dangerous than one that is obviously wrong from the start.

104 The sieve of Eratosthenes, to a thousand

fair

Sieve the primes up to 1000. Report how many there are, their sum, the twin pairs and the largest gap. Check the sieve against ordinary trial division on every number in range, then use the list to test Goldbach's conjecture up to 200.

Stuck? Three hints, smallest first
  1. 1. Mark multiples of each prime as composite. Start marking at i * i, not i * 2.
  2. 2. Trial division only needs divisors up to Math.sqrt(n), and only odd ones after 2.
  3. 3. A Set makes the membership checks in the last two tests cheap.

The answer

const add = (a, b) => a + b;

const sieve = (limit) => {
  const composite = new Uint8Array(limit + 1);
  const primes = [];
  for (let i = 2; i <= limit; i++) {
    if (!composite[i]) {
      primes.push(i);
      for (let j = i * i; j <= limit; j += i) composite[j] = 1;
    }
  }
  return primes;
};

const isPrime = (n) => {
  if (!Number.isInteger(n) || n < 2) return false;
  if (n % 2 === 0) return n === 2;
  for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
  return true;
};

const primes = sieve(1000);
const primeSet = new Set(primes);

const trialAgrees = (limit) => {
  for (let n = 0; n <= limit; n++) if (isPrime(n) !== primeSet.has(n)) return false;
  return true;
};

const twins = primes.filter((p, i) => primes[i + 1] === p + 2).map((p) => [p, p + 2]);

const largestGap = () => {
  let gap = 0;
  let after = 0;
  for (let i = 1; i < primes.length; i++) {
    const g = primes[i] - primes[i - 1];
    if (g > gap) {
      gap = g;
      after = primes[i - 1];
    }
  }
  return [gap, after];
};

// Returns the first even number that is NOT a sum of two primes, or 0 if none.
const goldbachFailsAt = (limit) => {
  for (let n = 4; n <= limit; n += 2) {
    let found = false;
    for (const p of primes) {
      if (p > n / 2) break;
      if (primeSet.has(n - p)) {
        found = true;
        break;
      }
    }
    if (!found) return n;
  }
  return 0;
};

console.log('how many      :', primes.length, 'the last one:', primes[primes.length - 1]);
console.log('the first ten :', primes.slice(0, 10));
console.log('their sum     :', primes.reduce(add, 0));
console.log('trial agrees  :', trialAgrees(1000));
console.log('twin pairs    :', twins.length, twins[0], 'up to', twins[twins.length - 1]);
console.log('largest gap   :', largestGap());
console.log('Goldbach to 200:', goldbachFailsAt(200), '<- zero means it held');
console.log('n / ln n says :', 1000 / Math.log(1000), 'against the real', primes.length);
console.log('tiny limits   :', sieve(10), sieve(2), sieve(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('there are 168 primes up to 1000, the last of them 997', [primes.length, primes[primes.length - 1]], [168, 997]);
test('the first ten are the ones you would write down by hand', primes.slice(0, 10), [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]);
test('they sum to 76127', primes.reduce(add, 0), 76127);
test('trial division agrees with the sieve on every number from 0 to 1000', trialAgrees(1000), true);
test('1 is not prime, nor is 0 or a negative, and 2 is the only even one', [isPrime(1), isPrime(0), isPrime(-7), isPrime(2), isPrime(9)], [false, false, false, true, false]);
test('there are 35 twin prime pairs below 1000, from 3 and 5 to 881 and 883', [twins.length, twins[0], twins[twins.length - 1]], [35, [3, 5], [881, 883]]);
test('the largest gap in range is 20, and it opens straight after 887', largestGap(), [20, 887]);
test('every even number from 4 to 200 is a sum of two primes', goldbachFailsAt(200), 0);
test('the sieve is right at the small limits too, where it must produce nothing', [sieve(10), sieve(2), sieve(1)], [[2, 3, 5, 7], [2], []]);

The sieve does no division at all. It walks up marking multiples, and anything still unmarked when you reach it is prime — because every composite has a prime factor below its square root and was therefore marked already. That is also why the inner loop starts at i * i: every multiple of i below i * i has a smaller prime factor and is already crossed out. Starting at i * 2 gives the same answer and does several times the work.

Uint8Array rather than a plain array is worth a line of explanation: it is allocated once at a fixed size, every slot starts at 0, and there are no holes for the engine to reason about. For a sieve to a million that difference is large. Trial division is here as the independent check — two algorithms with nothing in common agreeing on all 1001 answers is much stronger evidence than either one plus a spot check.

The statistics at the end are the reason to build the list. 168 primes below 1000, against the prime number theorem's estimate of 1000 / ln 1000, about 145 — the estimate is asymptotic and still 14% low here. 35 twin pairs, whose infinitude is unproven. A largest gap of 20 immediately after 887. And Goldbach's conjecture, that every even number above 2 is a sum of two primes, holding for every case up to 200 and unproven since 1742. A sieve to 1000 costs about a thousand operations and lets you look at all of it.

105 Modular exponentiation, inverses, and a check digit that works

hard

Raise a number to a large power modulo something, without ever computing the power. Compare it with the naive base ** exponent % m. Then find modular inverses with the extended Euclidean algorithm, and use mod 11 arithmetic to build an ISBN-10 check digit — and show what Luhn's mod 10 version misses.

Stuck? Three hints, smallest first
  1. 1. Square and multiply: halve the exponent each round and reduce mod m every step.
  2. 2. The extended algorithm tracks the coefficients x and y with a x + b y = gcd(a, b).
  3. 3. An inverse exists only when the two numbers are coprime.

The answer

const mod = (a, n) => ((a % n) + n) % n;

// Square and multiply. Nothing ever grows past m squared.
const powMod = (base, exponent, m) => {
  let result = 1;
  let b = mod(base, m);
  let e = exponent;
  while (e > 0) {
    if (e % 2 === 1) result = (result * b) % m;
    b = (b * b) % m;
    e = Math.floor(e / 2);
  }
  return result;
};

// Extended Euclid: returns [g, x, y] with a * x + b * y === g.
const egcd = (a, b) => {
  let [oldR, r] = [a, b];
  let [oldS, s] = [1, 0];
  let [oldT, t] = [0, 1];
  while (r !== 0) {
    const q = Math.floor(oldR / r);
    [oldR, r] = [r, oldR - q * r];
    [oldS, s] = [s, oldS - q * s];
    [oldT, t] = [t, oldT - q * t];
  }
  return [oldR, oldS, oldT];
};

const modInverse = (a, m) => {
  const [g, x] = egcd(mod(a, m), m);
  return g === 1 ? mod(x, m) : NaN;
};

// ISBN-10: weights 10 down to 1, and the whole thing must be 0 mod 11.
const isbn10Weighted = (chars) => {
  let total = 0;
  for (let i = 0; i < chars.length; i++) {
    const ch = chars[i];
    total += (10 - i) * (ch === 'X' ? 10 : Number(ch));
  }
  return total;
};
const digitsOf = (s) => s.split('').filter((ch) => ch !== '-');
const isbn10CheckDigit = (first9) => {
  const c = mod(-isbn10Weighted(digitsOf(first9)), 11);
  return c === 10 ? 'X' : String(c);
};
const isbn10Valid = (s) => {
  const d = digitsOf(s);
  return d.length === 10 && isbn10Weighted(d) % 11 === 0;
};

// Luhn: mod 10, doubling every second digit from the right.
const luhn = (s) => {
  let sum = 0;
  let double = false;
  for (let i = s.length - 1; i >= 0; i--) {
    let d = Number(s[i]);
    if (double) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    double = !double;
  }
  return sum % 10 === 0;
};

console.log('2 ** 10 mod 1000  :', powMod(2, 10, 1000));
console.log('7 ** 100 mod 13   :', powMod(7, 100, 13), 'naively:', 7 ** 100 % 13, 'BigInt:', String(7n ** 100n % 13n));
console.log('7 ** 1000 naively :', 7 ** 1000, '->', 7 ** 1000 % 13);
console.log('Fermat mod 13     :', [2, 3, 5, 6, 7].map((a) => powMod(a, 12, 13)));
console.log('the same on 15    :', powMod(2, 14, 15), '<- 15 is composite');
console.log('egcd(240, 46)     :', egcd(240, 46), 'check:', 240 * -9 + 46 * 47);
console.log('inverses mod 11   :', Array.from({ length: 10 }, (unused, i) => modInverse(i + 1, 11)));
console.log('6 mod 9           :', modInverse(6, 9), '<- they share a factor of 3');
console.log('check digits      :', isbn10CheckDigit('030640615'), isbn10CheckDigit('043942089'));
console.log('ISBN transposed   :', isbn10Valid('0306406152'), isbn10Valid('0306406512'));
console.log('Luhn transposed   :', luhn('4091234567890121'), luhn('4901234567890121'));

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 to the 10 mod 1000 is 24, and 7 to the 100 mod 13 is 9', [powMod(2, 10, 1000), powMod(7, 100, 13)], [24, 9]);
test('computing the power first gives 3, and BigInt confirms 9 is the right one', [7 ** 100 % 13, String(7n ** 100n % 13n)], [3, '9']);
test('push the exponent to 1000 and the naive version overflows to NaN', [7 ** 1000 === Infinity, Number.isNaN(7 ** 1000 % 13)], [true, true]);
test('Fermat: every a coprime to 13 gives 1 when raised to the 12th mod 13', [2, 3, 5, 6, 7].map((a) => powMod(a, 12, 13)), [1, 1, 1, 1, 1]);
test('the same test on composite 15 gives 4, which is how primality tests start', powMod(2, 14, 15), 4);
test('extended Euclid finds the coefficients, and they check out', [egcd(240, 46), 240 * -9 + 46 * 47], [[2, -9, 47], 2]);
test('so 3 has inverse 4 mod 11, and every unit mod 11 has one', [modInverse(3, 11), Array.from({ length: 10 }, (unused, i) => ((i + 1) * modInverse(i + 1, 11)) % 11)], [4, [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]);
test('6 has no inverse mod 9 since they share a factor; 17 mod 3120 has 2753', [Number.isNaN(modInverse(6, 9)), modInverse(17, 3120), (17 * 2753) % 3120], [true, 2753, 1]);
test('mod 11 catches the transposition; Luhn mod 10 passes 09 and 90 alike', [isbn10CheckDigit('030640615'), isbn10Valid('0306406152'), isbn10Valid('0306406512'), luhn('4091234567890121'), luhn('4901234567890121')], ['2', true, false, true, true]);

powMod is the one algorithm in this section you are likely to need and unlikely to invent. Squaring and multiplying reduces the exponent by half each round, so 7 ** 100 takes seven multiplications rather than a hundred, and reducing mod m after every one keeps every intermediate below m squared. The naive route fails in two different ways: 7 ** 100 % 13 returns 3 because 7 ** 100 is not exactly representable, and 7 ** 1000 % 13 returns NaN because the power overflowed to Infinity first. Note the one constraint on the fast version — b * b must stay exact, so m has to be below about 94,906,265. Past that, use BigInt.

The extended Euclidean algorithm carries the coefficients along with the remainders, so it ends with a x + b y = gcd(a, b). When the gcd is 1, that x is the modular inverse: it is the closest thing modular arithmetic has to division. When the gcd is not 1 there is no inverse at all, and returning NaN rather than a plausible number is the difference between a bug you find in a test and one you find in production. Everything RSA does is these two functions.

The check digits make the point about choosing a modulus. ISBN-10 weights its digits 10 down to 1 and works mod 11 — a prime — so every single-digit error and every transposition of two digits is caught. Luhn works mod 10, which is not prime, and it is blind to swapping a 0 and a 9: 4091234567890121 and 4901234567890121 both validate. That is a real property of every card number in your wallet, and it follows directly from 10 having factors.

106 Splitting money so every kobo lands somewhere

hard

Refund ₦100.00 to three people and split a ₦9,999.99 invoice between three cost centres in the ratio 3 : 5 : 7, in integer kobo, so that the shares add back to the total exactly. Then pull 7.5% VAT out of a VAT-inclusive price and show what goes wrong if you round both halves of it. Along the way, find one basket of naira prices whose float total is exactly right and one whose is not.

Stuck? Three hints, smallest first
  1. 1. Round each share separately and the parts no longer add up to the whole; the shortfall is the leftover kobo.
  2. 2. Floor every share, count how many kobo are still unallocated, then hand them to the shares with the largest discarded fraction.
  3. 3. When splitting a price into net and VAT, round one part and make the other the remainder.

The answer

const toKobo = (amount) => Math.round(amount * 100);
const naira = (kobo) => '₦' + (kobo / 100).toFixed(2);

// Hand out `total` kobo in the given ratio. Floor every share, then give the
// unallocated kobo to the shares with the largest discarded fraction, largest
// first. This is the largest-remainder rule, and it never invents or loses a kobo.
function allocate(total, weights) {
  const weight = weights.reduce((a, b) => a + b, 0);
  const exact = weights.map((w) => (total * w) / weight);
  const shares = exact.map(Math.floor);
  const left = total - shares.reduce((a, b) => a + b, 0);
  const byFraction = exact
    .map((v, i) => ({ i, frac: v - Math.floor(v) }))
    .sort((a, b) => b.frac - a.frac || a.i - b.i);
  for (let k = 0; k < left; k++) shares[byFraction[k].i]++;
  return shares;
}

// The wrong way: round every share independently and hope.
const roundEach = (total, parts) => new Array(parts).fill(Math.round(total / parts));

// Two baskets of naira prices, priced the same way. Only one of them adds up.
const basketA = [1250.75, 899.99, 45.5, 12345.67];
const basketB = [12000.05, 3450.15, 89.9];
const floatA = basketA.reduce((a, b) => a + b, 0);
const floatB = basketB.reduce((a, b) => a + b, 0);
const koboA = basketA.map(toKobo).reduce((a, b) => a + b, 0);
const koboB = basketB.map(toKobo).reduce((a, b) => a + b, 0);

const naiveThirds = roundEach(10000, 3);              // ₦100.00 three ways
const thirds = allocate(10000, [1, 1, 1]);
const centres = allocate(999999, [3, 5, 7]);          // ₦9,999.99 as 3 : 5 : 7
const exactCentres = [3, 5, 7].map((w) => (999999 * w) / 15);
const drift = centres.map((share, i) => Math.abs(share - exactCentres[i]));

// 7.5% VAT taken back out of a VAT-inclusive price. Round the net, and let the
// VAT be whatever is left, so the two halves always reconstruct the price.
const RATE = 0.075;
const netOf = (gross) => Math.round(gross / (1 + RATE));
const vatOf = (gross) => gross - netOf(gross);

// What happens if you round both halves instead?
let bothRounded = 0;
let firstLoss = 0;
for (let gross = 1; gross <= 10000; gross++) {
  const net = netOf(gross);
  if (net + Math.round(net * RATE) !== gross) {
    bothRounded++;
    if (firstLoss === 0) firstLoss = gross;
  }
}

console.log('basket A     :', floatA, 'kobo total', koboA, 'agree:', floatA === koboA / 100);
console.log('basket B     :', floatB, 'kobo total', koboB, 'agree:', floatB === koboB / 100);
console.log('naive thirds :', naiveThirds, 'sum', naiveThirds.reduce((a, b) => a + b, 0));
console.log('allocated    :', thirds, 'sum', thirds.reduce((a, b) => a + b, 0));
console.log('3 : 5 : 7    :', centres, 'sum', centres.reduce((a, b) => a + b, 0));
console.log('exact shares :', exactCentres, 'drift', drift);
console.log('VAT out of   :', naira(5375), '-> net', naira(netOf(5375)), 'VAT', naira(vatOf(5375)));
console.log('both rounded :', bothRounded, 'of the first 10000 prices fail, from', firstLoss, 'kobo');

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('one basket of naira floats happens to add up exactly', [floatA === 14541.91, koboA], [true, 1454191]);
test('the next basket, priced the same way, does not', [floatB, floatB === 15540.1, koboB], [15540.099999999999, false, 1554010]);
test('the same intermittency in miniature: one sum misses, one hits', [0.1 + 0.2 === 0.3, 4500 + 0.1 + 0.2 === 4500.3], [false, true]);
test('rounding three equal shares of ₦100.00 loses a kobo', [naiveThirds, naiveThirds.reduce((a, b) => a + b, 0)], [[3333, 3333, 3333], 9999]);
test('largest remainder hands out all 10000, at the cost of a lopsided share', [thirds, thirds.reduce((a, b) => a + b, 0)], [[3334, 3333, 3333], 10000]);
test('a 3 : 5 : 7 split of ₦9,999.99 adds back to the invoice exactly', [centres, centres.reduce((a, b) => a + b, 0)], [[200000, 333333, 466666], 999999]);
test('and no cost centre is more than a kobo from its true proportion', drift.every((d) => d < 1), true);
test('VAT out of an inclusive ₦53.75 is 5000 net plus 375, which reconstructs it', [netOf(5375), vatOf(5375), netOf(5375) + vatOf(5375), naira(5375)], [5000, 375, 5375, '₦53.75']);
test('rounding both halves instead fails on 698 of the first 10000 prices, from 7 kobo up', [bothRounded, firstLoss], [698, 7]);

The headline is the second assertion sitting next to the first. Basket A adds up to 14541.91 in floats, exactly; basket B, three prices written the same way, comes out 15540.099999999999. Float error in money is intermittent, which is worse than being reliable: the bug passes your test suite on Monday's prices and drops a kobo on Tuesday's. 0.1 + 0.2 !== 0.3 is the famous case, but 4500 + 0.1 + 0.2 === 4500.3 is also true, so "floats are always wrong" is not a rule you can lean on either. Integer kobo, as exercise 15 set out, removes the question.

Integers do not remove the division problem, though, and that is what the rest of this is. ₦100.00 does not divide by three. Rounding each share gives three of 3333 and a kobo left in your hand; the largest-remainder rule floors every share, counts the shortfall, and hands those kobo to the shares with the biggest discarded fraction — so the parts always sum to the whole and no share is off by more than one kobo. The cost is that one person gets 3334, which is a policy you must be able to defend, not an accident.

The VAT split is the same rule in a smaller frame: round one part, derive the other by subtraction. Round both and 698 of the first 10000 prices no longer reconstruct — the first is 7 kobo, where a net of 7 and a VAT of 1 add to 8. That is a receipt whose lines do not add up to its total, and it is the single most common bug in billing code.

107 Compensated summation, and where Kahan itself fails

hard

Add a hundred thousand tenths four ways: straight through, with Kahan compensation, with Neumaier's improvement, and pairwise. Check each against a reference computed in exact integer arithmetic. Then find the four-element list where Kahan summation gets the answer wrong and Neumaier does not.

Stuck? Three hints, smallest first
  1. 1. Kahan keeps a second variable holding the low-order bits the last addition dropped, and subtracts it from the next term.
  2. 2. Pairwise summation needs no compensation at all: it just adds halves of halves, so the error grows like log n rather than n.
  3. 3. Kahan assumes the running total is the larger of the two operands. Feed it a term far bigger than the total so far and the correction is computed from the wrong end.

The answer

const naive = (xs) => xs.reduce((a, b) => a + b, 0);

// Kahan: `c` carries what the last addition lost, and is removed from the next term.
function kahan(xs) {
  let sum = 0;
  let c = 0;
  for (const x of xs) {
    const y = x - c;
    const t = sum + y;
    c = t - sum - y;
    sum = t;
  }
  return sum;
}

// Neumaier: works out which operand was larger, accumulates the loss instead of
// folding it into the next term, and adds it back at the very end.
function neumaier(xs) {
  let sum = 0;
  let c = 0;
  for (const x of xs) {
    const t = sum + x;
    c += Math.abs(sum) >= Math.abs(x) ? sum - t + x : x - t + sum;
    sum = t;
  }
  return sum + c;
}

// Pairwise: halve, sum each half, add. No compensation, no extra state.
function pairwise(xs, lo, hi) {
  const from = lo === undefined ? 0 : lo;
  const to = hi === undefined ? xs.length : hi;
  if (to - from <= 8) {
    let s = 0;
    for (let i = from; i < to; i++) s += xs[i];
    return s;
  }
  const mid = (from + to) >> 1;
  return pairwise(xs, from, mid) + pairwise(xs, mid, to);
}

const N = 100000;
const tenths = new Array(N).fill(0.1);
const harmonic = Array.from({ length: N }, (_, i) => 1 / (i + 1));

// A reference for the harmonic sum, in exact integers: 30 decimal places of
// each term, added as BigInt, so the only error is the final conversion.
const SCALE = 10n ** 30n;
let scaled = 0n;
for (let k = 1n; k <= BigInt(N); k++) scaled += SCALE / k;
const reference = Number(scaled) / 1e30;

const ascending = harmonic.slice().reverse();          // smallest term first
const hostile = [1, 1e100, 1, -1e100];                 // true sum: 2
const cancelling = [1e16, 3, -1e16];                   // true sum: 3

console.log('100000 tenths, naive :', naive(tenths));
console.log('kahan / neumaier     :', kahan(tenths), neumaier(tenths));
console.log('pairwise             :', pairwise(tenths));
console.log('harmonic reference   :', reference);
console.log('naive, descending    :', naive(harmonic), naive(harmonic) - reference);
console.log('naive, ascending     :', naive(ascending), naive(ascending) - reference);
console.log('kahan / pairwise     :', kahan(harmonic), pairwise(harmonic));
console.log('hostile list         :', naive(hostile), kahan(hostile), neumaier(hostile), '<- want 2');
console.log('cancelling list      :', naive(cancelling), kahan(cancelling), neumaier(cancelling), '<- want 3');
console.log('a single addition    :', kahan([0.1, 0.2]), neumaier([0.1, 0.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('adding 100000 tenths straight through overshoots 10000', [naive(tenths), naive(tenths) === 10000], [10000.000000018848, false]);
test('Kahan, Neumaier and pairwise all land on 10000 exactly', [kahan(tenths), neumaier(tenths), pairwise(tenths)], [10000, 10000, 10000]);
test('the integer reference for the harmonic sum is 12.090146129863427', reference, 12.090146129863427);
test('Kahan and pairwise both hit that reference bit for bit', [kahan(harmonic) === reference, pairwise(harmonic) === reference], [true, true]);
test('where the naive sum is out by 9.2e-14 after 100000 terms', naive(harmonic) - reference, -9.237055564881302e-14);
test('sorting the terms smallest first cuts that error by a factor of 4.7, with no compensation', naive(ascending) - reference, -1.9539925233402755e-14);
test('but on this list Kahan is as wrong as the naive sum, and Neumaier is right', [naive(hostile), kahan(hostile), neumaier(hostile)], [0, 0, 2]);
test('and here all three of naive, Kahan and pairwise say 4 where the answer is 3', [naive(cancelling), kahan(cancelling), pairwise(cancelling), neumaier(cancelling)], [4, 4, 4, 3]);
test('neither compensation turns 0.1 + 0.2 into 0.3, because that error is not accumulation', [kahan([0.1, 0.2]), neumaier([0.1, 0.2])], [0.30000000000000004, 0.30000000000000004]);

Exercise 14 introduced Kahan summation as the fix for an accumulating error. This is the version with the fine print. Over 100000 tenths, and over 100000 harmonic terms checked against a BigInt reference computed to thirty decimal places, compensation earns its keep: Kahan and pairwise both land on the reference bit for bit, where the straight sum is out by 9.2e-14. Pairwise deserves attention — it is three lines, keeps no correction term, and matched the exact answer here, because splitting the list halves the depth of the addition chain and the error grows like log n instead of n.

Then [1, 1e100, 1, -1e100]. The true sum is 2. Kahan returns 0, exactly like the naive sum, because its correction is computed as t - sum - y on the assumption that sum is the larger operand — and when the incoming term is 1e100 times bigger, that subtraction recovers nothing. Neumaier's variant tests which operand is larger, accumulates the loss separately, and adds it back at the end, so it returns 2. [1e16, 3, -1e16] is the same shape in miniature: naive, Kahan and pairwise all say 4, Neumaier says 3.

And the last assertion is the boundary of the whole technique. Compensated summation fixes error that accumulates across additions. It cannot fix an operand that was already wrong: 0.1 + 0.2 is a single correctly rounded addition of two inexact doubles, there is nothing dropped to recover, and every method here returns 0.30000000000000004. If you need decimal exactness, scale to integers — the previous exercise — rather than reaching for a cleverer sum.

108 A hundred factorial, exactly

fair

Compute 100! exactly with BigInt and report its digit count, its trailing zeros and its digit sum. Then find the first Fibonacci number a double gets wrong, compute the 200th exactly, and work out C(100, 50) without ever forming a factorial you cannot hold. Finish with the four ways BigInt refuses to co-operate with Number.

Stuck? Three hints, smallest first
  1. 1. A BigInt literal ends in n, and the loop counter has to be a BigInt too, because mixing the two types throws.
  2. 2. The trailing zeros of n! count the factors of 10, so count the factors of 5: floor(100/5) + floor(100/25).
  3. 3. For a binomial coefficient, multiply and divide alternately — the running value stays whole at every step, so no giant intermediate is needed.

The answer

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

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

function fib(n) {
  let a = 0n;
  let b = 1n;
  for (let i = 0; i < n; i++) {
    const next = a + b;
    a = b;
    b = next;
  }
  return a;
}

function fibDouble(n) {
  let a = 0;
  let b = 1;
  for (let i = 0; i < n; i++) {
    const next = a + b;
    a = b;
    b = next;
  }
  return a;
}

// Multiply then divide, alternately: the running value is a binomial coefficient
// at every step, so it is always whole and never larger than the answer.
function choose(n, k) {
  let result = 1n;
  for (let i = 1n; i <= BigInt(k); i++) result = (result * (BigInt(n) - BigInt(k) + i)) / i;
  return result;
}

const f100 = factorial(100);
const text = String(f100);
let trailingZeros = 0;
while (text[text.length - 1 - trailingZeros] === '0') trailingZeros++;
const legendre = Math.floor(100 / 5) + Math.floor(100 / 25);
const digitSum = [...text].reduce((a, d) => a + Number(d), 0);

// The first index where the double version stops telling the truth.
let firstWrong = 0;
for (let i = 1; i <= 100 && firstWrong === 0; i++) {
  if (BigInt(fibDouble(i)) !== fib(i)) firstWrong = i;
}

// The four refusals, caught so the panel survives them.
const refusals = [];
try { refusals.push(1n + 1); } catch (e) { refusals.push(e.constructor.name); }
try { refusals.push(JSON.stringify({ total: 1n })); } catch (e) { refusals.push(e.constructor.name); }
try { refusals.push(Math.sqrt(4n)); } catch (e) { refusals.push(e.constructor.name); }
try { refusals.push(BigInt(1.5)); } catch (e) { refusals.push(e.constructor.name); }

console.log('100!            :', text);
console.log('digits          :', text.length, 'trailing zeros', trailingZeros, 'digit sum', digitSum);
console.log('as a double     :', Number(f100), 'against the double loop', factorialDouble(100));
console.log('first wrong fib :', firstWrong, String(fib(firstWrong)), fibDouble(firstWrong));
console.log('fib 200         :', String(fib(200)));
console.log('C(100, 50)      :', String(choose(100, 50)), 'as a double', Number(choose(100, 50)));
console.log('refusals        :', refusals);
console.log('7n / 2n         :', String(7n / 2n), 'and -7n / 2n is', String(-7n / 2n));
console.log('lost before conversion:', String(BigInt(Number.MAX_SAFE_INTEGER + 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('100! is 158 digits long and starts 9332621544', [text.length, text.slice(0, 10)], [158, '9332621544']);
test('it ends in 24 zeros, which is exactly how many factors of 5 are in 1..100', [trailingZeros, legendre], [24, 24]);
test('and its digits add up to 648', digitSum, 648);
test('turned into a double it is 9.332621544394415e157, where a double loop gives 9.33262154439441e157', [Number(f100), factorialDouble(100)], [9.332621544394415e157, 9.33262154439441e157]);
test('the 79th Fibonacci number is the first a double gets wrong, by exactly 1', [firstWrong, String(fib(79)), fibDouble(79)], [79, '14472334024676221', 14472334024676220]);
test('the 200th has 42 digits and no double can hold it', [String(fib(200)), String(fib(200)).length], ['280571172992510140037611932413038677189525', 42]);
test('C(100, 50) is exact without a single overflow, and its double is not', [String(choose(100, 50)), Number(choose(100, 50))], ['100891344545564193334812497256', 1.008913445455642e29]);
test('mixing, serialising, rooting and fractional conversion all throw', refusals, ['TypeError', 'TypeError', 'TypeError', 'RangeError']);
test('BigInt division truncates towards zero, and 1n == 1 while 1n === 1 is false', [String(7n / 2n), String(-7n / 2n), 1n == 1, 1n === 1], ['3', '-3', true, false]);

Exercise 54 found the three places doubles give up on factorials: 18! is the last safe integer, 22! the last exactly representable, 170! the last finite one. BigInt has none of those ceilings. 100! is 158 digits, ends in 24 zeros — one per factor of 5 in 1..100, which Legendre's count gives as 20 + 4 without looking at the number at all — and its digits add to 648. The double loop gets the leading digits and then diverges: 9.33262154439441e157 against 9.332621544394415e157.

Fibonacci puts the boundary at index 79, and note what the failure looks like: 14472334024676220 instead of 14472334024676221. Off by one, no warning, and every later term inherits it. C(100, 50) is the other half of the lesson — the naive formula needs 100! and 50!, but multiplying and dividing alternately keeps the running value whole and never bigger than the answer, so it works in doubles right up to the point where the answer stops fitting.

The cost is that BigInt is a separate numeric type and will not quietly become a Number. 1n + 1 throws a TypeError, JSON.stringify throws on a BigInt anywhere in the object, every Math function throws, and BigInt(1.5) throws a RangeError. Division truncates towards zero rather than producing a fraction. And BigInt(Number.MAX_SAFE_INTEGER + 2) is 9007199254740992: converting late cannot recover precision the double already lost, so convert at the boundary, count in BigInt, and format with String.

109 Newton's method for a square root, against Math.sqrt

fair

Implement Heron's iteration — x = (x + n / x) / 2 — and watch the error square itself at every step. Then compare its answer with Math.sqrt over the first ten thousand integers, count how often the two agree bit for bit, and find the stopping rule that never terminates.

Stuck? Three hints, smallest first
  1. 1. Each step roughly doubles the number of correct digits, so five or six are enough from a sensible starting guess.
  2. 2. Stopping when Math.abs(x * x - n) < 1e-10 is unusable for large n: at that scale the residual can never get that small.
  3. 3. The iteration ends up alternating between two adjacent doubles, so stop when the next value repeats either of the last two.

The answer

const step = (x, n) => (x + n / x) / 2;

// Iterate until the value stops changing, or repeats the one before it.
function heron(n) {
  if (n < 0) return { root: NaN, steps: 0 };
  if (n === 0) return { root: 0, steps: 0 };
  let x = n;
  let prev = -1;
  let steps = 0;
  while (steps < 100) {
    const next = step(x, n);
    steps++;
    if (next === x || next === prev) return { root: Math.min(x, next), steps };
    prev = x;
    x = next;
  }
  return { root: x, steps };
}

// Same iteration, started from a power of two near the root.
function heronGuessed(n) {
  if (n <= 0) return heron(n);
  let x = 2 ** Math.round(Math.log2(n) / 2);
  let prev = -1;
  let steps = 0;
  while (steps < 100) {
    const next = step(x, n);
    steps++;
    if (next === x || next === prev) return { root: Math.min(x, next), steps };
    prev = x;
    x = next;
  }
  return { root: x, steps };
}

// Two stopping rules, both capped so a panel cannot hang.
function stepsUntilAbsolute(n, cap) {
  let x = n;
  let steps = 0;
  while (Math.abs(x * x - n) > 1e-10 && steps < cap) { x = step(x, n); steps++; }
  return steps;
}
function stepsUntilRelative(n, cap) {
  let x = n;
  let steps = 0;
  while (Math.abs(x * x - n) > n * Number.EPSILON && steps < cap) { x = step(x, n); steps++; }
  return steps;
}

// No guard at all: five steps, whatever the input.
function unguarded(n, steps) {
  let x = n;
  for (let i = 0; i < steps; i++) x = step(x, n);
  return x;
}

const trace = [1];
for (let i = 0; i < 5; i++) trace.push(step(trace[trace.length - 1], 2));
const errors = trace.map((x) => x - Math.sqrt(2));
// Quadratic convergence says the next error is about the square of this one,
// divided by twice the root.
const predicted = errors[3] ** 2 / (2 * Math.sqrt(2));

let identical = 0;
let worstGap = 0;
for (let n = 1; n <= 10000; n++) {
  const mine = heron(n).root;
  const theirs = Math.sqrt(n);
  if (mine === theirs) identical++;
  const gap = Math.abs(mine - theirs) / theirs / Number.EPSILON;
  if (gap > worstGap) worstGap = gap;
}

console.log('trace from 1     :', trace);
console.log('errors           :', errors);
console.log('predicted 5th    :', predicted, 'actual', errors[4]);
console.log('heron(2)         :', heron(2).root, 'in', heron(2).steps, 'steps, against', Math.sqrt(2));
console.log('steps, 1e10      :', heron(1e10).steps, 'with a guess', heronGuessed(1e10).steps);
console.log('bit identical    :', identical, 'of 10000, worst gap', worstGap, 'x EPSILON');
console.log('absolute rule    :', stepsUntilAbsolute(2e20, 500), 'steps (capped at 500)');
console.log('relative rule    :', stepsUntilRelative(2e20, 500), 'steps');
console.log('unguarded 0      :', unguarded(0, 5), 'and -4 gives', unguarded(-4, 5));

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('five steps from 1 walk 2 down to 1.414213562373095', trace, [1, 1.5, 1.4166666666666665, 1.4142156862745097, 1.4142135623746899, 1.414213562373095]);
test('the error squares each time: 0.0857, 0.00245, 2.1e-6, 1.6e-12', errors.slice(1, 5), [0.08578643762690485, 0.002453104293571373, 0.000002123901414519125, 1.5947243525715749e-12]);
test('and the predicted next error is within a hundredth of a percent of the real one', Math.abs(predicted / errors[4] - 1) < 0.001, true);
test('it settles one part in 2 ** 52 below Math.sqrt(2), in six steps', [heron(2).root, heron(2).steps, heron(2).root === Math.sqrt(2)], [1.414213562373095, 6, false]);
test('over 1..10000 it matches Math.sqrt bit for bit 7565 times out of 10000', identical, 7565);
test('and is never off by more than a relative Number.EPSILON', worstGap < 1, true);
test('starting from a power of two near the root cuts 22 steps to 6', [heron(1e10).steps, heronGuessed(1e10).steps], [22, 6]);
test('an absolute tolerance never terminates for 2e20, where a relative one takes 38', [stepsUntilAbsolute(2e20, 500), stepsUntilRelative(2e20, 500)], [500, 38]);
test('and with no guard, 0 gives NaN while -4 gives a confident 1.6830612052619665', [Number.isNaN(unguarded(0, 5)), unguarded(-4, 5), Number.isNaN(Math.sqrt(-4))], [true, 1.6830612052619665, true]);

Heron's iteration is the oldest algorithm most people ever write, and its convergence is the thing to see: from a start of 1, the errors run 0.086, 0.0025, 2.1e-6, 1.6e-12. Each is about the square of the last divided by twice the root, which is what quadratic convergence means — the number of correct digits doubles per step. Six steps is not a coincidence of this example; it is what quadratic convergence buys you from any starting point within a factor of two, which is why the version seeded with 2 ** round(log2(n) / 2) needs 6 steps for 1e10 where starting from n itself needs 22.

It does not, however, reproduce Math.sqrt. Over the first ten thousand integers the two agree bit for bit 7565 times and differ by a bit the rest. Neither is sloppy: IEEE-754 requires sqrt to be correctly rounded, meaning within half a bit of the true root, and hardware delivers that; Heron's iteration in double arithmetic gets within one bit and no better, because each step rounds. So Math.sqrt is the right answer in production, and this exercise is about why it is worth understanding what it does.

The two traps are stopping and the domain. Math.abs(x * x - n) < 1e-10 is the textbook rule and it is unusable: at n = 2e20 the residual bottoms out around 32768, so the loop spins forever — this panel only survives it because the count is capped at 500. Scale the tolerance to n, or stop when the value stops moving. And the iteration has no idea what its domain is: n = 0 divides by zero and gives NaN, while n = -4 wanders off and returns 1.6830612052619665, a perfectly finite positive number that is not a square root of anything. Math.sqrt(-4) says NaN. Guard the input before you iterate.

Newsletter

New writing, when it lands.

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