Problem
An algorithm's complexity is only as good as the data structure underneath it. The same
idea, "remove the oldest item", is O(1) on one container and O(n) on another, and picking the
wrong one turns a linear solution into a quadratic one without any visible change in the
code. Python also offers shortcuts, such as Counter and sorted(key=...),
that replace twenty lines of loop with one.
The signals below tell you which tool to reach for. They also appear later in nearly every pattern page.
| Signal | Pattern | Why |
|---|---|---|
| "Have I seen this before?" | set or dict | Hash lookup is O(1) on average, against O(n) for in on a list. |
| Count how many of each | Counter or dict.get | One pass builds all the counts. |
| Remove from the front, add to the back | collections.deque | popleft is O(1); list.pop(0) shifts everything. |
| Always need the smallest or largest so far | heapq | Push and pop are O(log n) and the minimum is always at index 0. |
| Sorted data, find a position | bisect | Binary search in O(log n) with no hand-written loop. |
| Group items by a key | defaultdict(list) | Skips the "is the key there yet" branch. |
| Order by a computed value | sorted(..., key=...) | Sorting is O(n log n) and the key function runs once per element. |
Intuition
Every container is a trade. A list is a row of boxes: reaching box k is
instant, but squeezing a box in at the front means moving all the others along. A
dict or set is a coat check: hand over a ticket (the hash of the
key) and the attendant goes straight to the hook, without scanning the rack. A
deque is a row you can add to or remove from at either end. A heap
is a tournament bracket kept loosely sorted: only the champion is guaranteed to be on top, but
keeping that true after every change takes just one walk up or down a tree.
So the skill is not memorizing syntax. It is asking "which operation will my loop repeat n times?" and choosing the container for which that operation is cheap.
The tree is only a way of reading the array. Index i has children 2i + 1 and 2i + 2, so no pointers are stored.
Mechanics
What each operation costs
| Container | Cheap: O(1) (amortized or average) | Costly |
|---|---|---|
list | a[i], a.append(x), a.pop(), len(a) | a.pop(0), a.insert(0, x), x in a, a.index(x), a.remove(x) are O(n); a slice a[i:j] is O(j − i); a.sort() is O(n log n) |
dict | d[k], d[k] = v, k in d, del d[k] | Iterating all keys is O(n); keys must be hashable (a tuple works, a list does not) |
set | s.add(x), x in s, s.discard(x) | Union, intersection and difference cost about the size of the operands; no order is guaranteed |
deque | append, appendleft, pop, popleft | Indexing into the middle, d[i], is O(n) |
heapq (on a list) | h[0] (peek the minimum) | heappush and heappop are O(log n); heapify is O(n); searching for a value is O(n) |
bisect (on a sorted list) | bisect_left and bisect_right are O(log n) | insort finds the spot in O(log n) but must shift elements, so it is O(n) |
str | s[i], len(s) | Strings are immutable: s += t may copy; s[i:j] copies; "".join(parts) is the linear-time way to build one |
Why hash lookups are O(1)
A dict computes hash(key), turns it into a slot number, and checks that slot. If
two keys land in the same slot, it probes nearby slots. As long as the table stays under a
load limit and resizes (doubling, which is the amortized argument from the
complexity analysis page),
the expected number of probes is a constant. The caveat is that this is an average:
adversarial keys that all collide would degrade it, which interview inputs don't do.
The heap invariant
A binary min-heap stores a complete binary tree in an array with one rule: every parent is
less than or equal to both children. heappush appends to the end and
sifts up, swapping with the parent while it is larger. heappop
takes the root, moves the last element into the root slot and sifts down,
swapping with the smaller child while it is larger than that child. A swap only ever fixes
one violated pair and the tree has height ⌊log₂ n⌋, so each operation is O(log n). Python has
only a min-heap. For a max-heap, push negated values.
The binary search invariant
bisect_left(a, x) returns the first index where a[index] >= x
(or len(a) if there is none). The search keeps the answer inside the half-open
range [low, high): if the middle value is smaller than x, everything up to the
middle is too small, so low = middle + 1; otherwise the middle may itself be the
answer, so high = middle. The range halves each time and ends with
low == high, which is the answer. The
halving argument bounds this
at about log₂ n probes.
Worked example
First, build a heap by pushing 5, 3, 8, 1, 9, 2 in that order. The array after each push:
| Pushed | Heap array afterwards | Minimum (index 0) |
|---|---|---|
| 5 | [5] | 5 |
| 3 | [3, 5] | 3 |
| 8 | [3, 5, 8] | 3 |
| 1 | [1, 3, 8, 5] | 1 |
| 9 | [1, 3, 8, 5, 9] | 1 |
| 2 | [1, 3, 2, 5, 9, 8] | 1 |
The array is not sorted, only heap-ordered. Popping repeatedly returns the values in order: 1, 2, 3, 5, 8, 9. That is heapsort in two lines.
Second, a binary search with bisect_left over the sorted list
[1, 3, 5, 7, 9, 11, 13, 15], looking for 11:
| Probe | low | high | middle | Value at middle | Decision |
|---|---|---|---|---|---|
| 1 | 0 | 8 | 4 | 9 | too small: low = 5 |
| 2 | 5 | 8 | 6 | 13 | could be the answer: high = 6 |
| 3 | 5 | 6 | 5 | 11 | could be the answer: high = 5 |
The insertion point is index 5, reached in 3 probes
for 8 items, the same answer bisect.bisect_left gives.
Blue cells are still candidates; the amber outline is the probe.
Implementation
from bisect import bisect_left, bisect_right
from collections import Counter, defaultdict, deque
import heapq
# Counting and membership
counts = Counter("abracadabra") # Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
seen = set() # O(1) average add / lookup
groups = defaultdict(list) # missing key starts as []
for word in ["eat", "tea", "tan"]:
groups["".join(sorted(word))].append(word) # anagram key -> words
# Queue: both ends in O(1)
queue = deque([start])
while queue:
node = queue.popleft()
# ... queue.append(neighbour)
# Heap: smallest first; negate for a max-heap
heap = []
heapq.heappush(heap, (distance, node)) # tuples compare element by element
smallest = heapq.heappop(heap)
heapq.heapify(existing_list) # O(n), cheaper than n pushes
# Sorted list: positions
position = bisect_left(sorted_list, x) # first index with value >= x
present = position < len(sorted_list) and sorted_list[position] == x
how_many = bisect_right(sorted_list, hi) - bisect_left(sorted_list, lo)
# Sorting by a key, and the other builtins
people.sort(key=lambda p: (-p.score, p.name)) # descending score, then name
total = sum(x for x in nums if x % 2 == 0)
best = max(words, key=len)
pairs = list(zip(a, b))
for index, value in enumerate(nums):
pass
Variations and edge cases
- Top-k with a bounded heap. Keep a min-heap of size k and push each new
value, popping when the size exceeds k. The heap holds the k largest, in O(n log k).
heapq.nlargest(k, nums)does this for you. - Lazy deletion. A heap cannot remove an arbitrary item cheaply. Leave stale entries in and skip them when they reach the top, checking against a dict of current values.
- Tie-breaking. If two heap tuples have equal first elements, Python compares the second ones. Put an integer counter in the tuple when the second item (such as a node object) isn't orderable.
- Mutable keys.
[1, 2]can't be a dict key or set member; convert withtuple(...). Likewise[[0] * m] * nmakes n references to one row: build a grid with[[0] * m for _ in range(n)]. - Integer division and modulo.
//floors toward negative infinity and%of a negative number is non-negative in Python, unlike C or Java. Useint(a / b)ormath.truncwhen a problem wants truncation toward zero. - Edge cases that break naive code: empty input, a single element,
heappopon an empty heap (raisesIndexError), andbisect_leftreturninglen(a)when x is bigger than everything.
Complexity and tradeoffs
Emptying a list of 8 items with pop(0) moves 28 elements in total,
against 0 for deque.popleft, and the gap grows as n². The wrong container is the
brute-force baseline on many problems, and the right container turns n repeated O(n) steps into n repeated O(1) steps.
- Memory. A set of n integers takes several times the memory of a list of them, because of the hash table's spare slots. The trade for O(1) lookup is nearly always worth it, unless the values fit a small integer range, where a plain boolean array is both smaller and faster.
- Sorting first. If you only need membership, a set (O(n) to build) beats sorting plus binary search (O(n log n) to build). If you need order, ranges or neighbours, the sorted list wins.
- Heap against sorting. A heap helps when items arrive over time or you only need the top k. When all the data is in hand and you need it all in order, one sort is simpler.
- No balanced tree. Python has no built-in ordered set or sorted map. A
sorted list with
bisectgives O(log n) search but O(n) insertion, which is fine up to about 10⁵ elements; beyond that, think about a different pattern. - Hash-order assumptions. Dicts preserve insertion order, but sets do not. Never rely on set iteration order in an answer.
Problems
The two problems below are here because they are about the plain tools themselves: slicing, comparing and building lists. The problems that use a heap, a deque or a counter as part of a bigger idea live under the pattern pages.
Easy
-
14. Longest Common Prefix
Grind 169 NeetCode 250
Compare the strings column by column and stop at the first position where any string ends or disagrees. -
1929. Concatenation of Array
NeetCode 250
The output is the array followed by itself, so index i and i + n hold the same value.
Also uses this pattern
These problems have a different home page, but this idea is part of their solution.
- 179. Largest Number Medium
- 380. Insert Delete GetRandom O(1) Medium
Connections
Read first
- Complexity analysis intro
Read next
- Hash maps and sets intro
- Stacks intro
- Tree breadth-first search intro
- Heaps intro
- Merging intervals core
The hash-map page builds on the dict and set rows above. Heaps, top-k and the k-way merge
pages build on heapq. Binary search turns the bisect invariant into
a general technique, including searching over answers rather than arrays.
Check yourself
You process a queue of up to 10⁵ items by repeatedly calling queue.pop(0) on a list. What is wrong, and what is the fix?
Each pop(0) shifts the remaining elements, so the loop is O(n²) overall. Use collections.deque and popleft(), which is O(1).
After pushing 5, 3, 8, 1, 9, 2, is the heap array sorted? What is guaranteed?
No: it is [1, 3, 2, 5, 9, 8]. Only the heap order is guaranteed: every parent is at most its children, so index 0 is the minimum.
What does bisect_left(a, x) return when x is larger than every element?
len(a), the position where x would be appended. Check the index against the length before reading a[index].
Why does grid = [[0] * 3] * 3 behave surprisingly when you set grid[0][0] = 1?
The outer multiplication copies a reference to the same inner list, so all three rows are one object. Build each row separately with a list comprehension.
References
- Python documentation: the
heapq,bisectandcollectionsmodules, and "TimeComplexity" in the Python wiki. - Cormen, Leiserson, Rivest and Stein, Introduction to Algorithms: chapter 6 (heapsort and priority queues), chapter 11 (hash tables) and section 2.3 (binary search).
- Sedgewick and Wayne, Algorithms: section 2.4 (priority queues), section 3.4 (hash tables).
- Steven Skiena, The Algorithm Design Manual: sections on dictionaries and priority queues.
- J. W. J. Williams, "Algorithm 232: Heapsort", Communications of the ACM, 1964.
- The NeetCode roadmap and the Blind 75, Grind 169, NeetCode 150 and NeetCode 250 lists, used as the index of topics and problems.