最大流问题:增广路与 Edmonds-Karp 算法

最大流问题是其中一个经典的图论问题,其目标是在一个流网络中计算从源点到汇点的最大流量。流网络由节点和边组成,每条边都有一个容量,表示该边所能承载的最大流量。

最大流问题

通常来说,最大流问题仅在有向图上考虑,允许成环,且不考虑重边和自环。在数学上,流网络可以表示为一个有向图 $ G = (V, E) $,其中:

  • $ V $ 是图的节点集合,包含源点 $ s $ 和汇点 $ t $,以及若干中间节点。
  • $ E $ 是图的边集合,每条边 $ (u, v) \in E $ 具有一个非负容量 $ c(u, v) $,表示单位时间内通过该边的最大流量。

最大流问题的目标是找到从源点 $ s $ 到汇点 $ t $ 的最大流量 $ f $。具体地,我们需要满足以下条件:

1. 流量守恒条件:

对于图中的每个中间节点 $ v $(即 $ v \neq s $ 且 $ v \neq t $),流量必须满足流量守恒:流入节点 $ v $ 的流量等于流出节点 $ v $ 的流量。数学表达式为:

\[\sum_{(u, v) \in E} f(u, v) = \sum_{(v, w) \in E} f(v, w) \]

这意味着每个中间节点的流量是平衡的,即不存留流量。

2. 源点流出:

源点 $ s $ 的流量没有任何限制,可以向网络中流出任意数量的流量,因此流出源点的流量等于最大流:

\[\sum_{(s, v) \in E} f(s, v) = f_{\text{max}} \]

其中 $ f_{\text{max}} $ 为从源点 $ s $ 到汇点 $ t $ 的最大流量。

3. 汇点流入:

汇点 $ t $ 的流量也没有限制,但流入汇点的流量最终应也等于最大流。即我们要的答案,越大越好:

\[\sum_{(u, t) \in E} f(u, t) = f_{\text{max}} \]

4. 容量限制:

流量必须受到容量的限制,即每条边的流量不能超过其容量:

\[0 \leq f(u, v) \leq c(u, v) \quad \text{for all} \ (u, v) \in E \]

流量的大小 $ f(u, v) $ 需要满足每条边的容量约束。

最大流问题可以通过以下最优化问题来表达:

\[\max_{f} \sum_{(s, v) \in E} f(s, v) \quad \text{subject to:} \]

\[\sum_{(u, v) \in E} f(u, v) = \sum_{(v, w) \in E} f(v, w) \quad \text{for all} \ v \in V \setminus \{s, t\} \]

\[0 \leq f(u, v) \leq c(u, v) \quad \text{for all} \ (u, v) \in E \]

这个优化问题的目标是最大化从源点 $ s $ 流向汇点 $ t $ 的流量,同时确保流量守恒和容量限制。

假定下图的有向边是可以流动的最大人数,那么有多少人最终可以从武汉到上海呢?这道题目的答案是$ 8$。

最大流与最小割

要了解最大流的求解问题,一个经典的理论基础就是最大流最小割定理

一个流网络的是将图中的所有节点分成两个不相交的集合,源点属于一个集合,汇点属于另一个集合。割的容量是指从源点集合到汇点集合的所有边的容量总和。

一方面,最大流 $ f_{max} \leq $ 最小割容量。设有一个割 $ (S, T) $,其中 $ S $ 包含源点 $ s $, $ T $ 包含汇点 $ t $。割的容量是所有从 $ S $ 到 $ T $ 的边的容量之和。由于网络中的流量只能沿着边传递,所以流量也只能经过这些割边。 因此,任何从源到汇的流量都不能超过这个割的容量。换句话说,流量的总和最多等于最小割的容量,

另一方面,最小割的容量 $ \text{割的容量} \geq f_{max} $。在最大流 $ f_{max} $ 达到时,流网络的每条边都已经充分利用,流量从源点 $ s $ 出发,经过不同的路径最终到达汇点 $ t $。如果我们构造一个"残量网络",其中只考虑当前流量下剩余容量大于零的边,并通过分割将网络分为两个部分 $ S $ 和 $ T $(其中 $ S $ 包含源点,$ T $ 包含汇点),割的容量实际上就是从 $ S $ 到 $ T $ 的所有边的容量之和。在最大流达到时,这个割的容量等于最大流的流量,因为流量的传输和割的"限制"是相对应的。最小割容量无法小于最大流量,否则流量就会超过割的容量,从而导致不可能传输这些流量。

最大流最小割定理是最大流问题的一个重要结果,它揭示了流量与网络中"割"的关系。具体来说,这一定理告诉我们,在任何流网络中,最大流等于最小割的容量。换句话说,最大流问题的解可以通过最小割的大小来描述。定理可以使用数学知识证明。

这意味着最大流的大小等于将源点与汇点分开的"最小割"的容量。这个定理为求解最大流问题提供了理论依据,也指导了我们在实际问题中如何去寻找最优解。在最大流问题中,我们试图通过增广路径逐步提升流量,直到无法再找到增广路径为止。而增广路径的存在性与最小割的结构息息相关。当最大流形成时,所有源点向汇点的割边上都充满流量,加起来正等于最大流值(也是最小割值)。

上图的最小割也是 \(8\)。直接割掉源点伸出的两条边即可。

增广路与残差网络

在求解最大流问题时,增广路径是一个核心概念。增广路径是从源点到汇点的一条路径,其中每条边的剩余容量(即边的原始容量减去当前流量)大于零。

既然如此,我们只要找到一条源点到汇点间尚有空余流量的通路(即增广路),就可以增加最大流。通过反复找寻增广路径增大流量,直到找不到增广路,我们可以逐步提升网络中的流量到最大值。

为什么不断寻找增广路径直到不存在来求解最大流是正确的?

这里的核心原因在于最大流最小割定理 。该定理表明,最大流等于最小割的容量,也就是说,在流网络中的最大流量最终将受到最小割的限制。这个定理告诉我们,最大流的计算与最小割之间有着紧密的关系,而增广路径法正是基于这一理论来逐步寻找最大流。数学上可以证明,增广的正确性和最大流最小割定理等价。

当我们通过增广路径逐步增加流量时,实际上是在不断减少源点到汇点之间的可用容量。每次增广路径的更新都会改变网络中可用的流量,并可能"改变"最小割的结构。随着增广路径的不断增多,最终流量将无法再增加,即无法再找到增广路径时,算法将到达最大流,这时剩余的容量正好等于最小割的容量。这种通过不断寻找增广路径求解最大流的办法,叫做 Ford--Fulkerson 算法

增广路径与残差网络

为了有效地实现增广路径的寻找,我们需要借助残差网络的概念。残差网络是原图的一个变种,它反映了当前流量分配后,网络中仍然可以承载的流量。残差网络的构造方法如下:

  • 对于每条原图中的边 $ (u, v) $,如果 $ f(u, v) $ 是从 $ u $ 到 $ v $ 当前流量,则残差容量为 $ c(u, v) - f(u, v) $。
  • 同时,对于每条边 $ (u, v) $,还会引入一个反向边 $ (v, u) $,其残差容量为 $ f(u, v) $,即反向流量。

残差网络的特点之一是,它不仅包含了正向边的剩余容量,还包含了反向边的流量容量。正向边表示本增广路已被使用后的剩余流量,反向边表示已找到的增广路在未来可能被反悔撤回。这是增广路径法正确性的关键,无论用怎样的顺序寻找增广路,最终都可以得到正确结果。

残差网络是不断寻找增广路的中间产物,当我们在残差网络中寻找增广路径时,实际上是在找从源点到汇点的一条路径,使得沿途的每一条边都具有正的残差容量。每找到一条增广路径后,我们就沿着这条路径增加流量,同时更新残差网络中的正向边和反向边的容量,又得到一个残差网络。当增广路径不再存在时,意味着网络中没有可行的路径来进一步增加流量,此时最大流已经达到(最小割也被达到)。

下面的图最大流是 \(20000\)。即使一开始不幸选中 \(1 → 2 → 3 → 4\),也可以由反向边反悔,再次选择 \(1 → 3 → 2 → 4\) 抵消,反复迭代后最终仍然可得正确结果。

Edmonds-Karp 算法

在求解最大流问题时,我们已经知道要不断寻找增广路径。那么用什么策略选择增广路径 就是一个问题,选择直接影响到算法的效率和复杂度。我们可以采用多种策略来寻找增广路径,每种策略在不同场景下可能表现出不同的效率。然而,EK算法 (Edmonds-Karp算法)选择了**广度优先搜索(BFS)**来寻找最短的增广路径,这一策略已成为最流行的方式。

EK 算法的核心思想是通过BFS寻找最短增广路径,并不断更新流量和残差网络,直到没有增广路径为止。以下是具体实现步骤:

  1. 初始化

    • 设置流量 $ f(u, v) = 0 $ 对所有边初始化流量。
    • 构建残差图 $ G_f $,每条边的残差容量为其原始容量 $ c(u, v) $。
  2. 寻找增广路径

    • 使用BFS在残差图中寻找一条从源点 $ s $ 到汇点 $ t $ 的增广路径。这条路径由若干边组成,其中每条边的残差容量都大于零。
  3. 更新流量与残差图

    • 找到增广路径后,计算该路径上的最小残差容量 $ \Delta $。
    • 沿着增广路径增加流量 $ \Delta $,并更新残差网络:
      • 对于每条正向边 $ (u, v) $,减少其残差容量 $ c(u, v) $ 并增加反向边 $ (v, u) $ 的流量。
      • 对于每条反向边 $ (v, u) $,增加其残差容量。
  4. 重复步骤 2 和 3

    • 重复以上过程,直到找不到增广路径为止。此时,当前的流量即为最大流。
  5. 输出结果

    • 最终返回从源点到汇点的最大流量,即为最大流问题的解。
cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <climits>

using namespace std;

// 定义一个边的结构体
struct Edge {
    int v, capacity, flow;
    Edge(int v, int capacity) : v(v), capacity(capacity), flow(0) {}
};

// 图的邻接链表表示
class Graph {
public:
    int V;  // 图的顶点数
    vector<vector<Edge>> adj;  // 邻接链表表示的图

    Graph(int V) {
        this->V = V;
        adj.resize(V);
    }

    // 添加一条边
    void addEdge(int u, int v, int capacity) {
        adj[u].emplace_back(v, capacity);  // 正向边
        adj[v].emplace_back(u, 0);  // 反向边,初始容量为0
    }

    // 使用BFS查找增广路径
    bool BFS(int s, int t, vector<int>& parent) {
        // 记录每个节点的访问状态
        vector<bool> visited(V, false);
        queue<int> q;
        q.push(s);
        visited[s] = true;

        while (!q.empty()) {
            int u = q.front();
            q.pop();

            for (auto& edge : adj[u]) {
                int v = edge.v;
                if (!visited[v] && edge.capacity > edge.flow) {  // 只考虑容量大于流量的边
                    visited[v] = true;
                    parent[v] = u;  // 记录路径
                    if (v == t) return true;  // 找到增广路径
                    q.push(v);
                }
            }
        }
        return false;
    }

    // 寻找增广路径并更新流量
    int edmondsKarp(int s, int t) {
        int maxFlow = 0;
        vector<int> parent(V);  // 记录增广路径的父节点

        // 不断找到增广路径并更新流量
        while (BFS(s, t, parent)) {
            // 找到一条增广路径
            int pathFlow = INT_MAX;
            for (int v = t; v != s; v = parent[v]) {
                int u = parent[v];
                // 找到该边的剩余容量
                for (auto& edge : adj[u]) {
                    if (edge.v == v) {
                        pathFlow = min(pathFlow, edge.capacity - edge.flow);
                        break;
                    }
                }
            }

            // 更新流量
            for (int v = t; v != s; v = parent[v]) {
                int u = parent[v];
                for (auto& edge : adj[u]) {
                    if (edge.v == v) {
                        edge.flow += pathFlow;  // 更新正向边流量
                        break;
                    }
                }
                // 更新反向边流量
                for (auto& edge : adj[v]) {
                    if (edge.v == u) {
                        edge.flow -= pathFlow;  // 反向流量
                        break;
                    }
                }
            }

            maxFlow += pathFlow;  // 增加流量
        }

        return maxFlow;
    }
};

int main() {
    // 输入节点数、边数
    int V, E;
    cin >> V >> E;

    Graph g(V);

    // 输入每条边的信息:u, v, capacity
    for (int i = 0; i < E; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        g.addEdge(u, v, w);  // 添加边
    }

    int s, t;
    cin >> s >> t;  // 输入源点s和汇点t

    // 计算最大流
    int maxFlow = g.edmondsKarp(s, t);

    cout << "最大流量为: " << maxFlow << endl;

    return 0;
}

时间复杂度分析

每次BFS的时间复杂度是 $ O(E) $,因为BFS需要遍历图中的所有边。每次BFS都用于找到一条增广路径,并对残差图进行更新,包含对流量的调整以及反向边的更新。

增广的轮数上界是我们分析时间复杂度时的关键。对于每一轮增广,至少有一条边的流量被增大了。增广路径的轮数受限于图中的残差容量 的变化。具体来说,增广路径最多会进行 $ O(V \cdot E) $ 轮,严谨的证明较复杂,大致理解来说,每次增广至少会减少一条边的剩余容量。每次增广的过程中,某条路径上的边都会调整其流量,而每次增广路径的选择都会导致残差网络中某些边的容量减少。在最坏情况下,增广路径的数目不会超过图中边数和节点数的乘积。具体地,这个上界为 $ O(V \cdot E) $,因为每次增广最多减少一个单位的流量,而图中最多有 $ E $ 条边,每条边可能会参与多轮增广(在某些情况下,可能经过多次流量增大才会被"消耗"完)。每个节点最多会经历 $ O(E) $ 轮增广。

因此,增广路径的总轮数的上界是 $ O(V \cdot E) $。这意味着尽管每轮BFS的时间复杂度为 $ O(E) $,但在最坏情况下,总的增广轮数是 $ O(V \cdot E) $,因此EK算法的总时间复杂度为:

\[O(E \cdot V \cdot E) = O(V \cdot E^2) \]

这是一个保守宽松的上界。

为什么 EK 算法用 BFS 寻找最短增广路径?

EK 算法是 Ford--Fulkerson 算法最经典的实现之一。为什么要总是选择最短增广路径?为什么不是其他策略呢(比如每次寻找具有最大残余容量的增广路)?

使用BFS时,每次增广路径的寻找都会保证找到图中最短的增广路径(即路径中的边数最少)。这意味着每次增广都会尽量提高流量,避免不必要的流量"浪费",而相对于DFS可能导致的"长路径",BFS能够较为均匀地分配流量,提高了每次增广的效率。。由于每次增广的路径长度都相对较短,BFS在每轮中都能有效地逼近最大流。这使得整体时间复杂度不会随流量的大小而剧增。其复杂度仅和图规模有关,很稳定,而最大残余容量的复杂度和流量本身大小相关。