Skip to content

Commit 608b963

Browse files
colinleachColin LeachBethanyG
authored
Flower field approaches (#3935)
* [Flower Field] draft approaches doc * minor edits * Suggestions and edits for flower-field approaches intro doc. * Add guidance on approach selection for 2D processing Added a section discussing the choice of approach for processing a 2-dimensional board, emphasizing the trade-offs between readability and performance. Honestly, I'm not sure what's best here, so please don't feel inhibited in hacking it about! --------- Co-authored-by: Colin Leach <[email protected]> Co-authored-by: BethanyG <[email protected]>
1 parent b5d1682 commit 608b963

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"introduction": {
3+
"authors": [
4+
"colinleach",
5+
"BethanyG"
6+
]
7+
}
8+
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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

Comments
 (0)