Алгоритм Форда–Фалкерсона: как найти максимальный поток в сети (для начинающих)

от автора

Алгоритм Форда–Фалкерсона: как найти максимальный поток в сети (для начинающих)

Привет, будущие инженеры и программисты! Сегодня мы разберём классический алгоритм Форда–Фалкерсона — дедушку всех алгоритмов максимального потока. Если алгоритм Диница — это современный спорткар, то Форд–Фалкерсон — это надёжная «классика», которая учит основам и помогает понять суть задачи.

Представьте, что вы владелец сети трубопроводов, и вам нужно понять, сколько воды можно прокачать из водохранилища в город. У каждой трубы есть максимальная пропускная способность, и вода может течь только в одном направлении. Ваша задача — найти такой способ распределения воды по трубам, чтобы в город попало максимальное количество воды. Это и есть задача максимального потока!

Такие задачи встречаются повсюду:

Транспорт: сколько грузовиков может проехать от склада до магазинов по дорогам города?

Интернет: сколько данных можно передать от сервера к пользователям через сетевые каналы?

Производство: как максимально эффективно распределить ресурсы между цехами?

Алгоритм Форда–Фалкерсона — это простой и понятный способ решить эту задачу. Он был придуман американскими математиками Лестером Фордом и Делбертом Фалкерсоном в 1956 году.

1. Основная идея: «Ищи путь — толкай воду»

Представьте, что вы стоите у водохранилища с фонариком и хотите найти путь к городу. Алгоритм Форда–Фалкерсона работает очень просто:

Найди путь: Ищем любой путь от водохранилища (истока) до города (стока) по трубам, которые ещё не заполнены до предела.

Толкай воду: Определяем, сколько воды можно протолкнуть по этому пути (это будет равно самой узкой трубе на пути), и отправляем её.

Повторяй: Ищем новый путь и снова толкаем воду. Повторяем до тех пор, пока не найдём ни одного пути.

Готово: Сумма всей воды, которую мы протолкнули, и есть максимальный поток!

Звучит просто, правда? И это действительно так! Алгоритм Форда–Фалкерсона — это воплощение принципа «делай простые вещи, пока они работают».

2. Остаточный граф: «Я передумал, хочу по-другому!»

Но есть одна хитрость. Представьте, что вы протолкнули воду по пути A → B → C, а потом поняли, что было бы лучше отправить часть воды по пути A → D → C. Как это сделать?

В реальной жизни вода не может течь назад по трубе. Но в алгоритме мы можем «передумать» и перенаправить поток. Для этого используется концепция остаточного графа:

Прямые рёбра: Когда мы добавляем трубу A → B с пропускной способностью 10, мы можем по ней протолкнуть до 10 единиц воды.

Обратные рёбра: Одновременно мы создаём «воображаемую» трубу B → A с пропускной способностью 0. Это означает, что пока мы ничего не отправляли, мы не можем «отменить» поток.

Магия перенаправления: Когда мы отправляем 5 единиц воды по A → B:

  • Пропускная способность A → B становится 10 — 5 = 5

  • Пропускная способность B → A становится 0 + 5 = 5

Теперь, если алгоритм найдёт путь, который использует B → A, это будет означать «отмени 5 единиц потока, которые мы отправили по A → B, и отправь их в другое место». Это позволяет алгоритму «исправлять ошибки» и находить оптимальное решение.

3. Поиск пути: BFS vs DFS

Алгоритм Форда–Фалкерсона — это не один конкретный алгоритм, а семейство алгоритмов. Главное отличие — как искать путь от истока к стоку.

BFS (Breadth-First Search): Ищем самый короткий путь (по количеству рёбер). Это как если бы вы искали путь в лабиринте, исследуя все комнаты на расстоянии 1 шага, потом 2 шага, и так далее. Такой вариант называется алгоритмом Эдмондса–Карпа.

DFS (Depth-First Search): Ищем любой путь, идя «вглубь» графа. Это как если бы вы шли по лабиринту, всегда выбирая первый доступный поворот, пока не дойдёте до конца или тупика.

В нашем примере мы используем DFS, потому что его проще понять и реализовать.

4. Пример работы алгоритма

Давайте разберём простой пример. У нас есть 4 точки (0, 1, 2, 3) и следующие трубы с их пропускными способностями:

0 → 1 (пропускная способность 10) 0 → 2 (пропускная способность 10)   1 → 2 (пропускная способность 2) 1 → 3 (пропускная способность 4) 2 → 1 (пропускная способность 6) 2 → 3 (пропускная способность 10) 

Исток s = 0, сток t = 3.

Итерация 1:

  • Ищем путь: 0 → 1 → 3

  • Пропускные способности на пути: 10, 4

  • Узкое место: min(10, 4) = 4

  • Отправляем 4 единицы потока

  • Обновляем граф:

    • 0 → 1: 10 — 4 = 6

    • 1 → 3: 4 — 4 = 0

    • 1 → 0: 0 + 4 = 4 (обратное ребро)

    • 3 → 1: 0 + 4 = 4 (обратное ребро)

  • Общий поток: 4

Итерация 2:

  • Ищем путь: 0 → 2 → 3

  • Пропускные способности на пути: 10, 10

  • Узкое место: min(10, 10) = 10

  • Отправляем 10 единиц потока

  • Обновляем граф:

    • 0 → 2: 10 — 10 = 0

    • 2 → 3: 10 — 10 = 0

    • 2 → 0: 0 + 10 = 10 (обратное ребро)

    • 3 → 2: 0 + 10 = 10 (обратное ребро)

  • Общий поток: 4 + 10 = 14

Итерация 3:

  • Ищем путь: 0 → 1 → 2 → 3

  • Но 2 → 3 имеет пропускную способность 0, а 0 → 2 тоже 0

  • Пробуем другие пути, но все дороги из 0 либо заблокированы, либо ведут в тупик

  • Путей больше нет, алгоритм завершается

Максимальный поток = 14

5. Код на C# с подробными комментариями

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

// Алгоритм Форда-Фалкерсона — поиск максимального потока в сети  using System; using System.Collections.Generic;  namespace FordFulkersonMaxFlow {     /// <summary>Реализация алгоритма Форда-Фалкерсона с поиском в глубину (DFS).</summary>     public class FordFulkerson     {         private readonly int[,] _capacity;    // матрица пропускных способностей         private readonly int[,] _flow;        // матрица текущих потоков         private readonly bool[] _visited;     // массив для отслеживания посещённых вершин         private readonly int _n;              // количество вершин          public FordFulkerson(int n)         {             _n = n;             _capacity = new int[n, n];             _flow = new int[n, n];             _visited = new bool[n];         }          /// <summary>Добавляет ребро от from к to с пропускной способностью capacity.</summary>         public void AddEdge(int from, int to, int capacity)         {             _capacity[from, to] = capacity;         }          /// <summary>Находит максимальный поток из источника source в сток sink.</summary>         public int MaxFlow(int source, int sink)         {             int maxFlow = 0;                          // Основной цикл: ищем пути и толкаем поток, пока это возможно             while (true)             {                 // Обнуляем массив посещённых вершин для нового поиска                 Array.Fill(_visited, false);                                  // Ищем путь от источника к стоку с помощью DFS                 int pathFlow = DFS(source, sink, int.MaxValue);                                  // Если путь не найден, значит достигли максимума                 if (pathFlow == 0)                     break;                                  // Добавляем найденный поток к общему                 maxFlow += pathFlow;             }                          return maxFlow;         }          /// <summary>         /// Поиск в глубину (DFS) для нахождения пути от current к sink.         /// Возвращает минимальную пропускную способность на найденном пути.         /// </summary>         private int DFS(int current, int sink, int minCapacity)         {             // Если дошли до стока, возвращаем текущую минимальную пропускную способность             if (current == sink)                 return minCapacity;                          // Отмечаем текущую вершину как посещённую             _visited[current] = true;                          // Перебираем все возможные следующие вершины             for (int next = 0; next < _n; next++)             {                 // Проверяем, можем ли мы пойти в следующую вершину:                 // 1. Она не должна быть посещённой                 // 2. Остаточная пропускная способность должна быть больше 0                 if (!_visited[next] && GetResidualCapacity(current, next) > 0)                 {                     // Вычисляем остаточную пропускную способность до следующей вершины                     int residualCapacity = GetResidualCapacity(current, next);                                          // Рекурсивно ищем путь дальше, передавая минимум из текущей                     // минимальной пропускной способности и остаточной способности ребра                     int pathFlow = DFS(next, sink, Math.Min(minCapacity, residualCapacity));                                          // Если путь найден (pathFlow > 0), обновляем потоки                     if (pathFlow > 0)                     {                         // Увеличиваем поток по прямому ребру                         _flow[current, next] += pathFlow;                                                  // Уменьшаем поток по обратному ребру (для возможности "отмены")                         _flow[next, current] -= pathFlow;                                                  return pathFlow;                     }                 }             }                          // Если ни один путь не найден, возвращаем 0             return 0;         }          /// <summary>         /// Вычисляет остаточную пропускную способность между двумя вершинами.         /// Это разность между изначальной пропускной способностью и текущим потоком.         /// </summary>         private int GetResidualCapacity(int from, int to)         {             return _capacity[from, to] - _flow[from, to];         }          /// <summary>Выводит текущее состояние потоков (для отладки).</summary>         public void PrintFlows()         {             Console.WriteLine("Текущие потоки:");             for (int i = 0; i < _n; i++)             {                 for (int j = 0; j < _n; j++)                 {                     if (_capacity[i, j] > 0 && _flow[i, j] > 0)                     {                         Console.WriteLine($"  {i} → {j}: {_flow[i, j]} / {_capacity[i, j]}");                     }                 }             }         }     }      internal static class Program     {         private static void Main()         {             // Создаём граф с 4 вершинами             int n = 4;             FordFulkerson ff = new FordFulkerson(n);              // Добавляем рёбра (трубы) с их пропускными способностями             ff.AddEdge(0, 1, 10);  // от 0 к 1, пропускная способность 10             ff.AddEdge(0, 2, 10);  // от 0 к 2, пропускная способность 10             ff.AddEdge(1, 2, 2);   // от 1 к 2, пропускная способность 2             ff.AddEdge(1, 3, 4);   // от 1 к 3, пропускная способность 4             ff.AddEdge(2, 1, 6);   // от 2 к 1, пропускная способность 6             ff.AddEdge(2, 3, 10);  // от 2 к 3, пропускная способность 10              // Ищем максимальный поток от вершины 0 к вершине 3             int maxFlow = ff.MaxFlow(0, 3);                          Console.WriteLine($"Максимальный поток = {maxFlow}");                          // Выводим, как распределился поток по рёбрам             ff.PrintFlows();         }     } } 

6. Сравнение с алгоритмом Диница

Вы можете спросить: «А зачем изучать Форда–Фалкерсона, если есть более быстрый алгоритм Диница?» Отличный вопрос!

Форд–Фалкерсон — это основа: Он помогает понять суть задачи максимального потока. Это как изучение арифметики перед алгеброй.

Простота реализации: Код Форда–Фалкерсона проще понять и отладить. Для многих практических задач его скорости вполне достаточно.

Гибкость: Легко модифицировать под специфические требования. Хотите искать пути по BFS вместо DFS? Просто замените одну функцию.

Педагогическая ценность: Алгоритм Диница использует те же принципы остаточного графа, но с оптимизациями. Понимая Форда–Фалкерсона, вы легче поймёте и более сложные алгоритмы.

7. Заключение

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

Представьте, что у вас есть сложная логистическая задача: как оптимально распределить товары со складов по магазинам, учитывая пропускную способность дорог? Или как эффективно распределить вычислительную нагрузку между серверами? Алгоритм Форда–Фалкерсона поможет вам найти ответ.

Главное — не пугаться сложных названий и формул. За каждым алгоритмом стоит простая человеческая логика: «найди путь, протолкни что можешь, повтори». Именно такой подход помогает превратить сложную задачу в последовательность простых шагов.

Удачи в изучении алгоритмов! И помните: лучший способ понять алгоритм — это взять карандаш, нарисовать граф и прогнать алгоритм вручную. Компьютер подождёт, а понимание останется с вами навсегда.


ссылка на оригинал статьи https://habr.com/ru/articles/927400/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *