Computing and the Command Line

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

  1. Why organize data
  2. Interfaces and implementations
  3. Arrays
  4. Python’s list
  5. Naming the costs
  6. Measuring
  7. Linked lists
  8. Which one?
  9. Your turn
  10. So

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.

Top, an array: items side by side in one block, slots 0 to 7 holding cut, glue, sand, plane, and oil, then three spare slots; the address of item i is start plus 8 times i. Arrows show each item shifting one slot right. insert at 0: every item shifts one slot right, so the time grows with the length. items[3] and append: the same time at any size; a spare slot is used until the block is full. Bottom, a linked list: head points to a node holding cut, which points to glue, which once pointed to sand, shown dashed: before, glue pointed to sand. A new node, clamp, is inserted: glue now points to clamp and clamp to sand; sand points to None. Insert after glue: two references change, wherever glue is. Find item i: walk from the head, one node at a time. Nodes can be anywhere in memory; only the references tie them together.
Arrays jump straight to any position; linked lists insert without moving anything. Credit: StudyCorner diagram · CC BY 4.0 · Source

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

Why does items[500000] take no longer than items[0] in a Python list?

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, and len(items).
  • O(n), linear time: proportional to the number of items. x in items checks item after item; items.insert(0, x) and items.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

Going from 100,000 to 200,000 items quadrupled the time for list.insert(0, x). Why?

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 walking i nodes 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

What can a linked list do in constant time that an array can’t?

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

  1. Run growth.py with range(200). Do the jumps in “room for” stay the same size?
  2. In ends.py, how long would list.insert(0, x) take for 400,000 items? Predict, then run just that case.
  3. A list makes a fine stack: append to push, pop() to pop. Why is the end of the list the right end to use?
  4. Add a pop_front method to LinkedList that removes the first node and returns its value. What does it cost?
  5. Add a __len__ method that counts the nodes. How does its cost compare with len() on a Python list?
Answers
  1. 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.
  2. About four times 3.9 seconds, roughly 16 seconds, since doubling n again quadruples the time again. On the same PC it took 15.93 seconds.
  3. Adding and removing at the end are O(1); at the front they’d be O(n), since every item would shift 4.
  4. Constant time: value = self.head.value; self.head = self.head.next; return value (check for an empty list first).
  5. 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.

1day streak
0/1today's goal
–correct

Up next · 10 min

Hash Tables

Next lesson
Sources for this lesson
  1. 1
    Pat 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.
  2. 2
    Design 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.
  3. 3
    sys: 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.
  4. 4
    Time 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.
  5. 5
    collections: 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).