Перебор Соседних Клеток — забавные формулы

Не только в играх вроде "Го" или "Жизнь" - но и в создании фильтров для изображений - часто нужно для клетки или точки (x, y) перечислить её "соседей". Либо только четырех (по горизонтали и вертикали), либо все восемь (с диагоналями).

Можно не задумываясь написать массивчик с 4-мя или 8-ю парами смещений, вроде
[(-1, 0), (0, 1), (1, 0), (0, -1)] - а можно ли вместо него жахнуть какую-нибудь формулу? Давайте попробуем для утренней разминки ума в понедельник :)

В этой статье будет несколько 2-3 строчных примеров кода - уж извините пожалуйста :) зато она довольно короткая.

Типичная ситуация

Итак, допустим мы задали массив со "смещениями" соседей относительно текущей клетки, хотя бы вот в том виде что выше:

neighbors = [(-1, 0), (0, 1), (1, 0), (0, -1)]

Обычно встречается одна из двух необходимостей (или обе) - либо нам нужно перебрать этих самых "соседей" в цикле:

# let x, y be current cell coordinates
for dx, dy in neighbors:
  paint_it_black(x+dx, y+dy)

во всех примерах с циклом вы можете заменить paint_it_black на print и проставить x=0; y=0 вначале чтобы увидеть "чистые смещения".

Либо нужно получить координаты i-го соседа:

def get_neighbor(x, y, i):
  return x + neighbors[i][0], y + neighbors[i][1]

и вот нам интересно, можно ли обойтись без массива neighbors - ибо кажется, что особенно в варианте с 8 соседями он и выглядит громоздко и попутать можно чего-то невзначай - а проверять потом визуально.

Что если соседей... девять, включая себя?

Рассмотрим этот случай чисто для подготовки к следующим - он гораздо проще, ведь нам нужно перебрать квадрат 3x3 - например так:

for i in range(9):
  paint_it_black(x + i % 3 - 1, y + i // 3 - 1)

def get_neighbor(x, y, i):
  return x + i % 3 - 1, y + i // 3 - 1

В общем, пользуемся тем что частное и остаток от деления на 3 для чисел 0..8 возвращают естественно "строку" и "столбец": dx=i%3-1, dy=i//3-1.

Но как быть с 8-ю?

В случае перебора в цикле, конечно, можно использовать вариант для 9-ти и проверять отдельным условием что это не центральная точка. Ну выглядит омерзительно :)

И конечно для "занумерования" соседей в функции get_neighbor просто не годится такое "выкалывание" точки. Номера должны идти по порядку!

Однако - мы ведь перебираем в цикле 9 значений из которых одно нам не нужно. Что будет если перебирать начиная с числа следующего за "ненужным"? Это можно написать по-разному но общий принцип такой - добавим к номеру соседа пятёрку (т.к. нам нужно пропустить четвертого - себя) - и возьмём остаток от деления на 9 - ну и конечно помним что теперь перебирать на одного меньше (ведь 9м окажемся мы сами).

for i in range(8):
  i = (i+5) % 9
  paint_it_black(x + i % 3 - 1, y + i // 3 - 1)

def get_neighbor(x, y, i):
  i = (i+5) % 9
  return x + i % 3 - 1, y + i // 3 - 1

Когда принцип понятен, можно записать и короче

for i in range(8):
...   paint_it_black(x + (i+5)%3-1, y + (i+5)//3%3-1)

Это не единственный способ, но "кардинально" другой мы предложим в качестве домашнего задания после рассмотрения варианта 4-х соседей.

Что делать с 4-мя?

Посмотрите на тот случай с 9-ю, ведь в нём нужные нам "ортогональные" соседи идут через одного. Это даёт нам очень простую мысль - запишем тот же код, но используя индекс с удвоенным шагом:

for i in range(1, 9, 2):
  paint_it_black(x + i % 3 - 1, y + i // 3 - 1)

Элементарно, Ватсон! Для того чтобы их занумеровать в функции, нужно поступить чуть иначе - "смасштабировать" индекс:

def get_neighbor(x, y, i):
  i = i * 2 + 1
  return x + i % 3 - 1, y + i // 3 - 1

Но это не единственный вариант :) Что если нам нужно перебирать соседей по часовой стрелке - если кажется что это фантазия, вспомним например про пятую координату в "квадратах" на оперативных картах (загуглите "координата по улитке", не будем останавливаться подробно - или обратите внимание на зеленые цифры на картинке).

Что же, на помощь приходит школьная формула - ведь использование синусов-косинусов позволяет нарисовать круг или получить из полярных координат декартовы - это примерно то что нужно!

for i in range(4):
  a = i*math.pi/2
  paint_it_black(x + round(math.sin(a)), y + round(-math.cos(a)))

Ну и для get_neighbor(...) аналогично.

Дополнение: в комментарии предложена хорошая идея - аналогичная в смысле математики но более красивая в исполнении - если язык программирования поддерживает комплексные числа (например в Go они зачем-то прямо-таки стандартный тип) - то можно получать 4-х соседей просто возводя мнимую единицу в степень равную номеру соседа:

def get_neighbor(x, y, i):
  return x + round((1j**i).real), y + round((1j**i).imag)

Можно вообще представлять пару координат (точку, клетку) одним комплексным числом.

Заключение

Я вовсе не призываю использовать эти "хитроумные" формулы вместо упомянутого массива с парами "смещений". Это уж по вкусу - и по настроениям в команде. Кому что проще кажется. К слову, иногда с формулами можно попасть впросак - в частности в PHP функция round из последнего примера может округлять не только в положительный ноль но и в отрицательный. Сравнению это не помешает, но склеив из двух координат строковый ключ для ассоциативного массива я, конечно, поймал занятный баг (который отчасти и подтолкнул к рассмотрению других способов - и в общем написанию данной статьи).

В качестве упражнения попробуйте:

@RodionGork
11.11.2024 12:13 UTC
Первоисточник

Комментарии

@Alexandroppolus
11.11.2024 09:05 UTC
+4

При наличии в ЯП из коробки комплексных чисел, перебирать соседние 4 клетки можно было бы с помощью умножения на i (мнимую единицу). Или на -i, чтобы в другую сторону.

@RodionGork
11.11.2024 09:17 UTC
0

Кстати хорошая мысль (добавил в статью, спасибо) - и вообще использовать комплексные числа для обозначения точек в таких языках. Например в Go. А я-то думал зачем там поддержка комплексных чисел встроена изначально, прям как в Фортране :)

@aragaer
11.11.2024 12:02 UTC
+2

С учетом надвигающегося Advent of Code это и правда может быть полезно. Но иногда там бывают задачки, где интересны соседи не на плоскости, а в трех (а однажды было и четырех) измерениях. В этом случае если нужны совсем все соседи, то можно оставить решение с "начать от центра", но когда нужны только ортогональные соседи (т.е. манхеттенская дистанция 1), то формулами уже не очень получается.

@RodionGork
11.11.2024 12:30 UTC
0

За напоминание спасибо :)

Насчет "не очень получается" - скорее правильно сказать "не приходит сразу в голову". Интересно отметить что "диагональных" а не "ортогональных" соседей в пространстве любой размерности перечислить легко. Подумаем на досуге :)

@alexejisma
12.11.2024 09:47 UTC
0

Для обхода всех соседей можно использовать такой код:

r = (-1, 0, 1)
for dx in r:
    for dy in r:
        if dx or dy:
            paint_it_black(x + dx, y + dy)

Можно сделать код лаконичнее (убрав несколько уровней вложенности):

r = (-1, 0, 1)
neighbors = [
    (dx, dy)
    for dx in r  
    for dy in r   
    if dx or dy
]
for dx, dy in neighbors:
    paint_it_black(x + dx, y + dy)

Ну и, как мне кажется, самый изящный способ, который можно использовать для любого количества измерений, следующий:

import itertools

dims = 2
neighbors = [
    n
    for n in itertools.product((-1, 0, 1), repeat=dims)
    if any(n)
]
for dx, dy in neighbors:
    paint_it_black(x + dx, y + dy)

P.S. во всех трех вариантах можно убрать if ("if dx or dy" и "if any(n)")и тогда будут не только соседи, но и точка (x, y).

neighbors = [n for n in itertools.product((-1, 0, 1), repeat=dims)]
# or
neighbors = list(itertools.product((-1, 0, 1), repeat=dims))

P.P.S. Для ортогональных соседей ((-1, 0), (0, -1), (0, 1), (1, 0)) можно "if any(n)" заменить на "if sum(abs(x) for x in n) == 1" или "sum(map(abs, n)) == 1" (кому что больше нравится), а "if dx or dy" заменить, например, на "if dx + dy in (-1, 1)" или "if abs(dx + dy) == 1":

neighbors = [
    n
    for n in itertools.product((-1, 0, 1), repeat=dims)
    if sum(map(abs, n)) == 1
]

P.P.P.S. Для диагональных соседей ((-1, -1), (-1, 1), (1, -1) и (1, 1)) можно в коде выше заменить (-1, 0, 1) на (-1, 1) и убрать if, так как он будет не нужен.

neighbors = [n for n in itertools.product((-1, 1), repeat=dims)]
# or
neighbors = list(itertools.product((-1, 1), repeat=dims))

@DimPal
11.11.2024 14:11 UTC
+3

Можно без делений и взятия остатков, на битовой арифметике, например так:

for i in range(8):

... paint_it_black(x + ((148>>i)&1) - ((41>>i)&1) , y + ((224>>i)&1) - ((7>>i)&1)))

@RodionGork
11.11.2024 14:56 UTC
0

Можно и в одно число закодировать вместо двух :)

Правда как и с массивами, и с косинусами у этого подхода некоторый недостаток - со стороны довольно трудно понять что это за ересь автор написал.

Есть однако и одно важное преимущество - можно задать вообще любой порядок обхода любого количества соседей в пространстве любой размерности (главное чтобы бит хватило).

Но правда это именно не формула а "кодирование" порядка.

11.11.2024 20:03 UTC
0

Правда как и с массивами, и с косинусами у этого подхода некоторый недостаток - со стороны довольно трудно понять что это за ересь автор написал.

Вот уж не знаю, почему на хабре это кажется дичью (см. коммент ниже), но когда задача формулируется так, что нужно найти координаты точек на окружности, синус и косинус - это первое что приходит в голову.

А таблица смещений - это просто затабулированные значения синусов и косинусов, чтобы считать быстрее. Вообще никто не заставляет использовать значение угла в радианах или градусах, в примере с 8 соседями, значение угла - целое число от 0 до 7.

11.11.2024 20:26 UTC
0

ну нет, про "дичь" я не говорю :) если язык поддерживает запись чисел в двоичной форме вообще более понятно можно написать. в целом же "просто/сложно" это дело субъективное и часто зависит от того какой у человека опыт и т.п.

@commanderxo
11.11.2024 14:37 UTC
+3

Использование синусов-косинусов и полярных координат кажется дичью и экзотикой, но есть на Хабре отличная статья "Рисуем картинки с помощью кривой Гильберта". Всё хорошо, но медленно работает. Глянул в код, а там повороты на 90 градусов вычисляются через тригонометрические функции.

@iShrimp
11.11.2024 15:44 UTC
+1

Небольшая хитрость для 8 соседей: можно итерировать i от 5 до 12.

@RodionGork
11.11.2024 15:54 UTC
0

я сам сначала так и написал :) но для статьи подумал что это уж слишком загадочно будет выглядеть

@Germanjon
12.11.2024 05:34 UTC
0

Было бы интересно дополнить примерами, когда точка находится на границе (нет соседей с одной стороны) или в углу (нет соседей с двух стороны).

@RodionGork
12.11.2024 05:59 UTC
0

а как вы это хотите задавать? ну вот в функцию get_neighbors передавать например какие-то дополнительные параметры чтобы показать что точка в углу или на краю? выглядит не очень хорошо

да и необязательно края вообще есть (та же игра Жизнь в идеале про бесконечное поле)

на мой взгляд такие проверки обычно "снаружи" происходят - мы координаты сгенерировали - а снаружи можно или доп.условиями, или дополнительными клетками в матрице определить что часть координат невалидна

12.11.2024 06:31 UTC
0

Как раз интересно, как это делать? Навскидку вижу варианты:
- Передавать параметры "есть ли соседи по сторонам".
- Добавить около поля границы, чтобы гарантированно были все соседи.
- Делать доп-проверки перед передачей в функцию.

@alexejisma
12.11.2024 10:07 UTC
0

Можно добавить проверку в духе

for dx, dy in neigbours:
    if (xx := x + dx) in range(w) and (yy := y + dy) in range(h):
        paint_it_black(xx, yy)

где w - ширина области, h - высота

12.11.2024 10:19 UTC
0

можно но по-хорошему этот код внутри paint_it_black должен быть (за границей не рисуем)

он просто к "перечислению" не относится - вы не можете сказать "не глядя" сколько точек попадает в область - или например функция get_neighbor перестаёт работать понятно (какие точки теперь становятся первой, второй, последней)

т.е. я не говорю что это плохо, просто это уже про другое :)