← View series: problem solving with dsa
~/blog
Custom List Class
The Problem
You call lst.append(5) and it works. Magic. But what actually happens? The list has a fixed chunk of memory; if it runs out of room, it needs a bigger chunk — and that means copying everything over. If it shrinks, it should give memory back.
Python's list handles all of this internally. Building your own version from scratch makes every hidden allocation, resize, and shift visible. You will write a class that supports append, insert, pop, remove, __len__, __getitem__, and __contains__ — the core of what makes a list a list.
The Class Structure
A dynamic array needs three internal variables:
_capacity— how many elements fit without reallocating_size— how many elements are currently stored_data— the raw storage (a fixed-size Python list used as a C-style array)
class CustomList:
def __init__(self):
self._capacity = 1
self._size = 0
self._data = [None] * self._capacity
def __len__(self):
return self._size
def __getitem__(self, index):
if index < 0 or index >= self._size:
raise IndexError("Index out of bounds")
return self._data[index]
def __setitem__(self, index, value):
if index < 0 or index >= self._size:
raise IndexError("Index out of bounds")
self._data[index] = value
def __str__(self):
items = ", ".join(str(self._data[i]) for i in range(self._size))
return f"[{items}]"Line-by-line:
self._capacity = 1— start small. The list grows as needed.self._size = 0— no elements yet.self._data = [None] * self._capacity— allocate the raw array.[None] * 1gives[None].__len__returns_size, notlen(_data). The caller sees only the used slots.__getitem__and__setitem__validate the index against_size, not_capacity. An index past the last element is an error even if the underlying array has room.__str__joins only the_sizeelements, ignoring empty capacity slots.
Append
Appending adds an element at the next available position. If the array is full, it doubles the capacity first.
Example 1 — five appends, starting empty
Initial state: capacity = 1, size = 0, data = [None]
| Step | Value | Size before | Cap before | Full? | Action | Size after | Cap after | Data |
|---|---|---|---|---|---|---|---|---|
| 1 | 10 | 0 | 1 | No | data[0] = 10 | 1 | 1 | [10] |
| 2 | 20 | 1 | 1 | Yes | Resize to 2, data[1] = 20 | 2 | 2 | [10, 20] |
| 3 | 30 | 2 | 2 | Yes | Resize to 4, data[2] = 30 | 3 | 4 | [10, 20, 30, None] |
| 4 | 40 | 3 | 4 | No | data[3] = 40 | 4 | 4 | [10, 20, 30, 40] |
| 5 | 50 | 4 | 4 | Yes | Resize to 8, data[4] = 50 | 5 | 8 | [10, 20, 30, 40, 50, None, None, None] |
Resizes happen at steps 2, 3, and 5. Each resize allocates a new block and copies all existing elements. The capacity doubles each time: 1 → 2 → 4 → 8.
Example 2 — append after a fresh resize (showing the copy)
Before resize: capacity = 2, size = 2, data = [10, 20]
Resize to capacity 4:
Old data: [10, 20] capacity = 2
New data: [None, None, None, None] capacity = 4
Copy step 1: data[0] = old[0] → [10, None, None, None]
Copy step 2: data[1] = old[1] → [10, 20, None, None]
After resize: data = [10, 20, None, None], capacity = 4, size = 2.
Then data[2] = 30.
Example 3 — append without resize
Append 40 when capacity = 4, size = 3, data = [10, 20, 30, None]:
No resize needed. data[3] = 40. Size becomes 4.
Code
def append(self, value):
if self._size == self._capacity:
self._resize(self._capacity * 2)
self._data[self._size] = value
self._size += 1Line-by-line:
if self._size == self._capacity— the array is full. Nothing to shift into; we need more space.self._resize(self._capacity * 2)— double the capacity. Doubling makes the amortized cost O(1). A fixed increment (like +10) would make it O(n).self._data[self._size] = value— place the new value at the first unused slot.self._size += 1— increment the element count.
Code trace — appending 20 to data = [10], capacity = 1, size = 1
| Line | Before | Action | After |
|---|---|---|---|
if size == cap | size=1, cap=1 | 1==1 → True | — |
_resize(2) | cap=1, data=[10] | Doubles to 2 | cap=2, data=[10, None] |
data[size] = 20 | size=1, data=[10, None] | data[1] = 20 | data=[10, 20] |
size += 1 | size=1 | — | size=2 |
Resize
Resizing allocates a new, larger array, copies every element from the old array, and discards the old one.
Code
def _resize(self, new_capacity):
new_data = [None] * new_capacity
for i in range(self._size):
new_data[i] = self._data[i]
self._data = new_data
self._capacity = new_capacityLine-by-line:
new_data = [None] * new_capacity— allocate the fresh block.for i in range(self._size)— iterate over every used slot (not every capacity slot). Unused slots hold staleNoneand are not worth copying.new_data[i] = self._data[i]— copy each element.self._data = new_data— replace the old array reference. The old array becomes garbage.self._capacity = new_capacity— update the capacity.
Code trace — resizing from capacity 4 to 8
Before: capacity = 4, size = 4, data = [10, 20, 30, 40]
| i | data[i] | new_data[i] = data[i] | new_data state |
|---|---|---|---|
| 0 | 10 | new_data[0] = 10 | [10, None, None, None, None, None, None, None] |
| 1 | 20 | new_data[1] = 20 | [10, 20, None, None, None, None, None, None] |
| 2 | 30 | new_data[2] = 30 | [10, 20, 30, None, None, None, None, None] |
| 3 | 40 | new_data[3] = 40 | [10, 20, 30, 40, None, None, None, None] |
After: data = [10, 20, 30, 40, None, None, None, None], capacity = 8.
This is O(n) — every element is copied. The amortized argument works because resizes are rare: log₂ n resizes for n appends.
Insert
Inserting at a position shifts every element from that position onward one slot to the right, then places the new value.
Example 4 — insert at middle
Insert 99 at index 1 in data = [10, 20, 30, None] (capacity = 4, size = 3):
Before: [10, 20, 30, None]
↑ insert 99 here
Step 1: [10, 20, 30, 30] shift data[2] → data[3]
Step 2: [10, 20, 20, 30] shift data[1] → data[2]
Step 3: [10, 99, 20, 30] place 99 at index 1
Example 5 — insert at head
Insert 99 at index 0 in [10, 20, 30, None]:
Before: [10, 20, 30, None]
↑ insert 99 here
Step 1: [10, 20, 30, 30]
Step 2: [10, 20, 20, 30]
Step 3: [10, 10, 20, 30]
Step 4: [99, 10, 20, 30] place 99 at index 0
Every element shifts. Worst case.
Example 6 — insert at tail
Insert 99 at index 3 (the end, same as append) in [10, 20, 30, None]:
Before: [10, 20, 30, None]
↑ insert 99 here
Step 1: [10, 20, 30, 99] no shift needed, place directly
Zero shifts. Best case.
Example 7 — insert at middle with resize
Insert 99 at index 1 in [10, 20] (capacity = 2, size = 2):
Step 1: resize to 4 → [10, 20, None, None]
Step 2: shift → [10, 20, 20, None]
Step 3: shift → [10, 10, 20, None]
Step 4: place → [10, 99, 20, None]
The resize happens first because there is no room to shift.
Code
def insert(self, index, value):
if index < 0 or index > self._size:
raise IndexError("Index out of bounds")
if self._size == self._capacity:
self._resize(self._capacity * 2)
for i in range(self._size, index, -1):
self._data[i] = self._data[i - 1]
self._data[index] = value
self._size += 1Line-by-line:
if index < 0 or index > self._size— validate.index == self._sizemeans "insert at end" (append), which is valid.if self._size == self._capacity— only resize before shifting. If we shift first and then resize, the shifted data might get lost.for i in range(self._size, index, -1)— start from the last element and walk backward to the target position. The step-1is critical: if we iterated forward, we would overwrite the next element before reading it.self._data[i] = self._data[i - 1]— copy the element one slot to the right.self._data[index] = value— the old occupant atindexwas copied toindex + 1in the first loop iteration. The position is safe to overwrite.self._size += 1— one more element in the list.
Code trace — insert(1, 99) in data = [10, 20, 30, 40, None], size = 4, cap = 5
No resize needed (size 4 < cap 5).
| i | Before assignment | data[i] = data[i-1] | After |
|---|---|---|---|
| 4 | [10, 20, 30, 40, None] | data[4] = data[3] = 40 | [10, 20, 30, 40, 40] |
| 3 | [10, 20, 30, 40, 40] | data[3] = data[2] = 30 | [10, 20, 30, 30, 40] |
| 2 | [10, 20, 30, 30, 40] | data[2] = data[1] = 20 | [10, 20, 20, 30, 40] |
After loop: data[1] = 99. size = 5.
Final: [10, 99, 20, 30, 40]
Pop
Pop removes and returns the element at a given index (or the last element by default), then shifts everything after it left to fill the gap.
Example 8 — pop last
Pop from data = [10, 20, 30, None] at index 2 (last element):
Before: [10, 20, 30, None] size = 3
removed = 30
Step 1: [10, 20, None, None] data[2] = None, size = 2
No shift needed.
Example 9 — pop middle
Pop from data = [10, 20, 30, 40, None] at index 1:
Before: [10, 20, 30, 40, None] size = 4
↑ pop here
removed = 20
Step 1: [10, 30, 30, 40, None] shift data[2] → data[1]
Step 2: [10, 30, 40, 40, None] shift data[3] → data[2]
Step 3: [10, 30, 40, None, None] data[3] = None, size = 3
Example 10 — pop triggering shrink
Starting with capacity = 16, size = 4. Pop the last element:
After the pop: size = 3. capacity (16) is more than 4 × size (12). The shrink condition fires: capacity halves to 8.
The shrink prevents the array from holding on to 16 slots when only 3 are used.
Code
def pop(self, index=None):
if self._size == 0:
raise IndexError("Pop from empty list")
if index is None:
index = self._size - 1
if index < 0 or index >= self._size:
raise IndexError("Index out of bounds")
value = self._data[index]
for i in range(index, self._size - 1):
self._data[i] = self._data[i + 1]
self._data[self._size - 1] = None
self._size -= 1
if self._capacity > 4 and self._size <= self._capacity // 4:
self._resize(self._capacity // 2)
return valueLine-by-line:
if self._size == 0— nothing to remove.if index is None: index = self._size - 1— default to the last element.value = self._data[index]— save before overwriting.for i in range(index, self._size - 1)— iterate from the target position to the second-to-last element. Each iteration copies the element to the left.self._data[i] = self._data[i + 1]— shift left.self._data[self._size - 1] = None— clear the orphaned last slot.self._size -= 1— one less element.if self._capacity > 4 and self._size <= self._capacity // 4— if the array is less than 25% full, halve the capacity. The> 4guard prevents shrinking below a minimum size.
Code trace — pop(1) in data = [10, 20, 30, 40, None], size = 4, cap = 5
No shrink (cap 5 is not > 4).
| i | Before assignment | data[i] = data[i+1] | After |
|---|---|---|---|
| 1 | [10, 20, 30, 40, None] | data[1] = data[2] = 30 | [10, 30, 30, 40, None] |
| 2 | [10, 30, 30, 40, None] | data[2] = data[3] = 40 | [10, 30, 40, 40, None] |
| 3 | [10, 30, 40, 40, None] | data[3] = data[4] = None | [10, 30, 40, None, None] |
After loop: data[3] = None (already None). size = 3. Returns 20.
Final: [10, 30, 40, None, None] size = 3
Remove
Remove finds the first occurrence of a value and deletes it by shifting everything after it left.
Example 11 — remove value from middle
Remove 30 from [10, 20, 30, 40, None] (size = 4):
Step 1 — find: scan left to right
i=0: 10 ≠ 30
i=1: 20 ≠ 30
i=2: 30 == 30 → found at index 2
Step 2 — shift left:
Before: [10, 20, 30, 40, None]
Shift: [10, 20, 40, 40, None] data[2] = data[3]
Clear: [10, 20, 40, None, None] data[3] = None
Size: 3
Example 12 — remove value at head
Remove 10 from [10, 20, 30, None] (size = 3):
Step 1 — find: i=0, 10==10 → found at 0
Step 2 — shift left:
Before: [10, 20, 30, None]
Shift: [20, 20, 30, None] data[0] = data[1]
Shift: [20, 30, 30, None] data[1] = data[2]
Clear: [20, 30, None, None] data[2] = None
Size: 2
Two shifts. Worst case for remove.
Example 13 — remove value not present
Remove 99 from [10, 20, 30, None]:
Step 1 — find:
i=0: 10 ≠ 99
i=1: 20 ≠ 99
i=2: 30 ≠ 99
i=3: out of range → not found
Result: raise ValueError
Code trace — remove(10) in data = [10, 20, 30, 40, None], size = 4
Find phase — scan left to right:
| i | data[i] | data[i] == 10? |
|---|---|---|
| 0 | 10 | Yes → found at index 0 |
Shift phase — shift elements left from index 0 onward:
| j | Before assignment | data[j] = data[j+1] | After |
|---|---|---|---|
| 0 | [10, 20, 30, 40, None] | data[0] = data[1] = 20 | [20, 20, 30, 40, None] |
| 1 | [20, 20, 30, 40, None] | data[1] = data[2] = 30 | [20, 30, 30, 40, None] |
| 2 | [20, 30, 30, 40, None] | data[2] = data[3] = 40 | [20, 30, 40, 40, None] |
After shift: data[3] = None. size = 3.
Final: [20, 30, 40, None, None] size = 3
Code
def remove(self, value):
for i in range(self._size):
if self._data[i] == value:
for j in range(i, self._size - 1):
self._data[j] = self._data[j + 1]
self._data[self._size - 1] = None
self._size -= 1
return
raise ValueError("Value not found")Line-by-line:
for i in range(self._size)— scan every element.if self._data[i] == value— found the first match.for j in range(i, self._size - 1)— shift everything after the found position left by one.self._data[j] = self._data[j + 1]— copy the next element into the current slot.self._data[self._size - 1] = None— clear the orphaned last slot.self._size -= 1— one less element.raise ValueError("Value not found")— the scan completed without finding the value.
Clear
Code
def clear(self):
self._size = 0This does not free the underlying array. The capacity stays the same. If you clear and then re-fill, no reallocation is needed.
Contains
Code
def __contains__(self, value):
for i in range(self._size):
if self._data[i] == value:
return True
return FalseMakes value in custom_list work. Linear scan, O(n).
Putting It Together
cl = CustomList()
cl.append(10) # [10]
cl.append(20) # [10, 20]
cl.insert(1, 15) # [10, 15, 20]
cl.append(30) # [10, 15, 20, 30]
print(cl.pop()) # 30, list is [10, 15, 20]
print(cl.pop(0)) # 10, list is [15, 20]
cl.remove(15) # [20]
print(20 in cl) # True
print(99 in cl) # False
cl.clear() # []Closing
Building a list from scratch demystifies the operations you use every day. The append that looks magic is a size check, an occasional resize, and a single write. The insert that looks slow is a loop that shifts memory. Every operation has a concrete cost that is visible in the code. The next time you call append, you will know exactly what is happening behind the call.