Proofs, recurrences, choosing a data structure, AVL trees, and heaps.
Lucas P. Cordova, Ph.D.
Willamette University | Fall 2026
What is on it and how it runs
| When | Wednesday, October 14, in class |
| Covers | The start of the course through heaps and heap sort |
| Not on it | Greedy algorithms, minimum spanning trees, shortest paths |
| How you answer | Handwritten, on the paper exam. No computers or other devices. |
| You may use | Printed copies of your own homework submissions and feedback, and of code you wrote for this course |
| You may not use | Any device, LLMs or AI assistants of any kind, the internet, another person |
Short analyses, traces by hand, one or two short proofs, and choose-a-data-structure problems, which carry the most weight.
A framework
| If the claim looks like | Use | Template |
|---|---|---|
| “For every n ≥ 0, …” or anything about a structure of size n | Induction | base case, hypothesis, step |
| “This loop computes …” | Loop invariant | initialization, maintenance, termination |
| “No … exists”, “… is always at least …”, “… must be …” | Contradiction | assume the opposite, derive something false |
| “This runs in O(…)” | Count the work | a sum for loops, a recurrence for recursion |
| “This claim is false” | Counterexample | one specific input, shown to fail |
Name the technique in your first sentence: “We prove this by induction on h.” It tells the grader what to look for and it tells you what to write next.
Point to the exact line where you used the hypothesis. If you cannot find one, it is not an induction proof yet.
A perfect binary tree of height h has exactly 2h+1 − 1 nodes.
HEIGHT 2: A ROOT PLUS TWO PERFECT TREES OF HEIGHT 1. 1 + 2(3) = 7.
A loop invariant is a statement that is true before every iteration of a loop. It describes what the loop has accomplished so far.
Initialization is the base case. Maintenance is the inductive step. Termination is the conclusion, with one extra idea: plug in the value the loop variable has when the loop stops.
1 def insertion_sort(A): 2 for i in range(1, len(A)): 3 key = A[i] 4 j = i - 1 5 while j >= 0 and A[j] > key: 6 A[j + 1] = A[j] 7 j -= 1 8 A[j + 1] = key
At the start of iteration i, A[0:i] holds the original first i items, in sorted order.
In a max-heap with distinct keys, the largest key is at the root.
Assume the opposite. Use definitions to push forward until two statements cannot both be true. Conclude the original claim.
A correct template with a gap in the step earns most of the points. A page of algebra with no stated claim earns few. Write the skeleton first.
A recipe that solves almost all of them
1 def merge_sort(A): 2 if len(A) <= 1: 3 return A 4 mid = len(A) // 2 5 L = merge_sort(A[:mid]) # one call, size n/2 6 R = merge_sort(A[mid:]) # one call, size n/2 7 return merge(L, R) # O(n) work here
T(n) = 2 T(n / 2) + n
| Symbol | Meaning | Here |
|---|---|---|
| a | how many recursive calls one call makes | 2 |
| b | how much smaller each subproblem is: size n / b | 2 |
| f(n) | work done in this call, outside the recursive calls | n |
For T(n) = a T(n / b) + nd (a ≥ 1, b > 1):
| Compare | Who does the most work | Answer |
|---|---|---|
| d < c | the leaves | Θ(nc) = Θ(nlogb a) |
| d = c | every level the same | Θ(nd log n) |
| d > c | the root | Θ(nd) |
One question: going down the tree, does the work grow (more subproblems) or shrink (smaller subproblems)?
For T(n) = a T(n / b) + Θ(nd), the comparison is between two exponents.
| Measures | In words | |
|---|---|---|
| c = logb a | how fast the number of subproblems grows | growth caused by the recursion |
| d | how expensive the work at each node is, outside the recursive calls | growth caused by the work at each node |
As I move down the recursion tree, does the total work per level get bigger, stay the same, or get smaller?
Bigger: the leaves do most of the work. The same: every level contributes equally. Smaller: the root does most of the work.
1 def find_max(A, lo, hi): # max of A[lo:hi] 2 if hi - lo == 1: 3 return A[lo] 4 mid = (lo + hi) // 2 5 left = find_max(A, lo, mid) # size n/2 6 right = find_max(A, mid, hi) # size n/2 7 return max(left, right) # O(1) work
find_max(A, 0, 8). TEAL: THE ROOT. AMBER: THE LEAVES.
1 def find_max(A, lo, hi): # max of A[lo:hi] 2 if hi - lo == 1: 3 return A[lo] 4 mid = (lo + hi) // 2 5 left = find_max(A, lo, mid) # size n/2 6 right = find_max(A, mid, hi) # size n/2 7 return max(left, right) # O(1) work
Finding the maximum by halves: T(n) = 2 T(n / 2) + 1
Each call does almost nothing, but the calls multiply. The n leaves do more than everything above them: T(n) = Θ(nc) = Θ(n).
1 def merge_sort(A): 2 if len(A) <= 1: 3 return A 4 mid = len(A) // 2 5 L = merge_sort(A[:mid]) # size n/2 6 R = merge_sort(A[mid:]) # size n/2 7 return merge(L, R) # O(n) work here
Merge sort: T(n) = 2 T(n / 2) + n
Twice as many calls, each doing half the work: it cancels exactly. No level wins, so count the levels: n work × log2 n levels = Θ(n log n).
1 def search(A, k): # binary search 2 if not A: 3 return False 4 mid = len(A) // 2 5 if A[mid] == k: 6 return True 7 if k < A[mid]: 8 return search(A[:mid], k) # copies n/2 9 return search(A[mid + 1:], k)
Binary search that slices: T(n) = T(n / 2) + n
The work shrinks and the calls do not multiply. The root wins: T(n) = Θ(nd) = Θ(n). Passing lo and hi with no slicing gives Θ(log n).
1 def blend(A, lo, hi): 2 n = hi - lo 3 if n <= 1: 4 return A[lo] if n == 1 else 0 5 h = n // 2 6 q = h // 2 7 x = blend(A, lo, lo + h) # left half 8 y = blend(A, lo + h, hi) # right half 9 z = blend(A, lo + q, lo + q + h) # middle half 10 s = 0 11 for i in range(lo, hi): # one pass 12 s += A[i] 13 return max(x, y, z, s)
T(n) = 3 T(n / 2) + n
1 def blend(A, lo, hi): 2 n = hi - lo 3 if n <= 1: 4 return A[lo] if n == 1 else 0 5 h = n // 2 6 q = h // 2 7 x = blend(A, lo, lo + h) # left half 8 y = blend(A, lo + h, hi) # right half 9 z = blend(A, lo + q, lo + q + h) # middle half 10 s = 0 11 for i in range(lo, hi): # one pass 12 s += A[i] 13 return max(x, y, z, s)
T(n) = 3 T(n / 2) + n so a = 3, b = 2, d = 1.
Two calls of size n / 2 with linear work would be n log n. A third call makes it worse, but it stays below n2. An answer between n log n and n2 is believable.
Trap 1: calls that are written but do not all run.
1 def search(A, lo, hi, k): 2 if lo >= hi: 3 return -1 4 mid = (lo + hi) // 2 5 if A[mid] == k: 6 return mid 7 if k < A[mid]: 8 return search(A, lo, mid, k) 9 return search(A, mid + 1, hi, k)
Trap 2: work hidden inside one line.
1 def search(A, k): 2 if not A: 3 return False 4 mid = len(A) // 2 5 if A[mid] == k: 6 return True 7 if k < A[mid]: 8 return search(A[:mid], k) 9 return search(A[mid + 1:], k)
| Recurrence | a | b | d | c = logb a | Compare | T(n) | Seen in |
|---|---|---|---|---|---|---|---|
| 2T(n/2) + n | 2 | 2 | 1 | 1 | d = c | Θ(n log n) | merge sort |
| T(n/2) + 1 | 1 | 2 | 0 | 0 | d = c | Θ(log n) | binary search |
| 2T(n/2) + 1 | 2 | 2 | 0 | 1 | d < c | Θ(n) | tree traversal |
| 4T(n/2) + n | 4 | 2 | 1 | 2 | d < c | Θ(n2) | |
| 3T(n/3) + n2 | 3 | 3 | 2 | 1 | d > c | Θ(n2) | |
| 2T(n/4) + √n | 2 | 4 | 1/2 | 1/2 | d = c | Θ(√n log n) |
| Rule | Statement | Use it to |
|---|---|---|
| Definition | logb x = y means by = x | compute c: log2 8 = 3 because 23 = 8 |
| Product | log(xy) = log x + log y | split log(n · n) into 2 log n |
| Quotient | log(x / y) = log x − log y | simplify log(n / 2) = log n − 1 |
| Power | log(xk) = k log x | log(n3) = 3 log n = Θ(log n) |
| Change of base | logb x = log x / log b | log4 2 = 1/2; bases differ by a constant |
| Inverse | blogb x = x | 2log2 n = n |
| Swap | alogb n = nlogb a | count leaves: 2log4 n = n1/2 = √n |
The base of a log does not matter inside Θ. And log(n!) = Θ(n log n), which is where the sorting lower bound comes from.
The recipe needs subproblems of size n / b. If the size shrinks by subtraction, unroll or draw the tree.
| Recurrence | Unroll it | T(n) |
|---|---|---|
| T(n − 1) + 1 | 1 + 1 + … + 1, n times | Θ(n) |
| T(n − 1) + n | n + (n − 1) + … + 1 = n(n + 1) / 2 | Θ(n2) |
| 2T(n − 1) + 1 | 1 + 2 + 4 + … + 2n | Θ(2n) |
The question type that matters most
This is the decision you make in real code. Nobody asks you to rotate a tree at work. They ask why the lookup is slow.
| Ask | If yes | Why |
|---|---|---|
| Do I only ever look up by exact key? | hash table | expected O(1), but no order at all |
| Do I need order: next larger, previous, range, sorted output? | balanced BST (AVL) | O(log n) for everything, order included |
| Do I only need the largest (or smallest), repeatedly? | binary heap | O(1) peek, O(log n) insert and remove, tiny and simple |
| Do I need items by position: the i-th, insert in the middle? | array, or AVL as a sequence | array: O(1) access, O(n) middle insert; AVL: O(log n) both |
| Is the data static after loading? | sorted array | O(log n) search by binary search, nothing to maintain |
| Do I need a worst-case guarantee? | avoid hashing and plain BSTs | hashing is expected-case; a plain BST can be O(n) tall |
| Structure | find(k) | insert | delete | max or min | next larger |
|---|---|---|---|---|---|
| Unsorted array | n | 1 (a) | n | n | n |
| Sorted array | log n | n | n | 1 | log n |
| Hash table | 1 (e) | 1 (e, a) | 1 (e) | n | n |
| AVL tree | log n | log n | log n | log n | log n |
| Binary heap | n | log n (a) | log n (the max) | 1 | n |
(a) amortized (e) expected All others are worst case.
Hash table: fastest lookups, no order. AVL: nothing is fast, nothing is slow. Heap: one question answered very well.
A print shop keeps a set of job numbers. It needs add(k), cancel(k), and next_after(k): the smallest job number larger than k. Which structure?
| Candidate | add | cancel | next_after |
|---|---|---|---|
| Hash table | 1 (e) | 1 (e) | n |
| Sorted array | n | n | log n |
| AVL tree | log n | log n | log n |
AVL tree. The hash table loses on next_after (no order, so it must scan). The sorted array loses on add and cancel (shifting). Trade-off accepted: O(log n) updates in place of the hash table's O(1).
| The problem needs | Choose | The deciding fact |
|---|---|---|
| An emergency room queue: add a patient with a priority, treat the most urgent next. Nothing else. | Binary heap | Only the maximum is ever asked for. O(log n) both ways, O(1) to peek. |
| A course roster: look up a student by ID, enroll, drop. Never listed in order. | Hash table | Exact-key lookups only. Expected O(1), and order is not needed. |
| An event log: record an event with a timestamp, delete one, list every event between two times. | AVL tree | A range query is an order operation. Find the start in O(log n), then walk in order. |
| A text editor line buffer: jump to line i, insert a line in the middle, delete a line. | AVL as a sequence | By position, with middle inserts. An array pays O(n) per insert. |
The roster now also needs “print all students in ID order” once a semester. Does the answer change?
Choosing a hash table when the problem needs order. Forgetting that hashing is expected-case. Quoting O(log n) for a plain BST, which is only true if it is balanced.
Review: the facts, the cases, one full insert
| skew(x) | heavy child's skew | Shape | Fix |
|---|---|---|---|
| +2 | 0 or +1 | same side | single: rotate_left(x) |
| +2 | −1 | zigzag | double: rotate_right(x.right), then rotate_left(x) |
| −2 | 0 or −1 | same side | single: rotate_right(x) |
| −2 | +1 | zigzag | double: rotate_left(x.left), then rotate_right(x) |
An insert needs at most one fix (single or double). A delete can need a fix at every level on the way up.
rotate_left(20), then rotate_right(30).LABELS: HEIGHT (SKEW). MAGENTA: UNBALANCED. AMBER: JUST INSERTED.
The lowest unbalanced node. Its skew. The shape (same side or zigzag). The rotation or rotations, by name. The final tree.
TEAL: THE NODE THE ROTATION LIFTED INTO PLACE.
Review: the array, the two moves, build
parent(i) = (i - 1) // 2left(i) = 2i + 1, right(i) = 2i + 2THE ARRAY [9, 7, 8, 2, 5, 4] AS A TREE. LABELS ARE INDICES.
insert and delete_max: O(log n) each. build: O(n). Heap sort: O(n log n), in place.
build on [2, 9, 4, 7, 5, 8]: sink down at index 2, then 1, then 0.
| Step | What happens | Array |
|---|---|---|
| start | 2 9 4 7 5 8 | |
| i = 2 | 4 has one child, 8. Swap. | 2 9 8 7 5 4 |
| i = 1 | 9 beats 7 and 5. Nothing. | 2 9 8 7 5 4 |
| i = 0 | 2 swaps with 9, then with 7. | 9 7 8 2 5 4 |
Sinking costs a node's height. Half the nodes have height 0, a quarter have height 1, and so on. The total is O(n).
Review: one table, then which one when
| Sort | Best | Worst | In place | Stable | One-line idea |
|---|---|---|---|---|---|
| Insertion | n | n2 | yes | yes | grow a sorted prefix, one item at a time |
| Selection | n2 | n2 | yes | no | repeatedly move the max to the end |
| Merge | n log n | n log n | no | yes | sort two halves, merge them |
| Heap | n log n | n log n | yes | no | build a heap, delete_max n times |
| AVL | n log n | n log n | no | yes | insert everything, traverse in order |
| Counting | n + k | n + k | no | yes | keys in 0..k: count, then place |
| Radix | n logn k | n logn k | no | yes | counting sort on each base-n digit |
Any sort that only compares keys needs Ω(n log n) comparisons in the worst case. Counting and radix sort are not comparison sorts: they use keys as array indices.
| Situation | Reach for | Because |
|---|---|---|
| Small input, or nearly sorted | insertion sort | O(n) on sorted input, O(nk) when items are at most k places off |
| Need a worst-case guarantee and no extra memory | heap sort | O(n log n) always, in place |
| Need stability (equal keys keep their order) | merge sort | stable and O(n log n), at the price of O(n) extra space |
| Integer keys in a small range | counting or radix sort | linear time, by not comparing |
| Data arrives over time and you query in between | AVL tree | it is a sort you can keep inserting into |
Sort n integers, each between 0 and n3 − 1, in O(n). Which sort, and why does it not break the lower bound?
How to use the week
Templates for proofs, a recipe for recurrences, a five-step method for choosing a structure. The exam rewards method over memory.