Поиск соседей в программировании — это процесс поиска элементов, расположенных рядом с заданным элементом в структуре данных (например, матрице или графе). Этот подход применяется для анализа взаимосвязей между элементами, определения их свойств на основе окружения и выполнения различных алгоритмов (например, поиск пути, кластеризация, фильтрация данных).

В случае двумерной матрицы под соседями элемента обычно понимают элементы, которые находятся непосредственно по горизонтали, вертикали и/или диагонали относительно данного элемента. Например, для матрицы размера N×M, элемент с координатами (i,j) может иметь до 8 соседей (если считать диагонали).

Всесторонний поиск соседей в матрице может понадобиться в ряде задач:

  • Алгоритмы поиска пути (графы, лабиринты)

  • Алгоритмы заливки областей (например, заливка краской)

  • Обработка изображений

  • Игры на клеточных полях (например, "Сапёр")

  • Кластеризация данных

  • Моделирование физических процессов

  • Алгоритмы поиска кластеров (например, DBSCAN)

  • Обработка текста и символьных данных

Таким образом, всесторонний поиск соседей позволяет эффективно решать задачи, где требуется анализировать не только локальные данные в одной точке, но и её окружение.


Прежде чем мы продолжим, хочу пригласить в мой телеграмм канал Don Python, где я делюсь интересными особенностями языка, не очевидными фишками и решаю разные задачи.

Подписаться


Простейший пример

def get_neighbors(matrix, row, col):
	rows = len(matrix)
	cols = len(matrix[0])
	
	directions = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
	
	neighbors = []
	
	for dr, dc in directions:
		new_row, new_col = row + dr, col + dc
		
		if 0 <= new_row < rows and 0 <= new_col < cols:
			neighbors.append(matrix[new_row][new_col])
			
	return neighbors
	
	
matrix = [
	[1, 2, 3],
	[4, 5, 6],
	[7, 8, 9]
]

neighbors = get_neighbors(matrix, 1, 1)
print("Neighbors of element at (1,1):", neighbors)

# >>> Neighbors of element at (1,1): [2, 8, 4, 6, 1, 3, 7, 9]

Объяснение

1. Начинаем с объявления функции get_neighbors с параметрами:

  • matrix - матрица, с которой предстоит работать

  • row - индекс строки

  • col - индекс колонки

2. Получаем размер матрицы rows × cols

3. Массив directions содержит все возможные направления для поиска соседей в двумерной матрице.

Каждый элемент этого массива представляет собой кортеж (пару чисел), где:

  • Первое число (dx) отвечает за направление по строкам (по вертикали),

  • Второе число (dy) отвечает за направление по столбцам (по горизонтали).

Эти пары используются для смещения от текущей позиции элемента в двумерной матрице.

Направление

Пара чисел

Описание

Вверх

(-1, 0)

Смещение вверх на одну строку (координата строки уменьшается, столбец остаётся тем же).

Вниз

(1, 0)

Смещение вниз на одну строку (координата строки увеличивается, столбец остаётся тем же).

Влево

(0, -1)

Смещение влево на один столбец (строка остаётся той же, координата столбца уменьшается).

Вправо

(0, 1)

Смещение вправо на один столбец (строка остаётся той же, координата столбца увеличивается).

Вверх-влево

(-1, -1)

Диагональное смещение: вверх на одну строку и влево на один столбец.

Вверх-вправо

(-1, 1)

Диагональное смещение: вверх на одну строку и вправо на один столбец.

Вниз-влево

(1, -1)

Диагональное смещение: вниз на одну строку и влево на один столбец.

Вниз-вправо

(1, 1)

Диагональное смещение: вниз на одну строку и вправо на один столбец.

Если произвольно распечатать массив directions и наложить его на матрицу, можно получить следующую картину:

(-1, -1) (-1, 0) (-1, 1)
(0, -1)  (i, j)  (0, 1)
(1, -1)  (1, 0)  (1, 1)

Здесь:

  • Центр (i,j) — это текущая позиция элемента.

  • Каждая пара чисел из массива directions указывает на одну из клеток вокруг этого элемента.

4. Создаём массив neighbors. В будущем мы наполним его всеми найденными соседями.

5. И тут же возвращаем массив neighbors, опуская пока что основной алгоритм поиска.

Посмотрим что получается на текущий момент:

def get_neighbors(matrix, row, col):
	rows = len(matrix)
	cols = len(matrix[0])
	
	directions = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
	
	neighbors = []
	
	return neighbors

6. Теперь переходим к написанию алгоритма. Пробегаемся циклом for по массиву directions и распечатываем каждый кортеж в две переменные: dr, dc.

7. В первой строке цикла для каждого направления вычисляем новую позицию (строку и столбец) и проверяем, находятся ли они в пределах матрицы. Делаем мы это с помощью сложения индекса строки (row) со значением переменной dr и сложения индекса столбца (col) со значением переменной dc.

Процесс выглядит так:

Мы стоим на клетке (1,1) и хотим проверить соседей в разных направлениях.

  • Направление (−1,0) — движение вверх: новая позиция будет (0,1).

  • Направление (1,0) — движение вниз: новая позиция будет (2,1).

  • Направление (0,−1) — движение влево: новая позиция будет (1,0).

  • Направление (0,1) — движение вправо: новая позиция будет (1,2).

Если бы мы двигались по диагонали:

  • Направление (−1,−1) — новая позиция (0,0).

  • Направление (1,1) — новая позиция (2,2).

8. Теперь с помощью условия нужно определить, находится ли позиция, к которой мы хотим обратится в пределах матрицы. Для этого нужно убедиться, что значение new_row находится в диапазоне от 0 до len(matrix) и new_col в диапазоне от 0 до len(matrix[0]).

9. Если условие верно, то мы можем утверждать что позиция, к которой мы хотим обратиться доступна и является соседней для позиции, с которой мы начали поиск. Добавляем значение позиции в массив neighbors. Если условие не верно, переходим к следующей итерации.

Рабочая функция:

def get_neighbors(matrix, row, col):
	rows = len(matrix)
	cols = len(matrix[0])
	
	directions = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
	
	neighbors = []
	
	for dr, dc in directions:
		new_row, new_col = row + dr, col + dc
		
		if 0 <= new_row < rows and 0 <= new_col < cols:
			neighbors.append(matrix[new_row][new_col])
			
	return neighbors

10. Нам осталось создать произвольную матрицу, вызвать функцию, получить массив соседей и вывести результат:

matrix = [
	[1, 2, 3],
	[4, 5, 6],
	[7, 8, 9]
]

neighbors = get_neighbors(matrix, 1, 1)
print("Neighbors of element at (1,1):", neighbors)

Можно вернуться к основному блоку кода и вновь проанализировать все этапы.

Практический пример. Размытие картинки

Предлагаю на практике посмотреть, что можно сделать с помощью метода поиска соседей, а так же узнать, каким образом можно расширить радиус поиска.

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

Введем некоторые ограничения для упрощения и наглядности примера:

  • Картинка не больше 256×256

  • Картинка должна быть черно-белой

  • Здесь не будет подробного разбора о том, как работает преобразование картинки в матрицу и обратно. Я просто размещу рабочий код

Я выбрал следующую картинку:

Процесс размытия состоит из 3х основных этапов:

  1. Преобразование картинки в матрицу

  2. Создание фильтра размытия

  3. Преобразование матрицы обратно в картинку

Начальная структура файлов:

blur_img
	img.jpg
	main.py

Преобразование картинки в матрицу

Устанавливаем необходимую библиотеку:

pip install Pillow

Используем следующий код для создания функции преобразования:

from PIL import Image

def itm(*, image_path):  
    # Открываем изображение  
    img = Image.open(image_path)  
  
    # Преобразуем изображение в режим "L" (оттенки серого)  
    img = img.convert("L")  
  
    # Получаем размеры изображения  
    width, height = img.size  
  
    # Создаём матрицу  
    matrix = []  
  
    for y in range(height):  
        row = []  
        for x in range(width):  
            # Получаем значение пикселя  
            pixel_value = img.getpixel((x, y))  
            row.append(pixel_value)  
        matrix.append(row)  
  
    return matrix

Далее мы будем использовать эту функцию как импортируемый модуль. Для этого изменим нашу структуру файлов:

blure_img
	image_conversion
		image_to_matrix.py
	img.jpg
	main.py

Функция размытия

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

from image_conversion.image_to_matrix import itm    
  
def apply_blur_filter(image):  
    rows = len(image)  
    cols = len(image[0])  
  
    blurred_image = [[0] * cols for _ in range(rows)]  
  
    directions = [(-1, -1), (-1, 0), (-1, 1),  
                  (0, -1), (0, 0), (0, 1),  
                  (1, -1), (1, 0), (1, 1)]  

    for i in range(rows):  
        for j in range(cols):  
            pixel_sum = 0  
            count = 0  
  
            for dx, dy in directions:  
                new_x, new_y = i + dx, j + dy  
  
                if 0 <= new_x < rows and 0 <= new_y < cols:  
                    pixel_sum += image[new_x][new_y]  
                    count += 1  
  
            blurred_image[i][j] = pixel_sum // count  
  
    return blurred_image  
  

matrix = itm(image_path="img.jpg") 

blurred_image = apply_blur_filter(matrix)

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

На уровне поиска соседей добавятся 3 новых строки:

  • pixel_sum - сумма значений всех соседних пикселей

  • count - количество соседей текущего пикселя

  • blurred_image[i][j] = pixel_sum // count - расчет и присвоение нового значения

В итоге мы получаем новую матрицу с измененными значениями, которую нам нужно преобразовать в изображение.

Преобразование матрицы в картинку

Для сохранения матрицы обратно в изображение будем использовать установленную библиотеку Pillow. Процесс обратного преобразования включает создание нового изображения на основе матрицы пикселей и сохранение его в нужный формат.

from PIL import Image  
  
def mti(matrix, output_path):  
    # Получаем размеры матрицы (высота и ширина изображения)  
    height = len(matrix)  
    width = len(matrix[0])  
  
    # Создаём новое изображение в режиме RGB  
    img = Image.new("L", (width, height))  
  
    # Заполняем изображение пикселями из матрицы  
    for y in range(height):  
        for x in range(width):  
            img.putpixel((x, y), matrix[y][x])  # Устанавливаем пиксель  
  
    # Сохраняем изображение
    img.save(output_path)

Обновленная файловая структура:

blure_img
	image_conversion
		image_to_matrix.py
		matrix_to_image.py
	img.jpg
	main.py

Импортируем функцию mti в main.py и вызываем с нужными параметрами в конце кода:

from image_conversion.image_to_matrix import itm
from image_conversion.matrix_to_img import mti
  
def apply_blur_filter(image):... 


matrix = itm(image_path="img.jpg") 

blurred_image = apply_blur_filter(matrix)

mti(matrix=blurred_image, output_path='blur_img.jpg')

Исполняем файл main.py и получаем результат.

Было:

Стало:

Обновленная файловая структура:

blure_img
	image_conversion
		image_to_matrix.py
		matrix_to_image.py
	blur_img.jpg
	img.jpg
	main.py

Поиск соседей в произвольном радиусе

Предположим что предыдущего результата нам мало. Хочется регулировать степень размытия. Для этого недостаточно найти ближайших соседей. Требуется увеличить радиуса поиска.

Функции для получения матрицы из изображения и сохранения матрицы в виде изображения остаются без изменений. Нам нужно внести небольшие изменения в функцию apply_blur_filter.

Давайте посмотрим на изменения:

def apply_blur_filter(image, blur_radius=2):  
    rows = len(image)  
    cols = len(image[0])  
  
    blurred_image = [[0] * cols for _ in range(rows)]  
  
    offset_range = range(-blur_radius, blur_radius + 1) 

    for i in range(rows):  
        for j in range(cols):  
            pixel_sum = 0  
            count = 0  
  
            for dx in offset_range:  
			    for dy in offset_range: 
	                new_x, new_y = i + dx, j + dy  
	  
	                if 0 <= new_x < rows and 0 <= new_y < cols:  
	                    pixel_sum += image[new_x][new_y]  
	                    count += 1  
  
            blurred_image[i][j] = pixel_sum // count  
  
    return blurred_image  

Объяснение

1. В параметрах функции добавляем новый аргумент blur_radius

2. Теперь вместо массива directions с координатами для поиска ближайших соседей, мы будем использовать диапазон от -blur_radius до blur_radius + 1. При blur_radius=2 по умолчанию диапазон будет таким: [-2, -1, 0, 1, 2]

3. Самым значительным изменением стало добавление четвёртого цикла. Теперь переменные dx и dy используются не в рамках одного цикла, где мы обходили соседние пиксели относительно текущего. Теперь dx представляет собой координату на оси x, относительно которой выполняется обход по оси y в диапазоне offset_range.

Предлагаю немного погрузиться в тонкости

Для каждого пикселя нам нужно рассчитать новое значение, которое будет средним по области blur_radius + (blur_radius + 1) x blur_radius + (blur_radius + 1), включающей этот пиксель. Если радиус размытия 2, то мы будем учитывать всех соседей в пределах 2 пикселей от текущего, то есть область 5x5.

Допустим, у нас есть следующая матрица значений яркости или цвета пикселей:

[
    [10, 20, 30, 40, 50, 60],
    [15, 25, 35, 45, 55, 65],
    [20, 30, 40, 50, 60, 70],
    [25, 35, 45, 55, 65, 75],
    [30, 40, 50, 60, 70, 80],
    [35, 45, 55, 65, 75, 85]
]

Пусть координаты текущего пикселя будут (2, 2). Для пикселя с координатами (2,2) и значением 40, область размытия 5x5 включает следующие пиксели:

[
    [10, 20, 30, 40, 50],  # Соседи сверху
    [15, 25, 35, 45, 55],  # 2 строка
    [20, 30, 40, 50, 60],  # Строка с текущим пикселем (2,2)
    [25, 35, 45, 55, 65],  # 4 строка
    [30, 40, 50, 60, 70]   # Соседи снизу
]

В данном случае ось x представлена строкой [20, 30, 40, 50, 60]. Учитывая диапазон поиска [-2, -1, 0, 1, 2], можно понять, что цикл for dx in offset_range обработает каждый пиксель в этой строке. На каждой итерации цикла for dx, цикл for dy in offset_range будет обрабатывать все значения по оси y в том же диапазоне [-2, -1, 0, 1, 2]. Таким образом, если на оси x текущая координата равна (-2, 0), что соответствует пикселю (2, 0) в матрице, то цикл for dy обработает все значения в столбце [10, 15, 20, 25, 30].

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

[
    [25, 30, 35, 40, 45, 50],
    [30, 35, 40, 45, 50, 55],
    [35, 40, 40, 45, 50, 60],
    [40, 45, 45, 50, 55, 65],
    [45, 50, 50, 55, 60, 70],
    [50, 55, 60, 65, 70, 75]
]

Алгоритм размытия с радиусом blur_radius = 2 усредняет значения для области 5x5 вокруг каждого пикселя. Пиксели на границах матрицы учитывают меньшее количество соседей (из-за того, что часть области выходит за границы изображения). В результате мы получаем "размытую" версию исходной матрицы, где каждый пиксель заменён на среднее значение его соседей.

Предлагаю оценить результат размытия с произвольным радиусом, где blur_radius=2:

Было:

Стало:

Итоги

К написанию статьи меня подтолкнула следующая задача - https://dmoj.ca/problem/coci13c2p2

Благодарю за прочтение, призываю к конструктивной критике и обсуждению в комментах.

Всем мир!

Комментарии (9)


  1. RodionGork
    11.10.2024 07:37

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

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

    Любопытная задачка для новичка - попробовать сгенерить все эти числа в одном простом цикле / итераторе. Если бы не требовалось выколоть центральную клетку это было бы просто:

    for i in range(9):
      print(i%3-1, i//3-1)

    А вот как сделать чтобы было только восемь итераций и выводились нужные 8 пар координат? Возможно тут больше одного решения :)


    1. yastrebdev Автор
      11.10.2024 07:37

      Это больше для наглядности, потому что я изначально без подробной демонстрации не мог понять)

      Но по идее в ваш цикл можно добавить условие:

      for i in range(9):
          if i%3-1 == 0 and i//3-1 == 0:
              continue
          print(i%3-1, i//3-1)

      Спасибо за комментарий, я об этом не задумывался)


      1. Blooderst
        11.10.2024 07:37

        зачем такое сложное условие? i == 4 достаточно


        1. yastrebdev Автор
          11.10.2024 07:37

          Я понимаю, тоже так сначала подумал. Но i == 4 на мой взгляд не очень явное условие, если предполагается что range может принимать переменную или другое число. Мое условие излишне для этого примера, соглашусь, но глобально оно мне кажется более универсальным


    1. azTotMD
      11.10.2024 07:37

      Тригонометрия с округлением в помощь и не нужно никаких выкалываний точек.

      А потом мы кэшируем результаты и получаем решение как у автора


      1. yastrebdev Автор
        11.10.2024 07:37

        Если можете, напишите код в ответ на комментарий, что бы понимать о чем вы говорите. Я например на данный момент не понимаю тригонометрию. Буду благодарен за практический совет.


        1. azTotMD
          11.10.2024 07:37

          n_points = 8
          step = 2.0 * math.pi / n_points
          alpha = 0
          for _ in range(n_points):
            print(round(cos(alpha)), round(sin(alpha)))
            alpha += step

          например

          P.S. так не стоит делать, если мы гонимся за производительностью

          P.P.S. зато легко регулировать. Например, если хотим только ортогональных соседей - ставим n_point = 4. Или можно ввести радиус и смотреть не только ближайших соседей


  1. faceman555
    11.10.2024 07:37

    Спасибо большое!


  1. Tantozzik
    11.10.2024 07:37

    Благодарю, познавательно!