Sorting, Searching, and Recurrences

Algorithmics study and revision notes

Jaak Vilo

2026-08-22

Why ordering matters

Ordering pays an up-front cost to make later questions structured. A sorted array supports logarithmic search; a sorted stream can be merged in one pass; a partition can isolate one rank without sorting everything. These algorithms also introduce the central divide-and-conquer questions: how are subproblems formed, why does recombination preserve correctness, and how is work distributed across recursive levels?

Learning goals

After studying this note, you should be able to:

Prerequisites

You should understand arrays, half-open ranges, loops, recursion, probability as expectation, asymptotic notation, and arithmetic/geometric sums. The note restates the invariants and cost assumptions needed for each algorithm.

Models and definitions

A sorting input is a sequence of records with keys. A comparator must behave consistently as a total order: comparisons cannot contradict one another. Sorting rearranges records into nondecreasing key order while preserving exactly the input multiset.

A sort is stable if equal-key records retain their original relative order. It is in-place when it uses only a small amount of auxiliary storage beyond the input; state whether recursion-stack space is included. An adaptive algorithm benefits from existing order.

The comparison model learns about arbitrary keys only through comparisons. Integer-key algorithms may inspect digits or bits and therefore use a stronger model. Claims of expected time must identify whether randomness comes from the input distribution or the algorithm.

Binary search as boundary finding

For sorted A, define the lower bound of x as the smallest index jj with A[j] >= x, or nn if no such index exists.

lower_bound(A, x):
    lo = 0
    hi = length(A)
    while lo < hi:
        mid = lo + floor((hi - lo) / 2)
        if A[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo

The loop invariant is:

The unknown boundary lies in the closed set of candidate positions from lo through hi. Initially both value claims are vacuous. If A[mid] < x, positions through mid cannot be the boundary. Otherwise mid and everything after it remain on the at-least side. Each update preserves the invariant and strictly shortens hi-lo. At termination lo==hi, so the unique remaining position is the lower bound.

Membership is a separate question: x occurs exactly when the returned position satisfies lo < n and A[lo] == x. The interval length at least halves, so time is Θ(logn)\Theta(\log n) and iterative auxiliary space is Θ(1)\Theta(1).

Worked trace: a duplicate key

Let A = [2,4,4,7,9] and x = 4.

lo hi mid Decision
0 5 2 A[2] >= 4, set hi=2
0 2 1 A[1] >= 4, set hi=1
0 1 0 A[0] < 4, set lo=1

The answer is position 1, the first of the equal keys. Notice that the maintained object is an answer position, possibly nn, not an interval containing every occurrence.

Why comparison sorting needs Ω(nlogn)\Omega(n\log n)

Assume all nn keys are distinct. A deterministic comparison sort induces a binary decision tree: each internal node compares two keys and each leaf identifies one input ordering. Correctness requires at least n!n! leaves.

A binary tree of height hh has at most 2h2^h leaves, so 2hn!2^h\ge n! and

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

At least half the factors in n!n! are at least n/2n/2, hence

n!(n/2)n/2 n!\ge(n/2)^{n/2}

and therefore log2(n!)=Ω(nlogn)\log_2(n!)=\Omega(n\log n). This is a worst-case lower bound for comparison sorting, not for algorithms allowed to inspect restricted integer keys.

A baseline: insertion sort

Insertion sort maintains a sorted prefix and inserts the next record into it.

insertion_sort(A):
    for i = 1 to length(A) - 1:
        x = A[i]
        j = i
        while j > 0 and x.key < A[j-1].key:
            A[j] = A[j-1]
            j = j - 1
        A[j] = x

At the start of iteration i, A[0..i) is a sorted permutation of the first ii original records. Shifting larger records right and placing x in the gap preserves sortedness and the multiset. At the final iteration the prefix is the whole array.

Worst-case time is Θ(n2)\Theta(n^2), because a reverse-sorted input shifts 1+2++(n1)1+2+\cdots+(n-1) records. An already sorted input takes Θ(n)\Theta(n) comparisons. Using < rather than <= in the shift test makes this version stable. It is in-place and often effective for small or nearly sorted ranges.

Merge sort

Merge sort recursively sorts two halves and combines them. The merge step repeatedly takes the smaller first unconsumed record:

merge(L, R):
    i = 0; j = 0; out = empty sequence
    while i < length(L) and j < length(R):
        if L[i].key <= R[j].key:
            append L[i] to out; i = i + 1
        else:
            append R[j] to out; j = j + 1
    append the unconsumed suffix of L or R to out
    return out

The merge invariant says out is sorted, contains exactly the consumed records, and no unconsumed record is smaller than its last record. Since L and R are sorted, choosing their smaller front preserves this invariant. Taking from the left on equality preserves stability.

Correctness of merge sort follows by induction on input length. Arrays of length at most one are sorted. The induction hypothesis sorts both smaller halves, and the merge argument combines them correctly. Its recurrence is

T(n)=2T(n/2)+Θ(n)=Θ(nlogn). T(n)=2T(n/2)+\Theta(n)=\Theta(n\log n).

Standard array merging uses Θ(n)\Theta(n) auxiliary storage. Linked sequences can be merged by relinking nodes, but recursive stack and representation costs still need counting.

Randomized three-way quicksort

Quicksort partitions a range around a pivot, then recursively sorts the unequal sides. A three-way partition handles duplicate keys explicitly:

partition3(A, lo, hi, pivot):       // range [lo, hi)
    lt = lo; i = lo; gt = hi
    while i < gt:
        if A[i].key < pivot:
            swap A[lt], A[i]; lt = lt + 1; i = i + 1
        else if A[i].key > pivot:
            gt = gt - 1; swap A[i], A[gt]
        else:
            i = i + 1
    return (lt, gt)

The invariant partitions the range into < pivot, == pivot, unknown, and > pivot regions: [lo,lt), [lt,i), [i,gt), and [gt,hi). Each branch moves one unknown record to a known region. At termination the unknown region is empty. Quicksort recurses only on [lo,lt) and [gt,hi).

A pivot chosen uniformly from the current range gives expected O(nlogn)O(n\log n) time for every fixed input and expected Θ(nlogn)\Theta(n\log n) when the keys are distinct. Three-way partitioning can be faster when many keys are equal; an all-equal range is finished in Θ(n)\Theta(n) time. Repeated extreme pivots still give a Θ(n2)\Theta(n^2) worst case.

Time and recursion depth need separate safeguards. Ordinary recursive quicksort has expected O(logn)O(\log n) stack depth and worst-case Θ(n)\Theta(n) depth. Recursing on the smaller side and iterating over the larger side limits stack depth to O(logn)O(\log n) even in the worst case. Typical in-place quicksort is not stable.

Recurrences and the Master theorem

Start with a recurrence tree: label work per node, multiply by nodes per level, determine depth, and sum. The following common polynomial-gap form of the Master theorem then summarises recurrences

T(n)=aT(n/b)+f(n), T(n)=aT(n/b)+f(n),

with constants a1a\ge1, b>1b>1, comparable subproblem sizes, and a base case. Let q=logbaq=\log_ba.

  1. If f(n)=O(nqε)f(n)=O(n^{q-\varepsilon}) for some ε>0\varepsilon>0, then T(n)=Θ(nq)T(n)=\Theta(n^q).
  2. If f(n)=Θ(nq)f(n)=\Theta(n^q), then T(n)=Θ(nqlogn)T(n)=\Theta(n^q\log n).
  3. If f(n)=Ω(nq+ε)f(n)=\Omega(n^{q+\varepsilon}) and af(n/b)cf(n)af(n/b)\le cf(n) eventually for some c<1c<1, then T(n)=Θ(f(n))T(n)=\Theta(f(n)).

Examples:

The theorem does not apply directly to T(n)=T(n1)+nT(n)=T(n-1)+n, unequal irregular splits, or recurrences missing a shrinking factor. Expand or use substitution instead of forcing a case.

Sorting restricted keys

The following methods obtain stronger bounds by using structure beyond arbitrary key comparisons. For each one, identify the extra assumption before quoting its running time.

Counting sort assumes integer keys in 0..k-1. Count each key, convert counts to ending positions with prefix sums, and scan the input from right to left while placing records into an output array. The reverse scan plus prefix positions makes the method stable. Time is Θ(n+k)\Theta(n+k) and auxiliary space is Θ(n+k)\Theta(n+k). It is useful only when the key range is manageable.

LSD radix sort applies a stable inner sort from the least significant digit to the most significant. After pass ii, records are ordered by their last ii digits; stability preserves the order established by earlier passes. With dd digits and radix kk, time is Θ(d(n+k))\Theta(d(n+k)).

Bitwise MSD sorting partitions fixed-width unsigned keys by a mask, beginning with the highest bit, then recursively partitions the zero and one groups using the next bit.

Each record participates at most once per bit. For ww bits, time is O(wn)O(wn); this is linear only when ww is treated as fixed, and is O(nlogU)O(n\log U) when keys come from a universe of size UU. Signed integers and floating-point encodings require a deliberately chosen ordering transformation.

Bucket sort maps keys into ordered buckets, sorts each bucket, and concatenates. Expected linear time needs a distribution assumption that keeps total within-bucket work linear. A highly concentrated input can destroy that bound.

Selection and order statistics

The element of rank kk is the record that would occupy index kk after sorting. Quickselect uses the same three-way partition but continues only in the region containing the desired rank:

quickselect(A, k):
    require 0 <= k < length(A)
    lo = 0; hi = length(A)
    while true:
        choose a uniformly random pivot from A[lo..hi)
        (lt, gt) = partition3(A, lo, hi, pivot)
        if k < lt:       hi = lt
        else if k >= gt: lo = gt
        else:            return A[k]

Partition correctness ensures that a rank outside the equal region lies in exactly one remaining side. A random pivot yields expected Θ(n)\Theta(n) time because only one side is visited. Sorting first would cost Θ(nlogn)\Theta(n\log n). Deterministic median-of-medians pivoting gives Θ(n)\Theta(n) worst-case time, but with more machinery and larger constants.

Skip lists

A skip list implements an ordered dictionary as levels of sorted linked lists. Level 0 contains every key. Independently promote each inserted key to the next level with probability 1/21/2, repeating until the first failure. Higher levels act as express lanes.

Search starts at the top-left sentinel. At each level, move right while the next key is smaller than the target; otherwise drop one level. Sortedness guarantees that moving down cannot skip the target. At level 0, the next node is either the target or its insertion position.

Insertion records the last node visited at every level, chooses a random height, and splices the new tower after those predecessors. Deletion unlinks the target at each level where it occurs. With promotion probability 1/21/2, the expected number of stored node occurrences is

n+n/2+n/4+<2n. n+n/2+n/4+\cdots<2n.

The expected height and expected search, insertion, and deletion times are O(logn)O(\log n); the worst case remains Θ(n)\Theta(n). These expectations are over the promotion choices. Skip lists trade deterministic rotation rules for a simple randomized representation.

Complexity and trade-offs

Method Expected/best information Worst-case time Auxiliary space Stable?
Insertion sort Θ(n)\Theta(n) when sorted Θ(n2)\Theta(n^2) Θ(1)\Theta(1) yes
Merge sort Θ(nlogn)\Theta(n\log n) Θ(nlogn)\Theta(n\log n) Θ(n)\Theta(n) for arrays yes
Randomized three-way quicksort expected O(nlogn)O(n\log n); Θ(n)\Theta(n) when all keys are equal Θ(n2)\Theta(n^2) ordinary recursion: expected O(logn)O(\log n) stack, worst Θ(n)\Theta(n) usually no
Counting sort Θ(n+k)\Theta(n+k) Θ(n+k)\Theta(n+k) Θ(n+k)\Theta(n+k) stable version yes
Quickselect expected Θ(n)\Theta(n) Θ(n2)\Theta(n^2) implementation-dependent n/a

No row is universally best. Stability, key model, existing order, memory, worst-case guarantees, record size, and implementation environment all affect the decision.

Connections

Common mistakes

Self-check

  1. State and prove the three clauses of the lower_bound invariant.
  2. Why does lower_bound need to return nn for some inputs?
  3. Prove the insertion-sort prefix invariant.
  4. Why does taking the left record on a merge tie preserve stability?
  5. State the four-region invariant of three-way partitioning.
  6. Solve T(n)=4T(n/2)+nT(n)=4T(n/2)+n and justify the applicable Master-theorem case.
  7. Why must LSD radix sort use a stable inner sort?
  8. When is Quickselect preferable to sorting?
  9. Derive the expected linear space bound for promotion probability 1/21/2 in a skip list.

Revision summary

Implementations and hands-on exploration

Small experiments

  1. Implement lower_bound and assert all three clauses of its loop invariant at every iteration. Exhaustively compare the result with Python bisect_left on every nondecreasing array of length at most 7 over keys 0,1,2, including absent and duplicate targets.
  2. Count comparisons for insertion sort, merge sort, and seeded three-way quicksort on sorted, reversed, all-equal, and random tagged records. Before interpreting the counts, assert multiset preservation and sortedness for every result, stability for insertion and merge sort, and the four-region invariant inside partition3.

Sources and further study

References

Cormen, Thomas H., Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. 2022. Introduction to Algorithms. 4th ed. MIT Press.