Back to Data Structures

Functional Queues / Finger Trees

October 18, 2026 Wasil Zafar 21 min read

Concepts, invariants, and Python, C++, and Java implementations of Functional Queues / Finger Trees

Table of Contents

  1. Why Functional Queues / Finger Trees Matters
  2. Core Concepts and Terminology
  3. How Functional Queues / Finger Trees Works, Step by Step
  4. Worked Example with a Full Trace
  5. Implementation in Python, C++, and Java
  6. Complexity, Correctness, and Invariants
  7. Variants, Pitfalls, and Optimizations
  8. Practice Problems and Real-World Use

Why Functional Queues / Finger Trees Matters

In purely functional programming and persistent data structure design, imperative in-place mutations of head and tail array indices are prohibited. Standard persistent singly linked lists support $O(1)$ head insertion and removal (push_front and pop_front), but appending an element to the back requires traversing or copying the entire list in $O(N)$ time. When implementing persistent breadth-first search (BFS), task schedulers, undo/redo logs, or immutable sequence abstractions (such as Haskell's Data.Sequence or Scala's immutable collections), maintaining pure persistent queues with $O(1)$ operational bounds is an essential requirement.

Functional Queues (such as Okasaki's Banker's Queue) solve this problem by splitting the queue into two singly linked lists: a front list for dequeuing and a rear list for enqueuing. New elements are prepended to rear in $O(1)$ time, while removals extract elements from front in $O(1)$ time. When front becomes empty, the algorithm reverses rear and assigns it to front in $O(N)$ time. Because every enqueued element is reversed at most once during its lifetime, the amortized cost per operation is strictly $O(1)$. Finger Trees generalize this two-list architecture into a deeply nested tree structure with 2-3 node "fingers" at the prefix and suffix, extending $O(1)$ amortized access to both ends while enabling $O(\log N)$ concatenation and splitting.

Functional Queues achieve $O(1)$ amortized operational bounds without mutation by pairing a front list for output with a rear list for input. When front exhausts its elements, rear is reversed to replenish front, guaranteeing that every element is moved at most once. Finger Trees generalize this pattern into a flexible persistent sequence supporting $O(1)$ double-ended operations and $O(\log N)$ concatenation.

Core Concepts and Terminology

Understanding functional queues and finger trees requires formalizing persistent, immutability-preserving queue invariants.

  1. Front List ($F$): A singly linked list containing elements in dequeue order. The head of $F$ is the front of the queue.
  2. Rear List ($R$): A singly linked list containing recently enqueued elements in reverse arrival order. New elements are prepended to $R$.
  3. Rebalance / Pivot Step: When $F$ becomes empty during a dequeue operation, $R$ is reversed ($F' = \text{reverse}(R)$) and $R$ is reset to empty ($R' = \text{nil}$).
  4. Structural Sharing: The property wherein new persistent queue versions reuse nodes from existing versions without duplicating unchanged memory.
  5. Finger Tree Digits (Prefix/Suffix): In 2-3 Finger Trees, the left (prefix) and right (suffix) digits hold 1 to 4 elements acting as "fingers" into the ends of the sequence, while the deep spine recursively holds nested 2-3 tree nodes.
Structural sharing: a new version reuses the frozen nodes of the old version Version one stores a rear list of forty then thirty, frozen after construction. Creating version two by enqueuing fifty allocates a single new node whose pointer targets the existing forty node, so the entire tail is shared and version one is never modified. Structural sharing: V₂ reuses V₁'s frozen nodes VERSION V₁: REAR = [40, 30] VERSION V₂: REAR = [50, 40, 30] 40 30 nil Both nodes are frozen after creation V₁ remains a fully usable queue Creating later versions never writes into these allocations. Old versions stay intact. 50 40 shared 30 Enqueue(50) allocates only node 50 Its next pointer targets V₁'s node 40 The entire tail is reused, so V₂ costs O(1) memory. V₁ untouched; V₂ shares the tail. New Rear List V₂: node 50 points to [40] in Version V₁. Immutability guarantees that V₁ remains completely unchanged when V₂ is created.
TermDefinitionRole in AlgorithmExample
Front ListSingly linked list storing head elements in dequeue order.Direct extraction of front element via Front.head.Front = [10, 20, 30]
Rear ListSingly linked list storing tail elements in reverse arrival order.Direct $O(1)$ insertion of new items via Rear = Cons(x, Rear).Enqueuing 40 updates Rear to [40, 30].
Rebalance OperationReversing Rear into Front when Front is empty (Front = reverse(Rear)).Replenishes Front list while resetting Rear = nil.Empty Front with Rear = [30, 20] becomes Front = [20, 30], Rear = nil.
Amortized $O(1)$ BoundsAccounting guarantee that individual expensive $O(N)$ reverses average out to $O(1)$.Proves overall efficiency over any sequence of $M$ queue operations.Reversing an $N$-element list costs $N$ steps, paid by $N$ prior $O(1)$ enqueues.
Finger Tree Deep NodeCore recursive node in 2-3 Finger Trees containing a prefix digit, nested spine, and suffix digit.Provides $O(1)$ access at digits and $O(\log N)$ structural split/concat.Deep(Prefix=[1], Spine=..., Suffix=[2, 3])

How Functional Queues / Finger Trees Works, Step by Step

A Functional Queue executes two primary operations: Enqueue and Dequeue.

1. Enqueue Operation ($O(1)$ Worst-Case)

  1. Create a new queue handle (Front, Cons(new_elem, Rear)).
  2. Return the new queue version handle. Memory complexity is $O(1)$ due to pointer sharing.

2. Dequeue Operation ($O(1)$ Amortized)

  1. Empty Check: If both Front and Rear are empty, throw an underflow exception.
  2. Rebalance Check: If Front is empty: Reverse Rear to construct Front' = reverse(Rear). Set Rear' = nil.
  1. Extract Head: The dequeued value is Front.head.
  2. Construct Next Version: The remaining queue version is (Front.tail, Rear).
Dequeue either pops the front directly or performs one amortized reversal When the front list is non-empty the head is popped in constant time. When the front list is empty the rear list is reversed into a new front list, the rear is reset, and the head is popped. The reversal length is paid for by the prior enqueues that added those elements. Dequeue: direct pop or one amortized reversal WITHOUT REBALANCE WITH REBALANCE Front = [20, 30], Rear = [40] Front.head = 20 is popped Next version: ([30], [40]) One O(1) extraction, no copying Direct O(1) pop from Front. Front = [], Rear = [30, 20, 10] Reverse: Front' = [10, 20, 30] Rear' = nil, then pop 10 Cost |R| = 3, pre-paid by 3 enqueues Amortized O(1) per operation. The single O(N) reversal cost is amortized across all previously enqueued elements. Every enqueue banks one credit, and the rebalance spends exactly those banked credits.

The control flow diagram below outlines the operation logic for functional queue pop and enqueue handling:

Functional Queues / Finger Trees control flow
flowchart TD
A[Start: Functional Queue Operation] --> B{Operation Type?}
B -- Enqueue x --> C[New Rear = Cons x, Rear]
C --> D[Return new Queue Version Front, New Rear]
B -- Dequeue --> E{Is Front empty?}
E -- No --> F[Extracted Value = Front.head]
F --> G[Return Extracted Value and Queue Version Front.tail, Rear]
E -- Yes --> H{Is Rear empty?}
H -- Yes --> I[Throw Queue Underflow Exception]
H -- No --> J[New Front = reverse Rear]
J --> K[New Rear = nil]
K --> F

Worked Example with a Full Trace

Let us trace a sequence of operations starting from an empty functional queue $Q_0 = ([], [])$:

  1. $Q_1 = Q_0.\text{enqueue}(10) \implies \text{Front} = [], \text{Rear} = [10]$
  2. $Q_2 = Q_1.\text{enqueue}(20) \implies \text{Front} = [], \text{Rear} = [20, 10]$
  3. $Q_3 = Q_2.\text{enqueue}(30) \implies \text{Front} = [], \text{Rear} = [30, 20, 10]$
  4. $(\text{val}_1, Q_4) = Q_3.\text{dequeue}() \implies$ Front is empty! Rebalance $\text{Rear} \implies \text{Front}' = [10, 20, 30], \text{Rear}' = []$. Pop $10 \implies \mathbf{\text{val}_1 = 10}, Q_4 = ([20, 30], [])$.
  5. $Q_5 = Q_4.\text{enqueue}(40) \implies \text{Front} = [20, 30], \text{Rear} = [40]$
  6. $(\text{val}_2, Q_6) = Q_5.\text{dequeue}() \implies$ Front is non-empty. Pop $20 \implies \mathbf{\text{val}_2 = 20}, Q_6 = ([30], [40])$.
Version trace: every persistent queue handle stays valid A table of versions. Enqueues ten, twenty, and thirty build the rear list. The first dequeue reverses the rear list to replenish the front and returns ten. Enqueue forty then a direct dequeue returns twenty. All versions remain accessible and immutable. Version Trace: Every Queue Handle Stays Valid Ver Operation Front List Rear List Output / Notes Q₀ Initial [] [] Empty queue handle Q₁ enqueue(10) [] [10] O(1) prepend Q₂ enqueue(20) [] [20, 10] O(1) prepend Q₃ enqueue(30) [] [30, 20, 10] O(1) prepend Q₄ dequeue() [20, 30] [] Output = 10 (Rebalanced!) Q₆ dequeue() from Q₅ [30] [40] Output = 20 (Direct O(1)) Each version is an immutable handle that shares list nodes with its neighbors. Q₀ through Q₆ all remain valid simultaneously. Notice that persistent handles Q₀ through Q₆ all remain accessible and immutable. Each version shares unmodified list nodes with its neighbors; operations cost O(1) amortized.
Worked Example

Trace It by Hand

Trace Challenge: Starting with queue version $Q_3$ from the trace above where $\text{Front} = [], \text{Rear} = [30, 20, 10]$:

  1. Suppose we perform two consecutive dequeues from $Q_3$, creating $Q_4$ and $Q_7$.
  2. In $Q_4$, what is the state of Front and Rear immediately after the rebalance step but before popping the head element?
  3. What is the dequeued value and final state of $Q_7 = Q_4.\text{dequeue}()$?
  4. If we go back and perform $Q_{new} = Q_2.\text{dequeue}()$ on the older version $Q_2 = ([], [20, 10])$, does it affect $Q_3$ or $Q_4$? What value is returned?

Answer:

  1. Immediately after rebalance: $\text{Front} = [10, 20, 30]$, $\text{Rear} = []$.
  2. For $Q_7$: Head returned = 20. Remaining state: $\text{Front} = [30]$, $\text{Rear} = []$.
  3. $Q_2$ remains completely unchanged due to persistence. Rebalancing $Q_2$ reverses $[20, 10]$ to $\text{Front} = [10, 20]$, popping head 10. It has zero effect on $Q_3$ or $Q_4$.

Implementation in Python, C++, and Java

The complete, runnable code implementations below construct an immutable persistent functional queue (FunctionalQueue). All three language implementations follow identical logic, perform identical sample operations, and print identical terminal output.

class Node:
    def __init__(self, val, next_node=None):
        self.val = val
        self.next = next_node


class FunctionalQueue:
    def __init__(self, front=None, rear=None):
        self._front = front
        self._rear = rear

    def is_empty(self):
        return self._front is None and self._rear is None

    def enqueue(self, val):
        new_rear = Node(val, self._rear)
        return FunctionalQueue(self._front, new_rear)

    def dequeue(self):
        if self.is_empty():
            raise IndexError("Queue underflow: empty queue")

        curr_front = self._front
        curr_rear = self._rear

        # Rebalance if front is empty
        if curr_front is None:
            rev_front = None
            curr = curr_rear
            while curr is not None:
                rev_front = Node(curr.val, rev_front)
                curr = curr.next
            curr_front = rev_front
            curr_rear = None

        val = curr_front.val
        next_front = curr_front.next
        return val, FunctionalQueue(next_front, curr_rear)

    def peek(self):
        if self.is_empty():
            raise IndexError("Queue underflow: empty queue")
        if self._front is not None:
            return self._front.val
        # If front is empty, the head is the last element in rear
        curr = self._rear
        last_val = None
        while curr is not None:
            last_val = curr.val
            curr = curr.next
        return last_val


if __name__ == "__main__":
    q0 = FunctionalQueue()
    q1 = q0.enqueue(10)
    q2 = q1.enqueue(20)
    q3 = q2.enqueue(30)

    val1, q4 = q3.dequeue()
    q5 = q4.enqueue(40)
    val2, q6 = q5.dequeue()

    print(f"Dequeued from Q3: {val1}")
    print(f"Dequeued from Q5: {val2}")

    # Demonstrate persistence: q2 still retains its original head
    val_q2, _ = q2.dequeue()
    print(f"Dequeued from historical version Q2: {val_q2}")
#include <iostream>
#include <memory>
#include <stdexcept>

template <typename T>
class FunctionalQueue {
private:
    struct Node {
        T val;
        std::shared_ptr<const Node> next;
        Node(T val, std::shared_ptr<const Node> next = nullptr)
            : val(val), next(next) {}
    };

    std::shared_ptr<const Node> front;
    std::shared_ptr<const Node> rear;

public:
    FunctionalQueue() : front(nullptr), rear(nullptr) {}

    FunctionalQueue(std::shared_ptr<const Node> f, std::shared_ptr<const Node> r)
        : front(f), rear(r) {}

    bool isEmpty() const {
        return front == nullptr && rear == nullptr;
    }

    FunctionalQueue enqueue(T val) const {
        auto new_rear = std::make_shared<const Node>(val, rear);
        return FunctionalQueue(front, new_rear);
    }

    std::pair<T, FunctionalQueue> dequeue() const {
        if (isEmpty()) {
            throw std::underflow_error("Queue underflow: empty queue");
        }

        auto curr_front = front;
        auto curr_rear = rear;

        if (curr_front == nullptr) {
            std::shared_ptr<const Node> rev_front = nullptr;
            auto curr = curr_rear;
            while (curr != nullptr) {
                rev_front = std::make_shared<const Node>(curr->val, rev_front);
                curr = curr->next;
            }
            curr_front = rev_front;
            curr_rear = nullptr;
        }

        T val = curr_front->val;
        auto next_front = curr_front->next;
        return {val, FunctionalQueue(next_front, curr_rear)};
    }
};

int main() {
    FunctionalQueue<int> q0;
    auto q1 = q0.enqueue(10);
    auto q2 = q1.enqueue(20);
    auto q3 = q2.enqueue(30);

    auto res1 = q3.dequeue();
    int val1 = res1.first;
    auto q4 = res1.second;

    auto q5 = q4.enqueue(40);
    auto res2 = q5.dequeue();
    int val2 = res2.first;
    auto q6 = res2.second;

    std::cout << "Dequeued from Q3: " << val1 << "\n";
    std::cout << "Dequeued from Q5: " << val2 << "\n";

    // Demonstrate persistence
    auto res_q2 = q2.dequeue();
    std::cout << "Dequeued from historical version Q2: " << res_q2.first << "\n";

    return 0;
}
public class Main {

    static class Node<T> {
        final T val;
        final Node<T> next;

        Node(T val, Node<T> next) {
            this.val = val;
            this.next = next;
        }
    }

    static class FunctionalQueue<T> {
        private final Node<T> front;
        private final Node<T> rear;

        public FunctionalQueue() {
            this.front = null;
            this.rear = null;
        }

        public FunctionalQueue(Node<T> front, Node<T> rear) {
            this.front = front;
            this.rear = rear;
        }

        public boolean isEmpty() {
            return front == null && rear == null;
        }

        public FunctionalQueue<T> enqueue(T val) {
            Node<T> newRear = new Node<>(val, this.rear);
            return new FunctionalQueue<>(this.front, newRear);
        }

        public static class Result<T> {
            public final T value;
            public final FunctionalQueue<T> queue;

            public Result(T value, FunctionalQueue<T> queue) {
                this.value = value;
                this.queue = queue;
            }
        }

        public Result<T> dequeue() {
            if (isEmpty()) {
                throw new IllegalStateException("Queue underflow: empty queue");
            }

            Node<T> currFront = this.front;
            Node<T> currRear = this.rear;

            if (currFront == null) {
                Node<T> revFront = null;
                Node<T> curr = currRear;
                while (curr != null) {
                    revFront = new Node<>(curr.val, revFront);
                    curr = curr.next;
                }
                currFront = revFront;
                currRear = null;
            }

            T val = currFront.val;
            Node<T> nextFront = currFront.next;
            return new Result<>(val, new FunctionalQueue<>(nextFront, currRear));
        }
    }

    public static void main(String[] args) {
        FunctionalQueue<Integer> q0 = new FunctionalQueue<>();
        FunctionalQueue<Integer> q1 = q0.enqueue(10);
        FunctionalQueue<Integer> q2 = q1.enqueue(20);
        FunctionalQueue<Integer> q3 = q2.enqueue(30);

        FunctionalQueue.Result<Integer> res1 = q3.dequeue();
        int val1 = res1.value;
        FunctionalQueue<Integer> q4 = res1.queue;

        FunctionalQueue<Integer> q5 = q4.enqueue(40);
        FunctionalQueue.Result<Integer> res2 = q5.dequeue();
        int val2 = res2.value;

        System.out.println("Dequeued from Q3: " + val1);
        System.out.println("Dequeued from Q5: " + val2);

        // Demonstrate persistence
        FunctionalQueue.Result<Integer> resQ2 = q2.dequeue();
        System.out.println("Dequeued from historical version Q2: " + resQ2.value);
    }
}

Complexity, Correctness, and Invariants

The formal justification for functional queue amortized complexity uses the Banker's Method (accounting method) or Physicist's Method (potential method).

Define the potential function $\Phi(Q)$ of a functional queue $Q = (F, R)$ as the length of the rear list:

$$\Phi(Q) = \vert{}R\vert{}$$

  • Enqueue Operation: Actual cost $c_i = O(1)$. $\Delta\Phi = \Phi(Q_i) - \Phi(Q_{i-1}) = +1$.

Amortized cost $\hat{c}_i = c_i + \Delta\Phi = 1 + 1 = 2 = O(1)$. Dequeue Operation (Without Rebalance): Actual cost $c_i = O(1)$. $\Delta\Phi = 0$. Amortized cost $\hat{c}_i = O(1)$. Dequeue Operation (With Rebalance): Actual cost $c_i = 1 + \vert{}R\vert{}$ (to reverse $R$). Potential change $\Delta\Phi = 0 - \vert{}R\vert{} = -\vert{}R\vert{}$. Amortized cost $\hat{c}_i = (1 + \vert{}R\vert{}) + (-\vert{}R\vert{}) = 1 = O(1)$.

Structure / VariantEnqueue TimeDequeue TimeConcat / SplitPersistence SupportSpace Overhead
2-List Functional Queue$O(1)$ worst-case$O(1)$ amortized$O(N)$ linearFully persistent (single-threaded)$O(N)$ total elements
Real-Time Queue (Streams)$O(1)$ worst-case$O(1)$ worst-case$O(N)$ linearFully persistent (multi-threaded)$O(N)$ lazily evaluated
2-3 Finger Tree$O(1)$ amortized$O(1)$ amortized$O(\log(\min(N_1, N_2)))$Fully persistent$O(N)$ structural sharing
Imperative Circular Queue$O(1)$ worst-case$O(1)$ worst-case$O(N)$ linearEphemeral only (mutates array)$O(N)$ pre-allocated

Variants, Pitfalls, and Optimizations

Functional Variants

  1. Lazy / Real-Time Queues (Okasaki):

In persistent applications where an old queue version with an empty Front list is dequeued repeatedly across multiple threads, simple un-memoized rebalancing executes $O(N)$ list reversals repeatedly, destroying amortized bounds. Real-Time Queues solve this by maintaining Front as a lazy stream and incrementally reversing $R$ over subsequent operations, guaranteeing $O(1)$ worst-case persistence. 2. 2-3 Finger Trees (Hinze & Paterson): By replacing linear lists with digits and a nested 2-3 tree spine, Finger Trees provide $O(1)$ amortized push_front, pop_front, push_back, and pop_back, while supporting sequence splitting and concatenation in $O(\log N)$ time.

Heavy branching breaks the naive amortized reversal Real-time queues keep the front as a lazy stream and spread the reversal incrementally, holding constant time even when old versions are dequeued many times. A naive two-list queue re-executes the full reversal on every dequeue of a shared empty-front version, so total cost grows with the number of branches. Heavy Branching Breaks Naive Amortized Reversal REAL-TIME QUEUE / FINGER TREE NAIVE TWO-LIST QUEUE Front kept as a lazy stream Reversal spread incrementally over ops Worst-case O(1) under persistence Finger Tree: O(1) ends, O(log N) split Safe for heavily branched versions. Old empty-Front handle dequeued M times O(N) reversal re-executed every time Total cost climbs to O(M · N) Amortized argument assumed one thread Persistence re-pays the reversal. For persistent workloads with heavy branching, use Real-Time Queues or Finger Trees. Un-memoized reversals on shared versions void the single-threaded amortized bound.

Top Implementation Pitfalls:

  1. Persistent Branching Amortization Leak: In non-ephemeral workloads where an old queue version with an empty Front list is dequeued repeatedly, simple functional queues re-execute list reversals every time, causing time complexity to degrade to $O(M \cdot N)$.
  2. Deep Copy Overhead: Writing copy-on-write handlers that copy the Front or Rear lists during enqueue/dequeue operations destroys structural sharing and increases space complexity to $O(N^2)$.
  3. Queue Underflow on Peek: Forgetting that the front element resides at the end of Rear when Front is empty can cause peek() to incorrectly report nil or throw false underflow errors.

Practice Problems and Real-World Use

Functional Queues and Finger Trees are implemented extensively across pure functional language runtimes, persistent data structures, and compiler infrastructure:

Application / SystemCore Mechanism AppliedDifficulty / Context
**Haskell Data.Sequence**2-3 Finger Trees providing $O(1)$ access at ends and $O(\log N)$ concat.Production Standard Library
Clojure Persistent VectorsBitmapped vector tries and finger tree variants for immutable indexing.Production Runtime
Functional Breadth-First Search (BFS)State exploration using immutable queues to maintain versioned graphs.Medium-Hard
GHC Compiler Intermediate RepresentationPersistent task scheduling and expression rewrite queues.Real-World System
Undo / Redo ManagersDouble-ended persistent history buffers leveraging Finger Trees.System Design
LeetCode 239 - Sliding Window MaximumFunctional deque structures supporting range minimum/maximum tracking.Hard