Emmanuel Akinfulubi

JavaScript: Searching

Linear and binary search by hand.

5 exercises

1 Linear search

easy

Walk a list from the start and return the position of the first match, or a value meaning "not there".

Stuck? Three hints, smallest first
  1. 1. Return the index, not true. An index is strictly more useful and still tells you it was found.
  2. 2. Return -1 for not found, matching indexOf, so callers already know the convention.
  3. 3. This is the only search that works on unsorted data, which is its whole point.

The answer

function linearSearch(values, target) {
  for (let i = 0; i < values.length; i++) {
    if (values[i] === target) return i;
  }
  return -1;                       // same convention as indexOf
}

// A version taking a predicate, which is what findIndex does.
function searchBy(values, matches) {
  for (let i = 0; i < values.length; i++) if (matches(values[i], i)) return i;
  return -1;
}

const plates = ['LAG-441-XA', 'ABJ-772-KM', 'ENU-318-PH', 'ABJ-772-KM'];

console.log('found at    :', linearSearch(plates, 'ENU-318-PH'));
console.log('not present :', linearSearch(plates, 'KAN-905-BD'));
console.log('first of two:', linearSearch(plates, 'ABJ-772-KM'));
console.log('by predicate:', searchBy(plates, (p) => p.startsWith('ENU')));

Output


      
8 checks that prove this answer

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

8 checks

test('a match returns its index', linearSearch(plates, 'ENU-318-PH'), 2);
test('the first index wins on duplicates', linearSearch(plates, 'ABJ-772-KM'), 1);
test('absent gives -1', linearSearch(plates, 'KAN-905-BD'), -1);
test('an empty list gives -1', linearSearch([], 'x'), -1);
test('it agrees with indexOf', linearSearch(plates, 'ABJ-772-KM'), plates.indexOf('ABJ-772-KM'));
test('the predicate version finds by rule', searchBy(plates, (p) => p.startsWith('ENU')), 2);
test('and agrees with findIndex', searchBy(plates, (p) => p.startsWith('ENU')), plates.findIndex((p) => p.startsWith('ENU')));
test('it works on unsorted data', linearSearch([5, 1, 9, 3], 9), 2);

Linear search is O(n) and is the right answer more often than people expect: it needs no sorting, no preparation, and for a few hundred items the difference against a binary search is unmeasurable. Returning -1 rather than null matches indexOf and findIndex, so callers do not have to learn a new convention.

Answers JS Searching 1 in the original set.

2 Binary search on a sorted list

fair

Search a sorted list by halving the range each time. Get the loop boundaries right, and note the overflow-safe way to find the midpoint.

Stuck? Three hints, smallest first
  1. 1. The list must already be sorted. Binary search on unsorted data returns nonsense, quietly.
  2. 2. Use while (low <= high). With < you miss the case where the range is one element.
  3. 3. low + Math.floor((high - low) / 2) avoids an overflow that (low + high) / 2 can cause in other languages.

The answer

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

  while (low <= high) {                 // <= , not < : the one-element range matters
    // Written this way out of habit: in languages with fixed-width ints,
    // (low + high) can overflow. It cannot here, but the habit is free.
    const mid = low + Math.floor((high - low) / 2);
    const value = sorted[mid];

    if (value === target) return mid;
    if (value < target) low = mid + 1;
    else high = mid - 1;
  }
  return -1;
}

const sorted = [1, 3, 5, 7, 9, 11, 13];

for (const t of [1, 7, 13, 8]) {
  console.log('looking for', t, '->', binarySearch(sorted, t));
}

// The silent failure: unsorted input gives a wrong answer, not an error.
const unsorted = [9, 1, 7, 3];
console.log('binary search on unsorted:', binarySearch(unsorted, 7), '(7 is really at index 2)');

// Count the steps, to show the halving.
function steps(n) {
  const list = Array.from({ length: n }, (_, i) => i);
  let count = 0;
  let low = 0;
  let high = n - 1;
  while (low <= high) {
    count++;
    const mid = low + Math.floor((high - low) / 2);
    if (list[mid] === n - 1) break;
    if (list[mid] < n - 1) low = mid + 1;
    else high = mid - 1;
  }
  return count;
}

console.log('steps to find the last of 1000:', steps(1000));

Output


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

10 checks

test('the first element', binarySearch(sorted, 1), 0);
test('the middle', binarySearch(sorted, 7), 3);
test('the last element', binarySearch(sorted, 13), 6);
test('absent gives -1', binarySearch(sorted, 8), -1);
test('below the range', binarySearch(sorted, 0), -1);
test('above the range', binarySearch(sorted, 99), -1);
test('a single-element list finds its one value', binarySearch([42], 42), 0);
test('and reports a miss', binarySearch([42], 1), -1);
test('an empty list gives -1', binarySearch([], 1), -1);
test('1000 items take about ten steps', steps(1000) <= 10, true);

Two boundary details decide whether this is correct. while (low <= high) rather than <, because when the range narrows to a single element low equals high and that element still has to be checked — the single-element assertions cover it. And the unsorted example is the warning: binary search on unsorted input does not complain, it just returns the wrong answer.

Answers JS Searching 2 in the original set.

3 Find the insertion point for a value

fair

In a sorted list, find where a value should go to keep the order, whether or not it is already present.

Stuck? Three hints, smallest first
  1. 1. This is binary search with the equality case removed, so it never stops early.
  2. 2. When the loop ends, low is the insertion point.
  3. 3. Two variants: leftmost position, or rightmost. They differ only in one comparison.

The answer

// Leftmost: inserts before any equal values.
function lowerBound(sorted, target) {
  let low = 0;
  let high = sorted.length;          // note: length, not length - 1
  while (low < high) {
    const mid = low + Math.floor((high - low) / 2);
    if (sorted[mid] < target) low = mid + 1;
    else high = mid;
  }
  return low;
}

// Rightmost: inserts after any equal values.
function upperBound(sorted, target) {
  let low = 0;
  let high = sorted.length;
  while (low < high) {
    const mid = low + Math.floor((high - low) / 2);
    if (sorted[mid] <= target) low = mid + 1;   // <= is the only change
    else high = mid;
  }
  return low;
}

const sorted = [1, 3, 3, 3, 7, 9];

console.log('lowerBound(3):', lowerBound(sorted, 3));
console.log('upperBound(3):', upperBound(sorted, 3));
console.log('how many 3s  :', upperBound(sorted, 3) - lowerBound(sorted, 3));

// Inserting keeps the order.
const inserted = [...sorted];
inserted.splice(lowerBound(inserted, 5), 0, 5);
console.log('after inserting 5:', inserted);

Output


      
8 checks that prove this answer

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

8 checks

test('before the run of equals', lowerBound(sorted, 3), 1);
test('after the run of equals', upperBound(sorted, 3), 4);
test('the gap counts the duplicates', upperBound(sorted, 3) - lowerBound(sorted, 3), 3);
test('a value below everything goes first', lowerBound(sorted, 0), 0);
test('a value above everything goes last', lowerBound(sorted, 99), 6);
test('an absent middle value', lowerBound(sorted, 5), 4);
test('inserting there keeps it sorted', inserted, [1, 3, 3, 3, 5, 7, 9]);
test('an empty list inserts at 0', lowerBound([], 1), 0);

The pair is more useful than either alone: upperBound - lowerBound counts how many times a value occurs, in log time. Note high = sorted.length rather than length - 1, and while (low < high) rather than <= — the boundaries differ from plain binary search because the answer is a gap between elements, not an element.

Answers JS Searching 3 in the original set.

4 Search a list of records by field

easy

Find a record in a list by one of its fields. Return the record, and handle "not found" without crashing the caller.

Stuck? Three hints, smallest first
  1. 1. find returns the item or undefined; findIndex returns the position or -1.
  2. 2. For repeated lookups on the same data, build a Map once instead of searching every time.
  3. 3. A linear find is O(n) per lookup; a Map is O(1) after an O(n) setup.

The answer

const buses = [
  { plate: 'LAG-441-XA', route: 'Lagos - Abuja', seats: 14 },
  { plate: 'ABJ-772-KM', route: 'Lagos - Kano', seats: 30 },
  { plate: 'ENU-318-PH', route: 'Lagos - Abuja', seats: 7 },
];

// One-off lookup.
const findByPlate = (plate) => buses.find((b) => b.plate === plate) ?? null;

// Repeated lookups: pay the O(n) once, then every lookup is O(1).
const index = new Map(buses.map((b) => [b.plate, b]));
const lookup = (plate) => index.get(plate) ?? null;

// All matches, not just the first.
const onRoute = (route) => buses.filter((b) => b.route === route);

console.log('found      :', findByPlate('ABJ-772-KM'));
console.log('missing    :', findByPlate('KAN-905-BD'));
console.log('two on the Abuja run:', onRoute('Lagos - Abuja').map((b) => b.plate));

Output


      
8 checks that prove this answer

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

8 checks

test('a record is found', findByPlate('ABJ-772-KM').seats, 30);
test('missing returns null, not undefined', findByPlate('KAN-905-BD'), null);
test('the map agrees', lookup('ABJ-772-KM').seats, 30);
test('and on a miss', lookup('KAN-905-BD'), null);
test('filter finds every match', onRoute('Lagos - Abuja').length, 2);
test('an unknown route gives an empty list', onRoute('Lagos - Jos'), []);
test('find returns the object itself, not a copy', findByPlate('LAG-441-XA') === buses[0], true);
test('findIndex is the positional twin', buses.findIndex((b) => b.plate === 'ENU-318-PH'), 2);

The ?? null is deliberate: find returns undefined on a miss, and undefined is also what you get from a typo in a property name, so normalising to null makes "genuinely absent" distinguishable. The last assertion is worth noticing too — find hands back the actual object, so mutating the result mutates the list.

Answers JS Searching 4 in the original set.

5 Find the peak of a list that rises then falls

hard

A list rises to a single highest value and then falls. Find that peak without checking every element.

Stuck? Three hints, smallest first
  1. 1. Compare the midpoint with its right-hand neighbour to decide which half holds the peak.
  2. 2. If list[mid] < list[mid + 1] the peak is to the right, otherwise it is at mid or to the left.
  3. 3. Use while (low < high) and never look at mid + 1 out of range.

The answer

function findPeak(values) {
  if (values.length === 0) return -1;

  let low = 0;
  let high = values.length - 1;

  // low < high, so mid + 1 is always in range.
  while (low < high) {
    const mid = low + Math.floor((high - low) / 2);
    if (values[mid] < values[mid + 1]) low = mid + 1;   // still climbing
    else high = mid;                                     // at or past the peak
  }
  return low;
}

const linearPeak = (values) => values.indexOf(Math.max(...values));

const cases = [
  [1, 3, 7, 12, 9, 4],
  [1, 2, 3, 4],          // rises the whole way: peak is last
  [9, 5, 2],             // falls the whole way: peak is first
  [42],
];

for (const c of cases) {
  console.log(JSON.stringify(c).padEnd(20), 'peak at', findPeak(c), '=', c[findPeak(c)]);
}

Output


      
8 checks that prove this answer

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

8 checks

test('a peak in the middle', findPeak([1, 3, 7, 12, 9, 4]), 3);
test('a list that only rises peaks at the end', findPeak([1, 2, 3, 4]), 3);
test('a list that only falls peaks at the start', findPeak([9, 5, 2]), 0);
test('a single element', findPeak([42]), 0);
test('two rising', findPeak([1, 2]), 1);
test('two falling', findPeak([2, 1]), 0);
test('an empty list', findPeak([]), -1);
test('it agrees with the linear scan on every case', cases.every((c) => findPeak(c) === linearPeak(c)), true);

while (low < high) rather than <= is what makes values[mid + 1] safe: while the range holds two or more elements, mid is always below high, so mid + 1 exists. Cross-checking against a plain linear scan of Math.max is the cheap way to be confident, since the two use completely different reasoning to reach the same answer.

Answers JS Searching 5 in the original set.

The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

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