网络流介绍
这里主要介绍最大流问题,如下图:
对于一个流网络,存在一个唯一的源点(没有入边,即图中的0顶点),存在一个唯一的汇点(没有出边,即图中的顶点3),同时每条路线都有容量限制,从顶点0最多只能运算大小为3的容量给顶点1,这个叫做容量约束。同时1也得遵循流量守恒,也就是说如果顶点1得到了3这个容量,那么顶点1必须把3的流量全部运算出去,不能自己累积起来。
有了前面的概念,最大流问题就是问从源点到汇点能输送的最大可行流量,且全程满足容量约束和流量守恒。
实现
算法由豆包生成,此文件为c++文件。
cpp
#include <stdio.h>
#include <string.h>
#include <limits.h>
#include <queue>
using namespace std;
#define MAX_NODE 100 // 最大节点数,可根据需求调整
#define INF INT_MAX // 无穷大
// 容量矩阵:graph[u][v] 表示 u->v 的剩余容量
int graph[MAX_NODE][MAX_NODE];
// 父节点数组:记录 BFS 找到的增广路径
int parent[MAX_NODE];
int node_num; // 实际节点数
int source; // 源点
int sink; // 汇点
// BFS 寻找增广路径,返回路径的最小残余容量
int bfs() {
memset(parent, -1, sizeof(parent)); // 初始化父节点为-1
queue<int> q;
parent[source] = source;
q.push(source);
int min_flow = INF;
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v = 0; v < node_num; v++) {
// 剩余容量>0 且 未被访问
if (parent[v] == -1 && graph[u][v] > 0) {
parent[v] = u;
min_flow = min(min_flow, graph[u][v]);
if (v == sink) {
return min_flow; // 找到汇点,直接返回最小流量
}
q.push(v);
}
}
}
return 0; // 无增广路径,返回0
}
// 计算最大流
int edmonds_karp() {
int max_flow = 0;
int flow;
while ((flow = bfs()) > 0) { // 不断找增广路径
max_flow += flow;
// 更新残余网络(正向边减流量,反向边加流量)
int v = sink;
while (v != source) {
int u = parent[v];
graph[u][v] -= flow;
graph[v][u] += flow;
v = u;//注意!
}
}
return max_flow;
}
// 测试示例
int main() {
// 初始化参数
node_num = 4;
source = 0;
sink = 3;
memset(graph, 0, sizeof(graph));
// 添加边:u, v, capacity
graph[0][1] = 3;
graph[0][2] = 2;
graph[1][2] = 1;
graph[1][3] = 2;
graph[2][3] = 3;
int max_flow = edmonds_karp();
printf("最大流 = %d\n", max_flow); // 输出:5
return 0;
}
首先,这里通过一个二维数组来存储图,二维数组中的值即可以容量,比如[0][1]=3就表示顶点0有条线指向顶点1,且顶点1的容量为3,或者说这条线的容量为3.二维数组中没有赋值的位置默认为0,恰好可以表示此线路不能通流。
算法的核心是,通过不断的找图中的增广路径(从源点到汇点的所有不同路径),每条增广路径都有一个运量最低的线,把这个最低运量作为整条增广矩阵的运量,然后相加所有增广矩阵的运量,得到的就是图的最大流。
还是以这个图为例:

首先通过BFS函数找到了013这条路线,发现这条路线最多能运的容量为1,那就让max_flow累加1,接着再更新这个网,正向更新结果为0到1容量为1,1到3容量为0,这里的目的就相当于抹除了013这条路了,因为1到3的容量为0,这条路走不通了 ,同时把0到1的容量降低为1是说明在通过01的这条路线上其中已经把容量中的1分配给13路线上了,所有之后的有01片段的增广路线就不需要再给顶点1两份容量了。接着再反向更新,让3到1的容量为1,1到0的容量为1,这种退流机制就是为了尽可能不放弃找到流量最大的路线的机会,后面就会体验到这种机制的奇妙了。
接着又找到了0123这条路线,其由于0到1的容量为1,所以整条路线最多只能运输容量1份,那就让max_flow累加1,接着所以正向路线容量-1,所有反向路线容量+1.这样就变成了0到1为0,1到2为1,2到3为1.同时3到2为1,2到1为1,1到0为1.
然后再遍历023这一条增广路线,这里发现0到2容量为2,但是2到3容量为1了,没有关系那就让max_flow累加1.接着02为1,23为0,32为2,20为1.
现在的图就是这样了:
由于退流的影响,现在进行第四轮寻找,寻找路径是02123,红色是正向的黑色是负向的,0先到2,虽然到0有容量为1的路径,但由于0是源点,所以不会访问0,然后2会到1去,此时重要的是1可以回到2,因为1到2有容量1,当1回到2之后,那2就有权正反方向去遍历其它路径了,这样就可以访问到3了,这样再让max_flow累加上1.
以上内容作废!我跟着豆包思路去理解反向边和参量网络,发现豆包提供的思路和例子从一开始就是错的。我在这不删除以上错误的思路以警示不能盲目相信AI。
网络流的思路解释
首先基本是思想是对的,即我们需要找出网络中所有的增广路径,然后将这些增广路径的最小容量相加得到整个网络的最大流。但是算法中还是需要加入残量网络和反向边的机制。因为增广路径相当于局部最优解,而我们想要的网络最大流是全局最优解,这些机制的存在就是为了解决找出真正的全局最优解的问题。
举个例子就懂了,以这个通俗易懂的图为例:
在这个网络中,假设每个顶点的容量都是1,那么我们可以很确定地说这个网络的最大流是2,但如果在算法中进行BFS遍历找增广路径时第一次就恰好找到abef这条路径时,那问题很大了,当用完这条路径后,就相当于顶点b和顶点e的容量就变成0了,就找不到其他的增广路径了。然后算法就说最大流为1.
但如果引入了残量网络和反向边的机制的话,那就不一样了,当第一次遍历到abef时,机制会做这样的处理,结果如下:

这里的反向边可以看成是一种后悔药,这样的话我们就可以找到adebcf这条新路线了,可以这样解释,第二次找到的这种路线其实是向顶点b借了容量1然后找到了新路径到f,也就是说其实一开始的路径根本可以不用把b的容量借给第二条路径而是走第二条路径b之后的路径来避免之后第二条路径向第一天路径借容量,这样总的路径就多了。
而这里就不得不提一下最大流的核心了,我们要从起点把尽可能多的容量运输到终点,现在起点和终点之间有一张网,如果要实现最大流的话,那就需要尽可能地把流量分散到不同的路径上,也就是说增广路径越多,运输的容量就越大,反向边的存在就相当于找出了那本来可以作为可行路径的方法。
最小生成树介绍

最小生成树一般是针对于无向图的一个操作,现在左边有一个有权图,最小生成树就是在这个有权图中选择一些边来连通所有的点,同时满足边权和最小。
以下程序依然由豆包生成。
Prim算法实现
cpp
#include <stdio.h>
#include <limits.h>
// 图中顶点的最大数量
#define MAX_V 100
// 表示顶点之间无边相连(权值无穷大)
#define INF INT_MAX
// Prim算法核心函数
// graph: 邻接矩阵, n: 顶点数量, start: 起始顶点(从0开始)
void prim(int graph[MAX_V][MAX_V], int n, int start) {
// 保存最小生成树的父节点(用于回溯路径)
int parent[MAX_V];
// 每个顶点到最小生成树的最小权值
int key[MAX_V];
// 标记顶点是否已加入最小生成树
int in_mst[MAX_V];
// 初始化
for (int i = 0; i < n; i++) {
key[i] = INF; // 初始权值为无穷大
in_mst[i] = 0; // 所有顶点未加入MST
parent[i] = -1; // 父节点初始化为-1
}
// 起始顶点的权值设为0,第一个加入MST
key[start] = 0;
parent[start] = -1;
// 最小生成树需要n-1条边
for (int count = 0; count < n - 1; count++) {
// 步骤1: 找到未加入MST且key值最小的顶点u
int min = INF, u = -1;
for (int v = 0; v < n; v++) {
if (!in_mst[v] && key[v] < min) {
min = key[v];
u = v;
}
}
// 步骤2: 将u加入最小生成树
in_mst[u] = 1;
// 步骤3: 更新与u相邻顶点的key值和父节点
for (int v = 0; v < n; v++) {
// 条件: 1. v未加入MST 2. u和v之间有边 3. 边权值小于当前key[v]
if (!in_mst[v] && graph[u][v] != INF && graph[u][v] < key[v]) {
parent[v] = u;
key[v] = graph[u][v];
}
}
}
// 输出最小生成树的边和权值
printf("最小生成树的边 (父节点 -> 子节点) 权值\n");
int total_weight = 0;
for (int i = 0; i < n; i++) {
if (i != start) { // 跳过起始顶点(无父节点)
printf("%d -> %d \t\t %d\n", parent[i], i, graph[parent[i]][i]);
total_weight += graph[parent[i]][i];
}
}
printf("最小生成树的总权值: %d\n", total_weight);
}
int main() {
// 测试用例:无向带权连通图,4个顶点
int n = 4;
int graph[MAX_V][MAX_V] = {
{INF, 2, 2, INF},
{2, INF, 2, 1},
{2, 2, INF, 2},
{INF, 1, 2, INF}
};
// 从顶点0开始构建最小生成树
prim(graph, n, 0);
return 0;
}
Prim算法的思路是,先随便选一个顶点,再找与这个顶点相连的权最小的边所连的顶点,这样就有了两个顶点,然后看这两个顶点所被相连的边,再找出其中权最小的边,这样就有了3个顶点,然后重复上面的过程,直到所有顶点都被连通。当然这里还有一个约束,那就是我们选出的边,不能是连通我们已经连通过的某两个顶点的边。由于prim算法是一个一个找点,边的多少对算法的效率无影响,所以Prim算法适用于点少的图。
Kruskal算法实现
cpp
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
// 顶点数量最大值
#define MAX_VERTEX 100
// 边数量最大值
#define MAX_EDGE 1000
// 边的结构体:起点u、终点v、权值weight
typedef struct {
int u;
int v;
int weight;
} Edge;
// 并查集数组
int parent[MAX_VERTEX];
// 最小堆(存储边)
Edge heap[MAX_EDGE];
// 堆的当前大小
int heap_size;
// ====================== 并查集操作 ======================
// 初始化并查集
void Initialize(int n) {
for (int i = 0; i < n; i++) {
parent[i] = i; // 每个节点的父节点初始化为自身
}
}
// 查找节点x的根(带路径压缩)
int Find(int x) {
if (parent[x] != x) {
parent[x] = Find(parent[x]); // 路径压缩
}
return parent[x];
}
// 合并两个集合
void SetUnion(int u, int v) {
int root_u = Find(u);
int root_v = Find(v);
if (root_u != root_v) {
parent[root_v] = root_u; // 合并根节点
}
}
// ====================== 最小堆操作 ======================
// 交换两条边
void swap(Edge *a, Edge *b) {
Edge temp = *a;
*a = *b;
*b = temp;
}
// 堆的下沉操作(维护最小堆性质)
void Heapify(int i) {
int left = 2 * i + 1;
int right = 2 * i + 2;
int smallest = i;
if (left < heap_size && heap[left].weight < heap[smallest].weight) {
smallest = left;
}
if (right < heap_size && heap[right].weight < heap[smallest].weight) {
smallest = right;
}
if (smallest != i) {
swap(&heap[i], &heap[smallest]);
Heapify(smallest);
}
}
// 构建最小堆
void BuildHeap(Edge edges[], int n) {
heap_size = n;
for (int i = 0; i < n; i++) {
heap[i] = edges[i];
}
// 从最后一个非叶子节点开始堆化
for (int i = heap_size / 2 - 1; i >= 0; i--) {
Heapify(i);
}
}
// 取出堆顶的最小边
Edge DeleteMin() {
if (heap_size <= 0) {
Edge empty = {-1, -1, -1};
return empty;
}
Edge min_edge = heap[0];
heap[0] = heap[heap_size - 1];
heap_size--;
Heapify(0);
return min_edge;
}
// ====================== Kruskal算法核心 ======================
void Kruskal(Edge edges[], int edge_num, int vertex_num) {
int EdgesAccepted = 0; // 已选入MST的边数
Initialize(vertex_num); // 初始化并查集
BuildHeap(edges, edge_num); // 构建最小堆
printf("最小生成树的边 (u -> v) 权值\n");
int total_weight = 0;
// 选够n-1条边为止
while (EdgesAccepted < vertex_num - 1) {
Edge E = DeleteMin(); // 取出权值最小的边
if (E.weight == -1) break; // 无剩余边,退出
int Uset = Find(E.u);
int Vset = Find(E.v);
// 若不在同一集合,加入MST
if (Uset != Vset) {
printf("%d -> %d \t\t %d\n", E.u, E.v, E.weight);
total_weight += E.weight;
EdgesAccepted++;
SetUnion(E.u, E.v); // 合并集合
}
}
if (EdgesAccepted == vertex_num - 1) {
printf("最小生成树总权值: %d\n", total_weight);
} else {
printf("图不连通,无法生成最小生成树\n");
}
}
// ====================== 测试主函数 ======================
int main() {
// 测试用例:4个顶点,5条边的无向图
int vertex_num = 4;
int edge_num = 5;
Edge edges[] = {
{0, 1, 2},
{0, 2, 2},
{1, 2, 2},
{1, 3, 1},
{2, 3, 2}
};
Kruskal(edges, edge_num, vertex_num);
return 0;
}
Kruskal算法恰好和Prim算法反着来,Kruskal算法是先找到图中最小的边,这样第一次就找到了两个点和一条边,然后再找整个图中第二小的边,那就可能获4个点和两条边,或者3个点两条边,这样就是以边数的增长而进行最小生成树的实现。当然当寻找的边连接的是已连通顶点的话,那那条边就不能添加。直到所有点都被连通,算法就结束了。所以Kruskal算法适合边少的图,因为顶点的多少不影响其速率。