Overview
Algorithm optimization in Ruby focuses on improving code performance through strategic choices in data structures, algorithms, and implementation patterns. Ruby provides numerous built-in optimizations and tools for analyzing and improving algorithmic efficiency.
The core optimization areas include time complexity reduction, space complexity management, and leveraging Ruby's built-in optimized methods. Ruby's standard library includes highly optimized implementations for common operations like sorting, searching, and data manipulation that often outperform custom implementations.
# Inefficient nested iteration - O(n²)
def find_duplicates_slow(array)
duplicates = []
array.each_with_index do |item, i|
array[(i+1)..-1].each do |other|
duplicates << item if item == other
end
end
duplicates.uniq
end
# Optimized using Set - O(n)
require 'set'
def find_duplicates_fast(array)
seen = Set.new
duplicates = Set.new
array.each do |item|
if seen.include?(item)
duplicates.add(item)
else
seen.add(item)
end
end
duplicates.to_a
end
Ruby's garbage collector and memory management affect algorithmic performance. Understanding object allocation patterns helps optimize memory-intensive algorithms. The ObjectSpace
module provides tools for analyzing memory usage and object lifecycle.
Profiling tools like ruby-prof
, benchmark
, and memory_profiler
help identify bottlenecks and measure optimization effectiveness. Ruby's built-in Benchmark
module provides basic timing capabilities, while external gems offer more detailed analysis.
Basic Usage
Basic optimization starts with choosing appropriate data structures for specific use cases. Arrays excel at indexed access but perform poorly for membership testing. Hashes provide constant-time lookups but consume more memory. Sets optimize membership testing and duplicate elimination.
# Array membership testing - O(n)
large_array = (1..10000).to_a
large_array.include?(9999) # Linear search
# Hash key lookup - O(1)
large_hash = (1..10000).each_with_object({}) { |i, h| h[i] = true }
large_hash.key?(9999) # Constant time
# Set membership testing - O(1)
require 'set'
large_set = (1..10000).to_set
large_set.include?(9999) # Constant time
Enumerable methods provide optimized implementations for common operations. Methods like map
, select
, reduce
, and group_by
use internal iteration that outperforms manual loops in most cases.
# Manual iteration
result = []
(1..1000).each { |n| result << n * 2 if n.even? }
# Optimized enumerable chain
result = (1..1000).select(&:even?).map { |n| n * 2 }
# Further optimized with lazy evaluation for large datasets
result = (1..1_000_000).lazy.select(&:even?).map { |n| n * 2 }.first(100)
Memoization provides significant optimization for recursive algorithms by caching computed results. Ruby's Hash class serves as an effective memoization store for most scenarios.
# Fibonacci without memoization - exponential time complexity
def fibonacci_slow(n)
return n if n <= 1
fibonacci_slow(n - 1) + fibonacci_slow(n - 2)
end
# Fibonacci with memoization - linear time complexity
def fibonacci_fast(n, memo = {})
return n if n <= 1
memo[n] ||= fibonacci_fast(n - 1, memo) + fibonacci_fast(n - 2, memo)
end
String operations benefit from strategic optimization. String concatenation using <<
outperforms +
for building strings iteratively. The String#tr
method provides optimized character replacement that outperforms regular expressions for simple substitutions.
# Inefficient string building
result = ""
1000.times { |i| result = result + i.to_s }
# Optimized string building
result = ""
1000.times { |i| result << i.to_s }
# Most efficient for complex building
result = []
1000.times { |i| result << i.to_s }
final = result.join
Advanced Usage
Advanced optimization techniques involve algorithmic complexity analysis and sophisticated data structure selection. Understanding Big O notation helps evaluate algorithm efficiency and choose appropriate approaches for different problem scales.
Dynamic programming represents a class of optimization techniques that break complex problems into overlapping subproblems. The technique applies memoization systematically to avoid redundant computation.
class LongestCommonSubsequence
def initialize
@memo = {}
end
def lcs_length(str1, str2, i = str1.length - 1, j = str2.length - 1)
return 0 if i < 0 || j < 0
key = [i, j]
return @memo[key] if @memo.key?(key)
if str1[i] == str2[j]
@memo[key] = 1 + lcs_length(str1, str2, i - 1, j - 1)
else
@memo[key] = [
lcs_length(str1, str2, i - 1, j),
lcs_length(str1, str2, i, j - 1)
].max
end
@memo[key]
end
end
Custom data structures can provide significant optimizations for specific use cases. Implementing specialized structures like tries for string matching or binary heaps for priority queues often outperforms generic alternatives.
class Trie
def initialize
@root = {}
end
def insert(word)
node = @root
word.each_char do |char|
node[char] ||= {}
node = node[char]
end
node[:end] = true
end
def search(word)
node = @root
word.each_char do |char|
return false unless node[char]
node = node[char]
end
!!node[:end]
end
def starts_with(prefix)
node = @root
prefix.each_char do |char|
return false unless node[char]
node = node[char]
end
true
end
end
Algorithmic optimization often involves trading space for time or vice versa. Preprocessing data structures can convert expensive runtime operations into constant-time lookups.
class RangeQueryOptimizer
def initialize(array)
@array = array
@prefix_sums = build_prefix_sums
end
# Preprocessing: O(n) time, O(n) space
def build_prefix_sums
sums = [0]
@array.each_with_index do |val, i|
sums << sums[i] + val
end
sums
end
# Query: O(1) time after preprocessing
def range_sum(left, right)
@prefix_sums[right + 1] - @prefix_sums[left]
end
end
Bitwise operations provide extreme optimization for specific scenarios involving binary data or boolean arrays. Ruby supports all standard bitwise operators and can perform these operations efficiently.
class BitVector
def initialize(size)
@size = size
@bits = Array.new((size + 31) / 32, 0)
end
def set_bit(index)
word_index = index / 32
bit_index = index % 32
@bits[word_index] |= (1 << bit_index)
end
def get_bit(index)
word_index = index / 32
bit_index = index % 32
(@bits[word_index] & (1 << bit_index)) != 0
end
def count_set_bits
@bits.sum { |word| word.to_s(2).count('1') }
end
end
Performance & Memory
Performance measurement requires systematic benchmarking to validate optimization effectiveness. Ruby's Benchmark
module provides basic timing capabilities, while benchmark-ips
gem offers iterations-per-second measurements that account for Ruby's garbage collection patterns.
require 'benchmark'
require 'benchmark/ips'
# Basic timing comparison
array = (1..10000).to_a
Benchmark.bm(20) do |x|
x.report("Array#include?:") { 1000.times { array.include?(5000) } }
x.report("Binary search:") { 1000.times { binary_search(array, 5000) } }
end
# Iterations per second measurement
Benchmark.ips do |x|
x.report("linear search") { array.include?(5000) }
x.report("binary search") { binary_search(array, 5000) }
x.compare!
end
Memory optimization involves understanding Ruby's object allocation patterns and garbage collection behavior. The memory_profiler
gem tracks object allocations and identifies memory-intensive operations.
require 'memory_profiler'
report = MemoryProfiler.report do
# Code to profile
large_array = Array.new(100000) { |i| "string_#{i}" }
result = large_array.select { |s| s.include?('5') }
end
report.pretty_print
Lazy evaluation through Ruby's Enumerator::Lazy
prevents unnecessary memory allocation when processing large datasets. Lazy enumerators compute values on demand rather than materializing entire intermediate collections.
# Memory-intensive: creates multiple intermediate arrays
def process_large_dataset_eager(data)
data.map { |x| x * 2 }
.select { |x| x > 100 }
.map { |x| x.to_s }
.first(10)
end
# Memory-efficient: lazy evaluation
def process_large_dataset_lazy(data)
data.lazy
.map { |x| x * 2 }
.select { |x| x > 100 }
.map { |x| x.to_s }
.first(10)
end
Algorithm choice dramatically affects performance characteristics. Sorting algorithms demonstrate this principle clearly, with different algorithms performing better under different conditions.
class SortingBenchmark
def self.compare_sorts(array_size)
data = Array.new(array_size) { rand(1000) }
Benchmark.bm(15) do |x|
# Ruby's built-in sort (optimized quicksort/mergesort hybrid)
x.report("Built-in sort:") do
data.dup.sort
end
# Custom quicksort implementation
x.report("Custom quicksort:") do
quicksort(data.dup)
end
# Insertion sort (good for small arrays)
x.report("Insertion sort:") do
insertion_sort(data.dup)
end if array_size < 1000
end
end
def self.quicksort(array, low = 0, high = array.length - 1)
return array if low >= high
pivot = partition(array, low, high)
quicksort(array, low, pivot - 1)
quicksort(array, pivot + 1, high)
array
end
def self.partition(array, low, high)
pivot = array[high]
i = low - 1
(low...high).each do |j|
if array[j] <= pivot
i += 1
array[i], array[j] = array[j], array[i]
end
end
array[i + 1], array[high] = array[high], array[i + 1]
i + 1
end
end
Cache-friendly algorithms consider memory access patterns to optimize performance on modern processors. Sequential memory access patterns significantly outperform random access patterns due to CPU cache behavior.
Common Pitfalls
Premature optimization represents the most common pitfall in algorithmic optimization. Optimizing code before identifying actual bottlenecks through profiling often results in increased complexity without meaningful performance gains.
# Premature optimization: complex caching for rarely-called method
class User
def expensive_calculation
@cached_result ||= begin
# Simple calculation that runs once per user
age * 365
end
end
end
# Better: simple implementation until profiling shows bottleneck
class User
def age_in_days
age * 365
end
end
Micro-optimizations often provide negligible benefits while harming code readability. Focus optimization efforts on algorithmic improvements rather than minor syntax changes.
# Micro-optimization with minimal impact
def sum_even_numbers(numbers)
sum = 0
i = 0
while i < numbers.length
sum += numbers[i] if numbers[i] & 1 == 0
i += 1
end
sum
end
# Clearer code with equivalent performance
def sum_even_numbers(numbers)
numbers.select(&:even?).sum
end
Memory leaks occur when optimization attempts inadvertently retain object references. Global caches without size limits or cleanup mechanisms consume increasing memory over time.
# Memory leak: unbounded cache growth
class DataProcessor
@@cache = {}
def self.process(key)
@@cache[key] ||= expensive_computation(key)
end
end
# Fixed: bounded cache with LRU eviction
require 'lru_redux'
class DataProcessor
@@cache = LruRedux::Cache.new(1000)
def self.process(key)
@@cache.getset(key) { expensive_computation(key) }
end
end
Incorrect complexity analysis leads to choosing inappropriate algorithms for specific use cases. Hash operations appear constant-time but degrade with poor hash functions or high collision rates.
# Poor hash function causing O(n) performance
class BadHash
def initialize
@buckets = Array.new(10) { [] }
end
def store(key, value)
# All keys hash to same bucket
bucket_index = 0
@buckets[bucket_index] << [key, value]
end
def retrieve(key)
@buckets[0].find { |k, v| k == key }&.last
end
end
Optimization without measurement often targets wrong bottlenecks. Code that appears slow may not impact overall performance if called infrequently.
# Measuring optimization impact
def benchmark_optimization
original_time = Benchmark.realtime do
1000.times { slow_method }
end
optimized_time = Benchmark.realtime do
1000.times { fast_method }
end
improvement = (original_time - optimized_time) / original_time * 100
puts "Optimization improved performance by #{improvement.round(2)}%"
end
Production Patterns
Production optimization requires balancing performance with maintainability, monitoring, and operational concerns. Cached computations must handle cache invalidation correctly to prevent serving stale data.
class ProductionCache
def initialize(ttl: 3600)
@cache = {}
@ttl = ttl
end
def get(key, &block)
entry = @cache[key]
if entry && (Time.now - entry[:timestamp] < @ttl)
entry[:value]
else
value = block.call
@cache[key] = { value: value, timestamp: Time.now }
cleanup_expired_entries
value
end
end
private
def cleanup_expired_entries
return unless @cache.size > 1000
cutoff = Time.now - @ttl
@cache.reject! { |_, entry| entry[:timestamp] < cutoff }
end
end
Database query optimization often provides greater performance improvements than application-level algorithmic optimization. N+1 query problems cause exponential performance degradation as dataset size increases.
# N+1 query problem
def show_users_with_posts
users = User.all
users.each do |user|
puts "#{user.name}: #{user.posts.count} posts" # N queries
end
end
# Optimized with includes
def show_users_with_posts_optimized
users = User.includes(:posts)
users.each do |user|
puts "#{user.name}: #{user.posts.size} posts" # 1 query
end
end
Background processing moves expensive computations away from request threads, improving perceived performance. Queue-based architectures handle optimization asynchronously.
class OptimizationWorker
include Sidekiq::Worker
def perform(user_id, data_type)
user = User.find(user_id)
case data_type
when 'analytics'
result = compute_user_analytics(user)
user.update(analytics_cache: result)
when 'recommendations'
recommendations = generate_recommendations(user)
user.update(recommendations_cache: recommendations)
end
end
private
def compute_user_analytics(user)
# Complex computation moved to background
user.activities.group_by(&:date).transform_values(&:count)
end
end
Monitoring optimization effectiveness in production requires metrics collection and alerting. Performance regressions often occur gradually and need systematic detection.
class PerformanceMonitor
def self.track(operation_name)
start_time = Time.now
memory_before = get_memory_usage
result = yield
duration = Time.now - start_time
memory_after = get_memory_usage
memory_delta = memory_after - memory_before
log_metrics(operation_name, duration, memory_delta)
result
end
private
def self.get_memory_usage
GC.stat[:heap_allocated_pages] * GC::INTERNAL_CONSTANTS[:HEAP_PAGE_OBJ_LIMIT]
end
def self.log_metrics(operation, duration, memory_delta)
Rails.logger.info({
operation: operation,
duration_ms: (duration * 1000).round(2),
memory_delta_kb: (memory_delta / 1024.0).round(2),
timestamp: Time.now.iso8601
}.to_json)
end
end
Reference
Core Optimization Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Array#bsearch |
{block} |
Object |
Binary search on sorted array |
Array#bsearch_index |
{block} |
Integer |
Binary search returning index |
Enumerable#lazy |
None | Enumerator::Lazy |
Creates lazy enumerator |
Hash#fetch |
key, default=nil |
Object |
Constant-time key retrieval |
Set#include? |
object |
Boolean |
Constant-time membership test |
String#tr |
from_str, to_str |
String |
Optimized character replacement |
Complexity Reference
Operation | Array | Hash | Set |
---|---|---|---|
Access by index | O(1) | N/A | N/A |
Search/Include | O(n) | O(1) | O(1) |
Insert | O(1) | O(1) | O(1) |
Delete | O(n) | O(1) | O(1) |
Sort | O(n log n) | N/A | N/A |
Memory Profiling Tools
Tool | Purpose | Installation | Usage |
---|---|---|---|
memory_profiler |
Object allocation tracking | gem install memory_profiler |
MemoryProfiler.report { code } |
ruby-prof |
CPU and memory profiling | gem install ruby-prof |
RubyProf.profile { code } |
benchmark-ips |
Iterations per second | gem install benchmark-ips |
Benchmark.ips { |x| x.report } |
ObjectSpace |
Built-in memory analysis | Built-in | ObjectSpace.count_objects |
Common Algorithm Patterns
Pattern | Time Complexity | Space Complexity | Use Case |
---|---|---|---|
Memoization | Varies | O(n) | Overlapping subproblems |
Binary Search | O(log n) | O(1) | Sorted data search |
Hash Table | O(1) avg | O(n) | Fast key-value lookup |
Dynamic Programming | O(n²) typical | O(n) | Optimal substructure |
Two Pointers | O(n) | O(1) | Array/string problems |
Benchmarking Template
require 'benchmark'
require 'benchmark/ips'
# Basic timing
Benchmark.bm(20) do |x|
x.report("method_name:") { method_call }
end
# Iterations per second
Benchmark.ips do |x|
x.report("approach_1") { method_1 }
x.report("approach_2") { method_2 }
x.compare!
end
Performance Checklist
- Profile before optimizing
- Measure optimization impact
- Consider algorithmic complexity
- Choose appropriate data structures
- Cache expensive computations
- Use lazy evaluation for large datasets
- Avoid premature optimization
- Monitor production performance