Problem overview
The function kSmallest(nums, k) receives a slice of positive integers and a number k, and returns the k smallest values in any order. The statement asks for O(n) time and O(log n) extra space.
For nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5] and k = 4 the answer contains 1, 2, 2 and 3. A result such as [2, 1, 3, 2] is just as valid as [1, 2, 2, 3].
Approach: partition, then keep only the side that matters
Sorting the whole slice and taking the first k values costs O(n log n). The problem allows any order, which means a full sort does more work than needed.
The idea comes from Quick Sort. A partition picks a pivot and rearranges the range so that smaller-or-equal values come before the pivot and larger values after it, and it tells us the pivot's final index. If the pivot's index is pivot, the range from left to pivot holds exactly the pivot - left + 1 smallest values of the range. Compare that count with k:
- If the count is at least
k, allksmallest values lie on the left. Continue there, with the samek. - If the count is less than
k, then everything up to the pivot belongs to the answer. Continue on the right and look for the remainingk - (pivot - left + 1)values.
Unlike Quick Sort, only one side is processed. With random pivots the range shrinks geometrically on average, so the work is n + n/2 + n/4 + ..., which adds up to O(n) expected time. This technique is called quickselect.
Take the example with k = 4. Suppose the first pivot is the last element, 5. The partition leaves [5, 3, 2, 1, 3, 2, 5, 6, 7, 9] and returns index 6. The count is 7, which is at least 4, so the search continues in indices 0 to 5. Every later step works inside that range, and when the recursion ends the first four positions hold some arrangement of 1, 2, 2 and 3.
Step by step
- Declare
smallestKNumsabovekSmallest. It takesnums []intandleft, right, k int, returns nothing, and rearrangesnumsin place so theksmallest values of the range come first. - Base case. If
left >= right, there is at most one value and nothing to do. - Declare
partitionForSmallestwithnums,leftandright, returning the pivot's final index. - Choose a random pivot with
rand.Intn(right - left + 1)added toleft. Random pivots keep the expected time linear, even on sorted input. - Move it to the end with a tuple assignment.
- Set up
value(the pivot) andcorrectIndex(starting atleft) in one:=statement. - Scan from
lefttoright, includingright. - Move small values to the front. If
nums[i] <= value, swap it withnums[correctIndex]and increasecorrectIndex. In Go,++is a statement, so it goes on its own line. - Return
correctIndex - 1, where the pivot ended up. - Use the partition. Back in
smallestKNums, store the returned index inpivot. - If the count
pivot - left + 1is at leastk, recurse onlefttopivot - 1with the samek. - Otherwise recurse on
pivot + 1torightwithk - (pivot - left + 1). - Call it from
kSmallestover the whole slice, from 0 tolen(nums) - 1. - Return the first
kelements asnums[:k].
The solution in Go
func smallestKNums(nums []int, left, right, k int) {
if left >= right {
return
}
pivot := partitionForSmallest(nums, left, right)
if pivot-left+1 >= k {
smallestKNums(nums, left, pivot-1, k)
} else {
smallestKNums(nums, pivot+1, right, k-(pivot-left+1))
}
}
func partitionForSmallest(nums []int, left, right int) int {
randomIndex := left + rand.Intn(right-left+1)
nums[randomIndex], nums[right] = nums[right], nums[randomIndex]
value, correctIndex := nums[right], left
for i := left; i <= right; i++ {
if nums[i] <= value {
nums[i], nums[correctIndex] = nums[correctIndex], nums[i]
correctIndex++
}
}
return correctIndex - 1
}
func kSmallest(nums []int, k int) []int {
smallestKNums(nums, 0, len(nums)-1, k)
return nums[:k]
}
Go details worth knowing:
- Slicing doesn't copy.
nums[:k]is a new slice header over the same array, with lengthkand the capacity ofnums. The returned slice shares memory with the input. Changing its first element also changesnums, and the caller's slice has already been rearranged by the partitions. - The high bound is exclusive.
nums[:k]holds indices 0 tok-1, which is exactlykvalues. - Slice bounds are checked. If
kis larger thanlen(nums),nums[:k]panics with a slice-bounds error. The problem assumes a validk. - The import.
rand.Intnbelongs tomath/rand, which a complete program must import. - Multiple assignment.
value, correctIndex := nums[right], leftdeclares two variables at once, anda, b = b, aswaps two elements without a temporary. - Helpers in any order. The three functions can be declared in any order in the file.
Complexity
Time: expected O(n). Each call does one pass over its range and then continues on a single side. With random pivots the range shrinks by a constant factor on average, so the passes add up to a geometric series of about 2n steps. The worst case is O(n²), which random pivots make very unlikely.
Space: expected O(log n) for the recursion. No extra slices are created, because the partition rearranges the input.
Edge cases and common mistakes
kequal tolen(nums). The result is the whole slice, in some order.[3, 1, 2]withk = 3returns the values 1, 2 and 3.k = 1. The function returns a slice holding the minimum.- Duplicates. With
[4, 4, 4]andk = 2the answer is[4, 4]. Because values equal to the pivot go to its left, the recursion still finishes, but slices with many equal values make the partitions uneven and slow the search down. - Not subtracting the left part. When recursing right,
kmust shrink by the number of values already accepted. Passing the oldkwould look for too many. - Expecting sorted output. Only the first
kpositions are guaranteed to hold the right values, in no particular order. Sort the result if the order matters. - Input modified. The original slice is rearranged. Copy it first if the caller needs the order preserved.
Testing
For [5, 9, 3, 6, 2, 1, 3, 2, 7, 5] with k = 4, the result holds 1, 2, 2 and 3 in some order. For [3, 1, 2] with k = 1 it holds 1. For [4, 4, 4] with k = 2 it is [4, 4].