Guides Guide 1 Reference

The Python Toolkit

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.

If you already know Python well, skip to the cost table and the traps. Everything before that is background. For generators, decorators, the data model and the GIL, go on to Guide 5.
A convention used throughout this page. Every code line that does real work carries its cost as a trailing comment, for example # 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.

Contents

  1. Syntax you will see on these pages
  2. The five built-in types
  3. Slicing in depth
  4. What each operation actually costs
  5. collections: Counter, defaultdict, deque
  6. heapq: the priority queue
  7. bisect: binary search on a sorted list
  8. itertools
  9. functools and @cache
  10. The traps
  11. Idioms worth having
  12. Limits of the language

Syntax you will see on these pages

Ten pieces of Python notation account for almost everything in the sixteen pattern pages. If any of these look unfamiliar, read this section first.

Blocks are indentation, not braces

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.

Type hints, which Python ignores

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
What they are. : 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 truthiness

value = 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
The truthiness trap. 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.

Slicing

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.
The one thing to remember. A slice copies. 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.

Comprehensions

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.

Multiple assignment and unpacking

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).

f-strings

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

Nested functions and nonlocal

Every 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
The rule. An inner function can freely read outer variables, and can mutate objects they point at, such as appending to a list or adding to a set. It needs 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.

Classes, in the amount you need

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.

The five built-in types

Everything else is built on these. Get them right and the libraries make sense.

list

What it is. A resizable array, stored in one contiguous block of memory. Despite the name it is not a linked list. Indexing is instant. Adding at the end is instant. Adding or removing at the front shifts every other element, so it is slow.

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.

dict

What it is. A hash map: a collection of key to value pairs with average constant-time lookup, insert and delete. Keys must be hashable, which in practice means immutable: numbers, strings and tuples work, lists do not.

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.

set

What it is. A hash set: an unordered collection of unique, hashable values. It is a dict with only keys. Its whole purpose is answering “have I seen this before?” in constant time.

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
The single most valuable line on this page. 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.

tuple

What it is. A fixed-length, immutable list, written with round brackets. Because it can never change, Python can hash it, which means a tuple can be a dict key or a set member. A list cannot.

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.

str

What it is. An immutable sequence of characters. You can index and slice it like a list, but you can never change it in place. Every apparent edit builds a whole new string.
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.
Never build a string with += 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.

Coming from another language

You wantPythonJavaC++
Dynamic arraylistArrayListvector
Hash mapdictHashMapunordered_map
Hash setsetHashSetunordered_set
Queuecollections.dequeArrayDequedeque
Stacklist, with append and popArrayDequestack
Priority queueheapqPriorityQueuepriority_queue
Sorted map or tree mapnone built inTreeMapstd::map
Frequency countcollections.CounterMap plus mergemap plus increment
Binary searchbisectCollections.binarySearchlower_bound
The one real gap: Python has no balanced binary search tree in the standard library, so there is no equivalent of 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.

Slicing in depth

What a slice is. 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.

The three parts

PartMeaningDefault when omitted
startFirst index included0, or len - 1 when the step is negative
stopFirst index excludedlen, or “past the front” when the step is negative
stepHow far to move each time1. 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
Why half-open is the right choice. Three things fall out of it. The length is simply 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.

Negative indices count from the right

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
The [:-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.

A negative step walks backwards

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
With a negative step, 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.

Out-of-range is safe, unlike indexing

This is the biggest practical difference between 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.

A slice copies, and the copy is shallow

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.

Slice assignment: the part people never learn

On a mutable sequence you can put a slice on the left of an assignment. The right side may be any iterable, and the lengths do not have to match. That single fact gives you replace, insert, delete, and splice in one syntax.
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.
Extended slice assignment is stricter. When the step is not 1, the lengths must match exactly. 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

The idioms worth recognising on sight

IdiomMeaningCost
a[::-1]Reversed copyO(n)
a[:]Shallow copyO(n)
a[::2]Every other itemO(n)
a[1:]Everything but the firstO(n)
a[:-1]Everything but the lastO(n)
a[1:-1]Strip both endsO(n)
a[-k:]The last kO(k)
a[:k]The first k, safely, even if there are fewerO(k)
a[:m], a[m:]Split in two at mO(n)
a[k:] + a[:k]Rotate left by kO(n)
a[:] = []Clear in place, visible to every aliasO(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]

Naming a slice with 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'

The cost, and the trap

A slice is O(k) in the number of elements it produces, because it allocates and fills a new object. A single slice is cheap. A slice inside a loop is how a linear algorithm silently becomes quadratic.
O(n²): slices in the loop
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)
O(n): pass indices
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]

Slicing elsewhere

TypeA slice gives youWatch for
listA new list, shallow copyO(k). Assignable on the left.
strA new stringImmutable, so no slice assignment.
tupleA new tupleImmutable.
rangeAnother range, computed lazilyO(1). No elements are materialised.
bytes, bytearrayThe same typebytearray supports slice assignment.
NumPy arrayA view, not a copyO(1), and writing through it changes the original. The opposite of a list. See Guide 6.
pandasiloc is exclusive, loc is inclusiveThe one place the half-open rule does not hold. See Guide 6.
collections.dequeNothing. Slicing raises.Use itertools.islice.
A generator or iteratorNothing. Slicing raises.Use itertools.islice.

Common bugs

BugWhyFix
a[:-k] returns emptyk was 0, so it read as a[:0]a[:len(a) - k], or guard k == 0
a[1:4:-1] returns emptyA negative step needs start > stopa[1:4][::-1]
Bad indices give no errorSlices clamp, they do not raiseAssert the length you expected
Editing a copy also edits the originala[:] is shallowcopy.deepcopy for nested data
A function’s change is invisible to the callera = new rebinds a local namea[:] = new
Quadratic runtime out of nowhereA slice inside a loop or a recursionPass indices; slice once at the end
NumPy edits leak into the source arrayA NumPy slice is a viewarr[1:4].copy()
a[::0] raisesA zero step cannot advanceNever compute a step that can be 0
Say this out loud: “Slicing copies, so it is O(k), and I keep it out of loops. It clamps instead of raising, which is convenient and also hides bad indices. And a NumPy slice is a view, which is the opposite rule.”

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

What the module is. 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.

Counter

What it is. A subclass of 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.

The problem it solves

Without Counter
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
With Counter
from collections import Counter

counts = Counter("mississippi")    # O(n), same cost, one line

What you can do with it

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.
Reach for it when: the problem says frequency, most common, anagram, appears more than k times, or you need to compare two multisets. Used on Minimum Window Substring and Top K Frequent Elements.

defaultdict

What it is. A dict that manufactures a value the first time you touch a missing key. You give it a factory, which is just a function that makes the default: list makes [], int makes 0, set makes set(). Everything else behaves exactly like a normal dict.

The problem it solves

Without defaultdict
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
With defaultdict
from collections import defaultdict

graph = defaultdict(list)
graph["a"].append("b")    # O(1) average, and no guard needed

The three factories you will use

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
The one gotcha. Merely reading a missing key creates it. So 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").
Reach for it when: you are building an adjacency list, grouping items by a key, or counting with custom logic. Used throughout Topological Sort and Prefix Sum.

deque

What it is. A double-ended queue, pronounced “deck”. Internally it is a doubly linked list of small blocks, which means adding or removing at either end costs O(1). The price is that reaching into the middle costs O(n), because it has to walk the blocks.

If you know Java, this is ArrayDeque. C++: std::deque.

The problem it solves

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.

A list as a queue: O(n²)
queue = [1, 2, 3]
first = queue.pop(0)      # O(n)  shifts everything left
queue.append(4)           # O(1)
A deque as a queue: O(n)
from collections import deque

queue = deque([1, 2, 3])
first = queue.popleft()   # O(1)  no shifting
queue.append(4)           # O(1)

What you can do with it

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.
Reach for it when: you need a queue for BFS, you need to add or remove at both ends, or you want a fixed-size rolling window. For a plain stack a list is fine, because append and pop at the end are already O(1).

heapq

What a heap is. A binary tree stored in an array, kept so that every parent is smaller than or equal to both its children. That single rule means the smallest item is always at index 0, readable instantly. Pushing and popping restore the rule by sifting one item up or down the tree, which touches only the height, so both cost O(log n). The array is not sorted, and never becomes sorted. It is only heap-ordered.

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.

What 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.

The problem it solves

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
Do not print the list to check it. A heap of [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.

Two things Python makes you do by hand

NeedDo this
A max-heapThere is none. Push -value and negate again on the way out.
Order by a keyPush tuples. Python compares tuples element by element, so (distance, name) orders by distance first.
Delete an arbitrary itemNot supported. Mark it dead in a set and skip it when it surfaces.
Build from a listheapify, 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
The tuple-comparison trap. If two tuples tie on the first element, Python compares the second. If that is an object with no defined ordering, it raises 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.
Reach for it when: the problem says K largest, K closest, most frequent, median of a stream, merge K sorted lists, or always process the earliest-finishing thing next. Used in Pattern 9 and Meeting Rooms II.

bisect

What it is. Binary search, packaged. Given a list you are already keeping in sorted order, 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
Left versus right. 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²).
Reach for it when: the data is sorted and you need where does this go, how many are below x, or first and last occurrence. Used in Pattern 11 and in the fast solution to Longest Increasing Subsequence.

itertools

What it is. A module of functions that build iterators. An iterator produces values one at a time on demand rather than building a whole list in memory, which is why you usually wrap the result in 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.
FunctionWhat it gives you
accumulatePrefix sums in one line, or any running fold. See Pattern 13.
pairwiseEvery adjacent pair, so you can compare neighbours without index arithmetic. Python 3.10+.
groupbyRuns of equal consecutive items, which is run-length encoding. It only groups consecutive items, so sort first if you meant to group globally.
combinations, permutationsA brute force to check your backtracking answer against.
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 and @cache

What a decorator is. The @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.
ToolWhat it does
@cacheUnlimited 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.
reduceFolds a sequence into a single value with a two-argument function.
cmp_to_keyTurns an old-style comparison function into a sort key. Occasionally the cleanest way to express a custom order, as in Largest Number.

The traps

Mutable default argument

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.

Wrong
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
Right
def add(item, bucket=None):
    if bucket is None:      # O(1)
        bucket = []
    bucket.append(item)     # O(1)
    return bucket

Aliased rows in a 2D grid

* 2 on a list repeats the reference, not the contents. You end up with two names for one row.

Wrong
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.
Right
grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
# O(rows * cols), and correct.
# 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
{} for an empty setThat is an empty dictset()
items = items.sort()sort() returns None, so you lose the listitems.sort() alone, or sorted(items)
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)                 # 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.

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.
Set iteration order is not guaranteed across runsIf 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 BSTNo TreeMap or std::map equivalent. Use sortedcontainers if allowed, or a heap plus lazy deletion, or say what you would use.

The seven things to carry forward


← 16 — Union-Find Guide 2 — Constraints and Complexity →