Binary Search
Halve the search space every step. Easy to state, famously fiddly to write correctly, and far more widely applicable than "find a number in a sorted array".
By the end: Write binary search from memory, including searching on the answer.
The list is sorted. Look at the middle. If the target is smaller, the whole right half is irrelevant; if larger, the whole left half is. Repeat.
Every step throws away half of what remains, so a million items take about twenty steps. That is the power of O(log n), and it is why this shows up constantly.
function binarySearch(arr, target) {
let lo = 0, hi = arr.length - 1, steps = 0;
while (lo <= hi) {
steps++;
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] === target) return { index: mid, steps };
if (arr[mid] < target) lo = mid + 1; // target is to the right
else hi = mid - 1; // target is to the left
}
return { index: -1, steps };
}
const million = Array.from({ length: 1000000 }, (_, i) => i * 2);
console.log('find 1999998:', binarySearch(million, 1999998));
console.log('find 0: ', binarySearch(million, 0));
console.log('find missing:', binarySearch(million, 7));
console.log('a linear scan would take up to 1,000,000 steps');
Output
Pick one convention and never deviate
I recommend the inclusive form: hi is a real index, the loop runs while lo <= hi, and both sides always move past mid. Because both sides move, the range always shrinks, so it cannot loop forever.
let lo = 0, hi = arr.length - 1; // hi is INCLUSIVE
while (lo <= hi) { // so <= , not <
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid + 1; // both sides move PAST mid
else hi = mid - 1;
}
return -1; // lo is now the insertion point
// Bug 1: hi = mid instead of mid - 1, with an inclusive loop condition.
function buggyOne(arr, target) {
let lo = 0, hi = arr.length - 1, guard = 0;
while (lo <= hi) {
if (++guard > 50) return 'INFINITE LOOP';
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid + 1;
else hi = mid; // never shrinks when lo === hi
}
return -1;
}
// Bug 2: lo = mid instead of mid + 1.
function buggyTwo(arr, target) {
let lo = 0, hi = arr.length - 1, guard = 0;
while (lo <= hi) {
if (++guard > 50) return 'INFINITE LOOP';
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid; // stuck when hi === lo + 1
else hi = mid - 1;
}
return -1;
}
console.log('buggy hi = mid:', buggyOne([1, 3, 5], 2));
console.log('buggy lo = mid:', buggyTwo([1, 3, 5], 4));
console.log('correct version returns -1 for both, quickly');
Output
The version you actually need most: boundaries
Plain "find the target" is the least useful variant. Real questions ask for the first position satisfying a condition: the first element not less than x, the leftmost occurrence, the insertion point. That is lowerBound, and it is worth knowing cold.
// First index where arr[i] >= target. Returns arr.length if none.
function lowerBound(arr, target) {
let lo = 0, hi = arr.length; // hi is EXCLUSIVE here
while (lo < hi) { // so < , not <=
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] < target) lo = mid + 1;
else hi = mid; // keep mid as a candidate
}
return lo;
}
// First index where arr[i] > target.
function upperBound(arr, target) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (arr[mid] <= target) lo = mid + 1;
else hi = mid;
}
return lo;
}
const a = [1, 2, 2, 2, 3, 5, 5, 8];
console.log('array:', a);
console.log('lowerBound(2) =', lowerBound(a, 2), '← first 2');
console.log('upperBound(2) =', upperBound(a, 2), '← just past the last 2');
console.log('count of 2s =', upperBound(a, 2) - lowerBound(a, 2));
console.log('lowerBound(4) =', lowerBound(a, 4), '← insertion point for 4');
console.log('lowerBound(9) =', lowerBound(a, 9), '← past the end');
console.log('lowerBound(0) =', lowerBound(a, 0), '← before everything');
Output
function lowerBound(arr, t) { let lo = 0, hi = arr.length; while (lo < hi) { const m = (lo + hi) >> 1; arr[m] < t ? lo = m + 1 : hi = m; } return lo; }
function upperBound(arr, t) { let lo = 0, hi = arr.length; while (lo < hi) { const m = (lo + hi) >> 1; arr[m] <= t ? lo = m + 1 : hi = m; } return lo; }
function searchRange(arr, target) {
const first = lowerBound(arr, target);
if (first === arr.length || arr[first] !== target) return [-1, -1];
return [first, upperBound(arr, target) - 1];
}
console.log(searchRange([5, 7, 7, 8, 8, 10], 8)); // [3,4]
console.log(searchRange([5, 7, 7, 8, 8, 10], 6)); // [-1,-1]
console.log(searchRange([], 0)); // [-1,-1]
console.log(searchRange([1], 1)); // [0,0]
Output
Binary search on the answer
This is the technique that separates people who have memorised binary search from people who understand it. The array does not have to exist. If you can ask a yes/no question about a candidate answer, and the answers are monotonic — once it becomes true it stays true — then you can binary search the answer space.
// Find the largest k with k*k <= n. The predicate "k*k <= n" is monotonic:
// true for small k, false for large k. So binary search for the boundary.
function isqrt(n) {
if (n < 2) return n;
let lo = 1, hi = n, answer = 1;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (mid * mid <= n) { answer = mid; lo = mid + 1; } // feasible: try bigger
else hi = mid - 1; // too big
}
return answer;
}
for (const n of [0, 1, 4, 8, 16, 24, 2147395600]) {
console.log(`isqrt(${n}) = ${isqrt(n)}`);
}
Output
// Piles of bananas, h hours available. Eating speed k per hour means pile p
// takes ceil(p / k) hours. Find the smallest k that finishes in time.
function minEatingSpeed(piles, h) {
const hoursNeeded = (k) => piles.reduce((sum, p) => sum + Math.ceil(p / k), 0);
let lo = 1, hi = Math.max(...piles); // speed 1 is slowest, max is fastest useful
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (hoursNeeded(mid) <= h) hi = mid; // feasible: this might be the answer
else lo = mid + 1; // too slow
}
return lo;
}
console.log(minEatingSpeed([3, 6, 7, 11], 8)); // 4
console.log(minEatingSpeed([30, 11, 23, 4, 20], 5)); // 30
console.log(minEatingSpeed([30, 11, 23, 4, 20], 6)); // 23
console.log(minEatingSpeed([1], 1)); // 1
Output
Notice the structure: a feasible(candidate) function, a known range, and a binary search for the boundary. Once you see it, a dozen apparently different problems become the same problem with a different feasible.
function splitArray(nums, k) {
// Can we do it with no part exceeding `limit`? Greedy: fill parts in order.
const canDo = (limit) => {
let parts = 1, current = 0;
for (const x of nums) {
if (x > limit) return false;
if (current + x > limit) { parts++; current = x; }
else current += x;
}
return parts <= k;
};
let lo = Math.max(...nums); // no part can be smaller than this
let hi = nums.reduce((a, b) => a + b, 0); // one part holding everything
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (canDo(mid)) hi = mid;
else lo = mid + 1;
}
return lo;
}
console.log(splitArray([7, 2, 5, 10, 8], 2)); // 18
console.log(splitArray([1, 2, 3, 4, 5], 2)); // 9
console.log(splitArray([1, 4, 4], 3)); // 4
Output
Rotated arrays
A sorted array rotated at an unknown point is still searchable in O(log n), because at least one half of any range is properly sorted, and you can tell which by comparing the ends.
function searchRotated(nums, target) {
let lo = 0, hi = nums.length - 1;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (nums[mid] === target) return mid;
if (nums[lo] <= nums[mid]) {
// Left half is sorted. Is the target inside it?
if (nums[lo] <= target && target < nums[mid]) hi = mid - 1;
else lo = mid + 1;
} else {
// Right half is sorted.
if (nums[mid] < target && target <= nums[hi]) lo = mid + 1;
else hi = mid - 1;
}
}
return -1;
}
const rotated = [4, 5, 6, 7, 0, 1, 2];
for (const t of [0, 4, 2, 3]) console.log(`search ${t}:`, searchRotated(rotated, t));
console.log('single element:', searchRotated([1], 1), searchRotated([1], 0));
Output
function findMin(nums) {
let lo = 0, hi = nums.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
// If mid is greater than the last element, the rotation point is to the right.
if (nums[mid] > nums[hi]) lo = mid + 1;
else hi = mid; // mid could be the minimum, so keep it
}
return nums[lo];
}
console.log(findMin([3, 4, 5, 1, 2])); // 1
console.log(findMin([4, 5, 6, 7, 0, 1, 2])); // 0
console.log(findMin([11, 13, 15, 17])); // 11, not rotated
console.log(findMin([2, 1])); // 1
Output
Two dimensions and two arrays
// Each row is sorted left to right, each column top to bottom.
// Start at the top-right: too big means go left, too small means go down.
// O(rows + cols), which beats binary searching every row.
function searchMatrix(matrix, target) {
if (matrix.length === 0) return false;
let r = 0, c = matrix[0].length - 1;
const path = [];
while (r < matrix.length && c >= 0) {
path.push(`(${r},${c})=${matrix[r][c]}`);
if (matrix[r][c] === target) { console.log(' path:', path.join(' → ')); return true; }
if (matrix[r][c] > target) c--;
else r++;
}
console.log(' path:', path.join(' → '));
return false;
}
const matrix = [
[1, 4, 7, 11],
[2, 5, 8, 12],
[3, 6, 9, 16],
[10, 13, 14, 17],
];
console.log('find 5:', searchMatrix(matrix, 5));
console.log('find 20:', searchMatrix(matrix, 20));
Output
Your turn: five binary searches
searchInsert(nums, target)— the index of the target, or where it would be inserted. This islowerBound.firstBadVersion(n, isBad)— versions 1..n, all bad from some point on. Find the first bad one with the fewest calls toisBad.peakIndex(nums)— the array rises then falls; find the peak inO(log n). Comparenums[mid]withnums[mid+1].shipWithinDays(weights, days)— minimum ship capacity to send all packages in order withindays. Answer-space search with a greedy check.findMedianSortedArrays(a, b)— the hard one. The median of two sorted arrays inO(log(min(m,n))). Binary search the split point of the shorter array. Try it; theO(m+n)merge is a fine fallback.
Output
Recap
- Binary search halves the range each step, so a million items take about twenty comparisons.
- Pick one convention and hold it: inclusive
hiwithlo <= hiand both sides moving pastmid, or exclusivehiwithlo < hiandhi = mid. Mixing them causes infinite loops. lowerBound— the first index not less than the target — is more useful than plain search, and gives you counts, ranges and insertion points.- When a problem asks for a minimum or maximum value and checking a candidate is easy, binary search the answer using a monotonic
feasible()predicate. - In a rotated array one half is always sorted; compare against the end to work out which.