Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Поиск в ширину Алгоритмы на графах

Интереснее всего изучать алгоритмы не сами по себе, а вместе с Разрабатывая игру, мы изучаем эффективные алгоритмы и интересные структуры данных.Представим, что мы разрабатываем игру. Мы хотим написать алгоритм, который приведет персонажа (в синем кружке) к важному ресурсу (в красном кружке):

eyJpZCI6IjI1MTI4YWYyNjA4Mzk1NjA1MTY2ZTRlNDU0MDJkY2VkLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=eb587c062ac5e85f1ddc47fb2269c840d6dbfc3867307a9629e018507cd8296f

Компьютер должен проложить кратчайший маршрут с учетом всех препятствий. Здесь поможет обход графа в ширину, с которым мы познакомимся в этом уроке. Также этот алгоритм называют «алгоритмом поиска в ширину» или BFS (breadth first search).

Карты в играх похожи на шахматную доску — они состоят из отдельных клеток. Клетки могут быть:

  • Проходимыми — дорога, чистое поле, тропинка

  • Непроходимыми — река, лес, гора

Обычно юниты могут ходить влево, вправо, вверх и вниз, но иногда в играх разрешают движение по диагонали. Задачу усложняют препятствия, которые превращают карту в подобие лабиринта. Поэтому нам нужно, чтобы алгоритм построил маршрут в виде цепочки клеток. В нашем случае она выглядит так:

eyJpZCI6Ijc0NzMwMjVmZmI3N2MwYjZjYzI0ZTk3NGE3MGU0ZjE0LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=3a8f5450bddee0a6438dc345d700fd473e741a6c30527e279f3605057dade8d4

Обход в ширину

При поиске в глубину мы проверяем соседнюю вершину, далее — ее соседнюю вершину, и таким образом продвигаемся от исходной точки. А при поиске в ширину мы двигаемся по-другому: равномерно во все стороны, проверяя сначала ближайшие вершины, затем — следующие за ними и так далее.

Возьмем карту с персонажем, которого нужно провести к ресурсу. Перенесем ее на схему и продумаем алгоритм:

eyJpZCI6ImNlNjE5NGZmMjNiNzVmNjFmYWVmMmJiMTUyNzdiNmZmLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=1fa415cd64bf3d7c3c178da813ce8e5d3e484f3db686d880e917436d3c07344c

На первом шаге мы осматриваем соседние клетки. В нашей игре юнит может двигаться только вверх, вниз, вправо и влево. Если в соседних клетках нет ресурса, то двигаемся дальше и осматриваем соседей:

eyJpZCI6ImUzMzRkOGNmMTAwMTNkOWJiY2RjN2I5N2E1NWE0NzQzLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=4efcde058f6b4d231be461e7802405681dcfc410da8b1da70be8d2ad56f0067e

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

eyJpZCI6IjM2ZGE2MWEyOWFkOGIzZGUxY2MyYmEyZDRlY2IzYzNmLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=9595521e5ace95979f56354366c659d3d714c8fe815f8e919823cdbbeb5e529b

На очередном шаге мы добираемся до нашего ресурса — поиск закончен. Двигаясь назад по клеткам, мы можем восстановить путь:

eyJpZCI6IjIwNDNjNThiYTMzZjFhZjI0MDFlNWE5NDNmMTlkMTMxLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=2260c5f21307d356a30e3ecd7201f7347177c66e9dc79081cb7398156bed5ec7

В целом, идея алгоритма кажется ясной, но как именно искать соседей на каждом шаге? Разберемся в деталях:

eyJpZCI6IjU4MjBmOTg3MTEwNTdkZDRiZTQ5YTRhOWEwODM5NWU2LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=806ba4bc58a73fa645bb5ec29d3dbae5ae7c9be307b4eb2b80e85b0849f6c406

В качестве примера возьмем один из шагов алгоритма, показанный на рисунке слева. На нем мы видим:

  • Синие клетки — те, которые мы проверяем на текущем шаге

  • Светло-зеленые клетки — клетки, которые находятся по соседству от синих (сверху, снизу, справа и слева от них)

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

eyJpZCI6ImE2NzQwMzk3MjRhNTBhMTFjMjcxMTQ2NjgwMzM5YjgxLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=69f06734c012f030802c813c5f7efdb09c92d624290f9ffcadd1356f2bfc7340

Для наглядности закрасим:

  • Желтым цветом — уже проверенные клетки

  • Темно-зеленым — клетки, которые одновременно являются соседями двух и более клеток

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

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

Оставшиеся клетки будем добавлять во второе множество. Элементы в множестве встречаются не больше одного раза, поэтому одну и ту же клетку можно добавлять много раз — она останется в единственном экземпляре. Опираясь на цвета клеток, можем сказать, что во второе множество попадают все зеленые клетки: и светлые, и темные.

Неявные графы

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

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

eyJpZCI6IjczNzU3ODYzZTYyNDNiZTEwMDNhZTRmZTVkMTU0NDRmLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=15c47ae27245afc463e744fb65d732fc2f2b6041052338bbf214e9a1ec72b811

Из рисунка видно, что по соседству с голубой клеткой a[i][j] находятся четыре зеленые клетки с такими координатами:

  • a[i - 1][j]

  • a[i][j - 1]

  • a[i][j + 1]

  • a[i + 1][j]

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

Во-первых, не у каждой клетки есть соседи: например, у клеток в верхней строке нет соседей сверху, а у клеток в левом столбце — соседей слева. Во-вторых, мы должны рассматривать только проходимые клетки — с кодами, соответствующими дороге, тропинке или чистому полю.

Чтобы идентифицировать вершину, нам достаточно пары чисел, а именно строки и столбца клетки. При этом нам не надо в явном виде хранить вершины или ребра графа.

Если соседние вершины можно вычислить на основании дополнительной информации, то мы говорим, что граф представлен в неявном виде — речь идет о неявном графе.

Реализация

Для поиска в ширину мы реализовали функцию bfs(), что означает breadth first search:

const bfs = (map, fromRow, fromColumn, toRow, toColumn) => {
  const pack = (row, column) => `${row}:${column}`;
  const unpack = (cell) => cell.split(':').map((x) => parseInt(x, 10));

  const visited = new Set();
  const isValidNeighbour = (row, column) => {
    if (row < 0 || row >= map.length) {
      return false;
    }

    if (column < 0 || column >= map[row].length) {
      return false;
    }

    const cell = pack(row, column);
    if (visited.has(cell)) {
      return false;
    }

    return map[row][column] === 0;
  };

  let step = new Map();
  const initialCell = pack(fromRow, fromColumn);
  step.set(initialCell, [initialCell]);
  while (step.size > 0) {
    const nextStep = new Map();
    const tryAddCell = (row, column, path) => {
      if (isValidNeighbour(row, column)) {
        const cell = pack(row, column);
        const newPath = [...path];
        newPath.push(cell);
        nextStep.set(cell, newPath);
        visited.add(cell);
      }
    };

    for (const [cell, path] of step) {
      const [row, column] = unpack(cell);
      if (row === toRow && column === toColumn) {
        return path;
      }

      tryAddCell(row - 1, column, path);
      tryAddCell(row + 1, column, path);
      tryAddCell(row, column - 1, path);
      tryAddCell(row, column + 1, path);
    }

    step = nextStep;
  }

  return null;
};

На вход функция получает пять параметров:

  • Карту (map)

  • Исходные координаты (fromRow, fromColumn)

  • Координаты цели (toRow, toColumn)

Карта — это двумерный прямоугольный массив с числами. Функция предполагает, что 0 означает проходимую клетку (дорогу), а все остальные числа означают непроходимые клетки (гору, лес, озеро).

Сложность в том, что координаты — это пара чисел строка и столбец. Мы не можем использовать их, как ключ множества или словаря, потому что ключ — это одно значение. Мы могли бы сложить их в такой объект { row: 5, column: 3 }. Но, к сожалению, это простое решение в JavaScript работает неправильно.

В JavaScript два разных объекта считаются разными, даже если их поля и значения полей совпадают:

const a = { row: 5, column: 3 };
const b = { row: 5, column: 3 };
console.log(a === b); // => false

Чтобы обойти это ограничение, мы будем упаковывать координаты в строку — это поможет нам проверять уникальность координат. Перед использованием мы будем распаковывать координаты из строки:

const pack = (row, column) => `${row}:${column}`;
const unpack = (cell) => cell.split(':').map((x) => parseInt(x, 10));

Упаковка сводится к тому, что мы соединяем два числа в строку, связав их двоеточием. Координаты 5 и 3 превращаются в строку 5:3.

Распаковка выполняет обратное преобразование — извлекает части строки, разделенные двоеточием и превращает в числа.

Весь алгоритм поиска представляет собой один большой цикл. Перед циклом мы создаем пустое множество visited, куда будем помещать клетки, которые мы уже посетили. Функция isValidNeighbour проверяет, является ли клетка с указанными координатами нормальным соседом:

const isValidNeighbour = (row, column) => {
  if (row < 0 || row >= map.length) {
    return false;
  }

  if (column < 0 || column >= map[row].length) {
    return false;
  }

  const cell = pack(row, column);
  if (visited.has(cell)) {
    return false;
  }

  return map[row][column] === 0;
};

Клетка считается доступной для посещения, если она не выходит за границы карты, если мы ни разу ее не посещали и если в ней хранится код 0, который соответствует непроходимому участку карты.

Клетки, которые мы рассматриваем на каждом шаге алгоритмы, хранятся в словаре step. В самом начале мы помещаем туда единственную клетку — ту, с которой начинаем поиск. В качестве значения помещаем массив, в котором хранится путь. В начале путь — это наша единственная клетка:

let step = new Map();
const initialCell = pack(fromRow, fromColumn);
step.set(initialCell, [initialCell]);

На каждом шаге алгоритма мы убеждаемся, что в словаре step есть клетки. Если словарь пуст, значит, мы осмотрели все клетки, до которых могли добраться и не нашли пути. В этом случае возвращаем null.

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

const nextStep = new Map();
const tryAddCell = (row, column, path) => {
  if (isValidNeighbour(row, column)) {
    const cell = pack(row, column);
    const newPath = [...path];
    newPath.push(cell);
    nextStep.set(cell, newPath);
    visited.add(cell);
  }
};

Мы создаем новый словарь, куда будем заносить клетки на следующем шаге. Функция tryAddCell проверяет, что клетка с переданными ей координатами — нормальный сосед. Если это так, добавляет ее к уже построенному пути и к множеству посещенных клеток:

for (const [cell, path] of step) {
  const [row, column] = unpack(cell);
  if (row === toRow && column === toColumn) {
    return path;
  }

  tryAddCell(row - 1, column, path);
  tryAddCell(row + 1, column, path);
  tryAddCell(row, column - 1, path);
  tryAddCell(row, column + 1, path);
}

step = nextStep;

Наконец, основной цикл. Мы извлекаем все клетки и соответствующие им пути с текущего шага и проверяем, не добрались ли мы до конца. Если добрались, сразу возвращаем путь.

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

В конце концов подменяем старый словарь step новым словарем nextStep. Посмотрим, как работает алгоритм:

eyJpZCI6ImJhNGJjNGI5YjdhYWJjOGM0MmNhZDNkMWM5ZDYyOWVlLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=778ca31fdb94f084c5529a3d8976f1085c50ed56dbac8006e03da7aca0c71769
const map = [
  [0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 1, 1, 1, 0, 0, 0],
  [0, 0, 0, 0, 1, 0, 0, 0],
  [0, 0, 0, 0, 1, 1, 1, 1],
  [0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0]
];

console.log(bfs(map, 5, 3, 2, 6)); // =>
[
//   '5:3', '4:3', '3:3',
//   '3:2', '3:1', '2:1',
//   '1:1', '1:2', '1:3',
//   '1:4', '1:5', '2:5',
//   '2:6'
// ]

https://replit.com/@hexlet/algorithms-graphs-bfs

Как видим, алгоритм поиска в ширину нашел короткий путь.

Выводы

Повторим ключевые выводы этого урока:

  • Алгоритм поиска в ширину используется в компьютерных играх, чтобы прокладывать кратчайший маршрут на двумерной карте. По-английски алгоритм называется BFS, что означает breadth first search (поиск сначала вширь)

  • В отличие от поиска в глубину, поиск в ширину сначала перебирает ближайшие вершины, затем ближайшие к ближайшим, и так далее

  • Если, благодаря знаниям о задаче, мы можем определять соседние вершины, то можно не хранить граф в явном виде (например, в виде списков смежности)


Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Об обучении на Хекслете

Для полного доступа к курсу нужен базовый план

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

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»
Изображение Тото

Задавайте вопросы, если хотите обсудить теорию или упражнения. Команда поддержки Хекслета и опытные участники сообщества помогут найти ответы и решить задачу