../

Sorting & searching

Built-in sorts in TypeScript, JavaScript and Python and their traps, a comparison of the classic algorithms, short implementations, quickselect, and binary search templates you can adapt without off-by-one bugs. Heaps (MinHeap<T>, heapq) live on Trees & graphs; growth rates on Big-O; problems that start with "sort first" on Problem patterns.

Built-in sort

JavaScript / TypeScriptPython
In placexs.sort(cmp?): mutates and returns xsxs.sort(key=…, reverse=…): returns None
Copyxs.toSorted(cmp?) (ES2023)sorted(iterable, key=…, reverse=…)
Default orderstring order of UTF-16 code units< on the elements (mixed types raise TypeError)
Custom ordercomparator (a, b) => numberkey= function, called once per element
Stableyes, required since ES2019yes, guaranteed
AlgorithmTimSort in V8 (Node, Chrome); a stable merge sort elsewhereTimsort (adaptive merge sort)
CostO(nlog⁡n)O(n \log n) time, O(n)O(n) extra space; O(n)O(n) on sorted inputsame

The default-sort trap

const nums = [10, 9, 1];
nums.sort();                 // [1, 10, 9]: as strings!
nums.sort((a, b) => a - b);  // [1, 9, 10]
nums.sort((a, b) => b - a);  // [10, 9, 1]
 
// toSorted copies; the original is untouched
const asc = [3, 1, 2].toSorted((a, b) => a - b);
 
const words = ["b", "a", "C"];
words.toSorted();            // ["C", "a", "b"]
words.toSorted((a, b) => a.localeCompare(b));
// ["a", "b", "C"]
  • The comparator returns a negative number (a first), positive (b first) or 0 (keep order). It must be consistent: same answer for the same pair, and transitive. Otherwise the order is implementation-defined.
  • (a, b) => a > b returns a boolean: JavaScript quietly misorders, TypeScript rejects it. a - b is fine for finite numbers; for strings use localeCompare or a < b ? -1 : a > b ? 1 : 0.
  • undefined elements always go to the end without reaching the comparator; holes go after them.
  • Python's reverse=True keeps equal elements in their original order (it is not reversed(sorted(…))).

Multi-key sorts

WantTypeScriptPython
Key A, then BcmpA(a, b) || cmpB(a, b)key=lambda x: (x.a, x.b)
A descending, numericb.a - a.a || …negate: (-x.a, x.b)
A descending, stringswap the operands in localeComparetwo stable passes: sort by B, then by A with reverse=True
By field(a, b) => a.age - b.agekey=attrgetter("age") or itemgetter(1)

type Player = { name: string; score: number };
 
const players: Player[] = [
  { name: "Bo", score: 90 },
  { name: "Al", score: 90 },
  { name: "Cy", score: 75 },
];
// score descending, then name ascending
const ranked = players.toSorted(
  (a, b) =>
    b.score - a.score || a.name.localeCompare(b.name),
);
// Al 90, Bo 90, Cy 75

Comparator functions in Python

When the order is a relation between two items rather than a key per item, wrap a comparator with functools.cmp_to_key. "Largest Number" (arrange numbers to form the biggest concatenation) is the classic case: a goes first when a + b > b + a.

function largestNumber(nums: number[]): string {
  const cmp = (a: string, b: string): number =>
    a + b > b + a ? -1 : a + b < b + a ? 1 : 0;
  const s = nums.map(String).sort(cmp).join("");
  return s.startsWith("0") ? "0" : s;
}

O(nlog⁡n)O(n \log n) comparisons, each O(L)O(L) for strings of length LL.

Algorithm comparison

AlgorithmBestAverageWorstExtra spaceStableIn placeUse when
Bubblennn2n^2n2n^211yesyesnever; teaching only
Insertionnnn2n^2n2n^211yesyestiny (≤ 16–32) or nearly sorted input; Timsort uses it for short runs
Selectionn2n^2n2n^2n2n^211noyeswrites are expensive: at most nn swaps
Mergenlog⁡nn \log nnlog⁡nn \log nnlog⁡nn \log nnnyesnostability, linked lists, external (on-disk) sorting
Quicknlog⁡nn \log nnlog⁡nn \log nn2n^2log⁡n\log n stack (expected)noyesfastest in-memory in practice; random pivot makes n2n^2 unlikely
Heapnlog⁡nn \log nnlog⁡nn \log nnlog⁡nn \log n11noyesguaranteed nlog⁡nn \log n with O(1)O(1) space; introsort's fallback
Timsortnnnlog⁡nn \log nnlog⁡nn \log nnnyesnowhat the built-ins use: exploits existing runs
Countingn+kn + kn+kn + kn+kn + kn+kn + kyesnointeger keys in a small range kk
Radix (LSD)d(n+b)d(n + b)d(n+b)d(n + b)d(n+b)d(n + b)n+bn + byesnofixed-width integers or strings: dd digits in base bb
Bucketn+kn + kn+kn + kn2n^2n+kn + kyes*nofloats spread uniformly over a known range

* if the per-bucket sort is stable. Any comparison sort needs Ω(nlog⁡n)\Omega(n \log n) comparisons in the worst case; counting, radix and bucket sort beat that by looking at the key's value, not by comparing.

TermMeaning
Stableequal keys keep their input order, so sorting by B then by A gives "A, ties broken by B"
In placeO(1)O(1) (or O(log⁡n)O(\log n) stack) extra memory
Adaptivefaster when the input is already partly sorted (insertion, Timsort)

Implementations

In real code call the built-in. Write these in interviews or when you need a variant (partial sort, custom partition, counting on a small range).

Insertion sort

function insertionSort(xs: number[]): number[] {
  for (let i = 1; i < xs.length; i++) {
    const x = xs[i];
    let j = i - 1;
    while (j >= 0 && xs[j] > x) {
      xs[j + 1] = xs[j]; // shift bigger items right
      j--;
    }
    xs[j + 1] = x;
  }
  return xs;
}

O(n2)O(n^2) time, O(n)O(n) when nearly sorted; O(1)O(1) space; stable (strict > never jumps over an equal item).

Merge sort

function mergeSort(xs: number[]): number[] {
  if (xs.length <= 1) return xs.slice();
  const mid = xs.length >> 1;
  return merge(
    mergeSort(xs.slice(0, mid)),
    mergeSort(xs.slice(mid)),
  );
}
 
function merge(a: number[], b: number[]): number[] {
  const out: number[] = [];
  let i = 0;
  let j = 0;
  while (i < a.length && j < b.length) {
    // <= takes from the left on ties: stable
    out.push(a[i] <= b[j] ? a[i++] : b[j++]);
  }
  return out.concat(a.slice(i), b.slice(j));
}

T(n)=2T(n/2)+O(n)=O(nlog⁡n)T(n) = 2T(n/2) + O(n) = O(n \log n) time, O(n)O(n) space. merge alone is "Merge Sorted Array"; the same split-and-merge counts inversions.

Quicksort (Lomuto, random pivot)

function quickSort(
  xs: number[],
  lo = 0,
  hi = xs.length - 1,
): number[] {
  if (lo < hi) {
    const p = partition(xs, lo, hi);
    quickSort(xs, lo, p - 1);
    quickSort(xs, p + 1, hi);
  }
  return xs;
}
 
// Pivot ends at its final index p:
// xs[lo..p-1] < pivot <= xs[p+1..hi]
function partition(
  xs: number[],
  lo: number,
  hi: number,
): number {
  const r = lo + Math.floor(Math.random() * (hi - lo + 1));
  [xs[r], xs[hi]] = [xs[hi], xs[r]];
  const pivot = xs[hi];
  let i = lo; // next slot for an item < pivot
  for (let j = lo; j < hi; j++) {
    if (xs[j] < pivot) {
      [xs[i], xs[j]] = [xs[j], xs[i]];
      i++;
    }
  }
  [xs[i], xs[hi]] = [xs[hi], xs[i]];
  return i;
}

O(nlog⁡n)O(n \log n) expected, O(n2)O(n^2) worst; O(log⁡n)O(\log n) expected stack. Many equal keys still degrade Lomuto to n2n^2: use a three-way partition (less / equal / greater, the "Sort Colors" Dutch-flag loop). Hoare's partition does fewer swaps but returns a split point, not the pivot's final index.

Counting sort

// Stable; every key is an integer in [0, k)
function countingSort(xs: number[], k: number): number[] {
  const start = new Uint32Array(k + 1);
  for (const x of xs) start[x + 1]++;
  // prefix sums: start[v] = first output slot for v
  for (let v = 0; v < k; v++) start[v + 1] += start[v];
  const out = new Array<number>(xs.length);
  for (const x of xs) out[start[x]++] = x;
  return out;
}

O(n+k)O(n + k) time and space. To sort records, place the record and index by its key. Radix sort runs this stable pass once per digit, least significant first.

Heap sort

Push everything into a min-heap and pop nn times: O(nlog⁡n)O(n \log n). With the MinHeap<T> class that is a loop of push then pop; in Python, heapq.heapify(xs) then heappop nn times. The in-place version builds a max-heap inside the array and swaps the root to the end: O(1)O(1) extra space, but not stable. Rarely worth writing: heaps earn their keep in "top k" and "merge k sorted lists", both recipes on Trees & graphs.

Quickselect

The kk-th smallest element in O(n)O(n) expected time: partition like quicksort, then continue into the one side that holds index kk. Reuses partition from the quicksort above.

// k is 0-based: k = 0 is the minimum. Reorders xs.
function quickSelect(xs: number[], k: number): number {
  let lo = 0;
  let hi = xs.length - 1;
  while (lo < hi) {
    const p = partition(xs, lo, hi);
    if (p === k) return xs[p];
    if (p < k) lo = p + 1;
    else hi = p - 1;
  }
  return xs[lo];
}
// kth largest (1-based k): quickSelect(xs, n - k)
Approach for the kk-th / top kkTimeSpaceNotes
Sort, then indexO(nlog⁡n)O(n \log n)O(n)O(n) or O(1)O(1)simplest; fine unless told otherwise
QuickselectO(n)O(n) expected, O(n2)O(n^2) worstO(1)O(1)mutates; random pivot
Heap of size kkO(nlog⁡k)O(n \log k)O(k)O(k)streams; recipe on Trees & graphs; heapq.nlargest(k, xs)
Median of mediansO(n)O(n) worstO(log⁡n)O(\log n)theory; large constant

Works on anything monotone: a sorted array, or a yes/no predicate that is false…false then true…true. Each step halves the range: O(log⁡n)O(\log n) time, O(1)O(1) space.

// Index of t in sorted xs, or -1
function binarySearch(xs: number[], t: number): number {
  let lo = 0;
  let hi = xs.length - 1; // closed range [lo, hi]
  while (lo <= hi) {
    const mid = (lo + hi) >>> 1;
    if (xs[mid] === t) return mid;
    if (xs[mid] < t) lo = mid + 1;
    else hi = mid - 1;
  }
  return -1;
}
  • (lo + hi) >>> 1 halves without floats and is safe while lo + hi stays below 2³², which covers every real JS array. The lo + (hi - lo) / 2 idiom matters in Java or C++, where lo + hi can overflow.
  • With duplicates this returns some matching index. For the first or last one, use the bounds below.

Lower and upper bound

Half-open templates: the answer is an insertion point in [0, n], so there is no "not found" case to special-case. JavaScript has no built-in; Python has bisect.

// First i with xs[i] >= t (xs.length if none)
function lowerBound(xs: number[], t: number): number {
  let lo = 0;
  let hi = xs.length; // half-open [lo, hi)
  while (lo < hi) {
    const mid = (lo + hi) >>> 1;
    if (xs[mid] < t) lo = mid + 1;
    else hi = mid;
  }
  return lo;
}
 
// First i with xs[i] > t (xs.length if none)
function upperBound(xs: number[], t: number): number {
  let lo = 0;
  let hi = xs.length;
  while (lo < hi) {
    const mid = (lo + hi) >>> 1;
    if (xs[mid] <= t) lo = mid + 1;
    else hi = mid;
  }
  return lo;
}
 
const xs = [1, 2, 2, 2, 5];
lowerBound(xs, 2);                    // 1
upperBound(xs, 2);                    // 4
upperBound(xs, 2) - lowerBound(xs, 2); // 3 copies
xs.splice(lowerBound(xs, 3), 0, 3);   // keep sorted
Question on sorted xsAnswer
First index with value ≥ tlowerBound / bisect_left
First index with value greater than tupperBound / bisect_right
Is t present?i = lowerBound(xs, t), then i < n && xs[i] === t
How many equal tupperBound - lowerBound
Last index with value ≤ tupperBound - 1 (−1 means none)
Last index with value less than tlowerBound - 1
Count in [a, b]upperBound(b) - lowerBound(a)
Insert and stay sortedsplice at lowerBound / insort: O(log⁡n)O(\log n) search, O(n)O(n) shift

For many inserts plus ordered queries, reach for a balanced tree or sortedcontainers.SortedList (third party) instead.

Rotated sorted arrays

A sorted array rotated at an unknown pivot ([4, 5, 6, 7, 0, 1, 2]). At every mid, one half is sorted; check whether the target lies in that half's range.

// Distinct values. Index of t, or -1.
function searchRotated(xs: number[], t: number): number {
  let lo = 0;
  let hi = xs.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >>> 1;
    if (xs[mid] === t) return mid;
    if (xs[lo] <= xs[mid]) {
      // left half [lo, mid] is sorted
      if (xs[lo] <= t && t < xs[mid]) hi = mid - 1;
      else lo = mid + 1;
    } else {
      // right half [mid, hi] is sorted
      if (xs[mid] < t && t <= xs[hi]) lo = mid + 1;
      else hi = mid - 1;
    }
  }
  return -1;
}
 
// Minimum = the rotation point
function findMin(xs: number[]): number {
  let lo = 0;
  let hi = xs.length - 1;
  while (lo < hi) {
    const mid = (lo + hi) >>> 1;
    if (xs[mid] > xs[hi]) lo = mid + 1; // min is right
    else hi = mid; // mid could be the min
  }
  return xs[lo];
}

O(log⁡n)O(\log n) time, O(1)O(1) space. With duplicates, xs[mid] === xs[hi] tells you nothing: shrink with hi--, which makes the worst case O(n)O(n).

Binary search on the answer

When the question is "the smallest X such that feasible(X)" and feasibility is monotone (if capacity 10 works, 11 works), binary search over X instead of over an array. Cost: log⁡2\log_2 of the range, times one feasible call. Example, "Capacity To Ship Packages Within D Days": the least ship capacity that moves the weights, in order, within days.

function shipWithinDays(ws: number[], days: number): number {
  const fits = (cap: number): boolean => {
    let used = 1;
    let load = 0;
    for (const w of ws) {
      if (load + w > cap) {
        used++; // start a new day
        load = 0;
      }
      load += w;
    }
    return used <= days;
  };
  // Invariant: the answer is in [lo, hi]
  let lo = ws.reduce((a, b) => Math.max(a, b)); // biggest
  let hi = ws.reduce((a, b) => a + b); // one day: fits
  while (lo < hi) {
    const mid = (lo + hi) >>> 1; // round down: hi = mid
    if (fits(mid)) hi = mid; // mid works: maybe smaller
    else lo = mid + 1; // mid fails: answer is above
  }
  return lo;
}

O(nlog⁡S)O(n \log S) time where SS = sum(ws), O(1)O(1) space. Same shape: "Koko Eating Bananas", "Split Array Largest Sum", "Minimum Number of Days to Make m Bouquets", integer square root.

Invariant checklist

DecideClosed [lo, hi] (find exact)Converging [lo, hi] (find boundary)
What lo, hi meantarget, if present, is inside; everything outside is ruled outthe answer is always inside (use hi = n for "none")
Loop conditionlo <= hilo < hi
Updateslo = mid + 1, hi = mid - 1lo = mid + 1, hi = mid
mid roundingeitherdown when the branch is hi = mid; up ((lo + hi + 1) >>> 1) when it is lo = mid
After the loopnot foundlo === hi is the answer
  • Write down the predicate and check it really is false…false true…true (or the reverse).
  • Initialize lo and hi so the answer is inside, including the edges (smallest and largest possible).
  • Every branch must shrink the range; lo = mid with round-down mid loops forever on two elements.
  • Trace a 1-element and a 2-element range by hand before you trust it.
  • Floats: loop a fixed number of times (e.g. 100) or until hi - lo is below a tolerance.

Recipes

Sort Colors: three-way partition

Sort an array of 0s, 1s and 2s in one pass and O(1)O(1) space (the Dutch national flag). The same loop with "less than / equal to / greater than the pivot" is the partition that keeps quicksort fast on repeated keys. O(n)O(n) time. More two-pointer patterns are on Problem patterns.

function sortColors(xs: number[]): number[] {
  let lo = 0; // xs[0..lo) are 0
  let mid = 0; // xs[lo..mid) are 1
  let hi = xs.length - 1; // xs(hi..] are 2
  while (mid <= hi) {
    if (xs[mid] === 0) {
      [xs[lo], xs[mid]] = [xs[mid], xs[lo]];
      lo++;
      mid++;
    } else if (xs[mid] === 1) {
      mid++;
    } else {
      [xs[mid], xs[hi]] = [xs[hi], xs[mid]];
      hi--; // keep mid: the swapped-in value is unseen
    }
  }
  return xs;
}

Find Peak Element

Binary search works without a sorted array when you can always tell which side holds an answer: walk uphill. Neighbors are distinct and both ends count as −∞-\infty, so a peak always exists. O(log⁡n)O(\log n).

// An index whose value beats both neighbors
function findPeak(xs: number[]): number {
  let lo = 0;
  let hi = xs.length - 1;
  while (lo < hi) {
    const mid = (lo + hi) >>> 1;
    if (xs[mid] < xs[mid + 1]) lo = mid + 1; // uphill
    else hi = mid; // downhill: a peak at mid or left
  }
  return lo;
}

Custom order and argsort

Sort by a rank table instead of the natural order, and get the indices that would sort an array (argsort). O(nlog⁡n)O(n \log n).

const rank = new Map([["high", 0], ["mid", 1], ["low", 2]]);
const byRank = (p: string): number => rank.get(p) ?? 99;
 
const pri = ["low", "high", "mid", "high"];
pri.sort((a, b) => byRank(a) - byRank(b));
// ["high", "high", "mid", "low"]
 
const xs = [30, 10, 20];
const idx = xs.map((_, i) => i)
  .sort((a, b) => xs[a] - xs[b]); // [1, 2, 0]

One function covers "First Bad Version", integer square root and most answer searches. O(log⁡n)O(\log n) predicate calls.

// Smallest x in [lo, hi) with ok(x); hi if none.
// ok must be false…false then true…true.
function firstTrue(
  lo: number,
  hi: number,
  ok: (x: number) => boolean,
): number {
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    if (ok(mid)) hi = mid;
    else lo = mid + 1;
  }
  return lo;
}
 
// floor(sqrt(n)): one below the first x with x² > n
const isqrt = (n: number): number =>
  firstTrue(0, n + 1, (x) => x * x > n) - 1;

In Python, bisect works on any sequence, including a range: bisect_left(range(lo, hi), True, key=ok) returns the same offset from lo (3.10+ for key=).

Search a sorted matrix

Rows sorted and each row starts after the previous one ends ("Search a 2D Matrix"): treat it as one sorted array of rows × cols and map a flat index to a cell. O(log⁡(rc))O(\log(rc)). If only rows and columns are sorted separately ("Search a 2D Matrix II"), start at the top-right corner and step left or down: O(r+c)O(r + c).

function searchMatrix(m: number[][], t: number): boolean {
  const cols = m[0]?.length ?? 0;
  let lo = 0;
  let hi = m.length * cols - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >>> 1;
    const v = m[Math.floor(mid / cols)][mid % cols];
    if (v === t) return true;
    if (v < t) lo = mid + 1;
    else hi = mid - 1;
  }
  return false;
}

References