JavaScript: Searching
Linear and binary search by hand.
5 exercises
1 Linear search
easyWalk 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. Return the index, not
true. An index is strictly more useful and still tells you it was found. - 2. Return
-1for not found, matchingindexOf, so callers already know the convention. - 3. This is the only search that works on unsorted data, which is its whole point.
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.
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
fairSearch 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. The list must already be sorted. Binary search on unsorted data returns nonsense, quietly.
- 2. Use
while (low <= high). With<you miss the case where the range is one element. - 3.
low + Math.floor((high - low) / 2)avoids an overflow that(low + high) / 2can cause in other languages.
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.
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
fairIn 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. This is binary search with the equality case removed, so it never stops early.
- 2. When the loop ends,
lowis the insertion point. - 3. Two variants: leftmost position, or rightmost. They differ only in one comparison.
// 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.
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
easyFind 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.
findreturns the item orundefined;findIndexreturns the position or-1. - 2. For repeated lookups on the same data, build a
Maponce instead of searching every time. - 3. A linear
findis O(n) per lookup; a Map is O(1) after an O(n) setup.
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.
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
hardA list rises to a single highest value and then falls. Find that peak without checking every element.
Stuck? Three hints, smallest first
- 1. Compare the midpoint with its right-hand neighbour to decide which half holds the peak.
- 2. If
list[mid] < list[mid + 1]the peak is to the right, otherwise it is atmidor to the left. - 3. Use
while (low < high)and never look atmid + 1out of range.
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.
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.