DSA DSA In Depth
0%
lesson persistent-data-structurespath-copyingimmutability

Persistent Data Structures: Path Copying, Fat Nodes & Fractional Cascading Time Travel

7 min read Module 3.2: Self-Balancing, Augmented, Disk-Backed & Persistent Trees Updated Oct 23, 2026
📄 Cheat Sheet
MIT 6.851 Advanced Data Structures Lecture 1: Persistent Data Structures
MIT OpenCourseWare MIT 6.851 Advanced Data Structures Lecture 1: Persistent Data Structures Watch on YouTube ↗

Erik Demaine covers Partial, Full, Confluent, and Functional Persistence, Path Copying vs Sleator-Tarjan Node Splitting, and planar point location via persistent BSTs.

0:00 / --:--

Path Copying vs Fat Nodes vs Sleator–Tarjan Node-Splitting Method

Driscoll, Sarnak, Sleator, and Tarjan (1989) formalized four levels of persistence and three structural implementation techniques:

  1. Path Copying (Functional / Purely Immutable Persistence — Okasaki 1998): Every node is strictly immutable. Modifying a leaf allocates a new leaf and recursively allocates a new copy of each ancestor up to a new root roots[version], sharing all unmodified sibling subtrees. Achieves Full Persistence with $O(\log N)$ time and $O(\log N)$ space per update, and zero synchronization locks across concurrent reader threads!
  2. Fat Nodes: Instead of copying ancestor nodes, allow each node to store an arbitrarily long version-stamped modification history mods = [(v₁, field, val₁), (v₂, field, val₂), ...]. Uses $O(1)$ space per update, but looking up a pointer at version $v$ requires binary searching the node's modification log, slowing down every pointer dereference by $O(\log M)$!
  3. Sleator–Tarjan Bounded In-Degree Node Splitting (1989): Combines the $O(1)$ space of Fat Nodes with the $O(1)$ pointer traversal of Path Copying! For any pointer-based data structure where every node has bounded in-degree $\le k$ (for a tree without parent pointers, in-degree is $1$), give each node $2k$ extra modification slots (mods). As long as a node has a free mod slot, updates take $O(1)$ space without copying; when a node overflows its $2k$ slots, split (clone) the node and propagate pointer updates to its $\le k$ predecessors. By potential analysis with $\Phi$ proportional to full mod slots, this achieves $O(1)$ amortized space and $O(1)$ slowdown per pointer step!
Persistence Technique Update Time (BST) Space per Update Query Time at Version $v$ Supports Full / Functional Persistence?
Naive Deep Clone Θ(N) Θ(N) Words O(log N) Yes
Path Copying (Structural Sharing) O(log N) O(log N) Words O(log N) (Zero overhead!) Yes (Purely Functional & Lock-Free)
Fat Nodes (Unbounded Mod Log) O(log N) O(1) Words O(log N · log M) Partial Only (Full needs Dietz-Sleator order list)
Sleator–Tarjan Node Splitting O(log N) Amortized O(1) Amortized! O(log N) Yes (Partial & Full via Order Maintenance)

Why Parent Pointers Break Path Copying (In-Degree Explosion)

Why must purely functional persistent trees omit parent pointers? In a downward-directed binary tree, every node has in-degree $1$ (only its parent points to it), so replacing a node $u$ with $u'$ only requires updating 1 pointer in $u$'s parent (propagating $O(h)$ steps up to the root). If every child also stores a parent pointer back up to $u$, then $u$ has in-degree $3$ (its parent and both its children point to $u$)! Updating $u$ to $u'$ would force cloning $u$'s children so their parent pointers point to $u'$, which in turn forces cloning their children, cascading across all $\Theta(N)$ nodes of the entire tree!

Persistent BST Path-Copying & Structural Sharing Multi-Version DAG
O(log N) Space/Time per Version · Immutable V0..Vt

Trace how functional path-copying allocates only O(log N) new nodes along the root-to-insertion spine while sharing all unmodified subtrees via immutable child pointers (as used in Git trees, Clojure, and ZFS/Btrfs CoW).

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Immutability Rule: No existing node in V0 may ever be mutated in-place. Search path for 25 visits spine: 20 → 30 → 22 → right(null).
V0:20 10 30 22 40 Immutable Snapshot V0 (5 Nodes) Goal: V1 = insert(V0, 25)
New Nodes Allocated
0 New Nodes
Structural Sharing
5 / 5 Existing Nodes
Active Snapshots
V0 = {10, 20, 22, 30, 40}
[Step 1] Immutability Rule: No existing node in V0 may ever be mutated in-place. Search path for 25 visits spine: 20 → 30 → 22 → right(null).
Version 0 (Root V₀): Immutable 8 4 12 2 6 Version 1 = insert(V₀, 5): Clones ONLY 3 spine nodes! 8' 4' 6' 5 ╌╌ Structural Sharing Edges: • 8'.right points directly to V₀'s subtree (12) • 4'.left points directly to V₀'s subtree (2) Applications: • Git Commit Trees (Merkle DAG Path Copying) • ZFS / Btrfs Copy-on-Write (CoW) B-Trees
flowchart LR Call["insertPersistent(oldNode, key)"] --> Null{"oldNode == null ?"} Null -- "Yes" --> Base["return new Node(key, null, null)"] Null -- "No" --> Branch{"key < oldNode.key ?"} Branch -- "Yes (Go Left)" --> CloneL["return new Node(oldNode.key, insertPersistent(oldNode.L, key), oldNode.R) // Share oldNode.R!"] Branch -- "No (Go Right)" --> CloneR["return new Node(oldNode.key, oldNode.L, // Share oldNode.L! insertPersistent(oldNode.R, key))"]

Hardware Reality: Copy-on-Write (CoW) Filesystems (ZFS, Btrfs, LMDB) & Bump Arena Allocators

Path Copying is the foundational architecture of modern Copy-on-Write filesystems (ZFS, Btrfs, Apple APFS), MVCC embedded databases (LMDB), and functional languages (Clojure/Scala persistent vectors, Git Merkle trees). Because nodes in a Path-Copying tree are immutable once allocated, we never need per-node malloc/free overhead in batch workloads: allocating all version nodes out of a contiguous bump arena pool (std::vector<Node> pool) packs each version's $O(\log N)$ spine nodes side-by-side in a single CPU cache line!

Implementation: Arena-Backed Persistent Treap with $O(\log N)$ Path-Copying Split/Merge

// C++20: Purely Functional Persistent Treap using Contiguous Bump-Arena Path Copying
#include <vector>
#include <cstdint>
#include <utility>

class PersistentTreapArena {
    struct Node {
        int key;
        uint64_t prio;
        int left = 0, right = 0; // 0 = null sentinel index in pool
    };
    std::vector<Node> pool;

    int cloneNode(int u) {
        pool.push_back(pool[u]);
        return static_cast<int>(pool.size()) - 1;
    }

public:
    PersistentTreapArena() { pool.push_back({0, 0, 0, 0}); } // Index 0 is null

    int newNode(int key, uint64_t prio) {
        pool.push_back({key, prio, 0, 0});
        return static_cast<int>(pool.size()) - 1;
    }

    // Non-destructively splits version root 'u' into (L, R) by cloning only the O(log N) spine
    std::pair<int, int> splitPersistent(int u, int key) {
        if (u == 0) return {0, 0};
        int copy = cloneNode(u);
        if (pool[copy].key <= key) {
            auto [L, R] = splitPersistent(pool[copy].right, key);
            pool[copy].right = L;
            return {copy, R};
        } else {
            auto [L, R] = splitPersistent(pool[copy].left, key);
            pool[copy].left = R;
            return {L, copy};
        }
    }
};

Failure Modes & Adversarial Pitfalls

  • Mutating a Shared Child Pointer in Place Corrupts All Historical Versions: In a persistent tree, a single subtree node may be shared by thousands of historical roots $V_0, V_1, \dots, V_k$. Writing node->left = ... directly on an existing node instead of on cloneNode(node) silently mutates the past!
  • Splay Trees Cannot Be Made Persistently Efficient via Path Copying: Because splay(x) mutates pointers on every read (find) operation, path-copying a Splay Tree would allocate $O(\log N)$ new memory on every read query! Always use persistent Treaps, AVL trees, Red-Black trees, or Weight-Balanced trees where reads are 100% non-mutating.
Check yourself Question 1 of 4
Concept Check Path Copying Time and Space Complexity recall

In a purely functional (persistent) balanced binary search tree of height $h = \Theta(\log N)$, how does **path copying** produce a new tree version $T_{v+1}$ when updating or inserting a single key into version $T_v$?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

What are the three architectural benefits of a CoW B+ Tree compared to an in-place B+ Tree with Write-Ahead Logging (ARIES WAL)?

Key Takeaways

Partial Persistence allows querying any historical version; Full Persistence allows querying and mutating any historical version (forming a version tree); Confluent Persistence allows merging historical versions (forming a version DAG).
Path Copying clones only the O(log N) nodes along the modified root-to-leaf spine while sharing all unmodified off-path subtrees, achieving Full Persistence with O(log N) time and space per update.
Parent pointers must be omitted in Path-Copying trees because bidirectional edges increase node in-degree to 3, causing pointer updates to cascade across all Θ(N) nodes.
Driscoll, Sarnak, Sleator, and Tarjan's Node-Splitting method achieves O(1) amortized space per update on any pointer structure with bounded in-degree.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue