diff --git a/day13/main.go b/day13/main.go index a1d015d..ccbf417 100644 --- a/day13/main.go +++ b/day13/main.go @@ -10,11 +10,45 @@ import ( "strings" ) +// Coordinate represents a position in our pattern type Coordinate struct { row int col int } +// SmudgeComparer is a stateful comparator between two slices. The idea is that for a given chain of comparisons, +// the two arrays will be allowed to differ by exactly one item, exactly once. All other times, they must be +// exactly identical. +type SmudgeComparer struct { + haveDoneModification bool +} + +// ComparePossiblySmudgedLine performs the smudged comparison. See struct doc for more details +func (comparer *SmudgeComparer) ComparePossiblySmudgedLine(a []int, b []int) bool { + if comparer.haveDoneModification { + return slices.Equal(a, b) + } + + _, _, err := findExtraItem(a, b) + if errors.Is(err, errSlicesEqual) { + return true + } else if errors.Is(err, errSlicesDiffer) { + return false + } else { + comparer.haveDoneModification = true + return true + } +} + +// HaveDoneModification indicates whether or not a modification has been performed during previous runs of +// / ComparePossiblySmudgedLine +func (comparer *SmudgeComparer) HaveDoneModification() bool { + return comparer.haveDoneModification +} + +var errSlicesEqual = errors.New("slices are equal") +var errSlicesDiffer = errors.New("slices differ by more than one element") + func main() { if len(os.Args) != 2 && len(os.Args) != 3 { fmt.Fprintf(os.Stderr, "Usage: %s inputfile\n", os.Args[0]) @@ -42,6 +76,7 @@ func main() { } fmt.Printf("Part 1: %d\n", part1(patternSections)) + fmt.Printf("Part 2: %d\n", part2(patternSections)) } func part1(sections []map[Coordinate]struct{}) int { @@ -58,11 +93,26 @@ func part1(sections []map[Coordinate]struct{}) int { return total } +func part2(sections []map[Coordinate]struct{}) int { + total := 0 + for i, section := range sections { + sectionResults, err := evaluateSmudgedSection(section) + if err != nil { + panic(fmt.Sprintf("invalid section %d: %s", i, err)) + } + + total += sectionResults + } + + return total +} + +// evaluateSection will summarize the given section according to the mirror rules for part 1 func evaluateSection(section map[Coordinate]struct{}) (int, error) { maxRow := maxAlongAxis(Coordinate{row: 1, col: 0}, section) byRow := getAllAlongAxis(Coordinate{row: 1, col: 0}, section) for row := 0; row < maxRow; row++ { - if isMirroredAcrossAxis(row, byRow) { + if isMirroredAcrossAxis(row, byRow, slices.Equal) { return (row + 1) * 100, nil } } @@ -70,7 +120,7 @@ func evaluateSection(section map[Coordinate]struct{}) (int, error) { maxCol := maxAlongAxis(Coordinate{row: 0, col: 1}, section) byCol := getAllAlongAxis(Coordinate{row: 0, col: 1}, section) for col := 0; col < maxCol; col++ { - if isMirroredAcrossAxis(col, byCol) { + if isMirroredAcrossAxis(col, byCol, slices.Equal) { return col + 1, nil } } @@ -78,13 +128,38 @@ func evaluateSection(section map[Coordinate]struct{}) (int, error) { return 0, errors.New("section is not mirrored") } -func isMirroredAcrossAxis(axisIdx int, byAxis map[int][]int) bool { +// evaluateSection will summarize the given section according to the mirror rules for part 2 +func evaluateSmudgedSection(section map[Coordinate]struct{}) (int, error) { + maxRow := maxAlongAxis(Coordinate{row: 1, col: 0}, section) + byRow := getAllAlongAxis(Coordinate{row: 1, col: 0}, section) + for row := 0; row < maxRow; row++ { + comparer := SmudgeComparer{} + if isMirroredAcrossAxis(row, byRow, comparer.ComparePossiblySmudgedLine) && comparer.haveDoneModification { + return (row + 1) * 100, nil + } + } + + maxCol := maxAlongAxis(Coordinate{row: 0, col: 1}, section) + byCol := getAllAlongAxis(Coordinate{row: 0, col: 1}, section) + for col := 0; col < maxCol; col++ { + comparer := SmudgeComparer{} + if isMirroredAcrossAxis(col, byCol, comparer.ComparePossiblySmudgedLine) && comparer.haveDoneModification { + return col + 1, nil + } + } + + return 0, errors.New("section is not mirrored") +} + +// isMirroredAcrossAxis checks, For the given item along an axis, and a mapping between current axis items and +// perpendicular axis items, whether or not the surrounding rows could be considered mirrored +func isMirroredAcrossAxis(axisIdx int, byAxis map[int][]int, eqFunc func(a []int, b []int) bool) bool { maxAxisIdx := maxKey(byAxis) // Radiate "outwards" from the axis item, and check the corresponding elements for offset := 1; axisIdx+offset <= maxAxisIdx && axisIdx-(offset-1) >= 0; offset++ { - rowItems := byAxis[axisIdx-(offset-1)] + axisItems := byAxis[axisIdx-(offset-1)] oppositeItems := byAxis[axisIdx+offset] - if !slices.Equal(rowItems, oppositeItems) { + if !eqFunc(axisItems, oppositeItems) { return false } } @@ -92,6 +167,58 @@ func isMirroredAcrossAxis(axisIdx int, byAxis map[int][]int) bool { return true } +// findExtraItem finds the index of a standalone differing item in each list. If one of the two return values +// is not negative one, that is the index of the standout item (note that only one will be > -1 at a time). +// If the two slices are equal, errSlicesEqual is returned. If the two slices differ by more than one element, +// errSlicesDiffer is returned. +// +// NOTE: this function assumes that these lists are sorted +func findExtraItem(a, b []int) (int, int, error) { + if abs(len(a)-len(b)) > 1 { + return -1, -1, errSlicesDiffer + } + + aCursor := 0 + bCursor := 0 + aCandidate := -1 + bCandidate := -1 + for aCursor < len(a) && bCursor < len(b) { + aItem := a[aCursor] + bItem := b[bCursor] + + if aItem == bItem { + aCursor++ + bCursor++ + } else if aItem < bItem { + if aCandidate != -1 || bCandidate != -1 { + return -1, -1, errSlicesDiffer + } + + aCandidate = aCursor + aCursor++ + } else { + if aCandidate != -1 || bCandidate != -1 { + return -1, -1, errSlicesDiffer + } + + bCandidate = bCursor + bCursor++ + } + } + + if aCursor < len(a) && bCursor >= len(b) { + return aCursor, -1, nil + } else if bCursor >= len(a) && bCursor < len(b) { + return -1, bCursor, nil + } else if aCandidate == -1 && bCandidate == -1 { + return -1, -1, errSlicesEqual + } + + return aCandidate, bCandidate, nil +} + +// maxAlongAxis finds the max value along the given axis, using a coordinate set of {1, 0} or {0, 1} +// Panics if an invalid axis is given func maxAlongAxis(axis Coordinate, section map[Coordinate]struct{}) int { if !(axis.row == 0 && axis.col == 1 || axis.row == 1 && axis.col == 0) { panic("invalid axis") @@ -108,6 +235,10 @@ func maxAlongAxis(axis Coordinate, section map[Coordinate]struct{}) int { return res } +// getAllAlongAxis gets all items along the given axis, with the resulting map holding each index along that axis +// and the values holding all elements that are perpendicular to that axis +// +// Panics if an invalid axis is given func getAllAlongAxis(axis Coordinate, section map[Coordinate]struct{}) map[int][]int { if !(axis.row == 0 && axis.col == 1 || axis.row == 1 && axis.col == 0) { panic("invalid axis") @@ -171,3 +302,11 @@ func tryParse[T any](items []string, parse func(string) (T, error)) ([]T, error) return res, nil } + +func abs(n int) int { + if n < 0 { + return -n + } + + return n +}