网络流问题与最小生成树

网络流介绍

这里主要介绍最大流问题,如下图:

对于一个流网络,存在一个唯一的源点(没有入边,即图中的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算法适合边少的图,因为顶点的多少不影响其速率。

相关推荐
不忘不弃5 小时前
把IP地址转换为字符串
数据结构·tcp/ip·算法
Cathy Bryant5 小时前
拉格朗日量:简单系统
笔记·算法·数学建模·高等数学·物理
leoufung5 小时前
LeetCode 63:Unique Paths II - 带障碍网格路径问题的完整解析与面试技巧
算法·leetcode·面试
颜子鱼5 小时前
Linux字符设备驱动
linux·c语言·驱动开发
还不秃顶的计科生5 小时前
力扣hot100第三题:最长连续序列python
python·算法·leetcode
linuxxx1105 小时前
request.build_absolute_uri()关于使用IP+端口
网络·python·网络协议·tcp/ip·django
应用市场5 小时前
智能电饭锅多模式控制系统——从温度曲线到状态机完整实现
网络
wen__xvn5 小时前
代码随想录算法训练营DAY3第一章 数组part02
java·数据结构·算法