写在前面
在学习数据结构的图论时,有一类问题是很容易在现实生活中找到对应情况的问题,即最小路径问题,而对于其他的问题算法,如最小生成树等,我常常会困惑于其会应用于何种实际情况的求解,后面脱离了算法学习之后,这个问题算是搁置了下来。在接触到数学建模之后,我逐渐理解到为什么要在数学学科之外增设一个数学建模的课程,我逐渐明白之前对于算法应用的问题的产生背后是对算法工具认知的不足,当我对数学工具的认知能抽象到一定程度的时候,数学与应用的问题往往不攻自破,而这也是数学与数学建模之间的关系。
图与网络的发明是为了能够抽象的表示现实生活中的路线或者网络问题,但点与边的设计并不一定需要表示实体,在解决问题的过程中,一些规划问题或者可以对应到图论中的特征的问题都可以尝试用图论的知识来进行建模和求解,所以在图论建模上,抽象思维很重要。
图论与代数结构
图的基础概念在这里我们不多加叙述,这里我想谈一下图与树的关系。
树是一种特殊的图,它没有回路(即无环),而且有且只有一个根节点。因此,对于树的深度优先遍历(Depth First Search, 简称DFS)可以用递归实现。
在遍历的过程中,我们首先访问根节点,并将其标记为已访问。然后递归地访问所有子节点,直到遍历完整个子树;递归返回之后,再访问其其他未访问过的子节点。
这种实现方式非常简单易懂,易于理解和实现。通过递归调用函数,可以轻松地实现树的深度优先遍历,并且不需要使用复杂的数据结构来维护遍历过程中的状态,并且代码简洁易懂。而且,在树中,由于每个节点都只有一个父节点,因此不会存在重复访问的问题。因此,使用递归实现树的深度优先遍历是可行的。
但是需要注意,如果树的深度非常大(比如超过递归深度限制),或者树的度很大(即每个节点有很多个孩子),那么使用递归的方式实现遍历可能会导致栈溢出或者性能问题,建议使用非递归实现方式来进行树的遍历。
当使用递归遍历树的深度优先遍历时,系统会为每个递归函数调用创建一个栈帧(stack frame)。每个栈帧包含了函数的局部变量、参数以及返回地址等信息。
当树的深度很大或者树的度很大时,递归调用的次数也会很多,导致系统的栈空间被充满。如果此时还有更多的函数调用请求,就无法在栈空间中分配更多的空间了,从而导致栈溢出。
换句话说,如果树的深度比较大,递归的调用次数也会较多。而每一次递归调用都会占用一定的系统栈空间,当递归深度增大时,系统栈空间会被逐步占满,从而导致栈溢出。
为避免栈溢出问题,我们可以使用非递归的方式实现树的深度优先遍历。在非递归实现方式中,我们可以使用栈数据结构,手动模拟压栈和出栈的过程,而不是依赖系统的栈空间。这样,就可以避免递归过程中频繁的函数调用,从而减少栈空间的占用,避免栈溢出的问题。
也就是说,我们可以总结出一个结论:对图使用DFS,就可以得到它的强连通分量或者连通块。
如果对一个图使用深度优先搜索(DFS),将会遍历它的每一个连通块,并且会访问每个节点。在搜索的过程中,我们会从一个起点开始搜索,访问所有与其直接或间接相连的节点,将已经访问过的节点标记为已访问并添加到一个已访问的集合中。如果搜索到一个节点的所有邻居节点都被访问过,并且该节点没有其他未访问的邻居节点,那么该节点的搜索过程就结束了。
通过DFS,我们可以得到以下几个信息:
- 图中每个连通块的顶点集合;
- 以给定节点为起点的遍历顺序;
- 判定图是否为一棵树(无环连通图),如果不是则可以找到环(因为DFS遍历过程中会维护每一个节点的访问状态,如果在访问过程中某个节点的邻居节点已经被访问而且不是该节点的父节点,那么就说明存在环);
- 基于DFS的搜索算法(如迷宫问题和拓扑排序)。
基于此,我们可以一个简单的求图中的所有强连通分量的算法——Tarjan算法
Tarjan算法是一种常用于寻找有向图强连通分量的算法。算法的核心思想是用DFS遍历图,并在遍历过程中记录每个节点的追溯值(low-link值),判断是否存在强连通分量。追溯值是DFS的副产品,用于判断当前节点是否属于某个强连通分量。
在Tarjan算法中,对于图中的每个节点,都会分配一个唯一的追溯值,并在追溯过程中进行更新。具体而言,当我们访问一个节点时,Tarjan算法会先将该节点的追溯值和DFS序号设置为相同的值,并将其标记为访问过。然后,对于该节点的每一个未访问的邻居节点,我们会递归地调用它们的DFS过程,并在这个过程中更新邻居节点的追溯值。在返回时,如果邻居节点的追溯值小于当前节点的追溯值,那么我们将当前节点的追溯值更新为邻居节点的追溯值。
最终,当遍历完整个图之后,一个强连通分量中的所有节点的追溯值应该相同,也就是说,它们属于同一个强连通分量。如果一个节点的追溯值等于自己的DFS序号,那么说明该节点是一个强连通分量的根节点,它所在的连通分量中的所有节点的追溯值都等于自己的DFS序号。
因此,Tarjan算法的追溯值起到了判断当前节点是否属于某个强连通分量的作用。并且,Tarjan算法的追溯值可以非常高效和精确地计算出所有强连通分量的信息。
值得注意的是,追溯值只是一个标号,而不是一个具体计算出来的值,在程序设计的时候,我们通常使用搜索的时候根节点的标号作为追溯值。
给定一张 n 个点 m 条边的无向图,点的编号从 1到n ,可能存在重边和自环,不保证是一张连通图。现在,请你求出这张图所有的块。
以下是一个使用C++实现的求解无向图所有块的示例代码。该代码使用深度优先搜索(DFS)算法,采用邻接表来表示无向图,时间复杂度为O(n+m)。
```c++
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 100010;
int n, m;
vector<int> g[MAXN]; // 邻接表表示无向图
int vis[MAXN], order[MAXN], low[MAXN];
int tot = 0, idx = 0;
void dfs(int u) { // DFS搜索函数
vis[u] = 1;
order[u] = idx++;
low[u] = order[u];
for (int i = 0; i < g[u].size(); i++) { // 枚举u的各个邻居节点
int v = g[u][i];
if (!vis[v]) { // v尚未被访问过
dfs(v); // 递归搜索v
low[u] = min(low[u], low[v]);
if (low[v] >= order[u]) { // u是块的起点
printf("Block %d: ", ++tot);
while (1) {
int w = stk.top();
stk.pop();
printf("%d ", w);
if (w == v) break;
}
printf("\n");
}
} else { // v已被访问过
low[u] = min(low[u], order[v]);
}
}
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 0; i < m; i++) {
int u, v;
scanf("%d %d", &u, &v);
g[u].push_back(v);
g[v].push_back(u);
}
for (int i = 1; i <= n; i++) { // 枚举所有节点
if (!vis[i]) { // 如果i尚未被访问过
dfs(i); // 从i开始搜索
printf("Block %d: %d\n", ++tot, i);
}
}
return 0;
}
该代码使用了以下数组和变量:
- g:邻接表,用vector数组实现,其中g[i]表示与节点i相邻的所有节点。
- vis:标记数组,标记每个节点是否被访问过。未被访问的节点标记为0,已被访问的节点标记为1。
- order:记录DFS序,它表示每个节点第一次被访问的时间。在DFS的过程中,如果一个节点u第一次被搜索到,那么我们就将它自增,并把当前DFS序赋值给order[u]。
- low:记录追溯值,以检测块。在DFS的过程中,如果一个节点u第一次被搜索到,那么我们将low[u]初始化为order[u]。当v为u的一个邻居,且v尚未被访问时,我们递归地搜索v,并更新low[u]为min(low[u], low[v])。如果v已被访问,我们更新low[u]为min(low[u], order[v])。如果low[v] >= order[u],则说明u的所有子孙节点都无法回到u的祖先节点,u是一个块的起点。
- tot:记录块的数量。
- idx:记录DFS顺序,初始化为0。
在这个示例代码中,我们通过邻接表来存储整个无向图。对于每个未被访问的节点,我们从它开始进行DFS搜索,并识别块的起点。最后,我们输出所有块的编号及包含的节点。这个算法的时间复杂度是O(n+m),其中n为节点数,m为边数,因为每条边和每个节点只会被遍历一次。