Copy List With Random Pointer: Hash Map vs Interleaving
8 min readBytePatterns
Deep-copy a linked list whose nodes also point at random nodes: the two-pass hash map, the O(1) extra space interleaving trick, and a randomised check of both.
Copying an ordinary linked list is a warm-up: walk it, make a node for each node, link them in order. Add one extra pointer per node, one that may aim at any node in the list or at nothing, and the warm-up turns into a real question about identity: which new node should a copied pointer aim at?
The problem it solves
Each node has a value, a next pointer and a rand pointer. rand can point forwards, backwards, at the node itself, or be None. Return a deep copy: brand new nodes, the same values, and pointers that mirror the original's shape exactly, but only ever point at new nodes. A copy whose rand points back into the original list is wrong, even if every value looks right.
Values do not identify nodes; two nodes can hold the same value. The only thing that identifies a node is the node itself.
The intuition
The obstacle is order. When you copy node a, its rand may point at c, and the copy of c does not exist yet. You cannot point at a chair you have not drawn.
So separate creating from wiring:
- Pass one walks the list and creates a bare copy of every node, recording
original -> copyin a dictionary. - Pass two walks again. Every node now has a twin, so both pointers translate with a lookup: the copy's
nextisclone[n.next], itsrandisclone[n.rand].
Putting None -> None in the dictionary means a missing pointer translates like any other, with no special case.
There is a second, sneakier way to store the same mapping: in the list itself. Weave each copy directly after its original, a -> a' -> b -> b' -> c -> c'. Now the copy of any node x is simply x.next, so a copy's rand is n.rand.next. Unweave at the end and both lists are whole again, with no dictionary at all.
Watch it run
The animation copies a three-node list whose random links aim both forwards and backwards. Pass one lays down three bare twins, value only, and records each in the map. Only when every twin exists does pass two wire next and rand for each, both as lookups. The last frame drops the map: the copy stands alone, the same shape, no shared nodes.
Copy a List With Random Links
Step 1 of 9
Each node points at the next one and at any node at all. b's random link aims backwards; a's aims forwards.
The same interactive animation as the lesson — step through it with the controls.
The code
Both versions, with a helper that describes a list as (value, index of rand) pairs so two lists can be compared by shape:
class Node:
def __init__(self, val):
self.val, self.next, self.rand = val, None, None
def build(vals, rands):
"""rands[i] is the index node i's random link points at, or None."""
nodes = [Node(v) for v in vals]
for a, b in zip(nodes, nodes[1:]):
a.next = b
for node, r in zip(nodes, rands):
node.rand = nodes[r] if r is not None else None
return nodes[0] if nodes else None
def shape(head):
nodes, n = [], head
while n:
nodes.append(n)
n = n.next
where = {id(x): i for i, x in enumerate(nodes)}
return [(x.val, where[id(x.rand)] if x.rand else None) for x in nodes]
def copy_with_map(head):
clone = {None: None} # original node -> its copy
n = head
while n: # pass 1: bare copies
clone[n] = Node(n.val)
n = n.next
n = head
while n: # pass 2: translate both links
clone[n].next = clone[n.next]
clone[n].rand = clone[n.rand]
n = n.next
return clone[head]
head = build(["a", "b", "c"], [2, 0, None]) # a -> c, b -> a, c -> nothing
copy = copy_with_map(head)
print(shape(copy)) # [('a', 2), ('b', 0), ('c', None)]
print(copy is head, copy.rand is head.next.next) # False False
The interleaving version, three passes over the woven list:
def copy_interleaved(head):
if head is None:
return None
n = head
while n: # 1. weave: a -> a' -> b -> b' -> ...
twin = Node(n.val)
twin.next, n.next = n.next, twin
n = twin.next
n = head
while n: # 2. a copy's rand is the original's rand, one step on
n.next.rand = n.rand.next if n.rand else None
n = n.next.next
new_head, n = head.next, head
while n: # 3. unweave, restoring the original
twin = n.next
n.next = twin.next
twin.next = twin.next.next if twin.next else None
n = n.next
return new_head
copy2 = copy_interleaved(head)
print(shape(copy2) == shape(head), shape(head)) # True [('a', 2), ('b', 0), ('c', None)]
Both against Python's own copy.deepcopy, which tracks visited objects and so handles cycles, on 500 random lists with duplicate values. Each check also confirms the copy shares no node with the original and that the original is left unchanged:
import copy as copymod, random
def shares_nodes(a, b):
seen = set()
while a:
seen.add(id(a))
a = a.next
while b:
if id(b) in seen:
return True
b = b.next
return False
random.seed(15)
ok = True
for _ in range(500):
n = random.randint(0, 12)
vals = [random.randint(0, 3) for _ in range(n)]
rands = [random.choice([None] + list(range(n))) for _ in range(n)]
head = build(vals, rands)
want = shape(copymod.deepcopy(head))
for f in (copy_with_map, copy_interleaved):
c = f(head)
ok &= shape(c) == want and not shares_nodes(head, c)
ok &= shape(head) == [(v, r) for v, r in zip(vals, rands)]
print(ok) # True
The complexity
- Hash map: two passes,
O(n)time. The dictionary holdsn + 1entries, soO(n)extra space on top of the copy itself. - Interleaving: three passes,
O(n)time, andO(1)extra space; the mapping lives in thenextpointers for the duration. The output list is not counted as extra space in either version, since the problem requires it. - One-pass hash map variant: create a copy on first sight, for
nextor forrand, whichever comes first. StillO(n)time and space; it saves a walk, not memory.
Where it goes wrong
- Keying the map by value. Two nodes holding
3collide, and somerandpointers end up at the wrong copy. Key by the node object. - Pointing into the original.
clone[n].rand = n.randlooks right when you print values and is wrong by definition. The random check catches it withshares_nodes. - Forgetting to unweave. The interleaving version mutates the input while it runs. If step three is skipped or buggy, the caller's list is left corrupted, which is why the test compares the original's shape afterwards.
- Reaching for
deepcopyin production. It recurses throughnext, so a long list overflows the stack: on the Python 3.9 used here, a 400-node list already raisedRecursionError.
How to say it in an interview
"The random pointer can aim at a node I haven't copied yet, so I split the work. Pass one creates a bare copy of every node and stores original-to-copy in a hash map, with None mapped to None. Pass two sets each copy's next and rand by looking up the originals' pointers in the map. That's O(n) time and O(n) extra space. If space matters, I can weave each copy right after its original, so a node's copy is its next, set rand as n.rand.next, and unweave, restoring the input: O(1) extra space."
The same "identity, not value" mapping is how graph cloning works, one step up from hash table basics. Pointer rewiring in place is practised in reversing a linked list.