Зарегистрируйтесь, чтобы продолжить обучение

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

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

eyJpZCI6IjA5OTUwMjU2MTU5NjI2OWYzYjcxNzFjYmYxY2I0MGU1LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=ebbe589f6b5678b21c6901d10ed5f194e4b1aacb5e3ee33bd2b62a78adff8ee3

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

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

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

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

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

eyJpZCI6IjNmMjEwYzhjZGY3MmZkMGI3MmM1ZjJhZjExZjQ3ZmE4LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=da41c69262fe00f8a8f51e7697d66bb506cf2a59be4b504cf8cd6e6c60bbe84a

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

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

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

eyJpZCI6IjMyMGY5ZDk5MjcyODJkZThlMzViYTc5YTg0N2ZmNjI2LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=34d6ed059bbe18a668a61cf1e711b8a6af968313a283d1c1fb605032cbe14762

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

eyJpZCI6ImVlNWI1ZDM5YmE5MDNjZTQxOTBhZGQ1ZGU3ZTVjZTZmLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=9da01b778ba1ce6b44e3a6b6743d3bdd0722bf017a2d7925ea3b9748ccdfd41e

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

eyJpZCI6ImM1ZjliYzhmMjY0YjMzMWZlNDViZGRmYzdiNzlmNDY1LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=5acec03bf464e24b9289fd57fdeb98c48162231705ff04f0e92524cd7c87a9f1

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

eyJpZCI6ImQ4NTNiMWY4MWJjZDAzNjUxMGYyZjBhYWRkY2Q1NGU5LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=9c04164aa8678a9c1a66d5e994a878e2b16296982cf1e0151df32efae6017d7e

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

eyJpZCI6ImU1ZDc0OTU1OTdiYWVlYTViNGNjZjIxZWVmOTAxNTI5LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=8b455a329ded59b84a1823cade34ed6175b0fff82d0ae5da9b6f86b1a4e61c15

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

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

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

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

eyJpZCI6ImJiYjI3YmMyNzc5ZmRhOWI5ZTNlZDBmNjY2MDI0MjQ0LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=0003cf3c59625bbb791955368bd59581b036c4f511d024f05b604754bdc65527

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

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

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

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

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

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

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

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

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

eyJpZCI6Ijg2ZDQ4MGU0OGVlNmQzODkyOGJmMzZhMDUyNzBhMzRhLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=ee6e91f92df700ed1ddac3e9622a52dbac507fb1755c4325c45e8de740983cdf

Из рисунка видно, что по соседству с голубой клеткой 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. Посмотрим, как работает алгоритм:

eyJpZCI6ImE1ZGNhMzJkYjM2ZWYxYzAzYThkNTk5MjVlZjBmZmQ1LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=21ffa45388797a9830423c2d00d432037b9bd4eb6fb0accc09d9415c2a551cd0
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

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

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

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

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