Learn › DSA Patterns › Top K Elements › Case Study

LeetCode 347 · Medium · Top K Elements · TypeScript

Top K Frequent Elements:
6 Bugs Building a Heap From Scratch

Not a tutorial — a real debugging log. TypeScript has no built-in heap, so this journey hand-builds one, hits six distinct bugs along the way, and fixes each with a concrete compiler error before moving on.

Scroll to follow the journey

The Problem

LeetCode 347 — Top K Frequent Elements

Given an integer array nums and an integer k, return the k most frequent elements. You may return the answer in any order.

Input

nums = [1,1,1,2,2,3], k = 2

Output

[1,2]

Input

nums = [1], k = 1

Output

[1]

The Journey

Attempt by Attempt

Every bug below is a real compiler error, reproduced before being fixed — not just described after the fact.

1 First push() Draft — Four Bugs at Once 🐛 Buggy
push(item): void {
    heap.push(item)
    let i = size() - 1

    while (i > 0) {
        let parentIndex = floor((i - 1) / 2)
        if (heap[i].count < heap[parentIndex].count) {
            swap(heap[i], heap[parentIndex])
            i = parentIndex
        } else break
    }
}

Four separate mistakes fire at once — three missing `this.`, a lowercase `floor`, tuple values read like object properties, and a helper called with the wrong kind of argument.

heap, size(), and swap() are all members of the MinHeap class — outside a method body they need the this. prefix, or TypeScript can't find them at all. floor isn't a global function in TypeScript; only Math.floor exists. And since heap is typed as [number, number][] — an array of tuples, not objects — heap[i].count doesn't exist; the count lives at index [0]. On top of all that, swap was written to take two array indices, but this call hands it the values stored at those indices instead.

Proof — tsc --strict --noEmit output:

error TS2304: Cannot find name 'heap'.
error TS2304: Cannot find name 'size'.
error TS2304: Cannot find name 'floor'.
error TS2339: Property 'count' does not exist on type '[number, number]'.
error TS2345: Argument of type '[number, number]' is not assignable to parameter of type 'number'.
Fix →

Prefix every member access with this., swap floor for Math.floor, replace .count with index access [0], and call this.swap(i, parentIndex) — indices, not values.

2 Fixed push() — Verified by Hand ✅ Correct
push(item: [number, number]): void {
    this.heap.push(item);
    let i = this.size() - 1;

    while (i > 0) {
        let parentIndex = Math.floor((i - 1) / 2);
        if (this.heap[i][0] < this.heap[parentIndex][0]) {
            this.swap(i, parentIndex);
            i = parentIndex;
        } else break;
    }
}

Every fix from Attempt 1 applied together: this. on every member access, Math.floor for the parent-index formula, [0] to read the frequency out of the [count, num] tuple, and swap(i, parentIndex) passing indices already in scope instead of the values stored there.

Proof — hand trace, pushing (3,1) → (2,2) → (1,3):

push([3,1]) → heap: [[3,1]]
push([2,2]) → bubbles up → heap: [[2,2],[3,1]]
push([1,3]) → bubbles up → heap: [[1,3],[3,1],[2,2]]
Min-heap property holds at every step — smaller count always sits above its parent.
3 pop() — TypeScript Rejects a Logically-Safe Assignment 🐛 Buggy
pop() {
    if (this.heap.length === 0) return undefined

    const min = this.heap[0]
    const last = this.heap.pop()

    if (this.heap.length > 0) {
        this.heap[0] = last
        this.bubbleDown(0)
    }
    return min;
}

Compiles-clean logic, but TypeScript's own type system blocks the assignment.

Array.prototype.pop() is typed to return T | undefined, because an empty array has nothing to pop. Even though the if (this.heap.length === 0) guard above means last can never actually be undefined at the point it's used, TypeScript's checker doesn't trace that guard forward to a later, unrelated line. Assigning a possibly-undefined value into a slot typed as a definite tuple fails to compile.

Proof — tsc --strict --noEmit output:

error TS2322: Type '[number, number] | undefined' is not assignable to type '[number, number]'.
Fix →

Add a non-null assertion: const last = this.heap.pop()! — telling TypeScript the guard above already ruled out undefined.

4 toArray() — Wrong Return Type, Twice 🐛 Buggy
toArray(): [number] {
    return this.heap.map(([count, num]) => num)
}
// ⚠ an earlier draft of this same line was annotated [number, number][] instead —
// wrong in a different way, but still not "an array of numbers, any length"

The method's logic is correct, but its return type annotation lies about what it returns — in two different ways, back to back.

The .map(([count, num]) => num) callback returns a plain number for each entry, so the array produced is number[] — a list of any length. The first draft annotated it as [number, number][] (an array of two-element tuples, left over from copying the field's own type). The very next fix swapped to [number], which looks close but means something else again: a tuple with exactly one number, not an array of any length.

Proof — tsc --strict --noEmit output:

error TS2322: Type 'number[]' is not assignable to type '[number]'.
  Target requires 1 element(s) but source may have more.
Fix →

The correct annotation is simply number[] — an array of numbers, any length.

5 TS2440 — A Compile Error With Nothing to Do With the Algorithm 🐛 Buggy
// top of the file — added silently by editor autocomplete
import { MinHeap } from "some-heap-library";

// further down — the hand-written implementation this journey actually built
class MinHeap {
  // ...
}

A name collision introduced by the editor, not by any line actually typed on purpose.

Autocomplete silently inserted an import { MinHeap } from '...' line the moment new MinHeap() was typed, against some installed package that happened to export something with the same name. TypeScript then found two different things both named MinHeap in one file and refused to pick a winner.

Proof — tsc --strict --noEmit output:

error TS2440: Import declaration conflicts with local declaration of 'MinHeap'.
Fix →

Delete the stray auto-inserted import — the hand-built class is the one actually meant to run — or rename the class if the import is genuinely needed for something else.

6 Final — Verified Against the Full Example ✅ ✅ Correct
// O(N log K) — only ever keeps K entries in the heap at once
class MinHeap {
  private heap: [number, number][] = []; // [count, num]

  push(item: [number, number]): void {
    this.heap.push(item);
    let i = this.size() - 1;
    while (i > 0) {
      const p = Math.floor((i - 1) / 2);
      if (this.heap[i][0] >= this.heap[p][0]) break;
      this.swap(i, p);
      i = p;
    }
  }

  pop(): [number, number] | undefined {
    if (this.heap.length === 0) return undefined;
    const min = this.heap[0];
    const last = this.heap.pop()!;
    if (this.heap.length > 0) {
      this.heap[0] = last;
      this.bubbleDown(0);
    }
    return min;
  }

  private bubbleDown(i: number): void {
    while (true) {
      const l = 2 * i + 1, r = 2 * i + 2;
      let smallest = i;
      if (l < this.heap.length && this.heap[l][0] < this.heap[smallest][0]) smallest = l;
      if (r < this.heap.length && this.heap[r][0] < this.heap[smallest][0]) smallest = r;
      if (smallest === i) break;
      this.swap(i, smallest);
      i = smallest;
    }
  }

  private swap(i: number, j: number): void {
    [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
  }

  size(): number { return this.heap.length; }
  toArray(): number[] { return this.heap.map(([, num]) => num); }
}

// LeetCode 347
function topKFrequent(nums: number[], k: number): number[] {
  const freqMap = new Map<number, number>();
  for (const num of nums) freqMap.set(num, (freqMap.get(num) || 0) + 1);

  const heap = new MinHeap();
  for (const [num, count] of freqMap) {
    heap.push([count, num]);
    if (heap.size() > k) heap.pop();
  }
  return heap.toArray();
}

Every fix from every attempt applied together: this. everywhere, Math.floor for parent indices, [0]/index access instead of .count/.num, swap(i, j) by index, a non-null assertion on pop()'s popped element, number[] on toArray(), and the stray auto-import removed.

Proof — full trace against nums=[1,1,1,2,2,3], k=2:

freq map: {1:3, 2:2, 3:1}
push [3,1] → heap [[3,1]]
push [2,2] → heap [[2,2],[3,1]]
push [1,3] → heap [[1,3],[3,1],[2,2]], size 3 > 2 → pop()
pop(): removes [1,3], moves [2,2] to root, bubbles down (no swap needed)
final heap: [[2,2],[3,1]] → toArray() → [2, 1]
Expected: {1, 2} in any order — MATCH ✅

Interactive

Heap Visualizer

Watch the size-2 min-heap build against nums = [1,1,1,2,2,3], k = 2 — each node shows freq, num.

[0] root [1] left [2] right

Heap empty — press Step Forward to begin.

just pushed swapping being evicted

Lessons Learned

Key Takeaways

🧩

Tuples aren't objects — index, don't dot

When heap is typed [number, number][], values live at [0] / [1]. .count / .num compiles fine in loosely-typed JavaScript, but TypeScript's tuple types reject it outright — the compiler catches the mix-up before the code ever runs.

🔁

A helper's parameters are a contract

swap(i, j) was written to take array indices. Handing it the values stored at those indices instead — swap(heap[i], heap[j]) — is a type mismatch, not a stylistic choice, and TypeScript flags it immediately.

🧮

Derive the parent formula, don't memorize it

Children of node i always sit at 2i+1 and 2i+2. Solving either equation for the parent and letting Math.floor collapse both cases into one removes the need to memorize any index math at all.

⚠️

.pop() can return undefined — even when logic rules it out

TypeScript types Array.prototype.pop() as T | undefined unconditionally. A guard written earlier in the same function doesn't change that type for TypeScript's checker; only a non-null assertion or an explicit re-check does.

📦

[number] and number[] are not the same type

One is a tuple that must hold exactly one element. The other is an array of any length. Mixing them up produces a compile error that reads deceptively close to correct — this journey hit both variants back to back.

🕵️

Editor auto-import can create a collision you never typed

If an installed package happens to export something sharing a name with a class being written from scratch, autocomplete can silently import it — producing a TS2440 error far from where the real mistake actually is.

Final Result

Two Verified Solutions

Both pass every test case in this journey — pick based on how small k is relative to n.

Brute Force — Sort
// O(N log N) — sorts every unique count, even when k is tiny
function topKFrequent(nums: number[], k: number): number[] {
  const freqMap = new Map<number, number>();
  for (const num of nums) freqMap.set(num, (freqMap.get(num) || 0) + 1);

  return [...freqMap.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, k)
    .map(([num]) => num);
}
Min-Heap — This Journey's Result
// O(N log K) — only ever keeps K entries in the heap at once
class MinHeap {
  private heap: [number, number][] = []; // [count, num]

  push(item: [number, number]): void {
    this.heap.push(item);
    let i = this.size() - 1;
    while (i > 0) {
      const p = Math.floor((i - 1) / 2);
      if (this.heap[i][0] >= this.heap[p][0]) break;
      this.swap(i, p);
      i = p;
    }
  }

  pop(): [number, number] | undefined {
    if (this.heap.length === 0) return undefined;
    const min = this.heap[0];
    const last = this.heap.pop()!;
    if (this.heap.length > 0) {
      this.heap[0] = last;
      this.bubbleDown(0);
    }
    return min;
  }

  private bubbleDown(i: number): void {
    while (true) {
      const l = 2 * i + 1, r = 2 * i + 2;
      let smallest = i;
      if (l < this.heap.length && this.heap[l][0] < this.heap[smallest][0]) smallest = l;
      if (r < this.heap.length && this.heap[r][0] < this.heap[smallest][0]) smallest = r;
      if (smallest === i) break;
      this.swap(i, smallest);
      i = smallest;
    }
  }

  private swap(i: number, j: number): void {
    [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
  }

  size(): number { return this.heap.length; }
  toArray(): number[] { return this.heap.map(([, num]) => num); }
}

// LeetCode 347
function topKFrequent(nums: number[], k: number): number[] {
  const freqMap = new Map<number, number>();
  for (const num of nums) freqMap.set(num, (freqMap.get(num) || 0) + 1);

  const heap = new MinHeap();
  for (const [num, count] of freqMap) {
    heap.push([count, num]);
    if (heap.size() > k) heap.pop();
  }
  return heap.toArray();
}
Solution Time Space Verified
Brute Force (Sort) O(N log N) O(N) Sorts every unique count
Min-Heap (Size k) Optimized ✓ O(N log K) O(N + K) 6-bug journey + hand trace

Reuse This

Template: Top K via Size-Capped Min-Heap

The generic shape behind this solution — adapt it to any "give me the k best" problem.

Pseudocode
function topK(items, k, priority):
    heap = new MinHeap()          // ordered ascending by priority
    for item in items:
        heap.push(item)
        if heap.size() > k:
            heap.pop()             // discard the lowest-priority survivor

    return heap.toArray()          // whatever remains are the k highest-priority items

When to reach for this

Any "give me the k biggest / most-frequent / closest" question where k stays much smaller than the input — top-k frequent elements, k closest points, kth largest in a stream.

The trap this journey hit

In a language with no built-in heap, every operation — push, pop, the index math — has to be handwritten and type-checked correctly before the algorithm itself even runs.

Test Yourself

Quiz: Check Your Understanding

Question 1 of 5 Score: 0
A size-capped heap for "top K frequent elements" is built as a min-heap ordered by frequency, not a max-heap. After every push, if the heap holds more than k items, the smallest-frequency one is popped. Why does discarding the smallest survivor guarantee the k most frequent elements remain? basic

Keep Learning

Bugs are part of the process 🎉

Six real bugs, six real fixes — that's what building a data structure from scratch, in a language that doesn't hand you one, actually looks like.