โ† Back to DevBytes

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

Introduction to Top K Frequent Elements

The Top K Frequent Elements problem is one of the most common algorithmic challenges you'll encounter in coding interviews and real-world applications. Given an array of elements and an integer k, the task is to return the k most frequent elements in the array. While the problem statement sounds simple, there are multiple ways to solve it, each with different trade-offs in terms of time and space complexity.

In this tutorial, we'll walk through several approaches to solve this problem in JavaScript, starting from the brute-force method and progressing to more optimized solutions using heaps and bucket sort. By the end, you'll have a solid understanding of when to use each approach and why.

Understanding the Problem

Before diving into solutions, let's clearly define the problem. Given an array of integers (or strings, or any comparable items) and an integer k, we need to return the k elements that appear most frequently in the array. The order of the result typically does not matter unless specified.

For example, given the input [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.

Why This Problem Matters

This problem is more than just an interview exercise. It has practical applications in many domains:

Understanding how to efficiently solve this problem equips you with techniques applicable to a wide range of data processing tasks.

Approach 1: Brute Force with Sorting

The most straightforward approach is to count the frequency of each element, sort the elements by their frequency in descending order, and then return the first k elements. This approach is easy to implement but not the most efficient for large datasets.

Implementation

function topKFrequentBruteForce(nums, k) {
  // Step 1: Count the frequency of each element
  const frequencyMap = new Map();
  for (const num of nums) {
    frequencyMap.set(num, (frequencyMap.get(num) || 0) + 1);
  }

  // Step 2: Convert the map to an array of [element, frequency] pairs
  const frequencyArray = Array.from(frequencyMap.entries());

  // Step 3: Sort by frequency in descending order
  frequencyArray.sort((a, b) => b[1] - a[1]);

  // Step 4: Extract the top k elements
  return frequencyArray.slice(0, k).map(pair => pair[0]);
}

// Example usage
const nums = [1, 1, 1, 2, 2, 3];
const k = 2;
console.log(topKFrequentBruteForce(nums, k)); // Output: [1, 2]

Complexity Analysis

The time complexity of this approach is O(n log n) due to the sorting step, where n is the number of unique elements. The space complexity is O(n) for storing the frequency map and the sorted array. While this works fine for small to medium-sized inputs, it becomes inefficient when dealing with large datasets where n is very large.

Approach 2: Using a Min-Heap

A more efficient approach for large datasets involves using a min-heap (priority queue) of size k. The idea is to maintain a heap that always contains the k most frequent elements seen so far. Since JavaScript does not have a built-in heap data structure, we'll need to implement one or use a library.

Implementing a Min-Heap

class MinHeap {
  constructor() {
    this.heap = [];
  }

  // Get the parent index of a node
  getParentIndex(index) {
    return Math.floor((index - 1) / 2);
  }

  // Get the left child index of a node
  getLeftChildIndex(index) {
    return 2 * index + 1;
  }

  // Get the right child index of a node
  getRightChildIndex(index) {
    return 2 * index + 2;
  }

  // Swap two elements in the heap
  swap(i, j) {
    [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
  }

  // Insert a new element into the heap
  insert(value) {
    this.heap.push(value);
    this.heapifyUp(this.heap.length - 1);
  }

  // Maintain heap property after insertion
  heapifyUp(index) {
    while (index > 0) {
      const parentIndex = this.getParentIndex(index);
      if (this.heap[parentIndex][1] > this.heap[index][1]) {
        this.swap(parentIndex, index);
        index = parentIndex;
      } else {
        break;
      }
    }
  }

  // Remove and return the minimum element
  extractMin() {
    if (this.heap.length === 0) return null;
    if (this.heap.length === 1) return this.heap.pop();

    const min = this.heap[0];
    this.heap[0] = this.heap.pop();
    this.heapifyDown(0);
    return min;
  }

  // Maintain heap property after extraction
  heapifyDown(index) {
    while (true) {
      const leftChildIndex = this.getLeftChildIndex(index);
      const rightChildIndex = this.getRightChildIndex(index);
      let smallest = index;

      if (leftChildIndex < this.heap.length && 
          this.heap[leftChildIndex][1] < this.heap[smallest][1]) {
        smallest = leftChildIndex;
      }

      if (rightChildIndex < this.heap.length && 
          this.heap[rightChildIndex][1] < this.heap[smallest][1]) {
        smallest = rightChildIndex;
      }

      if (smallest !== index) {
        this.swap(index, smallest);
        index = smallest;
      } else {
        break;
      }
    }
  }

  // Get the minimum element without removing it
  peek() {
    return this.heap.length > 0 ? this.heap[0] : null;
  }

  // Get the current size of the heap
  size() {
    return this.heap.length;
  }
}

Solving the Problem with a Min-Heap

function topKFrequentHeap(nums, k) {
  // Step 1: Count the frequency of each element
  const frequencyMap = new Map();
  for (const num of nums) {
    frequencyMap.set(num, (frequencyMap.get(num) || 0) + 1);
  }

  // Step 2: Use a min-heap of size k
  const minHeap = new MinHeap();

  for (const [element, frequency] of frequencyMap.entries()) {
    if (minHeap.size() < k) {
      minHeap.insert([element, frequency]);
    } else if (frequency > minHeap.peek()[1]) {
      minHeap.extractMin();
      minHeap.insert([element, frequency]);
    }
  }

  // Step 3: Extract all elements from the heap
  const result = [];
  while (minHeap.size() > 0) {
    result.push(minHeap.extractMin()[0]);
  }

  return result;
}

// Example usage
const nums = [1, 1, 1, 2, 2, 3];
const k = 2;
console.log(topKFrequentHeap(nums, k)); // Output: [2, 1]

Complexity Analysis

The time complexity of the heap-based approach is O(n log k), where n is the number of unique elements. This is because we perform at most n heap operations, each taking O(log k) time. The space complexity is O(n + k) for the frequency map and the heap. This approach is particularly efficient when k is much smaller than n, as the heap operations become very fast.

Approach 3: Bucket Sort

The most optimal approach for this problem uses a bucket sort technique. The key insight is that the frequency of any element cannot exceed the total number of elements in the array. We can create an array of buckets where the index represents the frequency, and each bucket contains elements with that frequency.

Implementation

function topKFrequentBucket(nums, k) {
  // Step 1: Count the frequency of each element
  const frequencyMap = new Map();
  for (const num of nums) {
    frequencyMap.set(num, (frequencyMap.get(num) || 0) + 1);
  }

  // Step 2: Create buckets where index = frequency
  const bucket = Array(nums.length + 1).fill(null).map(() => []);
  for (const [element, frequency] of frequencyMap.entries()) {
    bucket[frequency].push(element);
  }

  // Step 3: Collect elements from highest frequency buckets
  const result = [];
  for (let i = bucket.length - 1; i >= 0 && result.length < k; i--) {
    if (bucket[i].length > 0) {
      for (const element of bucket[i]) {
        if (result.length < k) {
          result.push(element);
        }
      }
    }
  }

  return result;
}

// Example usage
const nums = [1, 1, 1, 2, 2, 3];
const k = 2;
console.log(topKFrequentBucket(nums, k)); // Output: [1, 2]

Complexity Analysis

The bucket sort approach achieves a time complexity of O(n), where n is the number of elements in the input array. Building the frequency map takes O(n) time, creating the buckets takes O(n) time, and collecting the results takes O(n) time in the worst case. The space complexity is also O(n) for the frequency map and the bucket array. This is the most efficient solution for this problem.

Approach 4: Quickselect Algorithm

Another interesting approach is the Quickselect algorithm, which is based on the partitioning technique used in Quicksort. This algorithm has an average time complexity of O(n), though the worst case is O(n^2). It's worth knowing but less commonly used in practice due to its worst-case behavior.

Implementation

function topKFrequentQuickselect(nums, k) {
  // Step 1: Count the frequency of each element
  const frequencyMap = new Map();
  for (const num of nums) {
    frequencyMap.set(num, (frequencyMap.get(num) || 0) + 1);
  }

  // Step 2: Convert to array of unique elements
  const unique = Array.from(frequencyMap.keys());
  const n = unique.length;

  // Step 3: Quickselect to find the kth most frequent element
  const targetIndex = n - k;

  function partition(left, right, pivotIndex) {
    const pivotFrequency = frequencyMap.get(unique[pivotIndex]);
    // Move pivot to the end
    [unique[pivotIndex], unique[right]] = [unique[right], unique[pivotIndex]];

    let storeIndex = left;
    for (let i = left; i < right; i++) {
      if (frequencyMap.get(unique[i]) < pivotFrequency) {
        [unique[storeIndex], unique[i]] = [unique[i], unique[storeIndex]];
        storeIndex++;
      }
    }

    // Move pivot to its final place
    [unique[storeIndex], unique[right]] = [unique[right], unique[storeIndex]];
    return storeIndex;
  }

  function quickselect(left, right) {
    if (left === right) return;

    const pivotIndex = Math.floor(Math.random() * (right - left + 1)) + left;
    const newPivotIndex = partition(left, right, pivotIndex);

    if (newPivotIndex === targetIndex) {
      return;
    } else if (newPivotIndex < targetIndex) {
      quickselect(newPivotIndex + 1, right);
    } else {
      quickselect(left, newPivotIndex - 1);
    }
  }

  quickselect(0, n - 1);

  // Step 4: Return the top k elements
  return unique.slice(targetIndex);
}

// Example usage
const nums = [1, 1, 1, 2, 2, 3];
const k = 2;
console.log(topKFrequentQuickselect(nums, k)); // Output: [1, 2]

Complexity Analysis

The Quickselect approach has an average time complexity of O(n) and a worst-case time complexity of O(n^2). By using a random pivot, we can reduce the likelihood of hitting the worst case. The space complexity is O(n) for the frequency map and the unique elements array, plus O(log n) for the recursion stack in the average case.

Comparing the Approaches

Let's summarize the different approaches and their complexities:

In most practical scenarios, the bucket sort approach is the best choice due to its guaranteed linear time complexity. However, if memory is a concern and k is very small compared to n, the min-heap approach might be preferable since it only stores k elements in the heap at any time.

Best Practices

Choose the Right Data Structure

Always start by building a frequency map using a Map or a plain object. The Map is generally preferred in modern JavaScript because it preserves insertion order, handles any key type, and has better performance for frequent additions and removals.

Handle Edge Cases

Make sure to handle edge cases in your implementation:

function topKFrequentSafe(nums, k) {
  // Edge case: empty array
  if (!nums || nums.length === 0) return [];

  // Edge case: k is 0
  if (k === 0) return [];

  // Edge case: k is greater than unique elements
  const frequencyMap = new Map();
  for (const num of nums) {
    frequencyMap.set(num, (frequencyMap.get(num) || 0) + 1);
  }

  if (k >= frequencyMap.size) {
    return Array.from(frequencyMap.keys());
  }

  // Proceed with bucket sort approach
  const bucket = Array(nums.length + 1).fill(null).map(() => []);
  for (const [element, frequency] of frequencyMap.entries()) {
    bucket[frequency].push(element);
  }

  const result = [];
  for (let i = bucket.length - 1; i >= 0 && result.length < k; i--) {
    for (const element of bucket[i]) {
      if (result.length < k) {
        result.push(element);
      }
    }
  }

  return result;
}

Consider Streaming Data

If you're dealing with streaming data where elements arrive continuously, the min-heap approach is more suitable. You can maintain a running frequency map and a heap of size k, updating them as new data arrives. This avoids the need to reprocess the entire dataset each time.

Use Libraries for Production Code

For production applications, consider using well-tested libraries instead of implementing your own heap. Libraries like heap-js or priorityqueuejs provide efficient heap implementations that you can use directly.

// Using heap-js library
import { MinHeap } from 'heap-js';

function topKFrequentWithLibrary(nums, k) {
  const frequencyMap = new Map();
  for (const num of nums) {
    frequencyMap.set(num, (frequencyMap.get(num) || 0) + 1);
  }

  const minHeap = new MinHeap((a, b) => a[1] - b[1]);

  for (const [element, frequency] of frequencyMap.entries()) {
    if (minHeap.size() < k) {
      minHeap.push([element, frequency]);
    } else if (frequency > minHeap.peek()[1]) {
      minHeap.pop();
      minHeap.push([element, frequency]);
    }
  }

  return minHeap.toArray().map(pair => pair[0]);
}

Profile Your Code

Always profile your code with realistic data before choosing an approach. The theoretical complexity is important, but real-world performance can be affected by factors like JavaScript engine optimizations, memory allocation patterns, and the specific characteristics of your data.

Real-World Example: Analyzing Log Data

Let's look at a practical example where we analyze server log data to find the most frequent error codes. This demonstrates how the Top K Frequent Elements algorithm can be applied to real-world problems.

// Simulated server log data
const serverLogs = [
  { timestamp: '2024-01-01T10:00:00', statusCode: 200, endpoint: '/api/users' },
  { timestamp: '2024-01-01T10:01:00', statusCode: 404, endpoint: '/api/posts' },
  { timestamp: '2024-01-01T10:02:00', statusCode: 500, endpoint: '/api/orders' },
  { timestamp: '2024-01-01T10:03:00', statusCode: 200, endpoint: '/api/users' },
  { timestamp: '2024-01-01T10:04:00', statusCode: 404, endpoint: '/api/posts' },
  { timestamp: '2024-01-01T10:05:00', statusCode: 200, endpoint: '/api/users' },
  { timestamp: '2024-01-01T10:06:00', statusCode: 500, endpoint: '/api/orders' },
  { timestamp: '2024-01-01T10:07:00', statusCode: 200, endpoint: '/api/users' },
  { timestamp: '2024-01-01T10:08:00', statusCode: 403, endpoint: '/api/admin' },
  { timestamp: '2024-01-01T10:09:00', statusCode: 200, endpoint: '/api/users' },
];

function topKFrequentEndpoints(logs, k) {
  const frequencyMap = new Map();
  for (const log of logs) {
    frequencyMap.set(log.endpoint, (frequencyMap.get(log.endpoint) || 0) + 1);
  }

  const bucket = Array(logs.length + 1).fill(null).map(() => []);
  for (const [endpoint, frequency] of frequencyMap.entries()) {
    bucket[frequency].push(endpoint);
  }

  const result = [];
  for (let i = bucket.length - 1; i >= 0 && result.length < k; i--) {
    for (const endpoint of bucket[i]) {
      if (result.length < k) {
        result.push({ endpoint, count: i });
      }
    }
  }

  return result;
}

const topEndpoints = topKFrequentEndpoints(serverLogs, 3);
console.log('Top 3 most accessed endpoints:');
topEndpoints.forEach(item => {
  console.log(`  ${item.endpoint}: ${item.count} requests`);
});
// Output:
// Top 3 most accessed endpoints:
//   /api/users: 5 requests
//   /api/posts: 2 requests
//   /api/orders: 2 requests

Common Pitfalls to Avoid

Forgetting to Handle Ties

When multiple elements have the same frequency, you need to decide how to handle ties. The problem usually specifies that any valid answer is acceptable, but in some cases, you might need a deterministic ordering. Be clear about the requirements before implementing your solution.

Using the Wrong Heap Type

A common mistake is using a max-heap instead of a min-heap for the heap-based approach. Since we want to keep only the top k elements, we should use a min-heap so that we can efficiently remove the smallest frequency element when the heap exceeds size k.

Ignoring Space Complexity

While time complexity often gets more attention, space complexity is equally important, especially when dealing with large datasets. The bucket sort approach uses O(n) extra space, which might be problematic for very large arrays. Always consider the memory constraints of your environment.

Testing Your Implementation

Thorough testing is essential to ensure your solution handles all cases correctly. Here's a test suite covering various scenarios:

function runTests() {
  // Test 1: Basic case
  console.assert(
    JSON.stringify(topKFrequentBucket([1, 1, 1, 2, 2, 3], 2).sort()) === 
    JSON.stringify([1, 2]),
    'Test 1 failed'
  );

  // Test 2: Single element
  console.assert(
    JSON.stringify(topKFrequentBucket([1], 1)) === 
    JSON.stringify([1]),
    'Test 2 failed'
  );

  // Test 3: All same frequency
  console.assert(
    topKFrequentBucket([1, 2, 3], 2).length === 2,
    'Test 3 failed'
  );

  // Test 4: k equals unique elements
  console.assert(
    topKFrequentBucket([1, 2, 3], 3).length === 3,
    'Test 4 failed'
  );

  // Test 5: Negative numbers
  console.assert(
    JSON.stringify(topKFrequentBucket([-1, -1, 2, 2, 2], 1)) === 
    JSON.stringify([2]),
    'Test 5 failed'
  );

  // Test 6: Large k
  console.assert(
    topKFrequentBucket([1, 1, 2, 2, 3, 3, 4], 10).length === 4,
    'Test 6 failed'
  );

  console.log('All tests passed!');
}

runTests();

Conclusion

Solving the Top K Frequent Elements problem in JavaScript offers an excellent opportunity to explore different algorithmic techniques and their trade-offs. We've covered four distinct approaches โ€” brute force sorting, min-heap, bucket sort, and quickselect โ€” each with its own strengths and ideal use cases. The bucket sort approach stands out as the most efficient with its O(n) time complexity, making it the go-to solution for most scenarios. However, understanding all approaches gives you the flexibility to choose the right tool for each specific situation, whether you're processing streaming data with a heap or dealing with memory constraints. By mastering these techniques and following best practices like handling edge cases and profiling with real data, you'll be well-equipped to tackle not only this problem but also the many real-world challenges that require finding the most frequent elements in a dataset.

๐Ÿ›  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