Sorting and Searching
Searching and sorting are where complexity theory pays off most directly: the difference between O(n) and O(log n) lookups, or O(n^2) and O(n log n) sorts, is the difference between code that scales and code that does not.
Binary search
If the data is sorted, you never need to scan it. Compare against the middle, discard the half that cannot contain the target, repeat:
function binarySearch(sorted, target) {
let lo = 0;
let hi = sorted.length - 1;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (sorted[mid] === target) return mid;
if (sorted[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
const ids = [3, 8, 15, 23, 42, 57, 91];
console.log(binarySearch(ids, 42)); // 4
console.log(binarySearch(ids, 50)); // -1
O(log n): a million elements resolve in at most 20 comparisons. The classic implementation bugs to avoid:
- Off-by-one boundaries. Loop while
lo <= hi, and move tomid + 1/mid - 1, never tomiditself — that causes infinite loops. - Unsorted input. Binary search on unsorted data returns garbage, not an error.
- Textbooks compute the midpoint as
lo + (hi - lo) / 2to avoid integer overflow; JavaScript numbers make this a non-issue at realistic sizes, but expect the question in interviews.
Binary search generalizes beyond exact matches: first/last occurrence, smallest element satisfying a condition, search in a rotated sorted array. Any monotonic "no no no yes yes yes" condition can be binary-searched.
When is sorting first worth it?
Searching an unsorted array once is O(n) — sorting first would cost O(n log n) and gain nothing. Sorting pays when you search repeatedly: sort once, then answer q queries at O(log n) each instead of O(n) each. For pure membership checks, a Set beats both.
The classic sorts
| Algorithm | Average | Worst | Space | Stable | Notes |
|---|---|---|---|---|---|
| Bubble sort | O(n^2) | O(n^2) | O(1) | Yes | Teaching tool, never used in practice |
| Insertion sort | O(n^2) | O(n^2) | O(1) | Yes | Excellent on tiny or nearly-sorted data |
| Selection sort | O(n^2) | O(n^2) | O(1) | No | Minimal swaps, otherwise unremarkable |
| Merge sort | O(n log n) | O(n log n) | O(n) | Yes | Predictable, the standard divide-and-conquer |
| Quicksort | O(n log n) | O(n^2) | O(log n) | No | Fast in practice, worst case on adversarial input |
| Heap sort | O(n log n) | O(n log n) | O(1) | No | In-place guaranteed O(n log n) |
Stable means equal elements keep their original relative order — it matters when sorting by one field of records that were already ordered by another.
Insertion sort
Grow a sorted prefix by inserting each new element into its place — how most people sort playing cards:
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
const current = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
console.log(insertionSort([5, 2, 9, 1, 7])); // [1, 2, 5, 7, 9]
Quadratic in general, but O(n) on nearly-sorted input — which is why production sorts hand small partitions to insertion sort.
Merge sort
Split in half, sort each half, merge two sorted halves. The merge step is where the work happens:
function mergeSort(arr) {
if (arr.length <= 1) return arr;
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid));
const right = mergeSort(arr.slice(mid));
const merged = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) merged.push(left[i++]);
else merged.push(right[j++]);
}
return [...merged, ...left.slice(i), ...right.slice(j)];
}
console.log(mergeSort([38, 27, 43, 3, 9, 82, 10])); // [3, 9, 10, 27, 38, 43, 82]
log n levels of splitting, O(n) merge work per level: O(n log n) always. The merge technique itself — walking two sorted sequences with two pointers — reappears constantly: merging sorted lists, intersecting sorted arrays, counting inversions.
When to just use .sort
Almost always. Array.prototype.sort is a highly tuned O(n log n) hybrid, and the spec has guaranteed stability since ES2019. Implementing quicksort by hand is for interviews; calling .sort is for everything else.
The one real gotcha — the default comparator sorts by string representation, even for numbers:
console.log([10, 1, 5, 2].sort()); // [1, 10, 2, 5] — lexicographic!
console.log([10, 1, 5, 2].sort((a, b) => a - b)); // [1, 2, 5, 10]
The comparator contract: return a negative number to put a first, positive to put b first, zero to treat them as equal.
const team = [
{ name: "Hafsa", score: 92 },
{ name: "Rizwan", score: 78 },
{ name: "Zakariya", score: 92 },
{ name: "Ayesha", score: 85 },
];
team.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
console.log(team.map((m) => m.name)); // ["Hafsa", "Zakariya", "Ayesha", "Rizwan"]
The || chains tie-breakers: sort by score descending, then by name for equal scores. Two more notes:
.sortmutates the array. Usearr.toSorted(cmp)(ES2023) or sort a copy when the original must survive.- For human-friendly string order, use
a.localeCompare(b)instead of comparison operators.
When might you not use .sort? When you do not need a full sort: the k largest items (a heap, or quickselect at O(n) average), values from a tiny fixed range (counting sort at O(n + k)), or checking whether data is already sorted (a single O(n) scan).
Practice problems: the Sorting and Divide & Conquer sections of the practice catalog — the binary search problems live under Divide & Conquer.