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:
- 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! - 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)$! - 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!
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).
Invariant & State
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 oncloneNode(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.