常用代码模板3——搜索与图论

常用代码模板3——搜索与图论

DFS

dfs没有模板,关键是搜索的顺序和剪枝

  • 关键:搜索顺序
  • 回溯、剪枝、搜索树
  • 剪枝:最优性剪枝(当前路径一定不如最优解)、可行性剪枝
  • 每次只存储一条搜索路径
  • 处理的问题:对空间要求比较高,算法思路比较奇怪
…… dfs(int u, ……) // 到树的第几层和其他参数
{
    // 剪枝
    
    // 搜到叶节点后的逻辑
    if(check(u))
    {
       
    }
    
    // 搜索该节点的所有子节点
    for (int i = 1; i <= n; i ++ ) 
    {
        if(check(i)) // 如果这个子节点可以走
        {
            store(u, i); // 存储路径
            dfs(u+1); // 移动到下一层,搜索它的所有子节点
            recover(i);  // 该子节点下的所有路径搜完了,回溯并恢复现场
        }
        // 搜索下一个子节点
    }
}

BFS

bfs图解

  • 搜索顺序:搜索所有到起点距离为1的点 - > 搜索所有到起点距离为2的点 - > 搜索所有到起点距离为3的点 - > ……(距离为n的点是由距离为n-1的点走到的,而且之前没有走过)

  • 当图中所有边的权重都是1时,bfs第一次搜到的一定是最短路,因为bfs是按到起点的距离向外搜索的,所以每个点第一次被搜到时一定是最短距离

  • 存储路径的话定义一个pre数组,对于每个点,记录它是从哪个点走过来的

  • 处理的问题:最小步数、最短路、最少操作次数

…… bfs()
{
    // 初始状态可以把所有点的距离置为-1, 表示没走过
    // memset(d, -1, sizeof(d))
    // 然后将起点入队并更新它的距离为0
    queue <- 初始状态
	while (queue不空)
	{
    	t <- 队头出队
    
    	t的所有邻点x入队(到t距离是1且没走过的点),d[x]=d[t]+1
	}
}

树与图的存储

树是一种特殊的图(无环连通图),与图的存储方式相同。
对于无向图中的边ab,存储两条有向边a->b, b->a。
因此我们可以只考虑有向图的存储。

(1) 邻接矩阵:储存稠密图,g[a][b] 存储边a->b,a->b如果有多条边只能存一条

(2) 邻接表:储存稀疏图

// 对于每个点k,开一个单链表,存储k所有可以走到的点。h[k]存储这个单链表的头结点
// N不小于点的总数,M不小于边的总数
int h[N], e[M], ne[M], idx;

// 添加一条边a->b
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

// 初始化
idx = 0;
memset(h, -1, sizeof h);

树与图的遍历

时间复杂度 O(n+m), n表示点数,m表示边数

1. 深度优先遍历

​ 对于树,dfs可以维护子树信息

int dfs(int u) // u是当前搜到的点
{
    st[u] = true; // st[u] 表示点u已经被遍历过

    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j]) dfs(j);
    }
}

树的遍历可以不用st数组,而是每次都传入当前点的父节点,只要不回头遍历父节点,就可以保证不重复遍历了

void dfs(int u, int father)
{
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j != father) dfs(j);
    }
}

dfs(root, 0); // 0这个下标不存值,而是从idx=1开始存

2. 宽度优先遍历

queue<int> q;
st[1] = true; // 表示1号点已经被遍历过
q.push(1);

while (q.size())
{
    int t = q.front();
    q.pop();

    for (int i = h[t]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j])
        {
            st[j] = true; // 表示点j已经被遍历过
            q.push(j);
        }
    }
}

拓扑排序

时间复杂度 O(n+m), n 表示点数,m 表示边数

// 递推的思想:如果要求一个图的拓扑序列,可以先找到一个入度为0的点并摘掉它,求剩余图的拓扑序列,再将该点放在开头即可得到原图的拓扑序列。最小问题(一个点就是它自身的拓扑序列)有解,
// 思路:先让所有入度为0的点入队,取队头并出队,遍历所有邻点,将由该点出发的边都删除,就相当于将这个点摘掉。然后在删的过程中,有邻点的入度减为零,则该邻点可以作为新图的起点,要入队。如果所有的点都入队了,说明每个新图都是有入度为0的点作为起点的(即有解),那么原问题一定有解,并且由于每个新图的起点都从队尾入队,则拓扑序列就存在队列中。
bool topsort()
{
    int hh = 0, tt = -1;

    // d[i] 存储点i的入度
    for (int i = 1; i <= n; i ++ )
        if (!d[i])
            q[ ++ tt] = i;

    while (hh <= tt)
    {
        int t = q[hh ++ ];

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (-- d[j] == 0)
                q[ ++ tt] = j;
        }
    }

    // 如果所有点都入队了,说明存在拓扑序列;否则不存在拓扑序列。
    return tt == n - 1;
}

最短路算法

最短路问题

  1. 概念

    • 源点:起点
    • 汇点:终点
    • n:点数
    • m:边数
  2. 分类

    • 单源最短路:从一个固定点到其他所有点的最短距离(如从1->n)

      • 所有边权都是正数:
        1. 朴素dijkstra算法:复杂度O(n2),与边数无关,适用于稠密图(点少边多),例如边数是n2级别的。
        2. 堆优化版dijkstra算法:复杂度O(mlogn),适用于稀疏图(点和边一个级别或点多边少),例如点数和边数差不多时。
      • 存在负权边:
        1. bellman-ford算法:复杂度O(nm),一般用于求不超过k条边的最短路
        2. spfa算法:一般O(m),最坏O(nm),如果对最短路经过的边数限制,只能用bellman-ford算法
    • 多源汇最短路:求任意两个点之间的最短距离(起点不止一个)

      floyd算法:复杂度O(n3)

  3. 关键:建图(将原问题抽象成最短路),如何定义点和边,不侧重于证明算法的正确性


dijkstra算法(无负权边)

1. 朴素dijkstra算法(稠密图)

时间复杂是 O(n2), n 表示点数

步骤:(这里的距离指的都是到出发点的距离)

  1. 起点的距离置为0,其他点的距离置为

  2. 每次从未标记的节点中选择距离最小的节点赋给 t ,并把这个点标记,收录到最短路径集合中(基于贪心算法,没有确定最短路的、距离出发点最近的点此时的距离就是它的最短距离)

  3. 用 t 更新邻点的距离,若(节点 t 的距离 + 节点 t 到该节点的边长)<该节点的距离,就更新该节点的距离

    由于 :

     1. 已经确定最短路的点的距离一定最小
     2. 与 t 不连通的点一定与起点不连通,$g[t][j]=dist[j]=\infty<dist[t]+g[t][j]=dist[t]+\infty$
    

    所以直接遍历所有点即可

每一次循环确定一个点的最短距离,循环 n - 1 次是因为最后一个点只更新了距离并且已经确定是最短距离,所以不需要再考虑加入st[n]的操作

重边保留长度最短的那条,自环不影响(在更新距离时,如果遍历到自己,dist[t] < dist[t] + g[t][t],所以dist[t]不变)

int g[N][N];  // 存储每条边
int dist[N];  // 存储1号点到每个点的最短距离
bool st[N];   // 存储每个点的最短路是否已经确定

memset(g, 0x3f, sizeof(g)); // 每条边初始化为正无穷

// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    for (int i = 0; i < n - 1; i ++ )
    {
        int t = -1;     // 在还未确定最短路的点中,寻找距离最小的点
        for (int j = 1; j <= n; j ++ )
        {
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;
        }
		
        st[t] = true; // 把这个点标记,收录到最短路径集合中
        
        // 用t更新其他点的距离
        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], dist[t] + g[t][j]);
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

2. 堆优化版dijkstra (稀疏图)

时间复杂度 O(mlogn), n 表示点数,m 表示边数

步骤:(这里的距离指的都是到出发点的距离)

  1. 起点的距离置为0,其他点的距离置为
  2. 用小根堆来维护距离最小的点,由于一个点会进堆入度次,则已经确定最短路的可能在堆顶,每次取堆顶后赋给 t 后要判断,直到取到未确定最短路的、距离最小的点,并把这个点标记,收录到最短路径集合中。
  3. 用 t 更新它的邻点的距离,若(节点 t 的距离 + 节点 t 到该节点的边长)<该节点的距离,就更新该节点的距离,并将该距离进堆。
typedef pair<int, int> PII;

int n;      // 点的数量
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边,w[]储存边长
int dist[N];        // 存储所有点到1号点的距离
bool st[N];     // 存储每个点的最短距离是否已确定

// 加边,带权
void add(int a, int b, int c)
{
	e[idx]=b, w[idx]=c, ne[idx]=h[a], h[a]=idx++;    
}

// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1});      // first存储距离,second存储节点编号,因为pair优先比较first

    // 堆空时所有与起点连通的点的最短路已经被算出来了
    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.second, distance = t.first;

        if (st[ver]) continue;
        st[ver] = true;

        for (int i = h[ver]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > distance + w[i])
            {
                dist[j] = distance + w[i];
                heap.push({dist[j], j});
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

Bellman-Ford算法(有负权边)

时间复杂度 O(nm), n 表示点数,m 表示边数

注意在模板题中需要对下面的模板稍作修改,加上备份数组,详情见模板题。

三角不等式:dist[b]dist[a]+w

松弛操作:dist[b]=dist[a]+w

循环k次后dist[]的意义:从起点经过不超过k条边到达该点的最短距离(如果发生串联则不是,所以需要一个backup[]数组备份上一次迭代的结果,松弛操作用backup[]更新)

如果图中存在负权回路,最短路不一定存在,若负环不在路径上,则不影响

步骤:

  1. 起点的距离置为0,其他点的距离置为
  2. 循环k次(k为允许经过的最大边数),每次对所有边都进行松弛操作dist[b]=dist[a]+w
  3. 循环结束之后,dist中存的就是最短路

注意:与起点不连通的点也可能被更新,所以要用dist[i] > inf / 2判断,并且不存在要返回inf,不是-1

int n, m;       // n表示点数,m表示边数
int dist[N];        // dist[x]存储1到x的最短路距离

struct Edge     // 边,a表示出点,b表示入点,w表示边的权重
{
    int a, b, w;
}edges[M];

// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    // 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在回路。由于经过n+1条边的dist比经过n条边的dist短,说明该回路一定是负权回路。
    
    // 循环n次表示经过n条边(不判断负环可改为n-1,因为只有n-1条边)
    for (int i = 0; i < n; i ++ )
    {
        // 对每条边都进行松弛操作
        for (int j = 0; j < m; j ++ )
        {
            auto t = edge[j];
            dist[t.b] = min(dist[t.b], dist[t.a] + t.w);
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return 0x3f3f3f3f; 
    return dist[n];
}

// if(bellman_ford()==0x3f3f3f3f) 不存在最短路
// else 存在 

spfa 算法(队列优化的Bellman-Ford算法)(无负环)

时间复杂度 平均情况下 O(m),最坏情况下 O(nm), n 表示点数,m 表示边数

因为松弛操作:dist[b]=dist[a]+wdist[b]要减小就要求dist[a]减小,所有可以用一个队列存储所有变小的的点(要判重),当队列空的时候,迭代也不会改变dist[]的值,退出。

步骤:

  1. 起点的距离置为0,其他点的距离置为
  2. 起点入队,打上标记
  3. 当队列不空时,取队头,去除标记,并用队头松弛所有邻边,并将成功松弛的点入队,打上标记
int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储每个点到1号点的最短距离
bool st[N];     // 存储每个点是否在队列中

// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q; 
    q.push(1);
    st[1] = true;

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                if (!st[j])     // 如果队列中已存在j,则不需要将j重复插入
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }
    return dist[n];
}
// if(spfa() == 0x3f3f3f3f) 不存在最短路
// else 存在 

spfa判断图中是否存在负环

时间复杂度是 O(nm), n 表示点数,m 表示边数

spfa判负环有两种做法,去掉st数组会更快

  1. bfs做法:(较慢)
int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N], cnt[N];        // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N];     // 存储每个点是否在队列中,可以不用

// 如果存在负环,则返回true,否则返回false。
bool spfa()
{
    // 不需要初始化dist数组,当dist初始值为0时,代码会在负环处打转(负环内元素一直入队出队,cnt[]单调递增直到>=n)
    // 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。

    queue<int> q;
    // 把所有点都放在初始点集中,防止有从1号点到不了的负环
    for (int i = 1; i <= n; i ++ )
    {
        q.push(i);
        st[i] = true;
    }

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                cnt[j] = cnt[t] + 1;
                if (cnt[j] >= n) return true;       // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环,且为负环(路更长距离反而更短)
                if (!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    return false;
}
  1. dfs做法:(较快,注意dist一定要初始化为0)

    只需将bfs中的队列换成栈即可。

floyd算法 (无负环)

时间复杂度是 O(n3), n 表示点数

重边取最短的,自环忽略

d[N][N]; // 邻接矩阵
初始化:
    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= n; j ++ )
            if (i == j) d[i][j] = 0;
            else d[i][j] = INF;

// 算法结束后,d[a][b]表示a到b的最短距离
void floyd()
{
    // 一定要先循环k
    for (int k = 1; k <= n; k ++ )
        for (int i = 1; i <= n; i ++ )
            for (int j = 1; j <= n; j ++ )
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
// if(d[a][b] > INF / 2) 不存在最短路
// else 存在 

最小生成树算法

一般是无向图,要同时加a->b,b->a两条边


朴素版prim算法(稠密图)

时间复杂度是O(n2),n 表示点数,m 表示边数

步骤:(距离:点到集合内点的所有连线中的最短长度)

  1. 将所有点的距离初始化为+
  2. 每次找到最小生成树集合外的距离最近的点赋给 t ,并将它标记,收录到最小生成树集合中
  3. 用 t 更新其他点的距离。如果该点到 t 的距离比当前该点到集合的距离短,就将该点的到集合的距离更新为到 t 的距离

每一次循环都将一个点加入最小生成树中,循环 n 次即可

重边保留长度最短的那条,自环会有影响,要先将新的树边长度加入权重之和中,再更新距离,否则dist[t] = min(dist[t], g[t][t])中,自环可能会修改dist[t],导致树边长度不正确

int n;      // n表示点数
int g[N][N];        // 邻接矩阵,存储所有边
int dist[N];        // 存储其他点到当前最小生成树的距离
bool st[N];     // 存储每个点是否已经在生成树中

memset(g, 0x3f, sizeof(g)); // 每条边初始化为正无穷
g[a][b] = g[b][a] = min(g[a][b], w); // 重边取最短,如果是无向图要加两条边

// 如果图不连通,则返回INF(值是0x3f3f3f3f), 否则返回最小生成树的树边权重之和
int prim()
{
    memset(dist, 0x3f, sizeof dist);

    int res = 0;
    for (int i = 0; i < n; i ++ )
    {
        int t = -1;
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        if (i && dist[t] == INF) return INF; // 当前不是第一个点(如果是第一个点dist还没更新,一个点也不能谈连通)且距离集合最短的点都是INF,说明图不连通,不存在最小生成树

        if (i) res += dist[t]; // 如果不是第一个点,当前的dist[t]就是距离最短的点到集合的距离,是一条树边,注意这一步要在更新距离之前,防止自环
        st[t] = true;

        for (int j = 1; j <= n; j ++ ) 
            dist[j] = min(dist[j], g[t][j]); // 可能会修改集合内点的dist,可以加个判断if(!st[j])
    }

    return res;
}
// 更简洁的写法
int prim()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0; // 规定一号点是起点,将它的dist提前置成0,可以减少特判
    int res = 0;
    for (int i = 0; i < n; i ++ )
    {
        int t = -1;
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                 t = j;
        if (dist[t] == INF) return INF; // 一号点是0,不会return
        st[t] = true;
        res += dist[t]; // 一号点的dist是0,放心加
        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], g[t][j]);
    }
    return res;
}

Kruskal算法(稀疏图)

时间复杂度是O(mlogm),n 表示点数,m 表示边数

步骤:

  1. 将所有边按权重从小到大排序。
  2. 枚举每条边a-b,权重c,如果a, b不在一个连通块(并查集维护),将这条边加入最小生成树集合中,并将a,b这两个连通块合并

重边、自环不影响

理解:将所有的边升序排列,从小到大遍历的过程中,每次都以最小的代价将两个点连通,如果这两个点已经连通,说明在之前已经用更小的代价将它们连通了。像这样,每个点的连通都是最优解,如果存在最小生成树,一定可以找到n - 1条边,保证每条边都是最优解,那么最终的权重之和就是最优解。如果找不到,说明不存在最小生成树。

int n, m;       // n是点数,m是边数
int p[N];       // 并查集的父节点数组,用并查集来维护连通块

struct Edge     // 存储边
{
    int a, b, w;

    bool operator< (const Edge &W)const
    {
        return w < W.w;
    }
}edges[M]; // M不小于总边数

int find(int x)     // 并查集核心操作
{
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int kruskal()
{
    sort(edges, edges + m);

    for (int i = 1; i <= n; i ++ ) p[i] = i;    // 初始化并查集

    int res = 0, cnt = 0; // res是最小生成树的树边权重之和,cnt是当前最小生成树集合中的边数
    for (int i = 0; i < m; i ++ )
    {
        int a = edges[i].a, b = edges[i].b, w = edges[i].w;

        a = find(a), b = find(b);
        if (a != b)     // 如果两个连通块不连通,则将这两个连通块合并,并将这条边加入最小生成树集合中
        {
            p[a] = b;
            res += w;
            cnt ++ ;
        }
    }

    if (cnt < n - 1) return INF; // 如果边数小于n-1条,说明图不连通,不存在最小生成树
    return res;
}

染色法判别二分图

时间复杂度是O(n+m),n 表示点数,m 表示边数

步骤:

  1. 将所有点置为未染色状态
  2. 遍历每个点,如果该点未染色,就以它为起点染色,如果返回了false,则不是二分图
  3. 染色:先将当前点染色,再遍历子节点。如果子节点未染色,则递归以它为起点染色,并检查返回值;如果子节点已经染色了,则判断是否同色,如果同色则不是二分图。
int n;      // n表示点数
int h[N], e[M], ne[M], idx;     // 邻接表存储图
int color[N];       // 表示每个点的颜色,-1表示未染色,0表示白色,1表示黑色

// 参数:u表示当前节点,c表示当前点的颜色
// 是二分图返回true, 不是返回false
bool dfs(int u, int c)
{
    color[u] = c;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (color[j] == -1)
        {
            if (!dfs(j, !c)) return false;
        }
        else if (color[j] == c) return false;
    }

    return true;
}

bool check()
{
    memset(color, -1, sizeof color);
    bool flag = true;
    // 尝试把所有点作为染色的起点,防止图不连通
    for (int i = 1; i <= n; i ++ )
        if (color[i] == -1)
            if (!dfs(i, 0))
            {
                flag = false;
                break;
            }
    return flag;
}

匈牙利算法

时间复杂度是O(nm),n 表示点数,m 表示边数,实际运行时间一般远小于O(nm)

二分图的匹配:给定一个二分图 G,在 G 的一个子图 M 中,M 的边集 {E} 中的任意两条边都不依附于同一个顶点,则称 M 是一个匹配。(即没有两条边共用一个点)

二分图的最大匹配:所有匹配中包含边数最多的一组匹配被称为二分图的最大匹配,其边数即为最大匹配数。

步骤:

​ 对于每一个find,遍历该点的子节点,如果该子节点没被标记,就标记它。如果该子节点已经有匹配的节点了,就递归调用find,看能否为该子节点的匹配对象找到新的匹配,如果可以,就匹配该子节点,返回成功。如果所有的子节点都无法匹配,就返回失败。

int n1, n2;     // n1表示第一个集合中的点数,n2表示第二个集合中的点数
int h[N], e[M], ne[M], idx;     // 邻接表存储所有边,匈牙利算法中只会用到从第一个集合指向第二个集合的边,所以这里只用存一个方向的边
int match[N];       // 存储第二个集合中的每个点当前匹配的第一个集合中的点是哪个
bool st[N];     // 表示第二个集合中的每个点是否已经被遍历过

bool find(int x)
{
    for (int i = h[x]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j])
        {
            st[j] = true;
            if (match[j] == 0 || find(match[j]))
            {
                match[j] = x;
                return true;
            }
        }
    }

    return false;
}

// 求最大匹配数,依次枚举第一个集合中的每个点能否匹配第二个集合中的点
int res = 0;
for (int i = 1; i <= n1; i ++ )
{
    memset(st, false, sizeof st);
    if (find(i)) res ++ ;
}

树的直径

#include <bits/stdc++.h>
using namespace std;

using ll = long long;
using pii = pair<int, int>;
const int N = 1e5 + 10;

vector<pii> g[N];
int dist[N];

void dfs(int u, int fa, int distance) // 从u出发,u的父节点是fa,u到起点的距离是distance
{
    dist[u] = distance;
    for (auto [v, w]: g[u])
    {
        if (v == fa) continue;
        dfs(v, u, distance + w);
    }
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 0; i < n - 1; i ++ )
    {
        int a, b, w;
        cin >> a >> b >> w;
        g[a].emplace_back(b, w), g[b].emplace_back(a, w);
    }
    dfs(1, -1, 0);
    int far = max_element(dist + 1, dist + n + 1) - dist;
    dfs(far, -1, 0);
    int diameter = *max_element(dist + 1, dist + n + 1);
    cout << 10 * diameter + (ll)diameter * (diameter + 1) / 2;
    return 0;
}

欧拉回路和欧拉路径

  1. 定义

    • 欧拉路径:经过连通图中所有边恰好一次的通路称为 欧拉路径
    • 欧拉回路:经过连通图中所有边恰好一次的回路成为欧拉回路
    • 欧拉图:具有欧拉回路的图称为欧拉图
    • 半欧拉图:有欧拉通路但不具有欧拉回路的图称为 半欧拉图
  2. 判定定理

    1. 对于无向图
      • 存在欧拉回路的充要条件
        • 图连通
        • 所有点的度数为偶数(一条边为两个邻点分别贡献1度)
      • 存在欧拉通路的充要条件
        • 图连通
        • 要么所有点的度数都是偶数,此时存在欧拉回路自然有欧拉通路;要么存在两个点的度数为奇数,其余点的度数都为偶数,此时两个奇度定点分别为欧拉通路的起点和终点
    2. 对于有向图
      • 存在欧拉回路的充要条件
        • 基图连通(有向图弱连通)
        • 每个点入度等于出度
      • 存在欧拉通路的充要条件
        • 基图连通(有向图弱连通)
        • 要么每个点入度等于出度,此时存在欧拉回路自然有欧拉通路;要么存在两个点的入度分别等于出度减1和出度加1,其余每个点的入度等于出度,此时出度=入度+1的点是起点,出度=入度-1的点是终点。
  3. 算法:寻找欧拉回路和欧拉通路的Hierholzer算法,复杂度O(n+m)

    算法分为两步,第一步判定是否有解,第二步求欧拉回路或欧拉通路

    1. 判定是否有解
      • 无向图:度数条件就记录每个点的度数,然后遍历判定。如果是求欧拉通路且固定了起点,还要判断起点是否满足奇数度数或者所有点度数是偶数;连通条件本质是不能存在孤立边,在算法执行完之后,判断找到的路径经过的边数是否等于总边数即可。
      • 有向图:度数条件就记录每个点的入度和出度,然后遍历判定。如果是求欧拉通路且固定了起点,还要判断起点是否满足出度=入度+1或者所有点度数是偶数;连通条件本质是不能存在孤立边,在算法执行完之后,判断找到的路径经过的边数是否等于总边数即可。
      • 连通条件:不能存在孤立边,但可以存在孤立点,可以找到路径后判断;如果确定没有孤立点,可以用并查集判断所有点的祖先是否相同来判断连通性。
    2. 寻找欧拉回路或欧拉通路的算法步骤
      1. 无向图看成有向图,建立两条边。
        • 通用:链式前向星O(n+m);无重边set,有重边multiset:复杂度O(n+mlogm)
        • 有向图:vector,O(n+m)
        • 定义一个栈。
      2. 符合条件的起点(很重要)开始做DFS,对于每个点,遍历其所有的出边,并将遍历到的出边删除,然后递归遍历邻点的所有出边。
      3. 遍历完所有出边后,将当前点压入栈中。
      4. 最终从栈顶到栈底就是欧拉回路(通路)。记住该算法是先遍历到的点后入栈,先遍历到的边也是后入栈,所以终点最先入栈,最后是起点入栈。(具体原因看代码实现)
    3. 起点的条件
      • 如果存在欧拉回路,要从第一个有边的点开始
      • 如果存在欧拉通路,有向图要从出度=入度+1的点开始,无向图要从奇度定点开始
  4. 模板题

    acwing欧拉回路 代码:链式前向星实现无向图和有向图的欧拉路径

    无向图给定起点的欧拉路径 代码:用multiset实现无向图

    洛谷-有向图的欧拉路径

    // 有向图vector
    #include <bits/stdc++.h>
    using namespace std;
    
    using ll = long long;
    using pii = pair<int, int>;
    const int N = 1e5 + 10, M = 2e5 + 10;
    vector<int> g[N];
    int n, m;
    int in[N], out[N], cur[N]; // 当前弧,每个点的初始出边的下标都是0,定义在全局就不需要初始化了
    int stk[M], top; // 注意,答案是边数+1(m条边,m+1个端点)
    
    void dfs(int u)
    {
        for (int i = cur[u]; i < g[u].size(); i = cur[u])
        {
            cur[u] ++ ; // 把当前遍历到的边删掉,下次遍历到时从新的当前弧开始
            // 有向图只删一条边,但是无向图要删两条边,可以用set实现,或者用链式前向星,相邻建边,先通过idx给边做标记,再次遇到时删除(链表删除操作)
            dfs(g[u][i]);
        }
        // 遍历完所有出边后将点加入栈,则先遍历到的点后入栈
        stk[top ++ ] = u;
    }
    
    int main()
    {
        ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
    
        cin >> n >> m;
        for (int i = 0; i < m; i ++ )
        {
            int u, v;
            cin >> u >> v;
            g[u].push_back(v);
            out[u] ++ , in[v] ++ ;
        }
        // 题目已经保证连通,判断度数条件即可
        int st = 0, ed = 0, cnt = 0; // st,ed初始化为0,当存在st,ed时,st,ed不是0
        for (int i = 1; i <= n; i ++ ) 
        {
            if (in[i] != out[i])
            {
                cnt ++ ;
                if (out[i] == in[i] + 1) st = i;
                else if (out[i] + 1 == in[i]) ed = i;
            }
            sort(g[i].begin(), g[i].end());
        }
        bool flag = true;
        // 两个度数条件,满足一个即可
        if (cnt == 0) dfs(1); // 存在欧拉回路时,从1开始字典序最小
        else if (cnt == 2 && st && ed) dfs(st);
        else flag = false;
        if (top != m + 1) flag = false; // 连通性判断,路径边数<总边数
        if (flag)
        {
            for (int i = top - 1; i >= 0; i -- ) 
                cout << stk[i] << ' ';
        }
        else cout << "No";
        return 0;
    }
    

点分治

模板题

参考教程:董晓算法视频讲解 董晓算法博客代码

时间复杂度O(nmlogn)n为点数,m为询问树上距离为k的点对是否存在的询问数

所以如果是找树上距离为特定值的点对,只需要O(nlogn)

算法作用:遍历树的所有点对的距离

  1. 树的性质:树的每对顶点之间存在唯一的一条初级通路(不经过重复点)
  2. 因此树的每对顶点之间的距离就是它们之间的初级通路的长度,而点分治可以遍历这些路径的长度,初级通路也是两点之间的最短路

image-20231002145831606

算法思想:

  1. 树上的路径可以分为两种

    1. 经过根节点的路径

      经过根节点的路径包括:

      • 根节点为端点,另一端在一个子树内,比如上图中的1到9
      • 根节点不是端点,两个端点在不同子树内,比如上图中的5到7
    2. 不经过根节点的路径,比如上图中的9到10

      可以发现,不经过根节点的路径上的点一定都在某棵子树内

  2. 长度的计算

    1. 对于经过根节点的路径,先预处理出每个点到根的路径长度,然后

      dis[u,v]=dis[u,root]+dis[root,v]

      比如上图中2到3的路径等于1+6

    2. 要排除不合法的路径,当u, v在同一棵子树时,用上述公式会重复计算一段路径长度。比如5到2,如果算5到1再加上1到2的话,会重复计算1到2的路径。

    3. 对于不经过根节点的路径,上文提到它们一定在某棵子树内,而在那棵子树内,它们是经过根节点的路径。因此,我们可以设计一个可以处理经过根节点的路径的函数,然后对它的子树也应用这个函数,那就可以遍历所有的路径了。这就是对子树不断分治,转化为经过根节点的路径的点分治算法。

  3. 根节点的选取

    如果是一棵均衡的树,分治次数是O(logn),这是树的高度。每次分治后,大概计算n个点到根节点的距离,对每个点做m次询问,则每次分治的复杂度是O(nm),总的时间复杂度是O(nmlogn)

    但如果退化为链树且从链的一端开始分治的话,分治次数就变成O(n),时间复杂度是O(n^2 * m)

    因此对于一棵树,我们要尽量选择其子树节点个数相对均衡的点作为根节点,这就是树的重心

    树的重心定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。

    image-20231002153013768

  4. 算法流程总结:

    1. 邻接表建树,如果是无向边要存两条有向边
    2. 先找到树的重心root,再以树的重心为根更新对应子树的节点个数数组siz[]
    3. 计算经过根节点的所有点对的路径长度,不管合法还是不合法
      1. 先求出所有点到根节点的距离
      2. 将所有距离升序排列
      3. 由公式dis[u,v]=dis[u,root]+dis[root,v],点对的距离dis[u,v]可以由它们各自到根节点的距离相加得到,而我们要对于询问的特定距离m,想知道是否有长度为m的点对距离。这就是变成在一个有序数组中寻找两数之和等于某个数的问题。用双指针算法解决。
      4. 当然,我们这里没有考虑u,v可能来自同一个子树,因此统计的距离长度为m的点对数比正确答案多
    4. 将根节点从树中删去
    5. 遍历所有子树,计算子树中的点到根节点的距离,进而得到子树中经过根节点的路径长度m的点对(双指针),这些都是不合法的,从记录数组中减去。对每棵子树都这样做,最后就可以得到正确的经过根节点的路径长度为m的点对了。
    6. 对每棵子树重复上述步骤,统计不经过根节点的路径,最终就可以遍历所有点对的路径,这就是点分治的过程。
#include <bits/stdc++.h>
using namespace std;

#define LOCAL
#ifdef LOCAL
#define dbg(...) fprintf(stderr, __VA_ARGS__)
#else
#define dbg(...)
#endif

using ll = long long;
using pii = pair<int, int>;
const int N = 1e4 + 10, M = 110;
struct Edge
{
    int to, w, ne;
}e[N << 1]; // 无向边,要存的边数等于2*(n - 1)
int h[N], idx = 1; // 从1开始存,对应函数的fa=0
// del标记每个点是否被删去,siz记录子树的节点数,mxs记录当前子树节点数的全局最大值,sum记录当前整棵树的节点数,root记录树的重心,是要计算路径的树的根节点
int del[N], siz[N], mxs, sum, root;
// d记录当前树的所有子孙节点到跟根节点的距离,dis顺序存储将d的所有值,cnt是dis的大小
int dis[N], d[N], cnt;
int ans[N]; // 满足第i个询问的点对数
int n, m, ask[M]; // ask记录所有询问的距离

// 加一条a到b,权值为w的有向边
void add(int a, int b, int w)
{
    e[idx].to = b, e[idx].w = w, e[idx].ne = h[a], h[a] = idx ++ ;
}

// 在以u为根,u的父节点是fa的树中寻找重心
// fa=0,表示这就是我们要求重心的那棵树
// 否则fa是用来防止往回遍历的,而边从idx=1开始存,取0也起到防止回去的作用
// 初始化mxs = sum 为整棵树的节点总数
void getroot(int u, int fa)
{
    siz[u] = 1; // siz[u]存以u为根节点的树的节点数(包括u自己)
    int s = 0; // s为以u为根节点的树,它的子树节点数的最大值
    for (int i = h[u]; i; i = e[i].ne) // 遍历子树
    {
        int to = e[i].to;
        if (to == fa || del[to]) continue;
        getroot(to, u); // 求子树的节点数,存到siz中,并在子树中寻找重心
        siz[u] += siz[to]; 
        s = max(s, siz[to]); // s更新,保证s是子树节点数的最大值
    }
    s = max(s, sum - siz[u]); // s更新,保证s是子树节点数的最大值
    // 解析:
    // 1. sum是要求重心的那棵树的总节点数
    // 2. 对于getroot(u, 0),sum = siz[u]
    // 3. 但对于子树来说,sum-siz[u]就是以u为根,fa为子树根节点的子树节点数
    if (s < mxs) mxs = s, root = u; // 如果子树节点数的最大值比当前的全局最大值小,就更新mxs,且root暂存为节点u
}

// 求出以u为根,u的父节点是fa的树中节点到总根节点的距离
void getdis(int u, int fa)
{
    dis[cnt] = d[u], cnt ++ ; // 进来的时候d[u]已经被算出来了,先存在dis中
    for (int i = h[u]; i; i = e[i].ne)
    {
        int to = e[i].to;
        if (to == fa || del[to]) continue;
        d[to] = d[u] + e[i].w;
        getdis(to, u);
    }
}
// 求出以u为根的树中,所有符合条件的点对路径,根节点的距离是w
// sign=1表示求出所有的,sign=-1表示删除不合法的
void calc(int u, int w, int sign)
{
    // cnt记录子孙节点的个数,d[]数组记录每个节点到根节点的距离
    cnt = 0, d[u] = w; // 根节点的距离初始化为w
    getdis(u, 0); // 计算每个节点到根节点的距离,初始化父节点是0,表示这个点没有父节点,计算的所有d[]都存在dis数组中
    sort(dis, dis + cnt); // 排序
    // 求出路径长度为ask[0~m]的点对
    for (int i = 0; i < m; i ++ )
    {
        int l = 0, r = cnt - 1;
        while (l < r)
        {
            if (dis[l] + dis[r] <= ask[i])
            {
                if (dis[l] + dis[r] == ask[i]) ans[i] += sign;
                l ++ ;
            }
            else r -- ;
        }
    }
}

// 遍历以u为根的树中所有点对之间的路径
void divide(int u)
{
    // 求出以u为根的树中,所有符合条件的点对路径,不管是否合法
    calc(u, 0, 1); // 第二个参数为0代表根节点的距离是0
    del[u] = 1; // 删除根节点
    for (int i = h[u]; i; i = e[i].ne) // 遍历所有子树
    {
        int to = e[i].to;
        if (del[to]) continue; 
        // 删除以to为根的子树中符合条件的点对路径,它们是不合法的
        calc(to, e[i].w, -1);  // e[i].w是子树的根节点到总根节点的距离
        // 求子树重心
        mxs = sum = siz[to]; 
        getroot(to, u);
        // 求子树中的合法点对路径
        divide(root);
    }
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
	
    // 邻接表存图
    cin >> n >> m;
    for (int i = 1; i <= n - 1; i ++ )
    {
        int u, v, w;
        cin >> u >> v >> w;
        add(u, v, w), add(v, u, w); // 无向边,存两条有向边
    }
    for (int i = 0; i < m; i ++ ) cin >> ask[i]; // 读入询问的距离
    // 寻找树的重心,结果存在全局变量root中
    mxs = sum = n; 
    getroot(1, 0);
    // 以重心为根,更新存储每棵子树的节点数的数组siz的值
    getroot(root, 0);
    // 遍历以root为根的树中的所有点对之间的路径
    divide(root);
 
    for (int i = 0; i < m; i ++ )
        ans[i] ? puts("AYE") : puts("NAY");
    return 0;
}

LCA

倍增算法

image-20231002203935836

image-20231002204337525

image-20231002205915463

image-20231002213020086

模板题

// 复杂度,打ST表O(nlogn),查询一次LCA耗时O(logn)
#include <bits/stdc++.h>
using namespace std;

const int N = 5e5 + 10, mdep = 20; // maxdepth>=log2(N)
vector<int> g[N];
int n, root, m;
// fa[i][j]存储节点i往上跳2^j层的祖先节点,dep[i]存储节点i的深度
int fa[N][mdep + 10], dep[N];

// 计算节点u的ST表
// 用深搜保证了路径上的点的ST表已算过
void dfs(int u, int father) // 树的dfs遍历写法
{
    dep[u] = dep[father] + 1; 
    fa[u][0] = father; // 跳一层就是父节点

    for (int i = 1; i <= mdep; i ++ ) // 跳2层、4层...
        fa[u][i] = fa[fa[u][i - 1]][i - 1]; // 分两段跳

    for (auto v : g[u]) // 遍历邻点,求它们的st表
    {
        if (v != father)
            dfs(v, u);
    }
}

int lca(int u, int v)
{
    if (dep[u] < dep[v]) swap(u, v); // 保证u比v深
    
    for (int i = mdep; i >= 0; i -- ) // 先跳到同一层
    {
        if (dep[fa[u][i]] >= dep[v])
            u = fa[u][i];
    }
    if (u == v) return v; // 易忽略的特殊情况,v就是v和u的LCA
    for (int i = mdep; i >= 0; i -- ) // u和v一起跳到LCA的下一层
    {
        if (fa[u][i] != fa[v][i])
            u = fa[u][i], v = fa[v][i];
    }
    return fa[u][0]; // u的父节点就是LCA
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);

    cin >> n >> m >> root;
    for (int i = 0; i < n - 1; i ++ )
    {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);
    }
    dfs(root, 0);
    for (int i = 0; i < m; i ++ )
    {
        int a, b;
        cin >> a >> b;
        cout << lca(a, b) << "\n";
    }
    return 0;
}

强连通分量

image-20231003155520613

对于强连通分量的理解:

  1. 是极大强连通子图,说明数量达到最大,再加一个点就不强连通
  2. 根据上图左下角的图1和图3,强连通分量一定有环
  3. 这四种有向边的定义是基于DFS树的,四种边都是图中原有的边,只是基于DFS树对它们做了分类定义
  4. 按照DFS的顺序,左子树的节点的访问时间早于右子树中的节点

根据上图左下角的图1和图3,一个点u在某个强连通分量中有两种情况:

  1. u有一条返祖边指向它的祖先节点
  2. u有一条横叉边指向左子树的一个点v,并且v有一条返祖边指向u的祖先节点,否则构不成强连通分量(左下角图2)

强连通分量的根

如果节点x是某个强连通分量在搜索树中遇到的第一个节点(也就是最早搜索到的),那么这个强连通分量的其余节点肯定是在搜索树中以x为根的子树中。节点x被称为这个强连通分量的根。

Tarjan算法

时间复杂度O(n+m)n是点数,m是边数,每个点只遍历一次,栈最多进出n个点

作用:找到图中所有的强连通分量,可以得到每个强连通分量包含哪些点以及每个强连通分量的大小。

image-20231003174344193

模板题

董晓算法讲解

// 模板来自董晓算法
#include <bits/stdc++.h>
using namespace std;

using ll = long long;
using pii = pair<int, int>;
const int N = 1e4 + 10;
vector<int> g[N];
int n, m;
// dfn每个点第一次被访问到的顺序
// low从节点出发所能访问到的最早时间戳
// time_stamp时间戳,一般从1开始,表示第i个访问的
int dfn[N], low[N], time_stamp;
// 栈以及标记一个点是否在栈中
stack<int> stk;
bool instk[N];
// scc每个点属于的强连通分量的标号
// siz大小为cnt,每个强连通分量的节点数
int scc[N], siz[N], cnt;

void tarjan(int x)
{
    // 入x时,盖戳、入栈
    dfn[x] = low[x] = ++time_stamp;
    stk.push(x);
    instk[x] = true;
    for (auto y : g[x]) // 枚举邻点
    {
        // 一开始dfn都是0,dfn从1开始赋值,因此为0表示尚未访问
        if (!dfn[y]) 
        {
            tarjan(y); // 对邻点做tarjan
            low[x] = min(low[x], low[y]); // 用子节点的low更新当前点的low
        }
        else if (instk[y]) // 若y已访问且在栈中
            low[x] = min(low[x], dfn[y]); // 横叉边或返祖边,更新low
    }
    // 离x时,记录SCC
    // 如果一个点的dfn=low,说明它是一个强连通分量的根
    // 此时从栈顶往下到它自己的所有节点都属于一个强连通分量
    if (dfn[x] == low[x])
    {
        cnt ++ ; // 强连通分量数加1
        while (1)
        {
            // 从栈顶往下到它自己的所有节点都记录下来
            int y = stk.top();  
            stk.pop();
            instk[y] = false; // 易忽略
            scc[y] = cnt; // 标记该点属于哪个强连通分量
            siz[cnt] ++ ; // 对应的强连通分量的节点数+1
            if (y == x) break; // y==x说明处理完了
        }
    }
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);

    cin >> n >> m;
    for (int i = 0; i < m; i ++ )
    {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
    }
    // 调用tarjan算法
    for (int i = 1; i <= n; i ++ ) // 易忽略,可能不连通
        if (!dfn[i]) 
            tarjan(i);
    int ans = 0;
    for (int i = 1; i <= cnt; i ++ )
        if (siz[i] > 1) ans ++ ;
    cout << ans;
    return 0;
}

网络流

参考资料

0x3f的网络流笔记

算法学习笔记(28): 网络流 - 知乎 (zhihu.com)

最大流 - OI Wiki (oi-wiki.org)

网络流的基本概念

img

img

img

img

最大流

存图方式

链式前向星

优点:容易取反向边。当反向边紧接着正向边建立,且正向边的编号是偶数时,可以通过^1互相取到。

// N是最大点数,M是题目中所给的最大边数,inf是流的最大值,要大于所有容量值
const int N = 210, M = 5010, inf = 0x3f3f3f3f; 

// 链式前向星存图
// 所谓链式前向星存的是边,to是该边指向的点(终点),c是该边的容量,ne是下一条与该边起点相同的边的编号。因为要建反向边,所以最大边数等于题目边数*2
struct Edge {ll to, c, ne;} edge[M * 2];

// h[i]存的i的第一条出边的编号,之后可以通过edge里的ne找到以i为起点的所有边
// add函数中使用idx++且以0为边界,所以idx从2开始
// 这样的好处是在定义是直接初始化,不需要在main函数里再初始化h为-1了
int h[N], idx = 2; // 从2,3开始配对

// mf[i]存S~i的流量上限,pre[i]存i的前驱边的编号
ll mf[N], pre[N];

// 遍历u的所有出边
for (int i = h[u]; i; i = edge[i].ne) // 0为边界
{
    auto to = edge[i].to, cap = edge[i].c;
    ...
}
// 增加一条a->b,容量是c的边,同时建它的反向边
void add(int a, int b, int c)
{
    // 这样赋值时变量的顺序要对应
    edge[idx] = {b, c, h[a]}, h[a] = idx ++ ;
    edge[idx] = {a, 0, h[b]}, h[b] = idx ++ ; 
}
  1. 当链式前向星的模板中每次加边用的下标是当前的idx(即idx++),要把idx初始化为偶数。

    idx初始为0,则边界是-1;idx初始为2,边界是0或-1。

  2. 当链式前向星的模板中每次加边用的下标是当前的idx+1(即++idx),要把idx初始化为奇数idx初始为-1,边界是-1;idx初始为1,边界是0或-1。

FF方法

算法学习笔记(28): 网络流 - 知乎 (zhihu.com)

找最大流算法的基本思想

// 维护残留网络
ans初始为0,每次找到一条增广路,由|f+f'|=|f|+|f'|,ans+=增广路流量
最终ans就是最大流的流量值
dfs 找增广路
{
    找增广路径(找一条从s出发沿着容量大于0的边走到t的通路),并计算它的流量
    更新残留网络 // 规定与流量同向的容量是正向,正向容量-流量,反向容量+流量
    返回流量
}
while (dfs能找到一条增广路)
{
	ans += 流量
}

Ford-Fulkerson算法具体的代码实现(from Pecco)

EK算法

EK:1e3~1e4(点数加边数)最大流不用,最小费用流用

y总的EK模板

董晓算法的EK模板

img

模板题:模板-网络最大流 草地排水

// 这是我结合多个模板写出的模板
#include <bits/stdc++.h>
using namespace std;

using ll = long long;
using pii = pair<int, int>;
const int N = 210, M = 5010, inf = 0x3f3f3f3f; 
struct Edge {ll to, c, ne;} edge[M * 2];
int h[N], idx = 2;
ll mf[N], pre[N];
int n, m, S, T;

void add(int a, int b, int c)
{
    edge[idx] = {b, c, h[a]}, h[a] = idx ++ ;
    edge[idx] = {a, 0, h[b]}, h[b] = idx ++ ;
}

bool bfs()
{
    // 每次寻找增广路前,初始化所有点的流量上限为0,这也是该点有没有走过的依据
    memset(mf, 0, sizeof mf);
    queue<int> q;
    q.push(S), mf[S] = inf; // 源点入队,假设流量是正无穷
    while (q.size())
    {
        // 每次遍历u的所有出边
        auto u = q.front();
        q.pop();
        for (int i = h[u]; i; i = edge[i].ne)
        {
            auto to = edge[i].to, cap = edge[i].c;
            // 如果该边的终点没走过且该边容量大于0
            if (!mf[to] && cap)
            {
                mf[to] = min(mf[u], cap); // 计算能到达该边终点的流量上限
                pre[to] = i; // 存该边终点的前驱边,也就是现在遍历到的这条边
                q.push(to); // 终点入队
                if (to == T) return true; // 如果已经找到汇点,返回true
            }
        }
    }
    return false; // 从源点走不到汇点,返回false
}

ll EK()
{
    ll maxflow = 0; // 存最大流的流量
    while (bfs()) // 当能找到一条增广路径时
    {
        // 新的可行流的流量等于原来的加上残留网络中增广路径的流量
        maxflow += mf[T]; 
        // 更新残留网络
        // 遍历增广路上的每个点,
        // 它的前驱边的容量-=流量,前驱边的反向边的容量+=流量
        for (int i = T; i != S; i = edge[pre[i] ^ 1].to) 
        {
            edge[pre[i]].c -= mf[T]; // pre[i]为i的前驱边编号
            edge[pre[i] ^ 1].c += mf[T]; // pre[i]^1为i的前驱边反向边编号
            // i的前一个点就是前驱边反向边的指向的点
        }
    }
    return maxflow;
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);

    cin >> n >> m >> S >> T;
    for (int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
    }
    cout << EK();
    return 0;
}

Dinic算法

Dinic:1e4~1e5(点数加边数)代码短

Dinic - OI Wiki

董晓算法讲解视频

y总的Dinic模板

董晓算法的Dinic模板

ISAP:和Dinic效率差不多,代码比Dinic长

img

img

img

#include <bits/stdc++.h>
using namespace std;

using ll = long long;
using pii = pair<int, int>;
const int N = 210, M = 5010, inf = 0x3f3f3f3f;
struct Edge {ll to, c, ne;} edge[M * 2];
int h[N], idx = 2;
int d[N], cur[N]; // d[]存点的深度,cur存每个点的当前弧
int S, T, n, m;

void add(int a, int b, int c)
{
    edge[idx] = {b, c, h[a]}, h[a] = idx ++ ;
    edge[idx] = {a, 0, h[b]}, h[b] = idx ++ ;
}

// 对点分层,找增广路
bool bfs()
{
    memset(d, 0, sizeof d);
    queue<int> q;
    q.push(S), d[S] = 1;
    while (q.size())
    {
        auto u = q.front();
        q.pop();
        for (int i = h[u]; i; i = edge[i].ne)
        {
            auto to = edge[i].to, cap = edge[i].c;
            if (!d[to] && cap)
            {
                d[to] = d[u] + 1;
                q.push(to);
                if (to == T) return true;
            }
        }
    }
    return false;
}

// 返回u在这一轮的网络结构以及流量上限是mf的情况下,从S~u~T的增广路流量之和
// 若u的高一层邻点是V,由S~u~T的增广路流量之和=把u分配给每个邻点v的流量是mf'的条件下,所有的邻点v的增广路流量之和累加的结果
ll dfs(int u, ll mf)
{
    if (u == T) return mf;
    ll sum = 0;
    // 从当前弧开始
    for (int i = cur[u]; i; i = edge[i].ne)
    {
        cur[u] = i; // 当前弧是为下一次再搜到u准备的,上一次搜到时已经搜索过u的前i-1条出边了,这些边已经增广到极限了(边 (u,v)已无剩余容量或 v的后侧已增广至阻塞),则 u的流量没有必要再尝试流向出边 (u,v)。而是从i开始尝试。
        // 两种情况
        // 1. 从这条边返回的f=mf,则说明v后面的边的容量都>=mf,则这条边i还有尝试的价值,此时mf=0,break,cur[u]就是i
        // 2. f<mf,说明v后面存在容量小于mf的边集L,则L经过这次搜索容量已经减为0了,i的后侧阻塞,没有尝试的价值,此时mf>0,cur[u]之后会更新
        auto to = edge[i].to, cap = edge[i].c;
        if (d[to] == d[u] + 1 && cap)
        {
            ll f = dfs(to, min(mf, cap)); // 分配min(mf,cap)时,邻点v的增广路流量之和
            edge[i].c -= f, edge[i ^ 1].c += f; // 更新残留网络
            sum += f, mf -= f; // 累加u的流出流量,减少u的剩余流量
            if (!mf) break; // 余量优化,如果u已经没有流量可分配了,就退出
        }
    }
    if (!sum) d[u] = 0; // 如果从u到T的流量是0,说明u和T不连通,直接将u从图中去掉(残枝优化)
    return sum;
}

ll dinic()
{
    ll maxflow = 0;
    while (bfs()) // 当存在增广路时
    {
        memcpy(cur, h, sizeof h); // 每一轮的网络结构不同,当前弧要从头开始
        maxflow += dfs(S, inf); // 每一轮能找到的所有增广路径流量之和累加
    }
    return maxflow;
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);

    cin >> n >> m >> S >> T;
    for (int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
    }
    cout << dinic();
    return 0;
}

最详细(也可能现在不是了)网络流建模基础 - victorique - 博客园 (cnblogs.com)

费用流

在所有的最大流中寻找费用最小的,费用等于将每条边的边权*流量累加起来

概念

img

EK算法

img

模板题:

https://www.luogu.com.cn/problem/P3381

https://loj.ac/p/102

#include <bits/stdc++.h>
using namespace std;

using ll = long long;
using pii = pair<int, int>;
const int N = 5e3 + 10, M = 5e4 + 10, inf = 0x3f3f3f3f;
struct Edge {int to, c, w, ne; } edge[M * 2];
int h[N], idx = 2;
int mf[N], d[N], pre[N]; // d[]数组存每个点到源点的距离
bool vis[N]; // vis数组-spfa中使用
int n, m, S, T;
int maxflow, mincost; // 答案

void add(int a, int b, int c, int w)
{
    edge[idx] = {b, c, w, h[a]}, h[a] = idx ++ ;
    // 反向边初始容量为0,费用等于正向边的相反数
    edge[idx] = {a, 0, -w, h[b]}, h[b] = idx ++ ;
}

bool spfa() // 寻找最短增广路
{
    // 不需要每次都把vis清零,所有点在最后出队时vis都会置为false
    memset(mf, 0, sizeof mf);
    memset(d, 0x3f, sizeof d);
    queue<int> q;
    q.push(S), vis[S] = true, d[S] = 0, mf[S] = inf;
    while (q.size())
    {
        auto t = q.front();
        q.pop();
        vis[t] = false;
        for (int i = h[t]; i; i = edge[i].ne)
        {
            auto e = edge[i];
            if (d[e.to] > d[t] + e.w && e.c) // 如果可以松弛且这条边的容量大于0,则该点入队
            {
                d[e.to] = d[t] + e.w;
                mf[e.to] = min(mf[t], e.c);
                pre[e.to] = i;
                if (!vis[e.to]) q.push(e.to), vis[e.to] = true;
            }
        }
    }
    return mf[T] > 0;
}

void EK()
{
    while (spfa())
    {
        maxflow += mf[T];
        mincost += mf[T] * d[T]; // 增广路的费用=流量*路径长度
    
        for (int i = T; i != S; i = edge[pre[i] ^ 1].to)
        {
            edge[pre[i]].c -= mf[T];
            edge[pre[i] ^ 1].c += mf[T];
        }
    }
}

int main()
{
    ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);

    cin >> n >> m >> S >> T;
    for (int i = 0; i < m; i ++ )
    {
        int a, b, c, w;
        cin >> a >> b >> c >> w;
        add(a, b, c, w);
    }    
    EK();
    cout << maxflow << ' ' << mincost;
    return 0;
}

最小割

  • 值:最小割=最大流

  • 方案:我们找到了最大流,根据最大流最小割定理,它的值也就是最小割,因此当我们找到了最大流,也就是没有s->t的增广路径时,剩下的残留网络刚好将S集合与T集合分割开来,这就是最小割。寻找最小割对应边也就十分简单了:

    1. 找到最大流max_flow
    2. 对最后的残留网络进行DFS/BFS遍历,沿残余容量大于0的边可达的点集合为S,不可达的为T
    3. 从S集合到T集合的边,就是最小割对应边

    原理:最大流的流量=最小割的容量,则在原网络中,S到T的边都是满流的,那么在最大流对应的残留网络(即最后的网络)中,这些边的容量都是0,也就是说,容量为0的边将S和T分隔。从s沿残留容量大于0的边能走到的都是S,都不到的都是T

posted @   sakuraLGGM  阅读(35)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示