【笔记】图论
基本
存图
我们一般用 vector 来存。有特殊需求的时候也可能用邻接表,比如写网络流时为了快速找反边用。
邻接表:
int h[N], e[M], w[M], ne[M], idx;
void init() {
memset(h, -1, sizeof h); // 一定记得设成 -1 !
return;
}
void add(int u, int v, int wt) {
e[idx] = v, w[idx] = wt, ne[idx] = h[u];
h[u] = idx++;
return;
}
vector:
using pii = pair<int, int>;
vector<pii> g[N];
void add(int u, int v, int w) {
g[u].push_back({v, w}); // 如果是无向图加两边就可以
return;
}
最短路
主要有全源最短路,单源最短路这两种。
Floyd
用来求全源最短路,本质上是 DP,核心思想是枚举每一个中间点,判断以这个点为中转站是否能让某两点 \(u, v\) 间的距离比原来更小。转移方程如下:
将 \(k\) 迭代取遍 \(1\sim n\) 即可。此处有一点很重要,当钦定中转点 \(k\) 时, \(d_{(u, v)}\) 表示的是以某个小于 \(k\) 的点为中转点时,\(u, v\) 间的最小距离(因为此时大于等于 \(k\) 的最短距离还没有更新)。因此注意可能会利用这个 “单调性” 来做题。
还有一点,如果题目对于最短路的要求不是仅与一个变量有关,那么 Floyd 可能是错的。举例,若定义两点间距离是两点间经过的边权和加上点权和,那么分别求出边权和与点权和最小值相加就是错的,因为它们不一定同时取等。
时间复杂度 \(O(n^3)\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 505;
int n, g[N][N];
int main() {
scanf("%d", &n);
memset(g, 0x3f, sizeof g);
for (int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
scanf("%d", &g[i][j]);
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
g[i][j] = min(g[i][j], g[i][k] + g[k][j]);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++)
printf("%d ", g[i][j]);
printf("\n");
}
return 0;
}
Dijkstra
单源最短路。
实质是贪心算法,在一个边权均为正的图中,记录每个点目前距离源点的距离为 \(d_u\),向这个集合中加入源点,之后每次都从这个集合中选取最小值,并删除,用它去松弛更新其他点的 \(d\) 值,如果发生更新就将新的点放入集合。
显然如果发生更新,一定是由这些点引起(但这些点并不一定引起更新),所以只要一个点被另一个点加入集合,它的最短距离就已经被确定,因此等到集合为空,\(d\) 中对应的就是所有点最短距离。用堆维护这个集合。
注意,Dijkstra 不能求含负权边的最短路或者最长路,因为这会使得它的贪心出错。
时间复杂度 \(O((n + m) \log n)\)。
#include <bits/stdc++.h>
using namespace std;
using pii = pair<int, int>;
const int N = 1e5 + 5, nex = 0x3f3f3f3f;
int n, m, s, q, dis[N];
bool st[N];
vector<pii> g[N];
void init(){
memset(dis, 0x3f, sizeof dis), memset(st, false, sizeof st);
return;
}
inline void add(int u, int v, int w){
g[u].push_back({v, w});
return;
}
void dijkstra(int s){
priority_queue<pii, vector<pii>, greater<pii> > q;
q.push({dis[s] = 0, s});
while(q.size()){
auto [d, u] = q.top();
q.pop();
if(st[u])
continue;
st[u] = true;
// 它在队里,现在被弹出用来更新别人,因此已经是最短,不再需要入队
for(auto [v, w] : g[u]){
int nd = dis[u] + w;
if(nd < dis[v]){
dis[v] = nd;
q.push({nd, v});
}
}
}
return;
}
int main(){
init();
scanf("%d%d%d%d", &n, &m, &s, &q);
for(int i = 1; i <= m; i++){
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
add(u, v, w);
}
dijkstra(s);
while(q--){
int u;
scanf("%d", &u);
if(dis[u] == nex)
printf("IMPOSSIBLE\n");
else
printf("%d\n", dis[u]);
}
return 0;
}
SPFA
本质是 BFS。用一个队列存储点及其目前的距离,不断用队头节点松弛更新其它点,直到队空(不再更新,即代表所有节点已经是最短)。
它还可以判断负环:当一个点的最短路中含超过 \(n\) 条边,说明存在。注意判断负环要放在最前面,不然可能会被 in[v] == true 跳过而漏判。
还可以做到求最长路:把松弛那里符号反过来就行。不过 dijkstra 就不行,因为不满足贪心的条件。
时间复杂度:平均 \(O(km)\) (\(k\) 为常数),最坏 \(O(nm)\)(网格图可卡)。
#include <bits/stdc++.h>
using namespace std;
using pii = pair<int, int>;
const int N = 1e4 + 5;
const int nex = 0x3f3f3f3f, inf = INT_MAX;
int n, m, s, d[N], in[N], cnt[N];
vector<pii> g[N];
void init() {
memset(d, 0x3f, sizeof d);
memset(in, false, sizeof in);
memset(cnt, 0, sizeof cnt);
return;
}
void add(int u, int v, int w) {
g[u].push_back({v, w});
return;
}
bool spfa(int s) {
queue<int> q;
q.push(s);
d[s] = 0, in[s] = true;
while (q.size()) {
int u = q.front();
q.pop();
in[u] = false;
for (auto [v, w] : g[u]) {
int nd = d[u] + w;
if (nd < d[v]) {
d[v] = nd;
if (in[v])
continue;
q.push(v);
in[v] = true, cnt[v]++;
if (cnt[v] >= n)
return false;
}
}
}
return true;
}
int main() {
init();
scanf("%d%d%d", &n, &m, &s);
for (int i = 1; i <= m; i++) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
add(u, v, w);
}
bool res = spfa(s);
if(!res) {
printf("Negative Circle\n");
return 0;
}
for (int i = 1; i <= n; i++)
printf("%d ", d[i] == nex ? inf : d[i]);
return 0;
}
最小生成树
Prim
朴素版 \(O(n^2)\),堆优化 \(O(m\log n)\) 的一个算法,理论上稠密图比 Kruskal 表现要好,不过我觉得没用。
Kruskal
依旧是贪心思想,将边从小到大排序,依次加入,同时用并查集维护点之间的连通性,如果这条边两端点已经在一个连通块中,说明这条边的加入会生成一个环,不满足最小生成树要求,是没有必要的,就跳过。加入满 \(n - 1\) 条边就可以退出了,如果遍历了 \(m\) 条边仍没有加入足够的边,说明图不连通。
注意以下两点,第一:并查集要初始化 \(f_u = u\)。而且这个初始化要放在输入 \(n\) 之后。第二:一定要在 Kruskal 之前先对边排序!
时间复杂度 \(O(m\log m)\),稀疏图表现很不错。
#include <bits/stdc++.h>
using namespace std;
const int N = 5e3 + 5, M = 2e5 + 5;
int n, m, fa[N];
struct edge {
int u, v, w;
}e[M];
bool cmp(edge a, edge b) {
return a.w < b.w;
}
void init() {
for (int i = 1; i <= n; i++)
fa[i] = i;
return;
}
int find(int u) {
if(fa[u] == u)
return u;
return fa[u] = find(fa[u]);
}
int kruskal() {
int sum = 0, cnt = 0;
for (int i = 1; i <= m; i++) {
int u = e[i].u, v = e[i].v, w = e[i].w;
int fu = find(u), fv = find(v);
if(fu == fv)
continue;
cnt++;
sum += w;
fa[fu] = fv;
if(cnt == n - 1)
return sum;
}
return -1;
}
int main() {
scanf("%d%d", &n, &m);
init();
for (int i = 1; i <= m; i++) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
e[i] = {u, v, w};
}
sort(e + 1, e + 1 + m, cmp);
int res = kruskal();
res == -1 ? printf("orz\n") : printf("%d\n", res);
return 0;
}
Borůvka
听有的人说如果边的特性比较多,可以用这个。待补。
欧拉路
欧拉路/回路的判断
假设我们已经找到了一条欧拉路,观察路径上的每一个点,不难发现除了起点和终点以外,从别的点“进入”它,以及从它“出去”的次数是相同的。也即除起点和终点以外,所有点的入度等于出度,起点的出度比入度多 \(1\),终点的出度比入度少 \(1\)。这就是判断欧拉路存在的充分必要条件(同时图也必须是连通的)。
特别地,如果所有点入度与出度都相同,那么图存在欧拉回路。
Hierholzer
这个算法的核心逻辑是,从起点开始进行 dfs,直到没有边可以走为止;这时如果还有边没走,那么一定存在某个点在这条路径上,且它在一个环里。(欧拉路相当于一条主链加上若干个环)每次把这个环合并到欧拉路里即可。用 dfs 可能会爆栈,迭代版可以解决这个问题。
看到“每条边恰好经过一次”“每个物品恰好都用一次”“首尾相接”类似的条件,可以试试往这边想。
注意对于无向图有向图欧拉路存在性的判断:
- 有向图:忽略边的方向,图弱联通,入度不等于出度,且出入度差 \(1\) 的点为 \(0\) 或 \(2\) 个。
- 无向图:图联通,且度数为奇数的点仅有 \(0\) 或 \(2\) 个。
时间复杂度 \(O(n+m)\)。
有向图:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n, m, s, in[N], ot[N], cur[N];
vector<int> g[N];
vector<int> stk, path;
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int u, v;
scanf("%d%d", &u, &v);
g[u].push_back(v);
in[v]++, ot[u]++;
}
for (int i = 1; i <= n; i++)
sort(g[i].begin(), g[i].end());
int c1 = 0, c2 = 0;
s = -1;
for (int i = 1; i <= n; i++) {
if (ot[i] == in[i])
continue;
if (ot[i] - in[i] == 1)
c1++, s = i;
else if (in[i] - ot[i] == 1)
c2++;
else {
printf("No\n");
return 0;
}
}
if (c1 == 0 && c2 == 0) {
for (int i = 1; i <= n; i++)
if (ot[i] > 0) {
s = i;
break;
}
} else if (!(c1 == 1 && c2 == 1)) {
printf("No\n");
return 0;
}
if (s == -1) {
printf("No\n");
return 0;
}
stk.push_back(s);
while (!stk.empty()) {
int u = stk.back();
if (cur[u] < (int)g[u].size()) {
stk.push_back(g[u][cur[u]++]);
} else {
path.push_back(u);
stk.pop_back();
}
}
if ((int)path.size() != m + 1) {
printf("No\n");
return 0;
}
for (int i = (int)path.size() - 1; i >= 0; i--) {
printf("%d", path[i]);
if (i) printf(" ");
}
printf("\n");
return 0;
}
无向图:
#include <bits/stdc++.h>
using namespace std;
using pii = pair<int, int>;
const int N = 1e5 + 5;
int n, src, c, d[60], fa[60], cur[60];
bool used[N], app[60];
vector<pii> g[60];
vector<int> p;
int id(char ch) {
if ('A' <= ch && ch <= 'Z')
return ch - 'A';
return ch - 'a' + 26;
}
char ch(int x) {
if (x < 26)
return 'A' + x;
return 'a' + x - 26;
}
void init() {
memset(app, false, sizeof app);
memset(used, false, sizeof used);
for (int i = 0; i <= 52; i++)
fa[i] = i;
return;
}
int find(int u) {
if(fa[u] == u)
return u;
return fa[u] = find(fa[u]);
}
void dfs(int u) {
while (cur[u] < (int)g[u].size()) {
int v = g[u][cur[u]].first, idx = g[u][cur[u]].second;
cur[u]++;
if (used[idx])
continue;
used[idx] = true;
dfs(v);
}
p.push_back(u);
return;
}
int main() {
scanf("%d", &n);
init();
for (int i = 1; i <= n; i++) {
char buf[5];
scanf("%s", buf + 1);
int u = id(buf[1]), v = id(buf[2]);
d[u]++, d[v]++;
g[u].push_back({v, i}), g[v].push_back({u, i});
app[u] = true, app[v] = true;
int fu = find(u), fv = find(v);
if (fu != fv)
fa[fu] = fv;
}
for (int i = 0; i < 52; i++)
sort(g[i].begin(), g[i].end());
int b = -1, odd = 0, fstapp = -1, fstodd = -1;
for (int i = 0; i < 52; i++) {
if (!app[i])
continue;
if (b == -1)
b = find(i);
if (find(i) != b) {
printf("No Solution\n");
return 0;
}
if (fstapp == -1)
fstapp = i;
if (d[i] & 1) {
odd++;
if (fstodd == -1)
fstodd = i;
}
}
if (odd == 0)
src = fstapp;
else if (odd == 2)
src = fstodd;
else {
printf("No Solution\n");
return 0;
}
dfs(src);
if ((int)p.size() != n + 1) {
printf("No Solution\n");
return 0;
}
for (int i = (int)p.size() - 1; i >= 0; i--)
putchar(ch(p[i]));
putchar('\n');
return 0;
}
连通性问题 (Tarjan)
以下定义:
- \(\text{dfn}_u\) 表示对图 \(G\) dfs 后 \(u\) 点被访问到的次序,即时间戳。
- T 边:指向一个未被访问到的新节点的边。
- B 边:指向一个已经被访问过的且在栈中的点的边。
- F 边:指向这条边始点的后代的边。
- C 边:指向其它子树节点的边。
强连通分量 (SCC)
若某图的一个子图中所有点都可以互相到达,则称它是这个图的一个强连通子图。如果向这个子图中再加入任何边和点都会使它不再强连通,则称这个子图是原图的一个强连通分量。
也就是说,强连通分量 = 有向图中的极大强连通子图。
我们定义 \(\text{low}_{u}\) 为节点 \(u\) 走过若干 T 边和至多一条 B 边可以到达的 \(\text{dfn}\) 值最小的节点的 \(\text{dfn}\) 值。
任选一个节点开始进行 dfs,若从 \(u\) 走到 \(\text{dfn}\) 值为空的节点(即未被搜索到过的节点),就更新其 \(\text{dfn}\) 值,将 \(\text{low}\) 值初始化为其 \(\text{dfn}\) 值,并把它入栈,继续搜索。若走到了 \(\text{dfn}\) 值不为空的节点,且它在栈中,说明 \(u\) 通过仅一条 B 边走到了它的祖先,这时候应维护 \(\text{low}_u = \min(\text{low}_u, \text{dfn}_v)\),注意这里应该用 \(\text{dfn}_v\) 来更新而不是 \(\text{low}_v\)。这是因为因为我们定义 \(\text{low}\) 值意为仅经过最多一条 B 边能到达的最高的节点,而 \((u, v)\) 本身就是一条 B 边,若用 \(\text{low}_v\) 来更新可能存在走两条及以上 B 边的可能,与定义不符。
对于 \(u\),处理完所有能走到的点之后,如果它的 \(\text{low}_u\) 与 \(\text{dfn}_u\) 仍然相等,说明它就是它所在的强连通分量中最高的那个点。由于栈中的节点 \(\text{dfn}\) 值单调递增,因此所有比 \(u\) 晚入栈的节点都是它的后代,也在这个强连通分量中。
所以我们弹栈,直到将 \(u\) 弹出,这些点全部归入一个强连通分量中。这样就求完了。
还有一个技巧:缩点。将一个强连通分量中的所有点看作一个点,忽略分量内部的连边,原图就会变成一个 DAG,可以进行拓扑 dp 等一系列操作。
时间复杂度 \(O(n + m)\)。
以下代码完成了求 SCC 及其元素。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5;
int n, m, num, cnt, top;
bool in[N];
int dfn[N], low[N], stk[N];
vector<int> g[N], p[N];
void scc(int u) {
dfn[u] = low[u] = ++num;
stk[++top] = u, in[u] = true;
for (int v : g[u]) {
if (!dfn[v]) {
scc(v);
low[u] = min(low[u], low[v]);
} else if (in[v])
low[u] = min(low[u], dfn[v]);
}
if (dfn[u] == low[u]){
cnt++;
int t;
do {
t = stk[top--];
in[t] = false;
p[cnt].push_back(t);
} while(t != u);
}
}
int main() {
scanf("%d%d", &n, &m);
while (m--) {
int u, v;
scanf("%d%d", &u, &v);
if (u != v)
g[u].push_back(v);
}
for (int i = 1; i <= n; i++)
if (!dfn[i])
scc(i);
for (int i = 1; i <= cnt; i++) {
printf("SCC %d : %d elements : \n", i, p[i].size());
sort(p[i].begin(), p[i].end());
for (int u : p[i])
printf("%d ", u);
printf("\n");
}
return 0;
}

浙公网安备 33010602011771号