Go Data Structures & Algorithms

code

Heaps in Go

Heaps are specialized tree-based data structures that satisfy the heap property. They are commonly used to implement priority queues, which are essential for many algorithms like Dijkstra's shortest path, Huffman coding, and heap sort. In this comprehensive guide, we'll explore heaps in Go, from basic concepts to advanced implementations and practical applications.

What are Heaps?

A heap is a specialized tree-based data structure that satisfies the heap property. In a heap, the keys of parent nodes are ordered with respect to their child nodes, and the highest (or lowest) priority element is always at the root.

Heaps are not to be confused with the memory heap used for dynamic memory allocation. In data structures, a heap is a specific tree structure.

Heap Properties

A heap must satisfy the following properties:

  1. Shape Property: A heap is a complete binary tree, which means all levels of the tree are fully filled except possibly the last level, which is filled from left to right.
  2. Heap Property: The key stored in each node is either greater than or equal to (in a max heap) or less than or equal to (in a min heap) the keys in the node's children.

Types of Heaps

There are several types of heaps, but the most common are:

  • Binary Heap: A binary tree where each node has at most two children.
  • Max Heap: A binary heap where the value of each node is greater than or equal to the values of its children.
  • Min Heap: A binary heap where the value of each node is less than or equal to the values of its children.
  • Fibonacci Heap: A more complex heap structure with better amortized running time for some operations.
  • Binomial Heap: A heap similar to a binary heap but with a different structure that allows for efficient merging of heaps.

In this guide, we'll focus primarily on binary heaps (both max and min), as they are the most commonly used.

Heap Operations

The main operations performed on a heap are:

  • Insert: Add a new element to the heap while maintaining the heap property.
  • Extract (or Remove) Max/Min: Remove and return the maximum (in a max heap) or minimum (in a min heap) element.
  • Peek: View the maximum (in a max heap) or minimum (in a min heap) element without removing it.
  • Heapify: Convert an array into a heap in-place.
  • Delete: Remove a specific element from the heap.
  • Increase/Decrease Key: Change the value of a key and restore the heap property.
  • Merge: Combine two heaps into one.

Implementing a Binary Heap

Binary heaps are typically implemented using arrays (or slices in Go), which provides a space-efficient way to represent a complete binary tree without using pointers. The parent-child relationships are defined by the array indices:

  • For a node at index i:
    • Its left child is at index 2*i + 1
    • Its right child is at index 2*i + 2
    • Its parent is at index (i-1)/2 (integer division)

Let's implement a binary max heap in Go:

package main

import (
    "fmt"
    "errors"
)

// MaxHeap represents a binary max heap
type MaxHeap struct {
    items []int
}

// NewMaxHeap creates a new empty max heap
func NewMaxHeap() *MaxHeap {
    return &MaxHeap{items: []int{}}
}

// Size returns the number of elements in the heap
func (h *MaxHeap) Size() int {
    return len(h.items)
}

// IsEmpty checks if the heap is empty
func (h *MaxHeap) IsEmpty() bool {
    return len(h.items) == 0
}

// Peek returns the maximum element without removing it
func (h *MaxHeap) Peek() (int, error) {
    if h.IsEmpty() {
        return 0, errors.New("heap is empty")
    }
    return h.items[0], nil
}

// Push adds a new element to the heap
func (h *MaxHeap) Push(value int) {
    h.items = append(h.items, value)
    h.siftUp(len(h.items) - 1)
}

// Pop removes and returns the maximum element
func (h *MaxHeap) Pop() (int, error) {
    if h.IsEmpty() {
        return 0, errors.New("heap is empty")
    }
    
    root := h.items[0]
    lastIdx := len(h.items) - 1
    
    // Replace root with the last element
    h.items[0] = h.items[lastIdx]
    h.items = h.items[:lastIdx]
    
    // Restore heap property if heap is not empty
    if !h.IsEmpty() {
        h.siftDown(0)
    }
    
    return root, nil
}

// siftUp moves a node up the tree until the heap property is satisfied
func (h *MaxHeap) siftUp(index int) {
    parent := (index - 1) / 2
    
    // If we're at the root or the heap property is satisfied, we're done
    if index > 0 && h.items[index] > h.items[parent] {
        // Swap with parent
        h.items[index], h.items[parent] = h.items[parent], h.items[index]
        // Continue sifting up
        h.siftUp(parent)
    }
}

// siftDown moves a node down the tree until the heap property is satisfied
func (h *MaxHeap) siftDown(index int) {
    largest := index
    left := 2*index + 1
    right := 2*index + 2
    
    // Check if left child exists and is larger than current largest
    if left < len(h.items) && h.items[left] > h.items[largest] {
        largest = left
    }
    
    // Check if right child exists and is larger than current largest
    if right < len(h.items) && h.items[right] > h.items[largest] {
        largest = right
    }
    
    // If largest is not the current node, swap and continue sifting down
    if largest != index {
        h.items[index], h.items[largest] = h.items[largest], h.items[index]
        h.siftDown(largest)
    }
}

// BuildHeap creates a heap from an array in O(n) time
func (h *MaxHeap) BuildHeap(arr []int) {
    h.items = make([]int, len(arr))
    copy(h.items, arr)
    
    // Start from the last non-leaf node and sift down
    for i := len(h.items)/2 - 1; i >= 0; i-- {
        h.siftDown(i)
    }
}

// HeapSort sorts an array using heap sort
func HeapSort(arr []int) {
    h := NewMaxHeap()
    h.BuildHeap(arr)
    
    for i := len(arr) - 1; i >= 0; i-- {
        arr[i] = h.items[0]
        h.items[0] = h.items[len(h.items)-1]
        h.items = h.items[:len(h.items)-1]
        if len(h.items) > 0 {
            h.siftDown(0)
        }
    }
}

// String returns a string representation of the heap
func (h *MaxHeap) String() string {
    return fmt.Sprintf("%v", h.items)
}

func main() {
    heap := NewMaxHeap()
    
    // Insert elements
    values := []int{10, 20, 15, 30, 40}
    for _, val := range values {
        heap.Push(val)
        fmt.Printf("Pushed %d: %s\n", val, heap)
    }
    
    fmt.Println("Heap size:", heap.Size())
    
    // Extract elements
    for !heap.IsEmpty() {
        max, _ := heap.Pop()
        fmt.Printf("Popped %d: %s\n", max, heap)
    }
    
    // Build heap from array
    arr := []int{4, 10, 3, 5, 1}
    heap.BuildHeap(arr)
    fmt.Println("Built heap:", heap)
    
    // Heap sort
    toSort := []int{4, 10, 3, 5, 1}
    HeapSort(toSort)
    fmt.Println("Sorted array:", toSort)
}

This implementation provides a binary max heap with methods for insertion, extraction, and heap construction. The heap sort algorithm is also included as a demonstration of how heaps can be used for sorting.

To implement a min heap, you would simply reverse the comparison operators in the siftUp and siftDown methods.

Using Go's Standard Library Heap

Go's standard library provides a container/heap package that implements a heap interface. To use it, you need to implement the heap.Interface, which requires the following methods:

  • Len() int: Returns the number of elements in the collection.
  • Less(i, j int) bool: Reports whether the element at index i should sort before the element at index j.
  • Swap(i, j int): Swaps the elements at indices i and j.
  • Push(x interface{}): Adds x as the last element.
  • Pop() interface{}: Removes and returns the last element.

Here's an example of using the standard library heap to implement a min heap:

package main

import (
    "container/heap"
    "fmt"
)

// IntHeap is a min-heap of integers
type IntHeap []int

// Len implements sort.Interface
func (h IntHeap) Len() int {
    return len(h)
}

// Less implements sort.Interface
// For min-heap, use h[i] < h[j]
// For max-heap, use h[i] > h[j]
func (h IntHeap) Less(i, j int) bool {
    return h[i] < h[j]
}

// Swap implements sort.Interface
func (h IntHeap) Swap(i, j int) {
    h[i], h[j] = h[j], h[i]
}

// Push implements heap.Interface
func (h *IntHeap) Push(x interface{}) {
    *h = append(*h, x.(int))
}

// Pop implements heap.Interface
// Note: This removes and returns the last element, not the "top" of the heap
// The heap package uses this method internally and handles the heap property
func (h *IntHeap) Pop() interface{} {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[0 : n-1]
    return x
}

func main() {
    // Initialize a min heap
    h := &IntHeap{10, 20, 15, 30, 40}
    
    // Heapify the slice
    heap.Init(h)
    fmt.Println("Initialized heap:", *h)
    
    // Push an element
    heap.Push(h, 5)
    fmt.Println("After pushing 5:", *h)
    
    // Peek at the minimum element
    fmt.Println("Minimum element:", (*h)[0])
    
    // Pop the minimum element
    for h.Len() > 0 {
        fmt.Printf("Popped %d, heap is now %v\n", heap.Pop(h), *h)
    }
    
    // Example of a priority queue with custom elements
    fmt.Println("\nPriority Queue Example:")
    pq := make(PriorityQueue, 0)
    
    // Add items to the priority queue
    heap.Push(&pq, &Item{value: "task1", priority: 3})
    heap.Push(&pq, &Item{value: "task2", priority: 1})
    heap.Push(&pq, &Item{value: "task3", priority: 2})
    
    // Process items in priority order
    for pq.Len() > 0 {
        item := heap.Pop(&pq).(*Item)
        fmt.Printf("Processing %s with priority %d\n", item.value, item.priority)
    }
}

// Item represents an item in a priority queue
type Item struct {
    value    string
    priority int
    index    int // Maintained by the heap.Interface methods
}

// PriorityQueue implements heap.Interface and holds Items
type PriorityQueue []*Item

func (pq PriorityQueue) Len() int { return len(pq) }

// Less defines the ordering of Items in the priority queue
// Lower priority values are higher priority (min-heap)
func (pq PriorityQueue) Less(i, j int) bool {
    return pq[i].priority < pq[j].priority
}

func (pq PriorityQueue) Swap(i, j int) {
    pq[i], pq[j] = pq[j], pq[i]
    pq[i].index = i
    pq[j].index = j
}

func (pq *PriorityQueue) Push(x interface{}) {
    n := len(*pq)
    item := x.(*Item)
    item.index = n
    *pq = append(*pq, item)
}

func (pq *PriorityQueue) Pop() interface{} {
    old := *pq
    n := len(old)
    item := old[n-1]
    old[n-1] = nil  // Avoid memory leak
    item.index = -1 // For safety
    *pq = old[0 : n-1]
    return item
}

This example demonstrates both a simple integer min heap and a more complex priority queue implementation using the standard library's container/heap package.

Heap Applications

Heaps are used in a variety of applications:

Priority Queues

The most common application of heaps is implementing priority queues, where elements are processed based on their priority rather than their insertion order.

Heap Sort

Heap sort is a comparison-based sorting algorithm that uses a binary heap data structure. It has a worst-case time complexity of O(n log n).

Graph Algorithms

Many graph algorithms, such as Dijkstra's shortest path and Prim's minimum spanning tree, use priority queues (often implemented with heaps) to efficiently select the next vertex to process.

Selection Algorithms

Heaps can be used to efficiently find the k-th smallest or largest element in an array.

Event Simulation

In discrete event simulation, heaps are used to maintain a queue of events sorted by their scheduled time.

Memory Management

Some memory allocators use heaps to manage free memory blocks efficiently.

Performance Analysis

Let's analyze the time complexity of common heap operations:

Operation Time Complexity
Find Max/Min (Peek) O(1)
Insert (Push) O(log n)
Delete Max/Min (Pop) O(log n)
Build Heap O(n)
Heapify (sift down) O(log n)
Increase/Decrease Key O(log n)
Delete Arbitrary Element O(log n) (if position is known)
Merge Two Heaps O(n) for binary heaps

The space complexity of a binary heap is O(n), where n is the number of elements in the heap.

Best Practices

Here are some best practices for working with heaps in Go:

Use the Standard Library When Possible

Go's container/heap package provides a well-tested implementation of the heap interface. Use it instead of implementing your own heap from scratch, especially for complex applications.

Choose the Right Heap Type

Select the appropriate heap type (min or max) based on your requirements. For finding minimums (like shortest paths), use a min heap. For finding maximums (like largest elements), use a max heap.

Consider Custom Comparators

When working with complex objects, implement custom comparison logic in the Less method to define the ordering of elements in the heap.

Optimize for Specific Use Cases

For specialized applications like k-way merging or finding the k-th smallest element, consider using more advanced heap structures like Fibonacci heaps or binomial heaps, which offer better theoretical performance for certain operations.

Avoid Unnecessary Heap Operations

Heap operations like insertion and extraction have a logarithmic cost. If you need to perform many operations on a heap, consider batching them or using more efficient algorithms when possible.

Use Heaps for Dynamic Priority Management

Heaps excel at managing priorities that change over time. If your application needs to frequently update priorities and extract the highest/lowest priority item, a heap is an excellent choice.

Conclusion

Heaps are powerful data structures for managing prioritized data efficiently. They provide O(1) access to the maximum or minimum element and O(log n) insertion and deletion operations. In Go, you can implement your own heap or use the standard library's container/heap package for a more flexible and reusable solution.

Understanding heaps and their applications is essential for solving a wide range of problems, from sorting and selection to graph algorithms and event simulation. By choosing the right heap implementation and following best practices, you can leverage the efficiency and versatility of heaps in your Go programs.

In the next section, we will explore sets, collections that store unique elements and support efficient membership testing.