rbtree-ruby

🌍 English | ζ—₯本θͺž

A pure Ruby implementation of the Red-Black Tree data structure, providing efficient ordered key-value storage with O(log n) time complexity for insertion, deletion, and lookup operations.

Features

  • Self-Balancing Binary Search Tree: Maintains optimal performance through red-black tree properties. All insertions, deletions, and lookups run in O(log n).
  • Ordered Operations: Efficient sorted iteration, range queries (lt, gt, between), min/max retrieval.
  • Multi-Value Support: MultiRBTree class stores multiple values per key, with access to first or last value individually.
  • Pure Ruby: No C extensions required. Works on MRI, JRuby, TruffleRuby, and all Ruby implementations.
  • Hybrid Indexing: Internal hash index enables O(1) key lookup and membership checks β€” matching standard Hash performance β€” while ordering still decides key identity, so lookup and ordering never disagree (see Key Requirements).
  • Memory Efficiency: Node recycling with auto-shrinking pool (AutoShrinkNodePool) drastically reduces GC pressure in long-running apps.
  • Nearest Key Search: Finds the closest numeric key in O(log n) time β€” ideal for spatial or temporal queries.
  • Safe Iteration: Use safe: true to safely modify the tree (insert/delete) during iteration. Without it, modifying during iteration raises RuntimeError rather than silently misbehaving.

Installation

Add this line to your application's Gemfile:

gem 'rbtree-ruby'

And then execute:

bundle install

Or install it yourself as:

gem install rbtree-ruby

Usage

Basic RBTree

require 'rbtree'

# Create an empty tree
tree = RBTree.new

# Or initialize with data (Bulk Insert)
tree = RBTree.new({3 => 'three', 1 => 'one', 2 => 'two'})
tree = RBTree[[5, 'five'], [4, 'four']]
tree = RBTree.new do # Block initialization
  data_source.each { |data| [data.time, data.content] } 
end

# Insert and retrieve values
tree.insert(10, 'ten')
tree[20] = 'twenty'
# Bulk insert
tree.insert({30 => 'thirty', 40 => 'forty'})
puts tree[10]  # => "ten"

# Iterate in sorted order
tree.each { |key, value| puts "#{key}: #{value}" }
# Output:
# 1: one
# 2: two
# 3: three
# 10: ten
# 20: twenty

# Modification during iteration
# Unlike standard Ruby Hash/Array, modification during iteration is fully supported
# with the `safe: true` option. This allows you to delete or insert keys safely while iterating.
# Without it, modifying the tree mid-iteration raises RuntimeError (see "Iteration Safety").
tree.each(safe: true) { |k, v| tree.delete(k) if k.even? }
tree.each(reverse: true) { |k, v| puts k }  # Same as reverse_each

# Min and max β€” O(1), and Enumerable-compatible when given a count or a block
tree.min  # => [1, "one"]
tree.max  # => [20, "twenty"]
tree.first(2)  # => [[1, "one"], [2, "two"]]
tree.last(2)   # => [[10, "ten"], [20, "twenty"]]
tree.min(2)    # => [[1, "one"], [2, "two"]]

# Range queries (return Enumerator, use .to_a for Array)
tree.lt(10).to_a   # => [[1, "one"], [2, "two"], [3, "three"]]
tree.gte(10).each { |k, v| puts k } # Block iteration
tree.between(2, 10).to_a  # => [[2, "two"], [3, "three"], [10, "ten"]]

# Range objects in [] (v0.3.4+)
tree[..10].to_a    # lte(10)
tree[2..10].each { |k, v| ... } # Block iteration on Range
tree[2...10].to_a  # between(2, 10, include_max: false)
tree[10..].to_a    # gte(10)
tree[2..10, reverse: true].to_a # with options

# Shift and pop
tree.shift  # => [1, "one"] (removes minimum)
tree.pop    # => [20, "twenty"] (removes maximum)

# Delete
tree.delete(3)  # => "three"

# Check membership
tree.has_key?(2)  # => true
tree.size         # => 2

MultiRBTree with Duplicate Keys

require 'rbtree'

tree = MultiRBTree.new

# Insert multiple values for the same key
tree.insert(1, 'first one')
tree.insert(1, 'second one')
tree.insert(1, 'third one')
tree.insert(2, 'two')

tree.size  # => 4 (total number of key-value pairs)

# Get first value
tree.value(1)      # => "first one"
tree[1]          # => "first one"

# Get all values for a key (returns Enumerator)
tree.values(1).to_a  # => ["first one", "second one", "third one"]

# Iterate over all key-value pairs
tree.each { |k, v| puts "#{k}: #{v}" }
# Output:
# 1: first one
# 1: second one
# 1: third one
# 2: two

# Delete only first value
tree.delete_value(1)  # => "first one"
tree.value(1)         # => "second one"

# Delete all values for a key
tree.delete_key(1)      # removes all remaining values
tree = RBTree.new({1 => 'one', 5 => 'five', 10 => 'ten'})

tree.nearest(4)   # => [5, "five"]  (closest key to 4)
tree.nearest(7)   # => [5, "five"]  (same distance, returns smaller key)
tree.nearest(8)   # => [10, "ten"]

Find the next or previous key in the tree:

tree = RBTree.new({1 => 'one', 3 => 'three', 5 => 'five', 7 => 'seven'})

tree.prev(5)   # => [3, "three"]  (largest key < 5)
tree.succ(5)   # => [7, "seven"]  (smallest key > 5)

# Works even if the key doesn't exist
tree.prev(4)   # => [3, "three"]  (4 doesn't exist, returns largest key < 4)
tree.succ(4)   # => [5, "five"]   (4 doesn't exist, returns smallest key > 4)

# Returns nil at boundaries
tree.prev(1)   # => nil (no key smaller than 1)
tree.succ(7)   # => nil (no key larger than 7)

Reverse Range Queries

All range queries return an Enumerator (use .to_a for Array) and support a :reverse option:

tree = RBTree.new({1 => 'one', 2 => 'two', 3 => 'three', 4 => 'four'})

tree.lt(3).to_a                    # => [[1, "one"], [2, "two"]]
tree.lt(3, reverse: true).to_a     # => [[2, "two"], [1, "one"]]
tree.lt(3).first                   # => [1, "one"] (lazy, no array created)

# Lazy evaluation
tree.gt(0).lazy.take(2).to_a  # => [[1, "one"], [2, "two"]] (only computes first 2)

Conversion and Merging

Seamlessly convert to standard Ruby objects or merge other collections:

tree = RBTree.new({1 => 'one', 2 => 'two'})

# Convert to Array (via Enumerable)
tree.to_a  # => [[1, "one"], [2, "two"]]

# Convert to Hash (keys are inserted in ascending order)
tree.to_h  # => {1 => "one", 2 => "two"}

# MultiRBTree maps each key to a copy of its value list
multi = MultiRBTree.new
multi.insert(1, 'a'); multi.insert(1, 'b')
multi.to_h  # => {1 => ["a", "b"]}

# Merge (destructive)
other = {3 => 'three'}
tree.merge!(other)
tree.size  # => 3

# Merge (non-destructive) β€” returns a new tree
merged = tree.merge({4 => 'four'})

# Merge with block for duplicate key resolution
merged = tree.merge({1 => 'ONE'}) { |key, old_val, new_val| old_val }

# Invert keys and values
tree = RBTree.new({1 => 'a', 2 => 'b', 3 => 'c'})
tree.invert.to_a  # => [["a", 1], ["b", 2], ["c", 3]]

Filtering and Copying

Note: dup, select, reject, delete_if, reject!, keep_if, invert, and merge are convenience methods composed from existing primitives. They provide no speed advantage over manual composition β€” their value is in readability and API completeness.

Create copies or filter trees using familiar Ruby idioms:

tree = RBTree.new({1 => 'one', 2 => 'two', 3 => 'three', 4 => 'four'})

# Deep copy β€” independent of original
copy = tree.dup
copy.delete(1)
tree.size  # => 4 (unchanged)

# select / reject β€” return a new tree
evens = tree.select { |k, _| k.even? }   # => {2=>"two", 4=>"four"}
odds  = tree.reject { |k, _| k.even? }   # => {1=>"one", 3=>"three"}

# delete_if / keep_if β€” modify in place
tree.delete_if { |k, _| k > 2 }
tree.to_a  # => [[1, "one"], [2, "two"]]

# reject! β€” like delete_if, but returns nil if nothing changed
tree.reject! { |_, _| false }  # => nil

For MultiRBTree, delete_if and keep_if operate at individual value granularity:

tree = MultiRBTree.new
tree.insert(1, 'a')
tree.insert(1, 'b')
tree.insert(2, 'c')

tree.delete_if { |k, v| k == 1 && v == 'a' }
tree.to_a  # => [[1, "b"], [2, "c"]]  β€” only 'a' was removed

MultiRBTree Value Array Access

For keys with multiple values, choose which value to access:

tree = MultiRBTree.new
tree.insert(1, 'first')
tree.insert(1, 'second')
tree.insert(1, 'third')

# Access first or last value
tree.value(1)               # => "first"
tree.value(1, last: true)   # => "third"
tree.first_value(1)         # => "first"
tree.last_value(1)          # => "third"

# Delete from either end
tree.delete_first_value(1)      # => "first"
tree.delete_last_value(1)       # => "third"  
tree.value(1)               # => "second"

# min/max with :last option
tree.insert(2, 'a')
tree.insert(2, 'b')
tree.min                  # => [1, "second"] (first value of min key)
tree.max(last: true)      # => [2, "b"]      (last value of max key)

Semantics

Key Requirements

Keys must be mutually comparable with <=>. Ordering β€” not eql?/hash β€” decides key identity: any key for which (key <=> stored_key) == 0 refers to the same entry.

tree = RBTree.new
tree[1] = 'int'

tree[1.0]           # => "int"   (1.0 <=> 1 is 0, so it is the same key)
tree.has_key?(1.0)  # => true
tree[1.0] = 'float'
tree.to_a           # => [[1, "float"]]  β€” the first key object inserted is kept

The internal hash index is keyed by eql?/hash, so a lookup like tree[1.0] above misses it and resolves through the tree instead. That fallback is skipped whenever it is provably futile β€” when every stored key and the lookup key share one class for which <=>-equality coincides with eql?, any matching key would already have been found in the index. Integer, Float, Rational, String, Symbol and Time qualify, so:

Tree Lookup cost
Keys all of one core class above O(1), hits and misses alike
Mixed key classes, or a custom key class O(1) on a hash hit, O(log n) otherwise

A custom key class that defines <=> alongside a matching eql?/hash can opt into the O(1) path:

class Version
  include Comparable
  attr_reader :parts
  def initialize(parts) = @parts = parts
  def <=>(other) = parts <=> other.parts
  def eql?(other) = other.instance_of?(Version) && parts == other.parts
  def hash = parts.hash
end

RBTree.coherent_key_class(Version)

This is purely an optimization hint β€” without it such trees are still correct, just with an O(log n) descent on lookups that miss the index. A class that defines <=> but leaves eql?/hash at their default identity semantics must not be declared.

Keys that cannot be compared at all are reported as absent by lookups rather than raising:

tree['no <=> with Integer']       # => nil
tree.has_key?('...')             # => false
tree['...'] = 'x'                # raises ArgumentError β€” insertion needs an ordering

Iteration Safety

Iteration walks live node links, and deleted nodes are recycled by the allocator, so mutating the tree during a plain iteration is not merely lossy β€” it can yield keys out of order. This is detected and reported:

tree.each { |k, v| tree.delete(k) }
# => RuntimeError: can't modify RBTree during iteration (pass `safe: true` to iterate safely)

tree.each(safe: true) { |k, v| tree.delete(k) }   # supported

The guard covers each, reverse_each, keys, all range queries, and any Enumerator they return β€” including one held across a later mutation. Replacing the value of an existing key is not a structural change and stays allowed. delete_if, keep_if and reject! iterate safely on your behalf.

In MultiRBTree, safe: true snapshots each key's value list, so removing values while that key is being iterated will not skip its siblings; values removed during the iteration of their own key may still be yielded.

Thread Safety

No operation is synchronized. The tree, its hash index, and the node allocator are all shared mutable state, so concurrent access from multiple threads requires external locking. Note that a node allocator passed to several trees (including via dup) is shared state between them.

Performance

All major operations run in O(log n) time:

  • insert(key, value) - O(log n)
  • delete(key) - O(log n)
  • value(key) / [] - O(1) (O(log n) only for a miss on a mixed-class or custom-key tree β€” see Key Requirements)
  • has_key? - O(1) (same proviso)
  • min / max - O(1)
  • shift / pop - O(log n)
  • prev / succ - O(log n) with O(1) hash check and faster startup
  • clear - O(1) (nodes are left to the GC rather than recycled)

Iteration over all elements takes O(n) time.

On lookup misses: resolving key identity through ordering rather than eql? costs nothing for ordinary key types. Measured on a 200,000-entry Integer-keyed tree, 50,000 missing lookups take 8.1 ms against 6.3 ms for a bare hash probe β€” the difference is a class check, not a tree descent. Only a mixed-class or custom-key tree pays the descent (~1.4 Β΅s per miss). Hits, insert, delete, iteration and range queries are unaffected.

RBTree vs Hash vs Array (Overwhelming Power)

For ordered and spatial operations, RBTree is not just fasterβ€”it is in a completely different class. The following benchmarks were conducted with 500,000 items:

Operation RBTree Hash/Array Speedup Why?
Nearest Key Search O(log n) O(n) scan ~8,600x faster Spatial binary search vs full scan
Range Queries O(log n + k) O(n) filter ~540x faster Direct subtree jump vs full scan
Min Extraction O(log n) O(n) search ~160x faster Continuous rebalancing vs full scan
Sorted Iteration O(n) O(n log n) FREE Always sorted vs explicit sort
Key Lookup O(1) O(1) Equal Hybrid Hash Index provides O(1) access like standard Hash

Memory Efficiency & Custom Allocators

RBTree uses an internal Memory Pool to recycle node objects.

  • Significantly reduces Garbage Collection (GC) pressure during frequent insertions and deletions.
  • Auto-Shrinking: The default AutoShrinkNodePool automatically releases unused nodes back to Ruby's GC when the pool gets too large relative to current usage, preventing memory leaks in long-running applications with fluctuating workloads.
  • Customization: You can customize the pool behavior or provide your own allocator:
# Customize auto-shrink parameters
pool = RBTree::AutoShrinkNodePool.new(
  history_size: 60,       # 1 minute history
  buffer_factor: 1.5,     # Keep 50% buffer above fluctuation
  reserve_ratio: 0.2      # Always keep 20% reserve
)
tree = RBTree.new(node_allocator: pool)

When to Use RBTree

βœ… Use RBTree when you need:

  • Ordered iteration by key
  • Fast min/max retrieval
  • Range queries (between, lt, gt, lte, gte)
  • Nearest key search
  • Priority queue behavior (shift/pop by key order)

βœ… Use Hash when you only need:

  • Fast key-value lookup (RBTree is now equally fast!)
  • No ordering requirements

Run ruby demo.rb for a full benchmark demonstration.

API Documentation

Full RDoc documentation is available. Generate it locally with:

rdoc lib/rbtree.rb

Then open doc/index.html in your browser.

Development

After checking out the repo, run bundle install to install dependencies.can then run:

# Generate RDoc documentation
rake rdoc

# Build the gem
rake build

# Install locally
rake install

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/firelzrd/rbtree-ruby.

License

The gem is available as open source under the terms of the MIT License.

Author

Masahito Suzuki (firelzrd@gmail.com)

Copyright Β© 2026 Masahito Suzuki