Skip to main content
PProfileMatch

Data Structures: Big-O in Practice and Choosing by Operation

Data Structures & AlgorithmsLesson 1 of 46 min read

What you'll learn

  • Measure the cost of an operation by counting the work done instead of trusting a stopwatch
  • Read O(1), O(log n), O(n), O(n log n) and O(n^2) and know how each one scales
  • Choose between list, set, dict, deque and heapq from the operations a task needs
  • Spot the accidental O(n^2) hiding in a loop that calls in on a list

On this page

  1. Count the work, not the seconds
  2. Reading Big-O without the maths
  3. The accidental quadratic
  4. Choose by the operations you need
  5. Common mistakes
  6. How this shows up in real projects and interviews

A festival check-in desk issued 2,000 wristbands and scans each one at the gate. The first prototype stored issued wristbands in a list and asked "is this one valid?" with a plain in. It felt instant during testing with 50 wristbands and crawled on opening day. Nothing was wrong with the logic; the program was simply doing far more work per scan than anyone realised. Big-O is the vocabulary for talking about that work, and choosing a data structure is mostly choosing which operations will be cheap.

Count the work, not the seconds

Stopwatch timings depend on your laptop, Python version and what else is running. Counting is more honest. The class below counts every time Python has to compare two wristbands for equality, so you can see exactly how many comparisons a membership test costs.

wristband_counts.py
class Wristband:
    scans = 0

    def __init__(self, code):
        self.code = code

    def __eq__(self, other):
        Wristband.scans += 1
        return self.code == other.code

    def __hash__(self):
        return hash(self.code)


issued = [Wristband(f"W{n:04d}") for n in range(2000)]

# Linear search: list membership
Wristband.scans = 0
probe = Wristband("W1999")
print("in list ->", probe in issued, "| equality checks:", Wristband.scans)

Wristband.scans = 0
print("missing  ->", Wristband("W9999") in issued, "| equality checks:", Wristband.scans)

# Hash lookup: set membership
gate = set(issued)
Wristband.scans = 0
print("in set   ->", probe in gate, "| equality checks:", Wristband.scans)
Wristband.scans = 0
print("missing  ->", Wristband("W9999") in gate, "| equality checks:", Wristband.scans)

Output

in list -> True | equality checks: 2000
missing  -> False | equality checks: 2000
in set   -> True | equality checks: 1
missing  -> False | equality checks: 0

The list had to walk past every earlier wristband to find the last one, and for a missing wristband it checked all 2,000. The set jumped almost straight to the right spot. Double the wristbands and the list work doubles; the set work does not change. Those two behaviours are called O(n) (linear) and O(1) (constant, on average). The set's single equality check is the confirmation that the item found in the bucket really is the one you asked for.

Reading Big-O without the maths

Big-O describes how the amount of work grows as the input size n grows, ignoring constant factors. It is a growth rate, not a speed. The table shows what the common rates look like at two sizes.

GrowthNamen = 1,000n = 1,000,000Typical source
O(1)constant11dict/set lookup, list index, append
O(log n)logarithmicabout 10about 20binary search, heap push/pop
O(n)linear1,0001,000,000one pass, in on a list
O(n log n)linearithmicabout 10,000about 20,000,000comparison sorting
O(n^2)quadratic1,000,0001,000,000,000,000nested loops over the same data

The last column is where the wins are. A quadratic algorithm at a million items is a trillion steps; nobody waits for that. A linear one at the same size finishes comfortably. Most performance fixes in everyday code are moving from the bottom row of the table up one or two rows.

The accidental quadratic

Here is the pattern that bites people most often: finding repeated scans by comparing every pair, versus remembering what you have already seen. Both functions return the same answer, and the counters show the price.

duplicate_scans.py
def duplicate_scans_pairs(scans):
    comparisons = 0
    dupes = []
    for i in range(len(scans)):
        for j in range(i + 1, len(scans)):
            comparisons += 1
            if scans[i] == scans[j] and scans[i] not in dupes:
                dupes.append(scans[i])
    return dupes, comparisons


def duplicate_scans_set(scans):
    seen, dupes, lookups = set(), [], 0
    for code in scans:
        lookups += 1
        if code in seen:
            if code not in dupes:
                dupes.append(code)
        else:
            seen.add(code)
    return dupes, lookups


for n in (100, 400, 1600):
    scans = [f"W{i % (n - 5):04d}" for i in range(n)]
    d1, c1 = duplicate_scans_pairs(scans)
    d2, c2 = duplicate_scans_set(scans)
    assert d1 == d2
    print(f"n={n:5d}  pairwise comparisons={c1:8d}  set lookups={c2:5d}  dupes={len(d1)}")

Output

n=  100  pairwise comparisons=    4950  set lookups=  100  dupes=5
n=  400  pairwise comparisons=   79800  set lookups=  400  dupes=5
n= 1600  pairwise comparisons= 1279200  set lookups= 1600  dupes=5

Input grew 16 times (100 to 1,600) and the pairwise version did about 258 times more work, because n x (n-1) / 2 grows with the square. The set version did exactly 16 times more. That gap is the entire reason interviewers ask you to "do it in one pass".

Choose by the operations you need

Do not start with "which structure is best?". Start with a list of the operations your code performs most and match them to a structure that makes those cheap. The costs below are for CPython's built-ins.

You need to...Reach forCostWatch out for
Read or write by positionlistO(1) by indexInserting or deleting near the front is O(n)
Add and remove at both endscollections.dequeO(1) at each endIndexing in the middle is O(n)
Ask "have I seen this?"setO(1) averageItems must be hashable; no order guarantee
Look up a value by keydictO(1) averageKeys must be hashable
Count occurrencescollections.CounterO(1) per updateIt is a dict underneath
Always take the smallest nextheapq on a listO(log n) push/popOnly index 0 is guaranteed smallest
Keep sorted and search by valuesorted list + bisectO(log n) searchInserting keeps O(n) shifting

One set of scans can answer several different questions, and each question suggests a different container. The snippet below uses a Counter for per-gate totals, dict.fromkeys to de-duplicate while keeping first-seen order, and a set comprehension for repeat entries.

three_questions.py
from collections import Counter

# Scans from three gates, in arrival order
scans = [
    ("north", "W0007"), ("south", "W0012"), ("north", "W0007"),
    ("east",  "W0031"), ("south", "W0012"), ("north", "W0044"),
    ("south", "W0012"),
]

per_gate = Counter(gate for gate, _ in scans)
first_seen_order = list(dict.fromkeys(code for _, code in scans))
repeat_entries = {code for code, n in Counter(c for _, c in scans).items() if n > 1}

print(per_gate)
print(first_seen_order)
print(sorted(repeat_entries))

Output

Counter({'north': 3, 'south': 3, 'east': 1})
['W0007', 'W0012', 'W0031', 'W0044']
['W0007', 'W0012']

Common mistakes

  • Calling `in` on a list inside a loop. Symptom: the job takes a second at 1,000 rows, then minutes at 100,000, and the profiler points at a line that looks innocent. Build a set once, outside the loop.
  • Using a list as a queue with `pop(0)`. Symptom: processing speed degrades steadily as the backlog grows. Every pop shifts the rest; use a deque (next lesson).
  • Putting a list or dict into a set or as a dict key. Symptom: TypeError: unhashable type: 'list'. Convert to a tuple, or a frozenset when order does not matter.
  • Expecting a set to keep order. Symptom: output order differs from the input, and may differ between runs for strings. Use dict.fromkeys or a list when order matters.
  • Optimising before measuring. Symptom: clever code that saves nothing because the real cost was a database call. Count or profile first, then change structures.

How this shows up in real projects and interviews

In day-to-day work, most speedups come from one change: turn a list that is searched repeatedly into a set or dict, or stop re-sorting inside a loop. Code review comments like "this is quadratic once the table grows" are common. In interviews, you are usually asked for the time and space complexity of your own solution, and the expected move is to start with the obvious pairwise version, name its cost, and then improve it with a hash-based container. You pay for the speed with memory, so say that trade-off out loud. The same thinking continues in Arrays and Strings, and when ordering matters, in Sorting Algorithms. Next, the stacks and queues lesson covers the structures that decide order of processing.

Try it yourself

A shuttle company logs boarding passes as strings. Write first_repeat(passes) that returns the first pass code that appears a second time, or None. Add a counter for lookups, run it on 100 and 10,000 passes where the repeat is at the very end, and confirm the work grows about 100 times, not 10,000 times. Then write the version using in on a list and compare the counts.

Show a hint

Keep a set of codes seen so far. The first code that is already in the set when you reach it is the answer. For the comparison version, replace the set with a list and count equality checks with a wrapper class like the Wristband example.

Key takeaways

  • Big-O describes how work grows with input size; counting operations is a more reliable check than a stopwatch.
  • Membership tests are O(n) on a list and O(1) on average for a set or dict.
  • Nested loops over the same data are the usual source of accidental O(n^2) code.
  • Choose a structure by listing the operations you perform most, then pick the one that makes them cheap.
  • Hash-based speed costs memory and requires hashable keys; mention the trade-off in interviews.

Test yourself

A trick from this lesson's ride: Ask "what if n doubled?" The exponent tells you the multiplier. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is Big-O notation in simple terms?

Big-O describes how the work an algorithm does grows as the input gets bigger, ignoring constant factors. O(n) means work grows in proportion to the input; O(n^2) means doubling the input roughly quadruples the work. It tells you about scaling, not exact speed.

Is a set faster than a list in Python?

For membership tests, yes: x in some_set is O(1) on average while x in some_list is O(n). A set uses more memory, has no ordering or indexing, and its items must be hashable. For tiny collections or when you need order and positions, a list is fine.

How do I choose the right data structure?

Write down the operations your code does most: lookup by key, add and remove at the ends, take the smallest, keep sorted, count items. Then match each to the structure that makes it cheap, such as dict, deque, heap or set. If several fit, take the simplest one and measure before optimising.