References

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

Big-ONameRating
O(1)constantExcellent
O(log n)logarithmicExcellent
O(n)linearFair
O(n log n)linearithmicBad
O(n²)quadraticHorrible
O(2ⁿ)exponentialHorrible
O(n!)factorialHorrible
O(n!)O(2^n)O(n^2)O(n log n)O(n)O(log n), O(1)OperationsElements

Searching & sorting

AlgorithmSpaceBestAverageWorst
Linear searchO(1)O(1)O(n)O(n)
Binary searchO(1)O(1)O(log n)O(log n)
Selection sortO(1)O(n²)O(n²)O(n²)
Insertion sortO(1)O(n)O(n²)O(n²)
Bubble sortO(1)O(n)O(n²)O(n²)
Merge sortO(n)O(n log n)O(n log n)O(n log n)
Quick sortO(log n)O(n log n)O(n log n)O(n²)
Heap sortO(1)O(n log n)O(n log n)O(n log n)

Choosing a structure

I need…UseWhy
Index / append at the endlistO(1) random access and append at the tail.
Fast membership testsset / dictO(1) average lookup vs O(n) scanning a list.
Queue / work at both endscollections.dequeO(1) appendleft / popleft; a list is O(n) at the front.
Smallest (or largest) firstheapqO(log n) push/pop, O(1) peek the min.
Count occurrencescollections.CounterTallying plus most_common(k), built in.
Group items by keydefaultdict(list)Auto-creates the bucket; no KeyError.
Unique while keeping orderdict.fromkeys(x)Dedupe without losing order (a set drops it).
Immutable / hashable keytuple or frozensetUsable as a dict key or set member.
Keep a list sortedbisect.insortO(log n) to find the slot (the insert still shifts, O(n)).

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)  -> 1
OperationCodeRuntimeTimeSpaceNotes
Create[3, 1, 2]CO(n)O(n)
Indexx[i]CO(1)O(1)
Storex[i] = vCO(1)O(1)
Appendx.append(v)CO(1)*O(1)Worst O(n). Amortized O(1): an occasional resize copies every element, so one call can hit O(n).
Pop lastx.pop()CO(1)O(1)
Insert at ix.insert(i, v)CO(n)O(1)Shifts every later element. Appending to the end is O(1); for front ops use collections.deque.
Pop / del at ix.pop(i)CO(n)O(1)Removing from the middle or front shifts every later element down. deque.popleft() is O(1).
Remove valuex.remove(v)CO(n)O(1)Scans to find v, then shifts the rest down, both O(n).
Membershipv in xCO(n)O(1)Linear C scan, still O(n) but far faster per element than a Python loop. For repeated lookups use a set or dict (O(1)).
Iterationfor v in xPyO(n)O(1)The loop body runs in interpreted Python, one bytecode step per item: the slow kind of O(n).
Slicex[a:b]CO(k)O(k)
Extendx.extend(y)CO(k)O(k)
Sort in placex.sort()CO(n log n)O(n)Timsort, in C. Sort once, never inside a loop (that becomes O(n² log n)).
Sorted copysorted(x)CO(n log n)O(n)
Dedupelist(set(x))CO(n)O(n)Two sequential linear passes (set then list): O(n) overall, not O(n²). Drops the original order; use list(dict.fromkeys(x)) to keep it.
min / maxmin(x)CO(n)O(1)
Copyx[:]CO(n)O(n)

tuple

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 key
OperationCodeRuntimeTimeSpaceNotes
Create(1, 2, 3)CO(n)O(n)
Indext[i]CO(1)O(1)
Lengthlen(t)CO(1)O(1)
Membershipv in tCO(n)O(1)
Slicet[a:b]CO(k)O(k)
Concatenatet + uCO(n+m)O(n+m)
Hash (dict/set key)hash(t)CO(n)O(1)

range

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)
OperationCodeRuntimeTimeSpaceNotes
Createrange(0, n)CO(1)O(1)Stores only start, stop and step; no elements are materialized.
Indexr[i]CO(1)O(1)
Lengthlen(r)CO(1)O(1)
Membershipx in rCO(1)O(1)Computed by arithmetic for integer x, not a scan.
Slicer[a:b]CO(1)O(1)Returns another range, no copy.
Iterationfor x in rPyO(n)O(1)

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)  -> 1
OperationCodeRuntimeTimeSpaceNotes
Createarray("i", [1, 2, 3])CO(n)O(n)
Indexa[i]CO(1)O(1)
Storea[i] = vCO(1)O(1)
Appenda.append(v)CO(1)*O(1)Worst O(n). Amortized O(1): an occasional resize copies every element, so one call can hit O(n).
Pop / del at ia.pop(i)CO(n)O(1)
Membershipv in aCO(n)O(1)
Iterationfor v in aPyO(n)O(1)Each value is boxed back into a Python int/float as you read it.

str

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          # True
OperationCodeRuntimeTimeSpaceNotes
Create"hello"CO(n)O(n)
Indexs[i]CO(1)O(1)
Lengthlen(s)CO(1)O(1)
Equalitys == tCO(n)O(1)Scans char by char until a mismatch, so O(n), not O(1).
Substringsub in sCO(n)O(1)Worst O(n·m)
Concatenates + tCO(n+m)O(n+m)
Build in a loops += xPyO(n²)O(n)A Python loop, and each += copies the whole string. Build a list, then "".join(parts) → C-level O(n).
Repeats * kCO(n·k)O(n·k)
Slices[a:b]CO(k)O(k)
strip / lower / titles.title()CO(n)O(n)
replaces.replace(a, b)CO(n)O(n)
split / join" ".join(xs)CO(n)O(n)
Hash (dict/set key)hash(s)CO(n)O(1)

bytes

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"
OperationCodeRuntimeTimeSpaceNotes
Createb"abc"CO(n)O(n)
Indexb[i]CO(1)O(1)Returns an int 0-255.
Lengthlen(b)CO(1)O(1)
Substringsub in bCO(n)O(1)Worst O(n·m)
Sliceb[a:b]CO(k)O(k)
Concatenateb + cCO(n+m)O(n+m)
Decodeb.decode()CO(n)O(n)

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 immutable
OperationCodeRuntimeTimeSpaceNotes
Createbytearray(b'abc')CO(n)O(n)
Index / storeba[i] = vCO(1)O(1)
Appendba.append(v)CO(1)*O(1)Worst O(n). Amortized O(1): an occasional resize copies every element, so one call can hit O(n).
Pop / del at iba.pop(i)CO(n)O(1)
Extendba.extend(c)CO(k)O(k)
Membershipsub in baCO(n)O(1)Worst O(n·m)
Sliceba[a:b]CO(k)O(k)

dict

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           # True
OperationCodeRuntimeTimeSpaceNotes
Create{"a": 1, "b": 2}CO(n)O(n)
Getd[k]CO(1)O(1)Worst O(n)
Setd[k] = vCO(1)O(1)Worst O(n)
Deletedel d[k]CO(1)O(1)Worst O(n)
Membershipk in dCO(1)O(1)Worst O(n)
View keys / itemsd.items()CO(1)O(1)A lazy view: O(1) to create, but O(n) to iterate or wrap in list().
Iterationfor k in dPyO(n)O(1)The loop body runs in interpreted Python, one bytecode step per item.
Copyd.copy()CO(n)O(n)

collections.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"] += 1
OperationCodeRuntimeTimeSpaceNotes
Createdefaultdict(list)CO(1)O(1)
Get / autocreated[k]CO(1)O(1)Worst O(n). Missing keys are created via the factory (list, int, set…) instead of raising KeyError.
Setd[k] = vCO(1)O(1)Worst O(n)
Membershipk in dCO(1)O(1)Worst O(n)

collections.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)]
OperationCodeRuntimeTimeSpaceNotes
Create / tallyCounter("aabbbc")CO(n)O(n)
Get countc[k]CO(1)O(1)Worst O(n)
Incrementc[k] += 1CO(1)O(1)Worst O(n)
Top-kc.most_common(k)CO(n log k)O(k)
Rank allc.most_common()CO(n log n)O(n)

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}
OperationCodeRuntimeTimeSpaceNotes
Create{1, 2, 3}CO(n)O(n)Visits every element. set(list(...)) and list(set(...)) are both O(n).
Adds.add(v)CO(1)O(1)Worst O(n)
Removes.discard(v)CO(1)O(1)Worst O(n)
Membershipv in sCO(1)O(1)Worst O(n)
Unions | tCO(|s|+|t|)O(|s|+|t|)
Intersections & tCO(min(|s|,|t|))O(min(|s|,|t|))Worst O(|s|·|t|)
Differences - tCO(|s|)O(|s|)

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 set
OperationCodeRuntimeTimeSpaceNotes
Createfrozenset({1, 2, 3})CO(n)O(n)
Membershipv in fsCO(1)O(1)Worst O(n)
Unionfs | tCO(|s|+|t|)O(|s|+|t|)
Intersectionfs & tCO(min(|s|,|t|))O(min(|s|,|t|))Worst O(|s|·|t|)
Hash (dict/set key)hash(fs)CO(n)O(1)

collections.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)
OperationCodeRuntimeTimeSpaceNotes
Createdeque([1, 2, 3])CO(n)O(n)
Appenddq.appendleft(v)CO(1)O(1)
Popdq.popleft()CO(1)O(1)
Access middledq[i]CO(n)O(1)
Membershipv in dqCO(n)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)   # 1
OperationCodeRuntimeTimeSpaceNotes
Create emptyh = []CO(1)O(1)
Heapify a listheapify(x)CO(n)O(1)Rearranges the list in place, no extra array.
Pushheappush(h, v)CO(log n)O(1)
Popheappop(h)CO(log n)O(1)
Peekh[0]CO(1)O(1)

bisect (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]
OperationCodeRuntimeTimeSpaceNotes
Find insertion pointbisect(a, x)CO(log n)O(1)
Insert, keep sortedinsort(a, x)CO(n)O(1)Locating the slot is O(log n), but inserting shifts every later element, so it is O(n) overall.

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 hit
FunctionCodeRuntimeTimeSpaceNotes
lenlen(x)CO(1)O(1)Stored on the object; it never recounts.
sumsum(xs)CO(n)O(1)One C-level pass, accumulator only.
min / maxmin(xs)CO(n)O(1)Scans every item.
sortedsorted(xs)CO(n log n)O(n)Timsort; returns a new list.
any / allany(xs)CO(n)O(1)Short-circuits on the first True / False.
zipzip(a, b)CO(n)O(1)Lazy; pairs items as you consume it.
enumerateenumerate(xs)CO(n)O(1)Lazy; yields (index, item) pairs.
map / filtermap(f, xs)CO(n)O(1)Lazy; your f runs once per item.
reversedreversed(xs)CO(1)O(1)Lazy reverse iterator, no copy.

comprehensions & 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) space
FormCodeRuntimeTimeSpaceNotes
List comp[f(x) for x in xs]PyO(n)O(n)Builds the whole list in memory.
Set / dict comp{f(x) for x in xs}PyO(n)O(n)Same, into a set or dict.
Generator expr(f(x) for x in xs)PyO(n)O(1)Lazy: holds one item at a time, so it streams huge inputs.
Consumed lazilysum(x*x for x in xs)PyO(n)O(1)No intermediate list is built.

Standard-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!)
FunctionCodeRuntimeTimeSpaceNotes
chainchain(a, b)CO(n)O(1)Lazily concatenates iterables: O(1) per item, O(n) to consume.
isliceislice(it, k)CO(k)O(1)Takes the first k without building the rest.
accumulateaccumulate(xs)CO(n)O(1)Running totals (or any binary op).
groupbygroupby(xs)CO(n)O(1)Groups adjacent equal items, so sort first for global groups.
productproduct(a, b)CO(n·m)O(n+m)Cartesian product: buffers the inputs, then yields lazily.
product (repeat)product(a, repeat=r)CO(nʳ)O(n)Exponential in r; explodes fast.
permutationspermutations(xs)CO(n!)O(n)Factorial blow-up; only feasible for tiny n.
combinationscombinations(xs, r)CO(nᵏ)O(n)Outputs C(n, r) tuples; still exponential in the worst case.

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])   # 6
FunctionCodeRuntimeTimeSpaceNotes
lru_cache / cache@lru_cacheCO(1)O(n)Memoizes by args (the cache is the O(n) space). Collapses repeated subproblems: naive fib drops from O(2ⁿ) to O(n).
reducereduce(f, xs)CO(n)O(1)Folds left to right with n calls to f.
cmp_to_keycmp_to_key(f)CO(n log n)O(n)Adapts an old two-arg comparator for sorting; wraps each element.
partialpartial(f, a)CO(1)O(1)Pre-binds arguments; creating it is O(1).

copy

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 cloned
FunctionCodeRuntimeTimeSpaceNotes
Shallow copycopy.copy(x)PyO(n)O(n)Clones the top-level container (n references); the inner objects are shared, not duplicated.
Deep copycopy.deepcopy(x)PyO(n)O(n)Recursively clones the whole object graph (n is the total object count); tracks seen objects to survive cycles.
Built-in shallowx[:] / x.copy()CO(n)O(n)Same as copy.copy for list/dict/set, without the import.

math

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)    # 120
FunctionCodeRuntimeTimeSpaceNotes
gcdgcd(a, b)CO(log n)O(1)Euclid's algorithm; n is the smaller value.
isqrtisqrt(n)CO(log n)O(1)Integer square root via Newton's method on the digits of n.
factorialfactorial(n)CO(n)O(1)n multiplications, each over a growing big integer.
sqrt / sin / logsqrt(x)CO(1)O(1)Fixed-width float operation.

random

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 unique
FunctionCodeRuntimeTimeSpaceNotes
randomrandom()PyO(1)O(1)A float in [0, 1).
randintrandint(a, b)PyO(1)O(1)An inclusive integer in [a, b].
choicechoice(seq)PyO(1)O(1)A single random index into a sequence.
shuffleshuffle(x)PyO(n)O(1)In-place Fisher-Yates shuffle, no copy.
samplesample(seq, k)PyO(k)O(k)Builds a new list of k items, no replacement.

statistics

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])      # 1
FunctionCodeRuntimeTimeSpaceNotes
meanmean(xs)PyO(n)O(1)A single pass over the data.
medianmedian(xs)PyO(n log n)O(n)Sorts a copy of the data first (the sort itself is C).
modemode(xs)PyO(n)O(n)Most common value (built on Counter).
stdevstdev(xs)PyO(n)O(1)Sample standard deviation, one pass.

collections.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)  -> 1
OperationCodeRuntimeTimeSpaceNotes
CreatePoint(1, 2)CO(1)O(1)
Field accessp.xCO(1)O(1)
Indexp[0]CO(1)O(1)
Replace fieldp._replace(x=9)CO(1)O(1)
As dictp._asdict()CO(1)O(1)

collections.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 item
OperationCodeRuntimeTimeSpaceNotes
CreateOrderedDict()CO(1)O(1)
Get / set / deld[k]CO(1)O(1)Worst O(n)
Move to endd.move_to_end(k)CO(1)O(1)
Pop first / lastd.popitem(last=False)CO(1)O(1)

collections.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"
OperationCodeRuntimeTimeSpaceNotes
CreateChainMap(a, b)CO(1)O(1)
Lookupcm[k]CO(m)O(1)Walks the m underlying maps in order until the key is found.
Set / deletecm[k] = vCO(1)O(1)Writes only to the first map in the chain.