Skip to content

Exercises - Measure performance

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.

X.XXXs
import timeit
# expected:
# average per run: X.XXXms

Exercise 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 — join
words = ["hello"] * 100
stmt1 = '" ".join(words)'
# approach 2 — + in a loop
stmt2 = """
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 timeit

Exercise 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 timing

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.XXXs

Exercise 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 comprehension
stmt1 = "[x for sublist in nested for x in sublist]"
# approach 2 — itertools.chain
stmt2 = "list(chain.from_iterable(nested))"
# expected:
# list comprehension : best X.XXXs
# chain.from_iterable: best X.XXXs

Exercise 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 items
setup = """
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 search
stmt1 = "target in data_list"
# approach 2 — dict lookup
stmt2 = "target in data_dict"
# expected:
# list search : best X.XXXs ← O(n) — scans every element
# dict lookup : best X.XXXs ← O(1) — direct hash lookup

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 scaling
t1 = 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 t3

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 loop
t = 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 collections
text = "the quick brown fox jumps over the lazy dog " * 1000
words = 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?

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 fastest

Exercise 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.