Problem overview
The method KSmallest receives an int[] named nums and an integer k. It must return the k smallest values as an int[], in any order. For nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5] and k = 4, the answer is the values 1, 2, 2 and 3. Duplicates count separately, which is why 2 appears twice. The arrays [1, 2, 2, 3] and [2, 1, 3, 2] are both correct.
The lesson asks for O(n) time and O(log n) extra space. Sorting the whole array would take O(n log n), which is more than needed: the answer doesn't have to be ordered, so most of that work is wasted.
Approach: partition, then keep only the side that matters
This is the Quickselect idea, a cousin of Quick Sort. Partition the array around a random pivot so that every value less than or equal to the pivot comes before it and every larger value comes after it. The pivot lands at its final sorted position, pivot. That tells us exactly how many values are at most the pivot: pivot - left + 1 of them, counting the pivot.
- If that count is at least
k, allksmallest values are inside[left, pivot]. The right side is irrelevant, so recurse on[left, pivot - 1]. - If the count is less than
k, everything in[left, pivot]belongs to the answer. Only the remainingk - (pivot - left + 1)values are still missing, and they are the smallest of[pivot + 1, right].
Quick Sort recurses into both sides. Quickselect recurses into only one, so the work shrinks geometrically: about n + n/2 + n/4 + ..., which is O(n) on average with a random pivot. When the search ends, the first k positions of the array hold the k smallest values, in no particular order.
Take [3, 1, 2] with k = 2 and suppose the pivot is 3. Everything is at most 3, so the pivot ends at index 2 and the count is 3, which is at least 2. The search continues in [0, 1], where the remaining work puts 1 and 2 first. The first two positions then hold 1 and 2.
Step by step
- Declare
SmallestKNums. It takes the array, the inclusive boundsleftandrightof the range it works on, andk. It returns nothing and rearrangesnumsin place. - Return early on tiny ranges. If
left >= right, there is at most one value and nothing to do. - Declare
Partitionabove it, returning the pivot's final index as anint. - Pick a random pivot.
randomIndexisleftplus a random offset from 0 toright - left. A random pivot keeps the expected time linear even when the input is already sorted. - Declare
Swap, a small helper that exchanges two elements through a temporary variable. - Move the pivot to the end of the range with
Swap, so the scan can partition everything else first. - Record the pivot value and the boundary.
valueis the pivot, andcorrectIndexstarts atleft: the position where the next value that is not larger than the pivot belongs. - Scan from
lefttoright, both included. The pivot is visited last. - Swap small values into place. If
nums[i] <= value, swap it withnums[correctIndex]and advancecorrectIndex. - Return
correctIndex - 1. The pivot was the last value moved, so it sits just beforecorrectIndex. - Partition in
SmallestKNums. After the early return, store the result ofPartitioninpivot. - Count at least
k? Recurse on[left, pivot - 1]with the samek. The pivot is the largest value of the range up topivot, so it can stay where it is. - Otherwise, recurse on
[pivot + 1, right]withk - (pivot - left + 1). - In
KSmallest, callSmallestKNumson the whole array, from 0 tonums.Length - 1. - Return the first
kvalues as a new array.
The solution in C#
class Solution {
public void Swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
public int Partition(int[] nums, int left, int right) {
int randomIndex = left + (int)(new Random().NextDouble() * (right - left + 1));
Swap(nums, randomIndex, right);
int value = nums[right];
int correctIndex = left;
for (int i = left; i <= right; ++i) {
if (nums[i] <= value) {
Swap(nums, i, correctIndex++);
}
}
return correctIndex - 1;
}
public void SmallestKNums(int[] nums, int left, int right, int k) {
if (left >= right) {
return;
}
int pivot = Partition(nums, left, right);
if (pivot - left + 1 >= k) {
SmallestKNums(nums, left, pivot - 1, k);
} else {
SmallestKNums(nums, pivot + 1, right, k - (pivot - left + 1));
}
}
public int[] KSmallest(int[] nums, int k) {
SmallestKNums(nums, 0, nums.Length - 1, k);
return nums.Take(k).ToArray();
}
}
The C# details worth a closer look:
correctIndex++as an argument. Post-increment passes the current value and then adds one.Swap(nums, i, correctIndex++)therefore does the same as swapping withcorrectIndexand incrementing on the next line.- LINQ's
TakeandToArray.Take(k)is lazy: it describes the firstkelements without copying them.ToArray()runs it and builds theint[]the method returns. These extension methods come fromSystem.Linq. Ifkis larger than the array,Takesimply stops at the end instead of throwing. - The cast in the random index.
NextDouble()returns a value from 0 up to but not including 1. Scaling by the range size and casting with(int)truncates the fraction.Random.Shared.Next(left, right + 1)would give the same kind of index, with an exclusive upper bound. - The input array is changed.
numsis partly rearranged, because arrays are passed by reference. Only the returned copy holds the answer.
Complexity
Time: O(n) expected. The first partition costs about n steps. With a random pivot, the range that remains is, on average, a constant fraction of the previous one, so the costs add up to a geometric series that stays proportional to n. Copying the final k values adds O(k). Unlucky pivots can make a single run slower, and the absolute worst case is O(n²), but randomness makes that very unlikely.
Space: O(log n) expected for the recursion stack, plus the O(k) result array that the method has to return anyway.
Edge cases and common mistakes
kequal to the array length. Every value belongs to the answer, so however the search rearranges the array,Take(k)returns all of its values.k = 0. The count is always at least 0, so the search shrinks to the left each time andTake(0)returns an empty array.- Duplicates. The partition test is
<=, so equal values stay on the small side. The answer for the example contains the value 2 twice, as it should. - Forgetting to subtract in the else branch. After keeping
pivot - left + 1values, onlykminus that many are still missing. Passing the originalkwould ask for too many. - Expecting sorted output. Only the first
kpositions are guaranteed to hold the right values. Sort the returned array if you need them in order. - Recursing into both sides. That turns the algorithm back into Quick Sort and loses the linear running time.
Testing
nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5],k = 4returns 1, 2, 2 and 3 in some order.nums = [3, 1, 2],k = 0returns an empty array.nums = [3, 1, 2],k = 3returns all three values.