Rope
A rope is a representation of a long string as a binary tree whose leaves are ordinary strings and whose internal nodes stand for the concatenation of their two children. Each node caches the length of its left subtree, its weight, so a position in the text can be found by descending the tree. Concatenating two ropes creates a single node and copies no characters, and indexing, splitting, inserting and deleting at any position take time logarithmic in the length of a balanced rope, where a flat string needs time linear in its length for all but indexing. Ropes are usually immutable, so versions of a text share all unchanged parts, which gives cheap undo. Boehm, Atkinson and Plass described them in 1995, after their use in the Cedar environment at Xerox PARC[1].
Structure
A leaf holds a string. An internal node holds two ropes and the length of the left one; the text of a node is the text of its left child followed by that of its right child. The text of the whole rope is read from the leaves left to right:
To find the character at position , start at the root: if is less than the weight go left, otherwise subtract the weight and go right. At a leaf, index the string directly. The time is proportional to the depth of the tree.
Operations
Concatenation makes a new node with the two ropes as children. Splitting at position descends to the leaf containing , splits that leaf, and on the way back up combines the pieces to the left of the path into one rope and those to the right into another. Insertion and deletion are two splits and concatenations:
For a balanced rope of length , indexing, splitting, insertion and deletion take time, and concatenation , or if the result is rebalanced. A flat string needs for each of these except indexing.
A persistent rope in OCaml, tested against a flat string under 20,000 random insertions and deletions.
(* A rope: a binary tree whose leaves are strings. Each node caches thelength of its left subtree (its weight), its total length and depth. *)type t = Leaf of string | Node of { left : t; right : t; weight : int; len : int; depth : int }let length = function Leaf s -> String.length s | Node n -> n.lenlet depth = function Leaf _ -> 0 | Node n -> n.depthlet short = 64 (* leaves shorter than this are merged when concatenated *)let node l r = Node { left = l; right = r; weight = length l; len = length l + length r; depth = 1 + max (depth l) (depth r) }let concat a b =match (a, b) with| Leaf "", x | x, Leaf "" -> x| Leaf x, Leaf y when String.length x + String.length y <= short -> Leaf (x ^ y)| Node { left; right = Leaf y; _ }, Leaf z when String.length y + String.length z <= short -> node left (Leaf (y ^ z))| _ -> node a b(* The character at position i: go left if i is below the weight. *)let rec get r i = match r with Leaf s -> s.[i] | Node n -> if i < n.weight then get n.left i else get n.right (i - n.weight)(* Split into the first i characters and the rest. *)let rec split r i =match r with| Leaf s -> (Leaf (String.sub s 0 i), Leaf (String.sub s i (String.length s - i)))| Node n ->if i < n.weight then let a, b = split n.left i in (a, concat b n.right)else if i > n.weight then let a, b = split n.right (i - n.weight) in (concat n.left a, b)else (n.left, n.right)let insert r i s = let a, b = split r i in concat (concat a (Leaf s)) blet delete r i k = let a, b = split r i in let _, c = split b k in concat a clet rec leaves r acc = match r with Leaf "" -> acc | Leaf s -> s :: acc | Node n -> leaves n.left (leaves n.right acc)let to_string r = String.concat "" (leaves r [])(* Rebuild as a balanced tree from the leaves. *)let balance r =let a = Array.of_list (leaves r []) inlet rec build lo hi = if hi - lo = 1 then Leaf a.(lo) else let mid = (lo + hi) / 2 in node (build lo mid) (build mid hi) inif Array.length a = 0 then Leaf "" else build 0 (Array.length a)(* Boehm, Atkinson and Plass: a rope of depth d is balanced if its lengthis at least Fib (d + 2). *)let fib = let a = Array.make 90 1 in for i = 2 to 89 do a.(i) <- a.(i - 1) + a.(i - 2) done; alet is_balanced r = length r >= fib.(depth r + 2)
Running it.
after 5000 edits: length 9711, depth 13, 471 leaves, equal to the string: true, get agrees: trueafter 10000 edits: length 18561, depth 18, 950 leaves, equal to the string: true, get agrees: trueafter 15000 edits: length 27244, depth 19, 1303 leaves, equal to the string: true, get agrees: trueafter 20000 edits: length 36543, depth 18, 1918 leaves, equal to the string: true, get agrees: true
Concatenating two short leaves copies them into one leaf, which keeps the tree from filling up with single characters: a rope's leaves should be short enough that copying one is cheap, and long enough that most of the text is in contiguous memory. Every edit copies only the nodes on the paths it touches, so the version before an edit remains intact, and an editor can keep every previous version for undo at a cost proportional to the edits[7].
Balance
Repeated concatenation at one end produces a tree as deep as it is long. Boehm, Atkinson and Plass call a rope of depth balanced if its length is at least the Fibonacci number , which bounds the depth by about , and rebuild a rope when an operation leaves it unbalanced[1]:
Their rebalancing inserts the leaves into an array of slots indexed by Fibonacci length, concatenating as it goes; the implementation above rebuilds a perfectly balanced tree from the leaves instead, which is simpler and has the same bound. Alternatively, a rope can be kept balanced at all times by storing it in a balanced tree such as a red-black tree or a B-tree, as most modern implementations do.
Building strings
Building a long text by repeated appending is quadratic with immutable strings, since every append copies the text so far. A rope appends by creating a node:
Appending 100-byte pieces to a flat string and to a rope.
(* Building a text by appending pieces. With flat strings every appendcopies the whole text so far; with a rope an append creates one node. *)let copied = ref 0let string_append a b = copied := !copied + String.length a + String.length b; a ^ btype t = Leaf of string | Node of t * t * intlet len = function Leaf s -> String.length s | Node (_, _, n) -> nlet rope_append a b = Node (a, b, len a + len b)
Running it.
pieces text size bytes copied (string) nodes (rope)1000 100000 50050000 10002000 200000 200100000 20004000 400000 800200000 40008000 800000 3200400000 8000
Doubling the number of pieces quadruples the bytes copied into strings, which reach 3.2 GB for an 800 kB text, while the rope creates one node per piece. Mutable buffers that grow by doubling, such as OCaml's Buffer and Java's StringBuilder, also make appending linear, but they do not help with insertions in the middle, and the result is not persistent. The appended rope here is a list leaning to one side and would need rebalancing before indexing.
Text editors
An editor needs fast insertion and deletion at the cursor, fast access to any line, and, for undo, access to earlier versions. Three structures are common. A gap buffer keeps the text in one array with a gap at the cursor, so edits at the cursor are constant time and moving the cursor far costs a copy proportional to the distance; Emacs uses one[3]. A piece table keeps the original file unchanged and a log of added text, and represents the document as a sequence of pieces pointing into the two[4]; Visual Studio Code stores its pieces in a balanced tree[8]. A rope stores the text itself in a balanced tree, and caches in each node not only lengths but other monoidal summaries such as the number of line breaks, so that the position of the th line is found by the same descent; the xi editor was built on this[5].
A rope whose nodes cache a monoidal summary of their subtree is an instance of the same idea as the finger tree, which is parameterized by the monoid of measures[6].
History
Ropes were used in the Cedar programming environment at Xerox PARC in the 1980s, where the string type of the Cedar language was implemented as a tree of immutable pieces[1]. Boehm, Atkinson and Plass described the data structure, its balance condition and its implementation, including a C version called cords, in 1995[1]. A rope class was included in the SGI implementation of the C++ Standard Template Library[2]. Ropes became common again in the 2010s as the text representation of editors, where their persistence suits undo and concurrent access to the text[5].
see also
- Finger treeA persistent sequence with amortized constant-time access at both ends, and concatenation and splitting in logarithmic time. The ends are kept in buffers of one to four elements, and the middle is a finger tree of 2-3 nodes, one level deeper at each step down the spine.
- MonoidA monoid is a set with an associative binary operation and an identity element for it: integers under addition with 0, strings under concatenation with the empty string, functions under composition with the identity. Associativity means a sequence can be combined with any bracketing, so a fold over a monoid can be split into parts and computed in parallel or incrementally. Lists are the free monoid, and every monoid-valued function on elements extends uniquely to lists.
- TrieA trie, or prefix tree, is a tree for a set of strings in which each edge is labelled with a character and each key is the path from the root to a node; keys with a common prefix share the nodes of that prefix. Lookup and insertion take time proportional to the length of the key, independent of the number of keys, and all keys with a given prefix are found in one subtree, in sorted order. Radix and Patricia trees compress chains of single-child nodes, and the same idea applies to integers, read bit by bit.
- Hash array mapped trieA hash array mapped trie (HAMT) is a trie over the bits of the keys' hash values: each level consumes a few bits, usually five, to choose one of 32 children, and each node stores only its occupied children in a compact array, located by counting bits in a 32-bit bitmap. Lookups follow about log32 n levels. Updated by copying the path to the changed leaf, a HAMT is a persistent hash map with small, cache-friendly nodes, and it is the standard implementation of immutable maps and sets in Clojure, Scala and Haskell's unordered-containers.
- ZipperA zipper represents a data structure together with a focus, a position inside it: the substructure at the focus, and the path from the focus back to the root together with everything to either side of that path. Moving the focus one step and editing at the focus take constant time, and the structure is persistent, so edits share everything off the path. The type of paths is the derivative of the structure's type, in the sense of calculus.
further reading
- [1]H.-J. Boehm, R. Atkinson, M. Plass, “Ropes: an alternative to strings”, Software: Practice and Experience 25 (1995).
- [2]Silicon Graphics, Standard Template Library Programmer’s Guide, “rope<T, Alloc>” (1990s).
- [3]C. A. Finseth, The Craft of Text Editing: Emacs for the Modern World, Springer (1991).
- [4]C. Crowley, “Data structures for text sequences”, Technical Report, University of New Mexico (1998).
- [5]R. Levien, “Rope science”, xi-editor documentation (2017).
- [6]R. Hinze, R. Paterson, “Finger trees: a simple general-purpose data structure”, Journal of Functional Programming 16 (2006).
- [7]C. Okasaki, Purely Functional Data Structures, Cambridge University Press (1998).
- [8]P. Lyu, “Text buffer reimplementation”, Visual Studio Code blog (2018).
last updated