Guides Guide 1 Reference

The Python Toolkit

Five modules, one complexity table, and a dozen traps. This is the part of Python that decides whether your correct idea also runs fast enough.

Interviewers do not test Python trivia. But they do watch you reach for list.pop(0) and quietly note that your O(n) algorithm just became O(n²). This page is the small set of facts that stops that happening.

Contents

  1. What each operation actually costs
  2. collections
  3. heapq
  4. bisect
  5. itertools
  6. functools
  7. The traps
  8. Idioms worth having
  9. Limits of the language

What each operation actually costs

Know this table. Most accidental time-limit failures are one row of it.

OperationCostNote
lst[i], lst[i] = x, len(lst)O(1)
lst.append(x), lst.pop()O(1)Amortised. A resize happens occasionally.
lst.insert(0, x), lst.pop(0)O(n)Everything shifts. Use a deque.
x in lstO(n)The most common accidental slowdown.
x in set, x in dictO(1)Average. O(n) worst case on hash collisions.
lst.sort(), sorted(it)O(n log n)Timsort. Stable, and O(n) on already-sorted input.
lst[i:j]O(j - i)It copies. Slicing in a loop hides a quadratic.
s1 + s2 for stringsO(len)Strings are immutable, so it builds a new one.
"".join(parts)O(total)One allocation. Always prefer this.
deque.popleft(), appendleft()O(1)The queue for BFS.
heappush, heappopO(log n)Min-heap only.
heapify(lst)O(n)Cheaper than n pushes.
bisect_left, bisect_rightO(log n)On a list you already keep sorted.
insortO(n)The search is log, the insert shifts. Easy to misjudge.
min(lst), max(lst), sum(lst)O(n)Calling one inside a loop is a hidden quadratic.
set(a) & set(b)O(min(len))Union, difference and intersection are all cheap.
dict.items(), keys()O(1)A view, not a copy. Iterating it is O(n).
copy.deepcopy(x)O(size)Slow. Almost never what you want in an interview.
The two that cost people offers. x in lst inside a loop over the same list, and lst.pop(0) as a queue. Both turn a linear algorithm into a quadratic one, both look completely innocent, and both are caught instantly by a reviewer.

collections

Three classes carry almost all the weight.

Counter

from collections import Counter

counts = Counter("mississippi")
counts["s"]                     # 4
counts["z"]                     # 0, and no KeyError
counts.most_common(2)           # [('i', 4), ('s', 4)]

# Counters compare and combine directly, which anagram problems love.
Counter("listen") == Counter("silent")      # True
Counter("aab") - Counter("ab")              # Counter({'a': 1})

Two behaviours matter. A missing key reads as 0 rather than raising, and most_common(k) uses heapq.nlargest internally, so it is O(n log k) rather than a full sort.

defaultdict

from collections import defaultdict

graph: defaultdict[str, list[str]] = defaultdict(list)
graph["a"].append("b")          # no "if key not in graph" needed

tally: defaultdict[str, int] = defaultdict(int)
tally["x"] += 1

buckets: defaultdict[str, set[str]] = defaultdict(set)
buckets["k"].add("v")
The one gotcha: merely reading a missing key creates it. So len(graph) can grow just from looking. If you need to check without creating, use key in graph or graph.get(key).

deque

from collections import deque

queue = deque([1, 2, 3])
queue.popleft()                 # O(1). list.pop(0) would be O(n).
queue.appendleft(0)
queue.extendleft([9, 8])        # note: inserts in reverse order

recent = deque(maxlen=3)        # fixed size; pushing past 3 drops from the far end

Use a deque for every BFS queue, for sliding-window maximum, and any time you need both ends. It is a doubly linked list of blocks, so indexing the middle is O(n). If you index it a lot, you wanted a list.

heapq

import heapq

heap = [5, 1, 4]
heapq.heapify(heap)             # O(n), in place
heapq.heappush(heap, 0)
smallest = heapq.heappop(heap)  # 0

heapq.heapreplace(heap, 9)      # pop then push, one sift instead of two
heapq.nlargest(2, [5, 1, 4])    # [5, 4]
heapq.nsmallest(2, [5, 1, 4])   # [1, 4]
NeedDo this
A max-heapPush -value. There is no max-heap in the standard library.
Order by a keyPush tuples: (key, tiebreak, payload).
Top k, quicklynlargest / nsmallest. They already do the size-k heap trick.
Delete an arbitrary itemNot supported. Mark it dead and skip it when it surfaces.
Tuple comparison reaches the second element on a tie. If that element is an object with no ordering, Python raises TypeError. Always put something comparable second, such as an insertion counter, and keep unorderable payloads third or later. See Pattern 9.

bisect

import bisect

data = [1, 3, 3, 5]
bisect.bisect_left(data, 3)     # 1, first index where data[i] >= 3
bisect.bisect_right(data, 3)    # 3, first index where data[i] >  3
bisect.bisect_left(data, 4)     # 3, the insertion point for a missing value

bisect.insort(data, 4)          # data becomes [1, 3, 3, 4, 5]
bisect_left is lower bound and bisect_right is upper bound. The count of values equal to x is bisect_right(data, x) - bisect_left(data, x). Both return an insertion point, never a promise that the value is present, so always check data[i] == x before trusting it. That check is the bug in Find First and Last Position.

Since Python 3.10 both accept a key= argument, which removes the old trick of building a parallel list of keys.

itertools

from itertools import accumulate, combinations, permutations, product, groupby, pairwise

list(accumulate([1, 2, 3, 4]))            # [1, 3, 6, 10]  prefix sums
list(accumulate([3, 1, 4], max))          # [3, 3, 4]      running maximum

list(combinations([1, 2, 3], 2))          # [(1, 2), (1, 3), (2, 3)]
list(permutations([1, 2]))                # [(1, 2), (2, 1)]
list(product([0, 1], repeat=2))           # [(0,0), (0,1), (1,0), (1,1)]

list(pairwise([1, 2, 3]))                 # [(1, 2), (2, 3)]  adjacent pairs
[(key, len(list(run))) for key, run in groupby("aaabbc")]   # [('a',3), ('b',2), ('c',1)]
FunctionUse it for
accumulatePrefix sums in one line. See Pattern 13.
pairwiseComparing adjacent elements without index arithmetic. Python 3.10+.
groupbyRun-length encoding. It only groups consecutive equal items, so sort first if you meant to group globally.
combinations, permutationsChecking a brute force against your backtracking answer.
productNested loops over a grid of choices, flattened into one loop.
In an interview, write the backtracking yourself. Then mention itertools as what you would ship. Reaching for the library first reads as avoiding the question.

functools

from functools import cache, lru_cache, reduce, cmp_to_key


@cache
def fib(n: int) -> int:
    """Without the decorator this is 2**n calls. With it, n."""
    return n if n < 2 else fib(n - 1) + fib(n - 2)


fib(100)                                   # instant
reduce(lambda a, b: a * b, [1, 2, 3, 4])   # 24
@cache is the fastest route from a brute-force recursion to a working dynamic programming solution. Two constraints: the arguments must be hashable, so pass tuples rather than lists, and the cache lives as long as the function, so define the helper inside the outer function to avoid results leaking between calls.

lru_cache(maxsize=None) is the same thing on older versions. cmp_to_key converts an old-style comparison function into a sort key, which is occasionally the cleanest way to express a custom ordering such as Largest Number.

The traps

Mutable default argument

Wrong
def add(item, bucket=[]):
    # The list is created ONCE, when
    # the function is defined. Every
    # call shares it.
    bucket.append(item)
    return bucket
Right
def add(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

Aliased rows in a 2D grid

Wrong
grid = [[0] * 3] * 2
grid[0][0] = 1
# grid is now [[1,0,0], [1,0,0]]
# because both rows are the SAME
# list object.
Right
grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
# grid is [[1,0,0], [0,0,0]]
# The comprehension builds a fresh
# row each time.

The rest

TrapWhat goes wrongFix
lst.pop(0) as a queueO(n) per pop, so O(n²) overallcollections.deque
result += char in a loopRebuilds the string each time, O(n²)Collect into a list, then "".join(parts)
x in lst inside a loopO(n) per checkBuild a set first
Mutating a list while iterating itSkipped elements, silentlyIterate a copy, or build a new list
-7 // 2 is -4Floor division rounds toward minus infinityint(-7 / 2) for truncation, or math.trunc
-7 % 3 is 2Different from C and Java, which give -1Usually what you want. Say so out loud.
0.1 + 0.2 != 0.3Floating pointStay in integers, or use math.isclose
is for value comparisonWorks for small ints by accident, then stops== for values, is only for None and identity
Recursion past ~1000 framesRecursionErrorRewrite iteratively, or sys.setrecursionlimit and say why
sorted(d) on a dictSorts the keys, not the itemssorted(d.items(), key=...)
Slicing inside a loops[i:] copies, so a linear loop turns quadraticPass indices instead of slices

Idioms worth having

from collections import Counter

# Iterate with an index, or two sequences together.
for index, value in enumerate("abc"):
    pass

for a, b in zip([1, 2], "xy"):
    pass

# Sort by a computed key. sorted() returns a new list; .sort() is in place.
words = ["bbb", "a", "cc"]
by_length = sorted(words, key=len)                 # ['a', 'cc', 'bbb']
by_two = sorted(words, key=lambda w: (len(w), w))  # length, then alphabetical

# Descending without reversing afterwards.
descending = sorted([3, 1, 2], reverse=True)

# max and min take a key too.
longest = max(words, key=len)

# Unpacking, including the starred form.
first, *rest = [1, 2, 3]
head, *middle, tail = [1, 2, 3, 4]

# Swap without a temporary. The right side is evaluated first.
a, b = 1, 2
a, b = b, a

# Chained comparison, which reads exactly like the maths.
n = 5
in_range = 0 <= n < 10

# any and all short-circuit.
has_even = any(x % 2 == 0 for x in [1, 3, 4])
all_positive = all(x > 0 for x in [1, 2])

# Dict and set comprehensions.
squares = {x: x * x for x in range(3)}
letters = {ch for ch in "hello"}

# The walrus operator, for "compute once, test, then reuse".
values = [1, 2, 3]
if (total := sum(values)) > 5:
    leftover = total - 5

# Counting without a loop.
most_common_char, _ = Counter("aabbbcc").most_common(1)[0]

# A grid of the four cardinal moves, used in every BFS and DFS on a grid.
DIRECTIONS = ((1, 0), (-1, 0), (0, 1), (0, -1))

Limits of the language

FactWhy it matters in an interview
Integers are arbitrary precisionNo overflow, ever. Say so when the interviewer is thinking in C++ or Java, where your sum would need a 64-bit type.
Default recursion limit is about 1000A DFS on a 200×200 grid can exceed it. Know the iterative form.
Strings are immutableEvery edit builds a new string. Build a list of characters and join at the end.
Sort is stableEqual keys keep their input order, so you can sort by two keys in two passes, least significant first.
Dicts keep insertion orderGuaranteed since 3.7. Useful for deterministic output, and for a simple LRU.
Sets and dicts have no order guarantee across runs for arbitrary objectsIf your output depends on set iteration order, it is not deterministic. Sort before returning.
Python is roughly 10 to 100 times slower than C++Assume about 10⁷ simple operations per second, not 10⁸. See Guide 2.
No built-in balanced BSTThere is no std::map equivalent. Use sortedcontainers if allowed, or a heap plus lazy deletion, or say what you would use.

The five things to carry forward


16 — Union-Find Guide 2 — Constraints and Complexity