Courses · Computer Science · Grades 9–12

Data Structures

How programs organize data: Big-O thinking, stacks, queues, hash maps, and trees β€” with Python implementations.

20 lessons1 quiz1 homework1 adaptive test

Lessons

Unit 1: Foundations and Searching

🎧 Listen to this unit

Lesson 1: Big-O Intuition

An algorithm's speed isn't measured in seconds β€” hardware varies β€” but in how work grows with input size n. Big-O notation describes that growth's shape, ignoring constants. O(1) constant: one step regardless of n (reading arr[0]). O(log n) logarithmic: halving each step (binary search β€” a billion items needs ~30 checks). O(n) linear: one pass (finding a maximum). O(n log n): efficient sorts. O(n²) quadratic: nested loops over all pairs. O(2^n) exponential: trying every subset β€” infeasible beyond tiny n.

The key insight: growth class dominates everything. An O(n²) algorithm on a fast computer loses to O(n log n) on a slow one once n is large β€” doubling n quadruples quadratic work but barely nudges n log n. This is why Google can search billions of pages in milliseconds: the data structures underneath (inverted indexes, trees) turn impossible scans into logarithmic hops. When analyzing, count the dominant operation and drop constants: 3n² + 100n + 5 is O(n²).

Also consider space complexity β€” memory growth β€” and the best/average/worst cases. Linear search is O(1) best-case (first item matches) but O(n) worst-case. Amortized analysis averages costly rare operations: Python list append is O(1) amortized even though occasional resizes cost O(n), because resizes are exponentially rare. Choosing structures is choosing trade-offs: speed vs. memory, average vs. worst case, simplicity vs. performance.

Worked example — comparing two approaches to finding duplicates:
  1. Naive: for each element, scan all later elements β€” nested loops, O(n²) time, O(1) extra space.
  2. Better: sort first (O(n log n)), then one linear scan comparing neighbors β€” O(n log n) total.
  3. Best: one pass with a hash set β€” O(n) time, O(n) space. Trade memory for speed.
  4. At n = 1,000,000: O(n²) β‰ˆ 10ΒΉΒ² operations (hours/days); O(n) β‰ˆ 10⁢ (milliseconds). The growth class decides feasibility.
  5. Lesson: when n is small, simplicity wins; when n grows, asymptotics rule.

Try it yourself: Classify: (a) binary search, (b) bubble sort, (c) printing an nΓ—n multiplication table, (d) recursive Fibonacci fib(n) = fib(n-1) + fib(n-2). For (d), why is it so slow?

Listen to this lesson

Narrated audio version: Data Structures.

Lesson 2: Arrays and Linked Lists

An array (Python's list) stores elements in contiguous memory: index i lives at address base + i Γ— size, so random access is O(1). The price: inserting at the front shifts everything (O(n)), and fixed capacity forces occasional O(n) resizes (amortized away in Python). Arrays excel when you read by index and append at the end β€” the most common pattern in practice.

A linked list chains nodes, each holding data plus a pointer to the next. No contiguous block needed, and inserting/deleting at a known node is O(1) β€” just rewire pointers. The price: no random access; reaching element i means walking i links (O(n)). Doubly linked lists add backward pointers for O(1) deletion given the node. Python lists are dynamic arrays, not linked lists β€” but implementing one teaches pointers-by-reference:

class Node:
        def __init__(self, data):
            self.data = data
            self.next = None

    class LinkedList:
        def __init__(self):
            self.head = None
        def prepend(self, data):
            node = Node(data)
            node.next = self.head
            self.head = node
        def to_list(self):
            out, cur = [], self.head
            while cur:
                out.append(cur.data)
                cur = cur.next
            return out
Real-world linked structures: browser history (doubly linked), undo stacks, and hash-map buckets (next lesson). The deeper lesson is indirection: data structures are built by composing references, and every structure is a trade-off between access patterns.

Worked example — reversing a linked list in place (a classic interview problem):
  1. def reverse(head):
            prev = None
            cur = head
            while cur:
                nxt = cur.next   # save next before overwriting
                cur.next = prev  # flip the pointer
                prev = cur       # advance prev
                cur = nxt        # advance cur
            return prev
  2. Trace 1β†’2β†’3: after processing node 1, its pointer aims backward (None); node 2 aims at 1; node 3 aims at 2.
  3. Three pointers (prev, cur, nxt) β€” the standard pattern for pointer surgery.
  4. Time O(n), extra space O(1): each node visited once, no new nodes.
  5. Verify: reverse then to_list on 1β†’2β†’3 gives [3, 2, 1].

Try it yourself: Extend the LinkedList with append(data) (O(n) walk to the end) and find(value). When would you prefer a linked list over Python's built-in list? (Hint: think about frequent insertions at the front.)

Lesson 3: Searching — Linear vs. Binary Search

Linear search checks items one by one until it finds the target or runs out: simple, works on any list, but O(n) in the worst case. Binary search needs a sorted list, then repeatedly halves the search range: compare the middle, discard the half that cannot contain the target. Its cost is O(log n) — on a million items, at most 20 steps. The trade-off is universal in data structures: a little extra structure (sortedness) buys a lot of speed.

Binary search is easy to get wrong: the classic bugs are off-by-one errors in the loop bounds and infinite loops when the range stops shrinking. The robust pattern keeps lo and hi as the search window and loops while lo <= hi, setting mid = (lo + hi) // 2 and moving lo = mid + 1 or hi = mid - 1 each step — the window always shrinks, so the loop always ends.

Searching also generalizes: Python's bisect module finds insertion points in sorted lists, databases use B-trees (a generalization of binary search) to find records among billions, and autocomplete systems search tries (prefix trees) letter by letter.

Worked example — binary search for 42 in [10, 20, 30, 40, 50, 60, 70]:
  1. lo = 0, hi = 6, mid = 3 → 40 < 42, so lo = 4.
  2. mid = (4 + 6) // 2 = 5 → 60 > 42, so hi = 4.
  3. mid = 4 → 50 > 42, so hi = 3. Now lo > hi — stop. 42 is not in the list, and it would be inserted at index 4.

Try it yourself: Trace binary search for 30 in [5, 15, 25, 35, 45]. How many comparisons? Answers: mid = 25 < 30 → search right [35, 45]; mid = 35 > 30 → window empty. Not found, 2 comparisons.

Download Worksheets

Print these worksheets for offline practice and homework. Each PDF includes an answer key on the last pages (for teacher/parent use).

Key vocabulary: Linear search — checking items one by one, O(n); binary search — halving search on sorted data, O(log n); search window — the lo..hi range still under consideration.

Lesson 4: Stacks and Queues

A stack is LIFO β€” last in, first out, like a stack of plates: push adds to the top, pop removes from the top, peek looks. Both operations are O(1). Stacks model nesting and backtracking: function calls (the call stack β€” which is why infinite recursion crashes with 'stack overflow'), undo history, browser back buttons, and matching parentheses. Python lists double as stacks (append/pop()).

A queue is FIFO β€” first in, first out, like a checkout line: enqueue at the back, dequeue from the front. Queues model waiting and fairness: print jobs, task schedulers, breadth-first search. Use collections.deque for O(1) both ends β€” a plain list's pop(0) is O(n) because everything shifts. A priority queue (heapq) dequeues the smallest item first β€” the engine behind Dijkstra's shortest-path algorithm and hospital triage systems.

The parentheses-matching algorithm shows stacks' elegance: scan the string, push openers, pop on closers β€” a mismatch or leftover means unbalanced. This O(n) single pass powers syntax checkers in every compiler and editor. Deques (double-ended queues) allow both ends, useful for sliding-window problems.

Worked example — balanced parentheses checker:
  1. def is_balanced(s):
            pairs = {')': '(', ']': '[', '}': '{'}
            stack = []
            for ch in s:
                if ch in '([{':
                    stack.append(ch)
                elif ch in ')]}':
                    if not stack or stack.pop() != pairs[ch]:
                        return False
            return not stack
    
        print(is_balanced("([{}])"))  # True
        print(is_balanced("([)]"))    # False
        print(is_balanced("((("))     # False
  2. Trace ([)]: push (, push [; ) pops [ β€” mismatch β†’ False.
  3. Each character is pushed/popped at most once: O(n) time, O(n) space.
  4. The stack's LIFO order is exactly what nesting requires: the most recent opener must close first.

Try it yourself: Use a stack to reverse the string 'hello' (push each char, pop all). Simulate a queue with deque: enqueue 1,2,3, dequeue twice, enqueue 4 β€” what's left, in order? When is a queue better than a stack for processing tasks?

Unit 2: Linear Structures and Maps

🎧 Listen to this unit

Lesson 5: Hash Maps

A hash map (Python dict) stores key→value pairs with average O(1) lookup, insert, and delete — the closest thing to magic in data structures. The trick: a hash function converts each key into an array index. Looking up phone["Ada"] hashes "Ada" → index 7421 → reads that slot directly, no searching. This powers database indexes, caches, symbol tables in compilers, and the word-counting pattern from the Python course.

Collisions β€” two keys hashing to one slot β€” are inevitable (pigeonhole principle). Strategies: chaining (each slot holds a linked list of entries) or open addressing (probe for the next free slot). A good hash function spreads keys uniformly; Python hashes strings with a randomized SipHash. When the table fills past its load factor (~2/3), it resizes β€” doubling and rehashing everything, an O(n) operation amortized over many O(1) inserts.

Hash maps have limits: worst-case O(n) under adversarial collisions (hash-flooding attacks are real), no ordering (use sorted() or a tree when order matters), and keys must be hashable/immutable β€” lists can't be dict keys, tuples can. Hash sets (set) are hash maps storing only keys: O(1) membership tests, deduplication, and set algebra (union, intersection). Choosing dict-vs-list is often the single biggest performance decision in a program.

Worked example — two-sum in O(n): find indices i, j with nums[i] + nums[j] == target:
  1. def two_sum(nums, target):
            seen = {}  # value -> index
            for i, x in enumerate(nums):
                need = target - x
                if need in seen:          # O(1) lookup!
                    return (seen[need], i)
                seen[x] = i
            return None
    
        print(two_sum([2, 7, 11, 15], 9))  # (0, 1)
  2. Trace: i=0, x=2, need=7 not in {} β†’ store {2:0}; i=1, x=7, need=2 found at 0 β†’ return (0, 1).
  3. One pass: O(n) time vs O(n²) for checking all pairs. The dict remembers the past so the loop only looks forward.
  4. Space O(n) for the table β€” the classic time-memory trade.

Try it yourself: Write first_repeated(words) returning the first word appearing twice, using a set. Why can't you use a list as a dictionary key? (Try it and read the error.) Group anagrams: given ['eat','tea','tan','ate','nat','bat'], return groups of anagrams using a dict.

Lesson 6: Sets and Dictionaries in Practice

A set is an unordered collection of unique items with O(1) membership testing. Its killer use: removing duplicates and testing membership fast. set([1, 2, 2, 3]) is {1, 2, 3}; 42 in my_set is instant even for a million items, where scanning a list would be slow. Set operations mirror math: union |, intersection &, difference -. Finding the common followers of two accounts is literally a & b.

A dictionary maps keys to values with O(1) average lookup. Practical patterns: counting with counts[word] = counts.get(word, 0) + 1 (or collections.Counter); grouping by a key, e.g. students by grade; and memoization, caching expensive function results: a Fibonacci function that stores computed values in a dict drops from exponential to linear time. Choose keys that are hashable (strings, numbers, tuples) — lists cannot be keys.

Both structures trade memory for speed: they store extra bookkeeping (hash tables) so lookups skip the scan. When you need “have I seen this before?” or “look this up by name,” reach for a set or dict before anything else.

Worked example — finding the first repeated character in "abca":
  1. Keep seen = set(). Read a: not in seen, add it. b: add. c: add.
  2. Second a: already in seen — first repeat is a.
  3. Cost: one pass, O(n) time, O(n) space. A nested-loop version would be O(n²).

Try it yourself: What does {1, 2, 3} & {2, 3, 4} give? Use a dict to count letters in "banana". Answers: {2, 3}; {'b': 1, 'a': 3, 'n': 2}.

Key vocabulary: Set — an unordered collection of unique items; dictionary — key→value mapping; memoization — caching function results; hashable — usable as a dict key or set member.

Lesson 7: Trees and Recursion

A tree is a hierarchy of nodes: one root, each node having children (and one parent), with leaves having no children. File systems, org charts, HTML documents, and decision trees are all trees. A binary tree gives each node at most two children (left/right); a binary search tree (BST) orders them β€” left subtree < node < right subtree β€” enabling O(log n) search, insert, and delete when balanced.

Recursion β€” functions calling themselves β€” is trees' natural language: every subtree is itself a tree. A recursive function needs a base case (stopping condition) and a recursive step (progress toward it):

class TreeNode:
        def __init__(self, val):
            self.val = val
            self.left = self.right = None

    def inorder(node):
        if node is None:          # base case
            return
        inorder(node.left)        # recursive step
        print(node.val, end=" ")
        inorder(node.right)
On a BST, inorder traversal prints values in sorted order β€” a beautiful fact worth proving to yourself. Other traversals: preorder (node, left, right β€” copies trees), postorder (left, right, node β€” deletes safely), level order (breadth-first, using a queue).

Trees are everywhere in computing: heaps (priority queues), tries (autocomplete), parse trees (compilers), game trees (chess AI). Balanced variants (AVL, red-black) guarantee O(log n) by rotating after inserts; Python's bisect on sorted lists and heapq cover many tree use-cases without building one. The recursive mindset β€” solve a smaller instance, combine β€” also underlies divide-and-conquer algorithms like merge sort and quicksort.

Worked example — BST insert and search, then sorted output:
  1. def bst_insert(root, val):
            if root is None:
                return TreeNode(val)
            if val < root.val:
                root.left = bst_insert(root.left, val)
            elif val > root.val:
                root.right = bst_insert(root.right, val)
            return root
    
        def bst_search(root, val):
            while root:
                if val == root.val:
                    return True
                root = root.left if val < root.val else root.right
            return False
    
        root = None
        for v in [5, 3, 8, 1, 4, 7]:
            root = bst_insert(root, v)
        inorder(root)            # 1 3 4 5 7 8
        print(bst_search(root, 4))  # True
        print(bst_search(root, 9))  # False
  2. Insert walks down comparing, creating the node at the empty spot β€” duplicates ignored here.
  3. Search is iterative (no recursion needed): halve the possibilities each step, O(height).
  4. Balanced height is O(log n); degenerate (sorted input) degrades to O(n) β€” a linked list in disguise.

Try it yourself: Write recursive count_nodes(root) and tree_height(root) (height of empty tree = -1 or 0 β€” pick and be consistent). Write factorial(n) recursively, then explain why recursion depth matters in Python (hint: try factorial(2000)).

Lesson 8: Graphs — BFS and DFS

A graph is nodes (vertices) connected by edges — the model for road networks, social networks, and the web. Graphs can be directed or undirected, weighted or unweighted. Two representations: an adjacency list (each node lists its neighbors; compact for sparse graphs) and an adjacency matrix (a grid of connections; fast edge lookups, O(V²) space).

Breadth-first search (BFS) explores level by level using a queue: visit all of a node's neighbors before going deeper. It finds the shortest path (fewest edges) in an unweighted graph — the algorithm behind “degrees of separation” and GPS routing on unweighted maps. Depth-first search (DFS) dives down one path as far as possible before backtracking, using a stack (or recursion). DFS powers maze solving, cycle detection, and topological sorting (ordering tasks with prerequisites, like a build system).

Both visit each node and edge once: O(V + E). The choice is about what you need: shortest path → BFS; explore everything / detect structure → DFS. Dijkstra's algorithm generalizes BFS to weighted graphs, and it runs the world's route planners.

Worked example — BFS from A in graph A–B, A–C, B–D, C–D:
  1. Queue: [A]. Visit A, enqueue neighbors B, C. Queue: [B, C].
  2. Visit B, enqueue D. Queue: [C, D]. Visit C (D already queued). Visit D.
  3. Order: A, B, C, D. D is 2 hops from A — the shortest path.

Try it yourself: DFS from A in the same graph (neighbors in alphabetical order). What order? Answers: A → B → D → C (DFS dives to D before backtracking to C).

Key vocabulary: Graph — nodes + edges; BFS — level-by-level search, shortest path; DFS — dive-deep search; adjacency list/matrix — the two graph representations.

Unit 3: Trees, Graphs, and Heaps

🎧 Listen to this unit

Lesson 9: Heaps and Priority Queues

A heap is a tree with a special property: in a min-heap, every parent is smaller than its children, so the smallest item is always at the root. Insert and remove-min both cost O(log n). A priority queue is the interface built on a heap: insert items with priorities and always remove the highest-priority one next. Emergency rooms, operating-system schedulers, and airport boarding all run on priority queues.

Heaps are stored compactly in arrays: the children of index i are at 2i + 1 and 2i + 2. Inserting bubbles the new item up until the heap property holds; removing the root replaces it with the last item and sifts it down. Python's heapq module implements a min-heap: heapq.heappush(h, x), heapq.heappop(h). To make a max-heap, push negated values.

Heap sort builds a heap and repeatedly extracts the minimum: O(n log n) with no extra space. A classic heap application is finding the k largest items in a stream: keep a min-heap of size k and compare each new item to the heap's root — O(n log k) instead of sorting everything.

Worked example — heapq with [5, 1, 4, 2]:
  1. heapq.heapify(h) rearranges to [1, 2, 4, 5] (heap order, not sorted order).
  2. heapq.heappop(h) returns 1; heap becomes [2, 5, 4].
  3. heapq.heappush(h, 0) gives [0, 2, 4, 5] — the new minimum is on top.

Try it yourself: Use a min-heap to find the 2 smallest of [7, 3, 9, 1, 5] without fully sorting. What is the root of a max-heap? Answers: Push all, pop twice: 1, then 3. The root of a max-heap is the largest item.

Key vocabulary: Heap — a tree with the heap property; priority queue — remove-highest-priority-first structure; heapq — Python's min-heap module.

Lesson 10: Sorting Algorithms — Merge Sort and Quick Sort

Merge sort is divide and conquer: split the list in half, recursively sort each half, then merge the two sorted halves by repeatedly taking the smaller front element. Merging is O(n), and the recursion is log n levels deep, so the total is O(n log n) in all cases. It needs O(n) extra space for the merge, but it is stable (equal items keep their order) and predictable.

Quick sort picks a pivot, partitions the list into items smaller and larger than the pivot, and recursively sorts each part. Average case O(n log n), but a bad pivot (e.g. always the first element of an already-sorted list) degrades to O(n²). Real implementations pick pivots cleverly (median-of-three, random) and switch to insertion sort for tiny lists. Python's sorted() uses Timsort, which blends merge sort with insertion sort and is the practical default.

The lesson of sorting is bigger than sorting: divide and conquer turns O(n²) problems into O(n log n) ones, and choosing an algorithm means weighing worst case, average case, space, and stability — not just memorizing names.

Worked example — merge sort on [6, 2, 8, 4]:
  1. Split: [6, 2] and [8, 4]. Split again: [6], [2], [8], [4].
  2. Merge pairs: [2, 6] and [4, 8].
  3. Merge: compare fronts 2 vs 4 → take 2; 6 vs 4 → take 4; 6 vs 8 → take 6; then 8. Result [2, 4, 6, 8].

Try it yourself: Quick sort [3, 1, 4] with pivot 3: what are the two partitions? What is merge sort's worst-case Big-O? Answers: Smaller: [1]; larger: [4]. O(n log n) in the worst case too.

Key vocabulary: Divide and conquer — splitting a problem into smaller copies of itself; pivot — quick sort's dividing element; stable sort — equal items keep their original order.

Lesson 11: Choosing the Right Structure and Interview Practice

Choosing a data structure starts with the operations you need. Frequent lookups by key? A hash map. Need the minimum again and again? A heap. Ordered data with insertions at both ends? A deque. LIFO behavior? A stack. FIFO? A queue. Hierarchy? A tree. Relationships between entities? A graph. Write down the operations and their frequencies first — the structure usually picks itself.

Patterns recur in interview and real problems: two pointers (pair sums in sorted arrays), sliding window (longest substring with constraints), hash map counting (anagrams, first unique character), stack for matching (balanced parentheses), BFS for shortest path, and prefix sums for range queries. Recognizing the pattern is most of the solution.

Practice method: solve a problem on paper first, then code, then test with edge cases (empty input, one item, duplicates, maximum size). Finally, state the Big-O of time and space — interviewers and code reviewers always ask. A solution that works but cannot be explained is only half a solution.

Worked example — “Find the two numbers in [2, 7, 11, 15] that add to 9” with a hash map:
  1. Scan once, storing each number's index. For 2: need 7 — not seen; store {2: 0}.
  2. For 7: need 2 — seen at index 0. Answer: indices 0 and 1.
  3. One pass, O(n) time — versus O(n²) for checking every pair.

Try it yourself: Which structure tracks the “top 10” scores in a live game: array, heap, or linked list? Why? Answers: A heap (min-heap of size 10): new scores compare against the current 10th place in O(log 10).

Key vocabulary: Two pointers / sliding window — classic array patterns; prefix sum — running totals enabling fast range sums; trade-off — exchanging time, space, and complexity.

Lesson 12: Tries and String Algorithms

A trie (pronounced "try") is a tree for strings: each edge is a letter, and each node represents a prefix. The word "cat" is the path root β†’ c β†’ a β†’ t, and a node is marked as the end of a word. Insert and lookup each cost O(m), where m is the word's length β€” independent of how many words are stored. Tries power autocomplete (list all words under the prefix node), spell-checkers, and IP routing tables. The cost is memory: every node stores up to 26 child pointers (or a dictionary of children), so a trie uses more space than a flat list of words.

String searching has its own algorithms. The naive search slides the pattern along the text and compares β€” O(nΒ·m) worst case. The KMP algorithm (Knuth–Morris–Pratt) preprocesses the pattern to learn how far it can skip after a mismatch, reaching O(n + m): when a mismatch occurs, it reuses the partial match instead of restarting. Python's in operator and str.find() use highly optimized search internally (a mix of algorithms), so you rarely implement KMP by hand β€” but interviews and text editors depend on it.

Real-world application: every keystroke in a search box walks a trie-like structure; DNA sequencers use string algorithms to match billions of base pairs against reference genomes.

Worked example — a trie holding "car", "cat", "dog":
  1. Root has children c and d. Under c: child a. Under ca: children r and t, both marked end-of-word.
  2. Lookup "cat": follow c β†’ a β†’ t, arrive at a word-end node β†’ found, 3 steps.
  3. Autocomplete "ca": reach node ca, collect all words below it: "car", "cat". One walk, every completion found.
Worked example — naive search vs. KMP on a mismatch:
  1. Text ABABD, pattern ABABC. Naive matching: positions 0–3 match (ABAB), then text D β‰  pattern C β€” naive restarts at position 1.
  2. KMP notices the matched prefix ABAB ends with AB, which is also a prefix of the pattern, so it shifts by 2 instead of 1 and resumes comparing β€” no re-checking of characters it already matched.
  3. Result: KMP scans each text character at most twice: O(n + m) instead of O(nΒ·m).

Try it yourself: Build a trie (nested dicts) with "apple", "app", "ape". Which nodes are word-ends? In a trie with 1,000 words, how many steps does lookup of a 5-letter word take? Answers: The nodes for "app", "apple", and "ape" are word-ends (note "app" is a word and a prefix); lookup takes 5 steps β€” O(word length), not O(number of words).

Key vocabulary: Trie — a prefix tree for strings; prefix — the start of a word; autocomplete — suggesting completions for a typed prefix; KMP — a linear-time string-search algorithm; naive search — slide-and-compare string matching, O(nΒ·m).

Unit 4: Advanced Algorithms

🎧 Listen to this unit

Lesson 13: Advanced Trees — AVL, B-Trees, and Segment Trees

A plain binary search tree degrades to O(n) when input arrives sorted. Self-balancing trees fix this by restructuring after each insert. An AVL tree keeps every node's two subtrees within height 1 of each other (the balance factor); when an insert breaks that rule, rotations (local rearrangements of a few pointers) restore balance in O(1), keeping all operations at O(log n). Red-black trees use looser rules (coloring nodes red/black) for similar guarantees with less restructuring β€” Python's bisect on sorted lists and the sortedcontainers library cover many of the same needs.

B-trees generalize the idea to disk: each node holds many keys and many children, so a database can find any record among billions with only 3–4 disk reads β€” one per tree level. Every major database (SQLite, PostgreSQL, MySQL) stores its indexes in B-trees. A segment tree answers range questions fast: given an array, "what is the sum of elements 3 through 7?" in O(log n), and updates a single element in O(log n), by storing partial sums in a binary tree over the array. It is the engine behind leaderboard ranges and interval queries.

Real-world application: when you search a database, you are almost always walking a B-tree; when a game ranks players in a score range, a segment-tree-like structure answers it.

Worked example — AVL rotation after inserting 1, 2, 3:
  1. Insert 1 (root), then 2 (right child), then 3 (right child of 2). The tree leans right: balance factor of node 1 is βˆ’2 β€” unbalanced.
  2. A left rotation around node 1 promotes 2 to root, with 1 as its left child and 3 as its right child.
  3. Result: a balanced tree of height 1. Every insert checks balance on the way back up and rotates at most once or twice.
Worked example — segment tree range sum on [2, 4, 1, 7]:
  1. Root stores the total 14; its children store 6 (2+4) and 8 (1+7); leaves store individual values.
  2. Query sum of indices 1–2 (4 + 1): combine the node for index 1 (4) and the node for index 2 (1) β†’ 5, touching O(log n) nodes.
  3. Update index 0 from 2 to 5: walk down to the leaf, then recompute sums on the way up β€” O(log n).

Try it yourself: Insert 10, 20, 30 into an empty BST. What shape results, and which rotation fixes it? Why do databases prefer B-trees over binary trees? Answers: A right-leaning chain; a left rotation around 10. B-trees have high fan-out, so the tree is shallow and each level costs one disk read β€” 3–4 reads for billions of rows.

Key vocabulary: AVL tree — a self-balancing BST with balance factor ≀ 1; rotation — a local rearrangement restoring balance; B-tree — a wide, shallow tree used for database indexes; segment tree — a tree for fast range queries and updates.

Lesson 14: Advanced Graphs — Dijkstra, Minimum Spanning Trees, and Topological Sort

BFS finds shortest paths only when every edge costs the same. Dijkstra's algorithm handles weighted graphs (roads with distances, flights with fares): it uses a priority queue to always expand the cheapest unvisited node, and once a node is settled, its shortest distance is final. With a binary heap it runs in O((V + E) log V). It fails on negative edge weights β€” Bellman–Ford handles those at higher cost. Dijkstra runs every car GPS: the road network is a weighted graph, and your route is the shortest path.

A minimum spanning tree (MST) connects all nodes with the cheapest possible total edge weight β€” the cheapest way to lay cable to every building, or roads to every town. Kruskal's algorithm sorts all edges and adds the cheapest one that does not create a cycle (using a union-find structure to test cycles); Prim's algorithm grows the tree from one node, always adding the cheapest edge to an outside node. Topological sort orders a directed acyclic graph (DAG) so every prerequisite comes before its dependent β€” the order a build system compiles files, or a university lists courses: take CS101 before CS201. If the graph has a cycle, no topological order exists.

Real-world application: maps (Dijkstra), network design (MST), and course prerequisites / build systems (topological sort) are three of the most-used graph algorithms in industry.

Worked example — Dijkstra on A–B (4), A–C (2), C–B (1), B–D (3), C–D (6):
  1. Start: dist(A) = 0, others ∞. Settle A (0). Relax neighbors: dist(C) = 2, dist(B) = 4.
  2. Settle C (2). Relax: via C, B = 2 + 1 = 3 < 4, so dist(B) = 3; D = 2 + 6 = 8.
  3. Settle B (3). Relax: D = 3 + 3 = 6 < 8, so dist(D) = 6. Settle D (6). Shortest path A β†’ C β†’ B β†’ D, cost 6 β€” cheaper than the direct-looking A β†’ B β†’ D (7).
Worked example — Kruskal on edges (A–B 1, B–C 2, A–C 3):
  1. Sort edges: 1, 2, 3. Take A–B (1): no cycle, keep. Take B–C (2): no cycle, keep.
  2. Take A–C (3): A and C are already connected through B β€” adding it would create a cycle, so skip.
  3. MST = {A–B, B–C}, total cost 3. An MST on V nodes always has exactly V βˆ’ 1 edges.

Try it yourself: Topologically sort: CS101 β†’ CS201 β†’ CS301, and CS101 β†’ MATH200. Give one valid order. Why can't Dijkstra handle negative edge weights? Answers: E.g. CS101, CS201, MATH200, CS301 (any order respecting the arrows). Dijkstra assumes a settled node's distance can never improve, but a later negative edge could improve it.

Key vocabulary: Dijkstra's algorithm — shortest paths on weighted graphs with non-negative weights; MST — cheapest tree connecting all nodes; Kruskal / Prim — two MST algorithms; topological sort — prerequisite-respecting order of a DAG; union-find — a structure for tracking connected components.

Lesson 15: Dynamic Programming

Dynamic programming (DP) solves problems by breaking them into overlapping subproblems, solving each once, and reusing the answers. Its two ingredients: optimal substructure (the best solution is built from best solutions to smaller pieces) and overlapping subproblems (the same small pieces recur). The naive recursive Fibonacci recomputes fib(2) hundreds of times for fib(10); DP stores each value once. Two styles: memoization (top-down β€” recursion plus a cache dictionary) and tabulation (bottom-up β€” fill a table iteratively, usually with less overhead and no recursion limit).

Classic DP problems: 0/1 knapsack (pick items with weights and values to maximize value under a weight limit β€” each item taken at most once); longest common subsequence (LCS) (the longest sequence appearing in both strings, in order β€” used by diff tools to compare files); coin change (fewest coins for an amount); and edit distance (minimum insertions/deletions/substitutions to turn one string into another β€” the core of spell-check suggestions). The standard method: define the state, write the recurrence, pick a base case, and decide memoization vs. tabulation.

Real-world application: spell-checkers rank corrections by edit distance; genome aligners use DP to match DNA sequences; route planners cache subproblem results the same way.

Worked example — memoized Fibonacci:
  1. def fib(n, memo={}):
        if n < 2:
            return n
        if n not in memo:
            memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
        return memo[n]
  2. fib(30) now computes 31 values once each: O(n) time, O(n) space β€” versus over a million calls naive.
  3. Tabulation version: dp = [0, 1]; for i in range(2, n + 1): dp.append(dp[i-1] + dp[i-2]).
Worked example — coin change for 11 cents with coins [1, 5, 10]:
  1. Let dp[x] = fewest coins for amount x. dp[0] = 0; dp[x] = 1 + min(dp[x - c]) over coins c ≀ x.
  2. dp[10] = 1 (one dime). dp[11] = 1 + dp[1] = 1 + 1 = 2 (dime + penny).
  3. The recurrence reuses smaller answers β€” that reuse is the whole idea of DP.

Try it yourself: Compute the LCS length of "ABC" and "AC" by hand (answer: 2, "AC"). Write the recurrence for climbing stairs (1 or 2 steps at a time): ways(n) = ? Answers: ways(n) = ways(n-1) + ways(n-2), with ways(0) = 1, ways(1) = 1 β€” it is Fibonacci in disguise.

Key vocabulary: Dynamic programming — solving overlapping subproblems once and reusing answers; memoization — top-down caching; tabulation — bottom-up table filling; optimal substructure — optimal solutions built from optimal subsolutions; recurrence — the formula relating a problem to smaller versions.

Lesson 16: Greedy Algorithms and Backtracking

A greedy algorithm makes the best-looking local choice at each step and never looks back. Greedy works when a problem has the greedy-choice property: the locally optimal choice is part of a globally optimal solution. Activity selection (pick the maximum number of non-overlapping activities) is the textbook success: sort by finish time, always take the activity that ends first and does not conflict β€” provably optimal. Coin change with US coins (quarters, dimes, nickels, pennies) is greedy-optimal: always take the largest coin that fits. But greedy fails in general: with coin denominations [1, 3, 4] and amount 6, greedy takes 4 + 1 + 1 (3 coins) while the optimum is 3 + 3 (2 coins). Always ask: does the local choice guarantee the global optimum? If unsure, DP is the safe route.

Backtracking is systematic trial and error: build a candidate step by step, and when a step violates a constraint, undo it and try the next option. It is depth-first search over the space of partial solutions, with pruning β€” abandoning branches that cannot work. The N-Queens problem (place N queens on an NΓ—N board so none attack another) is the classic: place a queen row by row, backtrack when a row has no safe column. Backtracking also solves Sudoku, mazes, and permutations. Its worst case is exponential, but pruning makes many instances fast.

Real-world application: scheduling (greedy), puzzle solvers and game AI (backtracking), and route planning (greedy nearest-neighbor as a fast heuristic) all use these patterns.

Worked example — activity selection:
  1. Activities (start, finish): A(1, 4), B(3, 5), C(0, 6), D(5, 7), E(8, 9). Sort by finish: A(4), B(5), C(6), D(7), E(9).
  2. Take A (ends 4). B starts 3 < 4 β€” conflicts, skip. C starts 0 < 4 β€” skip. D starts 5 β‰₯ 4 β€” take. E starts 8 β‰₯ 7 β€” take.
  3. Maximum set: {A, D, E} β€” 3 activities. No other selection can fit 4 into this schedule.
Worked example — backtracking on 4-Queens, first steps:
  1. Place queen in row 0, column 0. Row 1: columns 0 and 1 are attacked (same column, diagonal) β€” place at column 2.
  2. Row 2: every column is attacked by the first two queens β€” dead end. Backtrack: move row 1's queen to column 3.
  3. Row 2: place at column 1. Row 3: place at column 3? Check: columns 0, 3, 1 leave column... (one of the two 4-Queens solutions is columns [1, 3, 0, 2].) The search finds it by trying and undoing.

Try it yourself: Greedy coin change for 30 cents with US coins: which coins, how many? Give a coin system where greedy fails (other than [1, 3, 4]). Answers: Quarter + nickel = 2 coins. E.g. denominations [1, 5, 6] for amount 10: greedy takes 6 + 1 + 1 + 1 + 1 (5 coins); optimal is 5 + 5 (2 coins).

Key vocabulary: Greedy algorithm — best local choice at each step; greedy-choice property — local optimum leads to global optimum; backtracking — trial and error with undo; pruning — abandoning branches that cannot succeed.

Unit 5: Mastery and Capstone

🎧 Listen to this unit

Lesson 17: Sorting II — Heap Sort, Counting Sort, and Timsort

Comparison sorts (merge, quick) cannot beat O(n log n) in the worst case β€” a mathematical lower bound. But non-comparison sorts can, when the data has structure. Counting sort counts how many times each value occurs, then writes them out in order: O(n + k) where k is the value range β€” blazing fast when k is small (sorting exam scores 0–100). Radix sort sorts digit by digit (least significant first) using counting sort as a subroutine: O(dΒ·(n + k)) for d digits. Sorting a million 32-bit integers by radix sort is linear in practice.

Heap sort builds a max-heap (O(n)) and repeatedly extracts the maximum, placing it at the end: O(n log n) worst case with O(1) extra space β€” the only classic sort with both guarantees β€” but it is not stable and its cache behavior is poor, so it rarely wins in practice. Python's sorted() uses Timsort, a hybrid: it scans for existing runs of ordered data, sorts small runs with insertion sort, and merges runs like merge sort. Timsort is O(n) on nearly-sorted data β€” the common case in real programs β€” and O(n log n) worst case, stable, and the reason you should almost never hand-roll a sort in Python.

Real-world application: databases sort with radix-like and merge-like strategies chosen by data size; spreadsheets rely on Timsort-style adaptivity for instant sorts of mostly-sorted columns.

Worked example — counting sort on [2, 1, 2, 0, 1]:
  1. Count: value 0 appears once, 1 appears twice, 2 appears twice: counts = [1, 2, 2].
  2. Write out: one 0, two 1s, two 2s β†’ [0, 1, 1, 2, 2]. No comparisons between elements β€” just counting.
  3. Time O(n + k) = O(5 + 3); space O(k) for the counts array.
Worked example — heap sort on [3, 1, 4]:
  1. Heapify into a max-heap: [4, 1, 3]. Extract 4 β†’ place at end: [1, 3 | 4]. Re-heapify the rest: [3, 1 | 4].
  2. Extract 3 β†’ [1 | 3, 4]. Extract 1 β†’ [1, 3, 4]. Sorted.
  3. Each extraction costs O(log n); n extractions β†’ O(n log n), in place.

Try it yourself: Radix sort [170, 45, 75, 90, 2]: after sorting by the ones digit, what is the order? Is counting sort a good choice for sorting a million salaries up to $1,000,000? Answers: Ones digits: 170(0), 90(0), 2(2), 45(5), 75(5) β†’ [170, 90, 2, 45, 75]. No β€” k = 1,000,001 makes the count array huge; use a comparison sort or radix sort instead.

Key vocabulary: Counting sort — sort by counting occurrences, O(n + k); radix sort — digit-by-digit sort using counting sort; heap sort — O(n log n) in-place sort via a heap; Timsort — Python's adaptive hybrid sort; stable sort — equal items keep their order.

Lesson 18: Hashing in Depth, Caches, and Bloom Filters

Lesson 5 introduced hash maps; now look inside. A hash function must be deterministic (same input β†’ same output), fast, and uniform (spreading keys evenly). Python hashes strings with SipHash, randomized per run for security β€” the same string hashes differently in different runs, which is why you must never persist raw hash values. Collision resolution comes in two families: chaining (each slot holds a linked list of entries β€” CPython's dict uses a variant of this idea) and open addressing (on collision, probe the next slot β€” linear probing, quadratic probing, or double hashing). When the load factor (entries / slots) passes ~2/3, the table doubles and rehashes everything β€” an O(n) resize amortized over many O(1) inserts.

Hashing also powers caches: a web server keeps the most-used pages in a hash map so it need not recompute them. When the cache fills, LRU eviction (Least Recently Used) drops the item unused longest β€” implemented in Python with functools.lru_cache, which memoizes a function automatically. A Bloom filter is a space-sipping cousin: a bit array plus a few hash functions that can say "definitely not in the set" or "possibly in the set" β€” it can have false positives but never false negatives. Databases and browsers use Bloom filters to avoid expensive disk or network lookups for things that do not exist.

Real-world application: password storage hashes (with salt β€” never store plain passwords), content-delivery caches, and Chrome's Safe Browsing checks all run on these ideas.

Worked example — linear probing with table size 5, hash(key) = key mod 5:
  1. Insert 12: 12 mod 5 = 2 β†’ slot 2. Insert 7: 7 mod 5 = 2 β€” collision! Probe slot 3 β†’ free, store there.
  2. Insert 17: 17 mod 5 = 2 β€” probe 3 (occupied), 4 (free) β†’ store at 4.
  3. Lookup 17: hash to 2, probe 3, 4 β€” found in 3 probes. Clusters like this are why probing schemes matter.
Worked example — lru_cache on Fibonacci:
  1. from functools import lru_cache
    @lru_cache(maxsize=None)
    def fib(n):
        return n if n < 2 else fib(n-1) + fib(n-2)
  2. The decorator caches every return value in a dict keyed by arguments β€” the two-line version of Lesson 15's memoization.
  3. fib(100) returns instantly (a 21-digit number); without the cache it would need more calls than atoms in the universe.

Try it yourself: With table size 7 and hash = key mod 7, insert 14, 21, 28 by linear probing β€” which slots? A Bloom filter says "possibly present" for a username β€” what must you do next? Answers: 14 β†’ slot 0; 21 β†’ slot 0 collides β†’ slot 1; 28 β†’ slots 0, 1 occupied β†’ slot 2. Check the real database β€” "possibly" might be a false positive.

Key vocabulary: Chaining / open addressing — the two collision strategies; linear probing — checking the next slot on collision; load factor — entries Γ· slots; LRU cache — evict the least-recently-used item; Bloom filter — a space-efficient "definitely not / possibly" membership test.

Lesson 19: Complexity, P vs. NP, and Problem-Solving Patterns

This course has classified problems by how fast they can be solved. The classes have names: P = problems solvable in polynomial time (sorting, shortest path, two-sum) β€” the "feasible" problems. NP = problems whose solutions can be verified in polynomial time. Every P problem is in NP, but the great open question of computer science β€” worth a $1 million Millennium Prize β€” is whether P = NP: can every efficiently verifiable problem also be efficiently solved? Most experts believe not. NP-complete problems (traveling salesperson, Sudoku generalized, satisfiability) are the hardest in NP: solve one efficiently and you solve them all. In practice, NP-complete problems are handled with heuristics, approximation, and backtracking with pruning β€” not exact answers at scale.

Two more ideas complete your toolkit. Amortized analysis (Lesson 1) averages rare expensive operations over many cheap ones. Space–time trade-offs recur everywhere: memoization trades memory for speed; Bloom filters trade accuracy for space; compression trades CPU time for storage. Finally, the pattern library for interviews and real work: two pointers, sliding window, hash-map counting, stack matching, BFS shortest path, prefix sums, DP, and greedy. Recognizing which pattern fits is most of the solution β€” the code is usually short once the pattern is chosen.

Real-world application: delivery companies route thousands of stops with heuristics because exact traveling-salesperson solutions are NP-hard; chip designers and airlines live with approximations daily.

Worked example — verifying vs. solving Sudoku:
  1. Given a completed Sudoku grid, checking it (every row, column, box has digits 1–9) takes a few hundred operations β€” polynomial, so Sudoku is in NP.
  2. Finding a solution from a blank-ish grid can require exponential backtracking in the worst case β€” Sudoku (generalized to nΓ—n) is NP-complete.
  3. This gap β€” easy to check, hard to solve β€” is the heart of P vs. NP.
Worked example — choosing a pattern:
  1. "Longest substring without repeating characters" β†’ sliding window + hash set: O(n).
  2. "Shortest path in an unweighted maze" β†’ BFS: O(V + E).
  3. "Fewest coins for amount X" β†’ DP: O(amount Γ— coins). Name the pattern before writing code.

Try it yourself: Is sorting in P? Is checking whether a proposed traveling-salesperson tour is under budget in NP? Answers: Yes β€” sorting is polynomial (O(n log n)). Yes β€” verifying a tour means adding its edge weights and comparing to the budget, which is polynomial.

Key vocabulary: P — polynomial-time solvable; NP — polynomial-time verifiable; NP-complete — the hardest problems in NP; heuristic — a fast rule giving good-but-not-guaranteed answers; approximation — a solution provably close to optimal.

Lesson 20: Capstone — Build an Autocomplete Engine and Route Planner

Your capstone combines the whole course into two working tools. Part A β€” Autocomplete engine: build a trie (Lesson 12) over a word list, then given a prefix, return all completions ranked by frequency (store a count at each word-end node; use a heap (Lesson 9) to return the top 5). Part B β€” Route planner: model a small city's roads as a weighted graph (Lesson 14) and run Dijkstra to answer "fastest route from A to D." Add a menu, save/load the word list and graph from JSON files, and write tests for both.

Plan before coding: data structures first (trie node: children dict, is_word, frequency; graph: adjacency dict of neighbor β†’ weight), then algorithms (trie insert/search/completions; Dijkstra with heapq), then the interface (a menu loop). Test each piece with tiny inputs: a 3-word trie; a 4-node graph whose shortest path you computed by hand in Lesson 14. Measure: time autocomplete on 10,000 words, and state the Big-O of every operation in comments.

Real-world application: search engines and map apps are these two tools at planetary scale β€” tries over billions of queries, Dijkstra over graphs with hundreds of millions of roads, plus caches and ranking on top.

Worked example — the trie node and autocomplete shape:
  1. class TrieNode:
        def __init__(self):
            self.children = {}
            self.is_word = False
            self.frequency = 0
  2. Insert "cat" twice and "car" once: the word-end node for "cat" has frequency 2, for "car" frequency 1.
  3. Autocomplete("ca") collects ("cat", 2) and ("car", 1), then a heap picks the top result: "cat".
Worked example — capstone checklist (definition of done):
  1. Trie insert/search/autocomplete with frequency ranking; Dijkstra with correct shortest paths on a 4-node test graph.
  2. JSON save/load for words and graph; menu loop; at least 6 tests (trie insert, prefix search, ranking, Dijkstra, save, load).
  3. Big-O stated for every operation; a README explaining the design choices (why a trie, why Dijkstra, why a heap).

Try it yourself: Build both tools this week. As a stretch, add "did you mean?" using edit distance (Lesson 15) for misspelled prefixes. What is autocomplete's lookup Big-O for a prefix of length m? Answers: O(m) to reach the prefix node, plus time to collect completions below it.

Key vocabulary: Capstone — a project combining everything learned; ranking — ordering results by relevance or frequency; definition of done — the checklist a project must satisfy.

Key vocabulary

Big-O
Notation describing how an algorithm's work grows with input size n, ignoring constants.
Algorithm
A precise step-by-step procedure for solving a problem.
Amortized analysis
Averaging a rare expensive operation over many cheap ones (e.g. list append is O(1) amortized).
Array
Contiguous memory storage with O(1) index access; Python's list.
Linked list
Nodes chained by pointers; O(1) insert at a known node, O(n) access by index.
Node
A container holding data plus references (e.g. next, left/right) to other nodes.
Stack
LIFO structure: push/pop at one end, both O(1).
Queue
FIFO structure: enqueue at back, dequeue at front.
Hash map
Key→value store with average-O(1) operations via a hash function (Python dict).
Hash function
Maps keys to array indices; good ones spread keys uniformly.
Collision
Two keys hashing to the same slot; resolved by chaining or open addressing.
Tree
Hierarchical node structure with one root; no cycles.
Binary search tree
Ordered binary tree: left < node < right; O(log n) ops when balanced.
Recursion
A function calling itself; needs a base case and progress toward it.
Traversal
Visiting every node of a tree: inorder, preorder, postorder, or level order.

Quizzes & Exams

Check your understanding

Adaptive test

15 questions that adapt to your level.

Homework

Interactive Lab

Hands-on tools for this course. Everything runs right here in your browser β€” drag, slide, click, and experiment.

Further Study on YouTube

These free videos from trusted educators go deeper on this course's topics.