Игра «Жизнь» Конвея

Четыре строчки правил, поле из живых и мёртвых клеток — и вот по экрану ползёт маленькая фигура, не теряя формы. Игра «Жизнь» Конвея показывает, как из примитивных законов рождается почти живое поведение.

Игра «Жизнь» — это двумерный клеточный автомат с двумя состояниями (клетка жива или мертва) и окрестностью Мура (8 соседей), где судьба клетки на следующем шаге определяется только числом её живых соседей по трём простым правилам.

Придумал её в 1970 году математик Джон Конвей. «Игрой» её называют по традиции: игрок не делает ходов, он лишь задаёт начальную расстановку и смотрит, как поле эволюционирует само. Это не развлечение ради развлечения — «Жизнь» оказалась настолько богатой, что в ней удаётся построить логические схемы и даже модель универсального компьютера. Нам она интересна как чистейший пример эмерджентности: правила тривиальны, поведение — нет.

Правила Игры «Жизнь»

Для каждой клетки считаем число живых соседей среди восьми (окрестность Мура). Затем применяем правила:

  • Выживание. Живая клетка с 2 или 3 живыми соседями остаётся живой.
  • Смерть. Живая клетка с менее чем 2 соседями умирает «от одиночества», с более чем 3 — «от перенаселения».
  • Рождение. Мёртвая клетка ровно с 3 живыми соседями оживает.

Всё. Эти три правила обычно записывают коротко как «B3/S23»: рождение (Birth) при 3 соседях, выживание (Survival) при 2 или 3. Заметьте: правило локальное и одинаковое для всех клеток, никто не управляет полем сверху.

Планер — фигура, которая движется

Самое знаменитое творение «Жизни» — планер (glider). Это группа из пяти живых клеток, которая каждые 4 поколения возвращается к своей форме, но смещённая на одну клетку по диагонали. То есть фигура «летит». Соберём поле 8x8, поставим планер в угол и посмотрим 4 поколения.

def life_step(grid):
    rows = len(grid)
    cols = len(grid[0])
    new = [[0] * cols for _ in range(rows)]
    for r in range(rows):
        for c in range(cols):
            n = 0
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    if dr == 0 and dc == 0:
                        continue
                    rr, cc = r + dr, c + dc
                    if 0 <= rr < rows and 0 <= cc < cols:
                        n += grid[rr][cc]
            if grid[r][c] == 1:
                new[r][c] = 1 if n in (2, 3) else 0
            else:
                new[r][c] = 1 if n == 3 else 0
    return new

def show(grid):
    for row in grid:
        print(''.join('#' if c else '.' for c in row))
    print()

grid = [[0] * 8 for _ in range(8)]
for (r, c) in [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)]:
    grid[r][c] = 1
print("Поколение 0:")
show(grid)
for gen in range(1, 4):
    grid = life_step(grid)
    print(f"Поколение {gen}:")
    show(grid)

Вывод:

Поколение 0:
.#......
..#.....
###.....
........
........
........
........
........

Поколение 1:
........
#.#.....
.##.....
.#......
........
........
........
........

Поколение 2:
........
..#.....
#.#.....
.##.....
........
........
........
........

Поколение 3:
........
.#......
..##....
.##.....
........
........
........
........

Присмотритесь к фигуре: на каждом шаге она меняет форму, проходит через промежуточные состояния, но в целом «сползает» вправо и вниз. Если бы поле было больше и мы дали бы ещё шаги, планер продолжил бы лететь по диагонали бесконечно, пока не упрётся в край или другую фигуру. Никто не говорил клеткам «двигайтесь по диагонали» — это движение само сложилось из правил рождения и смерти.

Как работает под капотом

Сердце кода — двойной цикл по dr и dc со значениями (-1, 0, 1). Он перебирает все 9 клеток квадрата 3x3 вокруг текущей. Строчка if dr == 0 and dc == 0: continue пропускает саму клетку, оставляя ровно 8 соседей Мура. Так мы получаем n — число живых соседей.

Дальше работает синхронность: мы пишем в new, а читаем из старого grid. Если бы мы обновляли grid на месте, то уже изменённые клетки искажали бы подсчёт соседей для следующих, и планер бы «развалился». Проверка границ if 0 <= rr < rows and 0 <= cc < cols отбрасывает соседей за пределами поля — у нас «мёртвые» края, за полем жизни нет.

Логика перехода — это прямой перевод правил B3/S23 в код. Для живой клетки: 1 if n in (2, 3) else 0. Для мёртвой: 1 if n == 3 else 0. Две строки — и весь Конвей у вас в руках.

Частые ошибки

  • Обновление на месте. Самая частая ошибка: пересчитывать соседей по той же сетке, которую уже меняете. Нужен отдельный new — иначе фигуры искажаются.
  • Клетка считает саму себя. Если забыть continue при dr == 0 and dc == 0, клетка попадёт в число своих соседей, и все правила «съедут».
  • Неверная окрестность. «Жизнь» использует 8 соседей (Мур). Если посчитать только 4, получится совсем другой автомат без планеров.
  • Выход за границы. Без проверки 0 <= rr < rows индекс -1 в Python тихо завернётся на противоположный край, и поле «склеится» в тор там, где вы этого не хотели.

Итоги

  • Игра «Жизнь» — двумерный автомат с двумя состояниями и окрестностью Мура.
  • Правила B3/S23: живая клетка выживает при 2–3 соседях, мёртвая оживает ровно при 3.
  • Планер (glider) — фигура из 5 клеток, которая за 4 поколения сдвигается на одну клетку по диагонали.
  • Локальные правила порождают глобальное движение — клеткам никто не приказывал «лететь».
  • Главные технические детали: синхронное обновление через буфер, пропуск самой клетки и явная проверка границ.
Проверьте себя
1. По правилам Игры «Жизнь» (B3/S23), что произойдёт с мёртвой клеткой, у которой ровно 3 живых соседа?
AОстанется мёртвой
BОживёт (родится)
CОживёт, только если у неё 8 соседей
DУмрёт окончательно и больше не сможет ожить
2. Что такое планер (glider) в Игре «Жизнь»?
AНеподвижная стабильная фигура
BФигура из 5 клеток, которая за 4 поколения сдвигается на клетку по диагонали
CКлетка, которая никогда не умирает
DПустое поле без живых клеток
3. Зачем в коде стоит строка if dr == 0 and dc == 0: continue?
AЧтобы пропустить саму клетку и считать ровно 8 соседей, а не 9
BЧтобы ускорить цикл
CЧтобы клетка считала саму себя соседом
DЧтобы выйти из функции досрочно