The brief
You have spent four lessons predicting how code grows. Now you will make the computer check your predictions. The assignment is to build a complexity lab: a Python program that runs several functions on inputs of growing size, counts the steps each one takes, and decides from those counts which growth class each function belongs to.
This is a real technique. Engineers call it an empirical growth test, and they use it when a function is too tangled to analyse by hand or when they want to confirm that a change really did turn a quadratic loop into a linear one. It is also a good way to find out whether your own analysis is right, because the machine does not make arithmetic mistakes.
The assignment has four parts and one stretch. Plan about an hour. Write each part as its own function and test it before moving on. Do not read the hints until you have tried each part for at least ten minutes. The reference solution at the end is for checking your work, not for copying: if you copy it you will learn much less than if you get stuck, think, and then compare.
What you will build
Part A: instrumented functions. An instrumented function is one with a counter inside it that goes up each time it does one unit of work. Write seven functions. Each takes a size n and returns the number of steps it took.
| Function | What it does | Step you count | Expected class |
|---|---|---|---|
first_item(n) | reads the first element of a list of n items | one read | O(1) |
binary_search_worst(n) | binary search for a target larger than every item | one loop pass | O(log n) |
linear_search_worst(n) | linear search for a value that is missing | one comparison | O(n) |
merge_sort_comparisons(n) | merge sort of n pseudo-random numbers | one comparison during merging | O(n log n) |
duplicate_pairs(n) | compares every pair in a list with no duplicates | one pair comparison | O(n^2) |
triple_checks(n) | visits every triple of positions with i < j < k | one triple | O(n^3) |
fib_calls(n) | naive recursive Fibonacci | one call | exponential |
Part B: the classifier. Write classify(sizes, counts). It receives the input sizes you tried and the step counts you measured, and returns a label. The idea is to compare how much the count grew with how much the input grew. If the size multiplied by k and the count multiplied by g, then the growth exponent is log(g) / log(k). For a function that is O(n^2), the exponent is about 2. For O(n) it is about 1. For O(1) it is about 0.
Use these cut-offs on the exponent:
| Exponent below | Label |
|---|---|
| 0.05 | O(1) |
| 0.30 | O(log n) |
| 0.75 | O(sqrt n) |
| 1.07 | O(n) |
| 1.40 | O(n log n) |
| 2.40 | O(n^2) |
| 3.40 | O(n^3) |
| anything higher | worse than polynomial |
The cut-offs sit between the ideal exponents, with room for the small terms that Big-O throws away. A merge sort measured between 1,000 and 8,000 items has an exponent near 1.14, because n log n grows slightly faster than n, which is why O(n log n) begins at 1.07. Logarithms have a small positive exponent over a finite range, which is why O(log n) extends up to 0.30.
Part C: the report. Write a function that runs every lab entry and prints one aligned row per function with the function name, the list of counts and the class. Use the sizes in the starter table below.
Part D: written analysis. Answer five short questions about your results, in a comment block or a text file. They are listed at the end.
Stretch: space and amortised cost. Measure the recursion depth of a recursive factorial and a recursive binary search, and classify those depths with the same classify. Then measure the cost per append of a doubling dynamic array and show that it stays near a constant.
Input and output
The program takes no input. It prints a table. Two behaviours you can check by hand while you build it:
classify([1000, 2000, 4000, 8000], [1000, 2000, 4000, 8000]) # counts grew 8x as n grew 8xO(n)classify([100, 200, 400], [100, 400, 1600]) # counts grew 16x as n grew 4xO(n^2)classify([10, 20, 40], [5, 5, 5]) # counts did not growO(1)The sizes to use for each lab entry are:
| Function | Sizes |
|---|---|
first_item, binary_search_worst, linear_search_worst, merge_sort_comparisons | 1000, 2000, 4000, 8000 |
duplicate_pairs | 100, 200, 400, 800 |
triple_checks | 20, 40, 80, 160 |
fib_calls | 10, 12, 14, 16 |
Why different sizes? Each function has its own speed. A triple loop on 8,000 items would take hundreds of billions of steps. Choosing sizes the machine can finish in a second or two is part of designing a lab.
The report your program should print looks like this. Your counts for merge sort depend on the way you generate the random numbers, so yours may differ slightly, but the class must match.
function counts class
first item [1, 1, 1, 1] O(1)
binary search, missing target [10, 11, 12, 13] O(log n)
linear search, missing target [1000, 2000, 4000, 8000] O(n)
merge sort comparisons [8715, 19419, 42856, 93640] O(n log n)
all pairs, no duplicate [4950, 19900, 79800, 319600] O(n^2)
all triples [1140, 9880, 82160, 669920] O(n^3)
naive fib calls [177, 465, 1219, 3193] worse than polynomialRequirements checklist
Copy this list into your notes and tick items as you go.
-
first_item,binary_search_worst,linear_search_worst,merge_sort_comparisons,duplicate_pairs,triple_checksandfib_callseach takenand return a step count. - Every counter counts the unit named in the table, not seconds.
-
merge_sort_comparisonssorts pseudo-random data with a fixed seed, so the report is the same on every run. -
classify(sizes, counts)uses the ratio of the last count to the first count and the ratio of the last size to the first size. -
classifyreturns the labels listed in the table, includingworse than polynomial. - A function that always takes one step is labelled
O(1), notO(n). - The report prints seven aligned rows, one per function.
- Every class printed matches the expected class in Part A.
- Part D answers all five questions in your own words.
- Stretch: recursion depth of factorial is classified
O(n)and of recursive binary searchO(log n). - Stretch: cost per append stays under 3 for n = 10, 1,000 and 100,000.
Part D: the five questions
- Your
classifyfor merge sort uses the exponent from 1,000 to 8,000 items. Explain in two sentences why a puren log nfunction has an exponent above 1, and why that does not make it quadratic. binary_search_worstreturned 10, 11, 12, 13 for the four sizes. Explain why each doubling of n adds exactly one step.fib_callsis labelledworse than polynomial. Estimate its calls for n = 40 from the n = 16 count and a growth factor of about 1.6 per step of n, and say whether you would wait for it.- What is the auxiliary space of
merge_sort_comparisonsas written, counting the stack? Is the stack or the lists the larger part? - A friend says "the lab proves
linear_search_worstis O(n)". Why is that not quite right, and what would a proof need?
Grading rubric
| Level | What it looks like |
|---|---|
| 60 percent | Parts A and B work for the first four functions (O(1), O(log n), O(n), O(n log n)). classify is written but the cut-offs are rough, so one or two classes are wrong. The report prints but is not aligned. Part D is partly answered. |
| 80 percent | All seven functions work and all classes are correct. classify uses the exponent and the specified cut-offs. The report is aligned and runs in under a few seconds. Part D answers all five questions, with correct reasons for at least four. Code is clean: no repeated blocks, clear names. |
| 100 percent | Everything in the 80 percent level, plus the stretch: recursion depth and amortised cost are measured and explained. The report is built from one data table so adding a function means adding one line. Part D answers include the limits of an empirical test (see question 5), such as the size range, small terms and worst-case inputs. You can state, for each function, the analysis you did by hand and show that the lab agrees. |
Hints
Open a hint only after you have tried the step yourself.
Hint 1: the counters
Start with linear_search_worst. Make a list items = list(range(n)), loop over it, add one to steps per comparison and return steps. Do not break early: the target is missing, so the loop must run to the end. The other functions follow the same shape: a counter variable, a loop or recursion, and a return of the counter.
Hint 2: binary search with a missing target
Use low = 0, high = n - 1. While low <= high, add one to steps, take the middle, and because the target is larger than every item always move low = mid + 1. The loop ends when low passes high. Check your answer by hand for n = 8: the range shrinks 8, 4, 2, 1, so about 4 passes.
Hint 3: counting inside a recursive function
The counter must survive between calls. Use a list with one element, calls = [0], and an inner function that adds to calls[0]. A plain integer assigned inside the inner function would create a new local variable, and Python would raise UnboundLocalError if you used += on it without nonlocal.
Hint 4: merge sort with a seeded random list
random.Random(n) creates a random number generator seeded with n, so every run with the same n gives the same data. Build the list with rng.randint(0, 10**6). Count one step each time you compare left[i] with right[j] in the merge loop. Sort by recursion on slices; the slices cost extra copying, but we are counting comparisons, not copies.
Hint 5: classify
Compute growth = counts[-1] / counts[0] and scale = sizes[-1] / sizes[0], then exponent = math.log(growth) / math.log(scale). Walk through the cut-off list in order and return the label of the first cut-off the exponent is below. If a count is constant, growth is 1 and math.log(1) is 0, so the exponent is 0 and the label is O(1).
Hint 6: the report
Store the lab as a list of tuples (name, function, sizes). The report is one loop over that list: counts = [fn(n) for n in sizes]. Use f-string widths such as {name:<32} to align the columns.
Hint 7: the stretch
For the recursion depth, pass a depth argument that is 1 at the first call and depth + 1 in each recursive call, and keep the largest value in a one-element list. For the amortised cost, reuse the simulation from lesson four: add 1 for each write, add size when the array is full, double the capacity, and divide the total by n.
Reference solution
Try the whole assignment before opening this. The program below is complete. Run it, compare the output with yours, and read the parts where yours differs.
Solution
function counts class
first item [1, 1, 1, 1] O(1)
binary search, missing target [10, 11, 12, 13] O(log n)
linear search, missing target [1000, 2000, 4000, 8000] O(n)
merge sort comparisons [8715, 19419, 42856, 93640] O(n log n)
all pairs, no duplicate [4950, 19900, 79800, 319600] O(n^2)
all triples [1140, 9880, 82160, 669920] O(n^3)
naive fib calls [177, 465, 1219, 3193] worse than polynomial
recursion depth (space)
recursive factorial [100, 200, 400, 800] O(n)
recursive binary search [8, 9, 10, 11] O(log n)
amortised append cost
n = 10 cost per append = 2.500
n = 1000 cost per append = 2.023
n = 100000 cost per append = 2.311Some design choices worth noticing:
- Every counter is an integer, never a time. That makes the output identical on every machine.
LABis a table, so adding a function means adding one tuple. The report code does not change.classifyreturns the label of the first cut-off the exponent is below, so the cut-off list must stay in increasing order.merge_sort_comparisonsseeds the random generator withn. Without a seed, counts would change slightly from run to run, although the class would not.- The recursion-depth functions pass
depthdown instead of reading the stack, which makes the measurement portable.
Model answers to Part D
Solution
- The function
n log ngrows slightly faster thann, because the factorlog nitself grows as n does. Over an eight-fold increase in n,log2 ngoes from about 10 to about 13, so the count grows about8 * 1.3 = 10.4times, an exponent of about 1.13. It is not quadratic, because for a quadratic function the same eight-fold increase would give 64 times the count. - Binary search halves the range each pass. Doubling n adds one more halving before the range reaches nothing, because
nand2ndiffer by exactly one factor of 2. So the number of passes is aboutlog2(n)plus a small constant, and each doubling adds 1. - From n = 16 to n = 40 is 24 steps, so the growth is about
1.6^24, around 80,000. Starting from 3,193 calls that gives about 250 million calls, a few minutes in Python. You would wait, but at n = 50 it becomes about 40 billion calls and you would not. - The lists created during merging total O(n) at their peak (sizes n, n/2, n/4, and so on add to at most 2n). The recursion stack is only O(log n) deep. So the auxiliary space is O(n) and the lists are the larger part.
- The lab tests a few sizes, not every size, so it cannot prove a statement about all n. It also measures one input per size, such as the missing target, and says nothing about other inputs. A proof states the step count as a formula, here exactly n, and shows it is at most a constant times n for all n beyond some
n0. The lab is evidence, not proof, and a good piece of evidence is one that agrees with a hand analysis.
Extensions if you want more
- Add a function for
most_common_slowandmost_common_fastfrom lesson one, and see the lab call the first quadratic and the second linear. - Make the lab accept a function that reads its input, so that you can type a list size and see the step count live.
- Draw the counts as a table of ratios (each count divided by the previous one). For a quadratic function the ratio approaches 4 each time n doubles. For an O(n log n) function it falls toward 2 from above. Watching the ratios settle is a good way to see the classes.
- Replace the single exponent with a fit across all four sizes (average the exponents of consecutive pairs) and see whether the answer becomes steadier.
Summary of the module
- Complexity counts steps against input size and drops constants and smaller terms; Big-O is an upper bound, Omega a lower bound and Theta the tight bound.
- The common classes, in order, are O(1), O(log n), O(n), O(n log n), O(n^2), O(n^3), O(2^n) and O(n!); the size limit on n tells you which one you can afford.
- Count loops by the number of times the inner statement runs, and recursions by writing and solving a recurrence or drawing the recursion tree.
- Space complexity counts auxiliary memory, including the recursion stack, and many speedups spend space to save time.
- Amortised analysis explains why a doubling dynamic array appends in O(1) on average, and why growing by a constant would not.