Advent of Code 2024: Day 5 - Dependencies and Ordering

I found myself in the North Pole's printing department today, helping an elf with their safety manual updates. The printer had strict rules about page ordering - certain pages had to be printed before others. As I stared at the long list of rules like "47|53", I couldn't help but smile. This wasn't just about printing pages - this was a dependency resolution puzzle in disguise.

The Challenge: A Printer's Dependency Graph

The problem gave me two things: rules like "47|53" (meaning page 47 must be printed before page 53), and sequences of page numbers to validate. Part 1 asked me to identify sequences that already followed all the rules. Part 2 got more interesting - I needed to sort the invalid sequences according to the rules.

Four Languages, Four Approaches

Rust: Bidirectional Maps for Efficient Ordering

In Rust, I modeled the rules using two HashMaps - one for forward dependencies and one for reverse:

#[derive(Debug, Default)]
struct RuleMaps {
    forward: HashMap<i32, HashSet<i32>>,
    reverse: HashMap<i32, HashSet<i32>>,
}

impl RuleMaps {
    fn compare(&self, a: i32, b: i32) -> std::cmp::Ordering {
        if self.forward.get(&a)
            .map_or(false, |targets| targets.contains(&b)) {
            Ordering::Less
        } else if self.forward.get(&b)
            .map_or(false, |targets| targets.contains(&a)) {
            Ordering::Greater
        } else {
            a.cmp(&b)
        }
    }
}

This bidirectional approach made both validation and sorting efficient. The compare function integrates seamlessly with Rust's sorting mechanisms while respecting our dependency rules.

Elixir: Pattern Matching for Clear Logic

Elixir's pattern matching made the rule checking particularly elegant:

def should_come_before?(rules, a, b) do
  case Enum.find(rules, fn {source, target} ->
         (source == a and target == b) or (source == b and target == a)
       end) do
    {^a, ^b} -> true   # Direct rule a|b exists
    {^b, ^a} -> false  # Direct rule b|a exists
    nil -> false       # No direct rule exists
  end
end

The pin operator (^) creates a beautiful declarative way to express our ordering rules. The code reads almost like the problem description itself.

Go: Pragmatic Sorting

Go's approach shows its strength in straightforward, efficient code:

func shouldComeBefore(rules []rule, a, b int) bool {
    for _, r := range rules {
        if r.source == a && r.target == b {
            return true
        }
        if r.source == b && r.target == a {
            return false
        }
    }
    return false
}

func (s sequence) sort(rules []rule) sequence {
    sorted := make(sequence, len(s))
    copy(sorted, s)
    sort.Slice(sorted, func(i, j int) bool {
        return shouldComeBefore(rules, sorted[i], sorted[j])
    })
    return sorted
}

The explicit handling of comparisons and sorting reflects Go's preference for clarity over cleverness.

Haskell: Pure Functional Dependencies

Haskell's solution leverages efficient data structures from the containers package:

buildRuleMaps :: [Rule] -> (IntMap IntSet, IntMap IntSet)
buildRuleMaps rules = foldl' insertRule (IM.empty, IM.empty) rules
  where
    insertRule (!fwd, !rev) (src, tgt) =
        ( IM.insertWith IS.union src (IS.singleton tgt) fwd
        , IM.insertWith IS.union tgt (IS.singleton src) rev
        )

The use of IntMap and IntSet isn't just about performance - it's about expressing relationships between numbers in a purely functional way. The strict annotations (!) ensure our dependency maps are evaluated eagerly.

Performance Through Data Structures

Each language brought its own insights about efficient dependency handling. Rust's bidirectional maps made lookups O(1). Elixir's pattern matching compiler optimized our rule checks. Go's sort package provided an efficient implementation of our custom comparator. Haskell's specialized data structures gave us both performance and correctness guarantees.

Reflections on Dependencies

You know what's funny? I spend my days working with package managers and build systems, dealing with complex dependency trees. Yet here I was, getting excited about a printer queue doing essentially the same thing. Each of my languages handled it differently - Rust with its efficient maps, Elixir with elegant pattern matching, Go keeping it pragmatic, and Haskell showing off its pure functional approach. But they all had to solve the same core problem: making sure page 47 prints before page 53, just like making sure libssl installs before nginx.