skip to content

How do you implement a correct deep copy of an object graph that contains cycles and objects referenced from more than one place?

level: seniorimportance: should knowfreq 45%

answer

  1. Identity map original → copy (memo)
  2. Register the shell BEFORE recursing
  3. Identity hashing, not value equality
  4. Two-phase: allocate shells, then wire
  5. Cut set: stop at services, resources, identity

basics

~20 s

Carry a map from each already-copied original object to its copy. Before copying anything, look it up: if it is in the map, reuse that copy instead of recursing. This stops infinite loops on cycles and keeps shared objects shared in the copy.

solid answer

~60 s

Naive recursive copying fails twice on real graphs: a cycle (`a.next = b; b.prev = a`) recurses forever, and an object referenced from two places gets duplicated into two copies, so structure that was shared in the original becomes unshared in the copy — silently changing behaviour. Both are fixed by an **identity map** (also called a memo or visited map): a dictionary keyed by *reference identity*, not by equality, mapping original → copy. The algorithm is: on entry, look up the original; if present, return the stored copy. Otherwise allocate the shell of the copy, **insert it into the map before copying any fields**, then fill the fields recursively. Registering before recursing is what breaks the cycle — when recursion comes back around, the partially-built copy is already findable. Because equality may be value-based, the map must use identity hashing, or a cycle of `equal` nodes will collapse. For very deep graphs, convert the recursion to an explicit work-stack to avoid stack overflow. The same identity map is what serialization-based cloning and generic deep-copy library functions use internally.

code

pseudocode · 11 lines
pseudocode
deepCopy(obj, seen /* identity map */):
    if obj == null or isImmutable(obj): return obj
    if isSharedService(obj) or isResource(obj): return obj   // cut set
    if seen.has(obj): return seen.get(obj)                    // cycle + diamond
    copy = allocateBlank(typeOf(obj))
    seen.put(obj, copy)                                       // BEFORE fields
    for f in fieldsOf(obj):
        copy[f] = deepCopy(obj[f], seen)
    return copy

// a.next = b; b.prev = a  ->  a'.next.prev === a'   (cycle reproduced, not duplicated)

go deeper

for a junior

Recognize that cycles cause infinite recursion and that a visited/seen set is the fix.

for a middle

Give the identity-map algorithm and state that registration must precede field copying; mention preserving shared references.

for a senior

Add identity vs equality keying, iterative traversal for deep graphs, the cut set for services/resources/identity, cost O(nodes+edges), and the three tests.

for a principal

Question whether unbounded deep copy should exist at all: prefer explicit aggregate boundaries, immutable value objects, or serialization at a defined schema edge, and treat copy scope as an architectural contract.

### The problem in concrete terms A deep copy of `root` means: produce `root'` such that the subgraph reachable from `root'` is structurally the same as that from `root`, but shares no mutable object with it. Two graph features break the obvious recursive implementation. **1. Cycles.** `a.next = b; b.prev = a`. Copying `a` copies `b`, which copies `a`, which copies `b`… → infinite recursion, stack overflow. **2. Diamonds (shared references).** `a.left = c; a.right = c` — one `c` reachable by two paths. Naive recursion copies `c` twice, so `a'.left !== a'.right`. Nothing crashes; the copy simply has *different sharing structure* than the original. Later, `a'.left.set(...)` no longer shows up in `a'.right`, and behaviour diverges from the original in a way that is very hard to trace back to the clone. So correctness has two requirements: **terminate**, and **preserve the sharing topology** — equal-by-identity originals must map to equal-by-identity copies. ### The identity map algorithm ``` function deepCopy(obj, seen): # seen: identity-keyed map original -> copy if obj is null or immutable: return obj if seen.contains(obj): return seen.get(obj) # (A) cycle + sharing fix copy = allocateEmptyLike(obj) # shell, no fields yet seen.put(obj, copy) # (B) BEFORE recursing for each field f in obj: copy.f = deepCopy(obj.f, seen) return copy ``` The two load-bearing lines are (A) and (B). Line (B) must come *before* the field loop; if you fill fields first and register afterwards, a cycle re-enters before the entry exists and you are back to infinite recursion. This requires the ability to allocate an object without fully initializing it — hence why generic deep-copy utilities bypass constructors, and why constructor-based copying (`new Node(deepCopy(child))`) cannot handle cycles without a two-phase approach: allocate all shells, then link them. ### Identity, not equality The map must key on **reference identity** ("is this the same object?"), not on user-defined equality ("do these look the same?"). If two distinct nodes are `equal` by value, an equality-keyed map treats them as one and the copy collapses them into a single shared node — a subtle structural corruption. Languages provide this as identity hash maps / reference-equality dictionaries. A second reason: user-defined equality on a cyclic structure may itself recurse forever, or may depend on fields not yet filled in during copying. ### Depth and stack A 100 000-node linked list will overflow the call stack. The fix is to make the traversal iterative: 1. Push `root` on a work stack. 2. Pop an object; if unseen, allocate its shell, register it, and push its referenced objects. 3. Second pass (or same pass, filling by looking up the map): assign each copy's fields from the map. The two-phase "allocate all shells, then wire them" formulation is often clearer than one-phase recursion and is naturally cycle-safe. ### What still must be decided per field The identity map solves *structure*; it does not solve *policy*. You still choose which references to follow at all: - **Immutable values** → return as-is; no need to copy, and skipping them keeps the map small. - **Shared services** (logger, connection pool, configuration) → share, do not follow. A generic deep copier that follows every reference will happily clone half the application, because a single back-reference to a container or context object makes the entire heap reachable. This is the number-one practical failure of "just deep copy everything". - **External resources** (sockets, file handles, locks, threads) → cannot be copied; share, reset, or refuse. - **Identity fields** → reset after copying. A good implementation therefore combines the identity map with a **cut set**: an explicit rule for where the copy stops. Without a cut set, transitive reachability makes deep copy unbounded in practice. ### Cost Time and space are O(nodes + edges) in the copied region, plus the map itself (one entry per copied node). Deep copying inside a hot loop is a common performance surprise; if you need it often, consider copy-on-write or immutable/persistent structures, which give independence without traversal. ### Testing it Three assertions catch nearly everything: 1. Mutate any part of the copy → the original is unchanged (and vice versa). 2. Sharing preserved: if `a.left === a.right` in the original, then `a'.left === a'.right` in the copy. 3. Cyclic input terminates and the cycle is reproduced: `a'.next.prev === a'`.

  • Why must the identity map be inserted into before the fields are copied rather than after?
    Because a cycle re-enters the object while its fields are still being filled. If the entry only appears after the field loop, the re-entrant call finds nothing in the map and recurses again — infinite recursion. Registering the empty shell first means the recursive call returns the (still incomplete) copy, which is fine since it will be filled in by the outer frame before anyone uses it.
  • What goes wrong if the map is keyed by the objects' equals/hashCode instead of reference identity?
    Distinct-but-equal nodes are treated as the same object, so the copy collapses them into one shared instance and loses structure. Worse, user-defined equality on cyclic or partially-initialized objects can itself recurse forever or read fields that are not yet assigned.

Copying a city map where roads loop back on themselves. You keep a ledger: "I already drew Main Street as line #7." When a road leads you back to Main Street, you don't draw it again — you connect to line #7. Without the ledger you either draw forever, or end up with two separate Main Streets that no longer connect the same places.

saying these in an interview costs you the question

  • "Just recurse over the fields" — diverges on cycles and duplicates shared nodes.
  • Registering the copy in the memo after copying fields instead of before.
  • Using a value-equality map instead of identity hashing.
  • Believing a generic deep copy is bounded — one back-reference to a context object makes the whole heap reachable.
  • Ignoring stack depth on long chains instead of using an explicit work stack.
  • Believing the copy is correct because it "looks right", without asserting that preserved sharing (a.left === a.right) survives.

context