Python · Python Essentials

Python: Collections and Recursion

How dict and set work inside, insertion order, hashability, defaultdict and Counter, heapq with tuples, deque versus list, sorting by a key, changing a collection while iterating, and the recursion limit.

  • 8 min read
  • 21 practice questions
  • 1 graded problem
On this page
  1. Description
  2. Picking the right container
  3. dict and set: how they find things
  4. What can be a key
  5. The collections helpers
  6. Heaps and sorted lists
  7. Sorting your own way
  8. Changing a collection while looping over it
  9. How deep can recursion go?
  10. Common mistakes
  11. Check yourself
  12. Key takeaways
  13. Quick revise
  14. Practice

Description

Almost every Python answer in a coding interview leans on the same few containers: a list to keep things in order, a dict to look things up, a set to remember what you've already seen, and a heap or a deque to decide what comes next. You've probably used them all. This lesson is about using them with confidence: picking the right one quickly, and knowing the handful of rules that quietly break code that looks perfectly fine.

Here's the kind of bug we mean. This loop removes the even numbers from a list:

nums = [2, 4, 6, 7]
for x in nums:
    if x % 2 == 0:
        nums.remove(x)
print(nums)          # [4, 7]

No error, and the answer is wrong: 4 is still there. Java would at least throw an exception; Python just keeps going. By the end of the lesson you'll know exactly why, and the safe way to write it.

We'll start simple and get more technical as we go:

  1. Which container to pick, and what each one costs.
  2. How dict and set find things, and what can be a key.
  3. The collections helpers: defaultdict, Counter, deque, OrderedDict.
  4. Heaps and sorted lists with heapq and bisect.
  5. Sorting your own way.
  6. Changing a collection while you loop over it - and how deep recursion can go.

Picking the right container

You needUseMain cost
Items by positionlista[i] O(1), append O(1) on average, insert/remove at the front O(n)
Look up by keydictO(1) on average
"Have I seen this?"setO(1) on average
A queue, or a sliding windowcollections.dequeO(1) at either end
Smallest (or most urgent) firstheapq on a listheappush / heappop O(log n)
A sorted list you searchbisect on a listsearch O(log n), insert O(n)
Counting thingscollections.CounterO(1) per update

Two habits follow from this table. Never use x in some_list inside a loop to check whether you've seen something - that's a scan every time, and the loop becomes quadratic. Use a set. And never use list.pop(0) as a queue - it shifts every remaining item. Use a deque.

dict and set: how they find things

A dict (and a set) is a hash table. Python computes a number from the key - its hash - and uses it to jump straight to a slot, so lookups, inserts and deletes take about the same time however big the table is.

Two facts worth using in interviews:

  • A dict remembers insertion order. Iterating a dict gives keys in the order they were first added - a language guarantee since Python 3.7. Updating an existing key keeps its place; deleting it and adding it again moves it to the end. A set has no defined order at all.
  • Membership is O(1). x in some_dict and x in some_set jump straight to the slot.

What can be a key

seen = set()
seen.add((r, c))            # a tuple of ints: fine
seen.add([r, c])            # TypeError: cannot use 'list' as a set element (unhashable type: 'list')
groups[frozenset(tags)]     # a set as a key: freeze it first

Keys must be hashable: their hash must never change. That's why the built-in containers you can change - list, dict, set - can't be keys or set members. If a key could change after it was filed, the table would look for it in the wrong slot. Use a tuple instead of a list, and a frozenset instead of a set.

The collections helpers

from collections import Counter, OrderedDict, defaultdict, deque

counts = defaultdict(int)
counts["x"] += 1                    # no KeyError: a missing key starts at int() == 0

c = Counter("abcab")
c.most_common(2)                    # [('a', 2), ('b', 2)]
c["z"]                              # 0, and "z" is NOT added

window = deque(maxlen=3)
for x in range(5):
    window.append(x)                # deque([2, 3, 4], maxlen=3): the oldest fall off

Two details trip people up:

  • Reading a defaultdict adds the key. if counts["y"] > 0: quietly inserts "y": 0, so len(counts) and "y" in counts change. To check without side effects, use "y" in counts or counts.get("y", 0). A Counter doesn't do this - reading a missing key just gives 0.
  • most_common breaks ties by first appearance, not alphabetically. If a problem says "ties in alphabetical order", sort yourself: sorted(c.items(), key=lambda kv: (-kv[1], kv[0])).

OrderedDict adds move_to_end(key) and popitem(last=False) (remove the oldest), which is exactly an LRU cache: move a key to the end on every use, and evict from the front.

Heaps and sorted lists

heapq turns an ordinary list into a min-heap: the smallest item is always at index 0.

import heapq

h = [5, 1, 4, 2, 3]
heapq.heapify(h)                    # O(n): h is now [1, 2, 4, 5, 3]
heapq.heappop(h)                    # 1
heapq.heappush(h, 0)
heapq.nsmallest(2, [7, 3, 9, 1])    # [1, 3]

Only h[0] is guaranteed: the rest of the list is in "heap order", not sorted, so printing a heap shows something that looks almost sorted. For a max-heap, push negated values. To order by several things, push tuples - they compare field by field, so the second field breaks ties:

heapq.heappush(tasks, (-priority, name))   # highest priority first; ties by name, A to Z

There's no "decrease key" operation. If an item's priority changes, push a new entry and skip the stale one when it's popped.

bisect finds positions in a sorted list in O(log n):

import bisect

a = [1, 3, 3, 5, 8, 8, 10]
bisect.bisect_left(a, 3)            # 1: the first index where 3 could go
bisect.bisect_right(a, 3)           # 3: just after the last 3
bisect.bisect_right(a, 8) - bisect.bisect_left(a, 3)   # 5 values between 3 and 8

bisect.insort(a, x) keeps the list sorted, but inserting still shifts everything after the spot, so it's O(n) per insert.

Sorting your own way

nums.sort()                         # sorts in place and returns None
ordered = sorted(nums)              # returns a new list
people.sort(key=lambda p: (-p.score, p.name))   # score high to low, then name A to Z

The classic slip is nums = nums.sort(), which sets nums to None. Python's sort is stable: items that compare equal keep their original order - and that's still true with reverse=True, which keeps ties in their original order instead of reversing them.

When the rule compares pairs rather than single items - "Largest Number" puts a before b when a + b > b + a - a key can't express it. Wrap a comparison function with functools.cmp_to_key.

Changing a collection while looping over it

Back to the puzzle. A for loop over a list walks by position. Removing 2 shifts 4 into position 0, the loop moves on to position 1 - which now holds 6 - and 4 is never looked at.

Dicts and sets are stricter: changing their size during a loop raises an error.

d = {"a": 1, "b": 0, "c": 0}
for k in d:
    if d[k] == 0:
        del d[k]                    # RuntimeError: dictionary changed size during iteration

The fix is the same either way: build a new collection, or loop over a copy.

nums = [x for x in nums if x % 2]                # a new list
d = {k: v for k, v in d.items() if v != 0}      # a new dict
for k in list(d): ...                           # loop over a snapshot of the keys

How deep can recursion go?

Python stops recursion at about 1,000 nested calls by default and raises RecursionError. A recursive depth-first search on a path-shaped graph of 5,000 nodes fails, though the algorithm is fine.

You have three options:

  • Raise the limit: sys.setrecursionlimit(10**6). Since Python 3.11 this is safe for plain Python functions - a depth of 500,000 runs fine.
  • Rewrite with an explicit stack: a list you append to and pop from. This works on every Python version and in every interview.
  • For memoized recursion, warm the cache from the bottom up. @cache doesn't help the first deep call: fib(5000) still recurses 5,000 levels before anything is cached.

Common mistakes

MistakeWhat happensDo this instead
x in seen_list in a loopO(n^2)a set
list.pop(0) as a queueO(n) per popdeque.popleft()
A list as a dict key or set memberTypeErrora tuple
Reading a missing defaultdict keythe key is addedin or .get()
most_common for "ties alphabetically"ties come in first-seen ordersort by (-count, key)
Printing a heap to see the ordernot sortedpop items, or sorted(h)
nums = nums.sort()nums is Nonenums.sort() or sorted(nums)
Removing from a list inside for over itskips itemsbuild a new list
Deleting dict keys in a loop over itRuntimeErrora dict comprehension or list(d)
Deep recursionRecursionError near 1,000an explicit stack, or raise the limit

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Why does the loop at the top leave 4 in the list?

Show answer

The loop walks by position. Removing 2 shifts 4 into position 0, the loop moves on to position 1 (now 6), and 4 is never checked. Build a new list instead: [x for x in nums if x % 2].

2. How do you pop the highest priority first, breaking ties by the alphabetically first name?

Show answer

Push (-priority, name) onto a min-heap. Negating the priority puts the highest first, and the name, compared normally, breaks ties from A to Z.

3. A Counter gives [('b', 2), ('a', 2)] from most_common(2), but the problem wants ties in alphabetical order. What's going on?

Show answer

most_common orders equal counts by when each key was first seen, and b came first. Sort explicitly: sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))[:2].

Key takeaways

  • Pick by operation: set for "seen", dict for lookup, deque for queues, heapq for "smallest next", bisect for sorted search.
  • Dicts keep insertion order; keys must be hashable - use tuples and frozensets.
  • defaultdict reads add keys; Counter reads don't; most_common ties go by first appearance.
  • A heap only promises h[0]; negate for max; tuples break ties.
  • sort() returns None; sorting is stable, even with reverse=True.
  • Never change a collection while looping over it - build a new one.
  • Recursion stops near 1,000 by default: use an explicit stack or raise the limit.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the container

You needUseMain cost
Items by positionlista[i] O(1), append O(1) amortized, front insert/remove O(n)
Look up by keydictO(1) average, insertion-ordered
"Seen it?"setO(1) average, no order
Queue / sliding windowdeque (maxlen for windows)O(1) at both ends, O(n) in the middle
Smallest firstheapq on a listpush/pop O(log n), heapify O(n)
Sorted searchbisectO(log n) search, O(n) insort
CountingCounterO(1) per update
LRU cacheOrderedDict (move_to_end, popitem(last=False)) or functools.lru_cacheO(1)

Snippets worth knowing by heart

seen.add((r, c))                               # tuples are hashable, lists aren't
groups[frozenset(tags)]                        # a set as a key
counts = defaultdict(int); counts[w] += 1
sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))   # count desc, ties A to Z
heapq.heappush(h, (-priority, name))           # max priority, ties by name
bisect.bisect_right(a, hi) - bisect.bisect_left(a, lo)   # values in [lo, hi]
people.sort(key=lambda p: (-p.score, p.name))
functools.cmp_to_key(lambda a, b: ...)         # pairwise rules (Largest Number)
nums = [x for x in nums if keep(x)]            # filter without skipping
for k in list(d): ...                          # change a dict safely while looping
stack = [root]                                 # recursion without the limit:
while stack:
    node = stack.pop()

The traps

  • x in list inside a loop - quadratic.
  • list.pop(0) as a queue - O(n) each.
  • A list as a key - TypeError: unhashable type: 'list'.
  • Reading a defaultdict inserts the key; Counter reads don't.
  • most_common ties follow first appearance, not the alphabet.
  • A heap printed isn't sorted - only h[0] is guaranteed; no decrease-key.
  • nums = nums.sort() makes nums None.
  • Removing from a list while looping over it skips items; deleting dict keys raises RuntimeError.
  • Recursion past ~1,000 frames raises RecursionError; @cache doesn't help the first deep call.

Staff-level points, one line each

  • A dict is a dense insertion-ordered entries array + a sparse indices table (since 3.6) - that's where ordering came from.
  • Dicts resize at ~2/3 full: 64 B empty, 224 B for 1-5 items, 352 B for 6-10, 632 B for 11-21 - O(1) amortized, occasional O(n) rebuilds.
  • Instance dicts share one key table (PEP 412).
  • Lists over-allocate ~12.5%: capacity 4, 8, 16, 24, 32, 40 ... as you append.
  • Timsort (powersort merge policy since 3.11): O(n) on sorted runs, always stable even with reverse=True, key called exactly once per item, list locked during the sort (ValueError: list modified during sort).
  • heapq: children at 2i+1 and 2i+2; nlargest is O(n log k); 3.14 added heappush_max and friends.
  • deque is a linked list of 64-item blocks: O(1) ends, O(n) middle.
  • String hashes are randomized per process (PYTHONHASHSEED); small ints hash to themselves, so int sets often look sorted - never rely on it.
  • 3.11+: Python-to-Python calls don't use the C stack, so a raised limit (500,000 deep) is safe; 3.12+ guards recursion through C code separately.
  • functools.cache is unbounded - fine for one computation, a leak in a long-running process.

Say it in an interview

  • "I'll track visited cells in a set of tuples - O(1) membership."
  • "BFS uses a deque; pop(0) on a list would be O(n)."
  • "I push (-priority, name) so the heap pops the highest priority and breaks ties alphabetically."
  • "The graph can be 10^5 deep, so I'll use an explicit stack instead of recursion."

Practice

This skill comes with 21 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python: Collections and Recursion free

Top Pages by Visits

MediumProCoding problemPython

An analytics dashboard for an online store in Austin shows the k most visited pages from today's log. Each log entry is the path of one page view, like /pricing.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Python: Collections and Recursion free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime