图的搜索和遍历算法,即 BFS 和 DFS.
# 图的遍历
图的遍历是图的基本问题。其指的是如何通过某个特定的顺序或规则,完成对图中所有点的访问。更具体些,对一个图, 我们通常给出图上的一个源点 (source), 如何从 出发到达每一个点是我们关注的问题。我们希望得到一种通用的遍历规则。
关于此,我们常有两种方法,分别称为广度优先搜索 (breadth-first search, BFS) 和深度优先搜索 (depth-firth search, DFS)。图算法中的很多想法都源自这两种基本的图遍历方法。
# 广度优先搜索 (BFS)
# 算法代码和思路
我们用一个队列 Q
来记录要处理的节点,然后开一个布尔数组 vis[]
来标记是否已经访问过某个节点。
开始的时候,我们将所有节点的 vis
值设为 0
, 表示没有访问过;然后把起点 s
放入队列 Q
中并将 vis[s]
设为 1
.
之后,我们每次从队列 Q
中取出队首的节点 u
, 然后把与 u
相邻的所有节点 v
标记为已访问过并放入队列 Q
.
循环直至当队列 Q
为空,表示 BFS 结束。
下面给出广度优先搜索的代码:
void bfs(int u) { | |
while (!Q.empty()) Q.pop(); | |
Q.push(u); | |
vis[u] = 1; | |
d[u] = 0; | |
p[u] = -1; | |
while (!Q.empty()) { | |
u = Q.front(); | |
Q.pop(); | |
for (int i = head[u]; i; i = e[i].nxt) { | |
if (!vis[e[i].to]) { | |
Q.push(e[i].to); | |
vis[e[i].to] = 1; | |
d[e[i].to] = d[u] + 1; | |
p[e[i].to] = u; | |
} | |
} | |
} | |
} | |
void restore(int x) { | |
vector<int> res; | |
for (int v = x; v != -1; v = p[v]) { | |
res.push_back(v); | |
} | |
std::reverse(res.begin(), res.end()); | |
for (int i = 0; i < res.size(); ++i) printf("%d", res[i]); | |
puts(""); | |
} |
# 性质
# 复杂度
- 时间复杂度:.
- 空间复杂度:.
# 深度优先搜索
# 算法描述
对最新发现的顶点,如果存在以其为起点但未探测到的边,就继续探测下去。当所有顶点都被探索后,就按照发现该顶点的顺序进行回溯,直到某个顶点存在新的为探测到的边。直到所有的顶点都被探测之后,算法停止。这样的算法称为深度优先搜索 (depth-first search, DFS)。
在深度优先搜索中,每次扫描顶点 的邻接表发现新顶点 时,将 的前辈 取 . 这样,所有顶点的先辈子图形成一个森林,称为深度优先林 (depth-first forest). 深度优先林中的每一棵树称为深度优先树 (depth-first tree).
此外,在深度优先搜索中,我们在搜索过程中为每个顶点加两个时间戳。当发现一个顶点 (将顶点 置灰色) 时,为其添加时间戳 . 当结束检查 的邻接表 (将顶点 置黑色) 时,为其添加时间戳 . 这样的时间戳为深度优先搜索的拓展起了很大的作用。
下面,我们采用代码表示深度优先搜索的过程:
// initialize | |
for (Vertex u : G) { | |
u.color = WHITE; | |
u.pi = null; | |
} | |
int time = 0; | |
for (Vertex u : G) { | |
if (u.color == WHITE) { | |
dfs_visit(u); | |
} | |
} | |
void dfs_visit(Vertex u) { | |
u.color = GRAY; | |
u.d = ++time; // discovered time | |
for (Vertex v : u.adj) { // vertex in u's adjacent nodes | |
if (v.color == WHITE) { | |
v.pi = u; | |
dfs_visit(v); | |
} | |
} | |
u.color = black; | |
u.f = ++time; // finished time | |
} |
注意到,我们维护的域越多,可以得到的(冗余)信息也就越多。如果只是单纯进行图遍历,那么发现和完成时刻是不需要维护的。由此,我们可以写一个简单的 DFS. (就是一个迭代)
struct vertex { | |
int index; | |
int label; // if visited | |
vector<vertex*> adj; | |
}; | |
void dfs(vertex* root) { | |
// do something | |
for (auto v : root) { | |
if (v.label == 0) dfs(v); | |
} | |
} |
# 性质
# 复杂度
- 时间复杂度:.
- 空间复杂度:.
# 括号定理
# 白色路径定理
在一个图 的深度优先森林中,顶点 是顶点 的后裔,当且仅当搜索过程中于时刻 发现 时,可以从顶点 出发,经过一条完全由白色顶点组成的路径到达.
# 深度优先林中边的分类
在深度优先林中,我们对反向边进行分类。可以分为以下四种:
- 树边 (tree edge):
- 后向边 (back edge)
- 前向边
- 交叉边
# 应用
- 连通性判断
- 判环
- 拓扑排序
# DAG & 拓扑排序
# DAG
有向无环图 (directed acyclic graph, DAG).
# 拓扑排序
采用 DFS 实现。返回时间的倒序就是一个可行的拓扑序。
# 强连通性
# 强连通图
# Kosaraju 算法
是一个在线性时间 内寻找一个有向图中的强连通分量的算法。
算法如下:
- 对图中的每个顶点 , 标记
vis[u] = 0
(unvisited). 同时创建一个存放顶点的空栈L
. - 对图中的每个顶点 , 执行
visit(u)
, 其中visit(u)
定义如下:void visit(Vertex u) {
if (vis[u] == 0) {
vis[u] = 1; // visited
for (Vertex v : u.outNeighbor) visit(v);
L.push(u);
}
}
- 对
L
中的每个顶点 , 执行assign(u, u)
,
For each vertex u of the graph, mark u as unvisited. Let L be empty.
For each vertex u of the graph do Visit(u), where Visit(u) is the recursive subroutine:
If u is unvisited then:
Mark u as visited.
For each out-neighbour v of u, do Visit(v).
Prepend u to L.
Otherwise do nothing.
For each element u of L in order, do Assign(u,u) where Assign(u,root) is the recursive subroutine:
If u has not been assigned to a component then:
Assign u as belonging to the component whose root is root.
For each in-neighbour v of u, do Assign(v,root).
Otherwise do nothing.
# Tarjan 算法
此处的 Tarjan 算法是求强连通分量的 Tarjan 算法。
Tarjan 算法的核心是维护两个标志量:
dfn[u]
: 表示 DFS 中进入节点u
的时刻。low[u]
: 表示 DFS 中,u
的子树中能够回溯到的最早的已经在栈中的结点,即dfn
值最小的返祖边指向的点。
于是,从根开始的一条路径上的 dfn
严格递增, low
严格非降。
void TarjanSearch(int u) { | |
vis[u] = true; | |
low[u] = dfn[u] = ++dfncnt; | |
visStack.push(u); | |
for(int v=0;v<n;v++) { | |
if (edge[u][v] == 1) { | |
if (!vis[v]) { | |
TarjanSearch(v); // 搜索 | |
low[u]=min(low[u],low[v]); // 回溯 | |
} | |
else if (visStack.has(v)) | |
low[u]=min(low[u],dfn[v]); | |
} | |
} | |
} |
若存在 dfn[u] == low[u]
, 那么 u
及其上方的节点 (直到 u
本身) 构成一个强连通分支。