Quickselect and Nth Element
To find the kkth largest element you do not need the array sorted — you
need one element in the right place. Sorting delivers nn answers when
you asked for one, and charges for the privilege.
Quickselect is quicksort that recurses into only the side containing the answer. Each partition discards a fraction of the array instead of processing both halves, and the geometric series sums to — so the average cost is .
The judgement this page builds is knowing when quickselect is genuinely the right answer, because often it is not.
What you’ll learn
- Partitioning, and why quickselect recurses once where quicksort recurses twice.
- Why average and worst case, and the one line that makes the worst case vanish in practice.
- Three-way partitioning, and why it matters when values repeat.
- The honest comparison against heaps,
sortedsorted, and bucket sort — and when each wins. - Three real LeetCode problems solved in the browser: 215, 973, 347.
The cue
How it works
Partition around a pivot so that everything smaller sits left and everything larger sits right. The pivot is now at its final sorted position. Compare that position with the index you want:
- equal — done, no recursion at all;
- target is smaller — recurse left only;
- target is larger — recurse right only.
import random
def quickselect(nums, k):
"""Return the kth SMALLEST element (k is 0-indexed). Mutates nums."""
lo, hi = 0, len(nums) - 1
while True:
if lo == hi:
return nums[lo]
# random pivot -- this is what avoids the O(n^2) worst case in practice
pivot_index = random.randint(lo, hi)
nums[pivot_index], nums[hi] = nums[hi], nums[pivot_index]
pivot = nums[hi]
store = lo # Lomuto partition
for i in range(lo, hi):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[hi] = nums[hi], nums[store]
if k == store:
return nums[store]
elif k < store:
hi = store - 1 # recurse LEFT only
else:
lo = store + 1 # recurse RIGHT only
print(quickselect([3, 2, 1, 5, 6, 4], 4)) # 5 -- 5th smallest = 2nd largestimport random
def quickselect(nums, k):
"""Return the kth SMALLEST element (k is 0-indexed). Mutates nums."""
lo, hi = 0, len(nums) - 1
while True:
if lo == hi:
return nums[lo]
# random pivot -- this is what avoids the O(n^2) worst case in practice
pivot_index = random.randint(lo, hi)
nums[pivot_index], nums[hi] = nums[hi], nums[pivot_index]
pivot = nums[hi]
store = lo # Lomuto partition
for i in range(lo, hi):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[hi] = nums[hi], nums[store]
if k == store:
return nums[store]
elif k < store:
hi = store - 1 # recurse LEFT only
else:
lo = store + 1 # recurse RIGHT only
print(quickselect([3, 2, 1, 5, 6, 4], 4)) # 5 -- 5th smallest = 2nd largestThe loop is the tail-recursion written out, which keeps space at instead of of stack frames.
Complexity, honestly
| Approach | Time | Space | Notes |
|---|---|---|---|
sorted(nums)[k]sorted(nums)[k] | One line; often fast enough and the right first answer | ||
| Quickselect | average, worst | Mutates the input; unordered output | |
Size-kk heap | Best when kk is small or data streams | ||
heapq.nsmallest(k, ...)heapq.nsmallest(k, ...) | Stdlib, sorted output, no mutation | ||
| Bucket / counting sort | Only when the value range is bounded |
Three-way partitioning
With many duplicate values, two-way partitioning wastes work: equal elements
get shuffled repeatedly without shrinking the problem. A three-way (Dutch
national flag) partition splits into < pivot< pivot, == pivot== pivot, > pivot> pivot, and
the entire equal block is finished in one pass.
import random
def kth_smallest(nums, k):
def select(lo, hi):
pivot = nums[random.randint(lo, hi)]
lt, i, gt = lo, lo, hi
while i <= gt: # Dutch national flag
if nums[i] < pivot:
nums[lt], nums[i] = nums[i], nums[lt]
lt += 1
i += 1
elif nums[i] > pivot:
nums[i], nums[gt] = nums[gt], nums[i]
gt -= 1 # do NOT advance i here
else:
i += 1
# now: [lo, lt) < pivot, [lt, gt] == pivot, (gt, hi] > pivot
if k < lt:
return select(lo, lt - 1)
if k > gt:
return select(gt + 1, hi)
return nums[k] # inside the equal block
return select(0, len(nums) - 1)
print(kth_smallest([7, 7, 7, 7, 7], 2)) # 7 -- one partition finishes itimport random
def kth_smallest(nums, k):
def select(lo, hi):
pivot = nums[random.randint(lo, hi)]
lt, i, gt = lo, lo, hi
while i <= gt: # Dutch national flag
if nums[i] < pivot:
nums[lt], nums[i] = nums[i], nums[lt]
lt += 1
i += 1
elif nums[i] > pivot:
nums[i], nums[gt] = nums[gt], nums[i]
gt -= 1 # do NOT advance i here
else:
i += 1
# now: [lo, lt) < pivot, [lt, gt] == pivot, (gt, hi] > pivot
if k < lt:
return select(lo, lt - 1)
if k > gt:
return select(gt + 1, hi)
return nums[k] # inside the equal block
return select(0, len(nums) - 1)
print(kth_smallest([7, 7, 7, 7, 7], 2)) # 7 -- one partition finishes itOn [7,7,7,7,7][7,7,7,7,7] the first partition puts everything in the equal block and
returns immediately. Two-way partitioning would recurse times on the
same input.
Note the asymmetry: when swapping from the gtgt end you must not advance
ii, because the value swapped in is unexamined. Advancing it there is the
classic Dutch-flag bug.
Practice — real LeetCode problems
LC 215 — Kth Largest Element in an Array · Medium
Problem. Given an integer array numsnums and an integer kk, return the
kkth largest element. Note this is the kth largest in sorted order, not
the kth distinct element.
Constraints. 1 <= k <= len(nums) <= 10^51 <= k <= len(nums) <= 10^5,
-10^4 <= nums[i] <= 10^4-10^4 <= nums[i] <= 10^4. Can you solve it without sorting?
Examples. nums = [3,2,1,5,6,4], k = 2nums = [3,2,1,5,6,4], k = 2 gives 55 ·
nums = [3,2,3,1,2,4,5,5,6], k = 4nums = [3,2,3,1,2,4,5,5,6], k = 4 gives 44
Editorial — approach, complexity, follow-ups
Convert the “kth largest” into an ascending index once, at the top:
target = len(nums) - ktarget = len(nums) - k. Doing that conversion up front avoids a whole class
of off-by-one errors that come from carrying “largest” semantics through the
recursion.
Then partition and recurse into whichever side contains targettarget.
Time expected, worst case (astronomically unlikely with a random pivot). Space for the partitioning, plus expected recursion depth — convertible to by looping instead.
([7,7,7], 2)([7,7,7], 2) is the duplicate case: three-way partitioning resolves it in
one step, while a two-way version recurses repeatedly on identical values.
Two answers worth giving alongside:
heapq.nlargest(k, nums)[-1]heapq.nlargest(k, nums)[-1]— , one line. Genuinely better whenkkis small.sorted(nums)[-k]sorted(nums)[-k]— , the baseline. State it, then improve on it.
Follow-ups you should expect: “What if you cannot mutate the input?” —
copy first ( space) or use a heap. “kth distinct largest?” —
deduplicate into a set first, which changes the answer. “Streaming
(LC 703)?” — quickselect cannot; a size-kk min-heap can. “Guarantee
worst case?” — median-of-medians; name it and note the constant
factor makes it impractical.
LC 973 — K Closest Points to Origin · Medium
Problem. Given an array of pointspoints on the plane and an integer kk,
return the kk closest points to the origin, by Euclidean distance. The
answer may be returned in any order.
Constraints. 1 <= k <= len(points) <= 10^41 <= k <= len(points) <= 10^4,
-10^4 <= xi, yi <= 10^4-10^4 <= xi, yi <= 10^4.
Examples. points = [[1,3],[-2,2]], k = 1points = [[1,3],[-2,2]], k = 1 gives [[-2,2]][[-2,2]] ·
points = [[3,3],[5,-1],[-2,4]], k = 2points = [[3,3],[5,-1],[-2,4]], k = 2 gives [[3,3],[-2,4]][[3,3],[-2,4]]
Editorial — approach, complexity, follow-ups
Two observations do most of the work:
- Never compute a square root. exactly when for non-negative values, so comparing gives the same ordering with integer arithmetic — faster, and no floating-point tie-breaking weirdness.
- “Any order” is permission to be fast. If the result had to be sorted by distance you would pay regardless.
heapq.nsmallestheapq.nsmallest: time, space. Idiomatic and hard
to get wrong.
Quickselect on squared distance: average, extra space, and
returns points[:k]points[:k] after partitioning. Better asymptotically, more code,
mutates the input.
For these are close in practice. State both, note that
nsmallestnsmallest wins on clarity while quickselect wins on asymptotics, and pick
one. A max-heap of size kk (pushing and popping the farthest) is a third
answer worth mentioning — it is the one that extends to a
stream.
Follow-ups you should expect: “The points arrive as a stream?” — a
size-kk max-heap works; quickselect cannot. “Sorted by distance?” —
nsmallestnsmallest already returns sorted; after quickselect you sort just the kk.
“Closest to an arbitrary point, not the origin?” — subtract that point
first. “Ties in distance?” — the problem accepts any, so no special
handling.
LC 347 — Top K Frequent Elements · Medium
Problem. Given an integer array numsnums and an integer kk, return the
kk most frequent elements, in any order.
Constraints. 1 <= len(nums) <= 10^51 <= len(nums) <= 10^5, the answer is guaranteed unique.
Your algorithm must be better than .
Examples. nums = [1,1,1,2,2,3], k = 2nums = [1,1,1,2,2,3], k = 2 gives [1,2][1,2] ·
nums = [1], k = 1nums = [1], k = 1 gives [1][1]
Editorial — approach, complexity, follow-ups
Count frequencies in , then select the top kk counts. The interesting
part is how you select, and this is the problem where quickselect is
not the best answer.
| Method | Time | Note |
|---|---|---|
| Sort the counts | Explicitly ruled out by the constraints | |
most_common(k)most_common(k) | Uses a heap internally; clean and fast | |
Size-kk heap by hand | Same, spelled out | |
| Quickselect on counts | average | Randomised worst case |
| Bucket by frequency | worst case | Frequencies are bounded by nn |
Bucket sort wins here and is worth knowing, because the value range is
bounded: no frequency can exceed len(nums)len(nums), so you can index directly.
buckets = [[] for _ in range(len(nums) + 1)]
for value, count in Counter(nums).items():
buckets[count].append(value)
out = []
for count in range(len(buckets) - 1, 0, -1): # walk from high to low
for value in buckets[count]:
out.append(value)
if len(out) == k:
return outbuckets = [[] for _ in range(len(nums) + 1)]
for value, count in Counter(nums).items():
buckets[count].append(value)
out = []
for count in range(len(buckets) - 1, 0, -1): # walk from high to low
for value in buckets[count]:
out.append(value)
if len(out) == k:
return outThat is a genuine with no randomisation — strictly better than quickselect’s expected . Recognising a bounded range and reaching for counting/bucket techniques is the transferable lesson.
most_common(k)most_common(k) is the answer to write first: it satisfies the
better-than- requirement, is one line, and is obviously
correct. Then offer the bucket version when asked to beat .
Follow-ups you should expect: “Truly ?” — bucket by frequency. “Ties?” — the problem guarantees uniqueness; otherwise clarify the tie-break (LC 692 makes it lexicographic, which changes the comparator). “Top k frequent words (LC 692)?” — same counting, but the tie-break forces a custom sort key. “Streaming?” — counts change over time; you need a heap plus lazy deletion, or a count-min sketch for approximate answers at scale.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 215 | Kth Largest Element in an Array | Medium | The base quickselect; randomise the pivot |
| 973 | K Closest Points to Origin | Medium | Select on squared distance — never take a square root |
| 347 | Top K Frequent Elements | Medium | Bucket by frequency for a true ; the range is bounded |
| 692 | Top K Frequent Words | Medium | Lexicographic tie-break makes the comparator the hard part |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
| “Better than sorting?” | Whether you know selection exists | Quickselect: average by recursing into one side only |
| “Why ?” | Whether you can justify it | Each partition discards a constant fraction, so |
| “Worst case?” | Honesty | if pivots split badly; a random pivot makes that negligible, and median-of-medians makes it guaranteed at a bad constant |
| “Quickselect or a heap?” | Judgement | Heap when kk is small, data streams, or you must not mutate; quickselect when the whole array is in memory and kk is comparable to nn |
| “Many duplicates?” | Depth | Three-way partitioning finishes the equal block in one pass |
| “Can you avoid mutating the input?” | Care | Quickselect reorders in place; copy first or use a heap |
| “Truly worst case here?” | Recognising bounded ranges | If values (or counts) are bounded, bucket/counting sort gives with no randomisation |
Edge-case checklist
k == 1k == 1andk == len(nums)k == len(nums)— the extremes of the range.- Single element —
([1], 1)([1], 1)must work with no partitioning. - All identical values —
[7,7,7][7,7,7]; the case three-way partitioning exists for. - Already sorted, or reverse sorted — the fixed-pivot killer; proves you randomised.
- Negative values — fine for comparisons; only a problem if you assumed non-negative for bucketing.
- kth largest vs. kth distinct largest — different answers; clarify.
- Duplicates in the answer region — LC 215 counts repeats, so
[3,2,3,1,2,4,5,5,6][3,2,3,1,2,4,5,5,6]withk=4k=4gives44. - Mutating the caller’s list — a real design concern; note it or copy.
Recap
- Quickselect is quicksort that recurses into one side only, giving average instead of — because .
- Randomise the pivot. A fixed pivot is on sorted input, and sorted input is common in tests.
- Convert “kth largest” to an ascending index once, at the top.
- Three-way partitioning for duplicate-heavy data — the equal block
finishes in a single pass, and do not advance
iiwhen swapping from thegtgtend. - Quickselect is not automatically the right answer. A size-
kkheap wins for smallkk, streams, and immutable input; bucket sort wins outright when the value range is bounded (LC 347). - Selection needs the whole array in memory. For medians over a stream, use Two Heaps.
Next: Top K Elements — the heap-based view of the same family, and the patterns where a heap is the only option.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
