Builds on Read-Write Pointers, which you haven’t finished yet.
Quickselect (Kth Largest Element)
nums is an integer array and k is an integer. Find the value that would sit in position k if the array were sorted from largest to smallest — this counts every occurrence of a duplicate value separately, not just distinct values, so a repeated value can fill more than one of those ranked positions.
Bonus challenge: can you find it without fully sorting the array?
6, 5, 4, 3, 2, 1. The 2nd is 5.Quickselect
- Values arrive one at a time and the k-th largest is asked after every arrival, as in Kth Largest Element in a Stream (LC 703): quickselect needs every value at once and would start over for each question. See Top-K Heap
- The values you rank are counts, which can never exceed the array's length, as in Top K Frequent Elements (LC 347): a range that small and known in advance is the signal for a pass that is linear every time, with no random pivot. See Bucket Sort
| 1 | import random |
| 2 | Â |
| 3 | Â |
| 4 | def find_kth_largest(nums: list[int], k: int) -> int: |
| 5 | """Kth Largest Element in an Array: quickselect with a random pivot and a three-way partition. |
| 6 | Time: O(N) average, O(N^2) worst case | Space: O(1) |
| 7 | """ |
| 8 | target = len(nums) - k # The k-th largest sits at index n - k once sorted ascending |
| 9 | lo, hi = 0, len(nums) - 1 # target always stays inside nums[lo..hi] |
| 10 | Â |
| 11 | while True: |
| 12 | # 1. Pick a Random Pivot: |
| 13 | # A fixed nums[hi] is always the largest value on a sorted input, so a random pick means no input can force the worst case. |
| 14 | pivot = nums[random.randint(lo, hi)] # Random pivot: no fixed input can force the worst case |
| 15 | lt, i, gt = lo, lo, hi # < pivot: nums[lo..lt-1] | == pivot: nums[lt..i-1] | > pivot: nums[gt+1..hi] |
| 16 | Â |
| 17 | # 2. Three-Way Partition Around the Pivot: |
| 18 | # Every value lands in exactly one of three blocks; a value equal to the pivot only moves i, since it already sits in the middle block. |
| 19 | while i <= gt: |
| 20 | if nums[i] < pivot: |
| 21 | nums[lt], nums[i] = nums[i], nums[lt] # Grow the smaller block |
| 22 | lt += 1 |
| 23 | i += 1 |
| 24 | elif nums[i] > pivot: |
| 25 | nums[i], nums[gt] = nums[gt], nums[i] # Grow the larger block |
| 26 | gt -= 1 # The value swapped into nums[i] is unread: i stays |
| 27 | else: |
| 28 | i += 1 # Equal to pivot: it stays in the middle block |
| 29 | Â |
| 30 | # 3. Keep Only the Side That Can Hold target: |
| 31 | # The partition put every copy of pivot exactly where sorting would, so target's rank decides which single side to keep. |
| 32 | if target < lt: |
| 33 | hi = lt - 1 # The answer is among the smaller values |
| 34 | elif target > gt: |
| 35 | lo = gt + 1 # The answer is among the larger values |
| 36 | else: |
| 37 | return pivot # nums[lt..gt] all equal pivot, and target is one of them |
target always lies in nums[lo..hi], and after each partition nums[lo..lt-1] is smaller than pivot, nums[gt+1..hi] is larger, and nums[lt..gt] holds the copies of pivot at their sorted places.Quickselect finds the value that would sit at index target = len(nums) - k if nums were sorted, without sorting it: pick a pivot, partition nums[lo..hi] into the values smaller than pivot (nums[lo..lt-1]), equal to it (nums[lt..gt]) and larger (nums[gt+1..hi]), then keep only the block that holds target. This is correct because a partition puts every copy of pivot exactly where sorting would: nothing before lt is larger and nothing after gt is smaller. So if lt <= target <= gt the answer is pivot; otherwise the answer's rank is decided inside one side alone, and hi = lt - 1 or lo = gt + 1 throws the other side away for good. The trap is the pivot choice: a fixed nums[hi] (or nums[lo]) is always the largest (or smallest) value of a sorted input, so while target lies below it each pass removes a single value: asking a sorted input for its smallest value (k = n) rescans almost the whole array n times; pivot = nums[random.randint(lo, hi)] means no input can force that.
See it run
Quickselect, step by step
target 5lo, hi 0, 8pivot 3lt, i, gt 0, 0, 8
Each box is one entry of nums, with its index above it. Entries without a tag are not read yet, or were discarded by lo and hi.
pivotthe value picked at randomsmallera value below the pivotequala copy of the pivotithe value being readlargera value above the pivot
Start: target = 5, lo = 0, hi = 8. The random pick is nums[0], so pivot = 3.
Gotcha 1 of 4
- The trap
Gotcha 1: Using the fixed pivot
nums[hi]instead of a random oneMistakepivot = nums[hi]Fixpivot = nums[random.randint(lo, hi)]What happens: On a sorted input,
nums[hi]is always the largest value. Iftargetlies below it, each pass removes only that one value. With 10^5 values andk = 105, the passes scan about 5 * 10^9 values in total. The answer stays right, so this mistake has no example. A random pivot means no input can force this.
Deep Dive
Why one partition fixes the pivot's place
Take Kth Largest Element in an Array (LC 215) with nums = [3, 2, 3, 1, 2, 4, 5, 5, 6] and k = 4. There are 9 values, so target = len(nums) - k = 5. Index 5 is where the answer would sit if nums were sorted.
The first random pick is nums[0], so pivot = 3. The three-way partition reads nums[lo..hi] once. Afterwards nums is [2, 1, 2, 3, 3, 5, 5, 6, 4]. Indexes 0 to 2 hold the values smaller than 3, indexes 3 and 4 hold the two copies of 3, and indexes 5 to 8 hold the larger values.
In the sorted array, the three values smaller than 3 come first, and the two copies of 3 follow. So the two copies of 3 sit at indexes 3 and 4 in the sorted array too. The partition placed them correctly without sorting anything else.
Now target = 5 is above gt = 4. The answer is among the larger values, so lo becomes 5. The values at indexes 0 to 4 are never read again. The second random pick is nums[8], so pivot = 4. After the partition, nums[5..5] holds the pivot, and lt = gt = 5 = target. The answer is 4. In the sorted array [1, 2, 2, 3, 3, 4, 5, 5, 6], index 5 holds 4.
Why the average cost is O(n) and the worst case is O(n^2)
A pass over nums[lo..hi] costs one read per value. The next pass works on one side only. If the pivot is close to the middle in rank, the kept side is about half of the values, and the reads add up to about n + n/2 + n/4 + ..., which is about 2n. A random pivot lands in the middle half of the ranks half of the time, so the kept side shrinks by a constant fraction every two passes on average. The total is O(n) on average.
The numbers from a test agree. On a shuffled array of 10000 values with k = 5000, the code read about 34000 values on average over 50 runs, which is about 3.4 times n. Sorting the same array would cost about n log2 n, which is about 133000 comparisons.
The worst case happens when every pivot is the smallest or the largest value of the range. With the fixed pivot nums[hi] on a sorted array and k = n, each pass removes one value. For n = 10000, the code made 10000 passes and read 50005000 values, which is exactly n * (n + 1) / 2. With a random pivot, 20 runs on the same input read about 19700 values on average, about 2n.
Randomness does not remove the worst case. It removes the input that causes it: no fixed array can make the random choices bad every time. A deterministic O(n) worst case exists (the median-of-medians pivot rule), but the constant is large, and interviews rarely ask for it.
Equal values, and what is left in nums
The partition has three blocks, not two. A value equal to pivot stays in the middle block, and lt..gt is never empty after a pass, because the pivot itself is in it. The else branch returns as soon as target falls inside nums[lt..gt].
This makes many equal values cheap. For an array of 1000 copies of 7, the first pass puts all of them in the middle block, and the loop returns after 1 pass. A two-way split that sends the equal values to the smaller side leaves the middle block empty, and the loop can never return.
The code changes nums. When it returns, nums[target:] holds the k largest values in any order. In a test of 5000 random arrays, sorted(nums[target:]) equalled the last k values of sorted(original) every time. This is why the trigger says you can also take the k items up to that rank. If the caller needs the original order, copy nums first, which costs O(n) extra space.
The median is the same question. For an odd length, the median is the k-th largest with k = (len(nums) + 1) // 2.
Quickselect or the Top-K Heap
Both answer "which values are the largest". The Top-K Heap costs O(n log k) time and O(k) space, and it works on a stream, because it needs only the next value. Quickselect costs O(n) on average and O(1) extra space, but it needs every value in memory at once and it reorders nums.
For Kth Largest Element in a Stream (LC 703), Quickselect would restart for every new number. For Top K Frequent Elements (LC 347), the values are counts, which are never bigger than len(nums). A pass of buckets does that in O(n) every time, with no random choice.
Choose Quickselect when the array is given once, fits in memory, and you want one rank or the group of values up to that rank.
60-Second Pitch
I need the k-th largest value of an array that is fully in memory, once. Sorting works in O(n log n), but I can do better.
I use Quickselect. The k-th largest is at index target = n - k in sorted order. I pick a random pivot and partition the range into smaller, equal and larger values with a three-way split. The equal block sits at its final indexes.
If target is inside the equal block, I return the pivot. Otherwise I keep only the side that holds target, and the other side is never read again.
The average time is O(n), the worst case is O(n^2), and the random pivot means no input can force it. The space is O(1), and the array is reordered. For a stream, or if I must not change the input, I use the Top-K Heap instead.
Common Questions
- Why is the average cost
O(n)and not O(n log n)? Quickselect follows one side of the pivot, where sorting follows both. The sizes shrink by a constant fraction on average, so the reads add up to a constant times
n: about 3.4 timesnin a test withk = n / 2.- Why a random pivot?
A fixed pivot such as
nums[hi]is the largest value of a sorted array, so each pass removes one value. A random pivot means no fixed input can force that. The worst case is still possible, but its chance is tiny.- Why a three-way partition?
Copies of
pivotgo to a middle block that is already in its final place. With many equal values, one pass finishes, and the loop is guaranteed to return whentargetis in that block.- Quickselect or a heap?
Quickselect is
O(n)on average andO(1)extra space, but it needs all the values and reorders them. The Top-K Heap is O(n log k), works on a stream and does not change the input.- How do you get the median or the k smallest?
For an odd length, the median is the k-th largest with
k = (len(nums) + 1) // 2. For the k-th smallest, usetarget = k - 1.
SWE Reasoning
Spot it
- The question asks for one rank of an unsorted array: the k-th largest, the k-th smallest or the median.
- The whole array is in memory, and no stream follows.
- The answer may be given as a value, or as the group of the
kvalues up to that rank in any order. - Sorting costs O(n log n), and the question hints at
O(n).
The invariant, step by step
target = len(nums) - kis the index of the answer in the sorted array.loandhistart at the ends ofnums.- A random
pivotis taken fromnums[lo..hi]. The three-way partition sorts the range into smaller values, copies ofpivotand larger values. - After the partition,
nums[lt..gt]holds the copies ofpivotat the indexes where sorting would put them. - If
targetis belowlt, the answer is among the smaller values, sohi = lt - 1. Iftargetis abovegt,lo = gt + 1. The other side is discarded for good. - If
lt <= target <= gt, the answer ispivot, and the loop returns.
Traps
- A fixed pivot
nums[hi]on a sorted array removes one value per pass, son = 10000reads 50005000 values. target = k - 1gives the k-th smallest. The k-th largest is atlen(nums) - k.- Sending values equal to
pivotto the smaller side makes the middle block empty, so the loop never returns. - Moving
iafter a swap withnums[gt]skips a value that was never read. After that swap, onlygtmoves.
Practice
Builds on Read-Write Pointers, which you haven’t finished yet.
Quickselect (Kth Largest Element)
nums is an integer array and k is an integer. Find the value that would sit in position k if the array were sorted from largest to smallest — this counts every occurrence of a duplicate value separately, not just distinct values, so a repeated value can fill more than one of those ranked positions.
Bonus challenge: can you find it without fully sorting the array?
6, 5, 4, 3, 2, 1. The 2nd is 5.Quickselect
- Values arrive one at a time and the k-th largest is asked after every arrival, as in Kth Largest Element in a Stream (LC 703): quickselect needs every value at once and would start over for each question. See Top-K Heap
- The values you rank are counts, which can never exceed the array's length, as in Top K Frequent Elements (LC 347): a range that small and known in advance is the signal for a pass that is linear every time, with no random pivot. See Bucket Sort
| 1 | import random |
| 2 | Â |
| 3 | Â |
| 4 | def find_kth_largest(nums: list[int], k: int) -> int: |
| 5 | """Kth Largest Element in an Array: quickselect with a random pivot and a three-way partition. |
| 6 | Time: O(N) average, O(N^2) worst case | Space: O(1) |
| 7 | """ |
| 8 | target = len(nums) - k # The k-th largest sits at index n - k once sorted ascending |
| 9 | lo, hi = 0, len(nums) - 1 # target always stays inside nums[lo..hi] |
| 10 | Â |
| 11 | while True: |
| 12 | # 1. Pick a Random Pivot: |
| 13 | # A fixed nums[hi] is always the largest value on a sorted input, so a random pick means no input can force the worst case. |
| 14 | pivot = nums[random.randint(lo, hi)] # Random pivot: no fixed input can force the worst case |
| 15 | lt, i, gt = lo, lo, hi # < pivot: nums[lo..lt-1] | == pivot: nums[lt..i-1] | > pivot: nums[gt+1..hi] |
| 16 | Â |
| 17 | # 2. Three-Way Partition Around the Pivot: |
| 18 | # Every value lands in exactly one of three blocks; a value equal to the pivot only moves i, since it already sits in the middle block. |
| 19 | while i <= gt: |
| 20 | if nums[i] < pivot: |
| 21 | nums[lt], nums[i] = nums[i], nums[lt] # Grow the smaller block |
| 22 | lt += 1 |
| 23 | i += 1 |
| 24 | elif nums[i] > pivot: |
| 25 | nums[i], nums[gt] = nums[gt], nums[i] # Grow the larger block |
| 26 | gt -= 1 # The value swapped into nums[i] is unread: i stays |
| 27 | else: |
| 28 | i += 1 # Equal to pivot: it stays in the middle block |
| 29 | Â |
| 30 | # 3. Keep Only the Side That Can Hold target: |
| 31 | # The partition put every copy of pivot exactly where sorting would, so target's rank decides which single side to keep. |
| 32 | if target < lt: |
| 33 | hi = lt - 1 # The answer is among the smaller values |
| 34 | elif target > gt: |
| 35 | lo = gt + 1 # The answer is among the larger values |
| 36 | else: |
| 37 | return pivot # nums[lt..gt] all equal pivot, and target is one of them |
target always lies in nums[lo..hi], and after each partition nums[lo..lt-1] is smaller than pivot, nums[gt+1..hi] is larger, and nums[lt..gt] holds the copies of pivot at their sorted places.Quickselect finds the value that would sit at index target = len(nums) - k if nums were sorted, without sorting it: pick a pivot, partition nums[lo..hi] into the values smaller than pivot (nums[lo..lt-1]), equal to it (nums[lt..gt]) and larger (nums[gt+1..hi]), then keep only the block that holds target. This is correct because a partition puts every copy of pivot exactly where sorting would: nothing before lt is larger and nothing after gt is smaller. So if lt <= target <= gt the answer is pivot; otherwise the answer's rank is decided inside one side alone, and hi = lt - 1 or lo = gt + 1 throws the other side away for good. The trap is the pivot choice: a fixed nums[hi] (or nums[lo]) is always the largest (or smallest) value of a sorted input, so while target lies below it each pass removes a single value: asking a sorted input for its smallest value (k = n) rescans almost the whole array n times; pivot = nums[random.randint(lo, hi)] means no input can force that.
See it run
Quickselect, step by step
target 5lo, hi 0, 8pivot 3lt, i, gt 0, 0, 8
Each box is one entry of nums, with its index above it. Entries without a tag are not read yet, or were discarded by lo and hi.
pivotthe value picked at randomsmallera value below the pivotequala copy of the pivotithe value being readlargera value above the pivot
Start: target = 5, lo = 0, hi = 8. The random pick is nums[0], so pivot = 3.
Gotcha 1 of 4
- The trap
Gotcha 1: Using the fixed pivot
nums[hi]instead of a random oneMistakepivot = nums[hi]Fixpivot = nums[random.randint(lo, hi)]What happens: On a sorted input,
nums[hi]is always the largest value. Iftargetlies below it, each pass removes only that one value. With 10^5 values andk = 105, the passes scan about 5 * 10^9 values in total. The answer stays right, so this mistake has no example. A random pivot means no input can force this.
Deep Dive
Why one partition fixes the pivot's place
Take Kth Largest Element in an Array (LC 215) with nums = [3, 2, 3, 1, 2, 4, 5, 5, 6] and k = 4. There are 9 values, so target = len(nums) - k = 5. Index 5 is where the answer would sit if nums were sorted.
The first random pick is nums[0], so pivot = 3. The three-way partition reads nums[lo..hi] once. Afterwards nums is [2, 1, 2, 3, 3, 5, 5, 6, 4]. Indexes 0 to 2 hold the values smaller than 3, indexes 3 and 4 hold the two copies of 3, and indexes 5 to 8 hold the larger values.
In the sorted array, the three values smaller than 3 come first, and the two copies of 3 follow. So the two copies of 3 sit at indexes 3 and 4 in the sorted array too. The partition placed them correctly without sorting anything else.
Now target = 5 is above gt = 4. The answer is among the larger values, so lo becomes 5. The values at indexes 0 to 4 are never read again. The second random pick is nums[8], so pivot = 4. After the partition, nums[5..5] holds the pivot, and lt = gt = 5 = target. The answer is 4. In the sorted array [1, 2, 2, 3, 3, 4, 5, 5, 6], index 5 holds 4.
Why the average cost is O(n) and the worst case is O(n^2)
A pass over nums[lo..hi] costs one read per value. The next pass works on one side only. If the pivot is close to the middle in rank, the kept side is about half of the values, and the reads add up to about n + n/2 + n/4 + ..., which is about 2n. A random pivot lands in the middle half of the ranks half of the time, so the kept side shrinks by a constant fraction every two passes on average. The total is O(n) on average.
The numbers from a test agree. On a shuffled array of 10000 values with k = 5000, the code read about 34000 values on average over 50 runs, which is about 3.4 times n. Sorting the same array would cost about n log2 n, which is about 133000 comparisons.
The worst case happens when every pivot is the smallest or the largest value of the range. With the fixed pivot nums[hi] on a sorted array and k = n, each pass removes one value. For n = 10000, the code made 10000 passes and read 50005000 values, which is exactly n * (n + 1) / 2. With a random pivot, 20 runs on the same input read about 19700 values on average, about 2n.
Randomness does not remove the worst case. It removes the input that causes it: no fixed array can make the random choices bad every time. A deterministic O(n) worst case exists (the median-of-medians pivot rule), but the constant is large, and interviews rarely ask for it.
Equal values, and what is left in nums
The partition has three blocks, not two. A value equal to pivot stays in the middle block, and lt..gt is never empty after a pass, because the pivot itself is in it. The else branch returns as soon as target falls inside nums[lt..gt].
This makes many equal values cheap. For an array of 1000 copies of 7, the first pass puts all of them in the middle block, and the loop returns after 1 pass. A two-way split that sends the equal values to the smaller side leaves the middle block empty, and the loop can never return.
The code changes nums. When it returns, nums[target:] holds the k largest values in any order. In a test of 5000 random arrays, sorted(nums[target:]) equalled the last k values of sorted(original) every time. This is why the trigger says you can also take the k items up to that rank. If the caller needs the original order, copy nums first, which costs O(n) extra space.
The median is the same question. For an odd length, the median is the k-th largest with k = (len(nums) + 1) // 2.
Quickselect or the Top-K Heap
Both answer "which values are the largest". The Top-K Heap costs O(n log k) time and O(k) space, and it works on a stream, because it needs only the next value. Quickselect costs O(n) on average and O(1) extra space, but it needs every value in memory at once and it reorders nums.
For Kth Largest Element in a Stream (LC 703), Quickselect would restart for every new number. For Top K Frequent Elements (LC 347), the values are counts, which are never bigger than len(nums). A pass of buckets does that in O(n) every time, with no random choice.
Choose Quickselect when the array is given once, fits in memory, and you want one rank or the group of values up to that rank.
60-Second Pitch
I need the k-th largest value of an array that is fully in memory, once. Sorting works in O(n log n), but I can do better.
I use Quickselect. The k-th largest is at index target = n - k in sorted order. I pick a random pivot and partition the range into smaller, equal and larger values with a three-way split. The equal block sits at its final indexes.
If target is inside the equal block, I return the pivot. Otherwise I keep only the side that holds target, and the other side is never read again.
The average time is O(n), the worst case is O(n^2), and the random pivot means no input can force it. The space is O(1), and the array is reordered. For a stream, or if I must not change the input, I use the Top-K Heap instead.
Common Questions
- Why is the average cost
O(n)and not O(n log n)? Quickselect follows one side of the pivot, where sorting follows both. The sizes shrink by a constant fraction on average, so the reads add up to a constant times
n: about 3.4 timesnin a test withk = n / 2.- Why a random pivot?
A fixed pivot such as
nums[hi]is the largest value of a sorted array, so each pass removes one value. A random pivot means no fixed input can force that. The worst case is still possible, but its chance is tiny.- Why a three-way partition?
Copies of
pivotgo to a middle block that is already in its final place. With many equal values, one pass finishes, and the loop is guaranteed to return whentargetis in that block.- Quickselect or a heap?
Quickselect is
O(n)on average andO(1)extra space, but it needs all the values and reorders them. The Top-K Heap is O(n log k), works on a stream and does not change the input.- How do you get the median or the k smallest?
For an odd length, the median is the k-th largest with
k = (len(nums) + 1) // 2. For the k-th smallest, usetarget = k - 1.
SWE Reasoning
Spot it
- The question asks for one rank of an unsorted array: the k-th largest, the k-th smallest or the median.
- The whole array is in memory, and no stream follows.
- The answer may be given as a value, or as the group of the
kvalues up to that rank in any order. - Sorting costs O(n log n), and the question hints at
O(n).
The invariant, step by step
target = len(nums) - kis the index of the answer in the sorted array.loandhistart at the ends ofnums.- A random
pivotis taken fromnums[lo..hi]. The three-way partition sorts the range into smaller values, copies ofpivotand larger values. - After the partition,
nums[lt..gt]holds the copies ofpivotat the indexes where sorting would put them. - If
targetis belowlt, the answer is among the smaller values, sohi = lt - 1. Iftargetis abovegt,lo = gt + 1. The other side is discarded for good. - If
lt <= target <= gt, the answer ispivot, and the loop returns.
Traps
- A fixed pivot
nums[hi]on a sorted array removes one value per pass, son = 10000reads 50005000 values. target = k - 1gives the k-th smallest. The k-th largest is atlen(nums) - k.- Sending values equal to
pivotto the smaller side makes the middle block empty, so the loop never returns. - Moving
iafter a swap withnums[gt]skips a value that was never read. After that swap, onlygtmoves.