Problem overview
Numbers arrive one at a time, and after each one the program must name the k-th largest number seen so far. Equal numbers count separately: in [8, 7, 7, 5] the 2nd largest is 7, and so is the 3rd.
The lesson asks for a KthLargest struct with a Constructor(k, nums) function, which starts the object with k and the numbers that have arrived already, and an Add(val) method, which takes in one more number and returns the current k-th largest. The statement promises that at least k numbers have arrived whenever Add returns, so the answer always exists.
Take k = 3 and nums = [4, 5, 8, 2]. After Add(3) the numbers are 2, 3, 4, 5, 8. The three largest are 8, 5 and 4, so the answer is 4. After Add(5) the three largest are 8, 5, 5 and the answer is 5.
Approach: keep only the k largest
The direct way is to store every number and sort them on each Add. That costs O(n log n) per call, where n is how many numbers have arrived, and it keeps growing.
The better way starts from one observation: a number that is not among the k largest can never become the k-th largest again. New numbers only push the k-th largest up, never down. So everything below the top k can be thrown away for good, and the object only needs to hold k values.
Those k values live in a min-heap, a structure whose smallest value is always at index 0 and which adds or removes a value in O(log k). The smallest of the k largest numbers is, by definition, the k-th largest, so the answer is always waiting at the top.
When a new number arrives it is pushed in. If the heap then holds k + 1 values, the smallest of them is removed: it cannot be among the k largest. The heap never holds more than k + 1 values.
The lesson provides the minHeap type, a slice of ints that works with Go's container/heap package. Its zero value, an empty slice, is an empty heap that is ready to use.
Step by step
- Declare the field
kas anint.Addneeds it to know when the heap has grown too big. - Declare the field
largestas aminHeap, holding the k largest values with the smallest on top, atlargest[0]. - In
Constructor, create the struct withKthLargest{k: k}. The field left out,largest, starts as an empty heap. - Loop over
numswithrange, skipping the index with_. The numbers that arrived before the object existed count like any others, and they go through the same filter. - Call
kthLargest.Add(num)in the loop. ReusingAddavoids repeating the push-and-trim logic. Its return value is ignored because nobody has asked yet. Go passes the address ofkthLargestto the pointer method automatically, so the call changes this very struct. - Return
kthLargestafter the loop. - In
Add, pushvalwithheap.Pushand the pointer&this.largest. The pointer is needed because pushing grows the slice. - Trim the heap. If
Len()is now abovethis.k, callheap.Pop(&this.largest)to remove the smallest value. - Return
this.largest[0], the top of the heap.
The solution in Go
type KthLargest struct {
k int
largest minHeap
}
func Constructor(k int, nums []int) KthLargest {
kthLargest := KthLargest{k: k}
for _, num := range nums {
kthLargest.Add(num)
}
return kthLargest
}
func (this *KthLargest) Add(val int) int {
heap.Push(&this.largest, val)
if this.largest.Len() > this.k {
heap.Pop(&this.largest)
}
return this.largest[0]
}
A few Go details worth a closer look:
- Pointer receivers.
Addchangesthis.largest, so it needs the receiver*KthLargest. InConstructor,kthLargestis an addressable local variable, sokthLargest.Add(num)is shorthand for(&kthLargest).Add(num). - Returning the struct by value.
Constructorreturns a copy ofkthLargest. The copy shares the heap's backing array with the local variable, which is harmless because the local variable is never used again. heap.Pushandheap.Pop. They take a pointer to a value that implementsheap.Interfaceand keep the order: after either call, index 0 holds the smallest value. Readingthis.largest[0]directly removes nothing.- The heap's own slice. A
minHeapis a slice, so the heap's contents can be printed withfmt.Println. The printed order is the heap's internal order, not a sorted order. Only index 0 is guaranteed to be the smallest.
Complexity
Time: O(log k) per Add. The heap holds at most k + 1 values, so one push and one pop each take O(log k). Reading the top is O(1). The constructor makes one Add call for each number in nums, so it takes O(m log k) for m starting numbers.
Space: O(k). Only the k largest values are kept, however many numbers arrive.
Edge cases and common mistakes
- Equal values. With
k = 4andnums = [7, 7, 7, 7, 8, 3], the callsAdd(2),Add(10),Add(9),Add(9)return 7, 7, 7, 8. The heap keeps duplicates as separate values, so the second 9 pushes the last 7 out. k = 1. The heap holds a single value, the largest so far. Withk = 1and no starting numbers,Add(-3),Add(-2),Add(-4),Add(0),Add(4)return -3, -2, -2, 0, 4.- Using a max-heap. A max-heap puts the largest value on top, which is the wrong end: the k-th largest is the smallest of the kept values.
- Forgetting the starting numbers. They must be fed through
Addtoo. Skipping them leaves the heap short of values. - Trimming with
ifversusfor. Oneifis enough because each call adds exactly one value, so the heap is over by at most one. - Passing the heap without
&.heap.Push(this.largest, val)does not compile, because the methods that grow the slice are on the pointer type.
Testing
Using Constructor(3, []int{4, 5, 8, 2}), the calls Add(3), Add(5), Add(10), Add(9), Add(4) return 4, 5, 5, 8, 8. Right after the constructor, the heap holds [4 5 8]: the 2 was pushed out, and the heap's top, 4, is the 3rd largest. After each of those five calls it still holds exactly 3 values.
After 100,000 calls on an object made with k = 5, the heap holds 5 values, so the memory used does not grow with the stream.
