Problem overview
The function k_smallest receives a vector of positive integers, nums, and a number k. It returns the k smallest values, in any order. Duplicates count separately.
In the example, nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5] and k = 4. Sorted, the vector is [1, 2, 2, 3, 3, 5, 5, 6, 7, 9], so the four smallest are 1, 2, 2 and 3. The result may come in any order, so [2, 1, 3, 2] is just as correct.
The statement sets the budget: O(n log k) time and O(k) extra space.
Approach: keep the k best so far, with the worst on top
The obvious way is to sort the whole vector and take the first k elements. That costs O(n log n) time and needs a copy of the data or a mutable input. The budget asks for something that depends on k, which can be much smaller than n.
The idea is to keep a collection of the k smallest values seen so far, and to be able to find the largest of them quickly. That largest value is the one to throw out when a smaller one arrives. A max-heap does exactly this: it gives the greatest element in O(1) and inserts or removes in O(log k) when it holds at most k elements.
The loop looks at each value once:
- While the heap has fewer than
kelements, push the value. - Once it is full, compare the value with the heap's top, the largest of the kept values. If the new value is smaller, it belongs among the
ksmallest and the top does not. Remove the top and push the new value. - If the new value is not smaller, skip it.
When the loop ends, the heap holds exactly the k smallest values. Each of the n values costs at most one pop and one push, each O(log k), which gives O(n log k).
Rust's BinaryHeap is a max-heap by default, which is the one needed here, so no custom ordering is required.
Step by step
- Convert
ktousizeby shadowing it with a newlet k. It will be compared withheap.len(), which is ausize, and Rust doesn't compare different integer types. - Create the heap as a mutable
BinaryHeap<i32>withBinaryHeap::new(). - Loop over
&numswith the pattern&value. Iterating over a reference to a vector yields&i32items, and the pattern copies each number out, sovalueis a plaini32. - Fill the heap first. If
heap.len() < k, push the value. - When the heap is full, read its top.
heap.peek()returns anOptionholding a reference to the greatest element, orNonefor an empty heap. The patternSome(&top)unwraps it and copies the number intotop. - Replace the top when the new value is smaller. If
value < top, callheap.pop()to discard the largest, thenpushthe new value. The heap keeps exactlykelements. - Return the heap's contents.
heap.into_iter().collect(), as the final expression with no semicolon, builds aVec<i32>, because that is the function's return type.
The solution in Rust
use std::collections::BinaryHeap;
impl Solution {
fn k_smallest(nums: Vec<i32>, k: i32) -> Vec<i32> {
let k = k as usize;
let mut heap: BinaryHeap<i32> = BinaryHeap::new();
for &value in &nums {
if heap.len() < k {
heap.push(value);
} else if let Some(&top) = heap.peek() {
if value < top {
heap.pop();
heap.push(value);
}
}
}
heap.into_iter().collect()
}
}
The Rust details worth a closer look:
- Why copy the top out.
peek()borrows the heap. Iftopstayed a reference, the heap would remain borrowed, andheap.pop()inside theifwould not compile. WritingSome(&top)copies the number, which ends the borrow. A line such asprintln!("{:?} {:?}", h.peek(), h.pop())fails for the same reason: the compiler sayshcannot be borrowed as mutable because it is also borrowed as immutable. - Ignoring the result of
pop(). It returns anOptionwith the removed value. The call is a statement, so the value is dropped. That is what is wanted: the largest element is simply discarded. - The order of the result.
into_iter()on aBinaryHeapyields the elements in an unspecified order, not sorted. The statement accepts any order. To get them sorted, useinto_sorted_vec(), which returns ascending order. ascasts.k as usizedoesn't check the value. A negativekwould wrap to a huge number:-1i32 as usizeis18446744073709551615, and the heap would keep every element. The lesson's inputs don't have negative values fork.- Shadowing.
let k = k as usize;creates a new variable with the same name and a different type. The oldkcan't be used after it, and that is the point. else if let. It combines anelsewith a pattern test. The block runs only when the pattern matches. Here the heap is full, so withk > 0the pattern always matches. Withk = 0it is the pattern that fails on an empty heap, which is why nothing is ever pushed.
Complexity
Let n be the number of values.
Time: O(n log k). The loop runs n times. In each iteration the work is a length check, a peek and a comparison, all O(1), plus at most one pop and one push on a heap of at most k elements, each O(log k). Turning the heap into a vector at the end is O(k).
Space: O(k). The heap never holds more than k elements, and the returned vector has k elements too. The input vector isn't copied.
Edge cases and common mistakes
kis 0. Nothing is pushed and the function returns an empty vector.kis at leastnums.len(). The heap never fills up, and all values come back. Withk = 15and ten values, the result holds all ten.- Duplicates. They count separately. In the example, the result contains 2 twice, because there are two 2s among the four smallest.
- Using the wrong kind of heap. A min-heap would hold the smallest values on top and make it hard to find the one to discard. The max-heap keeps the largest of the kept values on top.
- Comparing with
<=. Replacing the top whenvalue == topgives the same values but does extra work for nothing. Strictly smaller is the right test. - Expecting sorted output. Don't compare against
[1, 2, 2, 3]directly. Sort the result first if the order matters to you.
Testing
Sort each result before reading it, since the order is unspecified.
nums = [5, 9, 3, 6, 2, 1, 3, 2, 7, 5],k = 4gives[1, 2, 2, 3].- The same
numswithk = 1gives[1]. - The same
numswithk = 0gives[]. - The same
numswithk = 15gives all ten values:[1, 2, 2, 3, 3, 5, 5, 6, 7, 9]. nums = [],k = 3gives[].
