2020-12-11

Created on Friday, December 11, 2020.

Day 11 Like a game of life

This is a much simplified version of Conways game of life, a coding pattern that we used for years at the Guardian when interviewing individuals. This is a little easier, because we have fewer rules, and no concerns around infinite or wrapping maps or anything.

The hardest part here, from experience, is working out how to count the neighbours of a cell. There's lots of ways to do this, but since I've done it before it shouldn't be too bad.

The key for finding the count of the neighbours is to work out how we are going to address the cells. My games experience makes me tend to address cells by cartesian coordinates, x and y. That means to find neighbours we want to find the cells addressed by x-1, y-1 through to x+1, y+1.

Secondly, we need to handle cells at the edge of the map. Instead of reaching directly into the data structure, we call getCell(x,y) which if we address outside the board, will just always return false.

Our third oddity here compared to Conways Game of Life is that not all cells can be occupied. Some cells have a seat, and can either be occupied, or empty, or floor. We'll represent those as a tristate variable, 0 = floor, 1=emptyseat, 2=occupied.

Lets go

import ipytest
ipytest.autoconfig()

from collections import defaultdict

FLOOR=0
EMPTY=1
OCCUPIED=2

def read_map(data):
    seats = defaultdict(int)
    seats["height"]=len(data)
    for y,line in enumerate(data):
        for x,c in enumerate(line.strip()):
            if c == "L":
                seats[(x,y)]=EMPTY
            if c == "#":
                seats[(x,y)]=OCCUPIED
    seats["width"]=len(line.strip())
    return seats

def get_cell(seats, x,y):
    if x < 0 or x >= seats["width"] or y < 0 or y >= seats["height"]:
        return FLOOR
    return seats[(x,y)]

testdata = """L.LL.LL.LL
LLLLLLL.LL
L.L.L..L..
LLLL.LL.LL
L.LL.LL.LL
L.LLLLL.LL
..L.L.....
LLLLLLLLLL
L.LLLLLL.L
L.LLLLL.LL"""

tests = [
    [(0,0),EMPTY],
    [(0,1),EMPTY],
    [(0,2),EMPTY],
    [(1,0),FLOOR],
    [(2,0),EMPTY],
    [(-1,0),FLOOR],
    [(0,-1),FLOOR]
]

testseats = read_map(testdata.split("\n"))
for test in tests:
    assert get_cell(testseats, test[0][0], test[0][1]) == test[1], f"get_cell({test[0]}) should equal {test[1]}"

Great, we've got a map, and we can look at it. Let's start working out if we can count neighbours

def count_neighbours(seats, x, y):
    neighbours = [(-1,-1), (0,-1), (1,-1), (-1,0), (1,0), (-1,1), (0,1), (1,1)]
    count = 0
    for (dx,dy) in neighbours:
        if get_cell(seats,x+dx,y+dy) == OCCUPIED:
            count += 1
    return count

testdata = """L.LL.LL.LL
LLLLLLL.LL
L.L.L..L..
LLLL.LL.LL
L.LL.LL.LL
L.L###L.LL
..L.L.....
LLLLLLLLLL
L.LLLLLL.L
L.LLLLL.LL"""
testseats = read_map(testdata.split("\n"))

assert count_neighbours(testseats, 0, 0) == 0
assert count_neighbours(testseats, 3, 5) == 1
assert count_neighbours(testseats, 4, 5) == 2
assert count_neighbours(testseats, 5, 5) == 1

Ok, time to handle swapping seats around. The key thing here is to not modify the map as we go, instead we build a new map. That way we don't count neighbours that are half from the previous iteration and half from the next iteration.

To work out the next step, our iterate function should return not just the new map, but should return a true/false to say whether or not any thing changed.

def iterate(seats):
    changed = False
    newseats = seats.copy()
    for y in range(seats["height"]):
        for x in range(seats["width"]):
            neighbours = count_neighbours(seats, x, y)
            if neighbours == 0 and seats[(x,y)] == EMPTY:
                newseats[(x,y)] = OCCUPIED
                changed = True
            if neighbours >= 4 and seats[(x,y)] == OCCUPIED:
                newseats[(x,y)] = EMPTY
                changed = True
    return newseats, changed

def countseats(seats):
    return len([k for k in seats.keys() if seats[k] == OCCUPIED])

testdata = """L.LL.LL.LL
LLLLLLL.LL
L.L.L..L..
LLLL.LL.LL
L.LL.LL.LL
L.LLLLL.LL
..L.L.....
LLLLLLLLLL
L.LLLLLL.L
L.LLLLL.LL"""
testseats = read_map(testdata.split("\n"))

assert get_cell(testseats, 0,0) == EMPTY
assert get_cell(testseats, 1,1) == EMPTY
    
testseats,changed = iterate(testseats)
assert changed
assert get_cell(testseats, 0,0) == OCCUPIED
assert get_cell(testseats, 1,1) == OCCUPIED

testseats,changed = iterate(testseats)
assert changed
assert get_cell(testseats, 0,0) == OCCUPIED
assert get_cell(testseats, 1,1) == EMPTY

testseats,changed = iterate(testseats)
assert changed
testseats,changed = iterate(testseats)
assert changed
testseats,changed = iterate(testseats)
assert changed

testseats,changed = iterate(testseats)
assert not changed
assert 37 == countseats(testseats)

Now we can try on real data and see how long it takes until the iterations stop

seats = read_map(open("day11.txt").readlines())
changed = True
iterations = 0
while changed:
    seats,changed = iterate(seats)
    iterations += 1
print(iterations)
print(countseats(seats))

76 2265

Part 2 - Line of sight

Ok, so this should be pretty simple from what we've done so far, but instead of count_neighbours only caring about the range -1 to 1 in each direction, we just need to look for a seat, occupied or not in the direction.

We'll now need to know if we went off hte edge of the map, so get_cell will change to return a special value if we're out of bounds as well

A simple change to count neighbours should allow us to replicate the tests, and then a change to the rules in iterate and we should be able to go again

VOID = 3

def get_cell(seats, x,y):
    if x < 0 or x >= seats["width"] or y < 0 or y >= seats["height"]:
        return VOID
    return seats[(x,y)]

def count_neighbours(seats, x, y):
    neighbours = [(-1,-1), (0,-1), (1,-1), (-1,0), (1,0), (-1,1), (0,1), (1,1)]
    count = 0
    for (dx,dy) in neighbours:
        tx,ty = x,y
        found = False
        while not found:
            tx,ty = tx+dx, ty+dy
            c = get_cell(seats,tx,ty)
            if c == OCCUPIED:
                count += 1
                found = True
            if c == VOID or c == EMPTY:
                found = True            
    return count

testdata = """.......#.
...#.....
.#.......
.........
..#L....#
....#....
.........
#........
...#....."""
testseats = read_map(testdata.split("\n"))

assert count_neighbours(testseats, 3, 4) == 8

testdata = """.............
.L.L.#.#.#.#.
............."""
testseats = read_map(testdata.split("\n"))

assert count_neighbours(testseats, 2, 1) == 0

testdata = """.##.##.
#.#.#.#
##...##
...L...
##...##
#.#.#.#
.##.##."""
testseats = read_map(testdata.split("\n"))

assert count_neighbours(testseats, 3, 3) == 0

Ok, so our neighbour check works, let's tweak iterate and go again on our original test data

def iterate(seats):
    changed = False
    newseats = seats.copy()
    for y in range(seats["height"]):
        for x in range(seats["width"]):
            neighbours = count_neighbours(seats, x, y)
            if neighbours == 0 and seats[(x,y)] == EMPTY:
                newseats[(x,y)] = OCCUPIED
                changed = True
            if neighbours >= 5 and seats[(x,y)] == OCCUPIED:
                newseats[(x,y)] = EMPTY
                changed = True
    return newseats, changed

def countseats(seats):
    return len([k for k in seats.keys() if seats[k] == OCCUPIED])

testdata = """L.LL.LL.LL
LLLLLLL.LL
L.L.L..L..
LLLL.LL.LL
L.LL.LL.LL
L.LLLLL.LL
..L.L.....
LLLLLLLLLL
L.LLLLLL.L
L.LLLLL.LL"""
testseats = read_map(testdata.split("\n"))

assert get_cell(testseats, 0,0) == EMPTY
assert get_cell(testseats, 2,0) == EMPTY
assert get_cell(testseats, 3,0) == EMPTY
    
testseats,changed = iterate(testseats)
assert changed
assert get_cell(testseats, 0,0) == OCCUPIED
assert get_cell(testseats, 2,0) == OCCUPIED
assert get_cell(testseats, 3,0) == OCCUPIED

testseats,changed = iterate(testseats)
assert changed
assert get_cell(testseats, 0,0) == OCCUPIED
assert get_cell(testseats, 2,0) == EMPTY
assert get_cell(testseats, 3,0) == EMPTY

testseats,changed = iterate(testseats)
assert changed
assert get_cell(testseats, 0,0) == OCCUPIED
assert get_cell(testseats, 2,0) == EMPTY
assert get_cell(testseats, 3,0) == OCCUPIED

testseats,changed = iterate(testseats)
assert changed
testseats,changed = iterate(testseats)
assert changed
testseats,changed = iterate(testseats)
assert changed

testseats,changed = iterate(testseats)
assert not changed
assert 26 == countseats(testseats)

Ok, let's try on real data!

seats = read_map(open("day11.txt").readlines())
changed = True
iterations = 0
while changed:
    seats,changed = iterate(seats)
    iterations += 1
print(iterations)
print(countseats(seats))

86 2045

Next

2020-12-12

Previous

2020-12-10