Ruby Sort and Custom Comparators: The Coding Interview Reference

May 29, 202610 min read
dsaalgorithmsinterview-prepruby
Ruby Sort and Custom Comparators: The Coding Interview Reference
TL;DR
  • sort_by calls its block exactly n times while sort calls it O(n log n) times, making sort_by faster for any non-trivial extraction key.
  • The spaceship operator <=> returns nil for incomparable types, causing Array#sort to raise ArgumentError rather than silently reorder.
  • Multi-key sort: return an array from sort_by. Ruby compares arrays lexicographically, giving you free multi-level ordering with no extra code.
  • Ruby's built-in sort is not stable: add the original index as a tiebreaker to guarantee equal-key elements keep their relative order.
  • Comparable + <=> gives you sort, <, >, between?, and clamp on custom objects from a single method definition.
  • Avoid the subtraction comparator from Java/C: a - b in a sort block works by accident on small integers but violates the documented contract. Use a <=> b.

Sorting in Ruby looks deceptively easy. Two methods, six letters each, a spaceship in the middle. Until nil shows up, or someone asks for a stable sort, or you call sort! on a parameter you were not supposed to touch and spend ten minutes debugging a mutation bug you introduced.

This covers both methods, the spaceship operator, multi-key comparators, the Comparable module, and the five traps that actually bite people in interviews.


sort vs sort_by: The Decision Is Simpler Than It Looks

sort takes a block with two parameters. You compare them and return -1, 0, or 1. Without a block, Ruby calls <=> on the elements directly.

nums = [3, 1, 4, 1, 5, 9, 2] nums.sort # [1, 1, 2, 3, 4, 5, 9] nums.sort { |a, b| b <=> a } # [9, 5, 4, 3, 2, 1, 1]

sort_by takes a single parameter and you return the key to sort on. Ruby runs your block once per element, builds an intermediate array of [key, element] pairs, sorts by key, and reconstructs the result in order. This pattern has a name: the Schwartzian Transform, coined by Randal L. Schwartz in a Perl newsgroup post in 1994. Ruby inherited it. The name sounds like a classified CIA program. The idea is just memoizing your key computation.

words = ["banana", "fig", "apple", "kiwi"] words.sort_by { |w| w.length } # ["fig", "kiwi", "apple", "banana"] words.sort_by(&:length) # identical, shorter

Use sort_by when you are extracting a key. Use sort when you need to compare two elements directly and the comparison is not a simple key extraction.

sort calls its block O(n log n) times. sort_by calls its block exactly n times, then sorts the intermediate keys. If computing the key is cheap, the extra allocation in sort_by costs you on small inputs. For anything non-trivial, attribute access, method calls, string manipulation, sort_by wins because it avoids repeated key computation. Calling .length inside a sort block means computing it for every pairwise comparison. Calling it inside sort_by means computing it once per element.

For a deeper look at how Ruby stacks up against Python on sort and related idioms, see Python vs Ruby for Coding Interviews.


The Spaceship Operator: What It Returns and When It Breaks

Someone on Matz's team named this one honestly. <=> looks like a tiny spaceship. It flies between two values and reports their relative position.

It returns -1 if left is smaller, 0 if equal, 1 if larger, and nil if the two objects are not comparable.

1 <=> 2 # -1 2 <=> 2 # 0 3 <=> 2 # 1 "a" <=> 1 # nil

The nil is where things get uncomfortable. When <=> returns nil, Array#sort raises ArgumentError: comparison of X with Y failed. Not a soft failure. Not a nil result. A crash, immediately, with a message that correctly tells you exactly what went wrong but does so in the most alarming way possible.

The scenario you will hit in an interview: an array that might contain nil values, or a method that returns different types depending on some condition.

[3, nil, 1].sort # ArgumentError: comparison of NilClass with Integer failed

Handling nil explicitly is the move. A clean pattern is to sort on a tuple, using a sentinel value to control nil placement:

arr = [3, nil, 1, nil, 2] arr.sort_by { |x| [x.nil? ? 1 : 0, x || 0] } # => [1, 2, 3, nil, nil]

The first element (0 or 1) controls nil placement. The second element is the actual sort key for non-nil values. Flip the first sentinel to push nils to the front instead. The tuple structure buys you clean control without any conditional logic inside the comparator itself.

One more thing: <=> is defined separately on each class. Integer, String, Float, Array and most built-in types define it. Your custom classes do not define it until you do. If you write a class and want to sort instances, you define <=> yourself. More on that below.


Multi-Key Sorting: Return an Array

Array comparison in Ruby is lexicographic. The first element is the primary key, the second is the tiebreaker, the third is the tiebreaker's tiebreaker. This is not a trick. It is just how Ruby compares arrays, and it makes multi-criteria sorting almost free.

people = [ { name: "Alice", age: 30 }, { name: "Bob", age: 25 }, { name: "Alice", age: 25 }, ] people.sort_by { |p| [p[:name], p[:age]] } # => [{Alice, 25}, {Alice, 30}, {Bob, 25}]

Returning an array from sort_by gives you as many sort levels as you want for free.

The wrinkle comes when you need one key ascending and another descending. For integers, negate the key:

# name ascending, age descending people.sort_by { |p| [p[:name], -p[:age]] } # => [{Alice, 30}, {Alice, 25}, {Bob, 25}]

For strings there is no negation. The workaround is a sort block with chained comparisons:

people.sort do |a, b| result = a[:name] <=> b[:name] result.nonzero? || b[:age] <=> a[:age] end

nonzero? returns nil when the value is 0 (falsy), so the || falls through to the tiebreaker. When the result is non-zero, nonzero? returns the value itself (truthy) and short-circuits. A little ugly. Works perfectly.

In an interview, the examiner might ask you to sort by last name descending, then first name ascending. When you reach for sort_by and realize string negation does not exist, this pattern is the one you want.


Comparable: Sort Your Own Objects

Include the module. Define <=>. That is the entire API surface.

class Task include Comparable attr_reader :priority, :name def initialize(priority, name) @priority = priority @name = name end def <=>(other) return @priority <=> other.priority if @priority != other.priority @name <=> other.name end end tasks = [ Task.new(2, "Deploy"), Task.new(1, "Write tests"), Task.new(2, "Code review"), ] tasks.sort # => [Task(1,"Write tests"), Task(2,"Code review"), Task(2,"Deploy")]

Including Comparable generates <, <=, >, >=, between?, and clamp automatically from your single <=> definition. The interviewer asks "can you check if one task has higher priority than another?" You write a > b. It works, because Comparable wired it up.

A detail worth noting: you can call .sort on an array of objects that define <=> without including Comparable. The module is what gives you the comparison operators and between?. If all you need is sorting, the module is optional. If you want a > b and a.between?(low, high), include it.

One subtlety worth raising if the interviewer probes: Comparable defines == in terms of <=> returning 0. If you also want your objects usable as Hash keys, you need to override hash to be consistent with ==. This is the standard Ruby contract that makes the eql? and hash pair important. You almost never need this in an interview problem, but naming the constraint shows you have used Ruby seriously.


Five Ruby Sort Traps

Trap 1: Ruby's sort is not stable.

Elements that compare equal can end up in any order. MRI Ruby uses an introsort variant. Quicksort, which introsort relies on for most inputs, is not stable. Two tasks with the same priority: their output order after sort is undefined. Not probably fine. Undefined.

Ruby's bug tracker has a feature request for stable sort going back years. It has not shipped. Plan accordingly.

To sort stably, include the original index as a tiebreaker:

arr.each_with_index .sort_by { |x, i| [key(x), i] } .map(&:first)

The index guarantees equal-key elements stay in their original relative order. See Stable Sort for the proof that this technique works and the cases where it matters.

Trap 2: The subtraction comparator is a Java habit, not a Ruby one.

In Java you write (a, b) -> a - b as a quick integer comparator. You ship it. It works. Three months later a test fails with values near the integer maximum, because a - b overflows. The Ruby block contract is -1, 0, or 1. Returning a - b produces the right sign for most integers, but it is not the documented contract.

# wrong (works by accident on small integers) arr.sort { |a, b| a - b } # right arr.sort { |a, b| a <=> b }

If you write the subtraction version in a Ruby interview, the interviewer will likely note it. You want to get there first.

Trap 3: Negating floats for reverse sort.

sort_by { |x| -x } works correctly for integers. For floats, -0.0 compares as equal to 0.0 in most circumstances, but that negative zero edge case can surprise you on numeric problems with extreme inputs. For floating-point keys, .sort.reverse or sort { |a, b| b <=> a } is safer. Pick the boring option.

Trap 4: sort! mutates the receiver.

sort returns a new array. sort! sorts in place. If the problem says "do not modify the input" and you call sort! on a parameter, that is a bug. The bang is a warning sign, not a performance optimization. Use sort by default, and only reach for sort! when you explicitly want mutation and have confirmed it is safe.

Trap 5: Three ways to sort descending, and what they cost.

arr.sort.reverse # extra array allocation arr.sort { |a, b| b <=> a } # single pass, correct for all types arr.sort_by { |x| -x } # clean, numbers only

The first option creates two arrays where one would do. On a large input inside a tight loop, that doubles your allocations. The second is the general correct answer. The third is fine for integer keys. In an interview, if someone asks you to explain your choices, reach for the second and say why.


Quick Reference

GoalIdiomatic Ruby
Sort ascendingarr.sort or arr.sort_by { |x| key(x) }
Sort descending (numbers)arr.sort_by { |x| -x }
Sort descending (general)arr.sort { |a, b| b <=> a }
Multi-key, both ascendingarr.sort_by { |x| [x.a, x.b] }
Multi-key, mixed directionarr.sort { |a, b| (a.name \<=\> b.name).nonzero? || b.age \<=\> a.age }
Stable sortarr.each_with_index.sort_by { |x, i| [key(x), i] }.map(&:first)
Sort custom objectsInclude Comparable, define <=>
Handle nils (nils last)arr.sort_by { |x| [x.nil? ? 1 : 0, x || 0] }
Sort by attributearr.sort_by(&:attribute_name)

If you want to practice applying these patterns under real interview conditions with voice-based feedback, SpaceComplexity runs timed mock DSA interviews where sorting problems and custom comparator questions come up frequently across all difficulty levels.

For a broader look at Ruby as an interview language, including which data structures are missing from the standard library, see Ruby for Coding Interviews. If you are deciding between Ruby and Python for an upcoming loop, Python vs Ruby for Coding Interviews breaks down the tradeoffs honestly. The stability section above connects directly to Quicksort vs Merge Sort if you want to understand why stability is an algorithmic property rather than an implementation detail.


Further Reading