|
| 1 | +--- |
| 2 | +title: "Advent of Code: Day 4" |
| 3 | +date: 2024-12-06T11:44:39+01:00 |
| 4 | +tags: |
| 5 | + - dev |
| 6 | + - devops |
| 7 | +--- |
| 8 | + |
| 9 | +Link to [Day #4](https://adventofcode.com/2024/day/4) puzzle. |
| 10 | + |
| 11 | +<!--more--> |
| 12 | + |
| 13 | +It's a pretty typical 2D matrix search problem, or a graph search problem, if |
| 14 | +you will. |
| 15 | + |
| 16 | +The problem is naturally unraveled into the following searches: |
| 17 | + |
| 18 | +- horizontally |
| 19 | +- horizontally, reversed |
| 20 | +- vertically |
| 21 | +- vertically, reversed |
| 22 | +- diagonally, all 4 directions (NW, NE, SW, SE) |
| 23 | + |
| 24 | +It's possible to write a single pair of for loops that addresses the general |
| 25 | +case. The (classic) idea is to think of all 8 compass directions to move along |
| 26 | +the matrix: |
| 27 | + |
| 28 | +- (1, 0) |
| 29 | +- (-1, 0) |
| 30 | +- (0, 1) |
| 31 | +- (0, -1) |
| 32 | +- (1, 1) |
| 33 | +- (-1, -1) |
| 34 | +- (-1, 1) |
| 35 | +- (1, -1) |
| 36 | + |
| 37 | +Within the inner iteration, change `x += dx` and `y += dy` (or `i += di`, `j += |
| 38 | +dj`, naming is hard). I did this many times in C++ though, and I want to write |
| 39 | +elegant Python code. |
| 40 | + |
| 41 | +Therefore I came up with the following solution instead, with nested list |
| 42 | +comprehensions: |
| 43 | + |
| 44 | +```python |
| 45 | +def search_horizontal(matrix, keyword): |
| 46 | + return sum((True for row in matrix for i in range(len(row) - len(keyword) + 1) if "".join(row[i:i + len(keyword)]) in [keyword, keyword[::-1]])) |
| 47 | +``` |
| 48 | + |
| 49 | +It follows the same principle as the original intent, however it leverages |
| 50 | +list slices so that we can omit the `dx/dy` step. |
| 51 | + |
| 52 | +The vertical search is pretty straightforward: it is just a matter of running |
| 53 | +the horizontal search in the transposed matrix (`zip(*matrix)`). |
| 54 | + |
| 55 | +I must confess that using `zip` to transpose matrices always felt magical and a |
| 56 | +mere coincidence that it just works™. Ruby has a `.transpose` method, which is |
| 57 | +more readable. |
| 58 | + |
| 59 | +For the diagonal search, I couldn't think of an elegant list comprehension |
| 60 | +manner to address it. Is it even possible to "2D slice" in Python? |
| 61 | + |
| 62 | +After-the-fact I decided to ask ChatGPT, and it is indeed possible, but it |
| 63 | +requires NumPy: |
| 64 | + |
| 65 | +> If a is 2-D, returns the diagonal of a with the given offset, i.e., the |
| 66 | +> collection of elements of the form a[i, i+offset]. If a has more than two |
| 67 | +> dimensions, then the axes specified by axis1 and axis2 are used to determine |
| 68 | +> the 2-D sub-array whose diagonal is returned. The shape of the resulting array |
| 69 | +> can be determined by removing axis1 and axis2 and appending an index to the |
| 70 | +> right equal to the size of the resulting diagonals. |
| 71 | +
|
| 72 | +The method call resembles `numpy.array([[1, 2], [3, 4]]).diagonal(offset=1)`, |
| 73 | +perhaps with the aid of `.flip()` to account for the other direction. |
| 74 | + |
| 75 | +Anyway, my plain diagonal search is: |
| 76 | + |
| 77 | +```python |
| 78 | +def search_diagonal(matrix, keyword): |
| 79 | + rows = len(matrix) |
| 80 | + cols = len(matrix[0]) |
| 81 | + |
| 82 | + count = 0 |
| 83 | + |
| 84 | + for i in range(rows): |
| 85 | + for j in range(cols): |
| 86 | + if i + len(keyword) <= rows and j + len(keyword) <= cols: |
| 87 | + if "".join(matrix[i + k][j + k] for k in range(len(keyword))) in [keyword, keyword[::-1]]: |
| 88 | + count += 1 |
| 89 | + if i + len(keyword) <= rows and j - len(keyword) >= -1: |
| 90 | + if "".join(matrix[i + k][j - k] for k in range(len(keyword))) in [keyword, keyword[::-1]]: |
| 91 | + count += 1 |
| 92 | + |
| 93 | + return count |
| 94 | +``` |
| 95 | + |
| 96 | +Part two is fundamentally a different problem. |
| 97 | + |
| 98 | +One way to address it is to search for all `'A'` characters, and then look |
| 99 | +around its "edges" to see if they contain exactly two `'M'` and two `'S'`, and |
| 100 | +that they are properly arranged: |
| 101 | + |
| 102 | +```python |
| 103 | +def search_double_mas(matrix): |
| 104 | + rows = len(matrix) |
| 105 | + cols = len(matrix[0]) |
| 106 | + |
| 107 | + count = 0 |
| 108 | + |
| 109 | + for i in range(1, rows - 1): |
| 110 | + for j in range(1, cols - 1): |
| 111 | + if matrix[i][j] != 'A': |
| 112 | + continue |
| 113 | + |
| 114 | + # look at a QWERTY keyboard to make sense of these variable names |
| 115 | + q = matrix[i - 1][j - 1] |
| 116 | + e = matrix[i - 1][j + 1] |
| 117 | + z = matrix[i + 1][j - 1] |
| 118 | + c = matrix[i + 1][j + 1] |
| 119 | + edges = [q, e, z, c] |
| 120 | + |
| 121 | + if edges.count('M') != 2 or edges.count('S') != 2: |
| 122 | + continue |
| 123 | + |
| 124 | + if q == e or q == z: |
| 125 | + count += 1 |
| 126 | + |
| 127 | + return count |
| 128 | +``` |
| 129 | + |
| 130 | +I couldn't find an opportunity for reuse of the solution from part one. |
| 131 | + |
| 132 | +The full solution: |
| 133 | + |
| 134 | +```python |
| 135 | +#!/usr/bin/env python3 |
| 136 | +import sys |
| 137 | + |
| 138 | +def search_horizontal(matrix, keyword): |
| 139 | + return sum((True for row in matrix for i in range(len(row) - len(keyword) + 1) if "".join(row[i:i + len(keyword)]) in [keyword, keyword[::-1]])) |
| 140 | + |
| 141 | +def search_vertical(matrix, keyword): |
| 142 | + return search_horizontal(zip(*matrix), keyword) |
| 143 | + |
| 144 | +def search_diagonal(matrix, keyword): |
| 145 | + rows = len(matrix) |
| 146 | + cols = len(matrix[0]) |
| 147 | + |
| 148 | + count = 0 |
| 149 | + |
| 150 | + for i in range(rows): |
| 151 | + for j in range(cols): |
| 152 | + if i + len(keyword) <= rows and j + len(keyword) <= cols: |
| 153 | + if "".join(matrix[i + k][j + k] for k in range(len(keyword))) in [keyword, keyword[::-1]]: |
| 154 | + count += 1 |
| 155 | + if i + len(keyword) <= rows and j - len(keyword) >= -1: |
| 156 | + if "".join(matrix[i + k][j - k] for k in range(len(keyword))) in [keyword, keyword[::-1]]: |
| 157 | + count += 1 |
| 158 | + |
| 159 | + return count |
| 160 | + |
| 161 | + |
| 162 | +def search_double_mas(matrix): |
| 163 | + rows = len(matrix) |
| 164 | + cols = len(matrix[0]) |
| 165 | + |
| 166 | + count = 0 |
| 167 | + |
| 168 | + for i in range(1, rows - 1): |
| 169 | + for j in range(1, cols - 1): |
| 170 | + if matrix[i][j] != 'A': |
| 171 | + continue |
| 172 | + |
| 173 | + # look at a QWERTY keyboard to make sense of these variable names |
| 174 | + q = matrix[i - 1][j - 1] |
| 175 | + e = matrix[i - 1][j + 1] |
| 176 | + z = matrix[i + 1][j - 1] |
| 177 | + c = matrix[i + 1][j + 1] |
| 178 | + edges = [q, e, z, c] |
| 179 | + |
| 180 | + if edges.count('M') != 2 or edges.count('S') != 2: |
| 181 | + continue |
| 182 | + |
| 183 | + if q == e or q == z: |
| 184 | + count += 1 |
| 185 | + |
| 186 | + return count |
| 187 | + |
| 188 | + |
| 189 | + |
| 190 | +def main(): |
| 191 | + with open(sys.argv[1]) as input: |
| 192 | + lines = input.read().splitlines() |
| 193 | + |
| 194 | + keyword = "XMAS" |
| 195 | + |
| 196 | + # ['abcd', 'efgh', 'ijkl'] -> [['a', 'b', 'c', 'd'], ['e', 'f', 'g', 'h'], ['i', 'j', 'k', 'l']] |
| 197 | + matrix = [list(line) for line in lines] |
| 198 | + |
| 199 | + # part one |
| 200 | + print(search_horizontal(matrix, keyword) + search_vertical(matrix, keyword) + search_diagonal(matrix, keyword)) |
| 201 | + |
| 202 | + # part two |
| 203 | + print(search_double_mas(matrix)) |
| 204 | + |
| 205 | + |
| 206 | +if __name__ == '__main__': |
| 207 | + main() |
| 208 | +``` |
0 commit comments