skip to content

Why does quickselect degrade on millions of ratings drawn from only five distinct values?

level: seniorimportance: nice to knowfreq 30%

answer

  1. Count the distinct keys, not the elements
  2. Random pivots do not defeat ties
  3. Equal elements all land on one side
  4. Shrinking by one element per round again
  5. Make equality its own region

basics

~20 s

A two-way partition that pushes elements equal to the pivot onto one side makes a block of identical keys shrink by a single element per round, so selecting the median of millions of 1-to-5 ratings turns quadratic. A three-way partition isolates the equal run and answers immediately.

solid answer

~50 s

With only five distinct keys, almost every partition picks a pivot that a huge fraction of the data ties with. A Lomuto-style two-way scheme sends all those equal elements to one side, so the surviving range loses roughly one element per round and the cost climbs toward O(n^2) even though the pivot was chosen at random: randomisation defends against adversarial *order*, not against a tiny key domain. Hoare-style partitioning fares better because both pointers stop on equal keys and the split lands near the middle. The real fix is a three-way partition that produces `< p`, `== p` and `> p` regions. If the target index falls inside the equal run, the pivot **is** the answer and the routine returns at once; otherwise a whole value class is eliminated per round, so with five distinct ratings the search finishes in a handful of linear passes.

code

pseudocode · 15 lines
pseudocode
// partition a[lo..hi] into < p, == p, > p, then select index k
lt = lo
i = lo
gt = hi
while i <= gt:
  if a[i] < p:
    swap(a[i], a[lt]); lt = lt + 1; i = i + 1
  else if a[i] > p:
    swap(a[i], a[gt]); gt = gt - 1
  else:
    i = i + 1
// now a[lo..lt-1] < p, a[lt..gt] == p, a[gt+1..hi] > p
if k < lt:      recurse on lo..lt-1
else if k > gt: recurse on gt+1..hi
else:           return p

go deeper

for a junior

Know that many repeated keys can be as bad for partition-based selection as sorted input, and that separating elements equal to the pivot into their own region is the standard remedy.

for a middle

Explain the mechanism: a two-way scheme sends every tie to one side, so the surviving range shrinks by a constant count instead of a constant fraction, which is the arithmetic-series path to quadratic time.

for a senior

Diagnose it from evidence. Runtime scaling like the square, a profile pinned in the partition loop, and a low distinct-key count; then choose between three-way partitioning and a plain counting pass over the value domain.

for a principal

Decide how much low-cardinality defence belongs in shared code at all: whether the team maintains a specialised partition scheme, or whether analytics over small key domains should be routed to counting aggregations by policy.

## The data shape that breaks the usual reasoning A rating field with values 1 through 5, an enum-valued column, a bucketed score: these are collections of millions of elements over a domain of a handful of keys. Every intuition about pivot randomisation was built for a model where ties are rare, and it quietly fails here. Randomising the pivot protects you against an adversary who controls the *arrangement* of the data. It does nothing about a key domain so small that the pivot ties with a fifth of the collection no matter which position you draw it from. ## Why a two-way partition stalls A Lomuto-style partition walks one index forward and maintains a boundary, moving every element that compares as "not greater than the pivot" to the left region. On a range where every element equals the pivot, every element moves left, the pivot's final index lands at one end, and the surviving range shrinks by exactly one. Repeat that over a run of a million identical ratings and the summed work is the arithmetic series `n + (n-1) + ...`, which is quadratic. The failure looks identical to the sorted-input pathology, but its cause is different and the usual remedy does not apply: you cannot randomise your way out of ties. Hoare-style partitioning behaves noticeably better on the same data, because both scanning pointers stop when they meet an element equal to the pivot and swap across. Equal elements therefore end up distributed on both sides and the split lands near the middle, which restores a geometric shrink. It still does redundant work, though: it keeps re-partitioning elements it already knows are equal to a pivot it already examined. ## The three-way partition The direct fix is to make equality a first-class outcome. A three-way scheme maintains three regions in one pass: strictly less than the pivot, equal to it, and strictly greater. It ends with an explicit equal range `lt..gt`. That range changes the selection logic in two useful ways. - **Immediate termination.** If the wanted index `k` lands anywhere in `lt..gt`, the answer is the pivot value itself, and the routine returns without any further recursion. On a five-valued rating column, the median almost always lands in the largest equal run, so the routine typically finishes on the first or second pass. - **Whole-class elimination.** When `k` falls outside the equal range, the recursion drops not just one element but every occurrence of that key. Since the recursion can therefore eliminate at least one distinct value per round, the number of rounds is bounded by the number of distinct keys, and with five keys the total is a small constant number of linear passes. The cost is a slightly busier inner loop: three comparisons and two moving boundaries instead of one. On data with all-distinct keys that overhead is real and usually not worth paying, which is why three-way partitioning is a targeted choice for low-cardinality data rather than a universal default. ## Diagnosing it in production The signature is a job whose runtime is fine on a sample and catastrophic on the full dataset, scaling like the square rather than linearly, with a profile dominated by the partition loop and no obvious hot allocation. A quick check is to count distinct keys: if the cardinality is tiny relative to the element count, ties are the suspect. Two other observations help. First, the answer to a selection query over a five-valued domain is one of five values, so a counting pass over the value domain answers it in a single scan with no partitioning at all, and on a known small key range that is the simplest fix of all. Second, an equal-heavy dataset makes the *result* less interesting than the interviewer's framing suggests: when the median rating is 4, what the analyst usually wants next is the distribution, which the counting pass already produced. ## What to say out loud Name the mechanism, not just the symptom: the surviving range must shrink by a constant *fraction*, and a two-way partition over a run of equal keys shrinks it by a constant *count*. Then name the fix in order of preference for the workload: three-way partitioning when keys are few but the domain is unknown or unbounded, and a plain counting pass when the key domain is small and known in advance.

  • Why doesn't randomising the pivot rescue the low-cardinality case?
    Randomisation defends against a chosen arrangement of the data. With five distinct keys, every draw lands on a value that a large fraction of the elements tie with, so the pivot is bad regardless of which position it came from. The problem is the key domain, not the ordering.
  • In the three-way scheme, what tells the routine it can stop without recursing?
    The equal range `lt..gt` holds the final sorted positions of every element equal to the pivot. If the target index lies inside that range, the answer is the pivot value itself, so the routine returns immediately rather than searching a region whose elements are all identical.
  • When would you not use a three-way partition?
    When keys are mostly distinct. The extra comparison and second moving boundary cost real time in the innermost loop and buy nothing if equal runs are short. Three-way partitioning is a response to measured low cardinality, not a default.
  • If the key domain is small and known in advance, is partitioning even the right tool?
    Often not. Counting occurrences of each of the few possible values in one linear pass, then walking the cumulative counts to the target index, answers the query with no partitioning, no data mutation and a single scan. Partition-based selection earns its keep when the key domain is large or unknown.

saying these in an interview costs you the question

  • Blames the pathology on input order rather than ties
  • Claims random pivots make duplicates harmless
  • Treats three-way partitioning as a free default
  • Cannot say what the equal range means for the answer
  • Recurses into a region of identical keys

context