|
| 1 | + |
| 2 | +# Introduction |
| 3 | + |
| 4 | +The Flower Field exercise is designed to practice iteration, boolean logic and raising errors with error messages. |
| 5 | +It also provides ample opportunities for working with `lists`, `list-indexing`, `comprehensions`, `tuples`, and `generator-expressions`. |
| 6 | + |
| 7 | + |
| 8 | +## General considerations and guidance for the exercise |
| 9 | + |
| 10 | +It is possible (_and potentially easier_) to break the problem down into a series of sub-tasks, with plenty of scope to mix and match strategies within these sections: |
| 11 | + |
| 12 | +- Is the board valid? |
| 13 | +- Is the current square a flower? |
| 14 | +- What are the valid neighboring squares, and how many of them contain flowers? |
| 15 | + |
| 16 | +Core Python does not support matrices nor N-dimensional arrays, though these are at the heart of many third-party packages such as NumPy. |
| 17 | +Due to this limitation, the input board and final result for this exercise are implemented in the tests as a `list` of strings; one string per "row" of the board. |
| 18 | + |
| 19 | + |
| 20 | +Intermediate processing for the problem is likely to use lists of lists with a final `''.join()` for each "row" in the returned single `list`, although other strategies could be employed. |
| 21 | +Helpfully, Python considers both [lists][ordered-sequences] and [strings][text-sequences] as [sequence types][common-sequence-operations], and can iterate over/index into both in the same fashion. |
| 22 | + |
| 23 | + |
| 24 | +## Validating boards |
| 25 | + |
| 26 | +The "board" or "field" must be rectangular: essentially, all rows must be the same length as the first row. |
| 27 | +This means that any board can be invalidated using the built-ins `all()` or `any()` to check for equal lengths of the strings in the `list` (_see an example below_). |
| 28 | + |
| 29 | +Perhaps surprisingly, both row and column lengths **can be zero/empty**, so an apparently "non-existent board or field" is considered valid and needs special handling: |
| 30 | + |
| 31 | + |
| 32 | +```python |
| 33 | + rows = len(garden) |
| 34 | + if rows > 0: |
| 35 | + cols = len(garden[0]) |
| 36 | + else: |
| 37 | + return [] |
| 38 | + |
| 39 | + if any([len(row) != cols for row in garden]): |
| 40 | + raise ValueError('The board is invalid with current input.') |
| 41 | +``` |
| 42 | + |
| 43 | +Additionally, the only valid entries for the board/field are a space `' '` (_position empty_) or an asterisk `'*'` (_flower in position_). |
| 44 | + All other characters are _invalid_ and should `raise` an error with an appropriate error message. |
| 45 | + The exercise [tests][flower-field-tests] check for specific error messages including punctuation, so should be read or copied carefully. |
| 46 | + |
| 47 | +Some solutions use regular expressions for these checks, but there are simpler (_and more performant_) options: |
| 48 | + |
| 49 | + |
| 50 | +```python |
| 51 | + if garden[row][col] not in (' ', '*'): |
| 52 | + # raise error |
| 53 | +``` |
| 54 | + |
| 55 | +Depending on how the code is structured, it may be possible to combine the checks for row length with the checks for valid characters. |
| 56 | +More commonly, board/field dimensions are checked at the beginning. |
| 57 | +Invalid characters are then detected while iterating through the rows of the board/field. |
| 58 | + |
| 59 | + |
| 60 | +## Processing squares and finding occupied neighbors |
| 61 | + |
| 62 | +Squares containing a flower are straightforward: you can copy `'*'` to the corresponding square in the results `list`. |
| 63 | + |
| 64 | +Empty squares present a challenge: count how many flowers are in all the squares _adjacent_ to it. |
| 65 | +But *How many squares are adjacent to the current position?* |
| 66 | +In the middle of a reasonably large board there will be 8 adjacent squares, but this is reduced for squares at edges or corners. |
| 67 | + |
| 68 | + |
| 69 | +### Some square processing methods |
| 70 | + |
| 71 | +Note that we only want a _count_ of nearby flowers. |
| 72 | +Their precise _location_ is irrelevant. |
| 73 | + |
| 74 | + |
| 75 | +1. Nested `if..elif` statements |
| 76 | + |
| 77 | + This can be made to work, but can quickly become very verbose or confusing if not thought out carefully: |
| 78 | + |
| 79 | + ```python |
| 80 | + for index_i, _ in enumerate(flowerfield): |
| 81 | + temp_row = "" |
| 82 | + for index_j in range(column_count): |
| 83 | + if flowerfield[index_i][index_j].isspace(): |
| 84 | + temp_row += count_flowers(flowerfield, index_i, index_j) |
| 85 | + elif flowerfield[index_i][index_j] == "*": |
| 86 | + temp_row += "*" |
| 87 | + else: |
| 88 | + raise ValueError("The board is invalid with current input.") |
| 89 | + flowerfield[index_i] = temp_row |
| 90 | + ``` |
| 91 | + |
| 92 | +2. Explicit coordinates |
| 93 | + |
| 94 | + List all the possibilities then filter out any squares that fall outside the board: |
| 95 | + |
| 96 | + ```python |
| 97 | + def count_adjacent(row, col): |
| 98 | + adj_squares = ( |
| 99 | + (row-1, col-1), (row-1, col), (row-1, col+1), |
| 100 | + (row, col-1), (row, col+1), |
| 101 | + (row+1, col-1), (row+1, col), (row+1, col+1), |
| 102 | + ) |
| 103 | + |
| 104 | + # which are on the board? |
| 105 | + neighbors = [garden[row][col] for row, col in adj_squares |
| 106 | + if 0 <= row < rows and 0 <= col < cols] |
| 107 | + # how many contain flowers? |
| 108 | + return len([adj for adj in neighbors if adj == '*']) |
| 109 | + ``` |
| 110 | + |
| 111 | +3. Using a comprehension or generator expression |
| 112 | + |
| 113 | + ```python |
| 114 | + # Using a list comprehension |
| 115 | + squares = [(row + row_diff, col + col_diff) |
| 116 | + for row_diff in (-1, 0, 1) |
| 117 | + for col_diff in (-1, 0, 1)] |
| 118 | + |
| 119 | + # Using a generator expression |
| 120 | + squares = ((row + row_diff, col + col_diff) |
| 121 | + for row_diff in (-1, 0, 1) |
| 122 | + for col_diff in (-1, 0, 1)) |
| 123 | + ``` |
| 124 | + |
| 125 | + A key insight here is that we can work on a 3x3 block of cells: we already ensured that the central cell does *not* contain a flower that would affect our count. |
| 126 | + We can then filter and count as in the `count_adjacent` function in the previous code. |
| 127 | + |
| 128 | +4. Using complex numbers |
| 129 | + |
| 130 | + ```python |
| 131 | + def neighbors(cell): |
| 132 | + """Yield all eight neighboring cells.""" |
| 133 | + for x in (-1, 0, 1): |
| 134 | + for y in (-1, 0, 1): |
| 135 | + if offset := x + y * 1j: |
| 136 | + yield cell + offset |
| 137 | + ``` |
| 138 | + |
| 139 | + A particularly elegant solution is to treat the board/field as a portion of the complex plane. |
| 140 | + In Python, [complex numbers][complex-numbers] are a standard numeric type, alongside integers and floats. |
| 141 | + *This is less widely known than it deserves to be.* |
| 142 | + |
| 143 | + The constructor for a complex number is `complex(x, y)` or (as here) `x + y * 1j`, where `x` and `y` are the real and imaginary parts, respectively. |
| 144 | + |
| 145 | + There are two properties of complex numbers that help us in this case: |
| 146 | + - The real and imaginary parts act independently under addition. |
| 147 | + - The value `complex(0, 0)` is the complex zero, which like integer zero is treated as False in Python conditionals. |
| 148 | + |
| 149 | + A tuple of integers would not work as a substitute, because `+` behaves as the concatenation operator for tuples: |
| 150 | + |
| 151 | + ```python |
| 152 | + >>> complex(1, 2) + complex(3, 4) |
| 153 | + (4+6j) |
| 154 | + >>> (1, 2) + (3, 4) |
| 155 | + (1, 2, 3, 4) |
| 156 | + ``` |
| 157 | + |
| 158 | + Note also the use of the ["walrus" operator][walrus-operator] `:=` in the definition of `offset` above. |
| 159 | + This relatively recent addition to Python simplifies variable assignment within the limited scope of an if statement or a comprehension. |
| 160 | + |
| 161 | + |
| 162 | +## Ways of putting it all together |
| 163 | + |
| 164 | +The example below takes an object-oriented approach using complex numbers, included because it is a particularly clear illustration of the various topics discussed above. |
| 165 | + |
| 166 | +All validation checks are done in the object constructor. |
| 167 | + |
| 168 | +```python |
| 169 | +"""Flower Field.""" |
| 170 | + |
| 171 | +def neighbors(cell): |
| 172 | + """Yield all eight neighboring cells.""" |
| 173 | + for x in (-1, 0, 1): |
| 174 | + for y in (-1, 0, 1): |
| 175 | + if offset := x + y * 1j: |
| 176 | + yield cell + offset |
| 177 | + |
| 178 | + |
| 179 | +class Garden: |
| 180 | + """garden helper.""" |
| 181 | + |
| 182 | + def __init__(self, data): |
| 183 | + """Initialize.""" |
| 184 | + self.height = len(data) |
| 185 | + self.width = len(data[0]) if data else 0 |
| 186 | + |
| 187 | + if not all(len(row) == self.width for row in data): |
| 188 | + raise ValueError("The board is invalid with current input.") |
| 189 | + |
| 190 | + self.data = {} |
| 191 | + for y, line in enumerate(data): |
| 192 | + for x, val in enumerate(line): |
| 193 | + self.data[x + y * 1j] = val |
| 194 | + if not all(v in (" ", "*") for v in self.data.values()): |
| 195 | + raise ValueError("The board is invalid with current input.") |
| 196 | + |
| 197 | + def val(self, x, y): |
| 198 | + """Return the value for one square.""" |
| 199 | + cur = x + y * 1j |
| 200 | + if self.data[cur] == "*": |
| 201 | + return "*" |
| 202 | + count = sum(self.data.get(neighbor, "") == "*" for neighbor in neighbors(cur)) |
| 203 | + return str(count) if count else " " |
| 204 | + |
| 205 | + def convert(self): |
| 206 | + """Convert the garden.""" |
| 207 | + return ["".join(self.val(x, y) |
| 208 | + for x in range(self.width)) |
| 209 | + for y in range(self.height)] |
| 210 | + |
| 211 | + |
| 212 | +def annotate(garden): |
| 213 | + """Annotate a garden.""" |
| 214 | + return Garden(garden).convert() |
| 215 | +``` |
| 216 | + |
| 217 | +The example below takes an opposite strategy, using a single function, `list comprehensions`, and nested `if-elif` statements": |
| 218 | + |
| 219 | +```python |
| 220 | +def annotate(garden): |
| 221 | + grid = [[0 for _ in row] for row in garden] |
| 222 | + positions = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)] |
| 223 | + |
| 224 | + for col, row in enumerate(garden): |
| 225 | + # Checking that the board/field is rectangular up front. |
| 226 | + if len(row) != len(grid[0]): |
| 227 | + raise ValueError("The board is invalid with current input.") |
| 228 | + |
| 229 | + # Validating square content. |
| 230 | + for index, square in enumerate(row): |
| 231 | + if square == " ": |
| 232 | + continue |
| 233 | + elif square != "*": |
| 234 | + raise ValueError("The board is invalid with current input.") |
| 235 | + grid[col][index] = "*" |
| 236 | + |
| 237 | + for dr, dc in positions: |
| 238 | + dr += col |
| 239 | + if dr < 0 or dr >= len(grid): |
| 240 | + continue |
| 241 | + |
| 242 | + dc += index |
| 243 | + if dc < 0 or dc >= len(grid[dr]): |
| 244 | + continue |
| 245 | + |
| 246 | + if grid[dr][dc] != "*": |
| 247 | + grid[dr][dc] += 1 |
| 248 | + |
| 249 | + return ["".join(" " if square == 0 else str(square) for square in row) for row in grid] |
| 250 | +``` |
| 251 | + |
| 252 | +## Which approach to use? |
| 253 | + |
| 254 | +Processing a 2-dimensional board inevitably means using some form of nested loops, which is likely to dominate performance. |
| 255 | + |
| 256 | +Using comprehensions and/or generators instead of explicit loops may offer a slight speed-up, as well as more concise code. |
| 257 | +However, performance differences are probably small, and the concise syntax _may_ be less easy to read. |
| 258 | + |
| 259 | +In this case, readability is probably more important than aggressive optimization. |
| 260 | +So, we need to understand the target audience, and how they perceive "readability". |
| 261 | + |
| 262 | +Python experts find comprehensions very idiomatic (and generators, which have similar syntax), but programmers with a different language background can get confused. |
| 263 | + |
| 264 | +Complex numbers are a more extreme case: wonderfully clear and elegant for people with a suitable mathematical background, potentially mystifying for the wider population. |
| 265 | +Tastes differ! |
| 266 | + |
| 267 | +[common-sequence-operations]: https://docs.python.org/3.13/library/stdtypes.html#common-sequence-operations |
| 268 | +[complex-numbers]: https://exercism.org/tracks/python/concepts/complex-numbers |
| 269 | +[flower-field-tests]: https://github.com/exercism/python/blob/main/exercises/practice/flower-field/flower_field_test.py |
| 270 | +[ordered-sequences]: https://docs.python.org/3.13/library/stdtypes.html#sequence-types-list-tuple-range |
| 271 | +[text-sequences]: https://docs.python.org/3.13/library/stdtypes.html#text-sequence-type-str |
| 272 | +[walrus-operator]: https://peps.python.org/pep-0572/ |
0 commit comments