图论

原来我没学过图论。

尝试把普及组+提高组的大部分图论内容重学。

定义啥的不写了。OI-Wiki 有详细的。

图的存储

一般来说有 3 种存图方法:

  • 直接存。例如 struct Edge { int a, b, w; }edges[N];

  • 邻接矩阵。即一个矩阵 \(g\),其中 \(g_{i, j}\) 表示 \(i, j\) 间的边数。如果无重边或重边无影响则 \(g\) 可以用 bool 存储。

  • 邻接表。存储以每个点为起点的所有边。一般两种写法:

    • vector<int> g[N]。动态数组。如果有边权可以 vector<pair<int, int>> g[N]

    • 链式前向星。

      // 初始化
      int h[N], e[M], ne[M], we[M], idx;
      memset(h, -1, sizeof h);
      idx = 0;
      
      // 加边
      void add(int a, int b, int c) {
      	e[idx] = b, ne[idx] = h[a], we[idx] = c, h[a] = idx ++ ;
      }
      
      // 访问以 u 为起点的所有出边 (u, v, w)
      for (int i = h[u]; ~i; i = ne[i]) {
      	int v = e[i], w = w[i];	
      }
      

      其中,e[i], we[i] 分别表示边-\(i\) 指向的点以及边权。h[i] 表示当前点-\(i\) 的所有出边中最大的边的编号。ne[i] 表示上一条与边-\(i\) 有相同起点的边。

其中:

  • 直接用数组存一般会在 Kruskal 等需要将边按边权排序时使用。
  • 邻接矩阵一般会在 Floyd 等平方及以上的算法中使用。
  • 邻接表的存图方法最常见,可用于除上述情况外几乎所有情景。两种邻接表的优劣分别是:
    • vector
      • 最好写的存图方法。
      • 可以快捷的将点的所有出边排序。
      • vector 内部常数较大,可能被毒瘤出题人卡常。
    • 链式前向星:
      • 代码较复杂,不过可以背下来。
      • 只能按照边加入的时间从晚到早遍历。
      • 常数略小。

最短路

给定一张有向图(无向图可以拆成两条有向边),边有边权。你需要求解源点到汇点的最短路。也就是你需要找一条从源点到汇点的路径,使得路径上边权和最小。

根据源点/汇点的数量,可将最短路算法归成两类:

  • 多源汇最短路。即源点汇点有多个,你需要求任意一个源点到任意一个汇点的最短路。特别的,Floyd,Johnson 算法中,源点、汇点集合均为点集全集。
  • 单源(多汇)最短路。即源点只有一个,你需要求从这个源点出发,到每个汇点的最短路。特别的,Dijkstra,Bellman-Ford 算法中,汇点集合是点集全集。

注意到我们省去了单源单汇和多源单汇。其中:

  • 单源单汇最短路存在没有意义。因为单源最短路完全包含它。
  • 多源单汇可以通过建反图的方式转化成单源多汇。

我们将按照这种分类介绍三种常见的最短路算法。

Floyd(多源汇最短路)

Floyd 是一种基于 DP 的算法。它的代码量最小,但是效率也最低。

\(f(u, v, k)\) 表示若我们只通过编号为 \(1 \sim k\) 的点(\(u, v\) 不受此限制),\(u \rightsquigarrow v\) 的最短路。

考虑转移。

若最短路不经过 \(k\),也就是所有中途的点编号均为 \(1 \sim k -1\)。那么应该从 \(f(u, v, k - 1)\) 转移过来。

否则,若最短路经过 \(k\),也就是我们先通过 \(1 \sim k - 1\) 的点从 \(u\)\(k\),再通过 \(1 \sim k - 1\) 的点从 \(k\)\(v\)。那么应该从 \(f(u,k,k-1)+f(k,v,k-1)\) 转移过来。

综上:

\[f(u, v, k) = \min f(u,k,k-1)+f(k,v,k-1) \]

for (int i = 1; i <= n; ++ i )
	for (int j = 1; j <= n; ++ j )
        for (int k = 0; k <= n; ++ k )
			f[k][i][j] = i == j ? 0 : INF;

for (auto edge : edges) {
	f[0][edge.from][edge.to] = min(f[0][edge.from][edge.to], edge.weight);
}

for (int k = 1; k <= n; ++ k )
	for (int i = 1; i <= n; ++ i )
		for (int j = 1; j <= n; ++ j )
			f[k][i][j] = min(f[k - 1][i][j], f[k - 1][i][k] + f[k - 1][k][j]);

时间空间复杂度均为 \(\Theta (n^3)\)。可以证明直接将 \(k\) 的一维去掉后,上面的过程仍然是正确的。空间复杂度降至 \(\Theta(n^2)\)

for (int i = 1; i <= n; ++ i )
	for (int j = 1; j <= n; ++ j )
		f[i][j] = i == j ? 0 : INF;

for (auto edge : edges) {
	f[edge.from][edge.to] = min(f[edge.from][edge.to], edge.weight);
}

for (int k = 1; k <= n; ++ k )
	for (int i = 1; i <= n; ++ i )
		for (int j = 1; j <= n; ++ j )
			f[i][j] = min(f[i][j], f[i][k] + f[k][j]);

传递闭包

一张有向图的传递闭包是指一个 \(01\) 矩阵 \(A = (a_{i,j})_{n\times n}\),其中 \(a_{i,j} = 1\) 表示 \(i\) 可以直接或间接地到达 \(j\)\(a_{i,j}=0\) 则表示不能。

事实上有显然的 \(\mathcal O(n^2)\) 做法:从每个点做 dfs。所以 Floyd 的意义何在??

类似 Floyd。设 \(f(u,v,k)\) 表示能否只通过 \(1 \sim k\) 的点(\(u, v\) 不受此限制)从 \(u\) 到达 \(v\)。转移:

\[f(u,v,k) = \bigvee f(u,k,k-1) \land f(k,v,k-1) \]

同理也可以将 \(k\) 的一维去掉。

for (int k = 1; k <= n; ++ k )
	for (int i = 1; i <= n; ++ i )
		for (int j = 1; j <= n; ++ j )
			a[i][j] |= a[i][k] && a[k][j];

时间复杂度 \(\Theta(n^3)\),空间复杂度 \(\Theta(n^2)\)

bitset 优化

尝试优化 \(j\) 的枚举。

显然最内层的 a[i][k]\(j\) 无关,可以提到前面判断。

for (int k = 1; k <= n; ++ k )
	for (int i = 1; i <= n; ++ i )
		if (a[i][k])
			for (int j = 1; j <= n; ++ j )
				a[i][j] |= a[k][j];

我们不放将每个 \(a_{i,1\sim n}\) 看作一个 bitset。那么最内层的 \(j\) 的循环,相当于将 \(a_i,a_k\) 这两个 bitset 按位或赋给 \(a_i\)。即:

bitset<int> a[N];

for (int k = 1; k <= n; ++ k )
	for (int i = 1; i <= n; ++ i )
		if (a[i][k]) a[i] |= a[k];

时间空间复杂度均被降至 \(\mathcal O(\frac{n^3}\omega)\),其中 \(\omega = 64\)\(\omega = 32\)

Dijkstra(单源最短路)

不妨设起点为 \(s\)

\(s\)\(u\) 的真实最短路是 \(D_u\),目前已经找到的最短路是 \(d_u\)。也就是说我们希望最终 \(d_u = D_u\)。初始化 \(d_s = 0\),其余 \(d_i = +\infty\)

我们定义 松弛 一条边 \((u, v, w)\) 是指,通过 \(s \rightsquigarrow u\) 的最短路以及 \(u \to v\) 这条边更新 \(s \rightsquigarrow v\) 的最短路,即 \(\operatorname{checkmin}(d_v, d_u + w)\)

将所有点分成两个集合。集合 \(S_1\) 存储所有已经找到最短路的点,\(S_2\) 存储未找到最短路的点。即 \(S_1 = \{u \mid d_u = D_u\}\)\(S_2 = \{u \mid d_u \ne D_u\}\)。初始 \(S_1 = \{s\},S_2 = V - \{s\}\)

算法流程是这样的:

  1. 取出 \(S_1\)\(d\) 最小的点。设其为 \(u\)。在 \(S_1\) 中删除 \(u\),并在 \(S_2\) 中加入 \(u\)。换言之我们断言 \(d_u= D_u\)

  2. 松弛 \(u\) 的所有出边。

  3. \(S_1\) 不为空,回到步骤 1。不难发现我们会执行 \(|V|\) 次这个流程。

为什么这样做是正确的?

不难发现我们只需要证明步骤 1 中取出的 \(u\) 是满足 \(d_u = D_u\)。考虑证明这一点。

读者自证不难。

如何快速取出 \(S_1\)\(d\) 最小的点?优先队列即可。

时间复杂度 \(\mathcal O(m \log m)\)

模板题 代码:

vector<int> get_dis(int s) {
	vector<int> dis(n, INF); vector<bool> st(n, false);
	priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> q;
	q.push({0, s}), dis[s] = 0;
	while (q.size()) {
		int u = q.top().second; q.pop();
		if (st[u]) continue; st[u] = true;
		for (Edge v : g[u])
			if (dis[v.to] > dis[u] + v.w)
				dis[v.to] = dis[u] + v.w, q.push({dis[v.to], v.to});
	}
	return dis;
}

值得说明的是,Dijkstra 只有在无负权边的基础上可以得到正确结果。

Bellman-Ford(单源最短路)

所以有负权边的图的最短路怎么求?Bellman-Ford。

Bellman-Ford 算法的精髓在于上面提到过的松弛操作。

如果图中没有负权回路(即边权和为负的回路,下称负环),那么 \(s\) 到任意点的最短路径中经过的边数一定 \(\le n - 1\)。这是因为,如果边数 \(\ge n\),那么点数必 \(\ge n + 1\),根据鸽巢原理一定存在至少一个节点被重复经过 \(\ge 2\) 次,也就是存在一个环。而因为图中没有负环,所以如果我们在路径上把这个环去掉,新路径的边权和一定比原路径更小。这与原路径最小矛盾。

所以我们可以对图中所有边松弛 \(n - 1\) 轮。或者说,我们重复对所有边松弛,直到没有节点的最短距离被更新时停止。而根据上面所说,在没有负环的情况下,我们最多松弛 \(n - 1\) 轮。此时就能得到每个点的真实最短路了。

正确性是显然的。时间复杂度 \(\mathcal O(nm)\)

代码:

vector<int> get_dis(int s) {
	vector<int> dis(n, INF);
	dis[s] = 0;
	for (int i = 0; i < n - 1; ++ i )
		for (Edge e : edges)
			dis[e.to] = min(dis[e.to], dis[e.from] + e.w);
	return dis;
}

Bellman-Ford 的优化:SPFA

若 SPFA 是标算的一部分,题目不应当给出 Bellman–Ford 算法无法通过的数据范围。—— OI-Wiki

所以 SPFA 不用学(?)

注意到如果上一轮松弛中,点 \(u\) 的最短距离没有发生变化,那么在新一轮的松弛里松弛 \(u\) 的出边就没有意义了。

所以我们用队列存储所有松弛后最短距离发生变化的点,每次只松弛队列内的点。类似 BFS。

时间复杂度 \(\mathcal O(kn)\),其中 \(k\) 是一个较小的常数。出题人可以轻易将 \(k\) 卡到 \(m\) 使 SPFA 死去。

vector<int> get_dis(int s) {
	vector<int> dis(n, INF);
	vector<bool> in_queue(n, false);
	queue<int> q;
	
	dis[s] = 0; q.push(s); in_queue[s] = true;
	while (q.size()) {
		int u = q.front();
		q.pop();
		for (Edge v : g[u])
			if (dis[v.to] > dis[u] + v.w) {
				dis[v.to] = dis[u] + v.w;
				if (!in_queue(v.to)) q.push(v.to), in_queue(v.to) = true;
			}
	}
	
	return dis;
}

负环

负环是指图中的一个环,且其边权和为负。

为什么我们要单独讨论这个问题呢?如果从 \(s\) 开始能到达某个负环,那么我可以在这个负环里转圈使得最短路变成负无穷。

考虑如何判断图中是否存在能从 \(s\) 走到的负环。

Bellman-Ford 里提到过,如果图中没有负环,那么松弛 \(n-1\) 轮后就一定能得到真实的最短路,即无法对任意一条边进行松弛。所以跑完 Bellman-Ford 后,我们只需要在枚举所有边一次,如果仍能松弛,即代表出现了负环。

bool negative_cycle(int s) {
	vector<int> dis(n, INF);
	dis[s] = 0;
	for (int i = 0; i < n - 1; ++ i )
		for (Edge e : edges)
			dis[e.to] = min(dis[e.to], dis[e.from] + e.w);
	for (Edge s : edges)
		if (dis[e.from] + e.w < dis[e.to]) return true;
	return false;
}

拓扑排序

一张有向图的一个拓扑排序是指一个将图中所有点的排列,使得对于任意边 \(u \to v\),在这个排列中都有 \(u\)\(v\) 更靠前出现。

不一定所有图都可以进行拓扑排序。具体的,拓扑排序存在的图被称为有向无环图(DAG),即无环的有向图。

不难发现 DP 实际上就是在进行拓扑排序。如果转移有环,即转移图就不是 DAG,所以不能进行拓扑排序,也就不能正确的 DP 了。

求一张 DAG 的拓扑排序的方法是这样的:

  • 维护队列 \(q\)。初始为空。

  • 将此时图中所有入度为 \(0\) 的点压入 \(q\)

  • 取出队首的点 \(u\) 并弹出。在原图中将 \(u\) 以及它的出边删掉(可以证明此时它没有入边)。

  • 重复操作 \(2,3\) 直到图中所有点都被删掉。

  • 所有点的入队/出队顺序即为一组合法的拓扑排序。

正确性显然。

queue<int> q;
vector<int> res;

for (int i = 0; i < n; ++ i )
	if (!d[i]) {
		q.push(i);
		res.push_back(i);
	}

while (q.size()) {
	int u = q.front();
	q.pop();
	
	for (int v : adj[u])
		if (! -- d[v]) {
			q.push(v);
			res.push_back(v);
		}
}

连通分量

posted @ 2024-10-03 22:28  2huk  阅读(11)  评论(0编辑  收藏  举报