常用代码模板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;
}
最短路算法
最短路问题
-
概念
- 源点:起点
- 汇点:终点
- n:点数
- m:边数
-
分类
-
单源最短路:从一个固定点到其他所有点的最短距离(如从1->n)
- 所有边权都是正数:
- 朴素dijkstra算法:复杂度
,与边数无关,适用于稠密图(点少边多),例如边数是 级别的。 - 堆优化版dijkstra算法:复杂度
,适用于稀疏图(点和边一个级别或点多边少),例如点数和边数差不多时。
- 朴素dijkstra算法:复杂度
- 存在负权边:
- bellman-ford算法:复杂度
,一般用于求不超过k条边的最短路 - spfa算法:一般
,最坏 ,如果对最短路经过的边数限制,只能用bellman-ford算法
- bellman-ford算法:复杂度
- 所有边权都是正数:
-
多源汇最短路:求任意两个点之间的最短距离(起点不止一个)
floyd算法:复杂度
-
-
关键:建图(将原问题抽象成最短路),如何定义点和边,不侧重于证明算法的正确性
dijkstra算法(无负权边)
1. 朴素dijkstra算法(稠密图)
时间复杂是
步骤:(这里的距离指的都是到出发点的距离)
-
起点的距离置为0,其他点的距离置为
-
每次从未标记的节点中选择距离最小的节点赋给 t ,并把这个点标记,收录到最短路径集合中(基于贪心算法,没有确定最短路的、距离出发点最近的点此时的距离就是它的最短距离)
-
用 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 表示边数
步骤:(这里的距离指的都是到出发点的距离)
- 起点的距离置为0,其他点的距离置为
- 用小根堆来维护距离最小的点,由于一个点会进堆入度次,则已经确定最短路的可能在堆顶,每次取堆顶后赋给 t 后要判断,直到取到未确定最短路的、距离最小的点,并把这个点标记,收录到最短路径集合中。
- 用 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 表示边数
注意在模板题中需要对下面的模板稍作修改,加上备份数组,详情见模板题。
三角不等式:
松弛操作:
循环k次后dist[]的意义:从起点经过不超过k条边到达该点的最短距离(如果发生串联则不是,所以需要一个backup[]数组备份上一次迭代的结果,松弛操作用backup[]更新)
如果图中存在负权回路,最短路不一定存在,若负环不在路径上,则不影响
步骤:
- 起点的距离置为0,其他点的距离置为
- 循环k次(k为允许经过的最大边数),每次对所有边都进行松弛操作
- 循环结束之后,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 表示边数
因为松弛操作:
步骤:
- 起点的距离置为0,其他点的距离置为
- 起点入队,打上标记
- 当队列不空时,取队头,去除标记,并用队头松弛所有邻边,并将成功松弛的点入队,打上标记
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数组会更快
- 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;
}
-
dfs做法:(较快,注意dist一定要初始化为0)
只需将bfs中的队列换成栈即可。
floyd算法 (无负环)
时间复杂度是 O(
重边取最短的,自环忽略
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算法(稠密图)
时间复杂度是
步骤:(距离:点到集合内点的所有连线中的最短长度)
- 将所有点的距离初始化为
- 每次找到最小生成树集合外的距离最近的点赋给 t ,并将它标记,收录到最小生成树集合中
- 用 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算法(稀疏图)
时间复杂度是
步骤:
- 将所有边按权重从小到大排序。
- 枚举每条边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;
}
染色法判别二分图
时间复杂度是

步骤:
- 将所有点置为未染色状态
- 遍历每个点,如果该点未染色,就以它为起点染色,如果返回了false,则不是二分图
- 染色:先将当前点染色,再遍历子节点。如果子节点未染色,则递归以它为起点染色,并检查返回值;如果子节点已经染色了,则判断是否同色,如果同色则不是二分图。
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;
}
匈牙利算法
时间复杂度是
二分图的匹配:给定一个二分图 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度)
- 存在欧拉通路的充要条件
- 图连通
- 要么所有点的度数都是偶数,此时存在欧拉回路自然有欧拉通路;要么存在两个点的度数为奇数,其余点的度数都为偶数,此时两个奇度定点分别为欧拉通路的起点和终点
- 存在欧拉回路的充要条件
- 对于有向图
- 存在欧拉回路的充要条件
- 基图连通(有向图弱连通)
- 每个点入度等于出度
- 存在欧拉通路的充要条件
- 基图连通(有向图弱连通)
- 要么每个点入度等于出度,此时存在欧拉回路自然有欧拉通路;要么存在两个点的入度分别等于出度减1和出度加1,其余每个点的入度等于出度,此时出度=入度+1的点是起点,出度=入度-1的点是终点。
- 存在欧拉回路的充要条件
- 对于无向图
-
算法:寻找欧拉回路和欧拉通路的Hierholzer算法,复杂度
算法分为两步,第一步判定是否有解,第二步求欧拉回路或欧拉通路
- 判定是否有解
- 无向图:度数条件就记录每个点的度数,然后遍历判定。如果是求欧拉通路且固定了起点,还要判断起点是否满足奇数度数或者所有点度数是偶数;连通条件本质是不能存在孤立边,在算法执行完之后,判断找到的路径经过的边数是否等于总边数即可。
- 有向图:度数条件就记录每个点的入度和出度,然后遍历判定。如果是求欧拉通路且固定了起点,还要判断起点是否满足出度=入度+1或者所有点度数是偶数;连通条件本质是不能存在孤立边,在算法执行完之后,判断找到的路径经过的边数是否等于总边数即可。
- 连通条件:不能存在孤立边,但可以存在孤立点,可以找到路径后判断;如果确定没有孤立点,可以用并查集判断所有点的祖先是否相同来判断连通性。
- 寻找欧拉回路或欧拉通路的算法步骤
- 无向图看成有向图,建立两条边。
- 通用:链式前向星
;无重边set,有重边multiset:复杂度 - 有向图:vector,
- 定义一个栈。
- 通用:链式前向星
- 从符合条件的起点(很重要)开始做DFS,对于每个点,遍历其所有的出边,并将遍历到的出边删除,然后递归遍历邻点的所有出边。
- 遍历完所有出边后,将当前点压入栈中。
- 最终从栈顶到栈底就是欧拉回路(通路)。记住该算法是先遍历到的点后入栈,先遍历到的边也是后入栈,所以终点最先入栈,最后是起点入栈。(具体原因看代码实现)
- 无向图看成有向图,建立两条边。
- 起点的条件
- 如果存在欧拉回路,要从第一个有边的点开始
- 如果存在欧拉通路,有向图要从出度=入度+1的点开始,无向图要从奇度定点开始
- 判定是否有解
-
模板题
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到9
- 根节点不是端点,两个端点在不同子树内,比如上图中的5到7
-
不经过根节点的路径,比如上图中的9到10
可以发现,不经过根节点的路径上的点一定都在某棵子树内
-
-
长度的计算
-
对于经过根节点的路径,先预处理出每个点到根的路径长度,然后
dis[u,v]=dis[u,root]+dis[root,v]
比如上图中2到3的路径等于1+6
-
要排除不合法的路径,当u, v在同一棵子树时,用上述公式会重复计算一段路径长度。比如5到2,如果算5到1再加上1到2的话,会重复计算1到2的路径。
-
对于不经过根节点的路径,上文提到它们一定在某棵子树内,而在那棵子树内,它们是经过根节点的路径。因此,我们可以设计一个可以处理经过根节点的路径的函数,然后对它的子树也应用这个函数,那就可以遍历所有的路径了。这就是对子树不断分治,转化为经过根节点的路径的点分治算法。
-
-
根节点的选取
如果是一棵均衡的树,分治次数是
O(logn)
,这是树的高度。每次分治后,大概计算n
个点到根节点的距离,对每个点做m
次询问,则每次分治的复杂度是O(nm)
,总的时间复杂度是O(nmlogn)
但如果退化为链树且从链的一端开始分治的话,分治次数就变成
O(n)
,时间复杂度是O(n^2 * m)
了因此对于一棵树,我们要尽量选择其子树节点个数相对均衡的点作为根节点,这就是树的重心
树的重心定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。
-
算法流程总结:
- 邻接表建树,如果是无向边要存两条有向边
- 先找到树的重心
root
,再以树的重心为根更新对应子树的节点个数数组siz[]
- 计算经过根节点的所有点对的路径长度,不管合法还是不合法
- 先求出所有点到根节点的距离
- 将所有距离升序排列
- 由公式
dis[u,v]=dis[u,root]+dis[root,v]
,点对的距离dis[u,v]
可以由它们各自到根节点的距离相加得到,而我们要对于询问的特定距离m
,想知道是否有长度为m
的点对距离。这就是变成在一个有序数组中寻找两数之和等于某个数的问题。用双指针算法解决。 - 当然,我们这里没有考虑u,v可能来自同一个子树,因此统计的距离长度为
m
的点对数比正确答案多
- 将根节点从树中删去
- 遍历所有子树,计算子树中的点到根节点的距离,进而得到子树中经过根节点的路径长度
m
的点对(双指针),这些都是不合法的,从记录数组中减去。对每棵子树都这样做,最后就可以得到正确的经过根节点的路径长度为m
的点对了。 - 对每棵子树重复上述步骤,统计不经过根节点的路径,最终就可以遍历所有点对的路径,这就是点分治的过程。
#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
倍增算法
// 复杂度,打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;
}
强连通分量
对于强连通分量的理解:
- 是极大强连通子图,说明数量达到最大,再加一个点就不强连通
- 根据上图左下角的图1和图3,强连通分量一定有环
- 这四种有向边的定义是基于DFS树的,四种边都是图中原有的边,只是基于DFS树对它们做了分类定义
- 按照DFS的顺序,左子树的节点的访问时间早于右子树中的节点
根据上图左下角的图1和图3,一个点u
在某个强连通分量中有两种情况:
u
有一条返祖边指向它的祖先节点u
有一条横叉边指向左子树的一个点v
,并且v
有一条返祖边指向u
的祖先节点,否则构不成强连通分量(左下角图2)
强连通分量的根
如果节点x是某个强连通分量在搜索树中遇到的第一个节点(也就是最早搜索到的),那么这个强连通分量的其余节点肯定是在搜索树中以x为根的子树中。节点x被称为这个强连通分量的根。
Tarjan算法
时间复杂度O(n+m)
,n
是点数,m
是边数,每个点只遍历一次,栈最多进出n个点
作用:找到图中所有的强连通分量,可以得到每个强连通分量包含哪些点以及每个强连通分量的大小。
// 模板来自董晓算法
#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;
}
网络流
参考资料
算法学习笔记(28): 网络流 - 知乎 (zhihu.com)
网络流的基本概念
最大流
存图方式
链式前向星
优点:容易取反向边。当反向边紧接着正向边建立,且正向边的编号是偶数时,可以通过^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 ++ ;
}
-
当链式前向星的模板中每次加边用的下标是当前的idx(即idx++),要把idx初始化为偶数。
idx初始为0,则边界是-1;idx初始为2,边界是0或-1。
-
当链式前向星的模板中每次加边用的下标是当前的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(点数加边数)最大流不用,最小费用流用
// 这是我结合多个模板写出的模板
#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(点数加边数)代码短
ISAP:和Dinic效率差不多,代码比Dinic长
#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)
费用流
在所有的最大流中寻找费用最小的,费用等于将每条边的边权*流量累加起来
概念
EK算法
模板题:
https://www.luogu.com.cn/problem/P3381
#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集合分割开来,这就是最小割。寻找最小割对应边也就十分简单了:
- 找到最大流
max_flow
- 对最后的残留网络进行DFS/BFS遍历,沿残余容量大于0的边可达的点集合为S,不可达的为T
- 从S集合到T集合的边,就是最小割对应边
原理:最大流的流量=最小割的容量,则在原网络中,S到T的边都是满流的,那么在最大流对应的残留网络(即最后的网络)中,这些边的容量都是0,也就是说,容量为0的边将S和T分隔。从s沿残留容量大于0的边能走到的都是S,都不到的都是T
- 找到最大流
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现