Emmanuel Akinfulubi

JavaScript: Array, 41–53

Building, searching, reshaping and reducing arrays.

Exercises 41 to 53 of 53

41 Mean, median and range

fair

Compute the mean, the median and the range of a list of numbers. The median is where the work is: it needs the values in order, and an even count has no single middle.

Stuck? Three hints, smallest first
  1. 1. Sort a copy, with a numeric comparator, or the median of [10, 9, 1] comes out wrong.
  2. 2. With an even count, average the two middle values.
  3. 3. Decide what an empty list means. NaN is more honest than 0 for a mean.

The answer

const total = (xs) => xs.reduce((t, n) => t + n, 0);
const mean = (xs) => (xs.length === 0 ? NaN : total(xs) / xs.length);

function median(xs) {
  if (xs.length === 0) return NaN;
  const sorted = [...xs].sort((a, b) => a - b);       // a copy, and numeric
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}

const spread = (xs) => (xs.length === 0 ? NaN : Math.max(...xs) - Math.min(...xs));

const readings = [4, 1, 9, 1, 100];

console.log('readings :', readings);
console.log('mean     :', mean(readings), '<- dragged upwards by the 100');
console.log('median   :', median(readings), '<- unmoved by it');
console.log('range    :', spread(readings));
console.log('even count:', median([1, 2, 3, 4]), '<- the average of 2 and 3');
console.log('--- the comparator matters ---');
console.log('median([10, 9, 1]) :', median([10, 9, 1]));
console.log('a default sort gives:', [10, 9, 1].sort(), 'so the middle would be', [10, 9, 1].sort()[1]);
console.log('input afterwards   :', readings);

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', mean(readings), 23);
test('the median of an odd count is the middle value', median(readings), 4);
test('the median of an even count averages the two middles', median([1, 2, 3, 4]), 2.5);
test('a numeric comparator gives the right median', median([10, 9, 1]), 9);
test('the default string sort would have said 10', [10, 9, 1].sort()[1], 10);
test('the range', spread(readings), 99);
test('the outlier moves the mean but not the median', mean(readings) > median(readings), true);
test('the median does not disturb its input', readings, [4, 1, 9, 1, 100]);
test('an empty list gives NaN rather than a misleading 0', Number.isNaN(mean([])), true);

median is a two-trap function and both traps are the ones this section keeps returning to. Sorting without copying reorders the caller's data; sorting without a comparator sorts as text, so [10, 9, 1] becomes [1, 10, 9] and the "middle" is 10. The assertions pin down both the right answer and the wrong one.

Returning NaN for an empty list is a deliberate choice. 0 is a plausible-looking mean that hides the fact that there was nothing to average, and it will be plotted on a chart as though it were data. NaN propagates, which is the point.

42 The mode, including ties

fair

Find the most frequent value. Decide what to return when two values tie, and when every value appears once.

Stuck? Three hints, smallest first
  1. 1. Count first, then find the highest count, then collect every value with that count.
  2. 2. Returning an array handles ties honestly; returning one value forces you to pick.
  3. 3. A Map keeps insertion order, so tied modes come out in first-seen order.

The answer

function modes(xs) {
  const counts = xs.reduce((m, v) => m.set(v, (m.get(v) ?? 0) + 1), new Map());
  if (counts.size === 0) return [];
  const best = Math.max(...counts.values());
  return [...counts].filter(([, n]) => n === best).map(([v]) => v);
}

// When one answer is required, say in the name which one you took.
const firstMode = (xs) => modes(xs)[0];

const trips = ['bus', 'keke', 'bus', 'okada', 'bus'];

console.log('modes(trips)      :', modes(trips));
console.log('a tie             :', modes([1, 1, 2, 2]), '<- both, in first-seen order');
console.log('all distinct      :', modes([1, 2, 3]), '<- everything ties at one');
console.log('empty             :', modes([]));
console.log('firstMode of a tie:', firstMode([2, 2, 1, 1]));
console.log('NaN can be a mode :', modes([NaN, NaN, 1]).map(String));

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 single mode', modes([1, 2, 2, 3]), [2]);
test('the mode of a list of words', modes(trips), ['bus']);
test('a tie returns both values', modes([1, 1, 2, 2]), [1, 2]);
test('in first-seen order', modes([2, 2, 1, 1]), [2, 1]);
test('all-distinct means everything is a mode', modes([1, 2, 3]), [1, 2, 3]);
test('an empty list has no mode at all', modes([]), []);
test('NaN can be the mode, because the counter is a Map', modes([NaN, NaN, 1]).length, 1);
test('and it really is NaN, not the string', Number.isNaN(modes([NaN, NaN, 1])[0]), true);
test('picking one answer from a tie is a choice you have to name', firstMode([2, 2, 1, 1]), 2);

The mode is the only one of the three averages that can legitimately have several answers, and most implementations quietly return the first one they happen to find. Returning an array makes the tie visible to the caller, who is the only one who can decide what to do about it.

Two edge cases decide whether an implementation is any good: every value appearing once (every value is a mode, tied at 1) and the empty list (no mode exists, so [] rather than undefined in an array). The Map counter is what allows NaN to be a mode at all — an object accumulator would have turned it into the string 'NaN', as exercise 34 showed.

43 Which methods mutate, and what they return

fair

Sort the array methods into the ones that change the array and the ones that return a new one. Then check the return value of each mutating method — none of them returns the array except sort, reverse and fill.

Stuck? Three hints, smallest first
  1. 1. The mutators are push, pop, shift, unshift, splice, sort, reverse, fill and copyWithin. That is the whole list.
  2. 2. push and unshift return the new length; pop and shift return the element; splice returns an array of what it removed.
  3. 3. Test it rather than remembering it: run each method against a copy and see whether the copy changed.

The answer

// Run an operation against a fresh array and report whether it was changed.
const changed = (run) => {
  const a = [3, 1, 2];
  run(a);
  return !same(a, [3, 1, 2]);
};

const mutators = [
  (a) => a.push(9), (a) => a.pop(), (a) => a.shift(), (a) => a.unshift(0),
  (a) => a.splice(1, 1), (a) => a.sort(), (a) => a.reverse(), (a) => a.fill(0),
];

const copiers = [
  (a) => a.map((n) => n), (a) => a.filter(() => true), (a) => a.slice(),
  (a) => a.concat([4]), (a) => a.flat(), (a) => a.join('-'),
];

console.log('mutating methods changed the array:', mutators.map(changed));
console.log('copying methods left it alone      :', copiers.map(changed));
console.log('--- what the mutators hand back ---');
console.log("push(2)        ->", [1].push(2), '(the new length)');
console.log('pop()          ->', [1, 2].pop(), '(the element removed)');
console.log('shift()        ->', [1, 2].shift(), '(the first element)');
console.log('unshift(0)     ->', [1].unshift(0), '(the new length)');
console.log('splice(0, 2)   ->', [1, 2, 3].splice(0, 2), '(an array of what went)');
console.log('sort()         ->', [2, 1].sort(), '(the same array)');
console.log('pop() on empty ->', [].pop(), '(undefined, and no error)');

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('every mutating method changed the array', mutators.map(changed).every(Boolean), true);
test('and no copying method did', copiers.map(changed).some(Boolean), false);
test('push returns the new length', [1].push(2), 2);
test('unshift returns the new length too', [1].unshift(0), 2);
test('pop returns the element it removed', [1, 2].pop(), 2);
test('shift returns the first element', [1, 2].shift(), 1);
test('splice returns an array of what it removed', [1, 2, 3].splice(0, 2), [1, 2]);
test('sort, reverse and fill return the array itself', (() => { const a = [2, 1]; return [a.sort() === a, a.reverse() === a, a.fill(0) === a]; })(), [true, true, true]);
test('popping an empty array gives undefined rather than throwing', [].pop(), undefined);

Nine mutators, and the list is short enough to learn: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin. Everything else on Array.prototype returns something new and leaves the receiver alone.

The return values are where the real bugs come from, because only three of the nine give you back the array. const bigger = xs.push(4) gives a number, const rest = xs.shift() gives one element rather than the remainder, and const shorter = xs.splice(0, 2) gives the part you deleted. When a mutating call appears on the right-hand side of an assignment, read it twice.

44 A copy that is only one level deep

fair

Copy an array with spread, then change something inside a nested element and check the original. Then make a copy where that does not happen.

Stuck? Three hints, smallest first
  1. 1. Spread, slice and concat all copy exactly one level.
  2. 2. structuredClone copies all the way down, including Date, Map and Set.
  3. 3. A JSON round trip is deep too, and it changes your data on the way through.

The answer

const original = [[1, 2], { seats: 9 }, new Date('2024-03-01')];

const shallow = [...original];
shallow[0].push(3);                        // reaches into the original

const deep = structuredClone(original);
deep[0].push(99);                          // does not

const viaJson = JSON.parse(JSON.stringify(original));

const clonedFunction = (() => {
  try { structuredClone([() => 1]); return 'cloned'; } catch (e) { return e.name; }
})();

console.log('the copy is a new array   :', shallow !== original);
console.log('but the inner array is not:', original[0], '<- the 3 arrived here too');
console.log('structuredClone           :', deep[0], ' original still', original[0]);
console.log('--- what JSON does on the way through ---');
console.log('the Date became           :', typeof viaJson[2], JSON.stringify(viaJson[2]).slice(0, 12) + '...');
console.log('structuredClone kept it   :', deep[2] instanceof Date);
console.log('undefined inside an object:', JSON.parse(JSON.stringify([{ a: undefined }])));
console.log('cloning a function        :', clonedFunction);

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 spread copy is a different array', shallow !== original, true);
test('but the nested array is shared, so the original changed', original[0], [1, 2, 3]);
test('structuredClone reaches all the way down, so the clone changed alone', deep[0], [1, 2, 3, 99]);
test('leaving the original as it was', original[0], [1, 2, 3]);
test('a JSON round trip is deep as well', viaJson[0], [1, 2, 3]);
test('but it turns a Date into a string', typeof viaJson[2], 'string');
test('while structuredClone keeps it a Date', deep[2] instanceof Date, true);
test('and JSON silently drops an undefined property', JSON.parse(JSON.stringify([{ a: undefined }])), [{}]);
test('structuredClone refuses a function instead of dropping it', clonedFunction, 'DataCloneError');

Also Basic 34, which clones an object. The array version has the same shape and one extra sting: [...xs] looks like a complete copy, and for a list of numbers or strings it is. Add one nested array or record and half the copy is shared, so a change "to the copy" edits the original — a bug that appears the day someone nests the data.

Three tools, three failure modes. Spread is shallow. A JSON round trip is deep but lossy: dates become strings, undefined properties disappear, NaN becomes null, and a cycle throws. structuredClone handles dates, maps, sets and cycles, and refuses functions with a DataCloneError — refusing loudly is better than dropping quietly.

45 Holes, and which methods ignore them

hard

A sparse array has slots that do not exist. Build one, then find out which methods skip the holes, which fill them in, and which drop them.

Stuck? Three hints, smallest first
  1. 1. [1, , 3], delete xs[1] and xs.length = 5 all produce holes.
  2. 2. i in xs is the test: a hole is absent, whereas a stored undefined is present.
  3. 3. map and forEach skip holes; Array.from and spread turn them into undefined.

The answer

const sparse = [1, , 3];                   // the middle slot does not exist
const deleted = [1, 2, 3];
delete deleted[1];                         // the same thing, made deliberately
const explicit = [1, undefined, 3];        // NOT sparse: three real values

let visits = 0;
sparse.forEach(() => { visits++; });

console.log('length     :', sparse.length, '<- the hole is counted');
console.log('1 in sparse:', 1 in sparse, ' 1 in explicit:', 1 in explicit);
console.log('Object.keys:', Object.keys(sparse), '<- only the real slots');
console.log('forEach visited', visits, 'of 3');
console.log('--- who skips, who fills, who drops ---');
console.log('map        :', [1, , 3].map(() => 9), 'hole kept?', !(1 in [1, , 3].map(() => 9)));
console.log('filter     :', [1, , 3].filter(() => true), '<- dropped');
console.log('Array.from :', Array.from([1, , 3]).map(String), '<- filled in');
console.log('spread     :', [...[1, , 3]].map(String), '<- filled in');
console.log('reduce     :', [1, , 3].reduce((a, b) => a + b), '<- skipped, so 4');

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('length counts the hole, but the slot is absent', [sparse.length, 1 in sparse], [3, false]);
test('while a stored undefined is present', 1 in explicit, true);
test('delete produces a hole rather than shortening the array', [deleted.length, 1 in deleted], [3, false]);
test('forEach never visits a hole', visits, 2);
test('map keeps the hole, so it stays absent', 1 in [1, , 3].map(() => 9), false);
test('filter removes holes altogether', [1, , 3].filter(() => true), [1, 3]);
test('Array.from fills them with real undefined values', 1 in Array.from([1, , 3]), true);
test('includes finds a hole as undefined, indexOf does not', [[1, , 3].includes(undefined), [1, , 3].indexOf(undefined)], [true, -1]);
test('and reduce skips it, so the sum is 4', [1, , 3].reduce((a, b) => a + b), 4);

The split runs along an old fault line. The methods from ES5 — map, forEach, filter, reduce, some, every, indexOf — were specified to skip absent indexes. The iteration-based ones added later — Array.from, spread, for...of, keys, includes, find, flat — treat a hole as undefined instead. There is no rule to derive; it is history.

Which is why the practical advice is to not have sparse arrays. Use filter instead of delete xs[i], and Array.from({ length: n }, ...) instead of new Array(n) followed by writes. If you inherit one, [...xs] or xs.fill() normalises it into real values first, and every method then agrees.

46 Run-length encode and decode

hard

Turn ['a', 'a', 'b', 'c', 'c', 'c'] into [['a', 2], ['b', 1], ['c', 3]], and write the decoder that reverses it. Non-adjacent repeats are separate runs.

Stuck? Three hints, smallest first
  1. 1. Compare each value with the last one you emitted: extend that run, or start a new one.
  2. 2. out.at(-1) gives you the run in progress, and you can increment its count in place.
  3. 3. Decoding is flatMap with Array(count).fill(value).

The answer

function encode(xs) {
  const runs = [];
  for (const v of xs) {
    const current = runs.at(-1);
    if (current !== undefined && current[0] === v) current[1] += 1;
    else runs.push([v, 1]);
  }
  return runs;
}

const decode = (runs) => runs.flatMap(([v, n]) => Array(n).fill(v));

// The compact text form, which is what "RLE" usually means.
const encodeText = (text) => encode([...text]).map(([c, n]) => c + n).join('');

const letters = ['a', 'a', 'b', 'c', 'c', 'c'];

console.log('encode      :', encode(letters));
console.log('decode      :', decode(encode(letters)));
console.log('round trip  :', same(decode(encode(letters)), letters));
console.log('not adjacent:', encode(['a', 'b', 'a']), '<- three runs, not two');
console.log('text form   :', encodeText('aaabbc'));
console.log('--- === is the comparison, for better and worse ---');
console.log("[1, '1']    :", encode([1, '1']), '<- two runs');
console.log('[NaN, NaN]  :', encode([NaN, NaN]).length, 'runs, because NaN !== NaN');

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 source's example", encode(letters), [['a', 2], ['b', 1], ['c', 3]]);
test('decode is the exact inverse', decode(encode(letters)), letters);
test('a single element is a run of one', encode(['a']), [['a', 1]]);
test('an empty input encodes to nothing, and decodes back', [encode([]), decode([])], [[], []]);
test('a repeat that is not adjacent is a separate run', encode(['a', 'b', 'a']), [['a', 1], ['b', 1], ['a', 1]]);
test('numbers work the same way', encode([1, 1, 2]), [[1, 2], [2, 1]]);
test('=== means 1 and "1" never share a run', encode([1, '1']).length, 2);
test('and NaN never joins its own run', encode([NaN, NaN]).length, 2);
test('the text form is the familiar one', encodeText('aaabbc'), 'a3b2c1');

The whole algorithm is "compare with the previous element", and the only decision is what "the same" means. Using === gives you the NaN assertion above: two adjacent NaNs become two runs of one, because NaN !== NaN. If that matters, compare with Object.is instead — it treats NaN as equal to itself, and separates 0 from -0.

Note that encoding does not always shrink the data. ['a', 'b', 'a'] produces three pairs, which is bigger than the input. Run-length encoding only pays when runs are long, which is why it shows up in bitmap formats and almost nowhere else.

47 Group consecutive values into runs

hard

Split [1, 1, 2, 2, 2, 3] into [[1, 1], [2, 2, 2], [3]]. Then make the "same group" test a parameter, and use it to compress [1, 2, 3, 7, 8, 10] into 1-3,7-8,10.

Stuck? Three hints, smallest first
  1. 1. Compare each value with the last value of the group in progress, not with the group as a whole.
  2. 2. Take the comparison as an argument, defaulting to (a, b) => a === b.
  3. 3. For number ranges the test is (a, b) => b === a + 1, and the input has to be sorted first.

The answer

function groupConsecutive(xs, together = (a, b) => a === b) {
  return xs.reduce((groups, v) => {
    const current = groups.at(-1);
    if (current !== undefined && together(current.at(-1), v)) current.push(v);
    else groups.push([v]);
    return groups;
  }, []);
}

const adjacent = (a, b) => b === a + 1;

// Page ranges: sort, group the consecutive runs, then print each run.
const compressRanges = (nums) =>
  groupConsecutive([...new Set(nums)].sort((a, b) => a - b), adjacent)
    .map((run) => (run.length === 1 ? String(run[0]) : `${run[0]}-${run.at(-1)}`))
    .join(',');

const shifts = [
  { name: 'Ada', dept: 'ops' },
  { name: 'Bola', dept: 'ops' },
  { name: 'Chi', dept: 'eng' },
];

console.log('equal neighbours :', groupConsecutive([1, 1, 2, 2, 2, 3]));
console.log('a later repeat   :', groupConsecutive([1, 2, 1]), '<- three groups');
console.log('consecutive ints :', groupConsecutive([1, 2, 3, 7, 8, 10], adjacent));
console.log('compressed       :', compressRanges([3, 1, 2, 8, 7, 10]));
console.log('unsorted, ungrouped:', groupConsecutive([3, 1, 2], adjacent), '<- sort first');
console.log('by a field       :', groupConsecutive(shifts, (a, b) => a.dept === b.dept).map((g) => g.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('equal neighbours group together', groupConsecutive([1, 1, 2, 2, 2, 3]), [[1, 1], [2, 2, 2], [3]]);
test('a repeat that is not adjacent starts a new group', groupConsecutive([1, 2, 1]), [[1], [2], [1]]);
test('an empty list gives no groups', groupConsecutive([]), []);
test('the groups always partition the input', groupConsecutive([1, 1, 2]).flat(), [1, 1, 2]);
test('a custom test groups consecutive integers', groupConsecutive([1, 2, 3, 7, 8, 10], adjacent), [[1, 2, 3], [7, 8], [10]]);
test('which compresses a page list', compressRanges([3, 1, 2, 8, 7, 10]), '1-3,7-8,10');
test('a run of one prints as a single number', compressRanges([5]), '5');
test('unsorted input is not grouped, so sorting is part of the job', groupConsecutive([3, 1, 2], adjacent), [[3], [1, 2]]);
test('records group on whichever field you compare', groupConsecutive(shifts, (a, b) => a.dept === b.dept).map((g) => g.length), [2, 1]);

Comparing against current.at(-1) rather than against the group's first element is what makes the parameterised version work. For equality the two are the same; for "consecutive" they are not, since each value has to follow the one immediately before it.

The compression example is the practical use, and it needs three steps in the right order: de-duplicate, sort numerically, then group. Skip the sort and the assertion above shows what you get — [[3], [1, 2]], which is not wrong, merely an answer to a different question. That is the recurring hazard of run-based algorithms: they only see neighbours, so the ordering is your responsibility.

48 The longest increasing run

hard

Find the longest stretch of strictly increasing values. In [1, 2, 1, 3, 5, 2, 4, 6, 7] that is [2, 4, 6, 7]. Decide what a tie does.

Stuck? Three hints, smallest first
  1. 1. Keep two arrays: the run in progress, and the best one seen so far.
  2. 2. When a value is not larger than the previous one, the current run ends and a new one starts at that value.
  3. 3. Copy the current run when it becomes the best, or the two names will point at one array.

The answer

function longestIncreasingRun(xs) {
  let best = [];
  let current = [];
  for (const v of xs) {
    if (current.length === 0 || v > current.at(-1)) current.push(v);
    else current = [v];                       // the run ends; start again here
    if (current.length > best.length) best = [...current];   // a copy, on purpose
  }
  return best;
}

const readings = [1, 2, 1, 3, 5, 2, 4, 6, 7];
const found = longestIncreasingRun(readings);

console.log('input   :', readings);
console.log('runs    : [1,2] [1,3,5] [2,4,6,7]');
console.log('longest :', found, 'length', found.length);
console.log('a tie   :', longestIncreasingRun([1, 2, 0, 3]), '<- both are 2 long, the first wins');
console.log('strictly increasing, so equals break it:', longestIncreasingRun([1, 1, 2]));
console.log('decreasing:', longestIncreasingRun([3, 2, 1]));
console.log('empty     :', longestIncreasingRun([]));
console.log('contiguous in the input?', readings.join(',').includes(found.join(',')));

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 longest run', found, [2, 4, 6, 7]);
test('and its length', found.length, 4);
test('a tie keeps the earlier run', longestIncreasingRun([1, 2, 0, 3]), [1, 2]);
test('an already increasing list is a single run', longestIncreasingRun([1, 2, 3]), [1, 2, 3]);
test('a decreasing list has runs of one', longestIncreasingRun([3, 2, 1]), [3]);
test('equal neighbours break the run, because increasing is strict', longestIncreasingRun([1, 1, 2]), [1, 2]);
test('one element is a run of one', longestIncreasingRun([5]), [5]);
test('an empty list gives an empty run', longestIncreasingRun([]), []);
test('the run really is contiguous in the input', readings.join(',').includes(found.join(',')), true);

Two details decide correctness. When the run breaks, the new run starts at the current value and not empty — write current = [] and you lose an element from every run after the first. And best = [...current] has to copy: assign the array itself and best keeps growing as current does, so a later, shorter run can end up reported as the longest.

"Strictly increasing" is a choice, and the [1, 1, 2] assertion states which one was taken. Change > to >= and you have non-decreasing runs instead, which is what you usually want for timestamps. This is the contiguous problem, not the longest increasing subsequence, which allows gaps and needs a rather different algorithm.

49 Partition a list in two

hard

Split a list into the elements that pass a test and the elements that fail, in one pass. Then extend the idea to more than two groups.

Stuck? Three hints, smallest first
  1. 1. Choose which array to push into: (keep(x) ? yes : no).push(x).
  2. 2. Return the pair as an array so the caller can destructure it.
  3. 3. Two calls to filter would do the same job with twice the predicate calls.

The answer

function partition(xs, keep) {
  const yes = [];
  const no = [];
  xs.forEach((x, i) => (keep(x, i) ? yes : no).push(x));
  return [yes, no];
}

// More than two outcomes needs a key rather than a boolean.
const partitionBy = (xs, keyOf) => xs.reduce((m, x) => {
  const key = keyOf(x);
  return m.set(key, [...(m.get(key) ?? []), x]);
}, new Map());

let onePass = 0;
partition([1, 2, 3, 4], (n) => { onePass++; return n % 2 === 1; });

let twoPasses = 0;
const odd = (n) => { twoPasses++; return n % 2 === 1; };
[1, 2, 3, 4].filter(odd);
[1, 2, 3, 4].filter((n) => !odd(n));

const [pass, fail] = partition([1, 2, 3, 4], (n) => n % 2 === 1);
const sizes = [...partitionBy([1, 2, 3, 4, 5, 6], (n) => n % 3)].map(([k, g]) => [k, g.length]);

console.log('odd / even   :', partition([1, 2, 3, 4], (n) => n % 2 === 1));
console.log('destructured :', pass, fail);
console.log('by index     :', partition(['a', 'b', 'c'], (_, i) => i % 2 === 0));
console.log('nothing passes:', partition([1, 2], () => false));
console.log('predicate calls: partition', onePass, ' two filters', twoPasses);
console.log('three ways by n % 3:', sizes);

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 list splits into pass and fail', partition([1, 2, 3, 4], (n) => n % 2 === 1), [[1, 3], [2, 4]]);
test('the two halves account for every element', pass.length + fail.length, 4);
test('order inside each half follows the input', partition([4, 1, 3, 2], (n) => n > 2), [[4, 3], [1, 2]]);
test('nothing passing leaves the first half empty', partition([1, 2], () => false), [[], [1, 2]]);
test('an empty input gives two empty halves', partition([], () => true), [[], []]);
test('the predicate receives the index', partition(['a', 'b', 'c'], (_, i) => i % 2 === 0), [['a', 'c'], ['b']]);
test('partition calls the predicate once per element', onePass, 4);
test('while filtering twice calls it twice per element', twoPasses, 8);
test('a key function gives as many groups as it has keys', sizes, [[1, 2], [2, 2], [0, 2]]);

partition is missing from the standard library and worth adding to your own, because the alternative is a pair of filter calls whose predicates must stay exact negations of each other. The assertions count the cost: four predicate calls against eight, and one place to change instead of two.

Returning an array rather than { yes, no } is deliberate — it destructures positionally, so the caller names the halves whatever suits them. The Map version generalises to any number of groups and keeps non-string keys intact, which is exercise 33's lesson applied here: the keys 1, 2 and 0 stay numbers and stay in the order first encountered.

50 Every permutation

hard

Produce every ordering of [1, 2, 3]. Then check the count against n! and work out how far this can be pushed.

Stuck? Three hints, smallest first
  1. 1. Pick each element in turn as the first, and permute what is left.
  2. 2. flatMap is the right combinator: one input element produces many results.
  3. 3. The base case is a list of one — and the empty list has exactly one ordering, the empty one.

The answer

function permutations(xs) {
  if (xs.length <= 1) return [xs.slice()];
  return xs.flatMap((v, i) => {
    const rest = [...xs.slice(0, i), ...xs.slice(i + 1)];   // everything except v
    return permutations(rest).map((tail) => [v, ...tail]);
  });
}

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

const three = permutations([1, 2, 3]);
const four = permutations([1, 2, 3, 4]);

console.log('permutations of [1,2,3]:');
for (const p of three) console.log('  ', p.join(''));
console.log('count for 4 values:', four.length, ' 4! =', factorial(4));
console.log('all distinct      :', new Set(four.map((p) => p.join(','))).size);
console.log('empty input       :', permutations([]), '<- one ordering, the empty one');
console.log('duplicates        :', permutations([1, 1]), '<- two orderings that look the same');
console.log('10! =', factorial(10), ' 12! =', factorial(12), '<- why this does not scale');

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('all six orderings of three values', three, [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]);
test('the count is n factorial', [four.length, factorial(4)], [24, 24]);
test('one element has a single ordering', permutations([1]), [[1]]);
test('and the empty list has one too', permutations([]), [[]]);
test('every permutation has the input length', four.every((p) => p.length === 4), true);
test('and no two of them are the same', new Set(four.map((p) => p.join(','))).size, 24);
test('the first result is the input order', permutations([3, 1, 2])[0], [3, 1, 2]);
test('duplicate inputs give duplicate orderings', [permutations([1, 1]).length, new Set(permutations([1, 1]).map(String)).size], [2, 1]);
test('12 values would be 479 million orderings', factorial(12), 479001600);

The recursion is the definition read aloud: every permutation is some element followed by a permutation of the rest. flatMap is what makes it one expression, since each choice of first element yields a whole batch of results.

permutations([]) returning [[]] rather than [] is the correct base case and the one people get wrong. There is exactly one way to arrange nothing, and returning an empty list instead makes every recursive call above it produce nothing at all.

The last assertion is the practical point. Factorial growth means 10 values is 3.6 million orderings and 12 is 479 million: at that size you cannot hold the answer, let alone build it. Any real problem phrased as "try every order" needs pruning, a heuristic, or a different formulation.

51 Binary search, and what it costs

hard

Search a sorted array by halving the range, and count the comparisons. Compare that with a linear scan over 1024 values, then run it on unsorted input.

Stuck? Three hints, smallest first
  1. 1. while (low <= high), not <: when the range narrows to one element, low equals high and that element still needs checking.
  2. 2. The midpoint must be floored, or the index is not an integer.
  3. 3. The array must already be sorted. Nothing in the algorithm can detect that it is not.

The answer

function binarySearch(sorted, target) {
  let low = 0;
  let high = sorted.length - 1;
  let comparisons = 0;

  while (low <= high) {                       // <= , or a one-element range is skipped
    const mid = Math.floor((low + high) / 2);
    comparisons++;
    if (sorted[mid] === target) return { index: mid, comparisons };
    if (sorted[mid] < target) low = mid + 1;
    else high = mid - 1;
  }
  return { index: -1, comparisons };
}

const evens = Array.from({ length: 1024 }, (_, i) => i * 2);
const unsorted = [5, 1, 9];

console.log('find 500      :', binarySearch(evens, 500));
console.log('find 501      :', binarySearch(evens, 501), '<- not present');
console.log('find the last :', binarySearch(evens, 2046));
console.log('a linear scan would have taken', evens.indexOf(2046) + 1, 'reads');
console.log('2 ** 11 =', 2 ** 11, 'which is why 11 comparisons cover 1024 values');
console.log('--- unsorted input ---');
console.log('binarySearch([5,1,9], 5):', binarySearch(unsorted, 5).index, '<- wrong, and it does not complain');
console.log('indexOf says             :', unsorted.indexOf(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('it finds a value and reports the index', binarySearch(evens, 500).index, 250);
test('in ten comparisons out of 1024 values', binarySearch(evens, 500).comparisons, 10);
test('the last element takes eleven', binarySearch(evens, 2046).comparisons, 11);
test('where a linear scan needs 1024 reads', evens.indexOf(2046) + 1, 1024);
test('a miss is -1', binarySearch(evens, 501).index, -1);
test('a single-element array is searched, not skipped', binarySearch([42], 42).index, 0);
test('and reports a miss on it too', binarySearch([42], 1).index, -1);
test('an empty array makes no comparisons at all', binarySearch([], 1), { index: -1, comparisons: 0 });
test('unsorted input gives a wrong answer, silently', [binarySearch(unsorted, 5).index, unsorted.indexOf(5)], [-1, 0]);

Also Searching 2 and Function 18, which derive the algorithm. The angle here is the cost, because that is the only reason to write it: eleven comparisons instead of 1024 reads, and each doubling of the data adds one comparison. Below a few hundred elements indexOf is the better choice — it needs no sorted input, no maintenance, and no correctness argument.

Two failure modes are worth carrying. while (low < high) silently never checks a one-element range, which the [42] assertions catch. And unsorted input produces a confident wrong answer: the last assertion shows the search reporting "not found" for a value sitting at index 0. If the sortedness is an assumption, make it an invariant somewhere else — the search cannot check it without doing the linear scan it exists to avoid.

52 Merge two sorted lists

hard

Merge two already-sorted lists into one sorted list in a single pass, without concatenating and re-sorting. Then build merge sort out of it.

Stuck? Three hints, smallest first
  1. 1. Two indexes, one per list. Take the smaller head, advance that index.
  2. 2. When one list runs out, everything left in the other is already sorted — append it.
  3. 3. <= rather than < on the comparison is what makes the merge stable.

The answer

function merge(a, b, key = (x) => x) {
  const out = [];
  let i = 0;
  let j = 0;
  while (i < a.length && j < b.length) {
    out.push(key(a[i]) <= key(b[j]) ? a[i++] : b[j++]);      // <= : ties take from a
  }
  return [...out, ...a.slice(i), ...b.slice(j)];             // whatever is left is sorted
}

function mergeSort(xs) {
  if (xs.length <= 1) return xs.slice();
  const mid = Math.floor(xs.length / 2);
  return merge(mergeSort(xs.slice(0, mid)), mergeSort(xs.slice(mid)));
}

const left = [1, 4, 7];
const right = [2, 3, 9];

const tiedLeft = [{ v: 1, side: 'L' }];
const tiedRight = [{ v: 1, side: 'R' }];
const tied = merge(tiedLeft, tiedRight, (x) => x.v).map((x) => x.side);

const merged = merge(left, right);

console.log('left    :', left);
console.log('right   :', right);
console.log('merged  :', merged);
console.log('sorted? :', merged.every((v, i) => i === 0 || merged[i - 1] <= v));
console.log('ties    :', tied, '<- the left-hand element first');
console.log('one empty:', merge([], [1, 2]));
console.log('mergeSort:', mergeSort([5, 3, 8, 1, 9, 2]));
console.log('inputs afterwards:', left, right);

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('two sorted lists become one', merged, [1, 2, 3, 4, 7, 9]);
test('the result is in order', merged.every((v, i) => i === 0 || merged[i - 1] <= v), true);
test('it agrees with concatenating and re-sorting', merged, [...left, ...right].sort((x, y) => x - y));
test('an empty list on either side returns the other', [merge([], [1, 2]), merge([1, 2], [])], [[1, 2], [1, 2]]);
test('two empty lists give an empty list', merge([], []), []);
test('duplicates are all kept', merge([1, 1], [1]), [1, 1, 1]);
test('a tie takes from the left list first, which makes it stable', tied, ['L', 'R']);
test('the inputs are not modified', [left, right], [[1, 4, 7], [2, 3, 9]]);
test('merge sort is this function plus recursion', mergeSort([5, 3, 8, 1, 9, 2]), [1, 2, 3, 5, 8, 9]);

Merging is linear: each element is looked at once, so two lists of n cost 2n. [...a, ...b].sort() throws that away and pays n log n to rediscover an ordering you already had. On two sorted pages of results, or two sorted files too big to hold at once, the difference is the whole point.

<= versus < looks like a detail and is the definition of stability. With <=, equal elements come out of a before b, so a merge sort built on it preserves the original relative order of ties — which is exactly the guarantee Array.prototype.sort makes, and exercise 27 relied on.

The key parameter is what makes it usable on records rather than numbers. It defaults to the identity function, so the simple case stays simple.

53 The maximum of every sliding window

hard

For [1, 3, -1, -3, 5, 3, 6, 7] and a window of 3, report the maximum of each window: [3, 3, 5, 5, 6, 7]. Do it without re-scanning each window.

Stuck? Three hints, smallest first
  1. 1. The obvious answer calls Math.max on every window, which is O(n·k). Start there, then improve it.
  2. 2. Keep a list of indexes whose values are in decreasing order; the front is the current maximum.
  3. 3. Before adding an index, drop every index at the back whose value is no larger — it can never be a maximum again.

The answer

// The obvious version: correct, and it re-reads every window.
const windowMaxNaive = (xs, k) =>
  Array.from({ length: Math.max(0, xs.length - k + 1) }, (_, i) => Math.max(...xs.slice(i, i + k)));

// One pass. `window` holds indexes whose values decrease from front to back.
function windowMax(xs, k) {
  if (!Number.isInteger(k) || k < 1) throw new RangeError('k must be a positive integer');
  const out = [];
  const window = [];
  let removals = 0;

  for (let i = 0; i < xs.length; i++) {
    while (window.length > 0 && xs[window.at(-1)] <= xs[i]) { window.pop(); removals++; }
    window.push(i);
    if (window[0] <= i - k) { window.shift(); removals++; }   // the front fell out of range
    if (i >= k - 1) out.push(xs[window[0]]);
  }
  return { values: out, removals };
}

const xs = [1, 3, -1, -3, 5, 3, 6, 7];

const refused = (() => {
  try { windowMax(xs, 0); return 'no error'; } catch (e) { return e.constructor.name; }
})();

console.log('input      :', xs);
console.log('windows of 3, one per line:');
for (let i = 0; i + 3 <= xs.length; i++) console.log('  ', xs.slice(i, i + 3), '->', Math.max(...xs.slice(i, i + 3)));
console.log('windowMax  :', windowMax(xs, 3).values);
console.log('naive      :', windowMaxNaive(xs, 3));
console.log('removals   :', windowMax(xs, 3).removals, 'for', xs.length, 'elements');
console.log('k of 0     :', refused);

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 classic example", windowMax(xs, 3).values, [3, 3, 5, 5, 6, 7]);
test('the naive version agrees', windowMax(xs, 3).values, windowMaxNaive(xs, 3));
test('there are n - k + 1 windows', windowMax(xs, 3).values.length, xs.length - 3 + 1);
test('a window of 1 is the list itself', windowMax(xs, 1).values, xs);
test('a window as long as the list gives one answer', windowMax([1, 2, 3], 3).values, [3]);
test('a window longer than the list gives none', windowMax([1, 2], 5).values, []);
test('duplicates are handled', windowMax([2, 2, 2], 2).values, [2, 2]);
test('each index leaves the window at most once, so the work is linear', windowMax(xs, 3).removals <= xs.length, true);
test('a window of 0 is refused rather than looping', refused, 'RangeError');

The insight is about what can be thrown away. If a new value is at least as large as the value at the back of the window, that older value can never be the maximum of any window containing the new one — so it is discarded immediately and permanently. What remains is a decreasing list of candidates whose front is always the answer.

The removals count is the proof that this is linear rather than quadratic: every index enters the window once and leaves at most once, so the total work is bounded by the length of the input, even though there is a while loop inside a for loop. Counting the discards is a good habit for any algorithm whose cost argument is amortised — it turns "trust me, it is O(n)" into something a test can check.

The naive version is kept because it is the specification. It is obviously correct, it is the right choice for small windows, and it is what the fast version is checked against.

Newsletter

New writing, when it lands.

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