Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsbeginner 60m read

L02 · Big-O in Practice — Counting, Not Vibes

How to derive the cost of a Python data structure operation from how it is built, instead of memorising a table you half-remember under pressure.

🧩DSAPhase 0 · Foundations· Session 002 of 130 60 min

🎯 Learn to count the cost of an algorithm by counting operations, and to derive the cost of each Python built-in from how it is implemented — so complexity becomes reasoning, not recall.

Series: LeetCode — From Basics to Interview-Ready · Session 2 / 65 · Phase 0 · Foundations

What this session IS — counting operations

Big-O is not a stopwatch. It does not say "this takes 3 seconds." It says how the work grows as the input grows. If doubling the input roughly doubles the work, that is O(n). If doubling the input quadruples the work, that is O(n^2). If doubling the input adds only a constant sliver, that is O(log n).

The naive way people "learn" this is to memorise a table: "dict lookup is O(1), list .index() is O(n)…" Then under pressure they blank, or worse, guess wrong. The reliable way — the one this session teaches — is to count the operations by asking "how is this thing built, and how much work does one call do?" Once you can derive the cost, you never have to trust your memory.

The analogy
🌍 Real world
A recipe that says "stir once per guest" scales linearly with guests. A recipe that says "introduce every guest to every other guest" scales with guests squared — 10 guests is 45 introductions, 100 guests is 4,950.
💻 Code world
A single loop over n items is "stir once per guest" — O(n). A loop inside a loop is "every guest meets every guest" — O(n^2). You count the nesting, not the clock.

Why "vibes" fail

Ask someone the cost of if x in my_list versus if x in my_set. By vibes both look like one line. By counting: in on a list checks each element until it finds x — up to n comparisons, O(n). in on a set hashes x and jumps straight to its bucket — O(1) average. Same syntax, hundred-thousand-fold difference at scale. Vibes cannot see that; counting can.

The counting rules

To find the Big-O of a block of code, apply these mechanically:

1. A single statement (assignment, arithmetic, comparison) = O(1).
2. A loop that runs n times = multiply its body's cost by n.
3. Nested loops = multiply their counts (n * n = n^2, n * m = n*m).
4. Sequential blocks = ADD, then keep the biggest (O(n) + O(n^2) = O(n^2)).
5. Drop constants and lower terms (3n + 5 -> O(n); n^2 + n -> O(n^2)).
6. A call to a built-in = look up (or derive) ITS cost and substitute it.

Rule 6 is where people trip: list.sort() inside a loop is not "one line," it is O(n log n) per iteration.

From first principles
Start with the question
Why do we drop constants and keep only the biggest term?
  1. 1
    Big-O describes behaviour as n grows toward infinity.
    forced by · That is where scaling problems live; small n is fast on anything.
  2. 2
    For large n, the fastest-growing term dwarfs the others.
    forced by · At n = 10^6, n^2 is 10^12 while the +n and +5 are rounding error.
  3. 3
    A constant multiplier (like 3n) does not change the growth SHAPE, only its steepness.
    forced by · 3n and n both double when n doubles; they are the same class.
  4. 4
    Therefore only the dominant term, stripped of constants, characterises scaling.
    forced by · It is the part that decides whether you fit the judge's budget.
⇒ Therefore
Big-O keeps the single fastest-growing term with its constant removed, because that term alone determines whether the algorithm survives large inputs.

The Python cost table — and how each row is derived

Memorising this is fine, but derive it once so you can rebuild it cold:

OperationCostWhy (how it is built)
list[i] (index)O(1)Python lists are arrays; the address is a direct offset.
list.append(x)O(1) amortisedUsually a free slot; occasionally the array is resized (rare, averaged out).
list.insert(0, x) / list.pop(0)O(n)Everything after the slot must shift by one.
x in listO(n)Scans until found or exhausted.
list.sort()O(n log n)Timsort — a comparison sort, which cannot beat n log n.
dict[k] / k in dictO(1) avgHashes k, jumps to a bucket.
set add / x in setO(1) avgSame hashing as dict.
heapq.heappush/heappopO(log n)Bubbles one element up/down a binary heap of height log n.
deque.appendleft/popleftO(1)Doubly linked block list — both ends are cheap.
str concatenation in a loopO(n^2)Strings are immutable; each += copies the whole prefix. Use "".join(parts).

The killers, learned the hard way, are the O(n) operations that look free: list.pop(0), x in list, and str += in a loop. Reach for deque, a set, and "".join respectively.

The template — annotating a function's cost

Here is the mechanical procedure applied to a real snippet. Read the comments as the counting.

def has_duplicate_slow(nums):        # nums has length n
    for i in range(len(nums)):       # outer loop: runs n times
        for j in range(len(nums)):   # inner loop: runs n times  -> n * n
            if i != j and nums[i] == nums[j]:  # O(1) body
                return True
    return False
# Cost: n * n * O(1) = O(n^2). Two nested loops over n -> square it.
 
def has_duplicate_fast(nums):        # nums has length n
    seen = set()                     # O(1)
    for x in nums:                   # loop runs n times
        if x in seen:                # set membership: O(1) average
            return True
        seen.add(x)                  # O(1) average
    return False
# Cost: n * O(1) = O(n). One pass, O(1) per step.

Same problem, O(n^2) versus O(n), decided entirely by "is the inner check a scan or a hash?" — rule 6 in action.

Mental modelMultiply down, add across, keep the biggest
Read the code top to bottom. Each loop is a multiplier stacked on its body. Sibling blocks are added. Then you erase everything except the fastest-growing term and its shape.
  • Nesting multiplies (loop in loop = product of counts).
  • Sequence adds (block after block = sum, keep the max).
  • A built-in call substitutes ITS cost, not O(1) by default.
  • Strip constants and lower terms at the very end.
🔔 Fires when you see
Any time you want the complexity of code you wrote: point at each loop and each built-in and say its cost out loud, then combine.

Memory technique — the "loops and lookups" chant

Two questions recover almost every complexity you will meet in interviews:

  1. "How many loops, and are they nested?" → gives you the n, n^2, n^3 skeleton.
  2. "What's inside — a scan or a lookup?" → a set/dict lookup keeps the body O(1); an in list or .index() secretly multiplies by another n.

Chant it: "Count the loops, then check the lookups." The loops set the exponent; a hidden linear lookup bumps that exponent up by one. If you catch yourself writing something in a_list inside a loop, a bell should ring: that is an accidental n^2.

Common misconception
✗ What most people think
One line of code is one operation, so a one-line solution is O(1) or at worst O(n).
✓ What is actually true
A single line can hide arbitrary cost. `sorted(x)` is O(n log n); `x in a_list` is O(n); a list comprehension over n items is O(n).
Why the myth is so sticky
Big-O counts the operations performed, not the characters typed. Built-ins and comprehensions loop internally; you must substitute their real cost (rule 6).
Prove it to yourself
Point at any one-liner and ask: does it touch every element? If yes it is at least O(n), no matter how short it looks.

What interviewers actually ask

Complexity analysis is not its own problem set — it is the sentence you must produce after every solution. But these classic problems are where a wrong cost read sinks people:

  • Contains Duplicate (217) · Amazon, Adobe (Easy) — probes: do you know x in list is O(n), making the naive check O(n^2), and that a set fixes it to O(n). Follow-up: "what if you cannot use extra space?" (sort → O(n log n)).
  • Two Sum (1) · Amazon, Google, Meta (Easy) — probes: can you state the O(n^2) brute cost and the O(n) hash cost correctly. Follow-up: sorted input → two pointers, O(1) space.
  • Valid Anagram (242) · Amazon, Bloomberg (Easy) — probes: O(n) count-compare vs O(n log n) sort-and-compare, and which you would pick and why.
  • Group Anagrams (49) · Amazon, Uber (Medium) — probes: cost of the key you choose (sorted string is O(k log k) per word) and how it multiplies over n words.
  • Top K Frequent Elements (347) · Amazon, Meta (Medium) — probes: heap O(n log k) vs full sort O(n log n) vs bucket O(n) — a pure complexity-tradeoff conversation.
  • Merge Intervals (56) · Amazon, Meta, Google (Medium) — probes: recognising the O(n log n) sort dominates the O(n) merge, so the whole thing is O(n log n).

Worked example 1 — the hidden quadratic

Problem in plain words: Given a list, return True if any value appears twice.

Brute + cost. "For each element, is it in the rest of the list?" People write if nums[i] in nums[i+1:]. That inner in is O(n) and slicing copies — this is O(n^2) hiding as one clean line. At n = 10^5 it dies.

Insight. Replace the linear membership test with a constant-time one: a set. This is the "check the lookups" bell ringing.

def contains_duplicate(nums):
    seen = set()                 # O(1)
    for x in nums:               # n iterations
        if x in seen:            # O(1) average  <-- the fix
            return True
        seen.add(x)              # O(1) average
    return False
# Time O(n), space O(n).

Complexity. O(n) time, O(n) space — versus O(n^2) time, O(1) space for the naive. Classic memory-for-time trade.

Follow-up. "No extra memory allowed." → sort in place, then scan neighbours: O(n log n) time, O(1) extra space. You traded time budget back for the space budget — name that out loud.

Try itFeel the quadratic bite.
import random, time
nums = list(range(30000))                 # all unique -> worst case, no early exit
random.shuffle(nums)
 
def slow(nums):
    for i in range(len(nums)):
        if nums[i] in nums[i+1:]:          # O(n) membership + slice copy = O(n^2)
            return True
    return False
 
t = time.time(); slow(nums); print("slow", round(time.time()-t, 3), "s")
t = time.time(); contains_duplicate(nums); print("fast", round(time.time()-t, 3), "s")

Watch the gap widen if you bump 30000 to 60000 — the slow one roughly quadruples, the fast one roughly doubles. That ratio IS the difference between O(n^2) and O(n).

💡 Hint · Time the slice-in-list version against the set version on a big list.

Worked example 2 — where the sort dominates

Problem in plain words: Given intervals like [1,3], [2,6], [8,10], merge all overlapping ones.

Brute idea. Compare every interval to every other repeatedly — O(n^2) or worse. Reading the constraints (n up to 10^4) says n^2 is borderline; there is a cleaner O(n log n).

Insight. If you sort by start, overlaps become adjacent, so one linear pass merges them.

def merge(intervals):
    intervals.sort(key=lambda iv: iv[0])   # O(n log n) -- this DOMINATES the whole function
    merged = [intervals[0]]
    for start, end in intervals[1:]:       # O(n) pass
        last = merged[-1]
        if start <= last[1]:               # overlaps the current block
            last[1] = max(last[1], end)    # extend it
        else:
            merged.append([start, end])    # gap -> start a new block
    return merged

Complexity. Sort O(n log n) + merge O(n) = O(n log n) (rule 4: add, keep the biggest). Space O(n) for output, or O(1) extra if you may mutate. The whole point of the counting rules: you can say which line dominates.

Follow-up. "Insert one new interval into an already-sorted list (57)." → no sort needed, single O(n) pass; the dominant term drops to O(n) because you removed the sort.

The tradeoff
Set/dict for O(1) lookups, or sort for O(n log n) ordering?
Hash (set/dict)
+ you gain O(1) average lookups; turns many O(n^2) scans into O(n).
− you pay O(n) extra memory; no ordering; O(1) is average, not worst case.
pick when Membership, counting, complement lookups where order does not matter.
Sort first
+ you gain Enables two-pointers, adjacency (merge intervals), binary search; O(1) extra space possible.
− you pay O(n log n) up front; mutates or copies the input.
pick when When adjacency or order unlocks a linear pass, or memory is tight.
Neither (plain scan)
+ you gain Zero extra memory, dead simple.
− you pay Often O(n^2) when it hides a membership test in a loop.
pick when Only for tiny n where the constraint budget allows it.
What a senior engineer actually does
Default to hashing to kill accidental quadratics; switch to sorting when order or adjacency is what makes the linear pass possible.

Where this shows up in production

The "hidden O(n) lookup inside a loop" bug is one of the most common real performance regressions. A function that checks membership against a growing Python list works fine in tests with a handful of items, then crawls in production when the list holds tens of thousands — because the innocent if item in seen_list is O(n) each call, making the whole loop O(n^2). The fix is always the same one this session drills: swap the list for a set. Engineers who count operations catch this in code review; engineers who go by vibes ship it and get paged. That is the practical value of the habit.

You can now…
  • Find a code block's Big-O by counting loops and substituting built-in costs.
  • Apply the rules: nesting multiplies, sequence adds, keep the biggest, drop constants.
  • Recite why each Python built-in costs what it does (list vs set vs heap vs deque).
  • Spot an accidental O(n^2): a membership test or slice inside a loop.
  • Identify the dominating line (e.g. the sort) in a multi-step function.
  • State time AND space complexity out loud after solving.
Recall check · click to reveal
★ = stretch question

Practice queue

Solve, then state both time and space complexity out loud — with the reason.