Day 3: Gear Ratios
by @bishabosha and @iusildra
Puzzle description
https://adventofcode.com/2023/day/3
Solution summary
The solution models the input as a grid of numbers and symbols.
- Define some models to represent the input:
case class Coord(x: Int, y: Int)
to represent one coordinate on the gridcase class Symbol(sym: String, pos: Coord)
to represent one symbol and its locationcase class PartNumber(value: Int, start: Coord, end: Coord)
to represent one number and its starting/ending location
- Parse the input to create a sparse collection of symbols and numbers
- Separate the symbols from the numbers
- Then summarise the whole grid as follows:
- in
part1
, find allnumbers
adjacent to asymbol
, and sum the total of the resultingnumber
values, - in
part2
,- Find all
numbers
adjacent to asymbol
whosechar
value is*
- Filter out the
*
symbol with less/more than 2 adjacent numbers - For each
*
symbol remaining, take the product of its two number values - Sum the resulting products
- Find all
- a symbol is adjacent to a number (and vice-versa) if that symbol is inside the number's bounding box on the grid at 1 unit away (see manhattan distance)
- in
Global
We want a convenient way to represent a coordinate to be able to compute whether one element is within the bounding box of another.
case class Coord(x: Int, y: Int):
def within(start: Coord, end: Coord) =
if y < start.y || y > end.y then false
else if x < start.x || x > end.x then false
else true
We also want to easily distinguish a Symbol
from a Number
, and to know whether a Symbol
is adjacent to a Number
:
case class PartNumber(value: Int, start: Coord, end: Coord)
case class Symbol(sym: String, pos: Coord):
def neighborOf(number: PartNumber) = pos.within(
Coord(number.start.x - 1, number.start.y - 1),
Coord(number.end.x + 1, number.end.y + 1)
)
Then we need to parse the input to get every Symbol
and Number
:
import scala.util.matching.Regex.Match
object IsInt:
def unapply(in: Match): Option[Int] = in.matched.toIntOption
def findPartsAndSymbols(source: String) =
val extractor = """(\d+)|[^.\d]""".r
source.split("\n").zipWithIndex.flatMap: (line, i) =>
extractor
.findAllMatchIn(line)
.map:
case m @ IsInt(nb) =>
PartNumber(nb, Coord(m.start, i), Coord(m.end - 1, i))
case s => Symbol(s.matched, Coord(s.start, i))
The object IsInt
with the .unapply
method is called an extractor. It allows to define patterns to match on. Here it will give me a number if it can parse it from a string.
The findPartsAndSymbols
does the parsing and returns a collection of PartNumber
and Symbol
. What we want to match on is either a number or a symbol (which is anything except the .
and a digit
). The regex match gives us some information (such as starting / ending position of the matched string) which we use to create the PartNumber
and Symbol
instances.
The m @ IsInt(nb)
is a pattern match that will match on the IsInt
extractor and binds the parsed integer to nb
and the value being matched to m
. A similar way to achieve this is:
.map: m =>
m match
case IsInt(nb) => PartNumber(nb, Coord(m.start, i), Coord(m.end - 1, i))
case s => Symbol(s.matched, Coord(s.start, i))
Part 1
Compute part1
as described above:
- Find all
numbers
andsymbols
in the grid - Filter out the symbols in a separate collection
- For each number element of the grid and if it has a least one symbol neighbor, return its value
- Sum the resulting values
def part1(input: String) =
val all = findPartsAndSymbols(input)
val symbols = all.collect { case s: Symbol => s }
all
.collect:
case n: PartNumber if symbols.exists(_.neighborOf(n)) =>
n.value
.sum
Part 2
We might want to represent a Gear
to facilitate the computation of the gear ratios:
case class Gear(part: PartNumber, symbol: Symbol)
(Note: a case class is not necessary here, a tuple would do the job)
Compute part2
as described above:
- Find all
numbers
andsymbols
in the grid - Filter out the symbols in a separate collection
- For each number element of the grid and if it has one
*
neighbor, return aGear
with the number and the*
symbol. For any other cases, returnNone
- The
.flatMap
method will filter out theNone
values when flattening, so we get a collection ofGear
only
- The
- Group them by
symbol
and map the values to thenumber
values- So we obtain a
Map[Symbol, List[Int]]
instead of aMap[Symbol, List[Gear]]
- So we obtain a
- Filter out the symbols with less/more than 2 adjacent numbers
- For each entry remaining, take the product of its two number values and sum the resulting products
def part2(input: String) =
val all = findPartsAndSymbols(input)
val symbols = all.collect { case s: Symbol => s }
all
.flatMap:
case n: PartNumber =>
symbols
.find(_.neighborOf(n))
.filter(_.sym == "*")
.map(Gear(n, _))
case _ => None
.groupMap(_.symbol)(_.part.value)
.filter(_._2.length == 2)
.foldLeft(0) { _ + _._2.product }
Final code
case class Coord(x: Int, y: Int):
def within(start: Coord, end: Coord) =
if y < start.y || y > end.y then false
else if x < start.x || x > end.x then false
else true
case class PartNumber(value: Int, start: Coord, end: Coord)
case class Symbol(sym: String, pos: Coord):
def neighborOf(number: PartNumber) = pos.within(
Coord(number.start.x - 1, number.start.y - 1),
Coord(number.end.x + 1, number.end.y + 1)
)
object IsInt:
def unapply(in: Match): Option[Int] = in.matched.toIntOption
def findPartsAndSymbols(source: String) =
val extractor = """(\d+)|[^.\d]""".r
source.split("\n").zipWithIndex.flatMap: (line, i) =>
extractor
.findAllMatchIn(line)
.map:
case m @ IsInt(nb) =>
PartNumber(nb, Coord(m.start, i), Coord(m.end - 1, i))
case s => Symbol(s.matched, Coord(s.start, i))
def part1(input: String) =
val all = findPartsAndSymbols(input)
val symbols = all.collect { case s: Symbol => s }
all
.collect:
case n: PartNumber if symbols.exists(_.neighborOf(n)) =>
n.value
.sum
case class Gear(part: PartNumber, symbol: Symbol)
def part2(input: String) =
val all = findPartsAndSymbols(input)
val symbols = all.collect { case s: Symbol => s }
all
.flatMap:
case n: PartNumber =>
symbols
.find(_.neighborOf(n))
.filter(_.sym == "*")
.map(Gear(n, _))
case _ => None
.groupMap(_.symbol)(_.part.value)
.filter(_._2.length == 2)
.foldLeft(0) { _ + _._2.product }
Run it in the browser
Part 1
Part 2
Solutions from the community
- Solution by johnduffell
- Solution by Yann Moisan
- Solution by Karthick Pachiappan
- Solution of Jan Boerman.
- Solution by Spamegg
- Solution by Niels Prins
- Solution by Philippus Baalman
- Solution by Karl Bielefeldt
- Solution by Michael Pilquist
- Solution by Nikolai Riabykh
- Solution by Brian Xiang
- Solution by CJ Smith
- Solution by Alexandru Nedelcu
- Solution by Joel Edwards
- Solution by Guillaume Vandecasteele
- Solution by jnclt
- Solution by Will Billingsley
- Solution by Seth Tisue
- Solution by Thanh Le
- Solution by g.berezin
- Solution by Marconi Lanna
- Solution by Rui Alves
- Solution by Paweł Cembaluk
Share your solution to the Scala community by editing this page.