Finger Tree: Versatile Functional Data Structure
Finger trees are a purely functional data structure introduced by Ralf Hinze and Ross Paterson in 2006. They solve a problem that plagues most functional data structures: how do you get efficient...
Key Insights
- Finger trees provide O(1) amortized access to both ends and O(log n) concatenation, making them the Swiss Army knife of functional data structures
- The “measured” abstraction lets you build sequences, priority queues, and interval trees from the same underlying structure by swapping out a monoid
- While theoretically elegant, finger trees carry real memory overhead—use them when you need their specific combination of operations, not as a default choice
What Are Finger Trees?
Finger trees are a purely functional data structure introduced by Ralf Hinze and Ross Paterson in 2006. They solve a problem that plagues most functional data structures: how do you get efficient access to both ends of a sequence while maintaining immutability?
Linked lists give you O(1) access to the head but O(n) to the tail. Balanced trees give you O(log n) everywhere but nothing better. Finger trees give you the best of both worlds: O(1) amortized access to either end, O(log n) access to the middle, and O(log n) concatenation.
The name comes from the idea of “fingers” pointing to the ends of the structure. Imagine holding a rope with both hands—you have immediate access to what’s under your fingers, but reaching the middle requires more work.
The Anatomy of a Finger Tree
A finger tree is a recursive structure with three possible shapes:
- Empty - contains nothing
- Single - contains exactly one element
- Deep - contains a prefix digit, a spine, and a suffix digit
The key insight is the “digit” concept. A digit holds 1 to 4 elements, providing a buffer that absorbs operations before they propagate deeper into the tree. The spine is itself a finger tree, but of nodes rather than elements—each node groups 2 or 3 elements together.
data FingerTree a
= Empty
| Single a
| Deep (Digit a) (FingerTree (Node a)) (Digit a)
data Digit a
= One a
| Two a a
| Three a a a
| Four a a a a
data Node a
= Node2 a a
| Node3 a a a
This recursive structure creates a fascinating property: as you descend into the spine, each level contains elements that are exponentially farther from the ends. The first level of the spine contains nodes representing positions 5-8 from either end. The second level represents positions roughly 13-24. This logarithmic layering is what enables efficient middle access.
Think of it like a nested Russian doll, where each inner doll holds progressively more elements bundled together.
Amortized Complexity and Lazy Evaluation
The O(1) amortized bound for end operations relies critically on lazy evaluation. When you push an element onto a full digit (one with four elements), you need to convert three elements into a node and push that node onto the spine. This push might cascade further up.
In a strict language, this cascade could cost O(log n) in the worst case. But with laziness, the cascade is suspended—you build up a “debt” of deferred computations. The amortization argument shows that this debt is paid off gradually over subsequent operations.
-- The cost of this push is deferred
cons :: a -> FingerTree a -> FingerTree a
cons a Empty = Single a
cons a (Single b) = Deep (One a) Empty (One b)
cons a (Deep (Four b c d e) spine suffix) =
-- This recursive call is lazy!
Deep (Two a b) (cons (Node3 c d e) spine) suffix
cons a (Deep prefix spine suffix) =
Deep (consDigit a prefix) spine suffix
The laziness means that even if you perform n consecutive pushes that all cascade, the actual work is spread across future operations that force the evaluation. This is the banker’s method of amortized analysis applied to persistent data structures.
Core Operations: Push, Pop, and Concatenation
Finger trees support the full deque interface with excellent performance:
-- Add to the left
cons :: a -> FingerTree a -> FingerTree a
-- Add to the right
snoc :: FingerTree a -> a -> FingerTree a
-- View from the left (returns head and tail)
viewl :: FingerTree a -> ViewL a
data ViewL a = EmptyL | a :< FingerTree a
-- View from the right
viewr :: FingerTree a -> ViewR a
data ViewR a = EmptyR | FingerTree a :> a
The viewl operation is where the structure shines:
viewl :: FingerTree a -> ViewL a
viewl Empty = EmptyL
viewl (Single x) = x :< Empty
viewl (Deep (One x) spine suffix) =
x :< deepl (viewl spine) suffix
viewl (Deep prefix spine suffix) =
headDigit prefix :< Deep (tailDigit prefix) spine suffix
-- Helper to reconstruct when prefix becomes empty
deepl :: ViewL (Node a) -> Digit a -> FingerTree a
deepl EmptyL suffix = digitToTree suffix
deepl (node :< spine') suffix =
Deep (nodeToDigit node) spine' suffix
When the prefix runs out of elements, we pull a node from the spine and convert it back into a digit. This maintains the invariant that a Deep tree always has non-empty digits.
Concatenation is where finger trees truly distinguish themselves. Most functional sequences require O(n) for concatenation, but finger trees achieve O(log n):
(><) :: FingerTree a -> FingerTree a -> FingerTree a
Empty >< tree = tree
tree >< Empty = tree
Single x >< tree = cons x tree
tree >< Single x = snoc tree x
Deep pr1 sp1 sf1 >< Deep pr2 sp2 sf2 =
Deep pr1 (addDigits sp1 sf1 pr2 sp2) sf2
The addDigits function handles the middle elements—combining the suffix of the left tree with the prefix of the right tree, bundling them into nodes, and recursively concatenating the spines.
The Power of Monoidal Annotations
Here’s where finger trees become genuinely powerful. By annotating each node with a monoidal “measure,” you can build radically different data structures from the same skeleton.
class Monoid v => Measured a v where
measure :: a -> v
data FingerTree v a
= Empty
| Single a
| Deep v (Digit a) (FingerTree v (Node v a)) (Digit a)
Each Deep node caches the combined measure of all its elements. This cache is maintained during operations and enables O(log n) splitting based on any monotonic predicate over the measure.
Example 1: Indexed Sequences
newtype Size = Size Int
deriving (Semigroup, Monoid) via Sum Int
instance Measured (Element a) Size where
measure _ = Size 1
-- Now we can split at any index in O(log n)
splitAt :: Int -> Seq a -> (Seq a, Seq a)
splitAt i tree = split (\(Size s) -> s > i) tree
Example 2: Priority Queues
newtype Priority a = Priority (Maybe a)
instance Ord a => Semigroup (Priority a) where
Priority Nothing <> p = p
p <> Priority Nothing = p
Priority (Just x) <> Priority (Just y) =
Priority (Just (min x y))
instance Ord a => Monoid (Priority a) where
mempty = Priority Nothing
-- Extract minimum in O(log n) by splitting where
-- the cached minimum equals the element's priority
Example 3: Interval Trees
data Interval = Interval Int Int
data IntervalMeasure = IM
{ low :: Int
, high :: Int
}
instance Semigroup IntervalMeasure where
IM l1 h1 <> IM l2 h2 = IM (min l1 l2) (max h1 h2)
The same split operation that gives you indexed access also gives you interval queries—you just change the monoid.
Practical Applications
Text Editor Buffers: Finger trees make excellent rope data structures. Each leaf holds a chunk of text, measured by character count. Cursor movement, insertion, and deletion all become O(log n) operations, and you get efficient undo through persistence.
Sequences in Haskell: The Data.Sequence module in Haskell’s containers library is a finger tree. It’s the go-to choice when you need both random access and efficient concatenation.
import qualified Data.Sequence as Seq
-- O(log(min(i, n-i))) indexing
element = seq `Seq.index` 1000
-- O(log(min(n, m))) concatenation
combined = seq1 Seq.>< seq2
-- O(1) access to ends
first = Seq.viewl seq
last = Seq.viewr seq
Search Trees: By measuring elements with their keys and using an ordered monoid, finger trees can implement search trees with O(log n) lookup and the bonus of O(log n) merge operations.
Trade-offs and When to Use Finger Trees
Finger trees aren’t free. They carry significant memory overhead—each element is wrapped in node structures with cached measures, and the tree structure itself consumes space. For small collections, a simple list or vector will outperform them.
The constant factors are also substantial. An array-backed vector will beat a finger tree for random access in practice, even though both are O(log n) or better. The finger tree wins when you need the combination of operations: efficient ends, efficient concatenation, and persistence.
Use finger trees when:
- You need a persistent sequence with efficient concatenation
- You’re building a text editor or similar buffer structure
- You want one data structure that adapts to different access patterns via measures
- You’re in a lazy functional language where the amortization works correctly
Avoid finger trees when:
- You’re in a strict language without careful thunk management
- You only need stack or queue operations (use simpler structures)
- Memory is constrained
- You need cache-friendly sequential access (use arrays)
In Haskell, reach for Data.Sequence when you outgrow lists. In Scala, the standard library’s Vector uses a related structure (relaxed radix balanced trees) that offers similar trade-offs. In other languages, you’ll likely need a third-party library or your own implementation—and you should carefully consider whether the complexity is worth it for your use case.
Finger trees represent a beautiful intersection of theory and practice. They’re not always the right choice, but when you need their specific combination of properties, nothing else comes close.