โ† Back to DevBytes

Solving Top K Frequent Elements in Go: Step-by-Step Guide

Introduction to Top K Frequent Elements

The "Top K Frequent Elements" problem is one of the most popular algorithmic challenges you will encounter in coding interviews and real-world data processing tasks. Given an array of integers and an integer k, the goal is to return the k most frequent elements in the array. While the problem statement sounds simple, solving it efficiently requires a solid understanding of data structures such as hash maps and heaps.

In this tutorial, we will explore multiple approaches to solve this problem in Go, starting from a brute-force solution and progressing to an optimal one using a min-heap. By the end, you will understand the trade-offs between different approaches and know how to choose the right one for your use case.

Why This Problem Matters

The Top K Frequent Elements problem is more than just an interview question. It has direct applications in many real-world scenarios, including:

Understanding how to solve this problem efficiently will help you build scalable systems that can process large volumes of data without running out of memory or taking too long to respond.

Understanding the Problem

Before jumping into code, let us clearly define the problem. Given an input slice of integers nums and an integer k, we need to return a slice containing the k elements that appear most frequently in nums. The order of the result does not matter, and we can assume that the answer is unique, meaning there are no ties for the k-th most frequent element.

For example, given nums = [1, 1, 1, 2, 2, 3] and k = 2, the output should be [1, 2] because 1 appears three times and 2 appears twice, making them the two most frequent elements.

Approach 1: Sorting by Frequency

The most straightforward approach is to count the frequency of each element using a hash map, then sort the elements by their frequency in descending order, and finally return the first k elements. This approach is easy to implement but has a time complexity of O(n log n) due to the sorting step.

Implementation

package main

import (
	"fmt"
	"sort"
)

func topKFrequentSort(nums []int, k int) []int {
	// Step 1: Count the frequency of each element
	freq := make(map[int]int)
	for _, num := range nums {
		freq[num]++
	}

	// Step 2: Extract unique elements
	unique := make([]int, 0, len(freq))
	for num := range freq {
		unique = append(unique, num)
	}

	// Step 3: Sort by frequency in descending order
	sort.Slice(unique, func(i, j int) bool {
		return freq[unique[i]] > freq[unique[j]]
	})

	// Step 4: Return the first k elements
	return unique[:k]
}

func main() {
	nums := []int{1, 1, 1, 2, 2, 3}
	k := 2
	result := topKFrequentSort(nums, k)
	fmt.Println(result) // Output: [1 2]
}

This solution works and is perfectly acceptable for small to medium-sized inputs. However, when the number of unique elements grows large, the sorting step becomes a bottleneck. We can do better.

Approach 2: Using a Min-Heap

A more efficient approach uses a min-heap of size k. The idea is to maintain a heap that always contains the k most frequent elements seen so far. Since it is a min-heap, the root always holds the element with the smallest frequency among the current top k. When we encounter a new element with a higher frequency than the root, we replace the root with the new element and heapify.

This approach reduces the time complexity to O(n log k), which is a significant improvement when k is much smaller than n.

Implementation

Go provides a built-in container/heap package that we can use to implement a min-heap. First, we need to define a type that implements the heap.Interface.

package main

import (
	"container/heap"
	"fmt"
)

// Element represents an item with its value and frequency
type Element struct {
	Value int
	Count int
}

// MinHeap implements heap.Interface and holds Elements
type MinHeap []Element

func (h MinHeap) Len() int           { return len(h) }
func (h MinHeap) Less(i, j int) bool { return h[i].Count < h[j].Count }
func (h MinHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *MinHeap) Push(x interface{}) {
	*h = append(*h, x.(Element))
}

func (h *MinHeap) Pop() interface{} {
	old := *h
	n := len(old)
	item := old[n-1]
	*h = old[:n-1]
	return item
}

func topKFrequentHeap(nums []int, k int) []int {
	// Step 1: Count the frequency of each element
	freq := make(map[int]int)
	for _, num := range nums {
		freq[num]++
	}

	// Step 2: Build a min-heap of size k
	h := &MinHeap{}
	heap.Init(h)
	for value, count := range freq {
		heap.Push(h, Element{Value: value, Count: count})
		if h.Len() > k {
			heap.Pop(h)
		}
	}

	// Step 3: Extract elements from the heap
	result := make([]int, 0, k)
	for h.Len() > 0 {
		result = append(result, heap.Pop(h).(Element).Value)
	}

	return result
}

func main() {
	nums := []int{1, 1, 1, 2, 2, 3}
	k := 2
	result := topKFrequentHeap(nums, k)
	fmt.Println(result) // Output: [2 1] (order may vary)
}

Notice that the order of the result may differ from the sorting approach because the heap does not guarantee any particular order when extracting elements. If you need the result sorted by frequency, you can sort it afterward, but for most use cases, the order does not matter.

Approach 3: Bucket Sort

There is an even more efficient approach that achieves O(n) time complexity using bucket sort. The idea is to create an array of buckets where the index represents the frequency. We place each element into the bucket corresponding to its frequency, then iterate from the highest frequency bucket downward, collecting elements until we have k of them.

Implementation

package main

import "fmt"

func topKFrequentBucket(nums []int, k int) []int {
	// Step 1: Count the frequency of each element
	freq := make(map[int]int)
	for _, num := range nums {
		freq[num]++
	}

	// Step 2: Create buckets where index = frequency
	bucket := make([][]int, len(nums)+1)
	for value, count := range freq {
		bucket[count] = append(bucket[count], value)
	}

	// Step 3: Collect elements from highest frequency bucket
	result := make([]int, 0, k)
	for i := len(bucket) - 1; i >= 0 && len(result) < k; i-- {
		for _, value := range bucket[i] {
			result = append(result, value)
			if len(result) == k {
				break
			}
		}
	}

	return result
}

func main() {
	nums := []int{1, 1, 1, 2, 2, 3}
	k := 2
	result := topKFrequentBucket(nums, k)
	fmt.Println(result) // Output: [1 2]
}

This approach is optimal in terms of time complexity because both the frequency counting and the bucket collection steps run in linear time. However, it uses O(n) extra space for the bucket array, which may be a concern for very large inputs.

Comparing the Approaches

Let us summarize the three approaches we have covered:

In practice, the bucket sort approach is usually the best choice when you need maximum performance. The min-heap approach shines in streaming scenarios where you cannot store all elements in memory at once, because you only need to keep k elements in the heap at any time.

Best Practices

When implementing the Top K Frequent Elements solution in Go, keep the following best practices in mind:

Example with Input Validation

package main

import (
	"errors"
	"fmt"
)

func topKFrequentSafe(nums []int, k int) ([]int, error) {
	if len(nums) == 0 {
		return nil, errors.New("input slice is empty")
	}
	if k <= 0 {
		return nil, errors.New("k must be positive")
	}

	freq := make(map[int]int)
	for _, num := range nums {
		freq[num]++
	}

	if k > len(freq) {
		return nil, errors.New("k exceeds number of unique elements")
	}

	bucket := make([][]int, len(nums)+1)
	for value, count := range freq {
		bucket[count] = append(bucket[count], value)
	}

	result := make([]int, 0, k)
	for i := len(bucket) - 1; i >= 0 && len(result) < k; i-- {
		for _, value := range bucket[i] {
			result = append(result, value)
			if len(result) == k {
				break
			}
		}
	}

	return result, nil
}

func main() {
	nums := []int{1, 1, 1, 2, 2, 3}
	k := 2
	result, err := topKFrequentSafe(nums, k)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println(result) // Output: [1 2]
}

Writing Unit Tests

To ensure your solution is robust, you should write comprehensive unit tests. Go's testing package makes this straightforward.

package main

import (
	"reflect"
	"sort"
	"testing"
)

func TestTopKFrequentBucket(t *testing.T) {
	tests := []struct {
		name     string
		nums     []int
		k        int
		expected []int
	}{
		{"basic case", []int{1, 1, 1, 2, 2, 3}, 2, []int{1, 2}},
		{"single element", []int{1}, 1, []int{1}},
		{"all same frequency", []int{1, 2, 3}, 3, []int{1, 2, 3}},
		{"k equals unique count", []int{1, 1, 2, 2, 3}, 3, []int{1, 2, 3}},
		{"large k", []int{3, 1, 1, 2, 2, 3}, 2, []int{1, 2}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := topKFrequentBucket(tt.nums, tt.k)
			sort.Ints(result)
			sort.Ints(tt.expected)
			if !reflect.DeepEqual(result, tt.expected) {
				t.Errorf("got %v, want %v", result, tt.expected)
			}
		})
	}
}

Notice that we sort both the result and the expected output before comparing them, since the order of elements in the result does not matter for this problem.

Performance Considerations

When dealing with very large datasets, you may need to consider additional optimizations:

Parallel Frequency Counting Example

package main

import (
	"fmt"
	"sync"
)

func countFrequencies(nums []int, numWorkers int) map[int]int {
	chunkSize := (len(nums) + numWorkers - 1) / numWorkers
	results := make([]map[int]int, numWorkers)
	var wg sync.WaitGroup

	for i := 0; i < numWorkers; i++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			start := workerID * chunkSize
			end := start + chunkSize
			if end > len(nums) {
				end = len(nums)
			}
			localFreq := make(map[int]int)
			for j := start; j < end; j++ {
				localFreq[nums[j]]++
			}
			results[workerID] = localFreq
		}(i)
	}

	wg.Wait()

	// Merge results
	merged := make(map[int]int)
	for _, localFreq := range results {
		for num, count := range localFreq {
			merged[num] += count
		}
	}

	return merged
}

func main() {
	nums := []int{1, 1, 1, 2, 2, 3, 3, 3, 3, 4, 4, 5}
	freq := countFrequencies(nums, 4)
	fmt.Println(freq) // map[1:3 2:2 3:4 4:2 5:1]
}

After merging the frequency maps, you can apply any of the three approaches discussed earlier to extract the top k elements.

Conclusion

Solving the Top K Frequent Elements problem in Go is an excellent way to deepen your understanding of hash maps, heaps, and sorting algorithms. We explored three distinct approaches, each with its own trade-offs: the simple sorting method for ease of implementation, the min-heap method for efficiency when k is small, and the bucket sort method for optimal linear-time performance. By choosing the right approach based on your specific constraints and following best practices such as input validation, preallocation, and thorough testing, you can build robust and efficient solutions that scale to real-world workloads. Whether you are preparing for a coding interview or building a production system, mastering this problem will make you a more effective Go developer.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles