Python Big-O Cheat Sheet
Big-O for CPython's built-in containers and common algorithms. Times are amortized; * marks amortized O(1) that can spike on resize.
Updated Jun 24, 2026
Definitions
- Big-O notation
- An upper bound on how an algorithm's cost grows as the input size n approaches infinity. Constants and lower-order terms are dropped, so O(2n + 5) is just O(n); it describes the shape of growth, not an exact operation count.
- Time complexity
- How the number of operations an algorithm performs grows with the input size. This is what the chart above plots: elements on the x-axis, operations on the y-axis.
- Space complexity
- How much extra memory an algorithm needs as the input grows, beyond the input itself. A pass that builds a new list of every element is O(n) space; one that mutates in place is O(1).
- Time, Space, worst case
- Time is the typical (average) cost and Space the extra memory. When the worst case is actually worse, it is called out in the Notes: a dict Get is O(1) time but O(n) worst under pathological hash collisions.
- Amortized
- The average cost per operation across a long sequence, even when one call is occasionally expensive. list.append is amortized O(1) (the * marker): almost every append is constant, and the rare O(n) resize is spread thin across all of them.
- C-level vs Python-level (Runtime column)
- Two operations with the same Big-O can differ 10–100× in wall-clock time. Built-ins marked "C" run inside CPython's compiled internals; code marked "Py" runs interpreted, one bytecode step at a time. Same growth curve, very different constant factor, which is why sum(x) beats a hand-written loop and "".join(parts) beats s += x.
- n vs. k
- n is the size of the whole input; k is the size of the part you touch (a slice, a sub-result). O(k) scales with that part, not the entire structure.
Complexity classes
Searching & sorting
Choosing a structure
Built-in containers
list
Ordered, mutable sequence backed by a dynamic array. Fast at the end, slow anywhere in the middle.
nums = [3, 1, 2]
nums.append(4) # O(1) -> [3, 1, 2, 4]
nums.sort() # O(n log n)
nums[0] # O(1) -> 1tuple
Immutable sequence, a fixed list. Hashable when its items are, so it can be a dict/set key, and it’s a little lighter than a list.
t = (1, 2, 3)
t[0] # O(1) -> 1
2 in t # O(n)
d = {(0, 0): "origin"} # tuple as a dict keyrange
A lazy, immutable arithmetic sequence. It stores only start, stop and step, so len, indexing, slicing and even membership are O(1); it never builds the list of numbers.
r = range(0, 1_000_000, 2)
len(r) # O(1) -> 500000
r[100] # O(1) -> 200
999_998 in r # O(1) (arithmetic, not a scan)array.array
A compact, mutable sequence of one numeric type (e.g. 'i' for int, 'd' for float). Same O(1) index and append as a list, but it stores raw C values, so it uses far less memory for large numeric buffers.
from array import array
a = array("i", [1, 2, 3]) # typed: signed int
a.append(4) # O(1)
a[0] # O(1) -> 1str
Immutable sequence of Unicode characters. Every transform (strip, lower, title, replace, +) returns a brand-new string and scans all n characters; even == and hashing are O(n), not O(1).
s = "hello"
s.upper() # "HELLO" (new string)
s[0] # "h"
"lo" in s # Truebytes
Immutable sequence of bytes (ints 0-255). Behaves like str: indexing is O(1), but every transform builds a new bytes object, so it is O(n).
b = b"hello"
b[0] # O(1) -> 104
b"lo" in b # True
b.decode("utf-8") # "hello"bytearray
Mutable counterpart of bytes: a resizable buffer of ints 0-255. Append is amortized O(1) like a list; inserting or deleting in the middle is O(n).
ba = bytearray(b"hi")
ba.append(33) # O(1) -> bytearray(b'hi!')
ba[0] = 72 # O(1)
bytes(ba) # back to immutabledict
Hash map of key → value. Average O(1) lookup, insert and delete, and it keeps insertion order. The O(n) worst case only happens under pathological hash collisions, and in practice it stays O(1).
d = {"a": 1}
d["b"] = 2 # insert, O(1)
d["a"] # O(1) -> 1
"a" in d # Truecollections.defaultdict
A dict that auto-creates a default for missing keys via a factory (list, int, set…), so you skip the get-or-create dance and never hit a KeyError.
from collections import defaultdict
groups = defaultdict(list)
groups["a"].append(1) # no KeyError, O(1)
counts = defaultdict(int)
counts["x"] += 1collections.Counter
A dict subclass that tallies hashable items. Missing keys read as 0, and most_common(k) returns the top-k by count.
from collections import Counter
c = Counter("aabbbc") # {'b':3,'a':2,'c':1}
c["a"] # 2
c.most_common(2) # [('b',3),('a',2)]set
Unordered collection of unique, hashable items. Average O(1) membership and add; the O(n) worst case only happens under pathological hash collisions. |s| means len(s).
s = {1, 2, 3}
s.add(4) # O(1)
2 in s # True
s & {2, 3, 9} # {2, 3}frozenset
Immutable, hashable set. Same average O(1) membership as set, but you can use it as a dict key or nest it inside another set.
fs = frozenset({1, 2, 3})
2 in fs # O(1)
fs | {4} # new frozenset
seen = {fs} # frozenset inside a setcollections.deque
Double-ended queue. O(1) push and pop at both ends; reach for it instead of list when you need a queue.
from collections import deque
q = deque([1, 2, 3])
q.appendleft(0) # O(1)
q.popleft() # O(1)heapq (on a list)
A binary min-heap kept in a plain list. The smallest item is always at h[0]; push and pop are O(log n).
import heapq
h = []
heapq.heappush(h, 3)
heapq.heappush(h, 1)
heapq.heappop(h) # 1bisect (sorted list)
Binary-search helpers for a list you keep sorted. Searching is O(log n); inserting with insort is O(n) because the elements after the slot shift over.
import bisect
a = [1, 3, 5, 7]
bisect.bisect(a, 4) # 2 (insertion point)
bisect.insort(a, 4) # -> [1, 3, 4, 5, 7]Language built-ins
functions
The functions you reach for constantly. All are implemented in C; the lazy ones (zip, enumerate, map, filter, reversed) do no work until you iterate them.
len(xs) # O(1)
sum(x for x in xs) # O(n)
sorted(xs, key=abs) # O(n log n)
any(x > 0 for x in xs) # O(n), stops at first hitcomprehensions & generators
A comprehension builds the whole collection in memory (O(n) space); a generator expression is lazy, yielding one item at a time, so it runs in O(1) space. Same time, very different memory.
squares = [x*x for x in xs] # list, O(n) space
total = sum(x*x for x in xs) # generator, O(1) spaceStandard-library tools
itertools
Lazy iterator building blocks. Most are O(1) per item (O(n) to consume), but the combinatorial ones (product, permutations, combinations) blow up: they are how you reach O(n!) and O(nᵏ) by accident.
from itertools import chain, product, permutations
list(chain([1, 2], [3, 4])) # [1, 2, 3, 4]
list(product([0, 1], repeat=2)) # [(0,0),(0,1),(1,0),(1,1)]
list(permutations([1, 2, 3])) # 6 tuples (3!)functools
Higher-order helpers. lru_cache / cache is the headline for Big-O: memoizing a recursive function reuses subproblem results, turning exponential recursion into linear.
from functools import lru_cache, reduce
@lru_cache
def fib(n): return n if n < 2 else fib(n-1) + fib(n-2)
reduce(lambda a, b: a + b, [1, 2, 3]) # 6copy
Shallow vs deep copying. copy.copy duplicates only the outer container; copy.deepcopy walks the entire object graph, so it is far more expensive for nested data.
import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a) # outer list new, inner lists shared
c = copy.deepcopy(a) # everything clonedmath
C-accelerated math functions. The number-theory ones are cheap: gcd and isqrt are logarithmic in the value, and the float ops are O(1).
import math
math.gcd(12, 18) # 6
math.isqrt(50) # 7
math.factorial(5) # 120random
Pseudo-random helpers. The core RNG is C, but these are thin Python wrappers, so they run at Python (Py) speed.
import random
random.choice([1, 2, 3]) # one item
random.shuffle(deck) # in place
random.sample(range(100), 5) # 5 uniquestatistics
Pure-Python statistics: correct and readable, but Py-level. For large numeric arrays, NumPy is far faster.
import statistics
statistics.mean([1, 2, 3]) # 2
statistics.median([3, 1, 2]) # 2
statistics.mode([1, 1, 2]) # 1collections.namedtuple
A tuple subclass with named fields, built by a factory. Attribute and index access are both O(1), and it stays immutable and hashable like a plain tuple.
from collections import namedtuple
Point = namedtuple("Point", "x y")
p = Point(1, 2)
p.x # O(1) -> 1
p[0] # O(1) -> 1collections.OrderedDict
A dict subclass with move_to_end() and order-sensitive equality. Same O(1) average operations as dict; plain dict already preserves insertion order since 3.7, so use this only when you need those extras.
from collections import OrderedDict
d = OrderedDict(a=1, b=2)
d.move_to_end("a") # O(1)
d.popitem(last=False) # O(1), pops the first itemcollections.ChainMap
Views several dicts as one without merging them. Creating it is O(1) (no copying); a lookup walks the maps in order, so it costs O(m) for m maps.
from collections import ChainMap
defaults = {"color": "red", "size": "M"}
cm = ChainMap({"size": "L"}, defaults)
cm["size"] # "L" (first map wins)
cm["color"] # "red"