Skip to content
Back to Modules
Part IA Lent Term

Algorithms I

Algorithms Sorting Complexity Data-Structures Dynamic-Programming Greedy Search-Trees Hash-Tables Part IA Lent Term
  • Foundations

    What distinguishes an algorithm, the idealised machine model, loop invariants, asymptotic notation, and worst/average-case analysis

    • What Is an Algorithm

      Definition

      An algorithm is a finite sequence of well-defined steps that takes some value(s) as input and produces some value(s) as output. Three properties characterise every algorithm: it must terminate (halt) on all valid inputs, each step must be unambiguously specified, and it must produce the correct output for every input instance.

      The problem an algorithm solves is separate from the algorithm itself. A problem specifies the input/output relationship (e.g. “reorder a sequence into non-decreasing order”), whilst an instance is a concrete input (e.g. [9, 102, 10, -7, 64, 18]). Many different algorithms can solve the same problem.

      Examples

      Insertion Sort

      Build a sorted prefix by taking each next element and inserting it into the correct position, shifting larger elements right to make room. The algorithm terminates because the outer loop runs a fixed number of iterations and the inner loop always decrements towards zero.

      Euclid’s GCD

      Repeatedly replace the larger number by the remainder when dividing by the smaller: gcd(a,b)=gcd(b,amodb)\gcd(a, b) = \gcd(b, a \bmod b). Terminates because the remainders form a strictly decreasing sequence of non-negative integers.

      The RAM Model

      To analyse algorithms independently of hardware, we assume an idealised machine (the Random Access Machine model):

      • Creating an array of size mm takes time proportional to mm.
      • Accessing any array element A[i]A[i] takes constant time.
      • All numerical operations (addition, comparison, multiplication) take constant time.
      • Each array cell holds one data item.

      This model abstracts away CPU caches, pipelining, and the fact that storing a pointer to an nn-element array requires Θ(logn)\Theta(\log n) bits. The RAM model is the conventional mathematical playground for algorithm analysis; it is often a good approximation to real performance unless large constants, cache effects, or small problem sizes dominate.

      Problem vs Algorithm

      ProblemAlgorithm
      SortingInsertion Sort, MergeSort, QuickSort, HeapSort
      Shortest pathDijkstra’s, Bellman-Ford
      Primality testingTrial division, Miller-Rabin

      A problem defines what to compute; an algorithm defines how to compute it. The efficiency of different algorithms for the same problem can differ dramatically (compare Insertion Sort’s Θ(n2)\Theta(n^2) with MergeSort’s Θ(nlogn)\Theta(n \log n)).

      Summary

      ConceptDescription
      AlgorithmFinite sequence of well-defined steps, takes input, produces output, terminates
      ProblemSpecification of input/output relationship
      InstanceConcrete input of a specific size
      CorrectnessAlgorithm halts with correct output for every input instance
      RAM modelUnit-cost operations for arithmetic, comparisons, memory access
      Why abstractEnables asymptotic analysis independent of hardware details
    • Loop Invariants and Correctness

      The Three-Part Correctness Framework

      An algorithm is correct if, for every input instance, it terminates with the correct output. For algorithms containing loops, we prove correctness using a loop invariant: a logical statement that remains true at the start of every loop iteration.

      The proof has three parts:

      1. Initialisation: The invariant is true before the first iteration.
      2. Maintenance: If the invariant is true at the start of an iteration, it remains true at the end (and thus at the start of the next iteration).
      3. Termination: When the loop exits, the invariant combined with the exit condition proves the postcondition (the algorithm’s goal).

      Partial vs Total Correctness

      Partial correctness: if the algorithm terminates, it produces the correct answer. Total correctness: partial correctness plus a guarantee that the algorithm always terminates. For Insertion Sort, the FOR loop counts upward from 2 to nn (finite), and each inner WHILE loop counts downward from a positive value (well-founded), so it is totally correct.

      Proving Insertion Sort Correct

      The algorithm:

      1. for j = 2 to A.length
      2. Key = A[j]; i = j - 1
      3. while i > 0 and A[i] > Key
      4.   A[i+1] = A[i]; i = i - 1
      5. A[i+1] = Key

      Invariant

      P: At the start of each iteration of the FOR loop, the subarray A[1j1]A[1 \dots j-1] contains the elements originally in positions 1j11 \dots j-1, in sorted order.

      Initialisation (j=2j = 2)

      A[11]A[1 \dots 1] is a single-element subarray, trivially sorted. ✅

      Maintenance

      Assume A[1j1]A[1 \dots j-1] is sorted at the start of the iteration. The WHILE loop shifts elements greater than Key one position right, creating a gap at the correct insertion point. When the loop exits, A[i+1] receives Key, and A[1j]A[1 \dots j] is now sorted. The invariant holds for the next iteration. ✅

      Termination (j=n+1j = n+1)

      When the FOR loop ends, j=n+1j = n+1. Substituting into the invariant, A[1n]A[1 \dots n] (the whole array) is sorted, which is exactly the postcondition. ✅

      Example Walkthrough

      Input: [9,102,10,7][9, 102, 10, -7]

      jKeyBefore WHILEAfter WHILESorted prefix
      2102[9,102,10,7][9, 102, 10, -7][9,102,10,7][9, 102, 10, -7][9,102][9, 102]
      310[9,102,10,7][9, 102, 10, -7][9,10,102,7][9, 10, 102, -7][9,10,102][9, 10, 102]
      4-7[9,10,102,7][9, 10, 102, -7][7,9,10,102][-7, 9, 10, 102]sorted

      Choosing a Useful Invariant

      Not every true statement is a useful invariant. The statement 1=11 = 1 satisfies initialisation and maintenance but proves nothing about sorting at termination. The invariant must relate program variables to the desired postcondition. It must be strong enough to imply the result upon termination but weak enough to be provably maintained.

      Summary

      StepWhat to check
      InitialisationInvariant holds before first iteration
      MaintenanceIf invariant holds before, it holds after one iteration
      TerminationInvariant + exit condition \Rightarrow postcondition
      Partial correctnessCorrect if it terminates
      Total correctnessCorrect plus guaranteed termination
      Key invariantA[1j1]A[1 \dots j-1] is sorted (Insertion Sort)
    • Asymptotic Notation: Big-O, Big-Ω, and Big-Θ

      Big-O: Asymptotic Upper Bound

      f(n)=O(g(n))f(n) = O(g(n)) if there exist positive constants cc and n0n_0 such that for all nn0n \ge n_0:

      0f(n)cg(n)0 \le f(n) \le c \cdot g(n)

      In words: ff grows at most as fast as gg, ignoring constant factors and small inputs. OO is pronounced “big-O”.

      Big-Ω\Omega: Asymptotic Lower Bound

      f(n)=Ω(g(n))f(n) = \Omega(g(n)) if there exist positive constants cc and n0n_0 such that for all nn0n \ge n_0:

      0cg(n)f(n)0 \le c \cdot g(n) \le f(n)

      In words: ff grows at least as fast as gg. The purple circles (worst-case cost) must be above the curve cg(n)c \cdot g(n).

      Big-Θ\Theta: Asymptotically Tight Bound

      f(n)=Θ(g(n))f(n) = \Theta(g(n)) iff f(n)=O(g(n))f(n) = O(g(n)) and f(n)=Ω(g(n))f(n) = \Omega(g(n)). In words: ff and gg grow at the same rate, within constant factors.

      Formal Definition

      There exist c1,c2>0c_1, c_2 > 0 and n0n_0 such that for all nn0n \ge n_0:

      0c1g(n)f(n)c2g(n)0 \le c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n)

      Examples

      FunctionBig-OBig-Ω\OmegaBig-Θ\Theta
      3n2+2n+13n^2 + 2n + 1O(n2)O(n^2) ✓, O(n3)O(n^3)Ω(n2)\Omega(n^2) ✓, Ω(n)\Omega(n)Θ(n2)\Theta(n^2)
      nlognn \log nO(n2)O(n^2) ✓, O(nlogn)O(n \log n)Ω(n)\Omega(n)Θ(nlogn)\Theta(n \log n)
      n2n^2O(n2logn)O(n^2 \log n)Ω(n2)\Omega(n^2)Cannot say n2=O(nlogn)n^2 = O(n \log n)

      Proving 3n2+2n+1=O(n2)3n^2 + 2n + 1 = O(n^2)

      We need cc and n0n_0 such that 3n2+2n+1cn23n^2 + 2n + 1 \le c \cdot n^2 for all nn0n \ge n_0.

      For n1n \ge 1: 2n2n22n \le 2n^2 and 1n21 \le n^2, so 3n2+2n+13n2+2n2+n2=6n23n^2 + 2n + 1 \le 3n^2 + 2n^2 + n^2 = 6n^2.

      Choose c=6c = 6, n0=1n_0 = 1. ✓

      Common Growth Rates

      Ranked from slowest to fastest:

      O(1)<O(logn)<O(n)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)O(1) < O(\log n) < O(\sqrt{n}) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!)

      The base of the logarithm is irrelevant: log2n\log_2 n and log10n\log_{10} n differ by a constant factor (log2n=log210log10n\log_2 n = \log_2 10 \cdot \log_{10} n), which big-O absorbs.

      Practical Interpretation

      • O(1)O(1): constant time, e.g. array access, arithmetic
      • O(logn)O(\log n): binary search depth, divide-and-conquer recursion depth
      • O(n)O(n): single pass over all elements
      • O(nlogn)O(n \log n): efficient comparison-based sorting
      • O(n2)O(n^2): nested loops, insertion sort worst case
      • O(2n)O(2^n): enumerating all subsets

      Θ\Theta, OO, and Ω\Omega are Sets

      Formally, O(g(n))O(g(n)) is a set of functions. Writing f(n)=O(g(n))f(n) = O(g(n)) means f(n)O(g(n))f(n) \in O(g(n)). This set perspective makes the notation transitive: if fO(g)f \in O(g) and gO(h)g \in O(h), then fO(h)f \in O(h). Likewise for Θ\Theta and Ω\Omega. In addition, fΘ(g)f \in \Theta(g) iff gΘ(f)g \in \Theta(f) (symmetry).

      Summary

      NotationMeaningMnemonic
      O(g(n))O(g(n))Upper bound”at most”
      Ω(g(n))\Omega(g(n))Lower bound”at least”
      Θ(g(n))\Theta(g(n))Tight bound”exactly” (within constants)
      Rule of thumbDrop constants and lower-order terms3n2+4nO(n2)3n^2 + 4n \to O(n^2)
    • Little-o and Little-ω

      Little-o: Strictly Slower Growth

      f(n)=o(g(n))f(n) = o(g(n)) if for every positive constant cc, there exists n0n_0 such that for all nn0n \ge n_0:

      0f(n)<cg(n)0 \le f(n) < c \cdot g(n)

      Equivalently, the limit ratio is zero:

      limnf(n)g(n)=0\lim_{n \to \infty} \frac{f(n)}{g(n)} = 0

      In words: ff grows strictly slower than gg. No matter how small a constant factor you pick, ff eventually falls below cgc \cdot g.

      Little-ω\omega: Strictly Faster Growth

      f(n)=ω(g(n))f(n) = \omega(g(n)) if for every positive constant cc, there exists n0n_0 such that for all nn0n \ge n_0:

      0cg(n)<f(n)0 \le c \cdot g(n) < f(n)

      Equivalently:

      limnf(n)g(n)=\lim_{n \to \infty} \frac{f(n)}{g(n)} = \infty

      In words: ff grows strictly faster than gg.

      Relationship to Big-O / Big-Ω\Omega

      BigLittleMeaning
      f=O(g)f = O(g)May be tight or loose; 3n2=O(n2)3n^2 = O(n^2) and 3n2=O(n3)3n^2 = O(n^3) are both true
      f=o(g)f = o(g)Always non-tight; 3n2=o(n3)3n^2 = o(n^3) is true, but 3n2=o(n2)3n^2 = o(n^2) is false
      f=Ω(g)f = \Omega(g)May be tight or loose; 3n2=Ω(n2)3n^2 = \Omega(n^2) and 3n2=Ω(n)3n^2 = \Omega(n) are both true
      f=ω(g)f = \omega(g)Always non-tight; 3n2=ω(n)3n^2 = \omega(n) is true, but 3n2=ω(n2)3n^2 = \omega(n^2) is false

      Concrete Examples

      ExpressionOOΩ\OmegaΘ\Thetaooω\omega
      3n23n^2 vs n2n^2
      3n23n^2 vs n3n^3
      3n23n^2 vs nn
      nlognn \log n vs n2n^2
      nn vs logn\log n

      The Quantifier Difference

      The distinction is in the quantifier for cc:

      • Big-O: c>0,n0:nn0,f(n)cg(n)\exists c > 0, \exists n_0: \forall n \ge n_0, f(n) \le c \cdot g(n) — some constant works.
      • Little-o: c>0,n0:nn0,f(n)<cg(n)\forall c > 0, \exists n_0: \forall n \ge n_0, f(n) < c \cdot g(n) — every constant works.

      For 3n23n^2 and n2n^2: pick c=2c = 2. Then 3n22n23n^2 \le 2n^2 fails for all nn, so 3n2o(n2)3n^2 \neq o(n^2). But for 3n23n^2 and n3n^3: for any cc, pick n0=3/cn_0 = 3/c and the inequality 3n2<cn33n^2 < c n^3 holds beyond that, so 3n2=o(n3)3n^2 = o(n^3).

      Common Pitfalls

      1. OO is not “grows exactly like”: n=O(n2)n = O(n^2) is true; OO means “at most.”
      2. Little-o requires strictness: Θ\Theta functions are not in oo of each other. 3n2=Θ(n2)3n^2 = \Theta(n^2) but 3n2o(n2)3n^2 \neq o(n^2).
      3. Lower bounds work symmetrically: Ω\Omega means “at least,” ω\omega means “strictly greater.”
      4. Constants are irrelevant for oo and ω\omega: f(n)=o(cg(n))f(n) = o(c \cdot g(n)) iff f(n)=o(g(n))f(n) = o(g(n)).
      5. The limit test: if limnf/g\lim_{n \to \infty} f/g is finite and non-zero, then f=Θ(g)f = \Theta(g) and neither f=o(g)f = o(g) nor f=ω(g)f = \omega(g) can hold.

      Summary

      NotationDefinitionLimit formMnemonic
      f=o(g)f = o(g)c>0,n0:f(n)<cg(n)\forall c > 0, \exists n_0: f(n) < c \cdot g(n)limf/g=0\lim f/g = 0”strictly less"
      f=ω(g)f = \omega(g)c>0,n0:f(n)>cg(n)\forall c > 0, \exists n_0: f(n) > c \cdot g(n)limf/g=\lim f/g = \infty"strictly greater"
      f=Θ(g)f = \Theta(g)c1,c2>0:c1gfc2g\exists c_1, c_2 > 0: c_1 g \le f \le c_2 glimf/g(0,)\lim f/g \in (0, \infty)"exactly”
      Key insightoo and ω\omega exclude asymptotic equality3n2o(n2)3n^2 \neq o(n^2)
    • Worst-Case, Average-Case, and Best-Case Analysis

      Three Kinds of Analysis

      For an input size nn, define the cost function C(x)C(x) for a specific input instance xx:

      • Worst-case: W(n)=maxx:x=nC(x)W(n) = \max_{x: |x|=n} C(x) — the maximum cost over all inputs of size nn.
      • Best-case: B(n)=minx:x=nC(x)B(n) = \min_{x: |x|=n} C(x) — the minimum cost over all inputs of size nn.
      • Average-case: A(n)=E[C(x)]A(n) = \mathbb{E}[C(x)] — the expected cost, assuming some probability distribution over inputs.

      Insertion Sort as Canonical Example

      Recall the cost expression for Insertion Sort:

      T(n)=an+(b+c+g)(n1)+dj=2ntj+(e+f)j=2n(tj1)T(n) = an + (b+c+g)(n-1) + d\sum_{j=2}^{n} t_j + (e+f)\sum_{j=2}^{n} (t_j - 1)

      where tjt_j is the number of WHILE-loop iterations for iteration jj. The value of tjt_j depends on how far A[j]A[j] must travel into the sorted prefix.

      Best Case (Θ(n)\Theta(n))

      Input is already sorted. Every tj=1t_j = 1 (only the failing comparison at loop entry). The sums collapse to linear terms, giving T(n)=linear function=Θ(n)T(n) = \text{linear function} = \Theta(n).

      Worst Case (Θ(n2)\Theta(n^2))

      Input is reverse-sorted. Each jj requires moving j1j-1 elements (plus the final failing comparison, so tj=jt_j = j):

      j=2ntj=j=2nj=n(n+1)21\sum_{j=2}^{n} t_j = \sum_{j=2}^{n} j = \frac{n(n+1)}{2} - 1

      j=2n(tj1)=j=2n(j1)=n(n1)2\sum_{j=2}^{n} (t_j - 1) = \sum_{j=2}^{n} (j-1) = \frac{n(n-1)}{2}

      Both sums are Θ(n2)\Theta(n^2), so T(n)=Θ(n2)T(n) = \Theta(n^2).

      Average Case (Θ(n2)\Theta(n^2))

      Assume all permutations of the input are equally likely. On average, half the elements in A[1j1]A[1 \dots j-1] are smaller than A[j]A[j] and half are larger, so the inner loop runs j/2j/2 times. This yields the same arithmetic progression with a factor of 1/21/2, still Θ(n2)\Theta(n^2). The average case is often the same order of magnitude as the worst case.

      Why Worst-Case Matters

      1. Guarantees: An O(n2)O(n^2) worst-case bound means the algorithm will never exceed that asymptotic cost for any input. This is essential for real-time and safety-critical systems.
      2. Adversarial inputs: Worst cases may occur in practice. A human sorting a list is unlikely to provide reverse-sorted input, but programmatic data sources might.
      3. Average case often equals worst case: For many algorithms (including Insertion Sort), the average and worst cases are the same asymptotic order, so there is no reason to use a weaker analysis.

      Analysis vs Benchmarking

      Asymptotic AnalysisBenchmarking
      Predicts behaviour for all input sizesMeasures behaviour on specific inputs
      Machine-independent (RAM model)Machine-dependent (CPU, cache, OS)
      Ignores constantsCaptures real constants
      Good for algorithm comparisonGood for implementation tuning

      The RAM model analysis is useful for algorithmic decisions (MergeSort over Insertion Sort for large nn). Benchmarks become useful for small nn, where constants dominate.

      Summary

      CaseInsertion SortDefinition
      BestΘ(n)\Theta(n)Already-sorted input
      WorstΘ(n2)\Theta(n^2)Reverse-sorted input
      AverageΘ(n2)\Theta(n^2)Random permutation, each element moves ~i/2i/2 positions
      Why worstProvides guarantees, often similar to average
    • Comparing Growth Rates

      Intuition by Algorithmic Pattern

      Growth rateTypical algorithm pattern
      O(1)O(1)Array access, arithmetic, hash table lookup
      O(logn)O(\log n)Binary search, balanced BST operations, divide-and-conquer depth
      O(n)O(n)Linear scan, counting sort, merging two sorted lists
      O(nlogn)O(n \log n)MergeSort, HeapSort, QuickSort average case, FFT
      O(n2)O(n^2)Insertion Sort, nested pairwise loops, naive matrix multiplication
      O(n3)O(n^3)Triple-nested loops, Floyd-Warshall all-pairs shortest path
      O(2n)O(2^n)Brute-force subset enumeration, naive recursion for Fibonacci
      O(n!)O(n!)Brute-force permutation enumeration, travelling salesman (naive)

      Concrete Numbers

      For a machine doing 10910^9 operations per second:

      nnlog2n\log_2 nnnnlog2nn \log_2 nn2n^2n3n^32n2^n
      103.310 ns33 ns100 ns1 µs1 µs
      1006.6100 ns660 ns10 µs1 ms4×10134 \times 10^{13} years
      1,000101 µs10 µs1 ms1 sastronomically large
      10610^6201 ms20 ms17 min32,000 yearsimpossible
      10910^9301 s30 s32,000 yearsimpossibleimpossible

      A supercomputer running at 101510^{15} ops/sec would handle n=106n=10^6 with Insertion Sort (n2n^2) in about 1 second, and MergeSort (nlognn \log n) instantly. The algorithm matters far more than the hardware.

      Using Limits to Compare Growth

      To determine the asymptotic relationship between f(n)f(n) and g(n)g(n), compute:

      limnf(n)g(n)\lim_{n \to \infty} \frac{f(n)}{g(n)}

      LimitConclusion
      0f(n)=o(g(n))f(n) = o(g(n)), so f(n)=O(g(n))f(n) = O(g(n)) but not Θ(g(n))\Theta(g(n))
      c(0,)c \in (0, \infty)f(n)=Θ(g(n))f(n) = \Theta(g(n))
      \inftyf(n)=ω(g(n))f(n) = \omega(g(n)), so f(n)=Ω(g(n))f(n) = \Omega(g(n)) but not Θ(g(n))\Theta(g(n))

      Example: Compare nlognn \log n and n2n^2

      limnnlognn2=limnlognn=0(by L’Hoˆpital)\lim_{n \to \infty} \frac{n \log n}{n^2} = \lim_{n \to \infty} \frac{\log n}{n} = 0 \quad \text{(by L'Hôpital)}

      So nlogn=o(n2)n \log n = o(n^2); the n2n^2 function dominates.

      Example: Compare 3n2+2n+13n^2 + 2n + 1 and n2n^2

      limn3n2+2n+1n2=3\lim_{n \to \infty} \frac{3n^2 + 2n + 1}{n^2} = 3

      The limit is finite and non-zero, so 3n2+2n+1=Θ(n2)3n^2 + 2n + 1 = \Theta(n^2).

      L’Hôpital’s Rule

      If limf(n)=limg(n)=\lim f(n) = \lim g(n) = \infty, then:

      limf(n)g(n)=limf(n)g(n)\lim \frac{f(n)}{g(n)} = \lim \frac{f'(n)}{g'(n)}

      This is essential for ratios like lognn\frac{\log n}{n} or nlogn\frac{n}{\log n} where the asymptotic behaviour is not obvious from inspection.

      Practice: Rank by Growth Rate

      Rank these functions from slowest to fastest growth:

      2log3n,n2,n,log(n!),2n,nlog3,nlogn,(logn)logn2^{\log_3 n}, \quad n^2, \quad \sqrt{n}, \quad \log(n!), \quad 2^n, \quad n^{\log 3}, \quad n \log n, \quad (\log n)^{\log n}

      Solution: 2log3n=nlog32n0.632^{\log_3 n} = n^{\log_3 2} \approx n^{0.63}; log(n!)=Θ(nlogn)\log(n!) = \Theta(n \log n); nlog3n1.59n^{\log 3} \approx n^{1.59}. The rank: n<2log3n<nlognlog(n!)<nlog3<n2<(logn)logn<2n\sqrt{n} < 2^{\log_3 n} < n \log n \approx \log(n!) < n^{\log 3} < n^2 < (\log n)^{\log n} < 2^n.

      Summary

      RateExample algorithmn=106n=10^6 on 1 GHz
      logn\log nBinary search20 ns
      nnLinear scan1 ms
      nlognn \log nMergeSort20 ms
      n2n^2Insertion Sort worst17 min
      n3n^3Floyd-Warshall32,000 years
      2n2^nSubset enumerationimpossible
      Limit testlimf/g\lim f/g determines oo, Θ\Theta, ω\omega
  • Comparison Sorts

    Insertion sort, selection sort, quicksort, mergesort, and the comparison-based sorting lower bound

    • Insertion Sort

      Algorithm

      Insertion Sort is an incremental algorithm: it builds a sorted prefix one element at a time. For each element A[j]A[j], find its correct position within the already-sorted prefix A[1j1]A[1 \dots j-1], shift larger elements right, and insert.

      Steps (1-indexed pseudocode convention):

      1. For j=2j = 2 to A.lengthA.\text{length}:
      2. Key=A[j]\text{Key} = A[j]
      3. i=j1i = j - 1
      4.  While i>0i > 0 and A[i]>KeyA[i] > \text{Key}:
      5.   A[i+1]=A[i]A[i+1] = A[i]
      6.   i=i1i = i - 1
      7. A[i+1]=KeyA[i+1] = \text{Key}

      Insertion sort visualisation: building a sorted prefix by inserting elements one at a time

      Visual Description

      The algorithm maintains two regions: A[1j1]A[1 \dots j-1] is sorted, A[jn]A[j \dots n] is yet to be processed. At each step, peel off A[j]A[j], walk backwards through the sorted region shifting any larger element right by one position, then drop the key into the gap.

      Correctness

      Loop invariant: At the start of each FOR iteration, A[1j1]A[1 \dots j-1] consists of the original first j1j-1 elements in sorted order.

      • Initialisation (j=2j=2): A[1]A[1] is a single element, trivially sorted.
      • Maintenance: The WHILE loop shifts all elements greater than Key right by one, preserving sortedness; inserting Key at the correct position extends the sorted prefix by one.
      • Termination (j=n+1j=n+1): The entire array A[1n]A[1 \dots n] is sorted.

      Complexity Analysis

      The running time depends on tjt_j, the number of WHILE-loop iterations for each jj:

      T(n)=an+b(n1)+dj=2ntj+ej=2n(tj1)T(n) = a \cdot n + b \cdot (n-1) + d\sum_{j=2}^{n} t_j + e\sum_{j=2}^{n} (t_j - 1)

      Best Case: Θ(n)\Theta(n)

      Input already sorted. tj=1t_j = 1 for all jj (only the failing comparison, no shifts). T(n)=pn+qT(n) = pn + q.

      Worst Case: Θ(n2)\Theta(n^2)

      Input reverse-sorted. tj=jt_j = j (shift all previous elements). The comparison count alone is:

      j=2n(j1)=n(n1)2=Θ(n2)\sum_{j=2}^{n} (j-1) = \frac{n(n-1)}{2} = \Theta(n^2)

      Average Case: Θ(n2)\Theta(n^2)

      On a random permutation, each element moves past roughly half the sorted prefix. The quadratic term carries a factor of 1/21/2 but remains Θ(n2)\Theta(n^2).

      Properties

      PropertyValue
      In-placeYes, O(1)O(1) extra space
      StableYes, equal keys stay in original relative order
      AdaptiveEfficient on nearly-sorted data (approaches O(n)O(n))
      OnlineCan sort items as they arrive

      When to Use

      Insertion Sort outperforms Θ(nlogn)\Theta(n \log n) algorithms for small nn (roughly n<50n < 50) due to low constant factors and excellent cache locality. It is also ideal for nearly-sorted data or as the base case inside recursive sorts (e.g. switching to Insertion Sort when subarrays fall below a threshold).

      Summary

      AspectDetail
      StrategyIncremental, build sorted prefix
      InvariantA[1j1]A[1 \dots j-1] is sorted
      Best caseΘ(n)\Theta(n), already sorted
      Worst caseΘ(n2)\Theta(n^2), reverse-sorted
      Average caseΘ(n2)\Theta(n^2)
      Comparisons worstn(n1)/2n(n-1)/2
      In-placeYes
      StableYes
    • Binary Insertion Sort

      Algorithm

      Binary Insertion Sort improves the search step of Insertion Sort by using binary search to find the insertion point, rather than a linear backwards scan. The shifting step remains unchanged.

      For each jj from 2 to nn:

      1. The prefix A[1j1]A[1 \dots j-1] is sorted.
      2. Perform binary search on A[1j1]A[1 \dots j-1] to find the index kk where A[j]A[j] belongs (the position where all elements before kk are A[j]\le A[j]).
      3. Shift A[kj1]A[k \dots j-1] right by one position.
      4. Insert A[j]A[j] at position kk.

      Binary insertion sort: binary search finds insertion point in sorted prefix, then elements shift

      Where the Improvement Lies

      Standard Insertion Sort uses a linear scan that performs O(j)O(j) comparisons to find the insertion point for element jj. Binary search performs O(logj)O(\log j) comparisons. Over all nn elements, comparisons drop from Θ(n2)\Theta(n^2) to:

      j=2nlog2j=log2(n!)=Θ(nlogn)\sum_{j=2}^{n} \log_2 j = \log_2(n!) = \Theta(n \log n)

      Where It Does Not Help

      The shifting step still moves up to j1j-1 elements on each insertion. In the worst case, every element must be shifted, requiring:

      j=2n(j1)=n(n1)2=Θ(n2)\sum_{j=2}^{n} (j-1) = \frac{n(n-1)}{2} = \Theta(n^2)

      movements. Since each shift is a write operation, the total time is still dominated by the Θ(n2)\Theta(n^2) shifting cost. The asymptotic running time remains Θ(n2)\Theta(n^2) worst case and average case.

      Comparison: Standard vs Binary Insertion

      MetricStandard Insertion SortBinary Insertion Sort
      ComparisonsΘ(n2)\Theta(n^2)Θ(nlogn)\Theta(n \log n)
      Shifts/swapsΘ(n2)\Theta(n^2)Θ(n2)\Theta(n^2)
      Overall timeΘ(n2)\Theta(n^2)Θ(n2)\Theta(n^2)
      Best caseΘ(n)\Theta(n)Θ(nlogn)\Theta(n \log n) (binary search still costs logj\log j each)

      The best case actually becomes worse with binary insertion: even on an already-sorted array, binary search still costs Θ(logj)\Theta(\log j) comparisons per element, totalling Θ(nlogn)\Theta(n \log n), whereas standard Insertion Sort gets Θ(n)\Theta(n).

      When Binary Insertion Sort Is Useful

      Binary insertion sort helps when:

      1. Comparisons are expensive, and writes are cheap: e.g. sorting strings where each string comparison is costly but copying a pointer (the “shift”) is lightweight.
      2. The data structure supports binary search but not efficient linear scan: e.g. an array where the sorted prefix is conceptually separate from the insertion machinery.

      In practice, standard Insertion Sort is usually preferred because its linear scan is cache-friendly and its best case is genuinely linear.

      Summary

      AspectDetail
      Search methodBinary search in sorted prefix
      Comparison countΘ(nlogn)\Theta(n \log n)
      Shift countΘ(n2)\Theta(n^2) worst case
      Overall timeΘ(n2)\Theta(n^2)
      Best caseΘ(nlogn)\Theta(n \log n) (worse than standard)
      Use caseExpensive comparisons, cheap writes
      Compared to standardReduces comparisons but does not improve asymptote
    • Selection Sort

      Algorithm

      Selection Sort repeatedly finds the minimum element of the unsorted suffix and swaps it with the first unsorted position. For 0-indexed arrays:

      For i=0i = 0 to n2n-2:

      1. Find the index jj of the minimum element in A[in1]A[i \dots n-1].
      2. Swap A[i]A[i] with A[j]A[j].

      Selection sort: repeatedly select the minimum of what remains and place it at the front

      Invariant

      At the start of iteration ii, the subarray A[0i1]A[0 \dots i-1] contains the ii smallest elements of the original array, in sorted order. The remaining elements A[in1]A[i \dots n-1] are unsorted.

      • Initialisation (i=0i=0): A[01]A[0 \dots -1] is empty, trivially sorted.
      • Maintenance: Appending the next minimum extends the sorted prefix correctly.
      • Termination (i=n1i=n-1): A[0n2]A[0 \dots n-2] sorted plus the final element (the largest) at n1n-1 completes the sort.

      Complexity: Always Θ(n2)\Theta(n^2) Comparisons

      Selection Sort performs no early exit. For each ii, finding the minimum in the remaining nin-i elements requires ni1n-i-1 comparisons:

      i=0n2(ni1)=k=1n1k=n(n1)2=Θ(n2)\sum_{i=0}^{n-2} (n-i-1) = \sum_{k=1}^{n-1} k = \frac{n(n-1)}{2} = \Theta(n^2)

      There is no best-case scenario that reduces comparisons; even if the array is already sorted, every element is still compared. The running time is always Θ(n2)\Theta(n^2) comparisons, making it data-insensitive.

      Swaps: Only Θ(n)\Theta(n)

      Each iteration performs exactly one swap, and there are n1n-1 iterations (the last element is automatically in place). Thus the total number of swaps is:

      Swaps=n1=Θ(n)\text{Swaps} = n-1 = \Theta(n)

      A lower bound theorem shows that any sorting algorithm must perform Ω(n)\Omega(n) swaps in the worst case (consider the input [2,3,,n,1][2, 3, \ldots, n, 1] where every item starts in the wrong place, and each swap touches two items). Selection Sort achieves this lower bound exactly.

      Properties

      PropertyValue
      ComparisonsAlways Θ(n2)\Theta(n^2)
      SwapsΘ(n)\Theta(n) (optimal)
      In-placeYes, O(1)O(1) space
      StableNo — the swap can jump over equal elements
      Data-sensitiveNo — same work regardless of input

      Why Stability Fails

      Consider [5a,5b,2][5_a, 5_b, 2]. Selection Sort finds 2 as the minimum and swaps it with 5a5_a, producing [2,5b,5a][2, 5_b, 5_a]. The relative order of 5a5_a and 5b5_b is reversed, so the sort is not stable.

      When to Use

      Selection Sort is effective when writes are expensive but comparisons are cheap. Each element is written at most twice (once when swapped into the sorted region, once when displaced from the unsorted region), giving Θ(n)\Theta(n) writes total. This is optimal for sorting algorithms.

      Summary

      AspectDetail
      StrategyRepeatedly select minimum, swap to front
      ComparisonsΘ(n2)\Theta(n^2), always
      SwapsΘ(n)\Theta(n), optimal
      In-placeYes
      StableNo
      Use caseExpensive writes, cheap comparisons
      Lower bound achievedΩ(n)\Omega(n) swaps
    • The Comparison-Based Sorting Lower Bound

      Statement

      Any comparison-based sorting algorithm must perform Ω(nlogn)\Omega(n \log n) comparisons in the worst case to sort nn elements.

      A comparison-based sort uses only pairwise comparisons (\le or >>) to determine the relative order of elements. It makes no assumptions about the values (no counting, no hashing, no radix).

      Proof via Decision Trees

      A decision tree models the behaviour of any comparison-based sort:

      • Each internal node represents a comparison A[i]A[j]A[i] \le A[j], with two outgoing edges (Yes/No).
      • Each leaf represents a specific permutation of the input — the sorted output.
      • A path from root to leaf corresponds to the sequence of comparisons made on some input.

      For nn distinct elements, there are n!n! possible input permutations. Since the algorithm must be correct for all permutations, the decision tree must have at least n!n! leaves.

      A binary tree of height hh has at most 2h2^h leaves. Therefore:

      2hn!2^h \ge n!

      Taking logarithms:

      hlog2(n!)h \ge \log_2(n!)

      Using Stirling’s approximation (n!2πn(n/e)nn! \approx \sqrt{2\pi n}(n/e)^n):

      log2(n!)=nlog2nnlog2e+O(logn)=Ω(nlogn)\log_2(n!) = n \log_2 n - n \log_2 e + O(\log n) = \Omega(n \log n)

      The height hh is the number of comparisons on the longest path, so the worst-case number of comparisons is Ω(nlogn)\Omega(n \log n). \square

      Implications

      • MergeSort at Θ(nlogn)\Theta(n \log n) is asymptotically optimal amongst comparison sorts.
      • HeapSort at O(nlogn)O(n \log n) is also optimal.
      • QuickSort averages Θ(nlogn)\Theta(n \log n) but can degrade to Θ(n2)\Theta(n^2).
      • No comparison sort can beat Ω(nlogn)\Omega(n \log n) in the worst case.

      Lower Bound on Swaps

      A separate result: the worst-case number of swaps is Ω(n)\Omega(n). Consider the input [2,3,,n,1][2, 3, \ldots, n, 1]. Every item is out of position, and each swap moves at most two items to their correct positions. Therefore at least n/2\lceil n/2 \rceil swaps are required. Selection Sort achieves this bound with Θ(n)\Theta(n) swaps.

      Comparison Sorts vs Linear-Time Sorts

      The Ω(nlogn)\Omega(n \log n) bound applies only to comparison-based sorts. Algorithms that exploit properties of the data can beat this bound:

      SortBoundAssumption
      Counting SortΘ(n+k)\Theta(n + k)Keys in range [0,k][0, k]
      Radix SortΘ(d(n+k))\Theta(d(n + k))dd-digit numbers
      Bucket SortΘ(n)\Theta(n) expectedUniform distribution over [0,1)[0, 1)

      These are not comparison-based; they use counting, digit extraction, or hashing.

      Summary

      ConceptDetail
      Lower boundΩ(nlogn)\Omega(n \log n) comparisons
      Proof techniqueDecision tree model
      Key inequality2hn!2^h \ge n!
      Stirling’s approxlog(n!)nlognnloge\log(n!) \approx n \log n - n \log e
      Optimal sortsMergeSort, HeapSort (comparison-based)
      Swap lower boundΩ(n)\Omega(n)
      ExceptionsLinear-time non-comparison sorts
    • Quicksort: Algorithm

      Divide-and-Conquer Structure

      Quicksort is a recursive divide-and-conquer sort:

      1. Divide: Choose a pivot element from the array. Partition (rearrange) the array so that all elements \le pivot come before it and all elements \ge pivot come after it.
      2. Conquer: Recursively sort the left and right subarrays (excluding the pivot, which is now in its correct final position).
      3. Combine: Nothing to do — the pivot placement plus recursively sorted subarrays yields a fully sorted array.

      Quicksort: pick a pivot, partition around it, recurse on both sides

      Lomuto Partitioning

      The standard partition scheme (Lomuto) picks the last element as pivot:

      1. x = A[r] (pivot is last element)
      2. i = p - 1 (boundary of ”\le pivot” region)
      3. For j = p to r-1:
      4.  If A[j] <= x:
      5.   i = i + 1
      6.   Swap A[i] and A[j]
      7. Swap A[i+1] and A[r] (place pivot in correct position)
      8. Return i+1 (pivot’s index)

      At any point, the array is partitioned as:

      [elementsxA[pi]elements>xA[i+1j1]unprocessedA[jr1]xA[r]][\, \underbrace{\text{elements} \le x}_{A[p \dots i]} \,|\, \underbrace{\text{elements} > x}_{A[i+1 \dots j-1]} \,|\, \underbrace{\text{unprocessed}}_{A[j \dots r-1]} \,|\, \underbrace{x}_{A[r]} \,]

      Walkthrough Example

      Array: [8,2,1,5,2,4][8, 2, 1, 5, 2, 4] with pivot x=4x = 4

      StepjConditionActionResult
      Initi = -1, pivot = 4[8,2,1,5,2,4][8, 2, 1, 5, 2, \underline{4}]
      108>48 > 4Nothing[8,2,1,5,2,4][8, 2, 1, 5, 2, 4]
      21242 \le 4i=0, swap A[0],A[1][2,8,1,5,2,4][2, 8, 1, 5, 2, 4]
      32141 \le 4i=1, swap A[1],A[2][2,1,8,5,2,4][2, 1, 8, 5, 2, 4]
      435>45 > 4Nothing[2,1,8,5,2,4][2, 1, 8, 5, 2, 4]
      54242 \le 4i=2, swap A[2],A[4][2,1,2,5,8,4][2, 1, 2, 5, 8, 4]
      Finalswap A[3],A[5][2,1,2,4,8,5][2, 1, 2, \underline{4}, 8, 5]

      Pivot 4 is at index 3. Left subarray [2,1,2][2, 1, 2] and right subarray [8,5][8, 5] are recursively sorted.

      Hoare Partitioning (Alternative)

      Hoare’s scheme uses two pointers moving inward from both ends, swapping out-of-order pairs. It typically performs fewer swaps than Lomuto, and the pivot ends up somewhere in the middle but not necessarily at the returned index.

      Pivot Selection Strategies

      StrategyEffect
      First elementSimple; worst case on sorted input
      Last elementSimple; worst case on reverse-sorted input
      RandomExpected Θ(nlogn)\Theta(n \log n); eliminates adversarial inputs
      Median-of-threePivot is median of A[p], A[mid], A[r]; reduces worst-case probability
      Median-of-mediansGuaranteed Θ(nlogn)\Theta(n \log n) worst case; high constant factor

      The choice of pivot is the single most important factor affecting Quicksort’s performance. A poor pivot (extreme min or max) produces an n1n-1 to 1 split and degrades to Θ(n2)\Theta(n^2). A good pivot (median) gives balanced splits and Θ(nlogn)\Theta(n \log n).

      Properties

      PropertyValue
      In-placeYes (O(logn)O(\log n) stack space for recursion)
      StableNo — partitioning swaps non-adjacent elements
      Divide stepΘ(n)\Theta(n) (one scan of subarray)
      Combine stepNo work required
    • Quicksort: Analysis

      Worst Case: Θ(n2)\Theta(n^2)

      The worst case occurs when the pivot is always the minimum or maximum element of the subarray, producing a split of 00 and n1n-1 elements. This happens, infamously, when the input is already sorted (or reverse-sorted) with a naive first/last-element pivot choice.

      Recurrence:

      T(n)=T(n1)+T(0)+Θ(n)=T(n1)+Θ(n)T(n) = T(n-1) + T(0) + \Theta(n) = T(n-1) + \Theta(n)

      Using the substitution method:

      T(n)=T(n1)+kn=(T(n2)+k(n1))+kn==Θ(1)+ki=1ni=Θ(n2)T(n) = T(n-1) + kn = (T(n-2) + k(n-1)) + kn = \dots = \Theta(1) + k\sum_{i=1}^{n} i = \Theta(n^2)

      Even a constant-sized split-off (e.g. consistently producing a subarray of size xx and nx1n-x-1 for constant xx) still yields Θ(n2)\Theta(n^2), because the recurrence becomes T(n)=T(nc)+Θ(n)T(n) = T(n-c) + \Theta(n).

      Best Case: Θ(nlogn)\Theta(n \log n)

      If the pivot always splits the array exactly in half:

      T(n)=2T(n/2)+Θ(n)T(n) = 2T(n/2) + \Theta(n)

      By the Master Theorem (Case 2, a=2,b=2,f(n)=Θ(n)=Θ(nlog22)a=2, b=2, f(n)=\Theta(n) = \Theta(n^{\log_2 2})):

      T(n)=Θ(nlogn)T(n) = \Theta(n \log n)

      Ratio Splits Also Give Θ(nlogn)\Theta(n \log n)

      A split of 1/41/4 to 3/43/4 still produces Θ(nlogn)\Theta(n \log n). The recursion tree has depth log4/3n\log_{4/3} n on the longest path and log4n\log_4 n on the shortest, with at most knkn work per level. In both cases:

      T(n)knlog4/3n=Θ(nlogn)T(n) \le kn \cdot \log_{4/3} n = \Theta(n \log n)

      An ratio split (any constant fraction on both sides, no matter how unbalanced) guarantees Θ(nlogn)\Theta(n \log n). Only constant-sized splits degrade to Θ(n2)\Theta(n^2).

      Average Case: Θ(nlogn)\Theta(n \log n)

      Assuming random pivot selection (all permutations equally likely), the expected number of comparisons is approximately 2nlnn1.39nlog2n2n \ln n \approx 1.39 \, n \log_2 n.

      Intuition: any two elements ziz_i and zjz_j (with i<ji < j in sorted order) are compared if and only if one of them is chosen as a pivot before any element between them in sorted order. The probability they are compared is 2ji+1\frac{2}{j-i+1}. Summing over all pairs:

      E[comparisons]=i=1n1j=i+1n2ji+1=i=1n1k=2ni+12k2nk=1n1k2nlnn+O(n)\mathbb{E}[\text{comparisons}] = \sum_{i=1}^{n-1} \sum_{j=i+1}^{n} \frac{2}{j-i+1} = \sum_{i=1}^{n-1} \sum_{k=2}^{n-i+1} \frac{2}{k} \le 2n \sum_{k=1}^{n} \frac{1}{k} \approx 2n \ln n + O(n)

      Thus the expected running time is Θ(nlogn)\Theta(n \log n).

      Comparison with MergeSort

      PropertyQuicksortMergeSort
      Worst caseΘ(n2)\Theta(n^2)Θ(nlogn)\Theta(n \log n)
      Average caseΘ(nlogn)\Theta(n \log n)Θ(nlogn)\Theta(n \log n)
      Extra spaceO(logn)O(\log n) (stack)Θ(n)\Theta(n) (merge arrays)
      StableNoYes
      Cache behaviourExcellent (in-place, sequential access)Good but external merge requires extra memory

      Despite the Θ(n2)\Theta(n^2) worst case, Quicksort is often faster than MergeSort in practice because of low constant factors and cache-friendly memory access patterns. The worst case can be mitigated by random pivot selection or median-of-three.

      Summary

      CaseRunning TimeCondition
      BestΘ(nlogn)\Theta(n \log n)Pivot always splits evenly
      AverageΘ(nlogn)\Theta(n \log n)Random pivot; expected 2nlnn{\sim}2n \ln n comparisons
      WorstΘ(n2)\Theta(n^2)Pivot always min or max
      Ratio splitΘ(nlogn)\Theta(n \log n)Any constant fraction split
      Constant splitΘ(n2)\Theta(n^2)Splitting off constant-size chunk
      Key insightUnbalanced but proportional splits are fine; constant-remainder splits are disastrous
    • Mergesort

      Algorithm

      Mergesort is a classic divide-and-conquer sorting algorithm:

      1. Divide: Split the array into two halves of (roughly) equal size.
      2. Conquer: Recursively sort each half.
      3. Combine: Merge the two sorted halves into a single sorted array.

      Mergesort: divide array into halves, recursively sort, then merge

      Merge Procedure

      Given two sorted subarrays LL and RR, use two pointers ii and jj:

      1. Initialise i=1i=1, j=1j=1 (pointing to the start of each subarray).
      2. For k=1k = 1 to L+R|L| + |R|:
      3.  If L[i]R[j]L[i] \le R[j]: place L[i]L[i] into output, increment ii.
      4.  Else: place R[j]R[j] into output, increment jj.
      5. If one subarray is exhausted, copy the remainder of the other.

      A common trick: append a sentinel \infty to both subarrays so neither is ever exhausted before the loop ends.

      Walkthrough Example

      Input: [9,102,10,7,64,18][9, 102, 10, -7, 64, 18]

      Split:                [9, 10, 102, -7, 64, 18]
                           /                          \
                 [9, 10, 102]                    [-7, 64, 18]
                /            \                  /             \
           [9]                [10, 102]     [-7]              [64, 18]
         (base)             /       \     (base)            /        \
                       [10]          [102]              [64]          [18]
                      (base)        (base)            (base)        (base)
      
      Merge up: [9] + [10, 102] -> [9, 10, 102]
                [64] + [18] -> [18, 64]   (sorted merge)
                [-7] + [18, 64] -> [-7, 18, 64]
                [9, 10, 102] + [-7, 18, 64] -> [-7, 9, 10, 18, 64, 102]

      Analysis

      Recurrence

      Let T(n)T(n) be the cost of sorting nn elements:

      T(n)={Θ(1)if n=12T(n/2)+Θ(n)if n>1T(n) = \begin{cases} \Theta(1) & \text{if } n = 1 \\ 2T(n/2) + \Theta(n) & \text{if } n > 1 \end{cases}

      The Θ(n)\Theta(n) term covers both the array copying for the subarrays and the merge comparison loop.

      Closed Form via Substitution

      Unwinding the recurrence:

      T(n)=2T(n/2)+kn=2[2T(n/4)+kn/2]+kn=4T(n/4)+2knT(n) = 2T(n/2) + kn = 2[2T(n/4) + k n/2] + kn = 4T(n/4) + 2kn

      After log2n\log_2 n substitutions:

      T(n)=nT(1)+knlog2n=Θ(nlogn)T(n) = nT(1) + kn \log_2 n = \Theta(n \log n)

      Via Master Theorem

      a=2a = 2, b=2b = 2, f(n)=Θ(n)f(n) = \Theta(n). Compute nlogba=nlog22=nn^{\log_b a} = n^{\log_2 2} = n. Since f(n)=Θ(nlogba)f(n) = \Theta(n^{\log_b a}), Case 2 applies:

      T(n)=Θ(nlogbalogn)=Θ(nlogn)T(n) = \Theta(n^{\log_b a} \cdot \log n) = \Theta(n \log n)

      The Call Tree Method

      At the top level, merge costs knkn. At the next level, two merges each cost kn/2k n/2, totalling knkn. Each of the log2n+1\log_2 n + 1 levels contributes exactly knkn, so T(n)=kn(log2n+1)=Θ(nlogn)T(n) = kn(\log_2 n + 1) = \Theta(n \log n).

      Properties

      PropertyValue
      Worst caseΘ(nlogn)\Theta(n \log n)
      Average caseΘ(nlogn)\Theta(n \log n)
      Best caseΘ(nlogn)\Theta(n \log n)
      StableYes — merge preserves order of equal keys
      In-placeNo (standard); Θ(n)\Theta(n) extra space
      Data-sensitiveNo — always Θ(nlogn)\Theta(n \log n)
      Non-integer nnT(n)=T(n/2)+T(n/2)+Θ(n)T(n) = T(\lceil n/2 \rceil) + T(\lfloor n/2 \rfloor) + \Theta(n), same solution

      Variations

      • Bottom-up merge sort: Iterative, merging pairs of size 1, then 2, then 4, etc. Useful for linked lists where splitting is O(1)O(1).
      • Natural merge sort: Exploits existing runs (sorted subsequences) in the input; can approach O(n)O(n) on nearly-sorted data.
      • In-place merge sort: The merge step can be made in-place with clever data movement, but the constant factor becomes large and practical implementations use the extra space.

      Summary

      AspectDetail
      StrategyDivide and conquer
      RecurrenceT(n)=2T(n/2)+Θ(n)T(n) = 2T(n/2) + \Theta(n)
      SolutionΘ(nlogn)\Theta(n \log n)
      Master TheoremCase 2: a=2,b=2,f(n)=Θ(n)a=2, b=2, f(n)=\Theta(n)
      SpaceΘ(n)\Theta(n) extra
      StableYes
      OptimalYes — matches Ω(nlogn)\Omega(n \log n) lower bound
  • Heaps and Heapsort

    The binary heap data structure, heapify, building a heap in linear time, and the heapsort algorithm

    • The Binary Heap Structure

      Definition

      A (binary) max-heap is a complete binary tree satisfying the heap property: the value at every node is greater than or equal to the values of its children. A min-heap reverses this: every node is less than or equal to its children.

      Two defining properties:

      1. Structural property: The tree is complete — every level except possibly the bottom is full, and the bottom level is filled from left to right.
      2. Ordering property: Parent \ge children (max-heap) or Parent \le children (min-heap).

      A max-heap showing parent values greater than or equal to children, and the corresponding array layout

      Array Representation

      The complete binary tree structure enables a compact array representation. Using 1-indexed arrays:

      • Root at A[1]A[1].
      • Left child of node ii: A[2i]A[2i].
      • Right child of node ii: A[2i+1]A[2i + 1].
      • Parent of node ii: A[i/2]A[\lfloor i/2 \rfloor].

      The root has no parent (1/2=0\lfloor 1/2 \rfloor = 0). A child is absent when 2i2i or 2i+12i + 1 exceeds A.heap_size (which may be less than A.length since the heap may occupy only a prefix of the array).

      Height of a Heap

      A heap with nn nodes has height h=log2nh = \lfloor \log_2 n \rfloor. The number of nodes at level kk is at most 2k2^k, and the deepest leaf is at level hh.

      • For n=6n=6: h=log26=2.58=2h = \lfloor \log_2 6 \rfloor = \lfloor 2.58 \rfloor = 2.
      • For n=15n=15: h=log215=3h = \lfloor \log_2 15 \rfloor = 3 (a perfect binary tree).

      Operations that traverse from root to leaf (or leaf to root) are bounded by the height, giving O(logn)O(\log n) time.

      Heap vs Binary Search Tree

      PropertyHeapBST
      Left-right orderingNone; only parent-child inequalityLeft subtree < root < right subtree
      StructureComplete treeNo structural guarantee (can be degenerate)
      Finding min/maxO(1)O(1) — always at rootO(h)O(h) — traverse to leftmost/rightmost
      SearchingO(n)O(n) — must scanO(h)O(h) if balanced
      In-order traversalNot meaningfulProduces sorted order
      Use casePriority queue, HeapsortDictionary, ordered map

      The heap is a semi-structure: cheaper to maintain than a fully ordered structure, yet sufficient for repeatedly extracting the largest (or smallest) element.

      Semi-Structured Nature

      In a max-heap of nn elements:

      • The largest is at exactly one place: the root.
      • The second largest is in one of two places: the root’s children.
      • The third largest is in one of three places: the other root child, or either child of the second largest.

      This “partial-sort” property is what makes heap operations O(logn)O(\log n) rather than the O(nlogn)O(n \log n) cost of full sorting.

      Summary

      PropertyDetail
      StructureComplete binary tree, array representation
      Parent at iii/2\lfloor i/2 \rfloor
      Left child at ii2i2i
      Right child at ii2i+12i + 1
      Heightlog2n\lfloor \log_2 n \rfloor
      Heap property (max)A[i]A[2i],A[2i+1]A[i] \ge A[2i], A[2i+1]
      Heap vs BSTHeap: weak order, strong structure; BST: strong order, weak structure
    • Heap Operations

      All max-heap operations run in O(logn)O(\log n) time, where nn is the number of elements in the heap. Each operation works by traversing a single path from root to leaf (or leaf to root), bounded by the heap height log2n\lfloor \log_2 n \rfloor.

      Max-Heapify (Bubble-Down)

      Purpose: Restore the max-heap property at node ii, assuming the subtrees rooted at ii‘s children are already valid max-heaps.

      Algorithm:

      1. Let l=2il = 2i, r=2i+1r = 2i + 1.
      2. Set largest to ii if A[i]A[l]A[i] \ge A[l] (or ll exceeds heap size), else to ll.
      3. If rr is within heap and A[r]>A[largest]A[r] > A[\text{largest}], set largest to rr.
      4. If largest i\ne i, swap A[i]A[i] and A[largest]A[\text{largest}], then recursively call Max-Heapify on largest.

      Time: O(logn)O(\log n) — each swap moves down one level, at most the height of the heap.

      Max-heapify: compare node with children, swap with larger child, recurse down

      Walkthrough

      Starting with a heap violation at the root: A=[4,14,10,8,7,9,3,2,1]A = [4, 14, 10, 8, 7, 9, 3, 2, 1] (node A[1]=4A[1] = 4, children 1414 and 1010):

      StepCurrent nodeChildrenLargestAction
      144 at index 11414, 10101414 at index 2Swap 4 and 14
      244 at index 288, 7788 at index 4Swap 4 and 8
      344 at index 422, 1144 at index 4No swap; done

      Result: [14,8,10,4,7,9,3,2,1][14, 8, 10, 4, 7, 9, 3, 2, 1] — a valid max-heap.

      Insert (Bubble-Up)

      Purpose: Insert a new key into the heap.

      Algorithm:

      1. Increment heap_size; place the new key at A[heap_size]A[\text{heap\_size}].
      2. Set j=heap_sizej = \text{heap\_size}.
      3. While j>0j > 0 and A[j]>A[parent(j)]A[j] > A[\text{parent}(j)]:
      4.  Swap A[j]A[j] and A[parent(j)]A[\text{parent}(j)].
      5. j=parent(j)j = \text{parent}(j).

      Time: O(logn)O(\log n) — at most one swap per level up to the root.

      Extract-Max

      Purpose: Remove and return the largest element (the root).

      Algorithm:

      1. If the heap is empty, error.
      2. Store A[1]A[1] (the maximum).
      3. Swap A[1]A[1] with A[heap_size]A[\text{heap\_size}].
      4. Decrement heap_size.
      5. Call Max-Heapify on the root (index 1).
      6. Return stored maximum.

      Time: O(logn)O(\log n) — constant work plus one Max-Heapify call.

      The swap with the last element preserves the structural property (only the rightmost bottom element can be removed without breaking completeness). The heap property is fixed by bubbling the new root downwards.

      Increase-Key (Bubble-Up)

      Purpose: Increase the value of a key at position ii, then restore the heap property by moving it upward.

      Algorithm:

      1. If new value < current value, error.
      2. Set A[i]A[i] to new value.
      3. While i>1i > 1 and A[i]>A[parent(i)]A[i] > A[\text{parent}(i)]:
      4.  Swap A[i]A[i] and A[parent(i)]A[\text{parent}(i)].
      5. i=parent(i)i = \text{parent}(i).

      Time: O(logn)O(\log n).

      Max-Peek

      Simply return A[1]A[1] in O(1)O(1) time. No modification to the heap.

      Operations Summary

      OperationPurposeTimeMethod
      Max-HeapifyFix a single violationO(logn)O(\log n)Bubble down
      InsertAdd new elementO(logn)O(\log n)Add at end, bubble up
      Extract-MaxRemove and return maxO(logn)O(\log n)Swap with last, bubble down
      Increase-KeyUpdate to larger valueO(logn)O(\log n)Update, bubble up
      Max-PeekView maximumO(1)O(1)Read A[1]A[1]

      All operations rely on the heap height being log2n\lfloor \log_2 n \rfloor, ensuring each upward or downward traversal touches at most O(logn)O(\log n) nodes.

    • Building a Heap in Linear Time

      Naive Build: Θ(nlogn)\Theta(n \log n)

      Inserting nn elements one by one, each costing O(logn)O(\log n), gives Θ(nlogn)\Theta(n \log n) total. This is the obvious approach but not optimal.

      Linear-Time Build: Bottom-Up Heapify

      A more efficient method calls Max-Heapify on all non-leaf nodes, working bottom-up from n/2\lfloor n/2 \rfloor down to 1:

      Build-Max-Heap(A):
        A.heap_size = A.length
        for i = floor(A.length / 2) downto 1:
          Max-Heapify(A, i)

      The key insight: leaves (indices n/2+1\lfloor n/2 \rfloor + 1 to nn) are already 1-element max-heaps, so they need no work. Starting from the last non-leaf node and working upward ensures that when Max-Heapify is called on node ii, both subtrees of ii are already valid heaps.

      Correctness

      Loop invariant: At the start of each iteration, every node index >i> i is the root of a valid max-heap.

      • Initialisation (i=n/2i = \lfloor n/2 \rfloor): All nodes >n/2> \lfloor n/2 \rfloor are leaves, trivially heaps.
      • Maintenance: Max-Heapify fixes node ii; after the call, ii is also a heap root. Moving to i1i-1 preserves the invariant.
      • Termination (i=0i = 0): All nodes, including the root (index 1), are valid heaps.

      Analysis: Why O(n)O(n)

      The cost of Max-Heapify at node ii is proportional to the height of node ii, i.e. the number of levels below it. At level hh from the bottom (height hh), there are at most n/2h+1\lceil n / 2^{h+1} \rceil nodes, and each Max-Heapify costs O(h)O(h). Summing over all levels:

      T(n)h=0lognn2h+1chcn2h=0h2h1T(n) \le \sum_{h=0}^{\lfloor \log n \rfloor} \left\lceil \frac{n}{2^{h+1}} \right\rceil \cdot c h \le c \cdot \frac{n}{2} \sum_{h=0}^{\infty} \frac{h}{2^{h-1}}

      The infinite sum converges: h=0h/2h=2\sum_{h=0}^{\infty} h/2^{h} = 2. Therefore:

      T(n)=O(n)T(n) = O(n)

      Concrete Calculation

      For n=15n = 15 (a perfect heap), the tree has 4 levels:

      Level (from leaf)Height hhNodes at this levelWork per nodeTotal work
      0 (leaves)0800
      114cc4c4c
      2222c2c4c4c
      3 (root)313c3c3c3c

      Total: 11c11c, whilst nlognn \log n would be 15×3.9c59c15 \times 3.9c \approx 59c. The linear-time build is substantially faster.

      Step-by-Step Example

      Input: A=[4,1,3,2,16,9,10,14,8,7]A = [4, 1, 3, 2, 16, 9, 10, 14, 8, 7] (n=10n=10)

      First non-leaf: 10/2=5\lfloor 10/2 \rfloor = 5, value 1616.

      Initial array as tree:          After Build-Heap (final):
                4                                 16
            /       \                          /      \
           1         3                       14        10
         /   \     /   \                   /   \     /    \
        2    16   9     10                8     7   9      3
       / \   /                          / \   /
      14  8 7                          2   4  1
      StepiiActionKey effect
      15Max-Heapify on node with value 16Already a heap (16 > 7)
      24Max-Heapify on node with value 2Swap 2 with 14; [14,8][14, 8] heap
      33Max-Heapify on node with value 3Swap 3 with 10; [10,9][10, 9] heap
      42Max-Heapify on node with value 1Swap 1 with 16, then 1 with 7; heap
      51Max-Heapify on node with value 4Swap 4 with 16, then 4 with 14, then 4 with 8

      Final heap: [16,14,10,8,7,9,3,2,4,1][16, 14, 10, 8, 7, 9, 3, 2, 4, 1].

      Summary

      AspectDetail
      Naive buildInsert nn elements: Θ(nlogn)\Theta(n \log n)
      Efficient buildBottom-up heapify: Θ(n)\Theta(n)
      Starting indexn/2\lfloor n/2 \rfloor (last non-leaf)
      Why O(n)O(n)Most nodes (leaves) cost O(0)O(0); higher nodes are few
      Series sumh/2h\sum h / 2^{h} converges to 2
      Key insightWork is proportional to height, not depth
    • Heapsort

      Algorithm

      Heapsort sorts an array in two phases:

      Phase 1: Build a max-heap from the input array in O(n)O(n) time.

      Phase 2: Repeatedly extract the maximum and place it at the end:

      1. Build-Max-Heap(AA).
      2. For i=A.lengthi = A.\text{length} down to 2:
      3.  Swap A[1]A[1] (the maximum) with A[i]A[i].
      4.  Decrement heap_size (excludes the sorted suffix).
      5.  Max-Heapify(AA, 1).

      Heapsort: build max-heap, then repeatedly extract max to build sorted suffix

      After each iteration, A[1i1]A[1 \dots i-1] is a max-heap and A[in]A[i \dots n] contains the largest ni+1n-i+1 elements in sorted order. When ii reaches 22, the entire array is sorted in ascending order (assuming a max-heap; for descending order, use a min-heap).

      Walkthrough

      Input: [1,9,3,4,2,7,6,5,8][1, 9, 3, 4, 2, 7, 6, 5, 8]

      Phase 1 (Build-Heap): $[1, 9, 3, 4, 2, 7, 6, 5, 8] → [9, 8, 7, 5, 2, 3, 6, 1, 4]$ (max-heap)
      
      Phase 2 (Extract-Max loop):
        i=9: swap 9 ⇄ 4, heapify → heap [8,5,7,1,2,3,6,4 | 9]
        i=8: swap 8 ⇄ 4, heapify → heap [7,5,6,1,2,3,4 | 8, 9]
        i=7: swap 7 ⇄ 4, heapify → heap [6,5,3,1,2,4 | 7, 8, 9]
        i=6: swap 6 ⇄ 4, heapify → heap [5,4,3,1,2 | 6, 7, 8, 9]
        i=5: swap 5 ⇄ 2, heapify → heap [4,2,3,1 | 5, 6, 7, 8, 9]
        i=4: swap 4 ⇄ 1, heapify → heap [3,2,1 | 4, 5, 6, 7, 8, 9]
        i=3: swap 3 ⇄ 1, heapify → heap [2,1 | 3, 4, 5, 6, 7, 8, 9]
        i=2: swap 2 ⇄ 1, heapify → heap [1 | 2, 3, 4, 5, 6, 7, 8, 9]
      
      Final: [1, 2, 3, 4, 5, 6, 7, 8, 9]

      Complexity Analysis

      Phase 1 (Build-Max-Heap): Θ(n)\Theta(n) as proved earlier.

      Phase 2: n1n-1 iterations, each calling Max-Heapify on the root of a shrinking heap. Max-Heapify on a heap of size kk costs O(logk)O(\log k). Summing:

      T(n)=Θ(n)+k=2nO(logk)=Θ(n)+O(log(n!))T(n) = \Theta(n) + \sum_{k=2}^{n} O(\log k) = \Theta(n) + O(\log(n!))

      Using Stirling’s approximation: log(n!)nlognn+O(logn)\log(n!) \le n \log n - n + O(\log n). Therefore:

      T(n)=O(nlogn)T(n) = O(n \log n)

      This bound is worst-case. Heapsort never degrades to Θ(n2)\Theta(n^2), unlike Quicksort.

      In-Place Property

      Heapsort uses only O(1)O(1) extra space beyond the input array (a few variables for indices and the temporary for swaps). The heap is stored within the same array as the sorted output, separated by the heap_size boundary. This is achieved by using the suffix of the array for the sorted region and the prefix for the heap.

      Cache Performance

      Heapsort’s in-place nature comes at a cost: Max-Heapify jumps between array positions at powers of 2 (i2i4ii \to 2i \to 4i \to \dots), which are far apart in memory for large indices. This results in poor cache locality compared to Quicksort (which scans contiguously during partitioning) and MergeSort (which merges contiguously). In practice, Heapsort is often slower than Quicksort for moderate nn, despite its superior worst-case guarantee.

      Comparison with Other Sorts

      SortWorst CaseAverage CaseIn-placeStable
      HeapsortO(nlogn)O(n \log n)O(nlogn)O(n \log n)YesNo
      MergeSortΘ(nlogn)\Theta(n \log n)Θ(nlogn)\Theta(n \log n)No (Θ(n)\Theta(n))Yes
      QuicksortΘ(n2)\Theta(n^2)Θ(nlogn)\Theta(n \log n)YesNo
      Insertion SortΘ(n2)\Theta(n^2)Θ(n2)\Theta(n^2)YesYes

      Summary

      PhaseCostDescription
      Build-HeapΘ(n)\Theta(n)Bottom-up max-heapify
      Extract loopO(nlogn)O(n \log n)n1n-1 extractions, each O(logk)O(\log k)
      TotalO(nlogn)O(n \log n)Worst-case guarantee
      SpaceO(1)O(1)In-place via array prefix/suffix split
      StableNoSwaps non-adjacent elements
      CachePoorNon-local memory access pattern
      AdvantageGood worst case, in-place
      DisadvantageSlower constant factors, poor cache
  • Linear-Time Sorts

    Counting sort, radix sort, bucket sort, stability, the decision-tree lower bound, and order statistics

    • Counting Sort

      Overview

      Counting sort sorts nn integers drawn from a known range [0,k][0, k]. It is not a comparison sort, so it evades the Ω(nlogn)\Omega(n \log n) lower bound. Instead, it counts occurrences of each value, computes cumulative counts to determine output positions, and places each element directly into its correct sorted position.

      Running time is Θ(n+k)\Theta(n + k); space is Θ(n+k)\Theta(n + k). Counting sort is stable. It is practical linear time only when k=O(n)k = O(n).

      Algorithm

      Counting sort diagram

      Given input array A of length nn and maximum value kk:

      1. Allocate a count array C[0..k], initialise all entries to 0.
      2. For each element vv in A, increment C[v].
      3. Convert C to cumulative counts: for i=1i = 1 to kk, set C[i] = C[i] + C[i-1]. Now C[i] is the number of elements i\le i.
      4. Allocate output array B[1..n]. Iterate through A from right to left (to preserve stability): for each v=A[j]v = A[j], place it at B[C[v]], then decrement C[v].

      The right-to-left pass in step 4 is what guarantees stability: when duplicate values exist, the element appearing later in the input is placed later in the output.

      Worked Example

      Sort A = [2, 5, 3, 0, 2, 3, 0, 3] with k=5k = 5.

      Step 1: Count occurrences

      ii012345
      C[i]202301

      Step 2: Cumulative counts

      ii012345
      C[i]224778

      Interpretation: there are 2 elements 0\le 0, 2 elements 1\le 1, 4 elements 2\le 2, etc. So the value 2 will occupy output positions 3 and 4 (1-indexed: C[2] = 4, then decrement yields 3 for next occurrence).

      Step 3: Place elements (right to left)

      • A[8]=3A[8] = 3: C[3] = 7B[7] = 3, C[3] = 6
      • A[7]=0A[7] = 0: C[0] = 2B[2] = 0, C[0] = 1
      • A[6]=3A[6] = 3: C[3] = 6B[6] = 3, C[3] = 5
      • A[5]=2A[5] = 2: C[2] = 4B[4] = 2, C[2] = 3
      • A[4]=0A[4] = 0: C[0] = 1B[1] = 0, C[0] = 0
      • A[3]=3A[3] = 3: C[3] = 5B[5] = 3, C[3] = 4
      • A[2]=5A[2] = 5: C[5] = 8B[8] = 5, C[5] = 7
      • A[1]=2A[1] = 2: C[2] = 3B[3] = 2, C[2] = 2

      Output: B = [0, 0, 2, 2, 3, 3, 3, 5]. Notice that the two 0s preserve their original relative order, as do the two 2s and three 3s. The sort is stable.

      Complexity Analysis

      StepCost
      Initialise C[0..k]Θ(k)\Theta(k)
      Count occurrencesΘ(n)\Theta(n)
      Cumulative countsΘ(k)\Theta(k)
      Place elementsΘ(n)\Theta(n)
      TotalΘ(n+k)\Theta(n + k)

      Space: Θ(n+k)\Theta(n + k) for the output array and count array. Counting sort is not in-place.

      When Is It Linear?

      If k=O(n)k = O(n), then Θ(n+k)=Θ(n)\Theta(n + k) = \Theta(n). This is the practical regime. If kk is much larger than nn (e.g. sorting a handful of 64-bit integers), then counting sort is Θ(k)\Theta(k)-dominated and wastes both time and space.

      Why It Beats the Lower Bound

      The Ω(nlogn)\Omega(n \log n) lower bound applies only to comparison-based sorting. Counting sort never compares elements against each other; it inspects the numerical value of each key directly and uses that value as an array index. This is not a valid operation in the comparison model.

      Stability

      Counting sort is stable because we iterate the input from right to left when placing elements. The last occurrence of a value in the original array is placed at the rightmost available position for that value in the output, preserving relative order amongst equal keys.

      Summary

      PropertyValue
      TypeNon-comparison, integer sort
      Worst-case timeΘ(n+k)\Theta(n + k)
      SpaceΘ(n+k)\Theta(n + k)
      StableYes
      In-placeNo
      Practical whenk=O(n)k = O(n)
      Beats Ω(nlogn)\Omega(n \log n)Yes (not comparison-based)
    • Radix Sort

      Overview

      Radix sort sorts integers digit by digit, starting from the least significant digit (LSD). It uses a stable auxiliary sort (typically counting sort) on each digit position, proceeding from right to left. If each digit lies in the range [0,k1][0, k-1] and there are dd digits, the running time is Θ(d(n+k))\Theta(d(n + k)).

      The algorithm relies critically on the stability of the per-digit sort: when we sort by a more significant digit, equal digits in that position preserve the ordering established by all less significant digits.

      Algorithm (LSD Radix Sort)

      Radix sort diagram

      for i = 1 to d:
          stably sort array A on digit i

      Digit 1 is the least significant digit (units). Each pass uses counting sort with kk being the radix (base) of the digit — typically k=10k = 10 for decimal or k=256k = 256 for byte-based sorting.

      Worked Example

      Sort [170, 45, 75, 90, 802, 24, 2, 66] by decimal digits (d=3d = 3, pad with leading zeros: [170, 045, 075, 090, 802, 024, 002, 066]).

      Pass 1: Sort by units digit (digit 1) Sorting stably by the last digit groups by units: 170(0), 090(0), 802(2), 002(2), 024(4), 045(5), 075(5), 066(6).

      Result: [170, 090, 802, 002, 024, 045, 075, 066]

      Pass 2: Sort by tens digit (digit 2) Stable sort by tens digit: 802(0), 002(0), 024(2), 045(4), 066(6), 170(7), 075(7), 090(9).

      Result: [802, 002, 024, 045, 066, 170, 075, 090]

      Pass 3: Sort by hundreds digit (digit 3) Stable sort by hundreds: 002(0), 024(0), 045(0), 066(0), 075(0), 090(0), 170(1), 802(8).

      Result: [2, 24, 45, 66, 75, 90, 170, 802]

      Sorted. Notice that 170 and 075 both had tens digit 7; stability preserved their order from the units pass (0 before 5, so 170 before 75). Similarly, 802 and 002 both had hundreds digit 0; stability preserved the tens-digit ordering.

      Why LSD Works

      After pass 1, the array is sorted by the least significant digit. Pass 2 sorts by the next digit; because the sort is stable, when two elements have the same value in digit 2, they remain in the order determined by pass 1 (i.e. by digit 1). By induction, after dd passes the array is fully sorted.

      If the per-digit sort were unstable, a later pass could scramble the ordering established by earlier passes. Stability is therefore essential to radix sort’s correctness.

      Complexity Analysis

      For nn numbers with dd digits each, using counting sort for each digit:

      • Each counting sort pass: Θ(n+k)\Theta(n + k)
      • dd passes: Θ(d(n+k))\Theta(d(n + k))

      For 32-bit integers, choosing bytes as digits gives d=4d = 4, k=256k = 256. Then Θ(4(n+256))=Θ(n)\Theta(4(n + 256)) = \Theta(n). Radix sort is a linear-time sort for fixed-width integers.

      Historical Note

      Radix sort was used by Herman Hollerith in his 1890 US Census tabulating machine. Punch cards were physically sorted into bins (digit buckets) and gathered, one digit column at a time. The IBM 83 Card Sorter (1955) sorted 1000 cards per minute using the same principle.

      Comparison with Other Linear Sorts

      SortAssumptionTime
      Counting sortKeys in [0,k][0, k], k=O(n)k = O(n)Θ(n+k)\Theta(n + k)
      Radix sortdd digits, each 0..k10..k-1Θ(d(n+k))\Theta(d(n + k))
      Bucket sortUniform distribution in [0,1)[0, 1)Θ(n)\Theta(n) average

      Radix sort generalises counting sort to larger key ranges by treating keys as composed of smaller-range digits.

      Summary

      PropertyValue
      TypeNon-comparison, digit-by-digit sort
      Worst-case timeΘ(d(n+k))\Theta(d(n + k))
      SpaceDepends on underlying stable sort
      StableYes (if digit sort is stable)
      Practical forFixed-width integers, strings
      Beats Ω(nlogn)\Omega(n \log n)Yes (not comparison-based)
    • Bucket Sort

      Overview

      Bucket sort distributes nn inputs into kk buckets according to their key range, sorts each bucket individually (typically with insertion sort), and concatenates the buckets in order. It assumes the input is drawn uniformly from a known interval. Under this assumption, each bucket receives approximately n/kn/k elements and the average-case running time is Θ(n)\Theta(n).

      Worst-case running time is Θ(n2)\Theta(n^2) if all elements fall into one bucket, since insertion sort on nn elements is Θ(n2)\Theta(n^2).

      Algorithm

      Bucket sort diagram

      For input in the range [0,1)[0, 1):

      1. Create nn empty buckets (linked lists or arrays).
      2. For each element xx, insert it into bucket nx\lfloor n \cdot x \rfloor.
      3. Sort each bucket (e.g. with insertion sort).
      4. Concatenate buckets in order (0, 1, …, n1n-1).

      The bucket index nx\lfloor n \cdot x \rfloor maps the uniform [0,1)[0, 1) range onto nn equal-width intervals.

      Worked Example

      Input: [0.78,0.17,0.39,0.26,0.72,0.94,0.21,0.12,0.23,0.68][0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68], n=10n = 10.

      Step 2: Distribute into buckets (10x\lfloor 10 \cdot x \rfloor):

      BucketRangeElements
      0[0,0.1)[0, 0.1)
      1[0.1,0.2)[0.1, 0.2)0.17, 0.12
      2[0.2,0.3)[0.2, 0.3)0.26, 0.21, 0.23
      3[0.3,0.4)[0.3, 0.4)0.39
      4[0.4,0.5)[0.4, 0.5)
      5[0.5,0.6)[0.5, 0.6)
      6[0.6,0.7)[0.6, 0.7)0.68
      7[0.7,0.8)[0.7, 0.8)0.78, 0.72
      8[0.8,0.9)[0.8, 0.9)
      9[0.9,1.0)[0.9, 1.0)0.94

      Step 3: Sort each bucket (insertion sort):

      • Bucket 1: [0.12,0.17][0.12, 0.17]
      • Bucket 2: [0.21,0.23,0.26][0.21, 0.23, 0.26]
      • Bucket 3: [0.39][0.39]
      • Bucket 6: [0.68][0.68]
      • Bucket 7: [0.72,0.78][0.72, 0.78]
      • Bucket 9: [0.94][0.94]

      Step 4: Concatenate: [0.12,0.17,0.21,0.23,0.26,0.39,0.68,0.72,0.78,0.94][0.12, 0.17, 0.21, 0.23, 0.26, 0.39, 0.68, 0.72, 0.78, 0.94]

      Complexity Analysis

      Distribution into nn buckets: Θ(n)\Theta(n). Sorting each bucket: let nin_i be the number of elements in bucket ii. Insertion sort on bucket ii costs O(ni2)O(n_i^2). Total:

      T(n)=Θ(n)+i=0n1O(ni2)T(n) = \Theta(n) + \sum_{i=0}^{n-1} O(n_i^2)

      Under the uniform distribution assumption, the expected value of ni2n_i^2 is 21/n2 - 1/n, giving T(n)=Θ(n)+nO(21/n)=Θ(n)T(n) = \Theta(n) + n \cdot O(2 - 1/n) = \Theta(n).

      If the uniformity assumption fails and all nn elements land in one bucket, insertion sort on that bucket costs Θ(n2)\Theta(n^2), which is the worst case.

      Comparison with Counting Sort and Radix Sort

      SortAssumptionAverageWorst
      Counting sortInteger keys in [0,k][0, k]Θ(n+k)\Theta(n + k)Θ(n+k)\Theta(n + k)
      Radix sortdd-digit integersΘ(d(n+k))\Theta(d(n + k))Θ(d(n+k))\Theta(d(n + k))
      Bucket sortUniform real keys in [0,1)[0, 1)Θ(n)\Theta(n)Θ(n2)\Theta(n^2)

      Bucket sort is the only one of the three whose worst case is quadratic. Its average-case speed depends on the uniform-distribution assumption, whereas counting and radix sort guarantee their bounds for all inputs satisfying their range constraints.

      Practical Use

      Bucket sort is most useful when input is known to be uniformly distributed — for example, hashing a uniform key space into buckets. In practice, it is often combined with other techniques (e.g. using a better sort within buckets, or using it as one phase of a hybrid algorithm).

      Summary

      PropertyValue
      TypeNon-comparison, distribution sort
      Average timeΘ(n)\Theta(n)
      Worst-case timeΘ(n2)\Theta(n^2)
      SpaceΘ(n)\Theta(n) for buckets
      StableDepends on bucket sort
      AssumptionUniform distribution of keys
      Beats Ω(nlogn)\Omega(n \log n)Yes on average (not comparison-based)
    • Stability of Sorting Algorithms

      Definition

      A sorting algorithm is stable if, for any two elements with equal sort keys, their relative order in the input is preserved in the output. In other words, if A[i]=A[j]A[i] = A[j] and i<ji < j, then after sorting, the element originally at ii appears before the element originally at jj.

      Stability is about the behaviour with respect to duplicate keys; it says nothing about the algorithm’s running time.

      Which Sorts Are Stable

      AlgorithmStable?Reason
      Insertion sortYesPlaces each element into its correct sorted position without jumping over equal elements
      Selection sortNoSwapping the minimum into position can leap over equal elements, changing their relative order
      MergesortYesMerge takes from the left subarray when keys are equal, preserving input order
      QuicksortNoPartitioning swaps elements arbitrarily; equal keys can cross each other
      HeapsortNoHeap operations (reheapify, extract) do not preserve relative order of equal keys
      Counting sortYesIterates from right to left when placing output, preserving rightmost-later ordering
      Radix sortDependsStable if the underlying digit sort is stable (typically counting sort)
      Bucket sortDependsStable if the sort used within each bucket is stable and concatenation is ordered

      Why Stability Matters

      Multi-key Sorting

      Suppose we want to sort student records first by exam mark (descending) and then alphabetically by surname for students with the same mark. Using a stable sort:

      1. Sort by surname (any sort works for this pass).
      2. Stably sort by mark.

      After step 2, students with equal marks remain in surname order from step 1. If the mark sort were unstable, the alphabetical ordering could be destroyed.

      This technique generalises: to sort by mm keys in priority order (least significant first), use mm stable sorts. This is sometimes called the “stable sort composition” property.

      Radix Sort

      Radix sort depends fundamentally on stability. Each digit pass must preserve the ordering established by less significant digits. An unstable digit sort would scramble the work of previous passes, making the algorithm incorrect.

      If you only have access to an unstable sort and need stability, you can extend the sort key to include the original index as a tiebreaker: (original_key, original_index). This guarantees uniqueness and simulates stability, at the cost of Θ(n)\Theta(n) extra space for the extended records.

      Counterexample: Unstable Quicksort

      Given [(3, A), (3, B)] where A and B are the original order:

      • Choose pivot = 3 (the last element, (3, B)).
      • All other elements are \le pivot, so they land left of pivot.
      • The partition may swap (3, A) with itself or rearrange arbitrarily.
      • Output might be [(3, B), (3, A)], violating stability.

      Summary

      PropertyValue
      Stable sortsInsertion, Mergesort, Counting sort, (some) Radix
      Unstable sortsSelection, Quicksort, Heapsort
      Key usesMulti-key sorting, radix sort, duplicate-sensitive applications
      Emulating stabilityExtend key with original index as tiebreaker
    • The Decision-Tree Lower Bound Proof

      Overview

      Any comparison-based sorting algorithm must perform Ω(nlogn)\Omega(n \log n) comparisons in the worst case. This is a fundamental lower bound, proved by modelling the algorithm as a decision tree. Non-comparison sorts (counting, radix, bucket) evade this bound because they examine key values directly, not just pairwise orderings.

      The Decision Tree Model

      Decision tree diagram

      A comparison-based sorting algorithm can be modelled as a binary tree:

      • Each internal node represents a comparison between two elements: aiaj?a_i \le a_j?
      • The two outgoing edges represent the outcomes: Yes (true) and No (false).
      • Each leaf represents a specific output permutation of the input — the sorted order.
      • A path from root to leaf represents the sequence of comparisons performed for a particular input that yields that sorted permutation.

      For inputs with nn distinct elements, there are n!n! possible permutations of the output. Each permutation must be reachable via some path in the decision tree, so the tree must have at least n!n! leaves.

      The Proof

      Consider an arbitrary comparison-based sorting algorithm. Let hh be the height of its decision tree (the maximum number of comparisons performed on any input of size nn).

      A binary tree of height hh has at most 2h2^h leaves (each level at most doubles the number of nodes, and leaves can only appear at the bottom). Since the tree must accommodate all n!n! possible output orderings:

      n!#leaves2hn! \le \text{\#leaves} \le 2^h

      Taking logs base 2:

      hlog2(n!)h \ge \log_2(n!)

      By Stirling’s approximation, log2(n!)=Θ(nlogn)\log_2(n!) = \Theta(n \log n). More precisely:

      log2(n!)=nlog2nnlog2e+Θ(logn)\log_2(n!) = n \log_2 n - n \log_2 e + \Theta(\log n)

      Therefore h=Ω(nlogn)h = \Omega(n \log n). Any comparison sort performs Ω(nlogn)\Omega(n \log n) comparisons in the worst case.

      The Flaw (Non-Comparison Sorts)

      The proof contains a crucial assumption: that the only way to gain information about the input is through pairwise comparisons. This is the comparison model.

      Counting sort does not compare elements against each other. Instead, it uses the numeric value of a key as an array index — an operation that is not a comparison. Radix sort similarly uses digit values to route elements into buckets. These operations are not representable as decision-tree nodes of the form aiaja_i \le a_j, so the lower bound does not constrain them.

      In essence: the lower bound says “if all you can do is compare pairs of elements, you need Ω(nlogn)\Omega(n \log n) comparisons”. Non-comparison sorts sidestep this by exploiting structural properties of the keys (bounded integer range, representable as digits, uniform distribution).

      Bubble Sort: A Cautionary Tale

      Bubble sort repeatedly scans the array, swapping adjacent elements that are out of order, until a full pass produces no swaps. In the worst case (reverse-sorted input), it performs Θ(n)\Theta(n) passes with Θ(n)\Theta(n) comparisons each, yielding Θ(n2)\Theta(n^2) total comparisons.

      Bubble sort is a comparison sort and is quadratically slower than the Ω(nlogn)\Omega(n \log n) lower bound permits. It is presented in the course as an example of what not to reinvent. Its saving grace is a nice loop invariant: after the kk-th pass, the kk largest elements are in their final sorted positions.

      Significance

      The decision-tree lower bound establishes a theoretical limit. It tells us that mergesort and heapsort are asymptotically optimal comparison sorts (both achieve Θ(nlogn)\Theta(n \log n)). It also tells us that to beat nlognn \log n, we must abandon the comparison model — which counting, radix, and bucket sort all do.

      Summary

      PropertyValue
      Lower bound for comparison sortsΩ(nlogn)\Omega(n \log n)
      Proof techniqueDecision tree with n!n! leaves
      Key inequality2hn!    h=Ω(nlogn)2^h \ge n! \implies h = \Omega(n \log n)
      Why non-comparison sorts beat itThey examine key values, not just pairwise order
      Optimal comparison sortsMergesort, Heapsort (both Θ(nlogn)\Theta(n \log n))
    • Order Statistics and Median

      Overview

      The ii-th order statistic of a set of nn elements is the ii-th smallest element. The minimum is the 1st order statistic (i=1i = 1); the maximum is the nn-th; the median (lower median) is the (n+1)/2\lfloor (n+1)/2 \rfloor-th.

      The selection problem: given a set AA of nn distinct numbers and an integer ii where 1in1 \le i \le n, find the element larger than exactly i1i-1 others.

      Finding Minimum and Maximum

      A simple linear scan finds the minimum (or maximum) in n1n-1 comparisons:

      min = A[1]
      for j = 2 to n:
          if A[j] < min: min = A[j]

      Finding both minimum and maximum simultaneously can be done in 3n/22\lceil 3n/2 \rceil - 2 comparisons: process elements in pairs, compare within each pair, then compare the larger against the running maximum and the smaller against the running minimum.

      TaskComparisons
      Find min onlyn1n - 1
      Find max onlyn1n - 1
      Find min and max naively2n22n - 2
      Find min and max optimally3n/22\lceil 3n/2 \rceil - 2

      Quickselect

      Quickselect diagram

      Quickselect uses the same partition subroutine as quicksort, but only recurses on the side containing the desired order statistic:

      1. Call PARTITION(A, p, r) which rearranges A[p..r] around a pivot and returns pivot index qq.
      2. Let k=qp+1k = q - p + 1 (the rank of the pivot within the subarray).
      3. If i=ki = k, return A[q] — the pivot is the answer.
      4. If i<ki < k, recurse on A[p..q-1] for the ii-th statistic.
      5. If i>ki > k, recurse on A[q+1..r] for the (ik)(i-k)-th statistic.

      Complexity

      • Expected: Θ(n)\Theta(n). Each partition is Θ(m)\Theta(m) on a subarray of size mm, but the subarray shrinks by a constant factor in expectation.
      • Worst case: Θ(n2)\Theta(n^2), when the pivot is always the minimum or maximum (e.g. already-sorted or reverse-sorted input).

      Improvements

      1. Randomised pivot: Choose the pivot at random; makes worst-case inputs unlikely.
      2. Median-of-three: Pick three elements, use their median as pivot. Guarantees at least one element on each side, reducing the worst-case probability from 2/n\sim 2/n to 2/n2\sim 2/n^2.
      3. Three-way partition: Split into << pivot, == pivot, >> pivot. Excellent when many duplicates exist.

      Median-of-Medians (Blum-Floyd-Pratt-Rivest-Tarjan)

      This deterministic algorithm finds the ii-th order statistic in O(n)O(n) worst-case time:

      1. Divide the nn elements into groups of 5. Find the median of each group (constant-time per group via insertion sort on 5 elements).
      2. Recursively find the median of these n/5\lceil n/5 \rceil medians; use it as the pivot.
      3. Partition around this median-of-medians pivot.
      4. Recurse on the appropriate side.

      Why O(n)O(n): At least 3(12n/52)3n/1063(\lceil \frac{1}{2} \lceil n/5 \rceil \rceil - 2) \ge 3n/10 - 6 elements are guaranteed to be greater than the pivot, and symmetrically for less than. So even the larger side is at most 7n/10+67n/10 + 6 elements. The recurrence:

      T(n)=T(n/5)+T(7n/10+6)+Θ(n)=O(n)T(n) = T(\lceil n/5 \rceil) + T(7n/10 + 6) + \Theta(n) = O(n)

      For n140n \ge 140, the constant factor works out; for smaller nn, any constant-time method suffices.

      Practical Considerations

      For most applications, simply sorting the array in Θ(nlogn)\Theta(n \log n) and reading the ii-th element is fast enough and far simpler to implement. Quickselect (randomised) is used in practice when nn is large and sorting overhead is undesirable.

      Summary

      MethodTime (expected)Time (worst case)
      Sort then pick ii-thΘ(nlogn)\Theta(n \log n)Θ(nlogn)\Theta(n \log n)
      Quickselect (randomised)Θ(n)\Theta(n)Θ(n2)\Theta(n^2)
      Median-of-mediansΘ(n)\Theta(n)Θ(n)\Theta(n)
      Find min or maxΘ(n)\Theta(n)Θ(n)\Theta(n)
      Find min and maxΘ(n)\Theta(n)Θ(n)\Theta(n)
  • Algorithm Design Strategies

    Log-log scaling, divide and conquer, dynamic programming (top-down and bottom-up), greedy algorithms, activity selection, and knapsack variants

    • Scaling Analysis and Log-Log Plots

      Overview

      Empirically assessing algorithmic complexity involves measuring runtime (or operation count) against input size and plotting the data. Log-log and log-linear plots reveal the functional form of the growth rate. This technique helps distinguish polynomial, linearithmic, and exponential behaviour without requiring a theoretical analysis.

      The Log-Log Plot

      If runtime T(n)T(n) follows a power law: T(n)cnkT(n) \approx c \cdot n^k, then taking logs:

      logT(n)=logc+klogn\log T(n) = \log c + k \log n

      On a log-log plot (both axes logarithmic), this is a straight line with slope kk. The constant cc determines the vertical intercept.

      The slope kk directly estimates the polynomial degree of the algorithm’s growth. Linear regression on log-transformed data can estimate kk from empirical measurements.

      Recognising Growth Patterns

      On a log-log plot:

      Curve shapeGrowth rate
      Flat horizontal lineΘ(1)\Theta(1) (constant)
      Gentle upward curve (concave)Θ(logn)\Theta(\log n)
      Straight line with slope 1Θ(n)\Theta(n)
      Straight line with slope slightly >> 1Θ(nlogn)\Theta(n \log n) (appears nearly straight but slightly curved on log-log)
      Straight line with slope 2Θ(n2)\Theta(n^2)
      Near-verticalΘ(2n)\Theta(2^n) (exponential)

      On a log-linear plot (y-axis logarithmic, x-axis linear), exponential growth appears as a straight line: logT(n)=logc+nloga\log T(n) = \log c + n \log a for T(n)=canT(n) = c \cdot a^n.

      Example: Heart Rate and Lifespan

      A classic data set from Levine (1997, Journal of the American College of Cardiology) plots resting heart rate against average lifespan across mammal species. The raw scatter plot appears hyperbolic, suggesting an inverse relationship:

      heart rate×lifespanconstant\text{heart rate} \times \text{lifespan} \approx \text{constant}

      Equivalently, log(heart rate)+log(lifespan)=constant\log(\text{heart rate}) + \log(\text{lifespan}) = \text{constant}. A log-log plot transforms this into a straight line with slope 1-1, confirming the relationship heart rate(lifespan)1\text{heart rate} \propto (\text{lifespan})^{-1}. This is the same technique applied to biological scaling rather than algorithmic scaling.

      Applying to Algorithm Analysis

      Given empirical timing data for various input sizes nin_i with measured runtimes tit_i:

      1. Plot (logni,logti)(\log n_i, \log t_i).
      2. Fit a linear regression to estimate the slope kk.
      3. If k1k \approx 1, the algorithm is Θ(n)\Theta(n). If k2k \approx 2, it is Θ(n2)\Theta(n^2).

      Caution: Θ(nlogn)\Theta(n \log n) does not produce a perfectly straight line on a log-log plot; it is slightly curved. To distinguish nlognn \log n from nn, you can plot T(n)/nT(n)/n against logn\log n — the former should appear linear.

      Summary

      PropertyValue
      PurposeEmpirically determine algorithmic growth rate
      Power law cnkc n^kStraight line with slope kk on log-log
      ExponentialStraight line on log-linear
      nlognn \log nSlightly curved on log-log; linear on T(n)/nT(n)/n vs. logn\log n
      Slope <0< 0Decreasing function (e.g. inverse relationship)
    • Divide and Conquer

      Overview

      Divide and conquer is an algorithm design paradigm with three steps:

      1. Divide the problem into smaller subproblems of the same type.
      2. Conquer the subproblems by solving them recursively. If a subproblem is small enough, solve it directly (base case).
      3. Combine the solutions to the subproblems into a solution for the original problem.

      The paradigm works best when subproblems are independent (no overlap) and the combine step is cheap relative to the recursive work.

      Canonical Examples

      AlgorithmDivideConquerCombine
      MergesortSplit array into two halvesRecursively sort each halfMerge the two sorted halves (Θ(n)\Theta(n))
      QuicksortPartition around a pivotRecursively sort left and right partitionsNothing (trivial concatenation)
      Binary searchCompare with midpointRecurse on left or right halfNothing (answer propagated up)

      Recurrence Relations

      Divide-and-conquer algorithms naturally lead to recurrences of the form:

      T(n)=aT ⁣(nb)+f(n)T(n) = a \cdot T\!\left(\frac{n}{b}\right) + f(n)

      where aa subproblems of size n/bn/b are solved recursively, and f(n)f(n) is the cost of dividing and combining.

      Solving Recurrences

      Recurrences of the form T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n) are solved using the Master Theorem, which has three cases based on comparing f(n)f(n) against nlogban^{\log_b a}. The detailed statement, worked examples, the recursion-tree method, and guidance on when the theorem does not apply are covered there.

      When Divide-and-Conquer Works Well

      1. Subproblems are independent — no overlapping work.
      2. The division yields subproblems of roughly equal size (ratio splits, not constant-sized subproblems). Even a 25%-75% split yields Θ(nlogn)\Theta(n \log n) for quicksort.
      3. The combine step is efficient — ideally O(n)O(n) or less.

      Warning: splitting off a constant-sized subproblem (e.g. T(n)=T(n1)+T(1)+Θ(n)T(n) = T(n-1) + T(1) + \Theta(n)) leads to Θ(n2)\Theta(n^2) behaviour. This is the degenerate case of quicksort with a bad pivot.

      Summary

      PropertyValue
      ParadigmDivide, conquer, combine
      Recurrence formT(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n)
      Solving toolMaster Theorem (3 cases)
      Optimal splitEqual-sized subproblems
      Worst splitConstant-sized subproblem leads to Θ(n2)\Theta(n^2)
      Key examplesMergesort, quicksort, binary search, Strassen’s algorithm
    • The Master Theorem

      The Recurrence Form

      Many divide-and-conquer algorithms have running times described by recurrences of the form

      T(n)=aT ⁣(nb)+f(n)T(n) = a\,T\!\left(\frac{n}{b}\right) + f(n)

      where a1a \ge 1 is the number of subproblems, b>1b > 1 is the factor by which the problem size shrinks, and f(n)f(n) accounts for the work of dividing and combining. We interpret n/bn/b as n/b\lfloor n/b \rfloor or n/b\lceil n/b \rceil; the asymptotic bounds are the same.

      The Critical Exponent

      The key quantity is the critical exponent

      ccrit=logbac_{\text{crit}} = \log_b a

      This is the exponent of nn in the time contributed by the leaf level of the recursion tree. The Master Theorem compares f(n)f(n) (the work done at the root and internal levels) against nccritn^{c_{\text{crit}}} (the work done at the leaves).

      The Three Cases

      Case 1: Leaf-Dominated

      If f(n)=O(nccritε)f(n) = O(n^{c_{\text{crit}} - \varepsilon}) for some constant ε>0\varepsilon > 0, then

      T(n)=Θ(nccrit)T(n) = \Theta(n^{c_{\text{crit}}})

      The leaves dominate: the cost at the bottom of the tree outweighs everything above. Note the ε\varepsilon: f(n)f(n) must be polynomially smaller than nccritn^{c_{\text{crit}}}, not just asymptotically smaller.

      Example: T(n)=9T(n/3)+nT(n) = 9\,T(n/3) + n

      a=9a = 9, b=3b = 3, ccrit=log39=2c_{\text{crit}} = \log_3 9 = 2. Here f(n)=n=O(n20.5)f(n) = n = O(n^{2 - 0.5}), so Case 1 gives T(n)=Θ(n2)T(n) = \Theta(n^2). The 99 subproblems at size n/3n/3 generate Θ(n2)\Theta(n^2) leaf work, overwhelming the linear f(n)f(n).

      Case 2: Balanced

      If f(n)=Θ(nccrit)f(n) = \Theta(n^{c_{\text{crit}}}), then

      T(n)=Θ(nccritlgn)T(n) = \Theta(n^{c_{\text{crit}}} \lg n)

      Each level of the recursion tree contributes the same amount of work (Θ(nccrit)\Theta(n^{c_{\text{crit}}})), and there are logbn\log_b n levels, giving the extra lgn\lg n factor.

      Example: T(n)=2T(n/2)+Θ(n)T(n) = 2\,T(n/2) + \Theta(n) (Mergesort)

      a=2a = 2, b=2b = 2, ccrit=1c_{\text{crit}} = 1. f(n)=Θ(n)=Θ(n1)f(n) = \Theta(n) = \Theta(n^1), so Case 2: T(n)=Θ(nlgn)T(n) = \Theta(n \lg n).

      Case 3: Root-Dominated

      If f(n)=Ω(nccrit+ε)f(n) = \Omega(n^{c_{\text{crit}} + \varepsilon}) for some ε>0\varepsilon > 0, and the regularity condition af(n/b)cf(n)a\,f(n/b) \le c\,f(n) holds for some constant c<1c < 1 and all sufficiently large nn, then

      T(n)=Θ(f(n))T(n) = \Theta(f(n))

      The root dominates: the top-level work outweighs everything below. The regularity condition ensures the work decreases geometrically down the tree: each level’s work is at most a constant fraction of the level above.

      Example: T(n)=3T(n/4)+nlgnT(n) = 3\,T(n/4) + n \lg n

      a=3a = 3, b=4b = 4, ccrit=log430.793c_{\text{crit}} = \log_4 3 \approx 0.793. f(n)=nlgn=Ω(n0.793+0.2)f(n) = n \lg n = \Omega(n^{0.793 + 0.2}). Check regularity: 3(n/4)lg(n/4)=34nlgn34nlg434nlgn3 \cdot (n/4)\lg(n/4) = \frac{3}{4}n\lg n - \frac{3}{4}n\lg 4 \le \frac{3}{4}n\lg n. With c=3/4<1c = 3/4 < 1, regularity holds. Case 3: T(n)=Θ(nlgn)T(n) = \Theta(n \lg n).

      The Regularity Condition (Case 3)

      The regularity condition af(n/b)cf(n)a\,f(n/b) \le c\,f(n) for c<1c < 1 is not a formality. Without it, Case 3 can fail even when f(n)=Ω(nccrit+ε)f(n) = \Omega(n^{c_{\text{crit}} + \varepsilon}). It rules out oscillating functions where the total cost at a child level could exceed the parent level. Most polynomially bounded functions encountered in practice satisfy it automatically.

      The Polynomial Gap (ε)

      The ε>0\varepsilon > 0 in Cases 1 and 3 means the Master Theorem requires a polynomial separation between f(n)f(n) and nccritn^{c_{\text{crit}}}. A logarithmic gap is not enough.

      Example Where the Master Theorem Fails

      T(n)=2T ⁣(n2)+nlgnT(n) = 2\,T\!\left(\frac{n}{2}\right) + n \lg n

      a=2a = 2, b=2b = 2, ccrit=1c_{\text{crit}} = 1. f(n)=nlgnf(n) = n \lg n grows faster than nn but the ratio f(n)/n=lgnf(n)/n = \lg n is not polynomial in nεn^\varepsilon for any ε>0\varepsilon > 0. So f(n)Ω(n1+ε)f(n) \notin \Omega(n^{1+\varepsilon}), and f(n)O(n1ε)f(n) \notin O(n^{1-\varepsilon}). The Master Theorem cannot resolve this recurrence. We must use the substitution method or the recursion-tree method instead.

      This gap between Cases 2 and 3 (or between Cases 1 and 2) arises fairly rarely in practice for standard algorithmic recurrences.

      The Recursion-Tree Method

      When the Master Theorem cannot be applied, the recursion tree provides a fallback: expand the recurrence level by level and sum the work.

      For T(n)=2T(n/2)+nlgnT(n) = 2T(n/2) + n \lg n:

      LevelNumber of nodesProblem sizeWork per nodeWork per level
      01nnnlgnn \lg nnlgnn \lg n
      12n/2n/2(n/2)lg(n/2)(n/2)\lg(n/2)nlg(n/2)n \lg(n/2)
      24n/4n/4(n/4)lg(n/4)(n/4)\lg(n/4)nlg(n/4)n \lg(n/4)
      \vdots\vdots\vdots\vdots\vdots
      kk2k2^kn/2kn/2^k(n/2k)lg(n/2k)(n/2^k)\lg(n/2^k)nlg(n/2k)n \lg(n/2^k)

      Total work: k=0lgn1nlg(n/2k)=nk=0lgn1(lgnk)\sum_{k=0}^{\lg n - 1} n \lg(n/2^k) = n \sum_{k=0}^{\lg n - 1} (\lg n - k). This is nn times an arithmetic series summing to Θ(lg2n)\Theta(\lg^2 n), so T(n)=Θ(nlg2n)T(n) = \Theta(n \lg^2 n).

      Worked Examples

      T(n)=T(n/2)+Θ(1)T(n) = T(n/2) + \Theta(1)

      a=1a = 1, b=2b = 2, ccrit=log21=0c_{\text{crit}} = \log_2 1 = 0. f(n)=Θ(1)=Θ(n0)f(n) = \Theta(1) = \Theta(n^0), so Case 2: T(n)=Θ(lgn)T(n) = \Theta(\lg n).

      Example 2: Strassen’s Matrix Multiplication

      T(n)=7T(n/2)+Θ(n2)T(n) = 7\,T(n/2) + \Theta(n^2)

      a=7a = 7, b=2b = 2, ccrit=log272.807c_{\text{crit}} = \log_2 7 \approx 2.807. f(n)=Θ(n2)=O(n2.8070.5)f(n) = \Theta(n^2) = O(n^{2.807 - 0.5}), so Case 1: T(n)=Θ(nlg7)Θ(n2.807)T(n) = \Theta(n^{\lg 7}) \approx \Theta(n^{2.807}).

      Example 3: T(n)=2T(n/2)+Θ(n3)T(n) = 2\,T(n/2) + \Theta(n^3)

      a=2a = 2, b=2b = 2, ccrit=1c_{\text{crit}} = 1. f(n)=Θ(n3)=Ω(n1+1)f(n) = \Theta(n^3) = \Omega(n^{1 + 1}). Regularity: 2(n/2)3=14n3cn32 \cdot (n/2)^3 = \frac{1}{4} n^3 \le c \cdot n^3 for c=1/4c = 1/4. Case 3: T(n)=Θ(n3)T(n) = \Theta(n^3).

      Example 4: T(n)=3T(n/2)+n2T(n) = 3\,T(n/2) + n^2

      a=3a = 3, b=2b = 2, ccrit=log231.585c_{\text{crit}} = \log_2 3 \approx 1.585. f(n)=n2=Ω(n1.585+0.3)f(n) = n^2 = \Omega(n^{1.585 + 0.3}). Regularity: 3(n/2)2=34n234n23(n/2)^2 = \frac{3}{4}n^2 \le \frac{3}{4} n^2. Case 3: T(n)=Θ(n2)T(n) = \Theta(n^2).

      Example 5: T(n)=8T(n/2)+Θ(n2)T(n) = 8\,T(n/2) + \Theta(n^2)

      a=8a = 8, b=2b = 2, ccrit=log28=3c_{\text{crit}} = \log_2 8 = 3. f(n)=Θ(n2)=O(n30.5)f(n) = \Theta(n^2) = O(n^{3 - 0.5}), Case 1: T(n)=Θ(n3)T(n) = \Theta(n^3).

      Systematic Approach to Applying the Theorem

      1. Identify aa, bb, and f(n)f(n) from the recurrence.
      2. Compute ccrit=logbac_{\text{crit}} = \log_b a.
      3. Compare f(n)f(n) against nccritn^{c_{\text{crit}}}.
      4. If f(n)f(n) is polynomially smaller → Case 1, result Θ(nccrit)\Theta(n^{c_{\text{crit}}}).
      5. If f(n)f(n) is asymptotically equal → Case 2, result Θ(nccritlgn)\Theta(n^{c_{\text{crit}}} \lg n).
      6. If f(n)f(n) is polynomially larger → check regularity; if satisfied, Case 3, result Θ(f(n))\Theta(f(n)).
      7. If none of the above applies (the gap case) → use the recursion tree or substitution method.

      Summary

      CaseConditionSolutionIntuition
      1f(n)=O(nccritε)f(n) = O(n^{c_{\text{crit}} - \varepsilon})Θ(nccrit)\Theta(n^{c_{\text{crit}}})Leaves dominate
      2f(n)=Θ(nccrit)f(n) = \Theta(n^{c_{\text{crit}}})Θ(nccritlgn)\Theta(n^{c_{\text{crit}}} \lg n)All levels equal
      3f(n)=Ω(nccrit+ε)f(n) = \Omega(n^{c_{\text{crit}} + \varepsilon}) and regularity holdsΘ(f(n))\Theta(f(n))Root dominates
      Gapf(n)/nccritf(n)/n^{c_{\text{crit}}} is polylogarithmicNot applicableUse tree method
      Example recurrencea,ba,bccritc_{\text{crit}}f(n)f(n)CaseResult
      T(n)=2T(n/2)+nT(n) = 2T(n/2) + n2,21nn2Θ(nlgn)\Theta(n \lg n)
      T(n)=9T(n/3)+nT(n) = 9T(n/3) + n9,32nn1Θ(n2)\Theta(n^2)
      T(n)=T(2n/3)+1T(n) = T(2n/3) + 11,1.50112Θ(lgn)\Theta(\lg n)
      T(n)=3T(n/4)+nlgnT(n) = 3T(n/4) + n \lg n3,4\approx0.793nlgnn \lg n3Θ(nlgn)\Theta(n \lg n)
      T(n)=2T(n/2)+nlgnT(n) = 2T(n/2) + n \lg n2,21nlgnn \lg nGapΘ(nlg2n)\Theta(n \lg^2 n)
    • Dynamic Programming: Introduction

      Motivation

      Consider the Fibonacci numbers: F0=F1=1F_0 = F_1 = 1, Fn=Fn1+Fn2F_n = F_{n-1} + F_{n-2} for n2n \ge 2. A naive recursive implementation:

      def f(n):
          if n < 2: return 1
          return f(n-2) + f(n-1)

      The call tree is exponential: computing F5F_5 calls F4F_4 and F3F_3; computing F4F_4 also calls F3F_3, etc. The same subproblems are recomputed many times. The running time is Θ(2n)\Theta(2^n) — utterly impractical for n>50n > 50.

      Dynamic programming (DP) solves this by storing computed results and reusing them. The same Fibonacci computation drops to Θ(n)\Theta(n).

      When to Use DP

      Two properties must hold:

      1. Optimal substructure: An optimal solution to the problem contains within it optimal solutions to subproblems. Given a choice at each step, the best overall choice is composed of locally best choices.

      2. Overlapping subproblems: The recursive solution revisits the same subproblems repeatedly. DP stores their solutions so each is solved only once.

      Divide and conquer also exploits optimal substructure but assumes subproblems are disjoint (no overlap). DP handles the overlapping case.

      Two Approaches

      Top-Down (Memoisation)

      Write the recursive solution. Before computing any subproblem, check a cache (memo table). If the result exists, return it; otherwise compute, cache, and return.

      cache = {}
      def f(n):
          if n in cache: return cache[n]
          if n < 2: res = 1
          else: res = f(n-2) + f(n-1)
          cache[n] = res
          return res
      • Solves only subproblems actually needed.
      • Requires stack space for recursion (depth proportional to nn for Fibonacci).

      Bottom-Up (Tabulation)

      Identify the dependency order of subproblems and fill the table iteratively from base cases upward.

      def f(n):
          x = [1] * (n+1)
          for i in range(2, n+1):
              x[i] = x[i-2] + x[i-1]
          return x[n]
      • Solves all subproblems up to nn, even ones not strictly needed.
      • No recursive stack overhead.
      • Easier to analyse space and time.

      Space can often be optimised. For Fibonacci, only the last two values are needed:

      def f(n):
          x, y = 1, 1
          for _ in range(2, n+1):
              x, y = y, x + y
          return y

      This reduces space from Θ(n)\Theta(n) to Θ(1)\Theta(1).

      DP vs. Divide and Conquer

      AspectDivide & ConquerDynamic Programming
      Subproblem overlapDisjoint (no overlap)Significant overlap
      ApproachRecursive, no cacheRecursive with cache (memoisation) or iterative (bottom-up)
      ExamplesMergesort, quicksort, binary searchFibonacci, LCS, matrix chain, rod cutting
      Time saved by DPNone (no overlap to exploit)Exponential → polynomial

      The Naive Bellman Recursion Problem

      General optimisation problems framed via the Bellman equation (choose optimal sequence of actions) suffer from the same exponential blow-up as Fibonacci when implemented naively. The computation tree grows exponentially with problem size, because the same states are reached through many different action sequences. DP is precisely the remedy.

      Summary

      PropertyValue
      RequirementsOptimal substructure + overlapping subproblems
      Top-downRecursion + memoisation cache
      Bottom-upIterative table filling from base cases
      Typical speed-upExponential → polynomial
      Space vs. timeOften can reduce space by discarding unneeded table entries
    • Dynamic Programming: The Four Steps

      The Four-Step Methodology

      Applying dynamic programming to a problem follows a standard pattern:

      1. Characterise the structure of an optimal solution.
      2. Recursively define the value of an optimal solution (the Bellman equation).
      3. Compute the value, typically bottom-up (or top-down with memoisation).
      4. Reconstruct the optimal solution from the computed table (if required, using backpointers).

      Example: Virtual Machine Hosting Problem (Rod Cutting)

      Given nn CPU cores and a price table p[]p[\ell] for a VM with \ell cores, find the maximum revenue achievable by partitioning cores into VMs.

      Step 1: Characterise the optimal structure

      An optimal partition for nn cores cuts off a VM of some size ii (earning p[i]p[i]) and optimally partitions the remaining nin - i cores. The first cut must be optimal for its remainder.

      Step 2: Recursive definition (Bellman equation)

      Let v(j)v(j) be the maximum revenue from jj cores:

      v(j)={0if j=0max1ij(p[i]+v(ji))if j>0v(j) = \begin{cases} 0 & \text{if } j = 0 \\[4pt] \max\limits_{1 \le i \le j} \big(p[i] + v(j - i)\big) & \text{if } j > 0 \end{cases}

      Step 3: Compute bottom-up

      m[0] = 0
      for j = 1 to n:
          m[j] = max(p[i] + m[j-i] for i = 1 to j)
      return m[n]

      Θ(n2)\Theta(n^2) time, Θ(n)\Theta(n) space. Each m[j]m[j] is computed once using O(j)O(j) lookups into already-computed smaller entries. The naive recursive version would be exponential.

      Step 4: Reconstruct the solution

      Store the choice of ii that achieved the maximum at each jj: choice[j] = i. To reconstruct, start at j=nj = n, output choice[j], subtract it, and repeat until zero.

      Example: Weight-Independent Interval Scheduling

      Given nn intervals [si,fi)[s_i, f_i), maximise the number of non-overlapping intervals selected.

      Step 1: Characterise

      Sort intervals by finish time. An optimal schedule either includes the last interval nn (in which case it cannot include any interval overlapping with nn) or excludes nn.

      Step 2: Recursive definition

      Let p(i)p(i) be the largest index j<ij < i such that interval jj does not overlap with interval ii (i.e. fjsif_j \le s_i). Define:

      OPT(i)={0if i=0max(OPT(i1),  1+OPT(p(i)))if i>0\text{OPT}(i) = \begin{cases} 0 & \text{if } i = 0 \\ \max\big(\text{OPT}(i-1),\; 1 + \text{OPT}(p(i))\big) & \text{if } i > 0 \end{cases}

      Step 3: Compute

      Θ(nlogn)\Theta(n \log n) for sorting by finish time, then Θ(n)\Theta(n) to fill the DP table after precomputing all p(i)p(i).

      Step 4: Reconstruct

      Trace back: if OPT(i)=OPT(i1)\text{OPT}(i) = \text{OPT}(i-1), skip interval ii. Otherwise include ii and jump to p(i)p(i).

      Extracting the Programme

      When computing the maximum in Step 3, also record which action achieved it. After filling the table, start from the original problem state and follow the recorded optimal actions to reconstruct the solution. This is equivalent to storing backpointers in a graph of states.

      If multiple actions yield the same optimal value, any may be chosen; the problem asks for an optimal solution, not the unique optimal solution.

      Summary

      StepTask
      1Characterise optimal structure
      2Write Bellman equation (recursive definition)
      3Compute value bottom-up (or memoised top-down)
      4Reconstruct solution via backpointers
      Key insightIdentify states that maximise subproblem overlap
      Common pitfallsExponential without memoisation; wrong state formulation causes poor overlap
    • Greedy Algorithms

      Overview

      A greedy algorithm makes the locally optimal choice at each step, hoping to reach a globally optimal solution. Unlike dynamic programming, which evaluates many options at each decision point, a greedy algorithm commits to a single choice without looking ahead or reconsidering.

      Greedy algorithms are fast when they work — typically O(nlogn)O(n \log n) or O(n)O(n). But they do not always work; many problems require the full DP treatment.

      When Greedy Works

      A problem admits a greedy solution if it satisfies two properties:

      1. Greedy choice property: A globally optimal solution can be reached by making a locally optimal (greedy) choice first. The choice does not depend on solutions to subproblems; it is made based on information available at the moment.

      2. Optimal substructure: After making the greedy choice, the remaining subproblem has the property that an optimal solution to the original problem consists of the greedy choice plus an optimal solution to the subproblem.

      The proof technique is typically an exchange argument: take any optimal solution, and show it can be transformed to include the greedy choice without decreasing its value (or increasing its cost).

      When Greedy Fails

      Counterexample: Coin Change

      Coins: {1,3,4}\{1, 3, 4\}. Target: 6.

      • Greedy (largest coin first): pick 4, then 1, then 1 → 3 coins.
      • Optimal: pick 3, then 3 → 2 coins.

      The greedy algorithm fails because picking the largest denomination first closes off the better combination of two 3s. The greedy choice property does not hold for this set of denominations. (For canonical coin systems like British currency {1,2,5,10,20,50}\{1, 2, 5, 10, 20, 50\}, greedy does work.)

      Counterexample: VMHP (Rod Cutting)

      Prices for VM cores: p[1]=1,p[2]=6,p[3]=7,p[4]=9p[1]=1, p[2]=6, p[3]=7, p[4]=9. For n=4n=4:

      • Greedy (best value-per-core): p[4]/4=2.25p[4]/4 = 2.25, p[2]/2=3p[2]/2 = 3. Pick a 2-core VM (value 6), remaining 2 cores → another 2-core VM (6). Total: 12.
      • Greedy (largest value first): pick 4-core (9), nothing left. Total: 9.
      • Optimal (DP): 2+2=122+2 = 12. In this case the value-per-core greedy happened to match the optimum, but the largest-value-first greedy failed.

      The point: neither greedy heuristic is guaranteed; DP evaluates all options safely.

      Greedy vs. DP

      AspectDynamic ProgrammingGreedy
      DecisionsConsiders all choices at each stepMakes one choice immediately
      Recursive callsMany (evaluates all subproblems)One (only the subproblem after the greedy choice)
      TimeTypically Θ(n2)\Theta(n^2) or Θ(nW)\Theta(nW)Typically O(nlogn)O(n \log n) or O(n)O(n)
      GuaranteeAlways optimal (if problem has optimal substructure)Only optimal if greedy choice property holds
      Proof neededOptimal substructureGreedy choice property + optimal substructure

      Famous Problems with Greedy Solutions

      • Activity selection (interval scheduling): pick earliest-finishing activity. Θ(nlogn)\Theta(n \log n).
      • Fractional knapsack: take items by value/weight ratio. Θ(nlogn)\Theta(n \log n).
      • Huffman coding: merge two least-frequent symbols iteratively. O(nlogn)O(n \log n).
      • Minimum spanning tree (Algorithms 2): Prim’s and Kruskal’s algorithms.
      • Coin change with canonical denominations.

      The Proof Pattern: Exchange Argument

      To prove a greedy algorithm correct:

      1. Let SS be an arbitrary optimal solution.
      2. If SS already contains the greedy choice, done.
      3. If not, construct SS' by replacing some element of SS with the greedy choice.
      4. Show SS' is no worse than SS (equal cardinality, equal or better value).
      5. By induction, repeat for the remaining subproblem.

      This is sometimes called a “might as well” proof: we might as well pick the greedy choice, because any optimal solution can be rearranged to include it without penalty.

      Summary

      PropertyValue
      PrincipleLocally optimal → globally optimal
      RequirementsGreedy choice property + optimal substructure
      Proof methodExchange argument
      When it worksActivity selection, fractional knapsack, Huffman, MST
      When it fails0/1 knapsack, coin change (non-canonical), general VMHP
      Advantage over DPFaster (fewer subproblems evaluated)
    • Activity Selection and Interval Scheduling

      Problem Statement

      Given nn activities, each with a start time sis_i and finish time fif_i (with si<fis_i < f_i), select the maximum-size subset of mutually compatible activities. Two activities ii and jj are compatible if their intervals do not overlap: [si,fi)[s_i, f_i) and [sj,fj)[s_j, f_j) satisfy fisjf_i \le s_j or fjsif_j \le s_i. The half-open interval convention means an activity finishing at time tt permits another to start at time tt.

      Greedy Strategy

      Sort activities by finish time (ascending). Then:

      1. Select the first activity (earliest finish).
      2. Among activities that start at or after the finish time of the last selected activity, select the one with the earliest finish time.
      3. Repeat until no more compatible activities remain.
      sort activities by finish time
      selected = {1}
      k = 1
      for i = 2 to n:
          if s[i] >= f[k]:
              selected = selected ∪ {i}
              k = i

      Running time: Θ(nlogn)\Theta(n \log n) for sorting, then Θ(n)\Theta(n) for the greedy scan.

      Worked Example

      ActivityStartFinish
      108:0008:30
      209:0011:30
      310:0011:00
      410:4512:00

      Sorted by finish time: 1(08:0008:30),3(10:0011:00),2(09:0011:30),4(10:4512:00)1(08:00\text{–}08:30), 3(10:00\text{–}11:00), 2(09:00\text{–}11:30), 4(10:45\text{–}12:00).

      • Select 1 (k=1k = 1).
      • i=2i = 2: activity 3. s3=10:00f1=8:30s_3 = 10:00 \ge f_1 = 8:30 → select 3 (k=3k = 3).
      • i=3i = 3: activity 2. s2=9:00<f3=11:00s_2 = 9:00 < f_3 = 11:00 → skip.
      • i=4i = 4: activity 4. s4=10:45<f3=11:00s_4 = 10:45 < f_3 = 11:00 → skip.

      Result: {1,3}\{1, 3\}. Optimal (size 2). Alternative optimal solutions: {1,2}\{1, 2\}, {1,4}\{1, 4\} — all achieve the same maximum cardinality.

      Why Earliest Finish Time Works

      The greedy choice is the activity with the earliest finish time. Intuition: this leaves the maximum remaining time for other activities, giving the greatest opportunity to schedule more.

      Any other first choice would finish later and could only reduce (or at best leave unchanged) the amount of remaining time. There is never a reason to prefer a later-finishing activity.

      Proof (Exchange Argument)

      Let AA be an optimal solution. Let kk be the activity with the earliest finish time overall.

      • If AA contains kk, done.
      • If AA does not contain kk, let jj be the first activity in AA. Replace jj with kk. Since fkfjf_k \le f_j, activity kk finishes no later than jj, so kk is compatible with all other activities in AA. The modified solution A=(A{j}){k}A' = (A \setminus \{j\}) \cup \{k\} has the same size and is also optimal.

      By induction, the greedy algorithm produces an optimal schedule.

      DP Formulation (Unweighted)

      The problem can also be solved with DP. Sort by finish time. Let p(i)p(i) be the largest index j<ij < i with fjsif_j \le s_i. Then:

      OPT(i)=max(OPT(i1),  1+OPT(p(i)))\text{OPT}(i) = \max\big(\text{OPT}(i-1),\; 1 + \text{OPT}(p(i))\big)

      This is Θ(n2)\Theta(n^2) to compute all p(i)p(i) values, or Θ(nlogn)\Theta(n \log n) with binary search on finish times. The greedy approach is simpler and equally optimal.

      Weighted Variant

      If each activity has a weight (value) and the goal is to maximise total weight rather than count, the greedy strategy fails. The DP formulation above remains correct with minor modification (using weight instead of 1), and no greedy shortcut exists. This is the weight-independent vs. weight-dependent distinction.

      Discussion: Incorrect Heuristics

      • Shortest activity first: can select a short activity that blocks two longer, mutually compatible ones.
      • Earliest start time first: a very long activity starting early blocks everything else.
      • Fewest overlaps first: a counterexample exists where the activity with fewest overlaps still prevents a better combination.

      Only earliest finish time greedily guarantees optimality for the unweighted case.

      Summary

      PropertyValue
      ProblemMaximise number of non-overlapping intervals
      Greedy ruleEarliest finish time first
      TimeΘ(nlogn)\Theta(n \log n) (sort) + Θ(n)\Theta(n) (select)
      ProofExchange argument
      Weighted variantRequires DP; greedy fails
      Alternative heuristicsShortest first, earliest start, fewest overlaps — all incorrect
    • Knapsack Variants

      Overview

      The knapsack problem models resource allocation with a capacity constraint. Given nn items, each with weight wiw_i and value viv_i, and a knapsack with capacity WW, select items to maximise total value without exceeding capacity. Two variants are considered: the 0/1 knapsack (items cannot be split) and the fractional knapsack (items can be split arbitrarily).

      The 0/1 variant requires dynamic programming; the fractional variant is solved greedily. Understanding why greedy works for one and not the other is a central lesson in algorithm design.

      0/1 Knapsack (DP)

      Each item is either taken in full or left behind. The DP recurrence:

      Let OPT(i,w)\text{OPT}(i, w) be the maximum value achievable using items 11 through ii with capacity ww:

      OPT(i,w)={0if i=0 or w=0OPT(i1,w)if wi>wmax(OPT(i1,w),  vi+OPT(i1,wwi))if wiw\text{OPT}(i, w) = \begin{cases} 0 & \text{if } i = 0 \text{ or } w = 0 \\[4pt] \text{OPT}(i-1, w) & \text{if } w_i > w \\[4pt] \max\big(\text{OPT}(i-1, w),\; v_i + \text{OPT}(i-1, w - w_i)\big) & \text{if } w_i \le w \end{cases}

      The two choices at each step: exclude item ii (value unchanged, capacity unchanged), or include item ii (gain viv_i, consume wiw_i of capacity).

      Example

      W=7W = 7, items: (w1=3,v1=4)(w_1=3, v_1=4), (w2=4,v2=5)(w_2=4, v_2=5), (w3=2,v3=3)(w_3=2, v_3=3).

      DP table (rows = items considered, columns = capacity):

      0/1 knapsack dynamic programming table: rows for items, columns for capacity

      w=0w=01234567
      i=0i=000000000
      i=1i=100044444
      i=2i=200045559
      i=3i=300345789

      Optimal: take items 1 and 3, total weight 3+2=573+2=5 \le 7, total value 4+3=74+3=7.

      Complexity

      Time: Θ(nW)\Theta(nW). Space: Θ(nW)\Theta(nW) or Θ(W)\Theta(W) with space optimisation (only previous row needed). This is pseudo-polynomial: polynomial in the numeric value WW, but not in the input size logW\log W. If WW is large, the algorithm is impractical.

      Fractional Knapsack (Greedy)

      Items can be split arbitrarily. The greedy strategy:

      1. Compute value per unit weight: vi/wiv_i / w_i for each item.
      2. Sort items by this ratio (descending).
      3. Take as much as possible of the highest-ratio item; if capacity remains, move to the next.
      sort items by v_i / w_i descending
      total = 0
      for each item (sorted):
          if W >= w_i:
              take all of item; W -= w_i; total += v_i
          else:
              take fraction W / w_i; total += (W / w_i) * v_i; break

      Time: Θ(nlogn)\Theta(n \log n) for sorting, Θ(n)\Theta(n) for selection.

      Example (Same Items)

      (w1=3,v1=4,v/w=1.33)(w_1=3, v_1=4, v/w = 1.33), (w2=4,v2=5,v/w=1.25)(w_2=4, v_2=5, v/w = 1.25), (w3=2,v3=3,v/w=1.5)(w_3=2, v_3=3, v/w = 1.5). Capacity W=7W = 7.

      1. Item 3: ratio 1.5, take all → value 3, capacity left 5.
      2. Item 1: ratio 1.33, take all → value 4, capacity left 2.
      3. Item 2: ratio 1.25, take 2/4=0.52/4 = 0.5 → value 2.5.

      Total: 3+4+2.5=9.53 + 4 + 2.5 = 9.5. (Compare 0/1 optimum: 7.)

      Why Greedy Works for Fractional

      The proof is straightforward: if any optimal solution does not fill capacity with the highest-ratio item first, we can exchange a lower-ratio item for an equal weight of the higher-ratio item and increase total value. So an optimal solution must greedily take the highest-ratio items.

      Why Greedy Fails for 0/1

      The all-or-nothing constraint is the culprit. A high-ratio item might be so heavy that taking it leaves capacity that can only be filled with low-value items, whereas skipping it allows two medium-ratio items that together yield more value. The greedy algorithm cannot “regret” the choice later — it has no lookahead.

      In the earlier example, a greedy-by-ratio approach for 0/1 would take item 3 (ratio 1.5), then item 1 (ratio 1.33), resulting in total weight 5 and value 7. It cannot fit item 2 (weight 4, would exceed 7). The DP found the same optimum here, but a different example can break greedy:

      Items: (1,2,ratio 2.0)(1, 2, \text{ratio } 2.0), (2,3,ratio 1.5)(2, 3, \text{ratio } 1.5), W=2W = 2. Greedy takes item 1 (value 2, weight 1), leaving capacity for nothing else. Optimal: take item 2 (value 3). Greedy fails.

      Comparison

      Property0/1 KnapsackFractional Knapsack
      Can split items?NoYes
      Solution methodDPGreedy
      TimeΘ(nW)\Theta(nW) (pseudo-polynomial)Θ(nlogn)\Theta(n \log n)
      Greedy works?NoYes
      Optimal substructureYesYes
      Greedy choice propertyNoYes

      Summary

      PropertyValue
      0/1 knapsackDP, Θ(nW)\Theta(nW) pseudo-polynomial
      Fractional knapsackGreedy by value/weight ratio, Θ(nlogn)\Theta(n \log n)
      Why greedy fails for 0/1All-or-nothing constraint blocks lookahead
      Why greedy works for fractionalCan always exchange lower-ratio for higher-ratio material
      Space optimisation0/1 can be Θ(W)\Theta(W) instead of Θ(nW)\Theta(nW)
  • Elementary Data Structures

    The pointer/object model, stacks, queues, linked lists, and rooted and binary trees

    • The Pointer and Object Model

      Overview

      Data structures are built from objects (records, structs) connected by pointers. The pointer-and-object model provides an abstract cost model for analysing data structure operations, independent of hardware details like cache hierarchies or page tables.

      Objects, Fields, and Pointers

      An object is a block of memory with named fields (attributes). Fields contain either primitive values (integers, booleans) or pointers to other objects. A pointer is the memory address of an object; it can be stored in a field, a variable, or passed as an argument.

      • NIL is a reserved pointer value meaning “points to nothing”.
      • An object’s fields are accessed with dot notation: x.key, x.next.

      The Cost Model

      The following operations each take Θ(1)\Theta(1) time in this model:

      OperationDescription
      Following a pointerReading a pointer field and accessing the target object
      Reading/writing a fieldSetting or getting a primitive field value
      Allocating an objectnew — obtaining a fresh block of memory
      ArithmeticAddition, subtraction, multiplication, division
      ComparisonsEquality, less-than, etc.

      Arrays are a special case: an array of length nn can be allocated in Θ(n)\Theta(n) time, and indexing A[i] is Θ(1)\Theta(1) (random access).

      Linked Structures

      Objects connected by pointers form linked structures:

      • Singly linked: each node has a next pointer. Traversal is forward-only. Space: 1 pointer per node.
      • Doubly linked: each node has next and prev pointers. Traversal in both directions. Space: 2 pointers per node. Deletion of a node given a pointer to it is Θ(1)\Theta(1) (versus Θ(n)\Theta(n) for singly linked).
      • Circular: the last node points back to the first (or to a sentinel). Useful for round-robin scheduling, circular buffers.
      • Sentinel nodes: a dummy node that simplifies edge cases (e.g. empty-list checks). The sentinel is never removed and its data field is irrelevant.

      Trees and Graphs

      Trees and graphs are built using the same pointer-and-object model:

      • A binary tree node: key, left, right, and optionally parent.
      • A general tree node: key, parent, and a list of children (using linked-list pointers or an array of child pointers).
      • A graph: vertices with adjacency lists (linked lists or arrays of edge pointers).

      Memory Allocation

      Memory management is a practical concern. In pseudocode we assume an infinite supply of memory; in reality, allocators like malloc manage a heap, tracking free and busy chunks (see Doug Lea’s allocator for a real-world use of doubly linked lists). Deallocation (free) returns memory to the free pool. Garbage collection automates deallocation by tracing reachable objects from root pointers and freeing unreachable ones.

      Why This Model Matters

      The model abstracts away machine-level complexity whilst remaining precise enough for asymptotic analysis. All data structure running times in the course are expressed in terms of this cost model. Real-world performance may differ due to constant factors (cache misses, memory bandwidth), but the asymptotic conclusions hold.

      Summary

      ConceptMeaning
      ObjectMemory block with named fields
      PointerAddress of an object (or NIL)
      Field access / pointer follow / arithmeticΘ(1)\Theta(1) each
      Array indexingΘ(1)\Theta(1) random access
      Singly vs. doubly linked1 vs. 2 pointers per node; forward-only vs. bidirectional
      SentinelDummy node simplifying boundary conditions
    • Stacks

      Abstract Data Type

      A stack is a last-in, first-out (LIFO) collection. The element most recently added (pushed) is the first one removed (popped). Operations:

      OperationDescription
      PUSH(S, x)Add element xx to the top of the stack
      POP(S)Remove and return the top element
      TOP(S) / PEEK(S)Return the top element without removing it
      IS-EMPTY(S)Return true iff stack contains no elements

      Array Implementation

      Maintain an array A[1..n] of maximum capacity nn and a variable top that tracks the current top position. Two conventions exist:

      Convention 1: top is the index of the current top element (0 means empty).

      • PUSH: top = top + 1; A[top] = x
      • POP: x = A[top]; top = top - 1; return x

      Convention 2: top is the index of the first free slot (1 means empty).

      • PUSH: A[top] = x; top = top + 1
      • POP: top = top - 1; return A[top]

      All operations are Θ(1)\Theta(1). Overflow occurs when pushing onto a full stack (top=n\texttt{top} = n or top=n+1\texttt{top} = n+1, depending on convention).

      Linked List Implementation

      Push adds a new node at the head; pop removes the head node. Both are Θ(1)\Theta(1). No capacity limit (until memory exhaustion).

      PUSH(S, x):
          node = new Node(value=x, next=S.head)
          S.head = node
      
      POP(S):
          if S.head == NIL: error "empty"
          x = S.head.value
          S.head = S.head.next
          return x

      Additional Operations (Challenge)

      Design a stack supporting AVERAGE(S) in Θ(1)\Theta(1):

      Maintain a running sum total and count size alongside the stack. On push: total += x; size += 1. On pop: total -= popped_value; size -= 1. AVERAGE returns total / size in Θ(1)\Theta(1).

      For MIN(S) in Θ(1)\Theta(1), maintain a second stack of minimums: on push, also push min(x,current min)\min(x, \text{current min}) onto the min-stack; on pop, pop both stacks. MIN reads the top of the min-stack.

      Applications

      ApplicationHow the stack is used
      Function call stackReturn addresses and local variables are pushed on call, popped on return
      Expression evaluation (postfix/RPN)Push operands; on operator, pop operands, evaluate, push result
      Undo functionalityEach action is pushed; undo pops and reverses
      Bracket matchingPush opening brackets; on closing bracket, pop and check match
      Depth-first searchImplicit via recursion, or explicit with a stack of vertices to visit

      Summary

      PropertyArrayLinked List
      PUSHΘ(1)\Theta(1)Θ(1)\Theta(1)
      POPΘ(1)\Theta(1)Θ(1)\Theta(1)
      TOPΘ(1)\Theta(1)Θ(1)\Theta(1)
      IS-EMPTYΘ(1)\Theta(1)Θ(1)\Theta(1)
      Max capacityFixed (nn)Unbounded
      SpaceΘ(n)\Theta(n) pre-allocatedProportional to number of elements
      PrincipleLIFO
    • Queues

      Abstract Data Type

      A queue is a first-in, first-out (FIFO) collection. Elements are added at the rear and removed from the front. Operations:

      OperationDescription
      ENQUEUE(Q, x)Add element xx to the rear of the queue
      DEQUEUE(Q)Remove and return the front element
      FRONT(Q)Return the front element without removing it
      IS-EMPTY(Q)Return true iff queue contains no elements

      Circular Array Implementation

      A fixed-capacity array Q[1..n] with two indices: head (front of queue) and tail (first free slot after the rear). Both wrap around modulo nn using an inc helper:

      inc(x): return (x == Q.length) ? 1 : x + 1
      • Initially: head = tail = 1 (empty queue).
      • Enqueue: store at Q[tail], then tail = inc(tail).
      • Dequeue: retrieve Q[head], then head = inc(head).
      • Empty: head == tail.
      • Full: inc(tail) == head (or equivalently head == tail + 1 modulo nn).

      All operations are Θ(1)\Theta(1). At most n1n-1 items can be stored (one slot is sacrificed to distinguish full from empty, since both would have head == tail).

      Circular queue diagram

      Linked List Implementation

      Maintain head (front) and tail (rear) pointers. Enqueue adds at the tail, dequeue removes from the head. Both operations are Θ(1)\Theta(1). No capacity limit.

      ENQUEUE(Q, x):
          node = new Node(value=x, next=NIL)
          if Q.tail == NIL:
              Q.head = Q.tail = node
          else:
              Q.tail.next = node
              Q.tail = node
      
      DEQUEUE(Q):
          if Q.head == NIL: error "empty"
          x = Q.head.value
          Q.head = Q.head.next
          if Q.head == NIL: Q.tail = NIL
          return x

      Circular Buffer Variant

      The circular array queue is also known as a circular buffer. It is widely used in operating systems for kernel pipes and I/O buffering. It avoids the allocation/deallocation overhead of linked lists and provides cache-friendly linear memory access.

      Deque (Double-Ended Queue)

      A deque supports insertion and deletion at both ends: PUSH-FRONT, PUSH-BACK, POP-FRONT, POP-BACK. It can be implemented with a doubly linked list or a circular array, all operations Θ(1)\Theta(1). A deque generalises both stacks and queues.

      Applications

      ApplicationHow the queue is used
      Task schedulingFIFO scheduling: tasks processed in arrival order
      Breadth-first searchDiscovered vertices are enqueued; processed in FIFO order
      Print queuesJobs printed in submission order
      Buffering / I/OData buffered between producer and consumer (circular buffer)
      Message passingMessages delivered in order between threads/processes

      Comparison: Stack vs. Queue

      AspectStackQueue
      OrderLIFOFIFO
      Insert calledPushEnqueue
      Remove calledPopDequeue
      Array implementationOne index (top)Two indices (head, tail) with wrap
      Full detectiontop == ninc(tail) == head
      ApplicationsRecursion, undo, parsingScheduling, BFS, buffering

      Summary

      PropertyCircular ArrayLinked List
      ENQUEUEΘ(1)\Theta(1)Θ(1)\Theta(1)
      DEQUEUEΘ(1)\Theta(1)Θ(1)\Theta(1)
      FRONTΘ(1)\Theta(1)Θ(1)\Theta(1)
      IS-EMPTYΘ(1)\Theta(1)Θ(1)\Theta(1)
      Max capacityn1n-1 itemsUnbounded
      SpaceΘ(n)\Theta(n) pre-allocatedPer-element
      PrincipleFIFO
    • Linked Lists

      Overview

      A linked list is a sequence of nodes, each containing data and one or more pointers to neighbouring nodes. Lists provide dynamic sizing (no pre-allocation needed) and constant-time insertion/deletion at known positions, at the cost of O(n)O(n) random access.

      Singly Linked Lists

      Each node has a value field and a next pointer. The list is accessed via a head pointer (and optionally a tail pointer for constant-time append).

      Singly linked list diagram

      Operations

      OperationWithout tailWith tail
      INSERT-HEADΘ(1)\Theta(1)Θ(1)\Theta(1)
      INSERT-TAILΘ(n)\Theta(n)Θ(1)\Theta(1)
      DELETE-HEADΘ(1)\Theta(1)Θ(1)\Theta(1)
      DELETE(x) given node xxΘ(n)\Theta(n) (need predecessor)Θ(n)\Theta(n)
      DELETE(x) given predecessorΘ(1)\Theta(1)Θ(1)\Theta(1)
      SEARCH(k)Θ(n)\Theta(n)Θ(n)\Theta(n)

      Insertion at head: create a new node, point its next to the current head, update head to the new node. This is Θ(1)\Theta(1).

      Deletion of a node xx in a singly linked list requires finding the predecessor of xx (to update its next pointer), which takes Θ(n)\Theta(n) in the worst case via a linear scan from head. If the predecessor is already known (e.g. when iterating), deletion is Θ(1)\Theta(1).

      Doubly Linked Lists

      Each node has prev, value, and next pointers. The list maintains head and optionally tail.

      Doubly linked list diagram

      The key advantage: given a pointer to a node xx, deletion is Θ(1)\Theta(1) — we can reach its predecessor via x. ⁣prevx.\!prev without a scan.

      DELETE(x):                    # x is a node in a doubly linked list
          if x.prev != NIL:
              x.prev.next = x.next
          else:
              L.head = x.next       # x was head
          if x.next != NIL:
              x.next.prev = x.prev

      Insertion at head or after a known node is also Θ(1)\Theta(1):

      INSERT-HEAD(L, k):
          x = new Node(prev=NIL, key=k, next=L.head)
          if L.head != NIL: L.head.prev = x
          L.head = x

      Circular Variants

      A circular linked list connects the last node back to the first (and optionally the first’s prev to the last in the doubly linked case). A sentinel node can be used as both head and tail, simplifying boundary checks.

      Circular singly linked lists with tail pointer enable Θ(1)\Theta(1) insertion at both ends and Θ(1)\Theta(1) deletion at the front.

      Comparison with Arrays

      AspectArrayLinked List
      Random access (ii-th element)Θ(1)\Theta(1)Θ(n)\Theta(n)
      Insert/delete at headΘ(n)\Theta(n) (shift elements)Θ(1)\Theta(1)
      Insert/delete at tailΘ(1)\Theta(1) amortised or Θ(n)\Theta(n)Θ(1)\Theta(1) with tail pointer
      Insert/delete in middleΘ(n)\Theta(n) (shift)Θ(1)\Theta(1) if position known, Θ(n)\Theta(n) to find
      SearchΘ(n)\Theta(n) linear, O(logn)O(\log n) if sortedΘ(n)\Theta(n)
      Space overheadNone beyond array itself1–2 pointers per element
      Cache localityExcellent (contiguous memory)Poor (scattered allocations)

      Space Overhead

      TypePointers per nodeNotes
      Singly linked1 (next)Forward traversal only
      Doubly linked2 (next, prev)Bidirectional; enables Θ(1)\Theta(1) deletion at known node
      CircularSame as linear variantExtra pointer logic but no extra fields

      Usage in Doug Lea’s Malloc

      The classic malloc implementation uses a doubly linked list to track free memory chunks in the heap. Each chunk is preceded by a list node containing the chunk size and free/busy status. Free chunks are linked together; when a chunk is freed, it is coalesced with adjacent free chunks. This is a real-world application of doubly linked lists at massive scale.

      Summary

      PropertySingly LinkedDoubly Linked
      Head insertΘ(1)\Theta(1)Θ(1)\Theta(1)
      Tail insertΘ(n)\Theta(n) (or Θ(1)\Theta(1) with tail)Θ(1)\Theta(1) with tail
      Delete given nodeΘ(n)\Theta(n) (need predecessor)Θ(1)\Theta(1)
      SearchΘ(n)\Theta(n)Θ(n)\Theta(n)
      Space per node1 pointer2 pointers
      TraversalForward onlyBidirectional
    • Rooted and Binary Trees

      Tree Terminology

      A rooted tree is a data structure with a single entry point (the root) and hierarchical parent-child relationships. Every node except the root has exactly one parent; the root has none.

      Binary tree diagram

      TermDefinition
      RootThe unique topmost node with no parent
      ParentThe node directly above a given node
      ChildA node directly below a given node
      SiblingNodes sharing the same parent
      Leaf (external node)A node with no children
      Internal nodeA node with at least one child
      Depth of a nodeNumber of edges from root to that node (root has depth 0)
      Height of a nodeNumber of edges on the longest path from that node to a leaf
      Height of the treeHeight of the root
      LevelSet of nodes at the same depth
      Ancestor / descendantNodes on the path toward/away from the root

      Binary Trees

      A binary tree is a rooted tree where each node has at most two children, designated as left child and right child. A child can be NIL (absent).

      Types of binary trees:

      • Full binary tree: every node has 0 or 2 children.
      • Complete binary tree: all levels are completely filled except possibly the last, which is filled from left to right.
      • Perfect binary tree: all leaves are at the same depth; every internal node has exactly 2 children. A perfect binary tree of height hh has 2h+112^{h+1} - 1 nodes.

      Height Bounds

      For a binary tree with nn nodes:

      • Minimum height: log2n\lfloor \log_2 n \rfloor, achieved by a complete/perfect binary tree.
      • Maximum height: n1n - 1, when every internal node has exactly one child (a degenerate tree, essentially a linked list).

      Thus a balanced binary tree is one whose height is O(logn)O(\log n), giving logarithmic-time operations. An unbalanced tree degenerates to O(n)O(n) height.

      Tree Traversals

      Three fundamental depth-first traversals of a binary tree:

      TraversalOrderVisits root
      PreorderRoot, then left subtree, then right subtreeFirst
      InorderLeft subtree, then root, then right subtreeMiddle
      PostorderLeft subtree, then right subtree, then rootLast

      Example tree with root RR, left child AA, right child BB (where AA has left child CC, right child DD):

      • Preorder: [R,A,C,D,B][R, A, C, D, B]
      • Inorder: [C,A,D,R,B][C, A, D, R, B]
      • Postorder: [C,D,A,B,R][C, D, A, B, R]

      Inorder traversal of a binary search tree visits keys in sorted order.

      Array Representation of Binary Trees

      Store a complete binary tree in an array A[1..n]:

      NodeArray index
      RootA[1]
      Parent of iiA[floor(i/2)]
      Left child of iiA[2i]
      Right child of iiA[2i + 1]

      This is the representation used for heaps. No explicit pointers are needed; the tree structure is encoded in the indices. The condition for a child to exist is 2iheap_size2i \le \text{heap\_size} (or 2i+1heap_size2i + 1 \le \text{heap\_size}).

      Pointer-Based Representation

      Each node is an object with fields:

      class TreeNode:
          key       # data
          left      # pointer to left child (or NIL)
          right     # pointer to right child (or NIL)
          parent    # pointer to parent (or NIL), optional

      Parent pointers are required for algorithms like PREDECESSOR and SUCCESSOR in BSTs, and for efficient traversal without an explicit stack.

      Trees with Unbounded Branching

      When nodes can have arbitrarily many children, a linked list of children is used rather than fixed left/right pointers. An alternative is the left-child, right-sibling representation: each node has a pointer to its first child and a pointer to its next sibling. This encodes any tree as a binary tree.

      Binary Search Trees (Preview)

      A BST is a binary tree satisfying the BST property: for every node xx, all keys in the left subtree are strictly less than x. ⁣keyx.\!key, and all keys in the right subtree are strictly greater. BSTs support SEARCH, INSERT, DELETE, MINIMUM, MAXIMUM, PREDECESSOR, and SUCCESSOR in O(h)O(h) time where hh is the tree height. With balanced trees, h=O(logn)h = O(\log n); with degenerate trees, h=O(n)h = O(n).

      Summary

      PropertyValue
      Min height (balanced)log2n\lfloor \log_2 n \rfloor
      Max height (degenerate)n1n - 1
      Perfect tree size2h+112^{h+1} - 1 nodes
      Array rep.: left child2i2i
      Array rep.: right child2i+12i + 1
      PreorderRoot → left → right
      InorderLeft → root → right (sorted for BSTs)
      PostorderLeft → right → root
      BST propertyLeft subtree << root << right subtree
  • Search Trees and Indexing

    Indexing and database motivation, binary search trees, B-trees, 2-3-4 trees, and red-black trees

    • Indexing and Database Motivation

      The (Key, Value) Store Abstraction

      Databases store records indexed by key. The core operation: given a key, find the associated record (or records). In the Cambridge course, we treat the index as an abstract data type holding a collection of (key, value) pairs, ordered by key. Values are typically small (e.g. pointers to records in memory or on disk).

      Abstract operations on an index:

      • search(k): return a cursor to the (key, value) pair with key k, or indicate absence.
      • search_gt(k): return a cursor to the first pair with key > k (range query start).
      • next(c), prev(c): move the cursor forward or backward in key order.
      • insert(k, v), delete(k): modify the index contents.

      Point queries (lookup by exact key) and range queries (keys in [a, b]) are the two fundamental search patterns. Sensible database indices allow multiple items with the same key; the course assumes unique keys for consistency with CLRS.

      Why Indexing Matters

      Without an index, answering a query like SELECT * FROM movies WHERE year > 2015 requires scanning every row of the table (linear time in the number of rows). With an index on year, the database can seek directly to the first matching entry and scan forward, touching only the relevant records.

      The speed-up is dramatic: a linear scan of a billion-row table can take minutes; an indexed range query examines only the matching rows plus a logarithmic number of index nodes.

      Index lookup example

      Primary vs Secondary Indices

      A primary index determines the physical ordering of data on disk. The data records themselves are organised in key order (e.g. a B-tree clustered index in InnoDB). There is exactly one primary index per table.

      A secondary index is a separate data structure that maps keys to record pointers (or primary key values). A table can have many secondary indices. A lookup via a secondary index requires two steps: search the secondary index to find the record pointer, then fetch the actual record.

      Updates (insert, delete) must maintain all indices, so indices speed up reads but slow down writes. Read-heavy workloads benefit most from indexing.

      The Disk Access Model

      Database indices are typically stored on disk (SSD or HDD), not in main memory. The cost model changes fundamentally:

      • Follow a pointer to a node stored on disk: ~2,000,000 CPU cycles.
      • CPU operations (comparisons, arithmetic): ~1 cycle each.

      The dominant cost is disk I/O: the number of disk pages read or written. An SSD consists of blocks made of pages; reads and writes operate on entire pages (e.g. 4 KB) at a time.

      Nodes of the search tree should therefore be sized to fit within one disk page. A node containing many keys and child pointers means fewer levels, and thus fewer disk accesses, to reach any given key.

      Why Multi-Way Search Trees?

      A balanced binary search tree stores one key per node and has height ~log₂(n). With 10⁹ records, that is roughly 30 levels, requiring up to 30 disk seeks per lookup.

      A B-tree with branching factor 100 (i.e. each node holds roughly 100 keys) has height ~log₁₀₀(n). With 10⁹ records, that is roughly 5 levels, requiring only 5 disk seeks per lookup. The motivation for B-trees is directly about minimising disk I/O.

      Summary

      ConceptDescription
      Index ADTCollection of (key, value) pairs, ordered by key
      Primary indexDetermines physical data ordering
      Secondary indexSeparate structure pointing to data
      Point queryLookup by exact key
      Range queryKeys in [a, b]
      Disk access cost~2M CPU cycles per seek
      B-tree motivationMinimise tree height to reduce disk I/O
      Node sizeDesigned to fit one disk page
    • Binary Search Trees

      The BST Property

      A binary search tree (BST) is a rooted binary tree where each node stores a (key, value) pair and satisfies the binary search tree property: for any node with key k, all keys in its left subtree are strictly less than k, and all keys in its right subtree are strictly greater than k. Duplicate keys are not permitted in search trees.

      This property enables efficient search: at each node, compare the target key with the node’s key and descend left or right accordingly.

      BST structure

      Operations

      All BST operations traverse a path from the root to a leaf, so each runs in Θ(h)\Theta(h) time where hh is the height of the tree.

      Start at the root. If the current node’s key equals the target, return it. Otherwise, go left if the target is smaller, right if larger. If a NIL pointer is reached, the key is not present.

      Insert

      Search for the key. If found, update the payload (replace the value). If not found, the search terminates at a NIL child pointer of some node; create a new node there.

      Delete

      Three cases for the node d containing the key to delete:

      1. No children: Remove d from its parent (set the parent’s pointer to NIL).
      2. One child: Replace d with its child in the parent’s pointer.
      3. Two children: Find d’s inorder successor (or predecessor), which has at most one child. Copy the successor’s (key, value) into d, then delete the successor node.

      Min, Max, Predecessor, Successor

      • Minimum: follow left pointers until NIL; the last non-NIL node.
      • Maximum: follow right pointers until NIL.
      • Successor: if node has a right child, find the minimum of the right subtree. Otherwise, walk up until a “first up-right” is found (the first ancestor for which the node is in the left subtree).
      • Predecessor: symmetric, using left subtree maximum or “first up-left”.

      Predecessor and successor require parent pointers; minimum and maximum do not.

      Inorder Traversal

      An inorder traversal (left subtree, node, right subtree) visits nodes in sorted key order. This is a Θ(n)\Theta(n) operation that produces the sorted sequence of all keys.

      Example: Building a BST

      Insert keys [N, D, A, I, S, O, R, U] in order:

      Step 1: [N]
      Step 2: [N] with left child [D]
      Step 3: [N] with [D] having left child [A]
      ...
      Result:       N
                 /     \
               D        S
             /   \    /   \
            A     I  O     U
                   \
                    R

      The Degeneracy Problem

      If keys are inserted in sorted order (e.g. A, D, I, N, O, R, S, U), each new key is larger than all existing keys, so each insert goes to the rightmost position. The tree becomes a linked list: every node has at most one child, and height h=nh = n.

      In this worst case, all operations become Θ(n)\Theta(n). The average case for random insertion order gives h=Θ(logn)h = \Theta(\log n), but adversarial or sorted input breaks this.

      This motivates self-balancing BST variants (red-black trees, AVL trees) that guarantee O(logn)O(\log n) height regardless of insertion order.

      Summary

      OperationTimeNotes
      SearchΘ(h)\Theta(h)Follow path from root
      InsertΘ(h)\Theta(h)Add as leaf
      DeleteΘ(h)\Theta(h)Three cases
      Min/MaxΘ(h)\Theta(h)Follow left/right pointers
      Predecessor/SuccessorΘ(h)\Theta(h)Requires parent pointers
      Inorder traversalΘ(n)\Theta(n)Sorted order
      Best heightΘ(logn)\Theta(\log n)Balanced
      Worst heightΘ(n)\Theta(n)Degenerate (sorted input)
    • B-Trees

      Motivation and Definition

      B-trees generalise binary search trees: each node can hold many keys and have many children. They are designed for disk-resident data where following a pointer costs ~2 million CPU cycles. By maximising the branching factor, B-trees minimise tree height and thus disk accesses.

      A B-tree of minimum degree tt (with t2t \ge 2) satisfies:

      1. Internal nodes hold at least t1t-1 keys (except the root, which may have as few as 1).
      2. Internal nodes hold at most 2t12t-1 keys.
      3. A node with mm keys has exactly m+1m+1 children.
      4. All keyless leaves exist at the same depth (perfect height balance).
      5. Keys partition the key ranges of children: for a node with keys k1,k2,,kmk_1, k_2, \ldots, k_m, child cic_i contains keys between ki1k_{i-1} and kik_i (with appropriate adjustments at the ends).

      B-tree structure

      Example: B-tree<2> (t=2). Internal nodes hold 1—3 keys and have 2—4 children. This is also known as a 2-3-4 tree (see next note).

      Height Bound

      The minimum number of keys at each level (starting from the root) is: 1, 2(t1)2(t-1), 2t(t1)2t(t-1), 2t2(t1)2t^2(t-1), and so on. Summing the geometric progression and rearranging:

      n1+(t1)i=1h2ti1n \ge 1 + (t-1)\sum_{i=1}^{h} 2t^{i-1}

      From which: hlogt(n+12)h \le \log_t\left(\frac{n+1}{2}\right)

      Thus the height is Θ(logtn)\Theta(\log_t n). Since tt can be large (e.g. 100), the tree is very shallow; search, insert, and delete all examine Θ(logtn)\Theta(\log_t n) nodes.

      Pointer Discipline

      Only one pointer to a B-tree is permitted: the root pointer. Internal node pointers must not be stored externally, because restructuring operations (splits, merges) move keys between nodes. A stale pointer could reference a node that no longer contains the expected key.

      BT-Search descends from the root. At each internal node, scan the keys (or use binary search since tt is constant with respect to nn) to find the first key \ge the search key. If found, return the payload. If not found at a leaf level (where children are keyless leaves), return NIL. Cost: O(tlogtn)=O(logn)O(t \cdot \log_t n) = O(\log n).

      Insert

      The naive approach: walk down to the bottom level of internal nodes, insert into the leaf node. If the node overflows (reaches 2t2t keys), split it around its median into two nodes of t1t-1 keys each, and promote the median to the parent. If the parent is full, splitting cascades upward. If the root splits, a new root is created: the tree grows taller at the top, preserving perfect height balance.

      Improved insert: preemptively split any full node encountered on the way down. This guarantees the parent is never full when a split promotion arrives, avoiding upward cascading. It also avoids revisiting nodes already read from disk.

      Delete

      More complex. Key cases:

      • If the key is in a leaf node and the node has >t1> t-1 keys: simply remove it.
      • If the key is in an internal node: swap with predecessor (or successor), which must be in a leaf; then delete from the leaf.
      • If the leaf has only t1t-1 keys (minimum): either redistribute (borrow a key from a sibling via the parent), or merge with a sibling (combine t1+t1+1=2t1t-1 + t-1 + 1 = 2t-1 keys into one node by “stealing” the separator from the parent). Merging may cascade upward if the parent becomes too small.
      • If the root’s last key is merged away, the root is replaced by its merged child: the tree becomes shorter.

      Summary

      PropertyValue
      Min keys per node (non-root)t1t-1
      Max keys per node2t12t-1
      Children per nodem+1m+1 (for mm keys)
      Heightlogt((n+1)/2)\le \log_t((n+1)/2)
      SearchO(logn)O(\log n)
      InsertO(logn)O(\log n), top-down splitting
      DeleteO(logn)O(\log n), redistribute or merge
      Key invarianceAll leaves at same depth
    • 2-3-4 Trees

      Definition

      A 2-3-4 tree is a B-tree with minimum degree t=2t = 2. Every internal node (except possibly the root) has 2, 3, or 4 children, holding 1, 2, or 3 keys respectively. Leaves are keyless and all at the same depth.

      The name derives from the number of children: a 2-node has 1 key and 2 children; a 3-node has 2 keys and 3 children; a 4-node has 3 keys and 4 children.

      2-3-4 tree structure

      Why Study 2-3-4 Trees?

      All B-tree properties hold: t1=1t-1 = 1 minimum key per non-root node, 2t1=32t-1 = 3 maximum keys per node. The height is log2((n+1)/2)\le \log_2((n+1)/2), so all operations are O(logn)O(\log n).

      2-3-4 trees are the conceptual bridge to red-black trees. Every 2-3-4 tree can be mapped to a red-black tree and vice versa (see “Equivalence of Red-Black and 2-3-4 Trees”). Understanding 2-3-4 operations provides intuition for red-black tree rebalancing.

      Insertion

      Insertion into a 2-3-4 tree follows the B-tree pattern with t=2t=2:

      1. Walk down from the root to find the appropriate leaf position.
      2. If the destination leaf has room (1 or 2 keys), insert directly.
      3. If the destination leaf is a 4-node (3 keys, full), split it: the middle key (the median) is promoted to the parent, and the remaining keys form two 2-nodes as children of the promoted key.

      Splitting can cascade upward. When the root itself is a 4-node and splits, a new root is created, increasing the tree height by 1 while maintaining perfect balance.

      Example: Inserting into a 2-3-4 Tree

      Starting with a root containing [D,G] (a 3-node). Insert A:

      • A < D, go left. The left child is a leaf. Insert A there: the leaf becomes [A,B] (if B was there) or just [A].

      Insert E into a leaf that already has 3 keys [B,D,E] (a 4-node): split by promoting D to the parent and creating left leaf [B] and right leaf [E].

      2-3-4 insert example

      Deletion

      Deletion follows the general B-tree delete algorithm with t=2t=2:

      • If the key is in a leaf with >1> 1 keys: simple removal.
      • If in an internal node: swap with predecessor (or successor), then delete from leaf.
      • If the target leaf is a 2-node (minimum size, t1=1t-1 = 1 key):
        • Rotate (redistribute): borrow a key from a sibling that has >1> 1 keys. The sibling’s key moves via the parent; the parent’s separator key moves down.
        • Merge: if both siblings are also 2-nodes, merge this node with a sibling by pulling the parent’s separator key down. The merged node has 3 keys (from 1+1+11+1+1). This may leave the parent too small, cascading upward.

      The Red-Black Connection

      The 2-3-4 tree maps directly to a red-black tree:

      • A 2-node (1 key, 2 children) → a single black node.
      • A 3-node (2 keys, 3 children) → a black node with one red child.
      • A 4-node (3 keys, 4 children) → a black node with two red children.

      This mapping is an isomorphism: operations on one structure correspond exactly to operations on the other. The height of a red-black tree is at most twice the height of its corresponding 2-3-4 tree, hence O(logn)O(\log n).

      Summary

      Property2-3-4 Tree
      TypeB-tree with t=2t=2
      Children per node2, 3, or 4
      Keys per node1, 2, or 3
      Heightlog2((n+1)/2)\le \log_2((n+1)/2)
      InsertSplit 4-nodes on overflow
      DeleteRotate or merge 2-nodes
      Map to red-black2-node→black, 3-node→black+red child, 4-node→black+2 red children
    • Red-Black Trees

      Motivation

      A standard BST degenerates to O(n)O(n) height when keys are inserted in sorted order. Red-black trees (RBTs) are binary search trees that maintain approximate balance through an extra colour attribute per node, guaranteeing O(logn)O(\log n) worst-case height with simple insertion and deletion algorithms.

      They generalise 2-3-4 trees into binary form: the structural logic of a multi-way tree is encoded in node colours rather than variable child counts.

      The Five Red-Black Properties

      A valid red-black tree satisfies:

      1. Every node is either red or black.
      2. The root is black.
      3. The leaves (NIL sentinels) are black and contain no keys.
      4. Both children of a red node are black (no two consecutive red nodes on any path).
      5. For each node, all simple paths to descendant leaves contain the same number of black nodes (the black-height is balanced).

      Properties 4 and 5 are the crucial ones: Property 4 limits how unbalanced paths can be (you cannot have long chains of nodes), and Property 5 forces uniform black-node depth across all paths.

      Red-black tree with black-heights

      Black-Height

      The black-height bh(x) of a node x is the number of black nodes on any path from x (excluding x itself) to a leaf. By Property 5, all such paths have the same count.

      Example: in a tree with root=black, two black children, and red grandchildren, the root’s black-height is 2. The leaves (NIL) have black-height 0.

      Height Bound

      Lemma: A red-black tree with nn internal (non-leaf) nodes has height h2log(n+1)h \le 2\log(n+1).

      Proof sketch: For any node xx, the subtree rooted at xx has at least 2bh(x)12^{bh(x)} - 1 internal nodes (minimum when all nodes are black, forming a complete binary tree down to black-height depth). The root’s black-height is at least h/2h/2 (by Property 4, at most half the nodes on any path can be red). Thus:

      n2h/21h2log(n+1)n \ge 2^{h/2} - 1 \quad\Rightarrow\quad h \le 2\log(n+1)

      This guarantees O(logn)O(\log n) worst-case height, hence O(logn)O(\log n) search, insert, and delete.

      Example: Insertion into a RBT

      Insert keys [N, D, A] into an initially empty red-black tree:

      • Insert N as the root (black, by Property 2).
      • Insert D < N: add as left child, coloured red. No violation (parent is black).
      • Insert A < D < N: add as left child of D, coloured red. Violation! A (red) has red parent D.

      The fix depends on the colour of the uncle (the parent’s sibling): D’s sibling is NIL (black leaf). Since the uncle is black, a rotation at N (right-rotate) and recolouring restores balance. Result: D becomes black root, A and N are red children.

      Connection to 2-3-4 Trees

      The red-black properties are an Oulipo-style characterisation: they describe valid trees without mentioning 2-3-4 trees. But the underlying reason they work is the isomorphism with 2-3-4 trees:

      • A black node anchors its 2-3-4 node.
      • Red nodes are “glued” to their black parent, representing extra keys in the same 2-3-4 node.
      • The black-height equals the 2-3-4 tree height.

      This provides both intuition and correctness: RBT insert/delete correctness follows from 2-3-4 tree correctness, since each operation translates.

      Summary

      PropertyRequirement
      1Every node red or black
      2Root is black
      3Leaves (NIL) are black
      4Red node → black children
      5All paths have same black count
      Height boundh2log(n+1)h \le 2\log(n+1)
      Search/Insert/DeleteO(logn)O(\log n) worst case
      Isomorphic to2-3-4 tree (B-tree with t=2t=2)
    • Red-Black Tree Operations

      Rotations

      A rotation is a local tree restructuring that preserves the BST ordering property and runs in Θ(1)\Theta(1) time. Two symmetric operations:

      • Left-rotate(T, x): node x’s right child y becomes the new parent; x becomes y’s left child; y’s former left subtree becomes x’s right subtree.
      • Right-rotate(T, y): the inverse operation.

      Rotations are the core mechanism for rebalancing after insertions and deletions. They change the structure without affecting the inorder traversal order.

      Tree rotation diagram

      Insertion

      Standard BST insertion is performed first: the new node is added as a leaf and coloured red. This preserves Property 5 (black-height) but may violate Property 4 (red parent with red child) or Property 2 (red root).

      If the new node’s parent is black, no fix is needed (Case 0). Otherwise, the insertion fix-up has three cases, distinguished by the colour of the uncle (the parent’s sibling).

      Case 1: Uncle is Red

      Both the parent and uncle are red. This corresponds to a 4-node in the 2-3-4 tree that has received an extra key.

      Fix: Recolour the parent, uncle, and grandparent. The parent and uncle become black; the grandparent becomes red. The grandparent may now violate Property 4 against its own parent, so the problem moves up two levels. Repeat the fix-up from the grandparent.

      Case 2: Uncle is Black, Node is a “Zig-Zag”

      The new node is the right child of a left parent (or left child of a right parent). This is a “bent” configuration.

      Fix: Rotate the parent away from the new node (left-rotate the left parent, or right-rotate the right parent). This transforms the situation into Case 3. The node that was the parent becomes the child.

      Case 3: Uncle is Black, Node is a “Zig-Zig”

      The new node is the left child of a left parent (or right child of a right parent). This is a “straight” configuration.

      Fix: Rotate the grandparent in the opposite direction (right-rotate if both are left children, left-rotate if both are right children). Then recolour: the parent becomes black, the grandparent becomes red. This terminates the fix-up.

      Example Walkthrough

      Insert 4 into:

          5B
         /  \
        3R  NIL
       /
      2R

      4 is inserted as right child of 3R (Case 2: zig-zag). Left-rotate 3:

          5B
         /  \
        4R  NIL
       /
      3R

      Now 4R and 3R form a zig-zig (Case 3). Right-rotate 5 and recolour: 4 becomes black, 5 becomes red, 3 stays red. Final tree is valid.

      Deletion (Outline)

      Deletion is more complex than insertion. The high-level approach:

      1. Perform standard BST deletion.
      2. If the removed node was black, a “double-black” violation occurs at its replacement position (a path now has one fewer black node).
      3. Fix the double-black by considering the sibling’s colour and its children’s colours, using rotations and recolouring.
      4. Cases include: sibling is red (rotate and recolour to make sibling black), sibling is black with two black children (recolour sibling red, push double-black upward), sibling is black with at least one red child (rotate and recolour to absorb the extra black).

      Full deletion is examinable at the concept level but not in exhaustive case-analysis detail. The key point: all fix-up cases run in O(1)O(1) per level visited, and the problem moves up at most O(logn)O(\log n) levels.

      Practical Considerations

      Red-black trees require fewer rotations on average than AVL trees for insertions, making them preferred in practice (e.g. Linux kernel’s CFS scheduler, Java’s TreeMap, C++ STL std::map). Both guarantee O(logn)O(\log n) worst-case height; the constant factors favour red-black for insert-heavy workloads and AVL for search-heavy workloads.

      Summary

      OperationTimeNotes
      RotateΘ(1)\Theta(1)Preserves BST order
      Insert (BST phase)O(logn)O(\log n)Colour new node red
      Insert fix-upO(logn)O(\log n)3 cases, at most 2 rotations
      Case 1Uncle redRecolour, move up
      Case 2Uncle black, zig-zagRotate parent, reduces to Case 3
      Case 3Uncle black, zig-zigRotate grandparent, recolour, done
      Delete (BST phase)O(logn)O(\log n)Standard BST deletion
      Delete fix-upO(logn)O(\log n)Fix double-black violations
    • Equivalence of Red-Black and 2-3-4 Trees

      The Isomorphism

      Every 2-3-4 tree can be converted to a red-black tree, and every red-black tree can be converted back to a 2-3-4 tree. The two structures are isomorphic: operations on one translate directly to operations on the other, preserving correctness and asymptotic bounds.

      Red-black to 2-3-4 mapping

      Mapping 2-3-4 Nodes to Red-Black Clusters

      Each node type in a 2-3-4 tree maps to a small cluster of red-black nodes:

      2-Node (1 key, 2 children) → Single Black Node

      A 2-node has one key and two children. Its red-black representation is a single black node. The children of the black node are the representations of the 2-node’s subtrees.

      3-Node (2 keys, 3 children) → Black + One Red Child

      A 3-node has two keys, k1<k2k_1 < k_2, and three children. There are two possible red-black representations (left- and right-handed isomers):

      • Right-handed: black node with key k2k_2, left red child with key k1k_1. Children: leftmost subtree under red node, middle under black node’s left, rightmost under black node’s right.
      • Left-handed: black node with key k1k_1, right red child with key k2k_2. Children arranged symmetrically.

      4-Node (3 keys, 4 children) → Black + Two Red Children

      A 4-node has three keys, k1<k2<k3k_1 < k_2 < k_3, and four children. The red-black representation: black node with key k2k_2, left red child with key k1k_1, right red child with key k3k_3. The four children map to the subtrees of the red and black nodes in order.

      Black-Height as 2-3-4 Height

      The black-height of a red-black tree equals the height of the corresponding 2-3-4 tree. This follows directly from the mapping: each level in the 2-3-4 tree contributes exactly one black node (the “anchor” of the cluster). Red nodes are “glued” to their black parent and do not increase the 2-3-4 tree height.

      This explains why the red-black tree height is at most 2h234+12h_{2-3-4} + 1 (or roughly 2h2h): in the worst case, every 2-3-4 node is a 3-node represented as a black node with a red child at the deepest level, doubling the path length compared to black-height.

      Translating Operations

      Insert

      Inserting into a 2-3-4 tree: find leaf, insert key, split 4-nodes that overflow. Translating to red-black:

      • Inserting a key into a 2-node (black node) → colour new node red. If parent is black, done. (Case 0)
      • Inserting into a 3-node → new red node may create two consecutive reds. Resolving this is a rotation (Case 2 or 3).
      • Inserting into a 4-node (black+two red children) → the 4-node is full. Splitting it in 2-3-4 terms corresponds to colour-flipping: the black parent becomes red, and both red children become black. This is exactly Case 1 of the RBT fix-up.

      Splitting a 4-Node = Colour Flipping

      When a 2-3-4 tree splits a 4-node, the middle key is promoted to the parent, and the node becomes two 2-nodes. In red-black terms:

      • Before: black node k2k_2 with red children k1k_1 (left) and k3k_3 (right).
      • After: k2k_2 becomes red (promoted into parent’s cluster), k1k_1 and k3k_3 become black (each becomes an independent 2-node).

      This colour flip may create a red-red violation at the parent level, which is then resolved by Cases 2/3 or further Case 1 flips.

      Merging in Deletion

      When a 2-3-4 tree merges nodes during deletion (stealing the separator key from the parent), the corresponding red-black operation involves recolouring and rotations that redistribute “blackness” to fix double-black violations.

      Why the Equivalence Matters

      1. Correctness: Proving RBT operations correct is done by showing they maintain the mapping to a valid 2-3-4 tree. The 2-3-4 tree’s perfect height balance guarantees the RBT’s logarithmic height.
      2. Intuition: The colour rules feel arbitrary until you see they encode 2-3-4 structure. “No two consecutive reds” means “no 2-3-4 node has more than 3 keys.” “Equal black-height” means “all 2-3-4 leaves are at the same depth.”
      3. Implementation: An RBT can be debugged by checking whether it maps to a valid 2-3-4 tree. This is a perfectly satisfactory correctness test.

      Summary

      2-3-4 NodeRed-Black ClusterKeys
      2-nodeSingle black node1
      3-nodeBlack + 1 red child2
      4-nodeBlack + 2 red children3
      2-3-4 height hhRBT black-height = hh
      4-node splitColour flip (Case 1)
      Insert into 3-nodeRotation (Cases 2/3)
      RBT height boundh2log(n+1)h \le 2\log(n+1)From 2-3-4 balance
  • Hash Tables

    The dictionary problem, hash functions, collision resolution by chaining and open addressing, load factor, resizing, and universal hashing

    • The Dictionary Problem

      The Dictionary ADT

      A dictionary (also called a map or associative array) stores a set of (key, value) pairs and supports three core operations:

      • INSERT(key, value): add a new pair, or update the value if key already exists.
      • DELETE(key): remove the pair with the given key.
      • SEARCH(key): return the value associated with the key, or indicate absence.

      The dictionary is one of the most fundamental abstract data types in computing. It underpins symbol tables in compilers, routing tables in networks, caches in operating systems, and database indices.

      Naive Implementations

      Unsorted Array

      Keep pairs in an array in any order.

      • INSERT: append to end, O(1)O(1).
      • SEARCH: linear scan, O(n)O(n).
      • DELETE: find then remove (or mark as deleted), O(n)O(n).

      Sorted Array

      Keep pairs sorted by key. SEARCH uses binary search: O(logn)O(\log n). But INSERT requires shifting elements to maintain sorted order: O(n)O(n). DELETE also O(n)O(n).

      Balanced BST (e.g. Red-Black Tree)

      All three operations in O(logn)O(\log n). This is the best worst-case guarantee among comparison-based structures.

      The Hash Table Promise

      Hash tables aim for O(1)O(1) expected time for all three operations. The core idea: compute an array index directly from the key using a hash function, then store the pair at that position.

      Given key "Cambridge", compute h("Cambridge") = 3, and store the value at table slot T[3]. Future searches for "Cambridge" go directly to slot 3 without comparing against other keys (unless there is a collision).

      The Inevitability of Collisions

      By the pigeonhole principle: the number of possible keys vastly exceeds the number of table slots. Even for integer keys: if UUIDs (128-bit) are keys and the table has 10610^6 slots, 21282^{128} keys map into 10610^6 slots; many keys must share the same index.

      A collision occurs when two distinct keys k1k2k_1 \neq k_2 produce the same hash value: h(k1)=h(k2)h(k_1) = h(k_2). Collision resolution is the central design challenge of hash tables.

      Key Design Decisions

      Building an effective hash table requires choosing:

      1. Hash function: maps keys to table indices. Must be fast to compute and produce a uniform distribution across the table.
      2. Collision resolution strategy: what to do when two keys map to the same slot. Two main families: chaining (linked lists per slot) and open addressing (probing for an empty slot).
      3. Load factor management: when to grow (or shrink) the table to maintain performance.

      Example: Collision in Practice

      Hash function h(k)=kmod10h(k) = k \bmod 10, table size m=10m = 10.

      INSERT(23, "Alice"):  h(23) = 3  → T[3] = ("Alice")
      INSERT(47, "Bob"):    h(47) = 7  → T[7] = ("Bob")
      INSERT(13, "Carol"):  h(13) = 3  → COLLISION with Alice!

      The hash table must now resolve what happens at slot 3. With chaining, both entries live in a linked list at T[3]. With open addressing, Carol probes for the next empty slot.

      Summary

      ImplementationINSERTSEARCHDELETE
      Unsorted arrayO(1)O(1)O(n)O(n)O(n)O(n)
      Sorted arrayO(n)O(n)O(logn)O(\log n)O(n)O(n)
      Balanced BSTO(logn)O(\log n)O(logn)O(\log n)O(logn)O(\log n)
      Hash table (expected)O(1)O(1)O(1)O(1)O(1)O(1)
      CollisionTwo keys map to same slot
      Load factor α\alphaα=n/m\alpha = n/m, nn keys, mm slots
    • Hash Functions

      Definition and Role

      A hash function h:U{0,1,,m1}h: U \to \{0, 1, \ldots, m-1\} maps keys from the universe UU to mm slots in a hash table. A good hash function satisfies:

      1. Fast to compute: Θ(1)\Theta(1) time per call. If the hash function itself is slow, the O(1)O(1) promise of hash tables is hollow (hidden large constant factors).
      2. Uniform distribution: each of the mm slots is equally likely for a random key. This is the simple uniform hashing assumption, which makes expected-case analysis possible.
      3. Deterministic: the same key always maps to the same slot (unless using universal hashing where the function is chosen at random at runtime).

      Handling Non-Integer Keys

      Hash functions ultimately produce integers. For non-integer keys (strings, objects), the key must first be converted to an integer. A common approach for strings: treat each character as a digit in a large base (e.g. 256 for ASCII), producing a polynomial:

      h(s)=(i=0s1s[i]Bs1i)modmh(s) = \left( \sum_{i=0}^{|s|-1} s[i] \cdot B^{|s|-1-i} \right) \bmod m

      The choice of base BB and modulus mm affects distribution quality.

      Division Method

      h(k)=kmodmh(k) = k \bmod m

      This is fast (one modular division), but the choice of mm is critical:

      • Avoid powers of 2 (e.g. m=2pm = 2^p): then h(k)h(k) depends only on the lowest pp bits of kk, ignoring higher-order bits. This produces poor distribution if keys share low-bit patterns.
      • Prefer mm as a prime number not too close to a power of 2. A prime modulus ensures all bits of kk influence the hash value.

      Example: For m=10m = 10, h(23)=3h(23) = 3, h(47)=7h(47) = 7, h(13)=3h(13) = 3 (collision). For m=11m = 11 (prime): h(23)=1h(23) = 1, h(47)=3h(47) = 3, h(13)=2h(13) = 2 (no collision).

      Multiplication Method

      h(k)=m(kAmod1)h(k) = \lfloor m (kA \bmod 1) \rfloor

      where AA is a constant in (0,1)(0, 1). Knuth recommends A(51)/20.6180339887A \approx (\sqrt{5} - 1)/2 \approx 0.6180339887 (the golden ratio conjugate).

      The idea: multiply kk by AA, extract the fractional part (kAmod1)(kA \bmod 1), and scale by mm. The fractional part distributes keys uniformly across [0,1)[0, 1) regardless of patterns in kk. The method works for any mm, which is convenient when resizing tables.

      Range Reduction

      Modern hash functions typically output a 32-bit or 64-bit unsigned integer (e.g. 0..2^32-1). This raw output is too large to use as a table index. Range reduction converts it:

      index=hraw(k)modm\text{index} = h_{\text{raw}}(k) \bmod m

      This is fast but introduces slight bias unless mm divides the hash output range. In practice, the bias is negligible for well-designed hash functions.

      Cryptographic vs Non-Cryptographic Hashing

      Cryptographic hash functions (SHA-256, BLAKE3) provide strong guarantees: collision resistance, preimage resistance. They are slow by design and not needed for hash tables.

      Non-cryptographic hash functions (MurmurHash, FNV-1a, xxHash) prioritise speed and reasonable distribution. These are the appropriate choice for in-memory data structures. The Cambridge course focuses on the latter.

      Low-Entropy Inputs

      If keys have low entropy (e.g. single letters, or words differing by one character like ROT, TOT, POT), even a good hash function cannot produce a uniform distribution: there are only 26 possible inputs, so at most 26 distinct hash values. For such inputs, choose domain-specific hash functions that maximise spread.

      Summary

      MethodFormulaProsCons
      Divisionkmodmk \bmod mSimple, fastNeed prime mm
      Multiplicationm(kAmod1)\lfloor m(kA \bmod 1) \rfloorWorks for any mmSlightly slower
      Range reductionhraw(k)modmh_{\text{raw}}(k) \bmod mDecouples hash from table sizeMinor bias
      Polynomial (strings)s[i]Bimodm\sum s[i] \cdot B^i \bmod mGood for stringsParameter-sensitive
      Desired propertyUniform distribution over [0,m1][0, m-1]
    • Collision Resolution: Chaining

      How Chaining Works

      In chaining, each slot T[i]T[i] of the hash table holds a pointer to a linked list (initially NIL). All pairs that hash to the same index ii are stored in the list at T[i]T[i]. There are multiple variants of chaining, differing in insertion policy and whether lists are sorted.

      Chaining hash table

      Variant 1: Unsorted List, Append at End (or Replace on Match)

      • INSERT: hash to slot, walk the list. If key found, replace value. If not found, append a new cell to the end.
      • SEARCH: hash to slot, walk the list until key is found or end is reached.
      • DELETE: hash to slot, walk the list. If found, remove the cell. Otherwise, no-op.

      Performance: Expected O(1+α)O(1 + \alpha) where α=n/m\alpha = n/m is the load factor. Searching for a key that is present takes, on average, half the time of searching for an absent key (since present keys are found halfway down the list on average).

      Variant 2: Sorted List

      Keep each chain sorted by key order (not hash value order, since all keys in a chain have the same hash).

      • INSERT: walk down the chain, insert when the next key is larger (or replace if equal).
      • SEARCH: walk until key found or a larger key encountered (early termination for absent keys).
      • DELETE: same search then removal.

      Performance: Rebind and fresh insert take the same expected time (both stop at the sorted position). Requires keys to be comparable.

      Variant 3: Push on Head, Tombstone Deletion

      • INSERT: always push on the head, regardless of whether the key already exists.
      • DELETE: push a tombstone entry on the head (key with NIL payload).
      • SEARCH: walk until the first match for the key is found (ignoring tombstones, or returning NIL if the newest entry is a tombstone).

      Performance: INSERT and DELETE are Θ(1)\Theta(1). SEARCH is O(I+D)O(I + D) where II and DD are the total numbers of insert and delete calls — grows without bound. Best for read-only tables or where the table is rebuilt periodically.

      Variant 4: Push on Head, Delete from List

      • INSERT: push on the head.
      • DELETE: walk the list and remove the first matching key.
      • SEARCH: walk until first match.

      Performance: INSERT: Θ(1)\Theta(1). SEARCH and DELETE: O(I)O(I) where II is the number of insert calls. When a key is deleted, its previous binding “comes back to life,” giving stack-like semantics per key.

      Load Factor and Performance

      The load factor α=n/m\alpha = n/m is the average chain length. Under the simple uniform hashing assumption (each key equally likely to hash to any slot), the expected chain length is α\alpha, and:

      • Unsuccessful search: traverse the entire chain, expected Θ(1+α)\Theta(1 + \alpha).
      • Successful search: traverse roughly half the chain, expected Θ(1+α/2)=Θ(1+α)\Theta(1 + \alpha/2) = \Theta(1 + \alpha).

      If α\alpha is bounded by a constant (by resizing when it exceeds a threshold, say 0.750.75), all operations are O(1)O(1) expected.

      Worst Case

      All nn keys could hash to the same slot. Then each chain is length nn, and all operations become Θ(n)\Theta(n). This requires either a pathological hash function or an adversarial input. Universal hashing (see later note) protects against the latter.

      Example

      Hash table with m=5m = 5 slots, using h(k)=kmod5h(k) = k \bmod 5. Insert 13, 27, 42, 8, 33:

      Slot 0: [ ]
      Slot 1: [ ]
      Slot 2: 42 → 27
      Slot 3: 33 → 13 → 8
      Slot 4: [ ]

      Search for 27: hash to 2, walk list: 422742 \neq 27, 2727 found. Search for 50: hash to 0, walk: NIL, absent. Load factor α=5/5=1.0\alpha = 5/5 = 1.0.

      Summary

      VariantINSERTSEARCHDELETENotes
      Unsorted, appendO(1+α)O(1+\alpha)O(1+α)O(1+\alpha)O(1+α)O(1+\alpha)Rebind faster than fresh
      SortedO(1+α)O(1+\alpha)O(1+α)O(1+\alpha)O(1+α)O(1+\alpha)Needs comparable keys
      Push on head, tombstoneΘ(1)\Theta(1)O(I+D)O(I+D)Θ(1)\Theta(1)SEARCH degrades
      Push on head, removeΘ(1)\Theta(1)O(I)O(I)O(I)O(I)Stack semantics per key
      Load factorα=n/m\alpha = n/mExpected O(1)O(1) if α=O(1)\alpha = O(1)
    • Collision Resolution: Open Addressing

      How Open Addressing Works

      In open addressing, all (key,value)(key, value) pairs are stored directly in the hash table array (no linked lists). Each slot holds either a pair or is empty (NIL). When a collision occurs at slot h(k)h(k), the algorithm probes alternative slots according to a deterministic sequence until an empty slot is found.

      The table must have strictly more slots than stored keys: the load factor α=n/m<1\alpha = n/m < 1.

      Open addressing with linear probing

      The Probe Sequence

      For key kk, the iith probe (starting from i=0i=0) is:

      h(k,i)=(h(k)+f(i))modmh(k, i) = (h'(k) + f(i)) \bmod m

      where h(k)h'(k) is the base hash function and f(i)f(i) defines the probing strategy. The sequence must be a permutation of {0,1,,m1}\{0, 1, \ldots, m-1\} so every slot is reachable.

      Linear Probing

      h(k,i)=(h(k)+i)modmh(k, i) = (h'(k) + i) \bmod m

      The simplest strategy: if slot h(k)h'(k) is occupied, try h(k)+1h'(k)+1, then h(k)+2h'(k)+2, and so on (wrapping around).

      Primary clustering: contiguous runs of occupied slots form, and any key hashing anywhere into the run must probe through the entire run. Linear probing effectively reduces the selectivity of the hash function: keys that did not originally collide end up competing for the same slots. Using a step size other than 1 does not solve this (it just shifts where clustering appears).

      Quadratic Probing

      h(k,i)=(h(k)+c1i+c2i2)modmh(k, i) = (h'(k) + c_1 i + c_2 i^2) \bmod m

      For example, with c1=c2=1c_1 = c_2 = 1: probe sequence h,h+2,h+6,h+12,h', h'+2, h'+6, h'+12, \ldots (differences +1,+3,+5,+7,+1, +3, +5, +7, \ldots). Starting from different hh' values produces different sequences, so keys that collide under hh' do not follow each other indefinitely.

      Secondary clustering: keys that hash to the same hh' value still follow the same probe sequence and collide repeatedly. This is less severe than primary clustering.

      Double Hashing

      h(k,i)=(h1(k)+ih2(k))modmh(k, i) = (h_1(k) + i \cdot h_2(k)) \bmod m

      Uses a second independent hash function h2(k)h_2(k). To ensure h2(k)h_2(k) is always coprime to mm (so the probe sequence visits all slots), a common choice is:

      h2(k)=(h2(k)mod(m1))+1h_2(k) = (h_2'(k) \bmod (m-1)) + 1

      Double hashing approximates the uniform hashing assumption: each probe sequence is equally likely to be any permutation. It avoids both primary and secondary clustering, at the cost of computing a second hash function.

      Insert, Search, and Delete

      INSERT: start at i=0i=0. Probe h(k,i)h(k, i). If slot is NIL, insert there. If slot is occupied by a key that equals kk, update the value. If a full cycle is made without finding an empty slot, the table is full (or the probe sequence was flawed).

      SEARCH: start at i=0i=0. Probe h(k,i)h(k, i). If slot matches the key, return value. If an empty (NIL) slot is reached, the key is not present. If a full cycle completes, key not present.

      DELETE: you cannot simply set the slot to NIL, because that would break probe sequences for other keys. A search for a key that probed past the now-deleted slot would falsely terminate at the NIL.

      Instead, use a tombstone (deleted marker): when searching, a tombstone means “keep probing” (it is not an empty slot). When inserting, a tombstone counts as “available” (can be overwritten).

      Performance

      Under the uniform hashing assumption, the expected number of probes for:

      • Unsuccessful search: 11α\frac{1}{1-\alpha}
      • Successful search: 1αln11α\frac{1}{\alpha}\ln\frac{1}{1-\alpha}

      Example with α=0.5\alpha = 0.5: unsuccessful search expects 10.5=2\frac{1}{0.5} = 2 probes; successful search expects 10.5ln21.39\frac{1}{0.5}\ln 2 \approx 1.39 probes.

      With α=0.9\alpha = 0.9: unsuccessful expects 10.1=10\frac{1}{0.1} = 10 probes; successful expects 10.9ln102.56\frac{1}{0.9}\ln 10 \approx 2.56 probes.

      Performance degrades sharply as α1\alpha \to 1. Typical implementations rehash when α\alpha exceeds 0.50.5.

      Resizing with Open Addressing

      Maintain counters for the number of live keys (nkn_k) and tombstones (nmn_m). When nk+nmn_k + n_m exceeds a threshold (e.g. 0.5m0.5 \cdot m):

      • If nknmn_k \gg n_m: rehash into a larger table.
      • If nknmn_k \approx n_m: rehash into a table of the same size (purging tombstones).
      • If nknmn_k \ll n_m: rehash into a smaller table.

      Tables sizes are often chosen as primes to improve hash function behaviour.

      Summary

      Probing MethodProbe SequenceClustering Issue
      Linearh+ih' + iPrimary clustering
      Quadratich+c1i+c2i2h' + c_1 i + c_2 i^2Secondary clustering
      Double hashingh1+ih2h_1 + i \cdot h_2Negligible
      TombstoneDeleted markerEnables correct deletion
      Load factor limitα<1\alpha < 1Typically keep α0.5\alpha \le 0.5
      Expected unsuccessful probes11α\frac{1}{1-\alpha}Under uniform hashing
      Expected successful probes1αln11α\frac{1}{\alpha}\ln\frac{1}{1-\alpha}Under uniform hashing
    • Load Factor and Resizing

      The Load Factor

      The load factor α=n/m\alpha = n/m measures how full a hash table is, where nn is the number of stored pairs and mm is the table size (number of slots).

      As α\alpha increases:

      • For chaining: average chain length grows, so search and insert expected time grows as O(1+α)O(1 + \alpha).
      • For open addressing: the probability of finding an occupied slot increases sharply, and the expected number of probes grows as 11α\frac{1}{1-\alpha} (unsuccessful search) or 1αln11α\frac{1}{\alpha}\ln\frac{1}{1-\alpha} (successful search).

      Why Resizing Is Necessary

      A hash table with fixed capacity mm will eventually become overloaded if nn grows. Without resizing:

      • Chaining: chain lengths grow linearly with nn, degrading to O(n)O(n) per operation.
      • Open addressing: the table fills completely, and insertions fail (or probe sequences become impractically long before that).

      Resizing is the operation of creating a new, larger table and rehashing all existing keys into it.

      Resizing Strategy

      When the load factor exceeds a threshold, double the table size (or increase to the next suitable prime):

      1. Allocate a new table of size m2mm' \approx 2m.
      2. For each key in the old table, compute h(k)modmh'(k) \bmod m' and insert into the new table.
      3. Replace the old table with the new one.

      Thresholds: typically 0.750.75 for chaining (some implementations use 1.01.0) and 0.50.5 for open addressing (the expected probe count at α=0.5\alpha = 0.5 is only 2 for unsuccessful search).

      Amortised Analysis of Resizing

      A single resize costs Θ(n)\Theta(n) because every key must be rehashed. If this happened on every insert, the insert cost would be Θ(n)\Theta(n), defeating the purpose.

      However, resizing happens infrequently. If the table doubles each time:

      • After mm inserts (from size m/2m/2 to mm), one resize costs Θ(m)\Theta(m).
      • The m/2m/2 inserts since the last resize each get charged an amortised Θ(1)\Theta(1) for the resize cost, plus their own Θ(1)\Theta(1) insert cost.

      This is identical to the amortised analysis of dynamic arrays: the total cost of nn inserts starting from an empty table is Θ(n)\Theta(n), so the amortised cost per insert is Θ(1)\Theta(1).

      Downsizing

      If many deletions reduce nn significantly (e.g. α<0.25\alpha < 0.25), the table can be downsized to reclaim memory. The same amortised argument applies: periodic downsizing adds Θ(1)\Theta(1) amortised cost per deletion.

      Avoid aggressive downsizing (e.g. at α=0.5\alpha = 0.5), which could cause thrashing: a sequence of insert-delete-insert oscillating around the threshold, triggering repeated O(n)O(n) resizes.

      Example: Resizing in Practice

      Start with m=4m = 4, threshold =0.75= 0.75.

      Insert 1: n=1, α=0.25, table=[1,_,_,_]
      Insert 2: n=2, α=0.50, table=[1,2,_,_]
      Insert 3: n=3, α=0.75, table=[1,2,3,_]
      Insert 4: n=4, α=1.00 > 0.75 → RESIZE to m=8
                 Rehash all 4 existing keys into new table.
                 Total cost: Θ(4) for resize + Θ(1) for insert 4.

      After resize, α=4/8=0.50\alpha = 4/8 = 0.50, well within the threshold.

      Performance of Open Addressing with Resizing

      With open addressing, tombstones also consume slots. When the fraction of tombstones becomes large, searches probe through many dead entries. A rehash into a same-size (or larger) table clears tombstones and restores performance. Maintain nkn_k (live keys) and nmn_m (tombstones); trigger rehash when nk+nmn_k + n_m exceeds threshold.

      Summary

      QuantityMeaning
      α=n/m\alpha = n/mLoad factor
      Chaining thresholdTypically 0.750.751.01.0
      Open addressing thresholdTypically 0.50.5
      Resize costΘ(n)\Theta(n) (rehash all keys)
      Amortised insert costΘ(1)\Theta(1) (same analysis as dynamic arrays)
      Resize triggerα\alpha exceeds threshold (or too many tombstones)
      DownsizingReclaim memory when α\alpha becomes very small
    • Universal Hashing

      The Problem with Fixed Hash Functions

      Any fixed hash function hh can be adversarially exploited. If an attacker knows (or reverse-engineers) the hash function, they can craft a set of keys that all map to the same slot:

      h(k1)=h(k2)==h(kn)=sh(k_1) = h(k_2) = \cdots = h(k_n) = s

      The result: chaining degrades to a linked list of length nn, or open addressing probes through all slots. All operations become Θ(n)\Theta(n). This is not just a theoretical concern: it has been used in denial-of-service attacks against web frameworks and network infrastructure.

      Randomising the input data or picking a random pivot (as in quicksort) reduces the probability of hitting the worst case, but for hash tables a stronger guarantee exists.

      Universal Families of Hash Functions

      Universal hashing: the hash function is chosen randomly at runtime from a family H\mathcal{H} of hash functions. The family is designed so that, regardless of which keys the adversary provides, the probability of collision between any two distinct keys is bounded.

      Definition: A family H\mathcal{H} of functions mapping UU to {0,1,,m1}\{0, 1, \ldots, m-1\} is universal if, for any distinct k1,k2Uk_1, k_2 \in U:

      PrhH[h(k1)=h(k2)]1m\Pr_{h \in \mathcal{H}}[h(k_1) = h(k_2)] \le \frac{1}{m}

      The probability is taken over the random choice of hh from H\mathcal{H}, not over the choice of keys. The keys can be chosen adversarially after H\mathcal{H} is known; the guarantee still holds.

      A Concrete Universal Family

      Choose a prime p>Up > |U| (larger than any possible key value). Define:

      ha,b(k)=((ak+b)modp)modmh_{a,b}(k) = ((ak + b) \bmod p) \bmod m

      where a{1,2,,p1}a \in \{1, 2, \ldots, p-1\} and b{0,1,,p1}b \in \{0, 1, \ldots, p-1\} are chosen uniformly at random. The family Hp,m={ha,b}\mathcal{H}_{p,m} = \{h_{a,b}\} is universal.

      Proof sketch: For any k1k2k_1 \neq k_2, the values (ak1+b)modp(ak_1 + b) \bmod p and (ak2+b)modp(ak_2 + b) \bmod p are uniformly distributed and pairwise independent over {0,,p1}\{0, \ldots, p-1\}. The probability they collide modulo mm is at most 1/m1/m.

      Why Universal Hashing Works

      With a universal family and chaining, the expected search time is O(1+α)O(1 + \alpha) even against an adversary. The adversary can choose keys knowing the family H\mathcal{H} but not which hHh \in \mathcal{H} will be chosen at runtime.

      The proof: for any search key kk, the expected number of other keys in the same chain is:

      E[collisions]=kkPr[h(k)=h(k)](n1)1m<α\mathbb{E}[\text{collisions}] = \sum_{k' \neq k} \Pr[h(k) = h(k')] \le (n-1) \cdot \frac{1}{m} < \alpha

      Thus the expected chain length is <1+α< 1 + \alpha, giving O(1)O(1) expected search when α=O(1)\alpha = O(1).

      Static Dictionaries and Perfect Hashing

      For a static dictionary (keys known in advance, no insert/delete after construction), universal hashing can achieve O(1)O(1) worst-case search time with no collisions, using a two-level scheme:

      1. Hash nn keys into nn primary slots using a universal function.
      2. For each slot with nin_i keys, build a secondary hash table of size ni2n_i^2 using another universal function.

      The expected total space is O(n)O(n), and with high probability (by choosing random hash functions repeatedly until no collisions occur), all secondary tables are collision-free. This is called perfect hashing.

      Practical Considerations

      Many production hash table implementations (Python’s dict, Rust’s HashMap) use a fixed high-quality hash function with per-process randomisation (a random seed). This approximates universal hashing: the seed is chosen at startup, making it difficult for an attacker to predict slot assignments.

      The Cambridge course treats universal hashing as the theoretical foundation: a fixed function cannot beat an adversary, but a randomly chosen function from a universal family can.

      Summary

      ConceptDefinition
      Fixed hash functionVulnerable to adversarial input
      Universal family H\mathcal{H}Pr[h(k1)=h(k2)]1/m\Pr[h(k_1)=h(k_2)] \le 1/m for any k1k2k_1 \neq k_2
      Example familyha,b(k)=((ak+b)modp)modmh_{a,b}(k) = ((ak+b) \bmod p) \bmod m
      Expected search timeO(1+α)O(1 + \alpha) even against adversary
      Perfect hashingZero collisions, O(1)O(1) worst-case search, O(n)O(n) expected space
      Per-process randomisationPractical approximation of universal hashing
  • Other Resources

    • Examinable Syllabus for Algorithms I

      Algorithms I Syllabus (25/26, Lent Term)

      The following topics are examinable for the Algorithms I component of CST Paper 1 (one question from a choice of two). The course covers 12 lectures taught by Dr John Fawcett.

      Sorting Algorithms

      All of the following sorting methods are examinable, including pseudocode, correctness proofs (via loop invariants), and asymptotic analysis:

      • Insertion sort: incremental, Θ(n2)\Theta(n^2) worst case, Θ(n)\Theta(n) best case (sorted input). Stable, in-place. Tight loop makes it fast for small nn.
      • Merge sort: divide and conquer, Θ(nlogn)\Theta(n \log n) worst and average case. Stable. Not in-place (requires Θ(n)\Theta(n) auxiliary space). Solved via Master Theorem or substitution.
      • Quicksort: divide and conquer with partition about a pivot. Θ(n2)\Theta(n^2) worst case (sorted input, or constant split-off), Θ(nlogn)\Theta(n \log n) expected case. In-place (Lomuto or Hoare partition). Instability of partition step makes it unstable.
      • Quicksort with Median-of-Medians: guarantees Θ(nlogn)\Theta(n \log n) worst case by selecting the median as pivot in Θ(n)\Theta(n) time.
      • Heapsort: build a max-heap in Θ(n)\Theta(n), then extract n1n-1 times at O(logn)O(\log n) each. Θ(nlogn)\Theta(n \log n) total. In-place, unstable. Demonstrates the heap data structure.
      • Counting sort: Θ(k+n)\Theta(k + n), where keys are in [0,k][0, k]. Non-comparison; stable. Requires k=O(n)k = O(n) to be linear.
      • Radix sort: sort digit-by-digit using a stable sort (e.g. counting sort) on each digit. Θ(d(n+k))\Theta(d(n + k)) for dd-digit numbers.
      • Bucket sort: assumes uniform distribution over [0,1)[0, 1). Θ(n)\Theta(n) average, Θ(n2)\Theta(n^2) worst.

      Sorting lower bounds: any comparison-based sort requires Ω(nlogn)\Omega(n \log n) comparisons. This is an information-theoretic lower bound (decision tree argument).

      Asymptotic Notation

      All five notations must be understood, with formal definitions and the ability to prove membership:

      NotationMeaningFormal definition
      O(g(n))O(g(n))Asymptotic upper bound0f(n)cg(n)0 \le f(n) \le c \cdot g(n) for nn0n \ge n_0
      Ω(g(n))\Omega(g(n))Asymptotic lower bound0cg(n)f(n)0 \le c \cdot g(n) \le f(n) for nn0n \ge n_0
      Θ(g(n))\Theta(g(n))Asymptotically tight boundc1g(n)f(n)c2g(n)c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n)
      o(g(n))o(g(n))Non-tight upper boundFor any c>0c > 0, 0f(n)<cg(n)0 \le f(n) < c \cdot g(n)
      ω(g(n))\omega(g(n))Non-tight lower boundFor any c>0c > 0, 0cg(n)<f(n)0 \le c \cdot g(n) < f(n)

      Properties: transitivity (all five), reflexivity (O,Ω,ΘO, \Omega, \Theta), symmetry (Θ\Theta only).

      Algorithm Design Strategies

      • Incremental: build solution step by step (insertion sort).
      • Divide and conquer: split into non-overlapping subproblems, solve recursively, combine (merge sort, quicksort, quicksort MoM, quickselect, Strassen’s algorithm).
      • Dynamic programming: overlapping subproblems with optimal substructure; memoisation; top-down (recursion + cache) and bottom-up (table filling).
      • Greedy algorithms: locally optimal choice leads to globally optimal solution; requires proof of correctness (exchange argument).
      • Semi-structures: heaps are cheaper to build than fully-sorted structures.

      Methods for Solving Recurrences

      • Guess and verify (substitution method): guess a bound, prove by induction.
      • Substitute and spot pattern: expand recurrence, identify arithmetic/geometric series, solve closed form.
      • Recursion tree method: draw call tree, sum costs per level, multiply by number of levels.
      • Master Theorem: for recurrences of form T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n). Three cases based on comparing f(n)f(n) with nlogban^{\log_b a}.

      Data Structures (Algorithms I Only)

      Elementary Structures

      • Pointers, NIL, objects.
      • Stacks (LIFO): PUSH, POP, O(1). Array and linked-list implementations.
      • Queues (FIFO): ENQUEUE, DEQUEUE, O(1). Circular buffer array implementation.
      • Singly and doubly linked lists; cyclic variants; sentinel nodes.
      • Rooted trees: with/without parent pointers, fixed/variable child counts.

      Binary Search Trees

      • BST property; SEARCH, INSERT, DELETE, MINIMUM, MAXIMUM, PREDECESSOR, SUCCESSOR.
      • All Θ(h)\Theta(h); h=Θ(n)h = \Theta(n) worst case, Θ(logn)\Theta(\log n) expected for random insertion.
      • Inorder traversal produces sorted order.

      Balanced Search Trees

      • B-trees: minimum degree tt; t1t-1 to 2t12t-1 keys per node; tt to 2t2t children. Height logt((n+1)/2)\le \log_t((n+1)/2). Search, insert (top-down splitting), delete (redistribute/merge). All O(logn)O(\log n). Disk I/O motivation.
      • 2-3-4 trees: B-tree with t=2t=2. 1—3 keys, 2—4 children per node. Conceptual bridge to red-black trees.
      • Red-black trees: five colour properties, black-height, height 2log(n+1)\le 2\log(n+1). Insertion fix-up (3 cases: recolour, zig-zag rotation, zig-zig rotation). Isomorphism with 2-3-4 trees. All operations O(logn)O(\log n).

      Hash Tables

      • Dictionary ADT: INSERT, DELETE, SEARCH.
      • Hash functions: division method, multiplication method, range reduction.
      • Collision resolution by chaining (4 variants).
      • Collision resolution by open addressing (linear, quadratic, double hashing).
      • Primary and secondary clustering.
      • Load factor α=n/m\alpha = n/m, resizing, amortised Θ(1)\Theta(1) insert.
      • Universal hashing: definition, example family, adversarial protection.

      Priority Queues and Heaps

      • Binary heaps: min-heap and max-heap. Structural property (complete tree) and ordering property (parent \le children for min-heap).
      • Array representation: root at index 1, children at 2i2i and 2i+12i+1, parent at i/2\lfloor i/2 \rfloor.
      • MAX-HEAPIFY (REHEAPIFY): O(logn)O(\log n).
      • BUILD-MAX-HEAP: Θ(n)\Theta(n).
      • HEAPSORT: Θ(nlogn)\Theta(n \log n).
      • Priority queue operations: INSERT, EXTRACT-MIN/MAX, DECREASE-KEY, all O(logn)O(\log n) via heap; also implementable via red-black trees.

      Order Statistics

      • MINIMUM and MAXIMUM in Θ(n)\Theta(n) via linear scan; simultaneous min+max in 3n/2\le 3\lfloor n/2 \rfloor comparisons.
      • Quickselect: expected Θ(n)\Theta(n), worst case Θ(n2)\Theta(n^2). With Median-of-Medians: worst case Θ(n)\Theta(n).

      Tripos Question Format

      Algorithms I is assessed in CST Paper 1, questions 7 and 8 (one to be answered from two). Typical structure:

      • Part (a): theory, definition, proof (e.g. state a loop invariant, prove correctness, derive a height bound).
      • Parts (b—d): application, algorithm design, tracing, or modifying an algorithm for a variant problem.

      Past papers from 2006—2026 are available. The syllabus is broadly stable across years.

      Summary of Examinable Topics

      CategoryTopics
      SortingInsertion, Merge, Quick, Heap, Counting, Radix, Bucket; stability; lower bounds
      AsymptoticsOO, Ω\Omega, Θ\Theta, oo, ω\omega; definitions and properties
      RecurrencesSubstitution, tree method, Master Theorem
      Design strategiesIncremental, divide and conquer, DP, greedy, semi-structures
      ProofsLoop invariants (initialisation, maintenance, termination); induction
      Elementary DSStacks, queues, linked lists, trees
      BSTsSearch, insert, delete; degeneracy
      Balanced treesB-trees, 2-3-4 trees, red-black trees; isomorphism
      Hash tablesChaining, open addressing, universal hashing, resizing
      HeapsHeapify, heapsort, priority queues
      Order statisticsQuickselect, Median-of-Medians
    • Proof Strategies for Algorithms I

      Why Proofs Matter in Algorithms I

      The exam frequently asks for proofs: correctness of an algorithm, a height bound for a tree, optimality of a greedy choice, or tightness of an asymptotic bound. Marks are awarded for clarity, rigour, and correctly identifying the induction variable and hypothesis.

      Two main proof strategies appear throughout the course: breakpoint induction (loop invariants) and along-a-path induction (used in Algorithms II but also relevant in Algorithms I for tree and heap properties).

      Breakpoint Induction (Loop Invariants)

      Used when an algorithm contains a loop. The invariant is a property that holds at a specific “breakpoint” — typically the start of each loop iteration.

      Three-Part Structure

      1. Initialisation: the invariant holds before the first iteration.
      2. Maintenance: if the invariant holds at the start of an iteration, it holds at the start of the next.
      3. Termination: when the loop terminates, the invariant (combined with the termination condition) proves the algorithm’s correctness.

      Example: Insertion Sort Correctness

      Invariant P: At the start of each iteration of the FOR loop (index jj), the subarray A[1..j1]A[1..j-1] contains the same elements as originally in positions 1..j11..j-1, but in sorted order.

      Initialisation (j=2j=2): A[1..1]A[1..1] is trivially sorted (one element).

      Maintenance: The WHILE loop shifts larger elements right by one position, creating space for A[j]A[j] at its correct sorted position within A[1..j]A[1..j]. After insertion, A[1..j]A[1..j] is sorted. The next iteration starts with j+1j+1, so A[1..(j+1)1]=A[1..j]A[1..(j+1)-1] = A[1..j] is sorted.

      Termination: When j=n+1j = n+1, the invariant says A[1..n]A[1..n] is sorted. This is exactly the desired output.

      Example: Partition Correctness

      Invariant P: At the start of each FOR loop iteration (with index jj), for any array index kk:

      1. If pkip \le k \le i, then A[k]xA[k] \le x (the pivot).
      2. If i+1kj1i+1 \le k \le j-1, then A[k]>xA[k] > x.
      3. If k=rk = r, then A[k]=xA[k] = x.

      Initialisation: i=p1i = p-1, so no kk satisfies condition 1 (vacuously true); similarly condition 2 vacuous. Condition 3 holds because x=A[r]x = A[r].

      Maintenance: Case split on whether A[j]xA[j] \le x or A[j]>xA[j] > x. In both cases, incrementing ii and swapping maintains the three regions.

      Termination: j=rj = r, so the unprocessed region is empty. The final swap of A[i+1]A[i+1] with A[r]A[r] places the pivot correctly, satisfying the post-condition: all elements left of the pivot are \le pivot, all right of pivot are >> pivot.

      Pitfalls in Loop Invariant Proofs

      1. Vague invariants: “the array is sorted so far” is too vague. Must specify which subarray, what “sorted” means, and the relationship to the original input.
      2. Missing the induction variable: must specify what the induction is over (the loop counter jj, the number of iterations, etc.).
      3. Invariant that is true but useless: e.g. "1=11 = 1" holds throughout but proves nothing.
      4. Forgetting the termination condition: the invariant must combine with the loop exit condition to yield correctness.

      Along-a-Path Induction

      Used in graph and tree algorithms where correctness propagates along a path or from root to leaves.

      Structure: Prove a property P(v)P(v) for vertices on a shortest path (or along a tree traversal), showing:

      • Base: PP holds for the start vertex (or root).
      • Inductive step: If PP holds for a vertex uu on the path, and the algorithm processes uu‘s successor vv next, then PP holds for vv after processing.

      Example (BFS shortest paths) used in Algorithms II but with relevance to Algorithms I proofs: prove that when BFS first discovers a vertex at distance dd, the discovered path is indeed a shortest path.

      Proving Height Bounds for Balanced Trees

      B-Tree Height

      Claim: For a B-tree with minimum degree tt and nn keys, hlogt((n+1)/2)h \le \log_t((n+1)/2).

      Proof: Count minimum keys per level. Root: 1\ge 1 key. Level 1: 2(t1)\ge 2(t-1) keys (minimum t1t-1 keys in each of 2 nodes). Level 2: 2t(t1)\ge 2t(t-1) keys. Level ii: 2ti1(t1)\ge 2t^{i-1}(t-1) keys. Summing the geometric series for h+1h+1 levels (root through deepest internal nodes) and rearranging gives the bound.

      Red-Black Tree Height

      Claim: h2log(n+1)h \le 2\log(n+1).

      Proof: Show the subtree rooted at any node xx has 2bh(x)1\ge 2^{bh(x)} - 1 internal nodes (by induction on the subtree height). The black-height of the root h/2\ge h/2 (at least half the nodes on any path are black by Property 4). Hence n2h/21n \ge 2^{h/2} - 1, so h2log(n+1)h \le 2\log(n+1).

      Proving Greedy Optimality (Exchange Argument)

      Structure:

      1. Assume an optimal solution SS^* exists.
      2. Show that the greedy choice can be swapped into SS^* without decreasing optimality.
      3. By induction, iteratively transforming SS^* to match greedy choices yields a greedy solution that is also optimal.

      Example: Activity selection (choose max number of non-overlapping activities). Greedy choice: pick activity with earliest finish time. Proof: if the optimal solution does not include the earliest-finishing activity, replacing its first activity with the earliest finisher cannot increase conflicts (the earliest finisher leaves the maximum remaining time for others).

      Proving Asymptotic Bounds

      From definitions: f(n)O(g(n))f(n) \in O(g(n)) means c>0,n0\exists c > 0, n_0 such that nn0,0f(n)cg(n)\forall n \ge n_0, 0 \le f(n) \le c \cdot g(n).

      Example: Show 3n2+2n+1O(n2)3n^2 + 2n + 1 \in O(n^2). Choose c=6c = 6 and n0=1n_0 = 1. For n1n \ge 1: 3n2+2n+13n2+2n2+n2=6n2=cn23n^2 + 2n + 1 \le 3n^2 + 2n^2 + n^2 = 6n^2 = c \cdot n^2.

      Example: Show 3n2+2n+1O(n)3n^2 + 2n + 1 \notin O(n). For any cc, 3n2+2n+1>cn3n^2 + 2n + 1 > cn for sufficiently large nn (since n2n^2 dominates). Use proof by contradiction: assume 3n2+2n+1cn3n^2 + 2n + 1 \le cn for large nn, divide by nn, get 3n+2+1/nc3n + 2 + 1/n \le c, which fails as nn \to \infty.

      Exam Proof Checklist

      When writing a proof in the Tripos:

      • State the inductive hypothesis clearly: what is P(n)P(n) and what is nn?
      • Specify the induction type (ordinary, strong, over program execution).
      • Cover base case(s) explicitly.
      • Show the inductive step P(k)P(k+1)P(k) \Rightarrow P(k+1) (or P(k)P(k)P(k) \Rightarrow P(k') for strong induction).
      • Conclude explicitly: “Therefore, the algorithm is correct” (or the bound holds).
      • Avoid vague language: “clearly”, “obviously”, “it follows that” without justification.

      Summary

      Proof StrategyWhen to UseExample
      Loop invariant (breakpoint)Algorithm contains a loopInsertion sort, partition, BFS
      Along-a-path inductionGraph/tree traversalBFS distances, Dijkstra (Algs II)
      Induction on tree heightBalanced tree boundsB-tree/RBT height proofs
      Exchange argumentGreedy optimalityActivity selection
      Asymptotic from definitionO/Ω/ΘO/\Omega/\Theta proofs3n2O(n2)3n^2 \in O(n^2)
      Induction variableMust be explicitjj (loop counter), nn (tree size), dd (distance)