Exercises - Measure performance
Exercises — timeit
Section titled “Exercises — timeit”Basic Timing
Section titled “Basic Timing”Exercise 1 — 🟢 Beginner
Use timeit.timeit() to measure how long it takes to create a list of 1000 numbers using range(). Print both the total time and the average time per run.
import timeit
# expected:# average per run: X.XXXmsExercise 2 — 🟢 Beginner
Use timeit.timeit() to compare two ways of concatenating a list of 100 strings — using join() and using + in a loop:
# approach 1 — joinwords = ["hello"] * 100stmt1 = '" ".join(words)'
# approach 2 — + in a loopstmt2 = """result = ""for w in words: result += w + " """"
# expected:# join() : X.XXXs ← likely faster# + loop : X.XXXs ← likely slower# hint: use setup to make words available inside timeitExercise 3 — 🟢 Beginner
Use timeit.timeit() to compare two ways of checking if a value exists in a collection — using a list and using a set:
# expected:# list lookup : X.XXXs ← slower# set lookup : X.XXXs ← faster# hint: use setup to build the list and set before timingUsing repeat
Section titled “Using repeat”Exercise 4 — 🟢 Beginner
Use timeit.repeat() to time sorting a reversed list of 1000 numbers. Print the best, worst, and average time across 5 runs and explain why they differ:
import timeit
# expected:# all results : ['X.XXX', 'X.XXX', 'X.XXX', 'X.XXX', 'X.XXX']# best : X.XXXs ← most reliable# worst : X.XXXs ← most affected by noise# average : X.XXXsExercise 5 — 🟡 Intermediate
Use timeit.repeat() to compare two ways of flattening a list of lists — using a list comprehension and using itertools.chain. Run each 5 times and compare their minimums:
from itertools import chain
nested = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] * 100
# approach 1 — list comprehensionstmt1 = "[x for sublist in nested for x in sublist]"
# approach 2 — itertools.chainstmt2 = "list(chain.from_iterable(nested))"
# expected:# list comprehension : best X.XXXs# chain.from_iterable: best X.XXXsExercise 6 — 🟡 Intermediate
Use timeit.repeat() to compare dictionary lookup vs list search for 1000 items. Run 5 times and explain why the results differ so dramatically:
# setup — build both structures with 1000 itemssetup = """data_list = list(range(1000))data_dict = {i: i for i in range(1000)}target = 999 # worst case for list — last element"""
# approach 1 — list searchstmt1 = "target in data_list"
# approach 2 — dict lookupstmt2 = "target in data_dict"
# expected:# list search : best X.XXXs ← O(n) — scans every element# dict lookup : best X.XXXs ← O(1) — direct hash lookupThe Timer Class
Section titled “The Timer Class”Exercise 7 — 🟡 Intermediate
Use the Timer class to verify that timeit scales linearly with number — doubling number should roughly double the total time:
import timeit
timer = timeit.Timer( stmt="sum(x**2 for x in range(1000))")
# run with increasing numbers and verify linear scalingt1 = timer.timeit(1_000)t2 = timer.timeit(2_000)t3 = timer.timeit(4_000)t4 = timer.timeit(8_000)
# expected — each should be roughly double the previous:# 1,000 runs : X.XXXs# 2,000 runs : X.XXXs ← ~2x t1# 4,000 runs : X.XXXs ← ~2x t2# 8,000 runs : X.XXXs ← ~2x t3The setup Parameter
Section titled “The setup Parameter”Exercise 8 — 🟡 Intermediate
Use the setup parameter to benchmark a binary search using bisect against a linear search using in. The setup should build the sorted list once — not inside the timing loop:
import timeit
# ❌ wrong — builds the list inside the timing loopt = timeit.timeit( stmt="500 in data", setup="data = list(range(1000))", # this is correct number=100_000)
# ✅ correct — build data in setup, search in stmt# expected:# linear search (in) : X.XXXs ← O(n)# bisect : X.XXXs ← O(log n)Exercise 9 — 🟡 Intermediate
Use setup to benchmark two ways of counting word frequencies in a text — using a plain dict and using collections.Counter:
import timeit
setup = """import collectionstext = "the quick brown fox jumps over the lazy dog " * 1000words = text.split()"""
stmt1 = """freq = {}for word in words: freq[word] = freq.get(word, 0) + 1"""
stmt2 = "collections.Counter(words)"
# expected:# plain dict : X.XXXs# Counter : X.XXXs# which is faster and why?Combined Exercises
Section titled “Combined Exercises”Exercise 10 — 🟡 Intermediate
Build a compare function similar to the one in the examples, then use it to benchmark five different ways of squaring numbers from a list:
import timeit
def compare(label, stmt, setup="", number=10_000, repeat=5): # your implementation here pass
# benchmark all five approaches:compare("list comprehension", "[x**2 for x in range(1000)]")compare("map() + lambda", "list(map(lambda x: x**2, range(1000)))")compare("map() + pow", "list(map(pow, range(1000), [2]*1000))")compare("generator expr", "list(x**2 for x in range(1000))")compare("numpy", "np.arange(1000)**2", setup="import numpy as np")
# expected output:# list comprehension best: X.XXXs avg: X.XXXs# map() + lambda best: X.XXXs avg: X.XXXs# map() + pow best: X.XXXs avg: X.XXXs# generator expr best: X.XXXs avg: X.XXXs# numpy best: X.XXXs avg: X.XXXs ← likely fastestExercise 11 — 🔴 Advanced
Use timeit to investigate how the size of the input affects performance. Plot the results to visualise how each approach scales with input size:
import timeit
sizes = [100, 500, 1000, 5000, 10000]number = 1000
for size in sizes: list_comp = timeit.timeit( stmt=f"[x**2 for x in range({size})]", number=number ) map_func = timeit.timeit( stmt=f"list(map(lambda x: x**2, range({size})))", number=number ) print(f"size={size:<6} list_comp={list_comp:.3f}s map={map_func:.3f}s")
# expected output:# size=100 list_comp=X.XXXs map=X.XXXs# size=500 list_comp=X.XXXs map=X.XXXs# size=1000 list_comp=X.XXXs map=X.XXXs# size=5000 list_comp=X.XXXs map=X.XXXs# size=10000 list_comp=X.XXXs map=X.XXXs# does the relative performance change as size grows?Try running each exercise on your own machine — the absolute numbers will differ, but the relative ordering between approaches should be consistent. If you see unexpected results, run with a higher repeat value to filter out noise.