Collections | Python - Wyatt's Notes
Python lists are ordered, mutable sequences of arbitrary objects. They are the most frequently Used built-in container and serve as the default sequence type for most tasks.
Internal Representation: Dynamic Arrays
Section titled “Internal Representation: Dynamic Arrays”CPython implements list as a contiguous array of pointers (specifically, a C array of PyObject*). This is a critical design decision with direct consequences for performance Characteristics.
graph LR
subgraph "list object on heap"
ob_refcnt["ob_refcnt"]
ob_type["ob_type"]
ob_size["ob_size (length)"]
allocated["allocated (capacity)"]
items["items[0] | items[1] | items[2] | ... | items[allocated-1]"]
end
subgraph "heap objects"
obj0["PyObject<br/>"int: 42'"]
obj1["PyObject<br/>'str: hello'"]
obj2["PyObject<br/>'list: [1,2]'"]
end
items --> obj0
items --> obj1
items --> obj2Each items[i] slot is a pointer to a heap-allocated PyObject. The list itself does not store the Objects inline — it stores references. This means:
- A list of three integers occupies three pointer slots (24 bytes on 64-bit) plus three separate heap allocations for the integer objects.
- Appending to a list never copies the contained objects. Only pointers are moved.
- A single object can appear in multiple lists simultaneously without duplication.
Growth Strategy and Amortized O(1) Append
Section titled “Growth Strategy and Amortized O(1) Append”When list.append() runs and the internal array is full, CPython must allocate a new, larger array And copy all existing pointers into it. The growth strategy determines how much larger the new array Is.
graph TD
A["append(x) called"] --> B{"ob_size == allocated?"}
B -- No --> C["items[ob_size] = x<br/>ob_size += 1"]
C --> D["return"]
B -- Yes --> E["new_allocated = grow(allocated)"]
E --> F["malloc new array<br/>of size new_allocated"]
F --> G["memcpy old pointers<br/>to new array"]
G --> H["free old array"]
H --> CThe growth formula (from CPython source, Objects/listobject.c) is:
new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6)Roughly, this means the new capacity is approximately newsize + newsize/8 + 6. This is a geometric growth factor of about 1.125x (9/8), which is deliberately smaller than the 2x factor Used by many other languages. The Python developers chose this because:
- Lists are frequently used for temporary accumulations where the final size is not much larger than the initial size. A 2x factor wastes more memory in these cases.
- The smaller factor still guarantees amortized O(1) append.
Amortized analysis. Consider n appends to an initially empty list. A resize occurs when the list Hits sizes that trigger reallocation. The total number of pointer copies across all resizes is Bounded by a geometric series that converges to O(n). Therefore, the average cost per append is O(1), even though any single append may cost O(n).
import sys
lst = []print(sys.getsizeof(lst)) # 56 bytes (empty list)
for i in range(10): lst.append(i) print(f"len={len(lst)}, sizeof={sys.getsizeof(lst)}")
## Output pattern on 64-bit CPython:## len=0, sizeof=56 (empty, 0 slots)# len=1, sizeof=88 (4 slots allocated)# len=2, sizeof=88 (4 slots)# len=3, sizeof=88 (4 slots)# len=4, sizeof=88 (4 slots, now full)# len=5, sizeof=120 (8 slots allocated -- grew)# len=6, sizeof=120 (8 slots)# len=7, sizeof=120 (8 slots)# len=8, sizeof=120 (8 slots, now full)# len=9, sizeof=184 (16 slots allocated -- grew)# len=10, sizeof=184 (16 slots)Time Complexity of List Operations
Section titled “Time Complexity of List Operations”| Operation | Average Case | Worst Case | Notes |
|---|---|---|---|
append(x) | O(1) amort. | O(n) | Resize when capacity reached |
pop() (from end) | O(1) | O(1) | |
pop(i) (from middle) | O(n) | O(n) | Shifts all elements after index i |
insert(i, x) | O(n) | O(n) | Shifts all elements from index i onward |
del lst[i] | O(n) | O(n) | Same as pop from middle |
lst[i] | O(1) | O(1) | Direct pointer dereference |
x in lst | O(n) | O(n) | Linear scan |
lst1 + lst2 | O(n+m) | O(n+m) | Creates new list, copies all pointers |
lst * n | O(n*k) | O(n*k) | |
lst.sort() | O(n log n) | O(n log n) | Timsort |
len(lst) | O(1) | O(1) | Stored in ob_size |
lst.clear() | O(1) | O(1) | Drops references, does not shrink array |
List Methods
Section titled “List Methods”lst = [3, 1, 4, 1, 5, 9]
# Mutationlst.append(2) # [3, 1, 4, 1, 5, 9, 2]lst.extend([6, 5]) # [3, 1, 4, 1, 5, 9, 2, 6, 5]lst.insert(0, 99) # [99, 3, 1, 4, 1, 5, 9, 2, 6, 5]lst.pop() # 5, lst = [99, 3, 1, 4, 1, 5, 9, 2, 6]lst.pop(0) # 99, lst = [3, 1, 4, 1, 5, 9, 2, 6]lst.remove(1) # removes first 1, lst = [3, 4, 1, 5, 9, 2, 6]lst.reverse() # [6, 2, 9, 5, 1, 4, 3]lst.sort() # [1, 2, 3, 4, 5, 6, 9]lst.clear() # []
# Non-mutatinglst = [3, 1, 4, 1, 5]lst.index(4) # 2lst.count(1) # 2lst.copy() # shallow copy [3, 1, 4, 1, 5]Slicing
Section titled “Slicing”Slicing creates a new list containing copies of the pointer slots in the specified range. It Does not copy the referenced objects.
lst = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# lst[start:stop:step]lst[2:5] # [2, 3, 4]lst[:3] # [0, 1, 2]lst[7:] # [7, 8, 9]lst[::2] # [0, 2, 4, 6, 8]lst[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]lst[1::2] # [1, 3, 5, 7, 9]lst[-3:] # [7, 8, 9]lst[-5:-2] # [5, 6, 7]
# Slice assignment (modifies in place)lst[2:5] = [20, 30, 40] # [0, 1, 20, 30, 40, 5, 6, 7, 8, 9]lst[1:1] = [10, 11] # insert without replacing: [0, 10, 11, 1, 20, ...]del lst[2:4] # delete sliceSlicing has O(k) time complexity where k is the size of the slice. The step parameter is handled Internally by a loop that strides through the source array, so lst[::2] is O(n/2) = O(n).
List Comprehensions
Section titled “List Comprehensions”List comprehensions are both more concise and faster than equivalent for loops because they run at C speed inside the CPython interpreter loop.
# List comprehensionsquares = [x**2 for x in range(10)]
# Equivalent for loop (slower -- Python bytecode per iteration)squares = []for x in range(10): squares.append(x**2)
# With conditioneven_squares = [x**2 for x in range(10) if x % 2 == 0]
# Nested (flattening a matrix)matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]flat = [x for row in matrix for x in row] # [1, 2, 3, 4, 5, 6, 7, 8, 9]Intuition
Section titled “Intuition”Python’s built-in collections are the workhorses of data manipulation. Lists are dynamic arrays: fast to append, slow to insert in the middle. Tuples are immutable lists: once created, they cannot change, which makes them hashable and useful as dictionary keys. Sets are unordered collections of unique elements, perfect for membership testing and deduplication. The collections module adds specialised containers: defaultdict eliminates key-existence checks, Counter tallies occurrences, and deque provides O(1) operations at both ends.