Chapter 4

Linked Lists: The Art of Pointers

Chapter 4: Linked Lists โ€” The Art of Pointers

Imagine a line of students queuing up. The array approach: everyone stands on a contiguous strip of ground, sorted by number. To insert someone in the middle, everyone behind them must shift one step back. The linked list approach: each person holds a slip of paper with the location of the next person. Want to insert someone? Just rewrite two slips of paper.

This analogy reveals the most fundamental characteristic of linked lists: logical order is completely decoupled from physical storage location. Nodes can be scattered anywhere in memory, connected only by pointers (references). This grants tremendous flexibility, but at a cost โ€” you can no longer "calculate" where the k-th element is; you must follow pointers from the beginning, one by one.


Level 1 ยท What You Need to Know

4.1 Singly Linked Lists: The Simplest Chain Structure

4.1.1 Node Definition

The basic building block of a linked list is the node. Each node contains two parts: the stored data and a reference to the next node.

class ListNode:
    """Singly linked list node"""
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next  # Points to the next node; None means end of list

This seemingly simple definition embodies an important computer science concept: self-referential data structures. The type definition references its own type, allowing us to describe unbounded structures with finite code.

WHY โ€” Why use a class instead of tuples/dicts? In Python, you could certainly use (val, next_ref) tuples or dicts to simulate a linked list. But classes offer advantages: (1) Attribute access node.next is faster than dictionary lookup node['next'] (in CPython, attribute access goes through __dict__ hash table or __slots__, and IDEs provide autocomplete); (2) You can add __repr__ for debugging; (3) It's the convention in LeetCode/interviews.

4.1.2 Building a Linked List

def build_linked_list(values):
    """Build a linked list from a Python list, return head node"""
    if not values:
        return None
    head = ListNode(values[0])
    current = head
    for val in values[1:]:
        current.next = ListNode(val)
        current = current.next
    return head

# Example: 1 -> 2 -> 3 -> None
head = build_linked_list([1, 2, 3])

4.1.3 Traversal

def traverse(head):
    """Traverse and print each node"""
    current = head
    while current:
        print(current.val, end=" -> ")
        current = current.next
    print("None")

Traversal has O(n) time complexity and O(1) space complexity. This is the most fundamental pattern in linked list operations โ€” a pointer walking from head to tail.

4.1.4 Insertion Operations

Linked list insertion has three cases: head insertion, tail insertion, and middle insertion.

def insert_at_head(head, val):
    """Insert at the head โ€” O(1)"""
    new_node = ListNode(val)
    new_node.next = head
    return new_node  # New node becomes the new head

def insert_at_tail(head, val):
    """Insert at the tail โ€” O(n)"""
    new_node = ListNode(val)
    if not head:
        return new_node
    current = head
    while current.next:
        current = current.next
    current.next = new_node
    return head

def insert_after(node, val):
    """Insert after a given node โ€” O(1) (assuming you already have a reference)"""
    if not node:
        return
    new_node = ListNode(val)
    new_node.next = node.next
    node.next = new_node

Key insight: Head insertion is O(1), but tail insertion is O(n) โ€” because you must traverse to the end first. This is why many implementations maintain a tail pointer.

4.1.5 Deletion Operations

def delete_node(head, target):
    """Delete the first node with value equal to target"""
    # Special case: delete head node
    if head and head.val == target:
        return head.next
    
    current = head
    while current and current.next:
        if current.next.val == target:
            current.next = current.next.next  # Skip the target node
            return head
        current = current.next
    return head  # Target not found

The core of deletion: find the predecessor of the target node, then make the predecessor's next skip over the target. This means you need to maintain a reference to the predecessor.

def search(head, target):
    """Search for a value in the linked list โ€” O(n)"""
    current = head
    while current:
        if current.val == target:
            return current
        current = current.next
    return None

Time Complexity Summary:

Operation Time Complexity Notes
Head insertion O(1) Only one pointer modification
Tail insertion O(n) or O(1) Depends on whether tail pointer is maintained
Insert after given node O(1) Already have reference to position
Delete head O(1) Just return head.next
Delete by value O(n) Must search first
Search O(n) Must traverse linearly
Random access (k-th) O(n) Cannot index like arrays

4.2 Doubly Linked Lists: Freedom in Both Directions

The limitation of singly linked lists is that traversal is unidirectional. If you're standing at some node and want to go back to the previous one, you can't โ€” unless you start over from the head. Doubly linked lists solve this by adding a prev pointer to each node.

class DoublyListNode:
    """Doubly linked list node"""
    def __init__(self, val=0, prev=None, next=None):
        self.val = val
        self.prev = prev
        self.next = next

4.2.1 Insertion and Deletion in Doubly Linked Lists

def dll_insert_after(node, val):
    """Insert after node in a doubly linked list โ€” O(1)"""
    new_node = DoublyListNode(val)
    new_node.next = node.next
    new_node.prev = node
    if node.next:
        node.next.prev = new_node
    node.next = new_node
    return new_node

def dll_delete(node):
    """Delete a node from a doubly linked list โ€” O(1)
    Precondition: node is not head or tail (or sentinels are used)
    """
    if node.prev:
        node.prev.next = node.next
    if node.next:
        node.next.prev = node.prev

The greatest advantage of doubly linked lists: given a reference to any node, you can delete it in O(1). Singly linked lists cannot do this โ€” you must find the predecessor, which requires traversing from the head.

4.2.2 Python collections.deque Under the Hood

WHO โ€” Source: CPython source code, Modules/_collectionsmodule.c.

Python's standard library collections.deque is not implemented as a simple doubly linked list. It's a hybrid of doubly linked list + fixed-size array blocks. Each block is a fixed-size array of 64 elements, and multiple blocks are connected via a doubly linked list.

Block 0          Block 1          Block 2
[a,b,c,...,64] <-> [e,f,g,...,64] <-> [h,i,j,...,64]

WHY โ€” Why not a pure doubly linked list? The problem with pure linked lists is: each element requires its own node object (Python object overhead is ~56 bytes), making memory utilization extremely poor. The blocked strategy lets every 64 elements share one pair of prev/next pointers, dramatically reducing memory overhead while maintaining O(1) insertion/deletion at both ends.

This design is also called an Unrolled Linked List, formally proposed by Shao, Bodik, and Goldstein in 1994 (though the idea of unrolling predates their paper).

4.3 Circular Linked Lists

In a circular linked list, the last node doesn't point to None โ€” it points back to some node in the list (usually the head).

def build_circular(values):
    """Build a circular linked list"""
    if not values:
        return None
    head = ListNode(values[0])
    current = head
    for val in values[1:]:
        current.next = ListNode(val)
        current = current.next
    current.next = head  # Tail points back to head
    return head

4.3.1 The Josephus Problem

WHO โ€” Source: Flavius Josephus, a 1st-century Jewish historian, described the original version of this problem in The Jewish War. The modern mathematical formalization was done by W. Ahrens (1918).

Problem statement: n people stand in a circle. Starting from person 1, count to k โ€” that person leaves. Resume counting from the next person until everyone has left. Determine the elimination order.

def josephus(n, k):
    """Josephus problem โ€” circular linked list simulation"""
    # Build circular linked list
    head = ListNode(1)
    current = head
    for i in range(2, n + 1):
        current.next = ListNode(i)
        current = current.next
    current.next = head  # Form the circle
    
    result = []
    prev = current  # prev points to the node before current
    curr = head
    
    while curr.next != curr:  # Until only one person remains
        # Count k-1 times (move k-1 steps)
        for _ in range(k - 1):
            prev = curr
            curr = curr.next
        # Eliminate
        result.append(curr.val)
        prev.next = curr.next  # Remove current node
        curr = curr.next
    
    result.append(curr.val)  # Last person
    return result

# josephus(7, 3) => [3, 6, 2, 7, 5, 1, 4]

Mathematical solution: The Josephus problem has an elegant recurrence J(n, k) = (J(n-1, k) + k) % n that computes the last survivor's index in O(n) time without simulating the entire process.

def josephus_survivor(n, k):
    """Find the last survivor (0-indexed) โ€” O(n) mathematical solution"""
    pos = 0
    for i in range(2, n + 1):
        pos = (pos + k) % i
    return pos  # Convert to 1-indexed: pos + 1

4.4 Linked Lists vs Arrays: A Comprehensive Comparison

Dimension Array (Python list) Linked List
Random access O(1) O(n)
Head insertion O(n) O(1)
Tail insertion Amortized O(1) O(n) or O(1)*
Middle insertion O(n) O(1)**
Deletion O(n) O(1)**
Memory layout Contiguous Scattered
Cache friendliness Excellent Poor
Extra space None (or small pre-allocation) One/two pointers per node
Fixed size? Dynamic resize (with copy cost) Naturally dynamic

*With tail pointer. **When you already have a reference to the position.

WHY โ€” Why does cache matter? Modern CPU L1 cache access takes ~1ns, while main memory access takes ~100ns โ€” a 100x difference. Arrays' contiguous memory layout means CPU prefetch can load upcoming data into cache ahead of time; linked list nodes are scattered across the heap, so nearly every node.next dereference causes a cache miss.

WHO โ€” Source: Bjarne Stroustrup (creator of C++) has emphasized this point in numerous talks, most famously in his GoingNative 2012 keynote "Why you should avoid linked lists," where he showed experimental data demonstrating that even for insertion/deletion-heavy workloads, std::vector often outperforms std::list due to cache effects.

4.5 Common Operations

4.5.1 Reversing a Linked List

This is the most classic linked list operation and one of the most frequent interview questions (LeetCode #206).

def reverse_list(head):
    """Iterative reversal โ€” O(n) time, O(1) space"""
    prev = None
    curr = head
    while curr:
        next_temp = curr.next  # Save the next node
        curr.next = prev       # Reverse the pointer
        prev = curr            # Advance prev
        curr = next_temp       # Advance curr
    return prev  # prev is now the new head

Visual walkthrough:

Start:  None <- 1    2 -> 3 -> None
               prev  curr

Step 1: None <- 1 <- 2    3 -> None
                     prev  curr

Step 2: None <- 1 <- 2 <- 3
                          prev  curr=None

Recursive version:

def reverse_list_recursive(head):
    """Recursive reversal โ€” O(n) time, O(n) space (stack)"""
    if not head or not head.next:
        return head
    new_head = reverse_list_recursive(head.next)
    head.next.next = head  # Make successor point back to me
    head.next = None       # Break the original forward link
    return new_head

4.5.2 Merging Two Sorted Lists (LeetCode #21)

def merge_two_sorted(l1, l2):
    """Merge two sorted lists โ€” O(n+m) time, O(1) space"""
    dummy = ListNode(0)  # Sentinel node
    tail = dummy
    
    while l1 and l2:
        if l1.val <= l2.val:
            tail.next = l1
            l1 = l1.next
        else:
            tail.next = l2
            l2 = l2.next
        tail = tail.next
    
    tail.next = l1 if l1 else l2  # Append the remaining portion
    return dummy.next

4.5.3 Finding the Middle Node (Two Pointers)

def find_middle(head):
    """Fast/slow pointers to find middle โ€” O(n) time, O(1) space
    For even length, returns the left-of-center middle node
    """
    slow = fast = head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next
    return slow

WHY โ€” Why two pointers? If you traverse once to compute length n, then walk n/2 steps to find the middle, that's 1.5n steps. The two-pointer approach needs only n steps โ€” when the fast pointer finishes, the slow pointer is exactly at the middle. Although both are O(n), the constant factor is smaller. More importantly, the two-pointer technique doesn't require knowing the list length, making it applicable to streaming data of unknown length.

4.5.4 Remove Nth Node From End (LeetCode #19)

def remove_nth_from_end(head, n):
    """Remove the nth node from the end โ€” single pass"""
    dummy = ListNode(0)
    dummy.next = head
    fast = slow = dummy
    
    # fast advances n+1 steps ahead
    for _ in range(n + 1):
        fast = fast.next
    
    # Move both until fast reaches the end
    while fast:
        fast = fast.next
        slow = slow.next
    
    # slow is now the predecessor of the target
    slow.next = slow.next.next
    return dummy.next

Trick: Advance the fast pointer by n+1 steps (not n), so when fast reaches None, slow is exactly one position before the target โ€” convenient for deletion.

4.6 Common Mistakes

Mistake 1: Forgetting to Handle Empty Lists

# Wrong! If head is None, head.val crashes
def bad_search(head, target):
    if head.val == target:  # NoneType has no attribute 'val'
        return head
    ...

# Correct: check if head is None first
def good_search(head, target):
    if not head:
        return None
    if head.val == target:
        return head
    ...

Mistake 2: Losing Pointer References

# Wrong! This breaks the chain
def bad_insert_after(node, val):
    new_node = ListNode(val)
    node.next = new_node      # The rest of the list after node is lost!
    new_node.next = node.next  # node.next is already new_node here

# Correct: save first, or set new node's next before modifying node
def good_insert_after(node, val):
    new_node = ListNode(val)
    new_node.next = node.next  # Connect to the rest first
    node.next = new_node       # Then modify the predecessor

Mistake 3: Modifying the List During Traversal

# Dangerous! Deleting nodes during traversal may skip elements or loop forever
current = head
while current:
    if should_delete(current):
        # How to correctly delete here? You've lost the prev reference!
        pass
    current = current.next

The correct approach is to maintain a prev pointer, or use a sentinel node.


Level 2 ยท Deeper Understanding

4.7 Cycle Detection with Fast/Slow Pointers (Floyd's Algorithm)

WHO โ€” Source: Robert W. Floyd, informally proposed in 1967 (he never published a formal paper on it, but Donald Knuth attributes the algorithm to Floyd in The Art of Computer Programming). Also called the "tortoise and hare" algorithm.

Problem: Given a linked list, determine if it contains a cycle.

Core idea: Set up two pointers โ€” slow moves one step at a time, fast moves two steps. If there's a cycle, the fast pointer will eventually catch the slow one (like runners on a track โ€” the faster one will lap the slower one). If there's no cycle, fast will reach None first.

def has_cycle(head):
    """Floyd's cycle detection โ€” O(n) time, O(1) space (LeetCode #141)"""
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            return True
    return False

WHY โ€” Why are they guaranteed to meet? Let the cycle length be C. When the slow pointer enters the cycle, the fast pointer is already somewhere inside. At each step, the fast pointer gains one step on the slow pointer, reducing their gap by 1. In at most C steps, the gap shrinks from some value to 0, meaning they meet.

More formal proof: Suppose slow takes k steps to reach the cycle entrance; at that point, fast has taken 2k steps. Fast's position in the cycle is (2k - steps_before_cycle) % C. Once both are in the cycle, their gap decreases by 1 per step, so they meet within at most C additional steps. Total time complexity: O(n).

For deeper discussion of cycle detection and its variants, see Chapter 23: Two Pointers and Sliding Window.

4.8 Finding the Cycle Entrance: Mathematical Derivation

Problem: Given a linked list with a cycle, find the node where the cycle begins (LeetCode #142).

def detect_cycle_entry(head):
    """Find the cycle entrance node"""
    slow = fast = head
    
    # Phase 1: Detect the cycle
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            break
    else:
        return None  # No cycle
    
    # Phase 2: Find the entrance
    # Reset one pointer to head, then advance both at the same speed
    slow = head
    while slow != fast:
        slow = slow.next
        fast = fast.next
    return slow

Mathematical derivation:

Let the distance from head to cycle entrance be a, from entrance to meeting point be b, and from meeting point back to entrance be c. Cycle length C = b + c.

This means: walking a steps from the head reaches the entrance, and walking (n-1) full laps plus c steps from the meeting point also reaches the entrance. So in Phase 2, both pointers advance at the same speed and meet at the entrance.

4.9 Skip Lists: An Introduction

WHO โ€” Source: William Pugh, 1990 paper "Skip Lists: A Probabilistic Alternative to Balanced Trees," published in Communications of the ACM.

A skip list builds upon a sorted linked list by adding multiple levels of "express lanes" to achieve O(log n) search.

Level 3: head ---------> 6 --------------------------> None
Level 2: head ---------> 6 ---------> 25 -----------> None
Level 1: head ---> 3 --> 6 ---> 9 --> 25 ---> 30 ---> None
Level 0: head -> 1 -> 3 -> 6 -> 7 -> 9 -> 12 -> 25 -> 30 -> 42 -> None

Core idea: Each node randomly decides how many levels it appears in. Upon insertion, a coin flip (with probability p, typically p=0.5 or p=0.25) determines whether the node is "promoted" to the next level. Search starts from the highest level, using express lanes to skip over many nodes.

import random

class SkipNode:
    def __init__(self, val, level):
        self.val = val
        self.forward = [None] * (level + 1)  # One next pointer per level

class SkipList:
    MAX_LEVEL = 16
    P = 0.5  # Promotion probability
    
    def __init__(self):
        self.header = SkipNode(-float('inf'), self.MAX_LEVEL)
        self.level = 0  # Current highest level
    
    def random_level(self):
        """Randomly determine the level for a new node"""
        lvl = 0
        while random.random() < self.P and lvl < self.MAX_LEVEL:
            lvl += 1
        return lvl
    
    def search(self, target):
        """O(log n) average time search"""
        current = self.header
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < target:
                current = current.forward[i]
        current = current.forward[0]
        return current and current.val == target
    
    def insert(self, val):
        """O(log n) average time insertion"""
        update = [None] * (self.MAX_LEVEL + 1)
        current = self.header
        
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < val:
                current = current.forward[i]
            update[i] = current
        
        new_level = self.random_level()
        if new_level > self.level:
            for i in range(self.level + 1, new_level + 1):
                update[i] = self.header
            self.level = new_level
        
        new_node = SkipNode(val, new_level)
        for i in range(new_level + 1):
            new_node.forward[i] = update[i].forward[i]
            update[i].forward[i] = new_node

WHY โ€” Why did Redis choose skip lists over red-black trees?

WHO โ€” Source: Salvatore Sanfilippo (antirez), creator of Redis, explained this decision in GitHub issues and multiple replies in 2014.

antirez's reasoning (paraphrased):

  1. Simpler implementation: Skip list code is roughly half the size of red-black tree code โ€” easier to understand, debug, and maintain.
  2. Naturally efficient range queries: The bottom level of a skip list is a sorted linked list; range queries just find the start and traverse sequentially. Range queries on red-black trees require in-order traversal, which is more complex to implement.
  3. Concurrency-friendly: Skip lists can more easily be made lock-free (though Redis is single-threaded, this is a general advantage).
  4. Tunable memory locality: By adjusting p, you can trade between time and space. Redis uses p=0.25, resulting in an average of only 1.33 pointers per node.

Expected complexity of skip lists:

Operation Average Worst Case
Search O(log n) O(n)
Insert O(log n) O(n)
Delete O(log n) O(n)
Space O(n) O(n log n)

The worst case (all nodes at the highest level) has exponentially small probability and can be ignored in practice.

4.10 Linked Lists in LRU Cache

WHO โ€” Source: The LRU (Least Recently Used) cache eviction strategy traces back to 1960s research on operating system page replacement algorithms. As an interview problem, it's LeetCode #146.

LRU Cache core requirements:

Solution: Hash Map + Doubly Linked List

class LRUNode:
    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}  # key -> LRUNode
        # Sentinel nodes: eliminate boundary conditions
        self.head = LRUNode()  # dummy head
        self.tail = LRUNode()  # dummy tail
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def _remove(self, node):
        """Remove a node from the doubly linked list โ€” O(1)"""
        node.prev.next = node.next
        node.next.prev = node.prev
    
    def _add_to_front(self, node):
        """Insert node right after head (marks it as most recently used) โ€” O(1)"""
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node
    
    def get(self, key):
        if key in self.cache:
            node = self.cache[key]
            self._remove(node)
            self._add_to_front(node)
            return node.val
        return -1
    
    def put(self, key, value):
        if key in self.cache:
            node = self.cache[key]
            node.val = value
            self._remove(node)
            self._add_to_front(node)
        else:
            if len(self.cache) >= self.capacity:
                # Evict the least recently used (node before tail)
                lru = self.tail.prev
                self._remove(lru)
                del self.cache[lru.key]
            node = LRUNode(key, value)
            self.cache[key] = node
            self._add_to_front(node)

WHY โ€” Why do we need a doubly linked list? The hash map provides O(1) lookup but cannot maintain usage order. The linked list maintains order but has O(n) search. Combined: the hash map stores key-to-node mappings for O(1) access, and the doubly linked list maintains usage order with O(1) move/delete for any node.

WHY โ€” Why doubly linked instead of singly linked? Because _remove(node) needs to modify both node's predecessor and successor. With a singly linked list, you'd need to traverse from the head to find the predecessor, making deletion O(n).

For detailed discussion of hash tables, see Chapter 8. For more LRU Cache variants and industrial implementations, see Chapter 38.

4.11 Recursion vs Iteration for Linked Lists: Trade-offs

The recursive structure of linked lists โ€” a node plus "the rest of the list" โ€” makes them naturally suited for recursive processing. But recursion isn't always the best choice.

# Recursive traversal
def recursive_traverse(head):
    if not head:
        return
    print(head.val)
    recursive_traverse(head.next)

# Recursive length
def recursive_length(head):
    if not head:
        return 0
    return 1 + recursive_length(head.next)

Comparison:

Dimension Recursion Iteration
Code conciseness Usually more concise May be longer
Space complexity O(n) stack space O(1)
Stack overflow risk Crashes if list > recursion limit No such risk
Tail-call optimization Python does NOT support it N/A
Debugging difficulty Harder (deep call stacks) Easier

WHY โ€” Python doesn't support tail-call optimization: Guido van Rossum explicitly stated in his 2009 blog post "Neopythonic: Tail Recursion Elimination" that Python will not implement TCO, because it would destroy the readability of tracebacks โ€” Python's philosophy values clear error reporting over performance optimization.

Practical advice: When working with linked lists in Python, almost always prefer iteration. Recursive solutions are better for teaching and proving correctness. CPython's default recursion limit is 1000 (modifiable via sys.setrecursionlimit()), meaning linked lists with more than 1000 nodes will crash the recursive version.


Level 3 ยท Going Deeper

4.12 The History of Linked Lists

WHO โ€” Source: Allen Newell, Cliff Shaw, Herbert A. Simon, 1955-1956, at RAND Corporation and Carnegie Mellon University, developing IPL (Information Processing Language).

The linked list was not invented to solve a specific programming problem โ€” it was invented to build artificial intelligence. In 1955, Newell, Shaw, and Simon were developing the Logic Theorist โ€” the first AI program in history, designed to automatically prove mathematical theorems. They needed a flexible data structure to represent and manipulate symbolic expressions (logical formulas), but the computers of the time only had fixed-size arrays.

Their solution was the IPL language, whose core data structure was the linked list. Each "cell" in IPL contained a symbol value and an address pointing to the next cell. This design directly influenced John McCarthy's creation of LISP in 1958 โ€” LISP's name stands for "LISt Processing," and the linked list is the language's foundational data structure.

Historical influence chain:

IPL (1955-56, Newell/Shaw/Simon)
    |
LISP (1958, McCarthy) โ€” the cons cell IS a linked list node
    |
All modern functional languages' list types
    (Haskell, ML, Erlang, Clojure...)

Fun fact: Newell and Simon won the 1975 Turing Award for their pioneering work in AI. The linked list was merely a byproduct of their work, yet its impact on computer science has been more far-reaching than the Logic Theorist itself.

4.13 XOR Linked Lists: Saving Pointer Space with XOR

WHO โ€” Source: This technique was widely known in the systems programming community in the 1990s. The earliest formal description is difficult to trace, but Knuth mentions a similar idea in exercises in The Art of Computer Programming Vol. 1 (1968).

A regular doubly linked list stores two pointers per node (prev and next). An XOR linked list stores only one value: prev_addr XOR next_addr.

Principle:

Let nodes A, B, C have memory addresses addr(A), addr(B), addr(C)

Regular doubly linked list:
  A.prev = None, A.next = addr(B)
  B.prev = addr(A), B.next = addr(C)
  C.prev = addr(B), C.next = None

XOR linked list:
  A.link = 0 XOR addr(B) = addr(B)
  B.link = addr(A) XOR addr(C)
  C.link = addr(B) XOR 0 = addr(B)

Traversal method: If you're at node B and know you came from A (i.e., you know addr(A)), then:

next_addr = B.link XOR addr(A)
          = (addr(A) XOR addr(C)) XOR addr(A)
          = addr(C)  (because X XOR X = 0)

Python pseudocode (Python cannot directly manipulate memory addresses; here we simulate using id() and ctypes):

import ctypes

class XORNode:
    def __init__(self, val):
        self.val = val
        self.npx = 0  # XOR of prev and next addresses

def xor(a, b):
    """XOR two node addresses (using id)"""
    return a ^ b

def insert_at_head(head, val):
    new_node = XORNode(val)
    new_node.npx = id(head) if head else 0
    if head:
        # head.npx = 0 XOR old_next_addr
        # New head.npx = id(new_node) XOR old_next_addr
        head.npx = xor(id(new_node), head.npx)
    return new_node

def traverse_xor(head):
    """Traverse an XOR linked list"""
    prev_addr = 0
    current = head
    while current:
        print(current.val, end=" -> ")
        next_addr = xor(prev_addr, current.npx)
        prev_addr = id(current)
        # Need to recover the object from address (in C, just dereference the pointer)
        current = ctypes.cast(next_addr, ctypes.py_object).value if next_addr else None
    print("None")

WHY โ€” Why is it rarely used in practice?

  1. Terrible readability: Code is extremely hard to understand and maintain.
  2. Debugging difficulty: You can't directly inspect predecessors/successors in a debugger.
  3. Conflicts with garbage collectors: GC needs to traverse all references to determine reachability. XOR-encoded "pointers" are invisible to the GC, and nodes may be incorrectly reclaimed. This makes XOR linked lists completely unusable in languages with GC (Java/Python/Go).
  4. Modern memory is abundant: When each pointer was 4 bytes, saving one pointer was meaningful. Now even with 8-byte pointers, memory is rarely the bottleneck.

Applicable scenarios: Extreme embedded systems (memory measured in KB), using C/C++ with manual memory management.

4.14 Free Lists in Memory Allocators

WHO โ€” Source: Free lists are among the oldest dynamic memory management techniques, dating back to 1960s ALGOL compilers. Doug Lea's dlmalloc (1987) and the subsequent ptmalloc2 (glibc's default allocator) extensively use free list variants.

When you call malloc() to allocate memory, how does the allocator know which memory blocks are free? The answer is the free list.

Core idea: String all free memory blocks together in a linked list. On allocation, take a block from the head; on deallocation, insert the block back at the head.

Free List (chain of free blocks):
[16B block] -> [32B block] -> [16B block] -> [64B block] -> None

malloc(16):
  Take the first 16B block, return to user
  Free List: [32B block] -> [16B block] -> [64B block] -> None

free(ptr):
  Insert ptr's block back at the head
  Free List: [16B block] -> [32B block] -> [16B block] -> [64B block] -> None

The elegant part: The linked list pointers for free blocks are stored within the free blocks themselves! Since no one is using the free block, its first few bytes can hold the next pointer. This means maintaining the free list requires zero additional memory overhead.

// Simplified free list implementation (C pseudocode)
typedef struct free_block {
    struct free_block *next;  // Stored inside the free block itself
} free_block_t;

free_block_t *free_list = NULL;

void *my_malloc(size_t size) {
    // Find an appropriately sized block in free_list
    free_block_t *block = free_list;
    free_list = block->next;
    return (void *)block;
}

void my_free(void *ptr) {
    // Insert the freed block at the head of free_list
    free_block_t *block = (free_block_t *)ptr;
    block->next = free_list;
    free_list = block;
}

Modern allocator optimizations:

4.15 Linux Kernel's list_head: Intrusive Linked Lists

WHO โ€” Source: Linux kernel, include/linux/list.h. This design was introduced starting with Linux 2.1 (circa 1996), contributed by multiple kernel developers.

Traditional linked lists (like our implementations above) are "non-intrusive": the list node contains the data. The Linux kernel uses "intrusive" linked lists: the data structure embeds the list node within itself.

// Linux kernel's list_head definition
struct list_head {
    struct list_head *next, *prev;
};

// Usage: embed list_head in your data structure
struct task_struct {
    pid_t pid;
    char comm[16];
    struct list_head tasks;     // Process list
    struct list_head children;  // Child process list
    // ... other fields
};

Key technique: The container_of macro. Given the address of a list_head, how do you find the enclosing structure?

// container_of macro: derive structure pointer from member pointer
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

// Traverse the process list
struct task_struct *task;
struct list_head *pos;
list_for_each(pos, &init_task.tasks) {
    task = container_of(pos, struct task_struct, tasks);
    printk("PID: %d, Name: %s\n", task->pid, task->comm);
}

WHY โ€” Why does the kernel use intrusive linked lists?

  1. Zero extra memory allocation: No need to kmalloc a separate list node for each entry. The list head is embedded in the existing structure, allocated together with it.
  2. One object can be in multiple lists simultaneously: For example, task_struct is simultaneously in the process list, run queue, and wait queue โ€” just embed multiple list_head fields.
  3. Type-agnostic: list_head operations (insert, delete, traverse) don't care what the enclosing structure type is โ€” highly reusable code.
  4. Cache-friendly: List nodes and data are in the same memory block, reducing pointer chasing and cache misses.

Similar idea in Python:

class IntrusiveNode:
    """Intrusive linked list node mixin"""
    def __init__(self):
        self.list_prev = None
        self.list_next = None

class Process(IntrusiveNode):
    """Process object that IS a list node"""
    def __init__(self, pid, name):
        super().__init__()
        self.pid = pid
        self.name = name

class IntrusiveList:
    """Intrusive doubly linked list"""
    def __init__(self):
        self.head = IntrusiveNode()
        self.head.list_prev = self.head
        self.head.list_next = self.head  # Empty list points to itself
    
    def insert(self, node):
        """Insert at head"""
        node.list_next = self.head.list_next
        node.list_prev = self.head
        self.head.list_next.list_prev = node
        self.head.list_next = node
    
    def remove(self, node):
        """Remove a specific node"""
        node.list_prev.list_next = node.list_next
        node.list_next.list_prev = node.list_prev

Level 4 ยท Engineering Practice

4.16 Why Linked Lists Are Rarely Used in Python

In real-world Python projects, you almost never need to implement your own linked list. Here's why:

1. Python's list is already good enough

Python's list is a dynamic array under the hood (C-implemented, a PyObject ** pointer array). Although head insertion is O(n):

2. collections.deque covers most scenarios

from collections import deque

d = deque()
d.appendleft(1)  # O(1) head insertion
d.append(2)      # O(1) tail insertion
d.popleft()      # O(1) head deletion
d.pop()          # O(1) tail deletion

deque has O(1) operations at both ends, sufficient to replace most linked list use cases.

3. Python object overhead is massive

Every Python object takes at least 56 bytes (sys.getsizeof(object()) = 56). A ListNode object:

import sys
node = ListNode(42)
print(sys.getsizeof(node))  # 48 (base object size)
# Plus __dict__: additional 104 bytes
# Total: ~152 bytes to store a single int (which itself only needs 28 bytes)

By comparison, storing an int reference in a list costs only 8 bytes (one pointer). Linked lists have abysmal space efficiency.

4. When are linked lists still useful?

4.17 High-Frequency Interview Problems

4.17.1 Palindrome Linked List (LeetCode #234)

Approach: Find middle -> Reverse second half -> Compare one by one -> (Optional) Restore the list

def is_palindrome(head):
    """O(n) time, O(1) space palindrome check"""
    if not head or not head.next:
        return True
    
    # 1. Find middle
    slow = fast = head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next
    
    # 2. Reverse second half
    second_half = reverse_list(slow.next)
    
    # 3. Compare
    p1, p2 = head, second_half
    result = True
    while p2:
        if p1.val != p2.val:
            result = False
            break
        p1 = p1.next
        p2 = p2.next
    
    # 4. Restore the list (good practice)
    slow.next = reverse_list(second_half)
    
    return result

4.17.2 Intersection of Two Linked Lists (LeetCode #160)

Approach: Two pointers start at the heads of both lists. When one reaches the end, redirect it to the other list's head. If there's an intersection, they'll meet at the intersection node.

def get_intersection_node(headA, headB):
    """O(n+m) time, O(1) space"""
    if not headA or not headB:
        return None
    
    pA, pB = headA, headB
    
    while pA != pB:
        pA = pA.next if pA else headB
        pB = pB.next if pB else headA
    
    return pA  # Either the intersection or None (both reach end simultaneously)

WHY โ€” Why does this work? Let list A have length a, list B have length b, and the common portion have length c. Pointer A's path: a + (b - c); pointer B's path: b + (a - c). Both are equal: a + b - c = b + a - c. So they are guaranteed to arrive at the intersection simultaneously (or both reach None simultaneously).

4.17.3 Sort List (LeetCode #148 โ€” Merge Sort)

WHY โ€” Why merge sort for linked lists? Quicksort requires random access (partition operations) and doesn't suit linked lists. Merge sort only needs sequential access and merge operations โ€” a perfect fit for linked lists. Moreover, linked list merge doesn't require extra O(n) space (unlike array merge which needs a temporary array).

def sort_list(head):
    """Linked list merge sort โ€” O(n log n) time, O(log n) space (recursion stack)"""
    if not head or not head.next:
        return head
    
    # Find middle and split
    slow, fast = head, head.next
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    
    mid = slow.next
    slow.next = None  # Split into two halves
    
    # Recursively sort both halves
    left = sort_list(head)
    right = sort_list(mid)
    
    # Merge
    return merge_two_sorted(left, right)

Advanced: Bottom-up iterative merge sort can achieve O(1) space (no recursion stack), but code complexity increases significantly.

4.17.4 Reverse Nodes in k-Group (LeetCode #25)

This is a "Hard" level linked list problem that comprehensively tests pointer manipulation skills.

def reverse_k_group(head, k):
    """Reverse every k nodes as a group"""
    # Check if there are k nodes remaining
    count = 0
    node = head
    while node and count < k:
        node = node.next
        count += 1
    
    if count < k:
        return head  # Fewer than k nodes, don't reverse
    
    # Reverse the first k nodes
    prev = None
    curr = head
    for _ in range(k):
        next_temp = curr.next
        curr.next = prev
        prev = curr
        curr = next_temp
    
    # head is now the last node of the reversed segment
    # curr is the start of the next group
    head.next = reverse_k_group(curr, k)  # Recursively handle the rest
    
    return prev  # prev is the new head of the reversed segment

Iterative version (preferred in interviews since it won't stack overflow):

def reverse_k_group_iterative(head, k):
    """Iterative k-group reversal"""
    dummy = ListNode(0)
    dummy.next = head
    group_prev = dummy
    
    while True:
        # Check if there are k nodes remaining
        kth = group_prev
        for _ in range(k):
            kth = kth.next
            if not kth:
                return dummy.next
        
        group_next = kth.next
        
        # Reverse nodes between group_prev.next and kth
        prev, curr = kth.next, group_prev.next
        for _ in range(k):
            next_temp = curr.next
            curr.next = prev
            prev = curr
            curr = next_temp
        
        # Update connections
        tmp = group_prev.next
        group_prev.next = kth
        group_prev = tmp

# Test: reverse_k_group_iterative(build_linked_list([1,2,3,4,5]), 2)
# Result: 2 -> 1 -> 4 -> 3 -> 5

4.18 The Sentinel Node (Dummy Head) Technique

The sentinel node (also called a dummy node) is one of the most practical techniques in linked list programming.

WHY โ€” Why do we need sentinels? The most annoying part of linked list operations is boundary conditions: the head node has no predecessor, the tail node has no successor. Inserting/deleting the head node requires special handling (because you need to update the head reference). A sentinel node unifies all operations by placing a fake node before the real head.

def remove_elements(head, val):
    """Remove all nodes with value val (LeetCode #203)
    Comparison: without sentinel vs with sentinel
    """
    # Method 1: Without sentinel โ€” special handling for head
    while head and head.val == val:
        head = head.next
    current = head
    while current and current.next:
        if current.next.val == val:
            current.next = current.next.next
        else:
            current = current.next
    return head
    
    # Method 2: With sentinel โ€” uniform handling
    dummy = ListNode(0)
    dummy.next = head
    current = dummy
    while current.next:
        if current.next.val == val:
            current.next = current.next.next
        else:
            current = current.next
    return dummy.next

Benefits of using a sentinel:

  1. No need to separately handle the "delete head node" case
  2. Uniform code logic, less error-prone
  3. Return dummy.next at the end โ€” it's naturally the correct new head (whether the original head survived or was deleted)

Rule of thumb: If your linked list operation might modify the head node, use a sentinel. This includes:

4.19 When to Use Linked Lists and When Not To

Scenarios where linked lists (or their variants) should be used:

  1. Frequent insertion/deletion at known positions: For example, text editor line buffers (though modern editors often use ropes or piece tables).
  2. O(1) concatenation of two sequences: Merging two linked lists requires changing just one pointer. Array concatenation is O(n).
  3. Implementing specialized data structures: LRU Cache, LFU Cache, and Fibonacci Heap all use linked lists internally.
  4. Severe memory fragmentation preventing large contiguous allocations: Common in embedded systems.
  5. Extremely large elements expensive to move: But in Python/Java where objects are references, "moving" just moves a pointer โ€” this reason doesn't apply.

Scenarios where linked lists should NOT be used:

  1. Random access needed: Linked list get(i) is O(n).
  2. Small data sets (fewer than a few thousand elements): Array cache friendliness will dominate linked list's theoretical advantages.
  3. Binary search needed: Cannot efficiently binary search on a linked list (unless using a skip list).
  4. High-level languages with GC (Python/Java): Object overhead makes linked list space efficiency abysmal.
  5. Parallel/vectorized computation: Modern CPU SIMD instructions require contiguous memory.

Decision tree summary:

Need random access?
  -> Yes: Use array
  -> No:
    Need operations at both ends?
      -> Yes: Use deque
      -> No:
        Need O(1) deletion of arbitrary known nodes?
          -> Yes: Use linked list + hash map
          -> No:
            Large dataset with frequent middle insertions?
              -> Yes: Consider linked list or skip list
              -> No: Use array

Chapter Summary

Linked lists are the foundation for understanding pointers/references and dynamic data structures. Although linked lists are rarely used directly in modern high-level languages, their ideas are everywhere:

Mastering linked lists is not about memorizing pointer manipulation code templates โ€” it's about understanding: when logical order is decoupled from physical location, what freedom do you gain, and what cost do you pay?

In the next chapter, we'll study stacks and queues โ€” abstractions obtained by restricting the "access patterns" of linked lists and arrays. This "constraint as power" design philosophy is a recurring theme in computer science.

Rate this chapter
4.5  / 5  (83 ratings)

๐Ÿ’ฌ Comments