图论
-1 最短路径
最短路径是指在一个有权图中,一个点到另一个点的路径中,边权和最小的路径。
在本章所有内容中,我们始终定义 \(n\) 为图的点数,\(m\) 为图的边数。
-1.1 单源最短路径
单源最短路径是指单起点,多终点的最短路径。
在本节中,我们始终定义 \(dis_i\) 为源点到点 \(i\) 的最短路径。
-1.1.1 Bellman-Ford
Bellman-Ford 是一种最基础的求解单源最短路径的算法,其整体思想是暴力,但是用途十分广泛。
具体实现中,该算法将 \(m\) 条边松弛 \(n - 1\) 次,其中松弛是指对于一条边 \((x, y)\),如果 \(dis_x + w_{x, y} < dis_y\),那么将 \(dis_y\) 的值修改为 \(dis_x + w{x, y}\),意思是如果当前从源点到 \(x\) 的最短路径加上 \((x, y)\) 的边权小于从源点到 \(y\) 的最短路径,那么就找到了一条比原来终点为 \(y\) 的最短路径更短的路径。
正确性证明:在一个有 \(n\) 个点的图中,任意两点的最短路至多经过 \(n\) 个点,故至多只需松弛 \(n - 1\) 次(源点不算)。详细证明这里不再阐述。
同时,Bellman-Ford 算法还可以处理有负权边的图的最短路,并且可以判断负环。
但是此算法由于其时间复杂度过高,为 \(\mathcal O(nm)\),所以选手一般不使用 Bellman-Ford,而是使用其队列优化版本 SPFA,后文将会详细阐述。
核心代码:
for(int i = 1; i < n; i++)//枚举n - 1轮
for(int j = 1; j <= m; j++)//枚举每一条边
{
int x = e[j].x, y = e[j].y, w = e[j].w;
if(dis[x] + w < dis[y])//松弛操作
dis[y] = dis[x] + w;
}
-1.1.1.1 SPFA
关于 SPFA,它死了。
SPFA 是 Bellman-Ford 的队列优化版本,在中国于 1994 年由西南交通大学的段凡丁提出。
在 Bellman-Ford 算法中,每一条边都会被松弛 \(n - 1\) 次,显然中间会有许多冗余的松弛,使用队列优化,可以避免这些冗余的松弛。
算法过程中,只要点 \(y\) 被松弛,那么它就可以松弛它的邻接点,将点 \(y\) 入队,再将这些点一个一个取出来,对它们的邻接点进行松弛。此外,记录一个点有没有入队,可以避免一个点重复入队,显著地缩减常数。
关于算法的时间复杂度,段凡丁在 \(1994\) 年提出时,证明 SPFA 的时间复杂度为 \(\mathcal O(km)\)(\(k\) 为小常数),但是段凡丁在论文中的复杂度证明时错误的,在构造的特殊数据下(例如完全图),SPFA 算法可以被卡成 \(\mathcal O(nm)\),与 Bellman-Ford 算法的时间复杂度一样,但是在随机图中,SPFA 算法的效率是比较优秀的。
模板题P3371 【模板】单源最短路径(弱化版)代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e4 + 5;
int n, m, s, dis[N];
bool vis[N];
struct node
{
int y, w;
};
vector<node> e[N];
void spfa(int s)
{
for(int i = 1; i <= n; i++)
dis[i] = INT_MAX;
queue<int> q;
q.push(s);//源点入队
vis[s] = true;//标记入队
dis[s] = 0;
while(!q.empty())
{
int x = q.front();
q.pop();
vis[x] = false;//出队标记
for(auto i : e[x])//不会还有人不会用auto吧
{
int y = i.y, w =i.w;
if(dis[x] + w < dis[y])//松弛操作
{
dis[y] = dis[x] + w;
if(vis[y])//如果已经在队中,跳过
continue;
q.push(y);
vis[y] = true;
}
}
}
return;
}
signed main()
{
cin >> n >> m >> s;
for(int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
e[x].push_back({y, w});
}
spfa(s);
for(int i = 1; i <= n; i++)
cout << dis[i] << " ";
return 0;
}
-1.1.1.2 负环
如果一个图中含有边权和为负的回路,那么这个回路就称作负环。
如何判断一个图中有没有负环呢?我们可以使用 Bellman-Ford 或 SPFA 算法来判定。
在 Bellman-Ford 算法的讲述中,我们提到:
任意两点的最短路至多经过 \(n\) 个点。
那么有没有一种情况,使得两点的最短路经过超过 \(n\) 个点呢?负环。在有负环的图中,我们可以走到负环中,一直沿着负环走,那么最短路就会一直降低。那么在 SPFA 算法中,如果有负环,那么就会卡死。
那么,在跑 SPFA 时,只要一个点入队超过 \(n - 1\) 次,那么这个图就存在负环。
模板题P3385 【模板】负环代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e4 + 5;
struct node
{
int y, w;
};
int t, n, m, dis[N], cnt[N];
bool vis[N];
vector<node> e[N];
void init()
{
for(int i = 1; i <= n; i++)
e[i].clear();
memset(dis, 0x3f, sizeof(dis));
memset(cnt, 0, sizeof(cnt));
memset(vis, false, sizeof(vis));
return;
}
bool spfa(int s)
{
queue<int> q;
dis[s] = 0;
q.push(s);
vis[s] = true;
while(!q.empty())
{
int x = q.front();
q.pop();
vis[x] = false;
for(auto i : e[x])
{
int y = i.y, w = i.w;
if(dis[x] + w < dis[y])
{
dis[y] = dis[x] + w;
cnt[y] = cnt[x] + 1;
if(cnt[y] >= n)//如果入队次数大于n - 1,就有负环
return false;
if(vis[y])
continue;
q.push(y);
vis[y] = true;
}
}
}
return true;
}
void slove()
{
cin >> n >> m;
init();
for(int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
e[x].push_back({y, w});
if(w >= 0)
e[y].push_back({x, w});
}
if(!spfa(1))
cout << "YES\n";
else
cout << "NO\n";
return;
}
signed main()
{
cin >> t;
while(t--)
slove();
return 0;
}
-1.1.1.3 差分约束
差分约束系统是指:
给出一组包含 \(m\) 个不等式,有 \(n\) 个未知数的形如:
的不等式组,求任意一组满足这个不等式组的解或者是否有解(\(a_i \ne b_i\) 且 \(a_i, b_i \le n\))。
我们单独来看一看一个不等式 \(x_{b_i} - x_{a_i} \le y_i\),将它稍微变一下,就变成了 \(x_{b_i} - y_i \ge x_{a_i}\)。这时,我们可以发现,这就像最短路中松弛操作的类似三角形不等式,于是,我们可以将差分约束转化为最短路/最长路来做。
对于每一个不等式 \(x_{b_i} - x_{a_i} \le y_i\),每次建一条 \(a_i \rightarrow b_i\),边权为 \(y_i\) 的边。为了防止不连通,我们要新建一个超级源点 \(0\) 或 \(n +1\) 点连一条边权为 \(0\) 的边,然后进行最短路或最长路,每个点的最短路即为一组解。
但是需要注意的一点是,如果图中存在负环,那么显然无解,因为不可能出现 \(a > b,\ b > c,\ c > a\) 的情况,所以需要使用 SPFA 或 Bellman-Ford 算法求解。
模板题P5960 【模板】差分约束代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 5e3 + 5;
struct node
{
int y, w;
};
int n, m, dis[N], cnt[N];
bool vis[N];
vector<node> e[N];
bool spfa(int s)
{
queue<int> q;
memset(dis, 0x3f, sizeof(dis));
q.push(s);
vis[s] = true;
dis[s] = 0;
while(!q.empty())
{
int x = q.front();
q.pop();
vis[x] = false;
for(auto i : e[x])
{
int y = i.y, w = i.w;
if(dis[x] + w < dis[y])
{
dis[y] = dis[x] + w;
cnt[y] = cnt[x] + 1;
if(cnt[y] > n)
return true;
if(vis[y])
continue;
q.push(y);
vis[y] = true;
}
}
}
return false;
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= m; i++)
{
int x, y, w;
cin >> y >> x >> w;
e[x].push_back({y, w});//建边
}
for(int i = 1; i <= n; i++)
e[0].push_back({i, 0});//超级源点
if(spfa(0))//无解
{
cout << "NO";
return 0;
}
for(int i = 1; i <= n; i++)
cout << dis[i] << " ";
return 0;
}
-1.1.2 Dijkstra
Dijkstra 是一种基于贪心的一种单源最短路径算法,其整体思想是蓝白点。
在算法实现中, 首先将源点标记为蓝点(求出最短路径的点,反之则白点),然后循环 \(n - 1\) 次,对于每一次循环,找出当前所有点中距离源点的最短路最小且的白点点 \(x\),将其标为蓝点,然后枚举点 \(x\) 的每一个邻接点 \(y\),进行松弛操作,如果 \(dis_x + w_{x, y} < dis_y\),那么 \(dis_y\) 更新为 \(dis_x + w_{x, y}\)。
关于正确性,此处引用@Alex_Wei 在初级图论中的正确性证明。
归纳假设已经拓展过的节点 \(p_1, p_2, ..., p_{k - 1}\) 在拓展时均取到了其最短路。\(p_k\) 为没有被拓展的 \(dis\) 最小的节点。
\(p_k\) 的最短路一定由 \(p_i(1 \le i < k)\) 的最短路拓展而来,不可能出现 \(dis_{p_i}+w_{p_i,p_k+1}+w_{p_k + 1, p_k} < dis_{p_j} + w_{p_j, p_k}(1 \le i,j < k)\) 的情况。否则由于边权非负,\(w_{p_k + 1, p_k} \le 0\),所以 \(dis_{p_i} + w_{p_i, p_k + 1} < dis_{p_j} + w_{p_j, p_k}\),即当前 \(dis_{p_k + 1} < dis_{p_k}\),与 \(dis_{p_k}\) 的最小性矛盾。
初始令源点 \(s\) 的 \(dis_s\) 为 \(0\) ,假设成立,因此算法正确。
但是需要注意的是,Dijkstra 算法无法处理有负权边的图的最短路,如有负权边,需使用 SPFA 算法。
该算法的时间复杂度为 \(\mathcal O(n^2)\)。
-1.1.2.1 堆优化 Dijkstra
根据上文所说,Dijkstra 算法是时间复杂度为 \(\mathcal O(n^2)\),显然是很慢的,在稀疏图中,还不如可以处理负权边的 Bellman-Ford,所以我们需要优化。
在寻找距离源点的最短路最小的白点时,一个一个找显然很慢,而这里我们可以使用小根堆优化。松弛的时候,只要条件成立,就将这个点压入堆中,然后将这些点一个一个取出对它的邻接点进行松弛。
这里的小根堆可以使用 C++ STL 中的 priority_queue
(优先队列)来实现。
此时的时间复杂度降为 \(\mathcal O(m \log m)\),十分优秀。
模板题P4779 【模板】单源最短路径(标准版)代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 5;
int n, m, s, dis[N];
bool vis[N];
struct node
{
int y, w;
};
vector<node> e[N];
void dijkstra(int s)
{
for(int i = 1; i <= n; i++)//将dis赋为极大值
dis[i] = INT_MAX;
priority_queue< pair<int, int> > pq;
pq.push(make_pair(0, s));//将源点入队
dis[s] = 0;//源点到源点的距离为0
while(!pq.empty())
{
int x = pq.top().second;
pq.pop();
if(vis[x])//如果是蓝点,那么跳过
continue;
vis[x] = true;//标记为蓝点
for(auto i : e[x])
{
int y = i.y, w = i.w;
if(dis[x] + w < dis[y])//松弛操作
{
dis[y] = dis[x] + w;
pq.push(make_pair(-dis[y], y));//入队
}
}
}
return;
}
signed main()
{
cin >> n >> m >> s;
for(int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
e[x].push_back({y, w});//建边
}
dijkstra(s);
for(int i = 1; i <= n; i++)
cout << dis[i] << " ";
return 0;
}
-1.1.3 扩展问题
-1.1.3.4 分层图最短路
分层图最短路的模型是指在一个图上,有 \(k\) 次影响当前的状态或代价的决策,求起点到终点的最短路。
顾名思义,分层图就是将原图进行分层,对于 \(k\) 个决策,将原图复制 \(k\) 遍,分别作为每一层,然后根据决策的内容给各层连边,变成一个完整的图。
-1.1.4 例题
P1948 [USACO08JAN] Telephone Lines S
考虑分层图。第 \(k\) 层表示已经连接 \(k\) 条免费的电缆,显然,每两层之间连接边权为 \(0\) 的边,然后 Dijkstra 求出第 \(k + 1\) 大的电缆。
Code:
-1.2 多源最短路径
多源最短路径是指多起点,多终点的最短路径。
-1.2.1 Floyd
Floyd 是一种多源最短路径算法,可以求出图上任意两点之间的最短路。
Floyd 本质是一个 DP。我们需要使用邻接矩阵来存图。\(dis_{i, j}\) 表示 \(i\) 到 \(j\) 的一条边。
算法实现中,首先需要枚举起点和终点 \(i, j\),然后再枚举一个中转点 \(k\),如果 \(dis_{i, k} + dis_{k, j} < dis_{i, j}\),那么更新 \(dis_{i, j}\)。显然,这也是松弛操作,我们可以直接用存图的数组进行 DP。
需要注意的是,枚举时,我们要先枚举中转点 \(k\),因为 Floyd 本质上是一个 DP,由小的状态推向大是状态,如果 \(dis_{i, k}\) 和 \(dis_{k, j}\) 没有求出来,那么松弛操作就无法完成。
模板题B3647 【模板】Floyd 算法代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 1e2 + 5;
int n, m, dis[N][N];
void floyd()
{
for(int k = 1; k <= n; k++)//枚举中转点
for(int i = 1; i <= n; i++)//枚举起点
for(int j = 1; j <= n; j++)//枚举终点
dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);//松弛操作
return;
}
int main()
{
cin >> n >> m;
memset(dis, 0x3f, sizeof(dis));
for(int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
dis[x][y] = dis[y][x] = min(dis[x][y], w);
}
for(int i = 1; i <= n; i++)
dis[i][i] = 0;
floyd();
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= n; j++)
cout << dis[i][j] << " ";
cout << "\n";
}
return 0;
}
-1.2.1.1 传递闭包
定义一个图的传递闭包为一个 \(n \times n\) 的 \(bool\) 矩阵 \(G\),若点 \(x\) 可达点 \(y\),那么 \(G_{x, y} = 1\),否则为 \(G_{x, y} = 0\)。
传递闭包可使用 Floyd 算法实现。我们同样枚举中转点 \(k\),起点和终点 \(i, j\),若 \(i, k\) 和 \(k, j\) 都存在路径,则 \(i, j\) 存在路径,\(G_{i, j} = 0\),否则 \(G_{x, y} = 0\)。
核心代码:
for(int k = 1; k <= n; k++)
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
if(g[i][k] && g[k][j])//如果(i, k)、(j, k)存在路径,那么(i, j)存在路径
G[i][j] = true;
-2 最小生成树
一个无向带权连通图的生成树是指图中的一个极小连通子图,它含有图中全部顶点,但由于其是一棵树,它只含有图中的 \(n - 1\) 条边。
图的所有生成树中具有边权和最小的生成树称为图的最小生成树。
-2.1 Kruskal
Kruskal 是一种基于贪心的一种算法,以边为对象进行贪心。
首先我们存储每一条边和两个端点,按照边权从小到大排序。依次枚举每一条边,设这一条边的两个端点分别为 \(x, y\),如果此时最小生成树中 \(x\) 和 \(y\) 不连通,那么在最小生成树中加入这条边。只要最小生成树的边数等于 \(n - 1\),那么最小生成树构造完成。连通性可以使用并查集维护。
关于正确性,由于我们对所有边从小到大进行了排序,所以每一次加入最小生成树的边一定是最小的。只要一条边的两个端点 \(x, y\) 连通,那么这条边就不能加入,否则就会增加边权和,并且还会形成环。
关于时间复杂度,由于排序其实才是最慢的一步,所以算法的时间复杂度为 \(\mathcal O(m \log m)\)。
模板题P3366 【模板】最小生成树代码:
#include<bits/stdc++.h>
using namespace std;
const int M = 2e5 + 5, N = 5e3 + 5;
struct node
{
int x, y, w;
}a[M];
int n, m, fa[N], ans, cnt;
int find(int x)
{
if (fa[x] == x)
return x;
return fa[x] = find(fa[x]);
}
void merge(int x, int y)
{
int fx = find(x), fy = find(y);
if (fx == fy)
return;
fa[fy] = fx;
return;
}
void kruskal()
{
for (int i = 1; i <= n; i++)//并查集初始化
fa[i] = i;
for (int i = 1; i <= m; i++)
{
int fx = find(a[i].x), fy = find(a[i].y);
if (fx != fy)//如果不连通,那么加入
{
merge(fx, fy);
cnt++, ans += a[i].w;
}
if (cnt == n - 1)//如果已经选了n-1条边了,结束循环
return;
}
return;
}
bool cmp(node x, node y)
{
return x.w < y.w;
}
int main()
{
cin >> n >> m;
for (int i = 1; i <= m; i++)
cin >> a[i].x >> a[i].y >> a[i].w;
sort(a + 1, a + 1 + m, cmp);//排序
kruskal();//开始Kruskal
if (cnt != n - 1)//如果不连通,输出"orz"
{
cout << "orz";
return 0;
}
cout << ans;
return 0;
}
-2.2 Prim
Prim 算法和 Kruskal 算法一样,都是基于贪心的算法,不过 Prim 算法是以点为对象的算法,也就是蓝白点,与 Dijkstra 算法相同。
我们设最小生成树(蓝点)的顶点集为 \(U\)。
在算法实现中, 首先将源点标记为蓝点(求出最短路径的点,反之则白点),然后循环 \(n - 1\) 次,对于每一次循环,找出当前所有点中距离 \(U\) 的距离最小且的白点 \(x\),将其标为蓝点,然后枚举点 \(x\) 的每一个邻接点 \(y\),更新距离 \(x\) 最近的点的距离。
该算法时间复杂度为 \(\mathcal O(n^2)\),不过还可以进行优化。
“找出当前所有点中距离 \(U\) 的距离最小且的白点” 这一过程可以类似 Dijkstra 算法,通过优先队列实现。
优化后该算法的时间复杂度为 \(\mathcal O(m \log m)\)
由于一般不使用该算法,因此不再提供代码。
-3 无向图连通性
本章与下一章均与且只与 Tarjan 算法有关。
在这里,笔者不得不说一句:Tarjan 老爷子真是神。
图的连通性问题是图论中非常重要的一部分。
(能说这一段我是抄的Alex_Wei的吗)
-3.1 割点与桥
Tarjan 算法是一种可以在线性时间复杂度内求出无向图的割点与桥的算法,同时也可以在同样的时间内求出无向图的双连通分量,有向图的强连通分量。Tarjan算法基于图的深度优先遍历(DFS)。
在学习该算法前,我们需要了解几个概念:
搜索树
在一个无向连通图中,我们选定一个节点出发,进行深度优先遍历,遍历到的边所组成的一棵树,就称为这个图的搜索树。
时间戳
在图的深度优先遍历中,按照每个节点第一次被访问的顺序,依次给每个节点进行编号,每个点的编号就称作这个点的时间戳,记作 \(dfn_x\)。
形象化一点,就是在深度优先遍历时,统计每个点第一次搜索到的序号。
追溯值
时间戳是自然存在于 DFS 中的,而在 Tarjan 算法中,还需引入一个新的概念:追溯值。
我们定义节点 \(x\) 的追溯值 \(low_x\) 表示在搜索树中,以 \(x\) 为根的子树的点和通过一条不在搜索树上的边能够到达的点的时间戳的最小值。
关于求值,以 \(x\) 为根的子树的时间戳的最小值显然是 \(x\) 本身的时间戳,于是我们先令 \(low_x = dfn_x\)。然后,从 \(x\) 出发,遍历 \(x\) 的邻接边 \((x, y)\)。若 \((x, y)\) 不在搜索树上,那么比较此时 \(low_x\) 和 \(dfn_y\) 的大小,如果 \(dfn_y\) 更小,那么更新 \(low_x\) 的值。
-3.1.1 割点
对于一个连通图,如果一个点 \(x\) 删去 \(x\) 和与 \(x\) 连接的边,使得该无向图不连通,那么点 \(x\) 为该图的一个割点。形象化一点,删掉割点和与其相连的边之后,图就不联通了。
上文我们提到,
参考文章
第二章:
所有章节
参考文献
算法竞赛进阶指南 - 李煜东