On this page
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:
- Which container to pick, and what each one costs.
- How
dictandsetfind things, and what can be a key. - The
collectionshelpers:defaultdict,Counter,deque,OrderedDict. - Heaps and sorted lists with
heapqandbisect. - Sorting your own way.
- Changing a collection while you loop over it - and how deep recursion can go.
Picking the right container
| You need | Use | Main cost |
|---|---|---|
| Items by position | list | a[i] O(1), append O(1) on average, insert/remove at the front O(n) |
| Look up by key | dict | O(1) on average |
| "Have I seen this?" | set | O(1) on average |
| A queue, or a sliding window | collections.deque | O(1) at either end |
| Smallest (or most urgent) first | heapq on a list | heappush / heappop O(log n) |
| A sorted list you search | bisect on a list | search O(log n), insert O(n) |
| Counting things | collections.Counter | O(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
dictremembers 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. Asethas no defined order at all. - Membership is O(1).
x in some_dictandx in some_setjump 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
defaultdictadds the key.if counts["y"] > 0:quietly inserts"y": 0, solen(counts)and"y" in countschange. To check without side effects, use"y" in countsorcounts.get("y", 0). ACounterdoesn't do this - reading a missing key just gives 0. most_commonbreaks 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
appendto andpopfrom. This works on every Python version and in every interview. - For memoized recursion, warm the cache from the bottom up.
@cachedoesn't help the first deep call:fib(5000)still recurses 5,000 levels before anything is cached.
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
x in seen_list in a loop | O(n^2) | a set |
list.pop(0) as a queue | O(n) per pop | deque.popleft() |
| A list as a dict key or set member | TypeError | a tuple |
Reading a missing defaultdict key | the key is added | in or .get() |
most_common for "ties alphabetically" | ties come in first-seen order | sort by (-count, key) |
| Printing a heap to see the order | not sorted | pop items, or sorted(h) |
nums = nums.sort() | nums is None | nums.sort() or sorted(nums) |
Removing from a list inside for over it | skips items | build a new list |
| Deleting dict keys in a loop over it | RuntimeError | a dict comprehension or list(d) |
| Deep recursion | RecursionError near 1,000 | an 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:
setfor "seen",dictfor lookup,dequefor queues,heapqfor "smallest next",bisectfor sorted search. - Dicts keep insertion order; keys must be hashable - use tuples and frozensets.
defaultdictreads add keys;Counterreads don't;most_commonties go by first appearance.- A heap only promises
h[0]; negate for max; tuples break ties. sort()returnsNone; sorting is stable, even withreverse=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.