Min Heaps in Go (Golang)
By: Gabriel G Baciu
Tags: Golang, Software Development, Algorithms, Software Engineering, Go

Photo by lee bernd on Unsplash

Algorithmic runtime complexity is something that I’ve got to understand quite well most recently by solving LeetCode problems. The nice thing about the LeetCode platform is that it benchmarks your code against other people's, and tells you where your solutions sit in terms of runtime and memory usage. That is important because if you are competitive in nature, as I am, and want to get better at what you are doing, you oftentimes won't be satisfied with a solution that is in the middle of the pack, but perhaps would want to push yourself to further optimize your solution.

So I came across the LeetCode 759 problem, in which you are given a bunch of employee schedules and you have to figure out the finite times when everyone’s free. For example, if you have two employees in an organization and one works from 1 to 3, and the other from 4 to 6, you’d have about 1 hour, from 3 to 4, when everyone is free. If the schedules overlap, for example, 1–3 and 2–5, then you won't have any free time when everyone is available.

I immediately recognized a pattern, as I had solved a similar problem before, or so I thought at the time, which is “Merge Intervals”, LeetCode 56. Basically, my thought process was that if I merged all the overlapping intervals, I would get a list of mutually exclusive time ranges, and then I would just loop through that and collect the spaces in between. So I did that and it did solve the problem, but to my surprise, my runtime was beating only 28.57% of Go submissions, which means that 71% of people solved it better from a runtime perspective.

Here is the first solution:

type Interval struct {

Start int

End int

}



func employeeFreeTime(schedule [][]*Interval) []*Interval {



// Flatten the interval array

var flatI []*Interval



for i := 0; i < len(schedule); i++ {

flatI = append(flatI, schedule[i]...)

}



// sort the flatten list

sort.Slice(flatI, func(i, j int) bool {

return flatI[i].Start < flatI[j].Start

})



// determine if two intervals are overlapping

overlap := func(a, b *Interval) bool {

if a.Start > b.End {

return false

}

if b.Start > a.End {

return false

}

return true

}



var merged []*Interval

merged = append(merged, flatI[0])



// merge all overlapping schedules

for i := 1; i < len(flatI); i++ {

a := flatI[i]

b := merged[len(merged)-1]



if overlap(a, b) {

b.Start = int(math.Min(float64(a.Start), float64(b.Start)))

b.End = int(math.Max(float64(a.End), float64(b.End)))

} else {

merged = append(merged, a)

}

}



var solution []*Interval



// if all work on same common schedule then return

if len(merged) == 1 {



return []*Interval{}



} else {



// push in solution the delta between overlapping intervals

for i := 0; i < len(merged); i++ {



if i + 1 < len(merged) {

solution = append(solution, &Interval{

Start: merged[i].End,

End: merged[i+1].Start,

})

}

}



}



return solution

}

Although I was happy that I got to solve it fast enough, I was not pleased with the runtime rank and the fact that 71% of the people who wrote Golang provided a better solution. So I peeked at the solution, and they were suggesting to use a Heap. Both a Min or a Max Heap would work in this case.

Ok, so what is a Min Heap? A Min Heap is a binary tree-based data structure, where the values of all the children of a particular node are greater than or equal to the value of that node. In other words, the value of each node would always be less than or equal to the value of all its children.

You would use a Min Heap, for example, if you wanted to efficiently retrieve O(1) the minimum element from a list without traversing the entire list all the time O(n). The insertion operation in a Min Heap is worst-case O(log n), but after that initial expense, you always have an O(1) retrieval of the min element. The same is true for Max Heap for that matter, where you would be able to retrieve max element in O(1), constant time.

Ok, so now back to our problem: why would a Min Heap be a solution or an optimization? It’s because the optimal solution for this problem is to take two intervals at a time, with the lowest start dates (two min-heap pop operations), and see if they overlap, and if they don't, then collect the delta as free time and repeat the process.

The lines that were inefficient in my solution were:

// sort the flatten list

sort.Slice(flatI, func(i, j int) bool {

return flatI[i].Start < flatI[j].Start

})

It’s because basically I casually insert an O(n log n) sorting operation. If we used a min-heap, we would have a worst-case a (n * heap push operation) = O(n log n). Given that each employee’s time intervals are sorted in ascending order as we are getting higher and higher start numbers, the heap insert operation becomes closer to O(1) than to O(log n).

The heap insert operation becomes less expensive with each employee schedule because every time a node is inserted into a min-heap, there are a series of swap operations, i.e. if the inserted node is smaller than its parent, then the new node and its parent are swapped, and this continues up until the tree fulfills the min-heap criteria. As the calendar start values become higher, the fewer swaps we need to do, to the point that we could get close to O(1) insert time on the highest values.

Here’s the new solution:

package main



import (

"container/heap"

)



// Slice of Intervals

type IntHeap []*Interval



func (h IntHeap) Len() int {

return len(h)

} func (h IntHeap) Less(i, j int) bool {

return h[i].Start < h[j].Start

} func (h IntHeap) Swap(i, j int) {

h[i], h[j] = h[j], h[i]

}



func (h *IntHeap) Push(x interface{}) {

// Push and Pop use pointer receivers because they modify the slice's length,

// not just its contents.

*h = append(*h, x.(*Interval))

}



func (h *IntHeap) Pop() interface{} {

old := *h

n := len(old)

x := old[n-1]

*h = old[0 : n-1]

return x

}



func (h *IntHeap) Peek() interface{} {

old := *h

n := len(old)

x := old[n-1]

return x

}



func employeeFreeTime3(schedule [][]*Interval) []*Interval {



// Declare the heap structure so we insert intervals in sorted order

var IntervalHeap IntHeap

heap.Init(&IntervalHeap)



// Flatten the interval array

var flatI []*Interval

for i := 0; i < len(schedule); i++ {

flatI = append(flatI, schedule[i]...)

}



for i := 0; i < len(flatI); i++ {

heap.Push(&IntervalHeap, flatI[i])

}



// determine if two intervals are overlapping

overlap := func(a, b *Interval) bool {

if a.Start > b.End {

return false

}

if b.Start > a.End {

return false

}

return true

}



temp := heap.Pop(&IntervalHeap).(*Interval)

var result []*Interval



for IntervalHeap.Len() > 0 {

a := temp

b := heap.Pop(&IntervalHeap).(*Interval)



// if intervals overlap

if overlap(a, b) {



// take as temp the one with the higher end

if a.End < b.End {

temp = b

}



// id they don't overlap, then we need to record delta

} else {

result = append(result, &Interval{

Start: a.End,

End: b.Start,

})

temp = b

}

}



return result

}

And here’s the result:

And the runtime distribution:

Ok, much better. Still not in the 99 percentile, as I’d prefer for every solution, but closer to the 90 percentile, from both memory usage and a runtime standpoint, and I was fairly happy with this optimization. The algorithm did not change that much; the main thing that changed is the data structure from a regular Go Slice to a Min Heap, with the latest solution.