Every tool used on this site, explained from scratch: what it actually is, what problem it solves, and when to reach for it. Written for someone who can program but has not written much Python.
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, starting from the beginning.
# O(1) average or # O(n log n). Where a cost is marked average, an adversarial input could make that single operation O(n); on interview inputs, treat it as constant. Amortised means the guarantee holds across a sequence of operations even though one of them occasionally costs more.Ten pieces of Python notation account for almost everything in the sixteen pattern pages. If any of these look unfamiliar, read this section first.
def example(flag: bool) -> int:
if flag:
inner = 1 # indented, so it belongs to the if
return inner
return 0 # dedented, so it belongs to the function
There are no braces and no semicolons. A colon opens a block, and indentation, conventionally four spaces, defines it.
def total(values: list[int], factor: int = 2) -> int:
"""The docstring. Triple quotes, and it is the first thing in the body."""
return sum(values) * factor # O(n): sum walks the whole list
: list[int] and -> int are annotations. Python does not check or enforce them at runtime; they are documentation for humans and for tools such as mypy. They cost nothing and they make your intent obvious to an interviewer, which is why every solution on this site has them.factor: int = 2 is a parameter with a default. Calling total([1, 2]) uses 2.
None, and truthinessvalue = None # the "nothing here" object, like null or nullptr
if value is None: # O(1). Use `is` for None, never ==
pass
# Empty things are falsy. All of these are False in an if:
# None, 0, 0.0, "", [], {}, set()
items: list[int] = []
if not items: # O(1). Emptiness is a stored length, not a scan.
pass
if node: looks fine until node is a valid object that happens to be empty or zero. On these pages the code says if node is not None: for exactly that reason. Be explicit when 0 or "" is a legitimate value.data = [10, 20, 30, 40, 50]
data[1:3] # O(2) [20, 30] index 1 up to but NOT including 3
data[:2] # O(2) [10, 20] from the start
data[2:] # O(3) [30, 40, 50] to the end
data[-1] # O(1) 50 a single index, not a slice
data[::-1] # O(n) a reversed COPY
# In general a[i:j] costs O(j - i), because it builds a whole new list.
data[1:] builds a whole new list, costing O(n). Slicing inside a loop is one of the most common ways to accidentally write a quadratic algorithm. Full treatment in Slicing in depth below.squares = [x * x for x in range(5)] # O(n) [0, 1, 4, 9, 16]
evens = [x for x in range(10) if x % 2 == 0] # O(n) with a filter
lookup = {x: x * x for x in range(3)} # O(n) a dict {0: 0, 1: 1, 2: 4}
unique = {ch for ch in "hello"} # O(n) a set {'h','e','l','o'}
grid = [[0] * 3 for _ in range(2)] # O(rows * cols) 2 rows of 3
Read it right to left: for each x in this range, if the filter passes, produce this value. It is a for loop that builds a collection, written on one line.
a, b = 1, 2
a, b = b, a # O(1) swap; the right side is evaluated first
first, *rest = [1, 2, 3] # O(n) rest is a NEW list [2, 3]
for index, value in enumerate("abc"): # O(n) total, O(1) per step
pass
for x, y in zip([1, 2], "xy"): # O(n) total; stops at the shorter one
pass
_ is the conventional name for a value you do not care about, as in for _ in range(k).
name, count = "queue", 3
message = f"{name} has {count} items" # O(len) "queue has 3 items"
debug = f"{count=}" # O(len) "count=3", handy when tracing
nonlocalEvery DFS and backtracking solution on this site defines a helper inside the main function. Two rules govern what that helper can touch.
def collect(items: list[int]) -> list[int]:
"""Reading and MUTATING an outer variable needs nothing special."""
out: list[int] = []
def helper(value: int) -> None:
out.append(value) # O(1). Mutating the list the name points at.
for item in items:
helper(item)
return out
def add_up(items: list[int]) -> int:
"""REASSIGNING an outer variable needs the nonlocal keyword."""
total = 0
def helper(value: int) -> None:
nonlocal total # without this, `total = ...` below would create
total = total + value # a brand new local variable and the outer one
# would never change
for item in items:
helper(item)
return total
nonlocal only to rebind the name itself, which is what total = ..., count += 1 and best = max(...) all do. This is why the templates on these pages accumulate into a list rather than into an integer wherever they can.from dataclasses import dataclass
@dataclass
class TreeNode:
"""@dataclass writes __init__, __repr__ and __eq__ for you."""
val: int = 0
left: "TreeNode | None" = None # quotes: the class is not defined yet
right: "TreeNode | None" = None
class Counterish:
"""The long form, when you need control over the constructor."""
def __init__(self, start: int = 0) -> None:
self.value = start # O(1). `self` is the instance, like `this`
def bump(self) -> None:
self.value += 1 # O(1). Every method takes self explicitly
self is Python’s this, and unlike most languages it is written out as the first parameter of every method. __init__ is the constructor.
Everything else is built on these. Get them right and the libraries make sense.
If you know Java, this is ArrayList. C++: std::vector. JavaScript: Array. Go: a slice.
items = [3, 1, 2]
items.append(4) # O(1) amortised -> [3, 1, 2, 4]
items[0] # O(1) 3
items.pop() # O(1) removes from the END -> [3, 1, 2]
len(items) # O(1) 3, the length is stored not counted
2 in items # O(n) a linear scan; use a set if this is hot
min(items) # O(n) so never call it inside a loop
items.sort() # O(n log n) IN PLACE, returns None -> [1, 2, 3]
ordered = sorted(items) # O(n log n) returns a NEW sorted list
# The two slow ones, and the whole reason deque exists:
items.insert(0, 0) # O(n) shifts every element right
items.pop(0) # O(n) shifts every element left
items.sort() returns None. Writing items = items.sort() silently destroys your list. Use sorted() when you want a value back.If you know Java, this is HashMap. C++: std::unordered_map. JavaScript: Map.
ages = {"ann": 30, "bob": 25}
ages["ann"] # O(1) average 30
ages["cat"] = 40 # O(1) average insert
"bob" in ages # O(1) average True. Membership tests the KEYS.
ages.get("dan", 0) # O(1) average 0, a default instead of a KeyError
len(ages) # O(1)
del ages["bob"] # O(1) average
for key, value in ages.items(): # O(n) to walk. items() itself is O(1),
pass # because it is a view, not a copy.
# "Average" because a pile of hash collisions can degrade one lookup to O(n).
# On interview inputs, treat all of these as O(1).
Since Python 3.7, dicts keep insertion order when you iterate them. That is guaranteed, not an accident, and several solutions on this site rely on it for deterministic output.
If you know Java, this is HashSet. C++: std::unordered_set.
seen = set() # O(1) NOTE: {} is an empty dict, not an empty set
seen.add(3) # O(1) average
3 in seen # O(1) average True
seen.discard(9) # O(1) average remove if present, no error if absent
len(seen) # O(1)
a, b = {1, 2, 3}, {2, 3, 4}
a & b # O(min(|a|, |b|)) {2, 3} intersection
a | b # O(|a| + |b|) {1,2,3,4} union
a - b # O(|a|) {1} difference
x in some_list scans the whole list and is O(n). x in some_set is O(1). Putting the wrong one inside a loop is the most common accidental slowdown in interviews.Closest to a lightweight, read-only struct or a record.
point = (2, 5)
x, y = point # O(1) unpacking
visited: set[tuple[int, int]] = set()
visited.add((2, 5)) # O(1) average. Legal, because tuples are hashable.
# visited.add([2, 5]) # TypeError: lists are not hashable
# Hashing a tuple is O(k) in its length, but k is a small constant such as 2
# for a grid coordinate, so in practice it is O(1).
That hashability is why every grid problem on this site stores coordinates as (row, col) tuples. Tuples also compare element by element, left to right, which is what makes (priority, index, task) work in a heap.
text = "hello"
text[0] # O(1) 'h'
text[1:3] # O(j - i) 'el', and it COPIES
len(text) # O(1) 5
"ell" in text # O(n * m) substring search, n = text, m = needle
# text[0] = "H" # TypeError: strings do not support assignment
parts = ["a", "b", "c"]
joined = "".join(parts) # O(total length) 'abc', ONE allocation
# The trap: building a string with += in a loop is O(n^2), because every +=
# copies everything written so far into a brand new string.
+= in a loop. Each += copies everything so far, making the loop O(n²). Collect the pieces in a list and call "".join(parts) once at the end.| You want | Python | Java | C++ |
|---|---|---|---|
| Dynamic array | list | ArrayList | vector |
| Hash map | dict | HashMap | unordered_map |
| Hash set | set | HashSet | unordered_set |
| Queue | collections.deque | ArrayDeque | deque |
| Stack | list, with append and pop | ArrayDeque | stack |
| Priority queue | heapq | PriorityQueue | priority_queue |
| Sorted map or tree map | none built in | TreeMap | std::map |
| Frequency count | collections.Counter | Map plus merge | map plus increment |
| Binary search | bisect | Collections.binarySearch | lower_bound |
TreeMap or std::map. If a problem needs ordered lookups with insertions, say so, and offer either the third-party sortedcontainers package or a heap with lazy deletion.sequence[start:stop:step] builds a new object of the same type holding a run of the original. It is half-open: the item at start is included, the item at stop is not. It works on every sequence, so list, str, tuple, range and bytes. It does not work on dict or set, which have no order to slice.| Part | Meaning | Default when omitted |
|---|---|---|
start | First index included | 0, or len - 1 when the step is negative |
stop | First index excluded | len, or “past the front” when the step is negative |
step | How far to move each time | 1. May be negative. Never 0, which raises. |
alphabet = ["a", "b", "c", "d", "e", "f"]
# index 0 1 2 3 4 5
# -index -6 -5 -4 -3 -2 -1
alphabet[1:4] # O(3) ['b', 'c', 'd'] 1, 2, 3. Not 4.
alphabet[:3] # O(3) ['a', 'b', 'c'] start defaults to 0
alphabet[3:] # O(3) ['d', 'e', 'f'] stop defaults to the end
alphabet[:] # O(n) a full shallow COPY
alphabet[::2] # O(n) ['a', 'c', 'e'] every other item
alphabet[1::2] # O(n) ['b', 'd', 'f'] every other, offset by one
# The length of a[i:j] is exactly j - i, once both are clamped into range.
len(alphabet[1:4]) # 3
stop - start. Adjacent slices join with no overlap and no gap: a[:k] + a[k:] == a for any k. And a[i:i] is empty, which is the sane answer. Every off-by-one you avoid in binary search comes from the same convention.alphabet = ["a", "b", "c", "d", "e", "f"]
alphabet[-1] # O(1) 'f' the last item
alphabet[-2:] # O(2) ['e', 'f'] the last two
alphabet[:-2] # O(4) ['a', 'b', 'c', 'd'] everything BUT the last two
alphabet[-3:-1] # O(2) ['d', 'e'] still half-open
alphabet[1:-1] # O(4) ['b', 'c', 'd', 'e'] drop the first and the last
[:-k] trap when k can be zero. a[:-0] is a[:0], which is empty, not the whole list. If k is computed, write a[:len(a) - k] or guard the zero case. This bites in “drop the last k” code the moment k happens to be 0.alphabet = ["a", "b", "c", "d", "e", "f"]
alphabet[::-1] # O(n) ['f','e','d','c','b','a'] the reverse IDIOM
alphabet[::-2] # O(n) ['f', 'd', 'b'] backwards, every other
alphabet[4:1:-1] # O(3) ['e', 'd', 'c'] from 4 down to, not including, 1
alphabet[:2:-1] # O(3) ['f', 'e', 'd'] from the end down to index 3
start and stop keep their meaning but the direction flips. So a[1:4:-1] is empty: it asks to walk from 1 backwards to 4, which never happens. If you want a reversed middle chunk, slice first and reverse second: a[1:4][::-1]. It is two passes and it always reads correctly.a[i] and a[i:j]. Indexing past the end raises IndexError. Slicing past the end silently clamps and gives you whatever exists, possibly nothing.alphabet = ["a", "b", "c", "d", "e", "f"]
# alphabet[99] # IndexError: list index out of range
alphabet[99:] # O(1) [] no error, just empty
alphabet[2:99] # O(4) ['c','d','e','f'] clamped to the end
alphabet[-99:2] # O(2) ['a', 'b'] clamped to the start
alphabet[4:2] # O(1) [] start after stop, so empty
That forgiveness is useful. batch = queue[:size] works whether or not there are size items left, so no length check is needed. It is also how bugs hide: an empty result may mean “nothing matched” or “my indices were nonsense”, and slicing will not tell you which.
import copy
rows = [[1, 2], [3, 4]]
shallow = rows[:] # O(n) a NEW outer list, the SAME inner lists
shallow.append([5, 6]) # rows is unaffected: the outer list is new
shallow[0].append(99) # rows[0] IS [1, 2, 99]: the inner list is shared
deep = copy.deepcopy(rows) # O(size) fully independent
a[:] is the idiomatic one-level copy, and it is exactly what list(a) and a.copy() do. For a list of lists, or a list of any mutable object, you almost certainly want copy.deepcopy. See Guide 5 for the full model.buffer = [0, 1, 2, 3, 4]
buffer[1:3] = ["a", "b"] # same length: plain replace -> [0,'a','b',3,4]
buffer[1:3] = ["x"] # SHORTER: the list shrinks -> [0,'x',3,4]
buffer[1:2] = ["p", "q", "r"] # LONGER: the list grows -> [0,'p','q','r',3,4]
buffer[2:2] = ["mid"] # empty slice = INSERT at 2 -> [0,'p','mid','q','r',3,4]
buffer[0:1] = [] # assigning [] DELETES -> ['p','mid','q','r',3,4]
del buffer[1:3] # the explicit delete -> ['p','r',3,4]
buffer[:] = [9, 9] # replace CONTENTS in place -> [9, 9]
a[:] = other versus a = other. The first mutates the object every other name still points at. The second just rebinds your local name and leaves everyone else looking at the old list. When a function must change a caller’s list, a[:] = ... is the way. See the passing-arguments section of Guide 5.b[::2] = [1, 2] works on a 4-element list and raises ValueError otherwise, because there is no sensible way to stretch a strided view.striped = [0, 0, 0, 0]
striped[::2] = [1, 1] # OK: 2 targets, 2 values -> [1, 0, 1, 0]
# striped[::2] = [1, 1, 1] # ValueError: attempt to assign sequence of
# size 3 to extended slice of size 2
| Idiom | Meaning | Cost |
|---|---|---|
a[::-1] | Reversed copy | O(n) |
a[:] | Shallow copy | O(n) |
a[::2] | Every other item | O(n) |
a[1:] | Everything but the first | O(n) |
a[:-1] | Everything but the last | O(n) |
a[1:-1] | Strip both ends | O(n) |
a[-k:] | The last k | O(k) |
a[:k] | The first k, safely, even if there are fewer | O(k) |
a[:m], a[m:] | Split in two at m | O(n) |
a[k:] + a[:k] | Rotate left by k | O(n) |
a[:] = [] | Clear in place, visible to every alias | O(n) |
def rotate_left(items: list, k: int) -> list:
"""Move the first k items to the back. O(n) time, O(n) space.
The modulo makes k larger than the length harmless, and it also makes
an empty list safe, which a bare k would not.
>>> rotate_left([1, 2, 3, 4, 5], 2)
[3, 4, 5, 1, 2]
>>> rotate_left([1, 2, 3], 7)
[2, 3, 1]
>>> rotate_left([], 3)
[]
"""
if not items:
return []
k %= len(items)
return items[k:] + items[:k]
def is_palindrome(text: str) -> bool:
"""Read the same forwards and backwards. O(n) time, O(n) space.
Clear and idiomatic. The O(1)-space version is two converging pointers,
which is Pattern 2.
>>> is_palindrome("racecar")
True
>>> is_palindrome("abc")
False
"""
return text == text[::-1]
slice()The bracket syntax is sugar for a slice object. Building one explicitly lets you give a recurring range a name, which beats a magic [8:12] scattered through a parser.
record = "2026-08-28T10:07"
YEAR = slice(0, 4) # O(1) to build
MONTH = slice(5, 7)
TIME = slice(11, None) # None means "to the end"
record[YEAR] # '2026'
record[MONTH] # '08'
record[TIME] # '10:07'
def starts_bad(text: str) -> list:
out = []
for i in range(len(text)):
out.append(text[i:]) # O(n - i) COPY
return out # total O(n^2)
def starts_good(text: str) -> list:
# Carry the index and read text[i]
# directly. Slice ONCE, at the end,
# only if a substring is really
# needed.
return list(range(len(text)))
The same trap appears in recursion. solve(items[1:]) looks elegant and copies the list at every level, turning an O(n) recursion into O(n²). Pass items plus a start index instead. That is exactly why every template on this site threads indices rather than slices, from backtracking to binary search.
from itertools import islice
def head_of_stream(stream, k: int) -> list:
"""You cannot slice an iterator. islice takes k items lazily. O(k).
>>> head_of_stream(iter(range(100)), 3)
[0, 1, 2]
"""
return list(islice(stream, k)) # generators have no [0:k]
| Type | A slice gives you | Watch for |
|---|---|---|
list | A new list, shallow copy | O(k). Assignable on the left. |
str | A new string | Immutable, so no slice assignment. |
tuple | A new tuple | Immutable. |
range | Another range, computed lazily | O(1). No elements are materialised. |
bytes, bytearray | The same type | bytearray supports slice assignment. |
| NumPy array | A view, not a copy | O(1), and writing through it changes the original. The opposite of a list. See Guide 6. |
| pandas | iloc is exclusive, loc is inclusive | The one place the half-open rule does not hold. See Guide 6. |
collections.deque | Nothing. Slicing raises. | Use itertools.islice. |
| A generator or iterator | Nothing. Slicing raises. | Use itertools.islice. |
| Bug | Why | Fix |
|---|---|---|
a[:-k] returns empty | k was 0, so it read as a[:0] | a[:len(a) - k], or guard k == 0 |
a[1:4:-1] returns empty | A negative step needs start > stop | a[1:4][::-1] |
| Bad indices give no error | Slices clamp, they do not raise | Assert the length you expected |
| Editing a copy also edits the original | a[:] is shallow | copy.deepcopy for nested data |
| A function’s change is invisible to the caller | a = new rebinds a local name | a[:] = new |
| Quadratic runtime out of nowhere | A slice inside a loop or a recursion | Pass indices; slice once at the end |
| NumPy edits leak into the source array | A NumPy slice is a view | arr[1:4].copy() |
a[::0] raises | A zero step cannot advance | Never compute a step that can be 0 |
Know this table. Most accidental time-limit failures are one row of it.
| Operation | Cost | Note |
|---|---|---|
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 lst | O(n) | The most common accidental slowdown. |
x in set, x in dict | O(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 strings | O(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, heappop | O(log n) | Min-heap only. |
heapify(lst) | O(n) | Cheaper than n pushes. |
bisect_left, bisect_right | O(log n) | On a list you already keep sorted. |
insort | O(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. |
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 is part of the standard library, so there is nothing to install. It provides container types built on top of list, dict and set that remove boilerplate you would otherwise write by hand. Three of them carry almost all the weight.dict whose values are counts. You hand it any iterable and it tallies how many times each item appears. Counter("aab") is {'a': 2, 'b': 1}. Looking up a key that is not there returns 0 instead of raising an error, which is the whole reason it exists.counts: dict[str, int] = {}
for ch in "mississippi": # O(n) overall,
if ch not in counts: # O(1) average
counts[ch] = 0
counts[ch] += 1
from collections import Counter
counts = Counter("mississippi") # O(n), same cost, one line
from collections import Counter
counts = Counter("mississippi") # O(n) Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})
counts["s"] # O(1) average 4
counts["z"] # O(1) average 0, and NO KeyError
counts.most_common(2) # O(n log k) [('i', 4), ('s', 4)]
counts.most_common() # O(n log n) no k, so it sorts everything
sum(counts.values()) # O(d) 11, d = number of DISTINCT keys
list(counts) # O(d) the distinct characters, first-seen order
# Counters compare and combine directly, which anagram problems love.
Counter("listen") == Counter("silent") # O(d) True
Counter("aab") - Counter("ab") # O(d) Counter({'a': 1})
Counter("aab") + Counter("bc") # O(d) Counter({'a': 2, 'b': 2, 'c': 1})
# It counts any iterable, not just strings.
Counter([1, 1, 2]) # O(n) Counter({1: 2, 2: 1})
most_common(k) does not sort everything. Internally it uses heapq.nlargest, so it costs O(n log k) rather than O(n log n). That is the same size-k heap trick as Pattern 9, already written for you.list makes [], int makes 0, set makes set(). Everything else behaves exactly like a normal dict.graph: dict[str, list[str]] = {}
if "a" not in graph: # O(1) average
graph["a"] = [] # O(1) average
graph["a"].append("b") # O(1) average
from collections import defaultdict
graph = defaultdict(list)
graph["a"].append("b") # O(1) average, and no guard needed
from collections import defaultdict
# list: for adjacency lists and grouping
graph: defaultdict[str, list[str]] = defaultdict(list)
graph["a"].append("b") # O(1) average -> {'a': ['b']}
# int: for tallies, when you want to control the counting yourself
tally: defaultdict[str, int] = defaultdict(int)
tally["x"] += 1 # O(1) average starts from 0
# set: for de-duplicated groups
buckets: defaultdict[str, set[str]] = defaultdict(set)
buckets["k"].add("v") # O(1) average
graph["z"] adds an empty list and len(graph) grows, just from looking. To check without creating, use "z" in graph or graph.get("z").If you know Java, this is ArrayDeque. C++: std::deque.
A plain list looks like it can be a queue: append at the end, pop(0) at the front. But pop(0) shifts every remaining element left by one, so it is O(n). Do that inside a BFS loop and a linear algorithm becomes quadratic.
queue = [1, 2, 3]
first = queue.pop(0) # O(n) shifts everything left
queue.append(4) # O(1)
from collections import deque
queue = deque([1, 2, 3])
first = queue.popleft() # O(1) no shifting
queue.append(4) # O(1)
from collections import deque
queue = deque([1, 2, 3])
queue.append(4) # O(1) add at the right
queue.appendleft(0) # O(1) add at the left
queue.pop() # O(1) remove from the right
queue.popleft() # O(1) remove from the left
len(queue) # O(1)
bool(queue) # O(1) False when empty, so `while queue:` reads well
queue[0] # O(1) the ENDS are cheap
queue[len(queue) // 2] # O(n) the MIDDLE is not: a deque is not an array
recent = deque(maxlen=3) # O(1) per push. Fixed size: a 4th item drops the oldest.
append and pop at the end are already O(1).If you know Java, this is PriorityQueue. C++: std::priority_queue, though that one is a max-heap by default and Python’s is a min-heap.
heapq is. Unusually, it is not a class. It is a module of functions that operate on an ordinary Python list. You create a plain [] and pass it to heappush and heappop. There is no Heap object to construct, which surprises people coming from other languages.You repeatedly need the smallest item from a changing collection. Sorting after every change is O(n log n) each time. Scanning for the minimum is O(n) each time. A heap gives you the minimum in O(1) and maintains itself in O(log n).
import heapq
heap: list[int] = [] # just a list; heapq gives it the heap property
heapq.heappush(heap, 5) # O(log n)
heapq.heappush(heap, 1) # O(log n)
heapq.heappush(heap, 4) # O(log n)
heap[0] # O(1) 1, the smallest, WITHOUT removing it
heapq.heappop(heap) # O(log n) 1, removes and returns the smallest
len(heap) # O(1)
existing = [5, 1, 4]
heapq.heapify(existing) # O(n) IN PLACE, cheaper than n pushes
heapq.heapreplace(existing, 9) # O(log n) pop then push, ONE sift not two
heapq.nlargest(2, [5, 1, 4]) # O(n log k) [5, 4]
heapq.nsmallest(2, [5, 1, 4]) # O(n log k) [1, 4]
# What a heap cannot do cheaply:
9 in heap # O(n) no fast search; it is not a sorted list
[1, 5, 4] is correct and is not sorted. Only heap[0] is guaranteed to be the minimum. The rest is in heap order, which looks random.| Need | Do this |
|---|---|
| A max-heap | There is none. Push -value and negate again on the way out. |
| Order by a key | Push tuples. Python compares tuples element by element, so (distance, name) orders by distance first. |
| Delete an arbitrary item | Not supported. Mark it dead in a set and skip it when it surfaces. |
| Build from a list | heapify, O(n), rather than n separate pushes at O(n log n). |
import heapq
# A max-heap of 3, 7, 5 by negating on the way in and out.
max_heap: list[int] = []
for value in (3, 7, 5):
heapq.heappush(max_heap, -value) # O(log n) each, O(n log n) for the loop
largest = -heapq.heappop(max_heap) # O(log n) 7
# Ordering by a key, with a safe tie-break in second position.
tasks: list[tuple[int, int, str]] = []
heapq.heappush(tasks, (2, 0, "write")) # O(log n)
heapq.heappush(tasks, (1, 1, "read")) # O(log n)
heapq.heappop(tasks) # O(log n) (1, 1, 'read') comes out first
TypeError in the middle of your run. Always put something comparable second, such as an insertion counter, and keep unorderable payloads third or later. See Pattern 9.bisect finds in O(log n) the index where a value belongs. It does not sort for you, and it gives wrong answers on unsorted input without complaining.If you know C++, bisect_left is lower_bound and bisect_right is upper_bound. Java: Collections.binarySearch, though that returns a negative encoded value when absent.
import bisect
data = [1, 3, 3, 5]
bisect.bisect_left(data, 3) # O(log n) 1, FIRST index where data[i] >= 3
bisect.bisect_right(data, 3) # O(log n) 3, FIRST index where data[i] > 3
bisect.bisect_left(data, 4) # O(log n) 3, where a 4 would be inserted
bisect.insort(data, 4) # O(n) the search is O(log n), the INSERT
# shifts everything after it
bisect_left gives the first position of a value, bisect_right gives one past the last. So the number of copies of x is bisect_right(data, x) - bisect_left(data, x). Both return an insertion point and never promise the value is actually present, so always check data[i] == x before trusting it. Forgetting that check is the bug in Find First and Last Position.insort is only half logarithmic. Finding the spot is O(log n), but inserting into a list shifts everything after it, which is O(n). Building a sorted list with repeated insort calls is O(n²).list(...) to see it. That laziness is the point: permutations of ten items can be walked without ever holding 3.6 million tuples at once.from itertools import accumulate, combinations, permutations, product, groupby, pairwise
list(accumulate([1, 2, 3, 4])) # O(n) [1, 3, 6, 10] running totals
list(accumulate([3, 1, 4], max)) # O(n) [3, 3, 4] running maximum
list(combinations([1, 2, 3], 2)) # O(C(n,k) * k) order ignored
list(permutations([1, 2])) # O(n! * n) order matters
list(product([0, 1], repeat=2)) # O(k**n * n) the full grid
list(pairwise([1, 2, 3])) # O(n) [(1, 2), (2, 3)] adjacent pairs
[(key, len(list(run))) for key, run in groupby("aaabbc")] # O(n)
# Creating any of these iterators is O(1). The costs above are what you pay to
# WALK them, which wrapping in list(...) forces you to do all at once.
| Function | What it gives you |
|---|---|
accumulate | Prefix sums in one line, or any running fold. See Pattern 13. |
pairwise | Every adjacent pair, so you can compare neighbours without index arithmetic. Python 3.10+. |
groupby | Runs of equal consecutive items, which is run-length encoding. It only groups consecutive items, so sort first if you meant to group globally. |
combinations, permutations | A brute force to check your backtracking answer against. |
product | Nested loops over a grid of choices, flattened into one loop. |
itertools as what you would ship. Reaching for the library first reads as avoiding the question.@something line above a def is a decorator: it wraps your function in another function. @cache wraps yours in a lookup table, so a repeat call with the same arguments returns the stored answer instead of running the body again. You change one line and nothing else.from functools import cache, lru_cache, reduce, cmp_to_key
@cache
def fib(n: int) -> int:
"""O(2**n) without the decorator. O(n) with it."""
return n if n < 2 else fib(n - 1) + fib(n - 2)
fib(100) # O(n) on the first call, O(1) on a repeat
reduce(lambda a, b: a * b, [1, 2, 3, 4]) # O(n) 24, folds the list into one value
# A cache hit is a dict lookup: O(1) average, plus O(k) to hash the arguments.
# The memory cost is O(number of distinct argument tuples).
@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, or results from one call leak into the next.| Tool | What it does |
|---|---|
@cache | Unlimited memoisation. Python 3.9+. |
@lru_cache(maxsize=None) | The same thing on older versions. With a size limit it evicts the least recently used entry. |
reduce | Folds a sequence into a single value with a two-argument function. |
cmp_to_key | Turns an old-style comparison function into a sort key. Occasionally the cleanest way to express a custom order, as in Largest Number. |
A default value is created once, when the function is defined, not on each call. So a default list is shared by every call forever.
def add(item, bucket=[]):
# The list is created ONCE, when
# the function is defined. Every
# call shares it. Same O(1) cost,
# wrong answer.
bucket.append(item)
return bucket
def add(item, bucket=None):
if bucket is None: # O(1)
bucket = []
bucket.append(item) # O(1)
return bucket
* 2 on a list repeats the reference, not the contents. You end up with two names for one row.
grid = [[0] * 3] * 2
grid[0][0] = 1
# O(rows) to build, and WRONG:
# grid is now [[1,0,0], [1,0,0]]
# because both rows are the SAME
# list object.
grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
# O(rows * cols), and correct.
# The comprehension builds a fresh
# row each time.
| Trap | What goes wrong | Fix |
|---|---|---|
lst.pop(0) as a queue | O(n) per pop, so O(n²) overall | collections.deque |
result += char in a loop | Rebuilds the string each time, O(n²) | Collect into a list, then "".join(parts) |
x in lst inside a loop | O(n) per check | Build a set first |
{} for an empty set | That is an empty dict | set() |
items = items.sort() | sort() returns None, so you lose the list | items.sort() alone, or sorted(items) |
| Mutating a list while iterating it | Skipped elements, silently | Iterate a copy, or build a new list |
-7 // 2 is -4 | Floor division rounds toward minus infinity | int(-7 / 2) for truncation, or math.trunc |
-7 % 3 is 2 | Different from C and Java, which give -1 | Usually what you want. Say so out loud. |
0.1 + 0.2 != 0.3 | Floating point | Stay in integers, or use math.isclose |
is for value comparison | Works for small ints by accident, then stops | == for values, is only for None and identity |
| Recursion past ~1000 frames | RecursionError | Rewrite iteratively, or sys.setrecursionlimit and say why |
sorted(d) on a dict | Sorts the keys, not the items | sorted(d.items(), key=...) |
| Slicing inside a loop | s[i:] copies, so a linear loop turns quadratic | Pass indices instead of slices |
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) # O(n log n) ['a', 'cc', 'bbb']
by_two = sorted(words, key=lambda w: (len(w), w)) # O(n log n) length, then A-Z
# Descending without reversing afterwards.
descending = sorted([3, 1, 2], reverse=True) # O(n log n)
# max and min take a key too.
longest = max(words, key=len) # O(n)
# 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: they stop at the first decisive element.
has_even = any(x % 2 == 0 for x in [1, 3, 4]) # O(n) worst case
all_positive = all(x > 0 for x in [1, 2]) # O(n) worst case
# Dict and set comprehensions.
squares = {x: x * x for x in range(3)} # O(n)
letters = {ch for ch in "hello"} # O(n)
# The walrus operator, for "compute once, test, then reuse".
values = [1, 2, 3]
if (total := sum(values)) > 5: # O(n), and only ONCE
leftover = total - 5
# Counting without a loop.
most_common_char, _ = Counter("aabbbcc").most_common(1)[0] # O(n)
# 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))
key= explained. Several built-ins take a key argument: a function applied to each element to decide the ordering. sorted(words, key=len) sorts by length, not alphabetically. lambda w: (len(w), w) is an unnamed one-line function returning a tuple, so it sorts by length first and breaks ties alphabetically.| Fact | Why it matters in an interview |
|---|---|
| Integers are arbitrary precision | No 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 1000 | A DFS on a 200×200 grid can exceed it. Know the iterative form. |
| Strings are immutable | Every edit builds a new string. Build a list of characters and join at the end. |
| Sort is stable | Equal keys keep their input order, so you can sort by two keys in two passes, least significant first. |
| Dicts keep insertion order | Guaranteed since 3.7. Useful for deterministic output, and for a simple LRU. |
| Set iteration order is not guaranteed across runs | If your output depends on it, the result 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 BST | No TreeMap or std::map equivalent. Use sortedcontainers if allowed, or a heap plus lazy deletion, or say what you would use. |
x in set is O(1) and x in list is O(n). This one line decides more outcomes than any other.0 for missing keys. defaultdict creates a default the first time you touch a key.list.pop(0) is O(n). A list is fine as a stack."".join(parts), never += in a loop. [[0] * n for _ in range(m)], never [[0] * n] * m.