Arrays and Linked Lists
Two basic ways to keep things in order. An array puts items side by side in one block, so any position is one calculation away, but inserting near the front shifts everything after it; a linked list chains separate nodes, so inserting is cheap anywhere but finding the hundredth item means walking past ninety-nine. Interfaces versus implementations, stacks, queues, and deques; Python's list as an array with spare room, watched with sys.getsizeof; O(1) and O(n); a timing where doubling the items quadruples the cost of inserting at the front; and a linked list built from scratch.
- 11 min
- 10 steps
- 3 questions
- Lesson 75 of 80
In this lesson
- Why organize data
- Interfaces and implementations
- Arrays
- Python’s list
- Naming the costs
- Measuring
- Linked lists
- Which one?
- Your turn
- So
Picking up where you left off.
Why organize data
Suppose a program keeps a million items and looks each one up once. If every lookup checks every item, that’s a million times a million, 10^12 checks; at a billion simple operations a second, about 16 minutes and 40 seconds 1. Organized well, the same lookups can take a fraction of a second. That’s what data structures are for: arranging data in memory so the operations a program does most stay fast as the data grows 1.
This module builds the main ones in Python and measures them. Next module, Algorithms, does the same for the procedures that use them.
Interfaces and implementations
Two questions get kept apart 1:
- the interface: what a structure does, the operations it offers and what they mean;
- the implementation: how it does them, with what layout in memory.
One interface can have many implementations. Three classic interfaces 1:
- A stack (last in, first out): add and remove at the same end, like a pile of plates. Your browser’s Back button, an editor’s undo history, and the stack in a process’s address space (last module) all work this way.
- A queue (first in, first out): add at the back, remove from the front, like a checkout line. The scheduler’s FIFO policy was a queue.
- A deque (“deck”, double-ended queue): add and remove at both ends.
Underneath, nearly all of them are built from one of two layouts: an array or a linked list.
Arrays
An array stores its items side by side in one block of memory. If each slot is 8 bytes, item i is at the block’s start address plus 8 * i, so reaching any position takes one calculation, the same time whatever the index 1. Arrays are also friendly to the memory hierarchy: walking through one in order is the fast, cache-friendly pattern from module 3.
Their weakness is change in the middle 1:
- Inserting or removing near the front means shifting every item after it one slot over.
- Growing past the end of the block means allocating a bigger block and copying everything across. Allocating twice the size each time keeps this rare enough that, averaged over many additions, it costs only a constant amount per addition 1.
Quick check
items[500000] take no longer than items[0] in a Python list?Start address plus index times slot size: one multiplication and one addition, whatever the index.
Python’s list
Python’s list is an array: a contiguous array of references to the actual objects, with its length stored alongside 2. Each reference is a 64-bit address, 8 bytes. When the array has to grow, Python allocates some extra room so the next few appends don’t need to copy anything 2. You can watch it happen with sys.getsizeof, which reports the size of the list object itself, not the items it refers to 3. Save this as growth.py:
# growth.py: watch a Python list's array grow as items are appended.
import sys
items = []
last = sys.getsizeof(items)
print(f"empty list: {last} bytes")
for i in range(40):
items.append(i)
size = sys.getsizeof(items)
if size != last: # the array was just reallocated
print(f"after {len(items):>2} items: {size} bytes, room for {(size - 56) // 8}")
last = size
me@linuxbox:~$ python3 growth.py
empty list: 56 bytes
after 1 items: 88 bytes, room for 4
after 5 items: 120 bytes, room for 8
after 9 items: 184 bytes, room for 16
after 17 items: 248 bytes, room for 24
after 25 items: 312 bytes, room for 32
after 33 items: 376 bytes, room for 40
An empty list is 56 bytes of bookkeeping; each slot adds 8. The first append reserved room for 4, and in 40 appends the array was replaced only 6 times. The other 34 appends just filled a spare slot. (These sizes are from 64-bit CPython 3.12; other versions may differ slightly.)
Naming the costs
Programmers describe how an operation’s time grows with the number of items, n, using big-O notation, which next module explains properly. For now, two cases 4:
- O(1), constant time: the same however big the list. Python’s
items[k],items[k] = x,append, andlen(items). - O(n), linear time: proportional to the number of items.
x in itemschecks item after item;items.insert(0, x)anditems.pop(0)shift the whole list.
Python’s documentation lists the cost of every operation on its built-in types; insert(k, x) is O(n − k), because only the items after position k move 4.
Measuring
This program adds n items three ways: at the end of a list, at the front of a list, and at the front of a collections.deque. Save it as ends.py:
# ends.py: add n items at the end of a list, at its front, and at a deque's front.
import time
from collections import deque
def at_end(n):
a = []
for i in range(n):
a.append(i)
def at_front(n):
a = []
for i in range(n):
a.insert(0, i) # every item already there shifts one place
def deque_front(n):
d = deque()
for i in range(n):
d.appendleft(i)
for n in (100_000, 200_000):
for name, fn in [("list.append", at_end), ("list.insert(0, x)", at_front), ("deque.appendleft", deque_front)]:
start = time.perf_counter()
fn(n)
print(f"n={n:<8,} {name:<18} {time.perf_counter() - start:7.3f} s")
On a desktop PC:
me@linuxbox:~$ python3 ends.py
n=100,000 list.append 0.003 s
n=100,000 list.insert(0, x) 0.998 s
n=100,000 deque.appendleft 0.003 s
n=200,000 list.append 0.006 s
n=200,000 list.insert(0, x) 3.928 s
n=200,000 deque.appendleft 0.005 s
Doubling n doubled the time for append: twice as many operations, each O(1). But it quadrupled the time for inserting at the front: twice as many inserts, and each one shifts, on average, twice as many items. A deque is built for exactly this: appends and pops at either end in about constant time, where a list pays O(n) for insert(0, v) and pop(0) 5. Use a deque whenever you need a queue.
Quick check
list.insert(0, x). Why?n inserts costing up to n each: the total grows like n times n.
Linked lists
A linked list gives up the single block. Each item lives in its own node, which holds the value and a reference to the next node; the list itself only remembers the first node, the head 1. Save this as linked.py:
# linked.py: a singly linked list, built from nodes that point to the next one.
class Node:
def __init__(self, value, next=None):
self.value = value
self.next = next # the next node, or None at the end
class LinkedList:
def __init__(self):
self.head = None # the first node
def push_front(self, value): # constant time: no shifting
self.head = Node(value, self.head)
def insert_after(self, node, value): # constant time, once you have the node
node.next = Node(value, node.next)
def find(self, value): # walks from the head: time grows with length
node, steps = self.head, 1
while node is not None and node.value != value:
node, steps = node.next, steps + 1
return node, steps
def __str__(self):
parts, node = [], self.head
while node is not None:
parts.append(str(node.value))
node = node.next
return " -> ".join(parts) + " -> None"
todo = LinkedList()
for task in ["sand", "glue", "cut"]:
todo.push_front(task)
print(todo)
node, steps = todo.find("glue")
todo.insert_after(node, "clamp")
print(todo)
print("found 'sand' after", todo.find("sand")[1], "steps")
me@linuxbox:~$ python3 linked.py
cut -> glue -> sand -> None
cut -> glue -> clamp -> sand -> None
found 'sand' after 4 steps
push_front added each task in front of the last, so they come out in reverse order. Inserting clamp after glue changed two references and moved nothing, but finding sand meant visiting every node before it. That’s the trade 1:
- Fast: adding or removing at the head, or next to any node you already have, in constant time.
- Slow: reaching position
i, which means walkinginodes from the head.
A doubly linked list gives each node a reference to the previous node as well, so both ends, and removal of any known node, are constant time 1. The nodes can be anywhere in memory, though, so walking a linked list loses the spatial locality that makes arrays fast.
You’ve used a linked structure already: each git commit stores a reference to its parent, so git log walks backward from HEAD one commit at a time, just like find here.
Quick check
Two references change and nothing moves. Finding the node in the first place is the slow part.
Which one?
In Python: use a list for almost everything, a deque for queues and anything that adds or removes at the front, and a hand-built linked list almost never. The ideas matter anyway: every structure in this module is built from these two layouts, references between nodes and blocks of slots.
Your turn
Exercises
- Run
growth.pywithrange(200). Do the jumps in “room for” stay the same size? - In
ends.py, how long wouldlist.insert(0, x)take for 400,000 items? Predict, then run just that case. - A list makes a fine stack:
appendto push,pop()to pop. Why is the end of the list the right end to use? - Add a
pop_frontmethod toLinkedListthat removes the first node and returns its value. What does it cost? - Add a
__len__method that counts the nodes. How does its cost compare withlen()on a Python list?
Answers
- No: the jumps grow as the list grows, so the spare room stays roughly in proportion to the list’s size, and reallocations get rarer.
- About four times 3.9 seconds, roughly 16 seconds, since doubling
nagain quadruples the time again. On the same PC it took 15.93 seconds. - Adding and removing at the end are O(1); at the front they’d be O(n), since every item would shift 4.
- Constant time:
value = self.head.value; self.head = self.head.next; return value(check for an empty list first). - Counting walks every node, O(n). A Python list stores its length, so
len()is O(1) 4.
So
Data structures arrange data so common operations stay fast as it grows. An interface (stack, queue, deque) says what a structure does; an implementation says how. Arrays keep items side by side, so any index is O(1), but inserting near the front is O(n), and growing means copying, kept rare by reserving spare room; Python’s list is such an array. Linked lists chain separate nodes: inserting next to a known node is O(1), but reaching position i means walking. Doubling the data doubles O(n) work per operation, and repeating an O(n) operation n times quadruples it.
Lesson complete
Nice work.
Sources for this lesson
- 1Pat Morin. Open Data Structures (in pseudocode), Edition 0.1G beta. opendatastructures.org (paperback by AU Press). verifiedFree textbook under a Creative Commons Attribution license. Used: ch. 1, the need for efficiency (a million searches of a million items is 10^12 inspections, over 16 minutes at a billion operations a second), interfaces versus implementations, the Queue (FIFO), Stack (LIFO), and Deque; ch. 2, array-based lists (constant-time get and set, add and remove at i costing O(n - i) for the shifting, growing by allocating an array of twice the size and copying, amortized O(1) growth); ch. 3, singly and doubly linked lists (nodes with references; get(i) walks from the head; insert or delete next to a known node in constant time); ch. 5, hash tables with chaining (an array of lists, hash(x) choosing the list, n kept at most the table length so lists average one item, doubling and reinserting when full) and hash codes (equal objects must have equal hash codes, unequal ones should rarely collide); ch. 6, binary trees (root, parent, child, leaf, depth, height) and the binary search tree property, O(n) worst case when unbalanced; ch. 7, random binary search trees, with search paths of length at most about 2 ln n; ch. 12, graphs (vertices, directed edges, paths, cycles; adjacency matrix with O(n^2) space versus adjacency lists with O(n + m); breadth-first search with a queue and a seen array, O(n + m), visiting vertices in order of distance and giving shortest paths; depth-first search, breadth-first search with a stack instead of a queue, used to detect cycles); ch. 14, B-trees, whose nodes have between B and 2B children and fit in one external-memory block, the primary data structure in file systems including ext4, NTFS, and HFS+, every major database, and cloud key-value stores.
- 2Design and History FAQ (Python documentation). Python Software Foundation. verifiedCPython's lists are variable-length arrays, not linked lists: a contiguous array of references, so indexing costs the same whatever the size or index; when the array must grow, extra space is allocated so the next few appends don't need a resize. Dictionaries are resizable hash tables: a key's hash code, from hash(), varies widely and depends on a per-process seed, and picks a location in an internal array, so lookups take constant time on average. Dictionary keys must be immutable, because a key whose value changed would have a different hash and could no longer be found; use a tuple instead of a list.
- 3sys: System-specific parameters and functions (Python documentation). Python Software Foundation. verifiedsys.getsizeof(object) returns an object's size in bytes, counting only the memory directly attributed to the object, not the objects it refers to.
- 4Time complexity of operations on built-in types (Python documentation). Python Software Foundation. verifiedCosts of operations on list, tuple, dict, set, str, and range in CPython, in big-O notation. list: append O(1) amortized, get and set item O(1), insert(k, x) and pop(k) O(n - k), so index 0 moves the whole list, x in l O(n); for adding and removing at both ends, use collections.deque. dict and set: key in d, get, set, and delete O(1) on average, assuming few collisions; O(n) in the worst case when every key hashes alike.
- 5collections: Container datatypes (Python documentation). Python Software Foundation. verifieddeque, a double-ended queue, generalizes stacks and queues, with appends and pops from either side in approximately O(1) time; lists are optimized for fixed-length operations and incur O(n) memory movement for pop(0) and insert(0, v).