Граф — представление, обход графа (DFS, BFS): гайд и примеры на JavaScript
-
Граф — это структура данных, которая представляет связи между объектами. Граф состоит из вершин (узлов) и рёбер (связей между ними).
Графы используются повсеместно:
- Социальные сети: пользователи — вершины, дружба — рёбра
- Маршрутизация: города — вершины, дороги — рёбра
- Рекомендации: товары связаны по схожести
- Анализ зависимостей: модули в проекте, задачи с приоритетами
- Поиск кратчайшего пути: навигация, логистика
- Паутина интернета: веб-страницы и ссылки между ними
Типы графов
Ориентированный граф — рёбра имеют направление (A → B). Неориентированный граф — рёбра двусторонние (A
B). Взвешенный граф — рёбра имеют вес/стоимость. Невзвешенный граф — все рёбра одинакового веса.
Представление графа в коде
Существует три основных способа представить граф. Выбор зависит от задачи, количества вершин и плотности графа.
1. Список смежности (Adjacency List) — самый эффективный
Для каждой вершины храним список её соседей. Это самый популярный способ.
// Неориентированный граф с помощью Map const graph = new Map(); // Добавление рёбер graph.set('A', ['B', 'C']); graph.set('B', ['A', 'D']); graph.set('C', ['A', 'D']); graph.set('D', ['B', 'C']); // Или с помощью объекта const graphObj = { 'A': ['B', 'C'], 'B': ['A', 'D'], 'C': ['A', 'D'], 'D': ['B', 'C'] };Преимущества: компактный для разреженных графов, быстрый доступ к соседям, O(V+E) память.
Недостатки: проверка наличия конкретного ребра требует линейного поиска.2. Матрица смежности (Adjacency Matrix)
Двумерный массив, где
matrix[i][j]= 1, если есть ребро между вершиной i и j.// Граф с 4 вершинами const matrix = [ [0, 1, 1, 0], // A: связана с B, C [1, 0, 0, 1], // B: связана с A, D [1, 0, 0, 1], // C: связана с A, D [0, 1, 1, 0] // D: связана с B, C ]; // Или с индексами для удобства const vertices = ['A', 'B', 'C', 'D']; // Проверка ребра A → B: matrix[0][1] === 1 // Для взвешенного графа: matrix[i][j] = весПреимущества: быстрая проверка ребра O(1), подходит для плотных графов.
Недостатки: требует O(V²) памяти, неэффективна для разреженных графов.3. Список рёбер (Edge List)
Просто список всех рёбер в виде пар вершин.
const edges = [ ['A', 'B'], ['A', 'C'], ['B', 'D'], ['C', 'D'] ]; // Для взвешенного графа: const weightedEdges = [ ['A', 'B', 5], ['A', 'C', 3], ['B', 'D', 2], ['C', 'D', 7] ];Используется: когда нужно обрабатывать все рёбра, в алгоритмах вроде Краскала.
Класс Graph для удобства
Создадим удобный класс для работы с графами:
class Graph { constructor(isDirected = false) { this.adjacencyList = new Map(); this.isDirected = isDirected; } addVertex(vertex) { if (!this.adjacencyList.has(vertex)) { this.adjacencyList.set(vertex, []); } } addEdge(vertex1, vertex2, weight = 1) { this.addVertex(vertex1); this.addVertex(vertex2); this.adjacencyList.get(vertex1).push({ node: vertex2, weight }); // Для неориентированного графа добавляем обратное ребро if (!this.isDirected) { this.adjacencyList.get(vertex2).push({ node: vertex1, weight }); } } getNeighbors(vertex) { return this.adjacencyList.get(vertex) || []; } hasVertex(vertex) { return this.adjacencyList.has(vertex); } display() { for (let [vertex, neighbors] of this.adjacencyList) { console.log(`${vertex} →`, neighbors.map(n => n.node).join(', ')); } } }Использование:
const g = new Graph(false); // неориентированный граф g.addEdge('A', 'B'); g.addEdge('A', 'C'); g.addEdge('B', 'D'); g.display(); // Вывод: // A → B, C // B → A, D // C → A // D → B
DFS (Depth-First Search) — Поиск в глубину
Как работает DFS
- Начинаем с начальной вершины
- Идём в глубину, посещая первого соседа
- Затем идём в соседа этого соседа и т.д.
- Когда соседей нет, возвращаемся (backtrack) к последней вершине и ищем другой путь
- Повторяем, пока не посетим все вершины
Аналогия: исследование лабиринта — идёшь в одну сторону полностью, потом разворачиваешься и пробуешь другую.
DFS рекурсивная реализация
class Graph { // ... предыдущий код ... dfsRecursive(startVertex, visited = new Set(), result = []) { visited.add(startVertex); result.push(startVertex); const neighbors = this.getNeighbors(startVertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { this.dfsRecursive(neighbor.node, visited, result); } } return result; } dfs(startVertex) { return this.dfsRecursive(startVertex); } }Пример использования:
const g = new Graph(false); g.addEdge('A', 'B'); g.addEdge('A', 'C'); g.addEdge('B', 'D'); g.addEdge('C', 'D'); g.addEdge('D', 'E'); console.log(g.dfs('A')); // ['A', 'B', 'D', 'C', 'E']Как это работает step-by-step:
- Посетить A, добавить в результат
- A имеет соседей B и C, идём к B (первому)
- B имеет соседей A (уже посещена) и D, идём к D
- D имеет соседей B (уже посещена), C и E, идём к C
- C имеет соседей A (уже посещена) и D (уже посещена)
- Возвращаемся к D, идём к E
- E соседей нет, всё готово
DFS итеративная реализация (со стеком)
class Graph { dfsIterative(startVertex) { const visited = new Set(); const stack = [startVertex]; const result = []; while (stack.length > 0) { const vertex = stack.pop(); if (!visited.has(vertex)) { visited.add(vertex); result.push(vertex); const neighbors = this.getNeighbors(vertex); // Добавляем соседей в стек в обратном порядке // чтобы первого соседа обработать первым for (let i = neighbors.length - 1; i >= 0; i--) { if (!visited.has(neighbors[i].node)) { stack.push(neighbors[i].node); } } } } return result; } }Когда использовать DFS:
- Поиск пути в лабиринте или графе
- Обнаружение циклов в графе
- Топологическая сортировка
- Проверка связности компонент
- Задачи с backtracking (судоку, головоломки)
- Когда граф очень широкий (много соседей) — DFS экономит память
BFS (Breadth-First Search) — Поиск в ширину
Как работает BFS
- Начинаем с начальной вершины
- Посещаем всех её соседей (уровень 1)
- Затем посещаем всех соседей уровня 1 (уровень 2)
- Затем уровень 3 и т.д.
- Продолжаем, пока не посетим все вершины
Аналогия: расширяющиеся волны в воде — сначала ближайшие точки, потом дальше.
BFS реализация (с очередью)
class Graph { bfs(startVertex) { const visited = new Set(); const queue = [startVertex]; const result = []; visited.add(startVertex); while (queue.length > 0) { const vertex = queue.shift(); // берём первый элемент result.push(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { visited.add(neighbor.node); queue.push(neighbor.node); // добавляем в конец } } } return result; } }Пример использования:
const g = new Graph(false); g.addEdge('A', 'B'); g.addEdge('A', 'C'); g.addEdge('B', 'D'); g.addEdge('C', 'D'); g.addEdge('D', 'E'); console.log(g.bfs('A')); // ['A', 'B', 'C', 'D', 'E']Как это работает step-by-step:
- Очередь: [A], посетить A, результат: [A]
- Соседи A: B, C. Очередь: [B, C], посетить B, результат: [A, B]
- Соседи B: A (уже), D. Очередь: [C, D], посетить C, результат: [A, B, C]
- Соседи
A (уже), D (уже в очереди). Очередь: [D], посетить D, результат: [A, B, C, D] - Соседи
B (уже), C (уже), E. Очередь: [E], посетить E, результат: [A, B, C, D, E]
Когда использовать BFS:
- Поиск кратчайшего пути в невзвешенном графе
- Проверка, является ли граф двудольным
- Уровневый обход дерева/графа
- Поиск всех вершин на определённом расстоянии
- Когда граф глубокий, а решение близко к началу
DFS vs BFS
Параметр DFS BFS Структура данных Стек (рекурсия) Очередь Память O(h) где h — глубина O(w) где w — ширина Кратчайший путь
Не гарантирует✓ Гарантирует (невзвешённый граф) Порядок посещения Глубоко в одну ветку Слой за слоем Применение Циклы, топосорт, backtracking Кратчайший путь, проверка расстояния Сложность O(V + E) O(V + E)
Практические примеры
Пример 1: Поиск пути между двумя вершинами
class Graph { // ... предыдущий код ... findPath(start, end) { const visited = new Set(); const path = []; const dfs = (vertex) => { visited.add(vertex); path.push(vertex); if (vertex === end) { return true; // путь найден } const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { if (dfs(neighbor.node)) { return true; } } } path.pop(); // backtrack return false; }; if (dfs(start)) { return path; } return null; // пути нет } } // Использование const g = new Graph(false); g.addEdge('A', 'B'); g.addEdge('B', 'C'); g.addEdge('A', 'D'); g.addEdge('D', 'C'); console.log(g.findPath('A', 'C')); // ['A', 'B', 'C']Пример 2: Поиск кратчайшего пути (невзвешённый граф)
class Graph { // ... предыдущий код ... shortestPath(start, end) { const visited = new Set(); const queue = [[start, [start]]]; // [вершина, путь до неё] visited.add(start); while (queue.length > 0) { const [vertex, path] = queue.shift(); if (vertex === end) { return path; } const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { visited.add(neighbor.node); queue.push([neighbor.node, [...path, neighbor.node]]); } } } return null; // пути нет } } // Использование const g = new Graph(false); g.addEdge('A', 'B'); g.addEdge('B', 'C'); g.addEdge('A', 'D'); g.addEdge('D', 'E'); g.addEdge('E', 'C'); console.log(g.shortestPath('A', 'C')); // ['A', 'B', 'C'] (длина 2) // Не выберет ['A', 'D', 'E', 'C'] (длина 3)Пример 3: Проверка наличия цикла в графе
class Graph { // ... предыдущий код ... hasCycle() { const visited = new Set(); const recursionStack = new Set(); const dfs = (vertex, parent = null) => { visited.add(vertex); recursionStack.add(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { const nextVertex = neighbor.node; // Для неориентированного графа проверяем, не родитель ли это if (this.isDirected && visited.has(nextVertex) && recursionStack.has(nextVertex)) { return true; // цикл найден } if (!this.isDirected && nextVertex === parent) { continue; // пропускаем обратную ссылку } if (!visited.has(nextVertex)) { if (dfs(nextVertex, vertex)) { return true; } } } recursionStack.delete(vertex); return false; }; for (let vertex of this.adjacencyList.keys()) { if (!visited.has(vertex)) { if (dfs(vertex)) { return true; } } } return false; } } // Использование const g1 = new Graph(false); g1.addEdge('A', 'B'); g1.addEdge('B', 'C'); g1.addEdge('C', 'A'); // цикл! console.log(g1.hasCycle()); // true const g2 = new Graph(false); g2.addEdge('A', 'B'); g2.addEdge('B', 'C'); console.log(g2.hasCycle()); // falseПример 4: Подсчёт количества связных компонент
class Graph { // ... предыдущий код ... countConnectedComponents() { const visited = new Set(); let count = 0; const dfs = (vertex) => { visited.add(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { dfs(neighbor.node); } } }; for (let vertex of this.adjacencyList.keys()) { if (!visited.has(vertex)) { dfs(vertex); count++; } } return count; } } // Использование const g = new Graph(false); g.addEdge('A', 'B'); g.addEdge('C', 'D'); g.addEdge('E', 'F'); g.addEdge('F', 'G'); console.log(g.countConnectedComponents()); // 3 компоненты: [A,B], [C,D], [E,F,G]Пример 5: Поиск всех вершин на определённом расстоянии
class Graph { // ... предыдущий код ... verticesAtDistance(start, distance) { const visited = new Set(); const queue = [[start, 0]]; // [вершина, расстояние] const result = []; visited.add(start); while (queue.length > 0) { const [vertex, dist] = queue.shift(); if (dist === distance) { result.push(vertex); } if (dist < distance) { const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { visited.add(neighbor.node); queue.push([neighbor.node, dist + 1]); } } } } return result; } } // Использование const g = new Graph(false); g.addEdge('A', 'B'); g.addEdge('A', 'C'); g.addEdge('B', 'D'); g.addEdge('C', 'E'); g.addEdge('D', 'F'); console.log(g.verticesAtDistance('A', 0)); // ['A'] console.log(g.verticesAtDistance('A', 1)); // ['B', 'C'] console.log(g.verticesAtDistance('A', 2)); // ['D', 'E'] console.log(g.verticesAtDistance('A', 3)); // ['F']Пример 6: Топологическая сортировка (для ориентированного ацикличного графа)
class Graph { // ... предыдущий код ... topologicalSort() { const visited = new Set(); const stack = []; const dfs = (vertex) => { visited.add(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { dfs(neighbor.node); } } stack.push(vertex); }; for (let vertex of this.adjacencyList.keys()) { if (!visited.has(vertex)) { dfs(vertex); } } return stack.reverse(); } } // Использование: расписание задач с зависимостями // Задача B зависит от A, задача D зависит от B и C const g = new Graph(true); g.addEdge('A', 'B'); g.addEdge('B', 'D'); g.addEdge('C', 'D'); console.log(g.topologicalSort()); // ['A', 'C', 'B', 'D'] или ['C', 'A', 'B', 'D'] // Суть: A и C выполняются первыми, потом B, потом DПример 7: Реальный пример — поиск маршрута между аэропортами
class Graph { // ... весь предыдущий код ... } // Создаём граф аэропортов const airports = new Graph(false); airports.addEdge('JFK', 'LAX'); airports.addEdge('JFK', 'ATL'); airports.addEdge('LAX', 'SFO'); airports.addEdge('ATL', 'DFW'); airports.addEdge('DFW', 'SFO'); airports.addEdge('ATL', 'MIA'); // Найти кратчайший маршрут из JFK в SFO const route = airports.shortestPath('JFK', 'SFO'); console.log(`Маршрут из JFK в SFO: ${route.join(' → ')}`); // Маршрут из JFK в SFO: JFK → LAX → SFO (2 перелёта) // Альтернатива была бы: JFK → ATL → DFW → SFO (3 перелёта) // Проверить, есть ли прямой путь const allNeighbors = airports.bfs('JFK'); console.log(`Достижимые из JFK:`, allNeighbors); // Достижимые из JFK: ['JFK', 'LAX', 'ATL', 'SFO', 'DFW', 'MIA']
Сложность алгоритмов
Временная сложность обоих алгоритмов: O(V + E)
- V — количество вершин
- E — количество рёбер
- Мы посещаем каждую вершину один раз и рассматриваем каждое ребро один раз
Пространственная сложность:
- DFS: O(h) где h — глубина графа (из-за рекурсивного стека вызовов)
- BFS: O(w) где w — максимальная ширина на каждом уровне
Практический вывод: для широких графов DFS более экономен по памяти, для глубоких графов BFS.
Полный рабочий класс Graph
class Graph { constructor(isDirected = false) { this.adjacencyList = new Map(); this.isDirected = isDirected; } addVertex(vertex) { if (!this.adjacencyList.has(vertex)) { this.adjacencyList.set(vertex, []); } } addEdge(vertex1, vertex2, weight = 1) { this.addVertex(vertex1); this.addVertex(vertex2); this.adjacencyList.get(vertex1).push({ node: vertex2, weight }); if (!this.isDirected) { this.adjacencyList.get(vertex2).push({ node: vertex1, weight }); } } getNeighbors(vertex) { return this.adjacencyList.get(vertex) || []; } hasVertex(vertex) { return this.adjacencyList.has(vertex); } dfs(startVertex) { const visited = new Set(); const result = []; const dfsHelper = (vertex) => { visited.add(vertex); result.push(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { dfsHelper(neighbor.node); } } }; dfsHelper(startVertex); return result; } bfs(startVertex) { const visited = new Set(); const queue = [startVertex]; const result = []; visited.add(startVertex); while (queue.length > 0) { const vertex = queue.shift(); result.push(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { visited.add(neighbor.node); queue.push(neighbor.node); } } } return result; } shortestPath(start, end) { const visited = new Set(); const queue = [[start, [start]]]; visited.add(start); while (queue.length > 0) { const [vertex, path] = queue.shift(); if (vertex === end) { return path; } const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { visited.add(neighbor.node); queue.push([neighbor.node, [...path, neighbor.node]]); } } } return null; } hasCycle() { const visited = new Set(); const recursionStack = new Set(); const dfs = (vertex, parent = null) => { visited.add(vertex); recursionStack.add(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { const nextVertex = neighbor.node; if (this.isDirected && visited.has(nextVertex) && recursionStack.has(nextVertex)) { return true; } if (!this.isDirected && nextVertex === parent) { continue; } if (!visited.has(nextVertex)) { if (dfs(nextVertex, vertex)) { return true; } } } recursionStack.delete(vertex); return false; }; for (let vertex of this.adjacencyList.keys()) { if (!visited.has(vertex)) { if (dfs(vertex)) { return true; } } } return false; } countConnectedComponents() { const visited = new Set(); let count = 0; const dfs = (vertex) => { visited.add(vertex); const neighbors = this.getNeighbors(vertex); for (let neighbor of neighbors) { if (!visited.has(neighbor.node)) { dfs(neighbor.node); } } }; for (let vertex of this.adjacencyList.keys()) { if (!visited.has(vertex)) { dfs(vertex); count++; } } return count; } display() { for (let [vertex, neighbors] of this.adjacencyList) { console.log(`${vertex} →`, neighbors.map(n => n.node).join(', ')); } } }
Заключение
DFS и BFS — это фундаментальные алгоритмы для работы с графами. Оба работают за O(V+E) и выбор между ними зависит от задачи:
- Выбирай DFS если нужно искать пути, обнаруживать циклы или когда память важна
- Выбирай BFS если нужен кратчайший путь в невзвешённом графе или слойный обход
Практикуйся с примерами выше, экспериментируй с разными топологиями графов, и вскоре эти алгоритмы станут второй натурой.
© 2024 - 2025 ExLends, Inc. Все права защищены.