Non-Linear Data Structures: Trees and Heaps

SA
StudyAI
AI-generated study notes
· Published Updated Pro Plus model

From the data structures and algorithms (python curriculum

TL;DR

You'll understand how trees organize data hierarchically and why that beats linear structures for search and priority tasks. You'll implement a binary search tree and a binary heap from scratch in Python, including insert, delete, and heapify. You'll know exactly when to reach for a BST versus a heap versus a balanced tree in real problems.

1. The Mental Model

A tree is just a chain of decisions: at each node you branch left or right (or into k children) based on some rule. A heap is a tree that only cares about one thing — "who's on top" — not about ordering everything else. Both trade the simplicity of arrays for faster answers to specific questions. A tree is a search structure; a heap is a priority structure — don't ask one to do the other's job.

2. The Core Material

2.1 Binary Search Trees (BST)

Close-up of a man with glasses and binary code projection, symbolizing cyber security.
Photo by cottonbro studio on Pexels

A BST is a binary tree where, for every node, everything in the left subtree is smaller and everything in the right subtree is larger. This invariant is what makes search fast — you eliminate half the remaining tree at each step, similar to binary search on a sorted array, but without needing contiguous memory or shifting elements on insert.

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

class BST:
    def __init__(self):
        self.root = None

    def insert(self, key):
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            return Node(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        return node  # duplicates ignored

    def search(self, key):
        node = self.root
        while node:
            if key == node.key:
                return True
            node = node.left if key < node.key else node.right
        return False

    def delete(self, key):
        self.root = self._delete(self.root, key)

    def _delete(self, node, key):
        if node is None:
            return None
        if key < node.key:
            node.left = self._delete(node.left, key)
        elif key > node.key:
            node.right = self._delete(node.right, key)
        else:
            # Found the node to delete
            if node.left is None:
                return node.right
            if node.right is None:
                return node.left
            # Two children: replace with in-order successor
            successor = node.right
            while successor.left:
                successor = successor.left
            node.key = successor.key
            node.right = self._delete(node.right, successor.key)
        return node

The delete case with two children is the part everyone forgets under pressure: you can't just remove the node, you need to pull up a replacement that preserves the ordering invariant. The in-order successor (smallest value in the right subtree) always works because it's bigger than everything on the left and smaller than everything else on the right.

Complexity: search/insert/delete are O(h) where h is the tree's height. For a balanced tree, h = O(log n). For a tree built by inserting sorted data (1, 2, 3, 4, 5...), it degenerates into a linked list and h = O(n). This is the single biggest trap with plain BSTs — they only perform well if the data arrives in a reasonably random order, or if you use a self-balancing variant (AVL, Red-Black) that rotates nodes to cap the height.

2.2 Binary Heaps

Intricate black and white pixel art featuring circular patterns with binary symbols, showcasing abstract shapes.
Photo by Google DeepMind on Pexels

A heap is a complete binary tree (every level full except possibly the last, filled left to right) satisfying the heap property: in a min-heap, every parent is ≤ its children; in a max-heap, every parent is ≥ its children. Because it's complete, you can store it in a flat array — no pointers needed. For index i (0-indexed): parent is (i-1)//2, left child is 2i+1, right child is 2i+2.

class MinHeap:
    def __init__(self):
        self.heap = []

    def push(self, val):
        self.heap.append(val)
        self._sift_up(len(self.heap) - 1)

    def _sift_up(self, i):
        while i > 0:
            parent = (i - 1) // 2
            if self.heap[i] < self.heap[parent]:
                self.heap[i], self.heap[parent] = self.heap[parent], self.heap[i]
                i = parent
            else:
                break

    def pop(self):
        if not self.heap:
            raise IndexError("pop from empty heap")
        root = self.heap[0]
        last = self.heap.pop()
        if self.heap:
            self.heap[0] = last
            self._sift_down(0)
        return root

    def _sift_down(self, i):
        n = len(self.heap)
        while True:
            left, right = 2 * i + 1, 2 * i + 2
            smallest = i
            if left < n and self.heap[left] < self.heap[smallest]:
                smallest = left
            if right < n and self.heap[right] < self.heap[smallest]:
                smallest = right
            if smallest == i:
                break
            self.heap[i], self.heap[smallest] = self.heap[smallest], self.heap[i]
            i = smallest

push is O(log n) — you bubble the new element up at most h levels. pop is also O(log n) — you move the last element to the root and sift it down. Peeking at the minimum (heap[0]) is O(1), which is the entire point of a heap: fast access to "the best one so far" without sorting everything.

A subtlety worth internalizing: building a heap from n elements all at once is O(n), not O(n log n). If you call push n times, you pay O(n log n) total. But if you use heapify — starting from an arbitrary array and sifting down from the last non-leaf node upward — most nodes are near the bottom (where sift-down is cheap), so the total work sums to O(n). This is why Python's heapq.heapify() exists:

import heapq
data = [9, 3, 7, 1, 8, 2]
heapq.heapify(data)       # O(n), in-place, becomes a valid min-heap array
heapq.heappush(data, 0)   # O(log n)
smallest = heapq.heappop(data)  # O(log n), returns 0

Python only ships a min-heap. For a max-heap, negate values on the way in and out, or push tuples (-priority, item).

2.3 Traversals and Why They Matter

Three depth-first traversal orders answer different questions on a BST:
- In-order (left, node, right) → gives sorted output. Use this to verify a BST is valid or to extract sorted data.
- Pre-order (node, left, right) → gives you the structure/shape, useful for serializing/cloning a tree.
- Post-order (left, right, node) → processes children before the parent, needed for deletion or computing subtree sizes/heights.

def inorder(node, result):
    if node:
        inorder(node.left, result)
        result.append(node.key)
        inorder(node.right, result)

Breadth-first traversal (level order) uses a queue instead of recursion and visits nodes level by level — this is what you want for "shortest path in an unweighted tree" style questions or for printing a tree level by level.

flowchart TD
    A["Need a data structure for..."] --> B{"Fast lookup/search by key<br/>with ordering?"}
    B -->|Yes, balanced insert order| C["BST / AVL / Red-Black Tree<br/>O(log n) search, insert, delete"]
    B -->|No| D{"Need repeated access<br/>to min/max only?"}
    D -->|Yes| E["Binary Heap<br/>O(1) peek, O(log n) push/pop"]
    D -->|No| F{"Need prefix/sorted<br/>string lookups?"}
    F -->|Yes| G["Trie"]
    F -->|No| H["Plain array/list<br/>or hash map"]

3. Worked Example

Problem: Given a stream of numbers [5, 3, 8, 1, 9, 2, 7], find the 3 smallest at any point efficiently, without sorting from scratch each time.

A min-heap is exactly the wrong tool here (it tells you the single minimum fast, but finding "the 3 smallest" means popping 3 times, which destroys the structure for anything beyond that). The right tool for "top-k" problems is actually a max-heap of size k: keep only 3 elements, and when a new number is smaller than the max of those 3, swap it in.

import heapq

def smallest_k(stream, k=3):
    heap = []  # max-heap via negation
    for num in stream:
        if len(heap) < k:
            heapq.heappush(heap, -num)
        elif -num > heap[0]:  # num is smaller than current max of our k
            heapq.heapreplace(heap, -num)
    return sorted(-x for x in heap)

print(smallest_k([5, 3, 8, 1, 9, 2, 7], 3))  # [1, 2, 3]

Walk through it: push 5 → heap [-5]. Push 3 → heap size 2, [-5,-3]. Push 8 → heap size 3, [-8,-3,-5] (max is 8). New number 1: is -1 > -8? Yes → replace, heap becomes size-3 containing {1,3,5}. New number 9: -9 > current max(-top)? 9 is bigger than everything in our set of smallest-so-far, so it's rejected. New number 2: 2 < 5 (current max of the kept set), so it replaces 5. Final kept set: {1,2,3}. Total cost: O(n log k) instead of O(n log n) for a full sort — a real win when k is small and the stream is huge (think: "top 10 trending posts out of a billion events").

This also illustrates the core heap lesson: the heap only guarantees fast access to its extreme element, not fast access to arbitrary ranks. If you needed the 3rd smallest specifically (not "the 3 smallest as a set"), you'd peek heap[0] after this process — that's your answer in O(1).

4. Production Pitfalls & Best Practices

4.1 Real-World Best Practices

  • Use heapq from the standard library, don't hand-roll heaps for production code — it's C-optimized and battle-tested.
  • For a max-heap, negate on push and pop, or push (-priority, id, item) tuples. Including a unique id avoids TypeError when Python tries to compare two items with equal priority.
  • Use heapq.heapify() on existing lists instead of looping heappush — O(n) vs O(n log n).
  • Reach for a self-balancing tree (or just use a sorted container / B-tree-backed structure like sortedcontainers.SortedList) when you need guaranteed O(log n) worst case; a plain BST has no such guarantee under adversarial input.
  • Store extra metadata in heap entries (e.g., (distance, node) tuples) — this is exactly how Dijkstra's algorithm and A* search are implemented.
  • Validate a BST with in-order traversal bounds checking, not by comparing a node only to its immediate children — a common interview mistake that misses violations two levels down.

4.2 Common Bugs & Anti-Patterns

Bug 1 — Comparing only immediate children when validating a BST:

# BAD: misses violations further down the tree
def is_bst(node):
    if not node:
        return True
    if node.left and node.left.key > node.key:
        return False
    if node.right and node.right.key < node.key:
        return False
    return is_bst(node.left) and is_bst(node.right)
# FIXED: pass down valid (low, high) bounds
def is_bst(node, low=float('-inf'), high=float('inf')):
    if not node:
        return True
    if not (low < node.key < high):
        return False
    return (is_bst(node.left, low, node.key) and
            is_bst(node.right, node.key, high))

Bug 2 — Forgetting tuples need a tiebreaker in heapq:

# BAD: crashes if two tasks have equal priority and task objects aren't comparable
heapq.heappush(heap, (priority, task_object))
# FIXED: insert a counter to break ties before Python tries to compare task_object
import itertools
counter = itertools.count()
heapq.heappush(heap, (priority, next(counter), task_object))

Bug 3 — Inserting sorted data into a plain BST and expecting O(log n):

# BAD: inserting 1..1000000 in order turns the BST into a linked list
for i in range(1, 1_000_001):
    bst.insert(i)   # height becomes O(n)!
# FIXED: shuffle insertion order, or use a self-balancing structure
import random
values = list(range(1, 1_000_001))
random.shuffle(values)
for v in values:
    bst.insert(v)   # expected height now O(log n)

Bug 4 — Trying to delete an arbitrary element from a heapq efficiently:

# BAD: heap doesn't support O(log n) arbitrary deletion
heap.remove(target)       # O(n) scan!
heapq.heapify(heap)       # then O(n) again to restore order
# FIXED: lazy deletion — mark as invalid, skip when popped
entry_finder = {}  # maps item -> entry
REMOVED = '<removed>'

def mark_removed(item):
    entry = entry_finder.pop(item)
    entry[-1] = REMOVED  # entry is a list [priority, count, item]

def pop_valid():
    while heap:
        priority, count, item = heapq.heappop(heap)
        if item is not REMOVED:
            del entry_finder[item]
            return item
    raise KeyError('pop from an empty priority queue')

4.3 When to Use What (Decision Matrix)

Situation Structure Why
Need sorted order traversal + range queries BST (balanced) In-order traversal is O(n), range queries O(log n + k)
Need repeated "give me the min/max right now" Binary Heap O(1) peek, O(log n) update
Priority queue with changing priorities (e.g. Dijkstra) Heap + lazy deletion or heapq with entry_finder True decrease-key is O(log n) in a Fibonacci heap but unnecessary complexity in practice
Need guaranteed O(log n) worst case under adversarial input AVL / Red-Black Tree / sortedcontainers Plain BST can degrade to O(n)
Need "k largest/smallest" from a huge stream Heap of size k O(n log k) beats full sort O(n log n)
Need prefix search on strings Trie, not BST BST compares whole keys; trie shares common prefixes

5. Now Try It

Task: Implement kth_smallest(root, k) that returns the k-th smallest value in a BST using in-order traversal, then modify it to stop early once it hits the k-th element (don't traverse the whole tree if you don't have to).

# Starter — build this tree first, then write kth_smallest
tree = BST()
for v in [8, 3, 10, 1, 6, 14, 4, 7, 13]:
    tree.insert(v)
# kth_smallest(tree.root, 3) should return 4

Write the recursive in-order version first (collect into a list, return result[k-1]). Then rewrite it with an explicit stack-based in-order traversal that returns as soon as you've popped k nodes — this avoids visiting the whole tree when k is small. Test both against k=1 (should give 1), k=5 (should give 7), and k=9 (should give 14, the max).

Success looks like: both versions return [1, 3, 4, 6, 7, 8, 10, 13, 14][k-1] for every k from 1 to 9, and your early-stop version visits noticeably fewer nodes for small k than the full-traversal version.

Frequently asked about Non-Linear Data Structures: Trees and Heaps

You'll understand how trees organize data hierarchically and why that beats linear structures for search and priority tasks. You'll implement a binary search tree and a binary heap from scratch in Python, including insert, delete, and heapify. Read the full notes above for the details.

Non-Linear Data Structures: Trees and Heaps is a core topic in data structures and algorithms (python. Most exam papers test it via a mix of definitions, worked examples, and applied problems. The notes above cover the high-yield sub-topics, common pitfalls, and the kind of questions examiners typically set.

Yes — every note in the StudyAI Campus Hub is free to read in full, right here on this page, with no account needed. If you clone the plan into your own dashboard, the free plan shows a preview of each note there; Basic and above unlock the full notes in your dashboard, along with practice quizzes, flashcards and offline study. You can always come back here to read the complete note for free.
Continue with
Sorting and Searching Algorithms

Study this next


Get the full data structures and algorithms (python curriculum

Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.

Save this course free