Sorting & Binary Search: Order Is a Superpower
What you'll learn
- Why sorted data is so valuable, and how the classic sorting algorithms work
- The difference between O(n²) sorts and O(n log n) sorts, and what stable means
- How to use your language's built-in sort with a comparator, and the classic bug that goes with it
- Binary search: finding anything in a sorted list of a million items in about 20 steps
- The lower-bound template that avoids off-by-one errors, and how to binary-search on an answer
- You will write: a leaderboard sort, a binary-search query engine and a merge sort that counts inversions
The bookshelf and the guessing game
Imagine a shelf of 1,000 books in random order. To find one title you must pick up books one by one: in the worst case, all 1,000. Now imagine the same shelf sorted by title. Open it in the middle: if your title comes before the one you see, ignore the right half, otherwise ignore the left half. Repeat. After about 10 looks you are standing at the book (2¹⁰ = 1,024).
It is the same as the guessing game "I'm thinking of a number from 1 to 1,000": the smart player says 500, hears "higher", says 750, and never needs more than 10 guesses. Every guess throws away half of the possibilities.
The price is that someone had to sort the shelf first. This lesson is about both halves of the deal: how to sort efficiently, and how to cash in on the order with binary search.
Sorting: the toolbox
The slow family, O(n²). Bubble, selection and insertion sort compare items in nested loops. For n = 1,000 that is about a million steps; for n = 1,000,000 it is about 10¹², far too slow. Insertion sort deserves a mention: it is O(n²) in the worst case but close to O(n) when the data is almost sorted, so real libraries use it for small pieces.
The fast family, O(n log n).
- Merge sort (divide and conquer): split the list in half, sort each half recursively, then merge the two sorted halves by repeatedly taking the smaller front item. Always O(n log n), needs O(n) extra space, and is naturally stable.
- Quicksort: pick a pivot, partition into "smaller" and "larger", recurse on both. O(n log n) on average and very fast in practice, but a bad pivot choice (for example always the first item of already-sorted data) makes it O(n²).
- Heap sort: O(n log n) worst case with O(1) extra space (you will meet heaps later in this track).
How fast can any comparison sort be? A sort that only compares pairs of items needs at least about n log n comparisons in the worst case. You cannot do better, so O(n log n) is the practical target. If your keys are small integers you can beat it: counting sort runs in O(n + k) for keys in the range 0..k.
Stable sort: items with equal keys keep their original relative order. This matters when you sort twice: sort by name, then stable-sort by grade, and students with equal grades stay in alphabetical order.
In real life you call the built-in sort. Know what it guarantees:
- Python
sorted()/list.sort(): stable (Timsort). JavaScriptArray.prototype.sort: stable since ES2019. Rustslice.sort(): stable (sort_unstableis the faster, unstable one). JavaCollections.sortandArrays.sorton objects: stable. - C++
std::sort: not stable; usestd::stable_sortwhen you need it. JavaArrays.sorton primitive arrays: no stability concept, because equal ints are indistinguishable.
The comparator. To sort by your own rule you pass a function that returns a negative number if a goes first, positive if b goes first and 0 for a tie. Warning for JavaScript: without a comparator, sort() compares items as text, so [10, 9, 1].sort() gives [1, 10, 9]. Always pass (a, b) => a - b for numbers.
The default sort puts 10 and 100 before 2 because the text "10" comes before "2". With a comparator the numbers sort correctly. In the last line Lina stays ahead of Sara and Omar ahead of Yusuf: that is stability.
const nums = [10, 9, 2, 1, 100];
console.log("default sort:", [...nums].sort()); // compares as TEXT
console.log("ascending: ", [...nums].sort((a, b) => a - b));
console.log("descending: ", [...nums].sort((a, b) => b - a));
// Stability: equal grades keep their original order (guaranteed since ES2019)
const students = [
{ name: "Lina", grade: 90 },
{ name: "Omar", grade: 85 },
{ name: "Sara", grade: 90 },
{ name: "Yusuf", grade: 85 },
];
students.sort((a, b) => b.grade - a.grade);
console.log(students.map((s) => s.name + ":" + s.grade).join(" "));
Lab exercise 1
JavaScript (Node.js)Graded in Code LabSort the leaderboard. Rank players by score, highest first. Players with the same score are listed alphabetically by name.
Input: the first line has n. Then n lines follow, each name score (a lowercase name without spaces and an integer score that may be negative or have several digits).
Output: n lines in the form name score, in ranked order. If n is 0, print nothing.
Example: input
4
zed 50
amy 70
bob 70
cy 20
prints
amy 70
bob 70
zed 50
cy 20
Use players.sort(comparator). Compare scores as numbers, and compare names with < and >.
- The example
- Scores 9, 10 and 100 (numbers, not text)
- Negative scores
- Everyone tied: alphabetical order
- A single player
- No players
Example run
- Input
4 zed 50 amy 70 bob 70 cy 20
- Expected output
amy 70 bob 70 zed 50 cy 20
const lines = require("fs").readFileSync(0, "utf8").split("\n");
const n = Number(lines[0]);
const players = [];
for (let i = 1; i <= n; i++) {
const [name, score] = lines[i].split(" ");
players.push({ name, score: Number(score) });
}
// TODO: sort `players` so the highest score comes first.
// When two scores are equal, the name that comes first alphabetically goes first.
// players.sort((a, b) => ...);More lines in Code Lab: 4
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Binary search
Binary search finds a value in a sorted array by repeatedly looking at the middle item and discarding the half that cannot contain the answer. Each step halves the search range, so a million items need at most about 20 steps (2²⁰ = 1,048,576): O(log n).
The one requirement is order. (More precisely: a yes/no question whose answer flips only once, from "no" to "yes". Sorted data gives you that for "is a[i] >= x?".)
Plain "find x" versions are famous for off-by-one bugs. Use one template for everything instead, the lower bound: the first index whose value is at least x (or n if there is none).
def lower_bound(a, x):
lo, hi = 0, len(a) # the answer is somewhere in [lo, hi]
while lo < hi:
mid = lo + (hi - lo) // 2
if a[mid] >= x:
hi = mid # mid might be the answer: keep it
else:
lo = mid + 1 # mid is too small: throw it away
return lo
Why it is safe. The loop keeps one invariant: every item before lo is smaller than x, and every item at hi or after is at least x. The range shrinks on every step and ends with lo == hi, which is the boundary.
Everything else is a one-liner on top of it:
- Is x present?
i = lower_bound(a, x), theni < len(a) and a[i] == x - First occurrence of x:
lower_bound(a, x) - How many copies of x (integers)?
lower_bound(a, x + 1) - lower_bound(a, x) - Where would x be inserted to keep the list sorted?
lower_bound(a, x)(Python'sbisect.bisect_left, C++'sstd::lower_bound)
Two details that bite.
- Overflow. In Java, C or C++ with 32-bit
int,(lo + hi) / 2can overflow whenlo + hiexceeds 2³¹ - 1. Writelo + (hi - lo) / 2. (Python integers do not overflow, but the habit is free.) - Duplicates. Plain "return when a[mid] == x" finds some copy. Lower bound finds the first one every time.
Binary search on the answer. The same template works without any array. Suppose you want the smallest x for which a yes/no check ok(x) is true, and you know that once it is true it stays true for larger x (for example "can the job be done with x workers?" or "is x² at least N?"). Binary-search over the range of possible x, calling ok(mid) instead of reading a[mid]. The demo below does exactly that.
One million items were searched in at most 20 steps. The second search shows that a failed lookup is just as fast. The last line searches over numbers 0 to a billion without any array: about 30 calls to ok().
import bisect
def binary_search(a, target):
lo, hi = 0, len(a) - 1
steps = 0
while lo <= hi:
steps += 1
mid = (lo + hi) // 2
if a[mid] == target:
return mid, steps
if a[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1, steps
a = list(range(0, 2_000_000, 2)) # one million even numbers
print("found 1234566 at index / steps:", binary_search(a, 1_234_566))
print("looking for 7 (absent) index / steps:", binary_search(a, 7))
print("bisect_left(7) =", bisect.bisect_left(a, 7), "(the position where 7 would be inserted)")
def first_true(lo, hi, ok):
"""Smallest x in [lo, hi] with ok(x) True; ok must be False...False True...True."""
while lo < hi:
mid = (lo + hi) // 2
if ok(mid):
hi = mid
else:
lo = mid + 1
return lo
# Binary search on an ANSWER: the smallest x with x * x >= 1_000_000_007
print("smallest x with x*x >= 10^9+7:", first_true(0, 10**9, lambda x: x * x >= 10**9 + 7))
Lab exercise 2
JavaGraded in Code LabA query engine with binary search. You get a sorted array and several queries. For each query x, print the smallest index i (0-based) such that a[i] >= x. If every item is smaller than x, print n.
Input: the first line has n q. The second line has the n integers of the array, sorted in non-decreasing order (it is empty when n is 0). The third line has the q queries.
Output: q lines, one answer per query.
Example: input
5 4
1 3 3 5 8
3 4 0 9
prints 1, 3, 0, 5 (one per line): the first item that is at least 3 is at index 1, at least 4 is the 5 at index 3, at least 0 is index 0, and nothing reaches 9 so the answer is n = 5.
Each query must take O(log n). Use the lower-bound template from the lesson with the half-open range [lo, hi).
- The example
- Duplicates: always the first copy
- One item
- Negative numbers
- Empty array
- 4,000 numbers and 500 queries
Example run
- Input
5 4 1 3 3 5 8 3 4 0 9
- Expected output
1 3 0 5
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt();
int q = in.nextInt();
int[] a = new int[n];
for (int i = 0; i < n; i++) a[i] = in.nextInt();
StringBuilder out = new StringBuilder();
for (int k = 0; k < q; k++) {More lines in Code Lab: 12
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Merge sort, and what the merge step knows
Here is merge sort in full. It is the cleanest example of divide and conquer: split, solve each half, combine.
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
left = merge_sort(a[:mid])
right = merge_sort(a[mid:])
merged, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= keeps equal items in order: stable
merged.append(left[i]); i += 1
else:
merged.append(right[j]); j += 1
return merged + left[i:] + right[j:]
Why O(n log n)? The list is halved about log₂ n times, and every level of the recursion merges n items in total. So the work is n per level times log n levels.
An inversion is a pair of positions i < j with a[i] > a[j]: two items that are in the wrong order relative to each other. A sorted array has 0 inversions; a reversed array of n items has n(n - 1) / 2. Inversions measure "how unsorted" the data is (for instance, how different two people's rankings of the same movies are).
Counting them naively takes O(n²) (check every pair). But merge sort can count them for free. When you merge two sorted halves and you take an item from the right half while k items are still waiting in the left half, that right item is smaller than all k of them. That is exactly k inversions, found in one step. Add them up over the whole recursion and you get the answer in O(n log n).
One caution: with n up to hundreds of thousands the count can exceed the range of a 32-bit integer, so use a 64-bit counter (long long, long) as a habit.
Lab exercise 3
C++Graded in Code LabCount the inversions. An inversion is a pair of positions i < j with a[i] > a[j] (strictly greater: equal values are not inversions).
Input: the first line has n (0 to 5,000). The second line has the n integers (it is missing when n is 0).
Output: one integer, the number of inversions.
Example: input 5 and 2 4 1 3 5 prints 3, from the pairs (2, 1), (4, 1) and (4, 3).
Write a merge sort that counts while it merges, as explained in the lesson. Keep the counter in a long long.
- The example
- Already sorted
- Reversed: n(n-1)/2
- Equal values are not inversions
- No numbers at all
- 4,000 pseudo-random numbers
Example run
- Input
5 2 4 1 3 5
- Expected output
3
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n = 0;
cin >> n;
vector<int> v(n);
for (int& x : v) cin >> x;
// TODO: print the number of inversions, i.e. pairs i < j with v[i] > v[j].
// Plan: write a recursive merge sort on v[lo, hi). While merging the sorted halvesMore lines in Code Lab: 6
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
In JavaScript, [10, 9, 1].sort() returns [1, 10, 9]. Why?
Question 2 of 5
What must be true for binary search to work?
Question 3 of 5
About how many comparisons does binary search need, at most, on a sorted array of 1,000,000 items?
Question 4 of 5
In Java or C++ with 32-bit int, why is mid = lo + (hi - lo) / 2 preferred over mid = (lo + hi) / 2?
Question 5 of 5
Which statement about merge sort and quicksort is correct?
Summary
- Sorted data is a superpower. It enables binary search, easy duplicate detection, merging and many greedy algorithms.
- Simple sorts (bubble, selection, insertion) are O(n²); merge sort, quicksort (on average) and heap sort are O(n log n), which is also the best any comparison sort can do. In practice you call the built-in sort.
- Stable sorts keep equal items in their original order. Always give JavaScript's
sorta numeric comparator. - Binary search is O(log n): about 20 steps for a million items. Use the lower-bound template with a half-open range
[lo, hi)andlo + (hi - lo) / 2. - The same template can search an answer instead of an array whenever a yes/no check flips only once.
- Merge sort can count inversions while it merges, in O(n log n) instead of O(n²).
Practice next: use binary search to find the minimum capacity that ships all packages in D days (search on the answer), search for a value in a rotated sorted array, and implement quicksort with a random pivot. Then continue with Trees & Graphs.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1JavaScript (Node.js)Not passed yet Try in Code Lab
- Lab exercise 2JavaNot passed yet Try in Code Lab
- Lab exercise 3C++Not passed yet Try in Code Lab