Problem overview
The method KSmallest receives an array nums of positive integers and a count k. It returns the k smallest values of the array, in any order. Duplicates count separately: they are values, not distinct numbers.
In the example, nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5] and k = 4. Sorted, the array begins 1, 2, 2, 3, so the answer contains those four values. [1, 2, 2, 3] and [2, 1, 3, 2] are both correct.
This lesson asks for O(n log k) time and O(k) extra space. Sorting the whole array would cost O(n log n). When k is much smaller than n, the heap approach in this lesson does less work and never stores more than k numbers.
Approach: keep the k best so far, with the worst on top
Scan the array once while holding the k smallest values seen so far. For each new value there are two cases:
- If fewer than
kvalues are held, keep the new one. - Otherwise, the new value only matters if it is smaller than the largest value held. In that case, the largest one is no longer among the
ksmallest, so it leaves and the new value takes its place.
Both cases need quick access to the largest value held, and that is what a max heap provides: the greatest element is always on top, and inserting or removing costs O(log k) when the heap holds at most k items. With n values, the loop costs O(n log k).
The trace for the example with k = 4 shows the heap's contents after each interesting value:
- 5, 9, 3 and 6 fill the heap: {9, 6, 5, 3}.
- 2 is smaller than the top, 9, so 9 leaves: {6, 5, 3, 2}.
- 1 beats 6: {5, 3, 2, 1}.
- 3 beats 5: {3, 3, 2, 1}.
- 2 beats 3: {3, 2, 2, 1}.
- 7 and 5 are not smaller than the top, 3, so they are ignored.
The heap ends up holding exactly the four smallest values. The lesson lists other versions of this problem; this one stays with the max heap of size k.
Step by step
- Create the max heap. C#'s
PriorityQueue<TElement, TPriority>dequeues the element with the lowest priority first, so by default it is a min heap. Passing a comparer that reverses the order of priorities turns it into a max heap. Here the element and the priority are both anint. - Loop over
numswithforeach, naming the variablevalue. - Fill the heap first. While
Countis belowk, every value is kept.Enqueue(value, value)uses the value as both the element and its priority. - Handle a full heap. Compare
valuewithPeek(), which reads the top, the largest value kept, without removing it. - Swap when the new value is smaller.
Dequeue()removes the largest, andEnqueueadds the new value, so the heap keeps exactlykvalues. - Copy the heap into an array. After the loop, create
resultwith lengthkand fill it by callingDequeue()ktimes. The values come out from largest to smallest, which is fine because any order is accepted.
The solution in C#
class Solution {
public int[] KSmallest(int[] nums, int k) {
PriorityQueue<int, int> smallestKNums = new PriorityQueue<int, int>(Comparer<int>.Create((x, y) => y - x));
foreach (int value in nums) {
if (smallestKNums.Count < k) {
smallestKNums.Enqueue(value, value);
} else if (value < smallestKNums.Peek()) {
smallestKNums.Dequeue();
smallestKNums.Enqueue(value, value);
}
}
int[] result = new int[k];
for (int i = 0; i < k; ++i) {
result[i] = smallestKNums.Dequeue();
}
return result;
}
}
The C# details worth a closer look:
- Two type arguments.
PriorityQueue<int, int>stores elements and priorities separately.Enqueue(element, priority)takes both, whileDequeue()andPeek()return only the element. Because each value is its own priority, ordering by priority orders the values. - The comparer.
Comparer<int>.Createbuilds a comparer from a lambda. A comparison returns a negative number when the first argument should come out first. Writingy - xswaps the arguments, so a larger priority comes out first. - Overflow in
y - x. Subtraction onintwraps around silently when the result is out of range. Withx = int.MinValue + 10andy = 100,y - xwraps to a negative number instead of a large positive one. The lesson's values are small positive numbers, so the shortcut is safe here. Code that must handle anyintis safer withy.CompareTo(x). Countis a property. It has no parentheses, unlikePeek()andDequeue().- Empty queues throw. Calling
Peek()orDequeue()on an emptyPriorityQueuethrows anInvalidOperationException. - Fixed-size result.
new int[k]creates an array ofkzeros. The loop overwrites each slot, with++iandi++behaving the same way in a loop header.
Complexity
Time: O(n log k). Each of the n values costs at most one Peek and one Dequeue/Enqueue pair, each O(log k) because the heap never holds more than k items. Emptying the heap into result costs O(k log k), which is no more than O(n log k) when k is at most n.
Space: O(k). The heap holds at most k values, and the result array holds k more.
Edge cases and common mistakes
- Using a min heap by mistake. A default
PriorityQueueputs the smallest value on top. Comparing with that top would tell you nothing about which kept value to throw away. - Duplicates. With
[4, 4, 4]andk = 2, the answer is[4, 4]. The testvalue < Peek()is strict, so a value equal to the largest kept is skipped, and that is still correct because the kept set has the same values. - k equal to the array length. The heap just fills up and the result contains every value, largest first.
- k below 1 or above the array length. The code assumes
1 <= k <= nums.Length. Withk = 0, the first iteration callsPeek()on an empty queue. Withklarger than the array, the final loop callsDequeue()too many times. Both throw. - Peeking before the heap is full. The
Count < kbranch comes first, soPeek()is never called on an empty queue for validk. - Result order. The returned array is in descending order. For the example it is
[3, 2, 2, 1], which is an accepted answer.
Testing
nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5],k = 4gives[3, 2, 2, 1], which has the same values as[1, 2, 2, 3].nums = [4, 4, 4],k = 2gives[4, 4].