CPython Container Internals: list, dict, set, heapq, sorted and bisect
After this lesson, you will be able to:
- Explain why a list over-allocates, simulate its growth with different rules, and show that a proportional rule gives O(1) amortized appends while a fixed step does not
- State the hash and equality contract a dict depends on, and show how a key that changes after insertion gets lost
- Say what insertion order guarantees in a dict, and what happens to it on update, delete and re-insert
- Explain why a set prints in an order you cannot rely on, and why a tuple is hashable only if its items are
- Describe a deque as a chain of blocks and a heapq heap as a plain list, and count the comparisons of heapify, push and pop
- Explain runs, binary insertion, merging and galloping in Timsort, and show its adaptivity and stability with a comparison counter
- Say what interning and the small-integer cache promise (little) and use bisect with a clear idea of where its time goes
Before You Start
#The Picture Under Every Container
a[i] is one step, because the address is the start of the row plus i times the size of a pointer. Second, copying a container copies pointers, not objects.rows = [[1], [2]]
copy = rows[:] # a new row of pointers
copy[0].append(9) # but both rows still point at the same inner list
print(rows)
# Output:
# [[1, 9], [2]]Keep this picture. A list is a row of pointers, a dict is a table of pointers, and a heap is a row of pointers with a rule about their order. Every cost in this lesson is a cost of moving or comparing pointers.
#list: A Row of Pointers That Over-Allocates
| Append number | Length before | Capacity before | Reallocate? | Pointers copied |
|---|---|---|---|---|
| 1 | 0 | 0 | yes, to 1 | 0 |
| 2 | 1 | 1 | yes, to 2 | 1 |
| 3 | 2 | 2 | yes, to 4 | 2 |
| 4 | 3 | 4 | no | 0 |
| 5 | 4 | 4 | yes, to 8 | 4 |
Appends 6, 7 and 8 fit without any copy. The next copy comes at append 9. Copies get rarer as the list grows. Before we count them, make a prediction about a different rule.
A model list adds room for 8 more pointers each time it is full. You compare building a list of 10,000 items with building one of 100,000 items. About how does the copying work per append change?
The cell below simulates four growth rules. It builds no real list. It only counts how many pointers the reallocations copy.
def simulate_appends(n, grow):
"""Return (reallocations, copies) for n appends. grow(capacity) gives the new capacity."""
capacity, size, reallocs, copies = 0, 0, 0, 0
for _ in range(n):
if size == capacity:
capacity = grow(capacity)
copies += size # a full block is copied into the bigger one
reallocs += 1
size += 1
return reallocs, copies
rules = {
"add 8 slots": lambda c: c + 8,
"times 1.25": lambda c: max(c + 1, int(c * 1.25)),
"times 1.5": lambda c: max(c + 1, int(c * 1.5)),
"times 2": lambda c: max(1, c * 2),
}
for n in (1_000, 100_000):
for name, grow in rules.items():
reallocs, copies = simulate_appends(n, grow)
print(f"n={n:>7} {name:<12} reallocations={reallocs:>6} copies per append={copies / n:.2f}")
# Output:
# n= 1000 add 8 slots reallocations= 125 copies per append=62.00
# n= 1000 times 1.25 reallocations= 31 copies per append=4.49
# n= 1000 times 1.5 reallocations= 18 copies per append=2.14
# n= 1000 times 2 reallocations= 11 copies per append=1.02
# n= 100000 add 8 slots reallocations= 12500 copies per append=6249.50
# n= 100000 times 1.25 reallocations= 52 copies per append=4.82
# n= 100000 times 1.5 reallocations= 30 copies per append=2.77
# n= 100000 times 2 reallocations= 18 copies per append=1.31With a fixed step the copies per append grow with n: 62 at a thousand items, 6,249 at a hundred thousand. With a proportional rule they stay flat. Here is why. Say each growth multiplies the capacity by r, a number above 1. The copy sizes are at most n, then n/r, then n/r², and so on. That geometric series sums to at most n times r/(r-1), so the copies per append never pass r/(r-1), whatever n is. For r = 2 the bound is 2, and for r = 1.25 it is 5, which fits the 4.82 above.
With a fixed step the copy sizes are 8, 16, 24 and so on. They add up to about n²/16, which is O(n²) for n appends.
There is a price. After a growth the capacity can be r times the length, so up to (r-1) times the length is unused room. A big r wastes memory and a small r copies more often. CPython uses a small ratio, well under 2, and the exact rule is an implementation detail that has changed between versions.
A real allocator can sometimes extend a block in place without copying, so our counts are the worst case. The next cell checks the real list for the same behavior without printing any size.
import sys
def allocation_changes(n):
"""How many times does the size of a list's block change over n appends?"""
items, last, changes = [], sys.getsizeof([]), 0
for i in range(n):
items.append(i)
size = sys.getsizeof(items)
if size != last:
changes += 1
last = size
return changes
few, many = allocation_changes(1_000), allocation_changes(100_000)
print("changes happen on only a few appends:", many < 100_000 // 100)
print("100 times more appends, fewer than 3 times more changes:", many < 3 * few)
# Output:
# changes happen on only a few appends: True
# 100 times more appends, fewer than 3 times more changes: TrueThe number of changes grows with the logarithm of n, which is the signature of a proportional rule. A list also does not give room back the moment you remove an item. It shrinks only when it is far below capacity, so a loop that appends and pops near a boundary does not reallocate each time.
append is O(1) amortized, because of the geometric series above, and one single append can cost O(n). a[i] is O(1) by the address arithmetic. insert(i, x) and pop(i) are O(n - i), because every pointer after position i must shift by one. x in a is O(n), because it compares each pointer's object in turn. The space is O(n) pointers plus the spare room.#tuple: The Same Row, With No Spare Room
A tuple is also a row of pointers, but its length is fixed when it is made. It has no spare room and no append, so it needs no capacity field and no reallocation. That makes it smaller than a list of the same items and cheap to build.
"Immutable" has a precise meaning here: the tuple's slots can never point somewhere else. It does not mean the objects behind the pointers are frozen.
t = (1, [2, 3])
t[1].append(4) # allowed: the slot still points at the same list
print(t)
try:
hash(t)
except TypeError as error:
print("TypeError:", error)
print(hash((1, 2)) == hash((1, 2)))
# Output:
# (1, [2, 3, 4])
# TypeError: unhashable type: 'list'
# True(1, 2) is hashable and (1, [2, 3]) is not, because the list inside has no hash. A tuple works as a dict key only when everything inside it is hashable. Hashing costs O(length). Comparison goes item by item and stops at the first difference, which matters in the heap section.#dict: Order, Equality and the Hashing Contract
d = {}
d["a"], d["b"], d["c"] = 1, 2, 3
d["a"] = 10 # update: the key keeps its place
print(list(d))
del d["a"]
d["a"] = 11 # delete, then insert again: the key goes to the end
print(list(d))
print({1: "x", 2: "y"} == {2: "y", 1: "x"})
try:
for key in d:
d[key + "!"] = 0
except RuntimeError as error:
print("RuntimeError:", error)
# Output:
# ['a', 'b', 'c']
# ['b', 'c', 'a']
# True
# RuntimeError: dictionary changed size during iteration== is never called. If not, does the stored hash match the key's hash? If not, skip it with no == call. If it matches, call ==, and a true answer is a hit. Otherwise it probes on.a == b then hash(a) must equal hash(b), or equal keys land in different places and the dict stores both. And a key's hash must not change while it is in the dict. This is why list, dict and set are unhashable: they compare by contents and can change, so no stable hash could obey the first rule.== calls to show the recipe. It also shows a famous exception, a key that is not equal to itself.class Key:
eq_calls = 0
def __init__(self, v):
self.v = v
def __hash__(self):
return hash(self.v)
def __eq__(self, other):
Key.eq_calls += 1
return self.v == other.v
first = Key(5)
table = {first: "five"}
def eq_calls_for(probe):
Key.eq_calls = 0
table.get(probe)
return Key.eq_calls
print("the same object:", eq_calls_for(first))
print("an equal object:", eq_calls_for(Key(5)))
print("a different hash:", eq_calls_for(Key(6)))
nan = float("nan")
print({nan: 1}[nan], float("nan") in {nan: 1})
# Output:
# the same object: 0
# an equal object: 1
# a different hash: 0
# 1 False{nan: 1}[nan] works, because the identity check comes before ==. A different nan object fails. The same shortcut explains why interning strings helps, which we return to below.Now predict what happens when a key changes after it is stored.
A class hashes and compares by its field x. class Point: def __init__(self, x): self.x = x def __hash__(self): return hash(self.x) def __eq__(self, other): return self.x == other.x p = Point(1) seen = {p: "here"} p.x = 2 print(p in seen)
str and bytes hashes for each process, so an attacker cannot easily pick colliding keys, and you must never print or save hash("text"). Space is O(n).Deleting never shrinks a dict by itself. It leaves a hole in the entries array. The next insert that finds the array full triggers a rebuild sized for the keys that are left. You will model this in the last problem of the Try It section.
#set: A Dict Without Values
add, remove and in, and O(n) in the worst case.a & b looks each item of the smaller set up in the larger one, so it costs O(min(len a, len b)) on average. a | b costs O(len a + len b). a - b costs O(len a).A set is not ordered, and what it prints is an accident of slot positions.
print({3, 1, 2})
print({8, 1})
print(frozenset({1, 2}) == frozenset({2, 1}), {frozenset({1, 2}): "ok"}[frozenset({2, 1})])
# Output:
# {1, 2, 3}
# {8, 1}
# True ok3, 1, 2 sit in slots 3, 1 and 2 and print in slot order, which looks sorted. The 8 goes to slot 0 of an 8-slot table, so it prints first. Never rely on this order. A frozenset cannot change, so it can be hashed and used as a key.#deque: A Chain of Blocks
collections.deque is a doubly linked chain of fixed-size blocks. Each block holds many pointers, and the chain keeps a pointer to the first and last block. Adding or removing at either end touches one block, so it is O(1). The block size is an implementation detail.list.pop(0) shifts every remaining pointer one slot left. Taking all n items off the front this way costs (n-1) + (n-2) + ... + 0 = n(n-1)/2 moves, which is O(n²). For n = 4 that is 3 + 2 + 1 + 0 = 6 moves. A deque does none of them.i it walks the chain from the nearer end, so d[i] is O(n) in the middle, and there are no slices.from collections import deque
window = deque(range(5), maxlen=3) # a full deque drops from the other end
print(window)
d = deque([1, 2, 3])
d.rotate(1)
d.appendleft(0)
print(d, d[0], d[-1])
try:
d[1:3]
except TypeError as error:
print("TypeError:", error)
# Output:
# deque([2, 3, 4], maxlen=3)
# deque([0, 3, 1, 2]) 0 2
# TypeError: sequence index must be integer, not 'slice'maxlen a deque becomes a sliding window: appending to a full one drops the item at the opposite end, in O(1). That is a good fit for "the last 100 loss values" in a training loop. Space is O(n) pointers plus a little per block.#heapq: A Heap Is Just a List
heapq module has no heap class. A heap is an ordinary list that obeys one rule, the heap invariant: for every index k, heap[k] <= heap[2k + 1] and heap[k] <= heap[2k + 2]. Read the list as a tree where the children of index k sit at 2k + 1 and 2k + 2.heap[0] is the smallest item. The list is not sorted.heap[0], move the last item to the root, and fix the order downward. CPython does this in a clever way. The moved item is usually large, so CPython walks it all the way down to a leaf, always choosing the smaller child without comparing against the moving item, and then sifts it back up. That saves comparisons on average. A pop is O(log n).[7, 3, 9, 1]. The last parent is index 1, the item 3, and its child holds 1. That is smaller, so swap: [7, 1, 9, 3].[1, 7, 9, 3]. The 7 now sits at index 1 with a smaller child 3, so swap again: [1, 3, 9, 7]. Done, and the list is not sorted.Why is this O(n) and not O(n log n)? Half the nodes are leaves and move zero levels. A quarter move at most one level, an eighth at most two. The total moves are n times (1/4 + 2/8 + 3/16 + ...), a series that adds up to n, so the work is O(n), at most about 2n comparisons. Now try one yourself.
What does this print? import heapq h = [5, 3, 8, 1, 9, 2] heapq.heapify(h) print(h)
The next cell counts comparisons for each operation on 10,000 items. Only facts that do not depend on the version are printed.
import heapq
import math
import random
class Counted:
"""A number that counts every < it takes part in."""
compares = 0
def __init__(self, value):
self.value = value
def __lt__(self, other):
Counted.compares += 1
return self.value < other.value
def count(action):
Counted.compares = 0
action()
return Counted.compares
n = 10_000
random.seed(5)
values = random.sample(range(10 * n), n)
objects = [Counted(v) for v in values]
print("heapify uses fewer than 2n comparisons:", count(lambda: heapq.heapify(objects)) < 2 * n)
heap = []
print("n ascending pushes:", count(lambda: [heapq.heappush(heap, Counted(v)) for v in range(n)]))
heap = []
print("n descending pushes, more than 5n:", count(lambda: [heapq.heappush(heap, Counted(v)) for v in range(n, 0, -1)]) > 5 * n)
heap = []
print("n random pushes, fewer than 3n:", count(lambda: [heapq.heappush(heap, Counted(v)) for v in values]) < 3 * n)
print("n pops, fewer than n log2 n:", count(lambda: [heapq.heappop(heap) for _ in range(n)]) < n * math.log2(n))
# Output:
# heapify uses fewer than 2n comparisons: True
# n ascending pushes: 9999
# n descending pushes, more than 5n: True
# n random pushes, fewer than 3n: True
# n pops, fewer than n log2 n: TrueAscending pushes cost one comparison each, because every new item is larger than its parent. Descending pushes are the worst case, since each new item climbs to the root. A random push costs a small constant on average, because most items stay near the bottom. That average is O(1), while the worst case is O(log n).
heapq offers only a min-heap, so for a max-heap push negated numbers. heapq.nsmallest(k, items) and nlargest keep a heap of size k, so they cost O(n log k) time and O(k) space. If k is close to n, sorting is simpler.x in heap is O(n), because the invariant says nothing about where x is. If you need the items in order, pop them or sort a copy.#sorted and list.sort: Timsort in Detail
The earlier lesson told the story in one paragraph. Here are the parts, with their reasons.
A list has 10,000 items in rising order, followed by 5 extra items that belong somewhere inside that run. About how many comparisons does sorted need?
import math
import random
class Counted:
"""A number that counts every < it takes part in."""
compares = 0
def __init__(self, value):
self.value = value
def __lt__(self, other):
Counted.compares += 1
return self.value < other.value
def sort_cost(values):
Counted.compares = 0
sorted(Counted(v) for v in values)
return Counted.compares
n = 10_000
ordered = list(range(n))
random.seed(1)
shuffled = ordered[:]
random.shuffle(shuffled)
print("one rising run:", sort_cost(ordered), "| n - 1 =", n - 1)
print("one falling run:", sort_cost(ordered[::-1]))
print("shuffled, below n log2 n:", sort_cost(shuffled) < n * math.log2(n))
print("shuffled, above 5n:", sort_cost(shuffled) > 5 * n)
blocks = [list(range(i * 1_000, (i + 1) * 1_000)) for i in range(10)]
random.shuffle(blocks)
flat = [x for block in blocks for x in block]
print("10 sorted blocks in random order, below 1.1 n:", sort_cost(flat) < 1.1 * n)
stragglers = list(range(0, 20_000, 2)) + [5, 7, 9, 10_001, 15_001]
extra = sort_cost(stragglers) - (len(stragglers) - 1)
print("a long run plus 5 stragglers, extra comparisons under 200:", 0 < extra < 200)
# Output:
# one rising run: 9999 | n - 1 = 9999
# one falling run: 9999
# shuffled, below n log2 n: True
# shuffled, above 5n: True
# 10 sorted blocks in random order, below 1.1 n: True
# a long run plus 5 stragglers, extra comparisons under 200: TrueTen sorted blocks in random order cost barely more than one pass, because galloping finds that each block fits whole before or after its neighbor. The exact counts for a shuffled list change between Python versions, so the cell tests only bounds.
reverse=True has a subtle rule. CPython does not sort and then flip the list, which would reverse ties. It reverses the list, sorts, and reverses back, so ties keep their input order. One more documented detail: while a sort runs, the list appears empty to any code that looks at it.rows = [("b", 1), ("a", 1), ("c", 0), ("d", 1)]
by_score = lambda row: row[1]
print(sorted(rows, key=by_score))
print(sorted(rows, key=by_score, reverse=True))
print(sorted(rows, key=by_score)[::-1])
items = [3, 1, 2]
seen = []
items.sort(key=lambda x: seen.append(len(items)) or x)
print(seen, items)
# Output:
# [('c', 0), ('b', 1), ('a', 1), ('d', 1)]
# [('b', 1), ('a', 1), ('d', 1), ('c', 0)]
# [('d', 1), ('a', 1), ('b', 1), ('c', 0)]
# [0, 0, 0] [1, 2, 3]b, a, d in input order. The third line, which flips the sorted list, reverses the ties and breaks stability. Sorting needs only <, so define __lt__ on your class and nothing else. The key function runs once per item. Total cost: O(n log n) time worst case, O(n) when the data is one run, and O(n) extra space.#Interning and Small Integers: What You May Rely On
==, and use is only for None and for sentinels you made yourself. There is one documented tool. sys.intern(s) returns the one shared copy of a string, and that part is a promise.import sys
built = "".join(["he", "llo"])
literal = "hello"
print(built == literal)
print(sys.intern(built) is sys.intern(literal))
# Output:
# True
# TrueWhy would you intern on purpose? Look back at the lookup recipe. When the lookup key and the stored key are the same object, the dict stops at the identity check and never compares the characters. A program that builds millions of dict lookups from a few repeated long strings, such as column names read from a file, can intern them once. For ordinary code, do not bother.
#bisect: Binary Search on a List
bisect module searches a sorted list in O(log n) comparisons. bisect_left(a, x) returns the first position where x could go while keeping order, so everything before it is smaller than x. bisect_right returns the position after any items equal to x. The module does not check that the list is sorted, and on unsorted data it returns nonsense without any error.key= argument (Python 3.10 and later) to wrap each probed item in a counting class.import bisect
class Counted:
compares = 0
def __init__(self, value):
self.value = value
def __lt__(self, other):
Counted.compares += 1
return self.value < other.value
for n in (1_000, 1_000_000):
data = list(range(n))
Counted.compares = 0
spot = bisect.bisect_left(data, Counted(777), key=Counted)
print(n, spot, Counted.compares)
a = [1, 2, 2, 2, 3]
print(bisect.bisect_left(a, 2), bisect.bisect_right(a, 2))
bisect.insort(a, 2) # insort means insort_right: the new 2 goes after the old ones
print(a)
# Output:
# 1000 777 10
# 1000000 777 20
# 1 4
# [1, 2, 2, 2, 2, 3]x in a sorted list is bisect_right(a, x) - bisect_left(a, x), in O(log n).insort is where people get surprised. Finding the place costs O(log n) comparisons, but inserting into a list shifts every later pointer, so insort costs O(n) in all. Keeping a list sorted with insort is fine for thousands of items and slow for millions. If you only ever need the smallest item, a heap does the job in O(log n). If you need order plus fast inserts at large scale, you need a balanced tree, which the lesson on balanced trees builds.#Choosing: Costs with Reasons
Costs are for CPython, "avg" means average case, and "amort." means amortized.
| You need | Use | Cost | Why |
|---|---|---|---|
| Add or remove at the end | list | O(1) amort. | spare room, and no pointer after it moves |
| Add or remove at the front | deque | O(1) | a chain of blocks, no shifting |
| Is this item present? | set or dict | O(1) avg | hash to a slot, then the lookup recipe |
| Items in the order they arrived, with lookup | dict | O(1) avg | the entries array keeps the order |
| The smallest item again and again | heapq | O(log n) per pop | heap invariant in a list |
| The k best of a stream | heapq.nlargest | O(n log k) | a heap of size k |
| Sort once | sorted | O(n log n), O(n) if mostly ordered | Timsort finds runs |
| A sorted list with a few inserts | bisect.insort | O(n) per insert | search is log n, but the shift is n |
| How many items lie in a range of a sorted list | two bisect calls | O(log n) | two binary searches |
list as a queue for large n, since pop(0) is O(n). Do not use a deque when you index into the middle. Do not use a heap to test membership.insort to build a million-item sorted list one item at a time: append everything and sort once, which is O(n log n) rather than O(n²).#Where These Show Up
heapq heap, so the next timer is always at the front.deque(maxlen=k) is a natural window of recent losses, heapq.nlargest picks the top-k predictions without sorting the whole output, and bisect maps a score to a bucket in a sorted list of thresholds. These are examples, not a full list.#Common Mistakes
#Try It
Work the ladder in order. Rung 1 simulates list growth, rung 2 builds a heap by hand with a comparison counter, rung 3 keeps the k best items of a stream, rung 4 merges with galloping, and rung 5 builds a small insertion-ordered dict. Every rung has a counter, so you can see the cost, and the last few include inputs that try to break your code.
Tests · Rung 1: (11, 1023) (125, 62000) for 1,000 appends; n = 0 gives (0, 0); a rule that does not grow raises ValueError. Rung 2: every result is a valid heap; the empty and one-item lists cost 0; all-equal and descending lists must not break it; 5,000 random items use fewer than 10,000 comparisons. Rung 3: ties keep the earlier pair, k = 0 gives an empty list, k above the stream length returns every pair, a rising stream replaces 1,000 times and a falling one only 10. Rung 4: with 5 stragglers inside a 10,000-item run the plain merge counts 7506 and the galloping merge 68; interleaved input costs 19999 either way; empty inputs cost 0; equal values come out left list first. Rung 5: insertion order survives update, delete and re-insert, a DELETED marker keeps a collided key findable, 200 insert and delete rounds leave under 20 entries, and 1,000 keys give a 2,048-slot table.
del leaves holes until a rebuild.#Recap
==, so equal keys need equal hashes and a stored key must never change its hash. Dict order is a promise. Set order is an accident.bisect.insort finds in O(log n) but shifts in O(n).A model list grows its capacity by multiplying by 1.5 each time it is full. About how many items are copied per append, at most, over a long run of appends?
Key Takeaways
- Every container holds pointers, so copies are shallow and each cost is a cost of moving or comparing pointers
- A list grows by a ratio above 1, which bounds copies per append by r/(r-1); a fixed step costs O(n) per append and O(n²) overall
- A dict lookup checks identity, then the stored hash, then ==, so equal keys need equal hashes and a stored key must not change its hash
- Dict order is a language promise (update keeps the place, delete then insert goes to the end); set order is an accident of slots
- A deque is a chain of blocks, a heapq heap is a list with the parent-at-most-children rule, and heapify is O(n) because most nodes sit near the bottom
- Timsort finds runs, binary-inserts short ones, merges in balanced order and gallops over streaks: O(n log n) worst case, O(n) on one run, and stable
- Compare values with ==, never rely on interning, and remember that bisect.insort finds in O(log n) but shifts in O(n)