图中的概念很多,包括有向图和无向图、完全图和有向完全图、稀疏图和稠密图、入度和出度、路径和简单路径、回路(环)、(强)连通图和(强)连通分量、生成树和生成森林等,还有哈密顿图、欧拉图等又特殊性质的图。面试中考核的是两种搜索算法:深度优先搜索和广度优先搜索。
一、图的存储结构
图是由 ( V , E ) (V,E) ( V , E ) 来表示的,对于无向图来说,其中 V = ( v 0 , v 1 , … , v n ) V=(v_0, v_1, \ldots, v_n) V = ( v 0 , v 1 , … , v n ) , E = { ( v i , v j ) ( 0 ≤ i , j ≤ n 且 i 不等于 j ) } E=\{ (v_i, v_j)(0\le i,j \le n\text{且}i\text{不等于}j)\} E = {( v i , v j ) ( 0 ≤ i , j ≤ n 且 i 不等于 j )} ,对于有向图, E = { < v i , v j > ( 0 ≤ i , j ≤ n 且 i 不等于 j ) } E=\{<v_i, v_j>(0 \le i, j \le n\text{且}i\text{不等于}j)\} E = { < v i , v j > ( 0 ≤ i , j ≤ n 且 i 不等于 j )} 。V 是顶点的集合,E 是边的集合。
1、邻接矩阵
无向图的邻接矩阵是对称矩阵,n个顶点的无向图需要 n × ( n + 1 ) / 2 n\times(n+1)/2 n × ( n + 1 ) /2 个空间大小
有向图的邻接矩阵不一定对称,n个顶点的有向图需要 n 2 n^2 n 2 的存储空间
无向图中第 i 行的非零元素的个数为顶点 V i V_i V i 的度
有向图中第 i 行的非零元素的个数为顶点 V i V_i V i 的出度,第 i 列的非零元素的个数为顶点 V i V_i V i 的入度
无向图顶点 Vi 的度为第 i 个单链表中的结点数
无向图中顶点 Vi 的出度为第 i 个单链表中的结点个数,顶点 Vi 的入度为全部单链表中连接点域值是 i 的结点个数
逆邻接表:有向图中对每个结点建立以 Vi 为头的弧的单链表
邻接矩阵和邻接表
在边稀疏( e < < n × ( n + 1 ) / 2 e << n\times(n+1)/2 e << n × ( n + 1 ) /2 )的情况下,用邻接表表示图比邻接矩阵节省存储空间,当与边相关的信息较多时更是如此
在邻接表上容易找到任何一顶点的第一个邻接点和下一个邻接点,但要判定任意两个顶点( v v v 和 v 1 v_1 v 1 )之间是否有边或弧相连,则需搜索第 i 个或第 j 个链表,因此,不及邻接矩阵方便。
边结点和顶点结点如下示:
其中tailvex
是指弧起点在顶点表的下标,headvex
是指弧终点在顶点表的下标。 headlink
是指入边表指针域,指向终点相同的下一条边,taillink
是指边表指针域,指向起点相同的下一条边。 如果是网,我们还要在其中加入权值域,来存储权值。
firstin
表示入边表头指针,指向该顶点的入边表中第一个结点。 firstout
表示出边表头指针,指向该顶点的出边表中第一个结点。
横向是出度,纵向是入度。
邻接多重表的结构和十字链表类似。边结点和顶点结点如下示:
边结点由6个域组成:mark
为标志域,可标记这条边是否被搜索过; ivex
和jvex
为该边依附的两个顶点在图中的位置;ilink
指向下一条依附于顶点ivex
的边;jlink
指向下一条依附于顶点jvex
的边,info
为指向和边相关的各种信息的指针域。顶点结点由2个域组成:data
存储和该顶点相关的信息如顶点名称;firstedge
域指示第一条依附于该顶点的边。
深度优先搜索思想: 假设初始状态是图中所有顶点均未被访问,则从某个顶点 v 出发,首先访问该顶点,然后依次从它的各个未被访问的邻接点出发深度优先搜索遍历图,直至图中所有和 v 有路径相通的顶点都被访问到。若此时尚有其他顶点未被访问到,则另选一个未被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
广度优先搜索思想: 从图中某顶点 v 出发,在访问了 v 之后依次访问 v 的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使得“先被访问的顶点的邻接点先于后被访问的顶点的邻接点被访问,直至图中所有已被访问的顶点的邻接点都被访问到。如果此时图中尚有顶点未被访问,则需要另选一个未曾被访问过的顶点作为新的起始点,重复上述过程,直至图中所有顶点都被访问到为止。
设G=(V,E)
是连通的无向图,如果V中顶点a是一个关节点
,若 V 中有顶点v,w
使得v,w,a
各不相同且 v和 w 之间的每条路都包含 a。换言之,如果删除a和与之相邻的所有边时,就会把图的一个连通分量拆分成多个连通分量。而若对V中每个不同的三元组v,w,a
,在v和w之间都存在一条不包含a的路径,则说G是双连通
的。因此,仅当一个连通的无向图没有关节点时,它才是双连通的。
边e1
和e2
等价,若e1=e2
或者有一条环路既包含e1
又包含e2
,则称边e1
和e2
是等价的。
假设 V i V_i V i 是 E i E_i E i 中各边都彼此连接的顶点集(或者利用边的等价性来划分等价类),则每个图 G i = ( V i , E i ) G_i=(V_i, E_i) G i = ( V i , E i ) 叫做 G 的一个双连通分量 。
对所有的 i , j i,j i , j (不相等), V i V_i V i 和 V j V_j V j 最多一起包含一个顶点;
当且仅当 v 是 V i V_i V i 和 V j V_j V j 共同包含的顶点时,v 是 G 的一个关节点。
求关节点
假设对连通无向图G进行深度优先搜索和深度优先编号,产生深度优先生成树 S = ( V , T ) S=(V,T) S = ( V , T ) 和回退边 B。
性质:如果一个顶点 v 的真子孙的所有回退边都指向 v 或者比 v 更深层次(离树根更远)的顶点,则 v 是关节点。若有的回退边指向 v 的真祖先,则 v 不是关节点。
算法步骤:
对图进行深度优先搜索,计算每个顶点v的深度优先编号 dfn[v]
,形成深度优先生成树
计算所有顶点 v 的low[v]
编号是在深度优先生成树上按后根遍历顺序进行的。low[v]
取下述三值的最小者:
计算完low
编号后,求关节点,根据性质,可知:
非树根顶点 v 是关节点,当且仅当 v 有某个儿子 y,使low[y] >= dfn[v]
,这里其实就是说其真子孙的回退边都指向 v 或者比 v 更深层次的顶点
有向图的一个强连通分量是该图中顶点的一个最大子集:其中的任意两个顶点 x 和 y,存在 x 到 y 的路径,也存在 y 到 x 的路径。令 G = ( V , E ) G=(V,E) G = ( V , E ) 是一个有向图,将V分割成若干等价类 V i ( l < = i < = r ) V_i(l<=i<=r) V i ( l <= i <= r ) ,使得 V i V_i V i 中的 v 和 w 等价的充要条件是有一条路径从 v 到 w,也存在一条路径从 w 到 v。令 E i ( l ≤ i ≤ r ) E_i(l\le i\le r) E i ( l ≤ i ≤ r ) 是头、尾均在 V i V_i V i 的边集,则 G i = ( V i , E i ) G_i=(V_i, E_i) G i = ( V i , E i ) 是 G 的强连通分量 ,简称强分量。把只具有一个强连通分量的有向图称为强连通图 。
连接两个强分量的边叫做分支横边
。通过构造G的归约图 ,可以展示各强分量间的联系。归约图中每个强分量用一个顶点表示,显然,归约图中不存在环路。
Kosaraju
算法步骤:
对有向图 G 进行深度优先搜索并且对顶点进行逆编号 (即记录它们的离开时间)。
将 G 中的每条边取反方向,构造一个新有向图 Gr。
根据前面的编号,从编号最大的顶点开始对 G,进行一次深度优先搜索,凡是能到达的所有顶点,都形成一棵深度优先搜索树;若本次搜索没有到达所有顶点,从图中删除这些顶点及相连的边,继续重复该动作。
在 Gr 的深度优先森林中,每棵树对应 G 的一个强连通分量。
图的连通性判断方法主要有:并查集、 DFS
、 BFS
、 WARSHALL
。
最短路径树(SPT):在加权有向图中,有一个顶点 s,以 s 为起点的最短路径树是图的一幅子图,包含 s 和从 s 到达的所有顶点。这棵树的根结点是 s,树的每条路径都是有向图中的一条最短路径。
Dijkstra单源最短路径算法,即计算从起点出发到每个点的最短路径。所以Dijkstra常常作为其他算法的预处理,使用邻接矩阵的时间复杂度为 O ( n 2 ) O(n^2) O ( n 2 ) ,用优先队列的复杂度为 O ( ( m + n ) l o g n ) O((m+n)logn) O (( m + n ) l o g n ) ,近似为 O ( m l o g n ) O(mlogn) O ( m l o g n ) 。
基本思想:每次找到离源点(如1号结点)最近的一个顶点,然后以该顶点为中心进行扩展,最终得到源点到其余所有点的最短路径。
基本步骤:
设置标记数组book[]
:将所有的顶点分为两部分,已知最短路径的顶点集合 P 和未知最短路径的顶点集合 Q,很显然最开始集合 P 只有源点一个顶点。 b o o k [ i ] book[i] b oo k [ i ] 为 1 表示在集合 P 中;
设置最短路径数组 d s t [ ] dst[] d s t [ ] 并不断更新:初始状态下,令 d s t [ i ] = e d g e [ s ] [ i ] dst[i] = edge[s][i] d s t [ i ] = e d g e [ s ] [ i ] ,很显然此时 d s t [ s ] = 0 dst[s]=0 d s t [ s ] = 0 , b o o k [ s ] = 1 book[s]=1 b oo k [ s ] = 1 。此时,在集合 Q 中可选择一个离源点 s 最近的顶点 u 加入到 P 中。并依据以u为新的中心点,对每一条边进行松弛操作(松弛是指由结点 s − > j s->j s − > j 的途中可以经过点 u,并令 d s t [ j ] = m i n { d s t [ j ] , d s t [ u ] + e d g e [ u ] [ j ] } dst[j]=min\{dst[j], dst[u]+edge[u][j]\} d s t [ j ] = min { d s t [ j ] , d s t [ u ] + e d g e [ u ] [ j ]} ),并令 b o o k [ u ] = 1 book[u]=1 b oo k [ u ] = 1 ;
在集合 Q 中再次选择一个离源点 s 最近的顶点 v 加入到 P 中。并依据 v 为新的中心点,对每一条边进行松弛操作(即 d s t [ j ] = m i n { d s t [ j ] , d s t [ v ] + e d g e [ v ] [ j ] } dst[j]=min\{dst[j], dst[v]+edge[v][j]\} d s t [ j ] = min { d s t [ j ] , d s t [ v ] + e d g e [ v ] [ j ]} ),并令 b o o k [ v ] = 1 book[v]=1 b oo k [ v ] = 1 ;
复制 #include <vector>
#include <iostream>
using namespace std;
const int MAX_LEN = 10001;
// 最短路径长度 Dijkstra
void Dijkstra(int N, int S, vector<int>& dst, vector<vector<int>>& graph){
vector<bool> book(N + 1, false);
book[S] = true;
for(int i = 1; i <= N; ++i)
dst[i] = graph[S][i];
dst[S] = 0;
// 迭代 N - 1 次
for(int i = 1; i < N ; ++i){
int u = S;
int tmp = MAX_LEN;
// 找 u
for(int j = 1; j <= N; ++j){
if(!book[j] && tmp > dst[j]){
tmp = dst[u = j];
}
}
if(tmp == MAX_LEN)
break;
// 松弛边
book[u] = true;
for(int j = 1; j <= N; ++j){
if(!book[j] && graph[u][j] != MAX_LEN){
int d = dst[u] + graph[u][j];
if(d < dst[j])
dst[j] = d;
}
}
}
}
int main(void){
int N = 0, M = 0, S = 0, T = 0;
while(cin >> N >> M >> S >> T){
vector<int> dst(N + 1, MAX_LEN);
vector<vector<int>> graph(N + 1, vector<int>(N + 1, MAX_LEN));
int u, v, len;
while(M-- > 0){
cin >> u >> v >> len;
if(len < graph[u][v]){
graph[u][v] = graph[v][u] = len;
}
}
Dijkstra(N, S, dst, graph);
cout << dst[T] << endl;
}
return 0;
}
SPFA
是bellman-ford的改进算法(队列实现),效率也更高,故直接介绍SPFA
。 相比于Dijkstra
,SPFA
可以计算带负环的回路。 邻接表的复杂度为: O ( k E ) O(kE) O ( k E ) 。E 为边数,k 一般为2或3。
bellman-ford算法的基本思想是,对图中除了源顶点 s 外的任意顶点 u,依次构造从 s 到 u 的最短路径长度序列 d i s t [ u ] , d i s 2 [ u ] , … , d i s ( n − 1 ) [ u ] dist[u], dis2[u], \ldots, dis(n-1)[u] d i s t [ u ] , d i s 2 [ u ] , … , d i s ( n − 1 ) [ u ] ,其中 n 是图 G 的顶点数,dis1[u]
是从 s 到 u 的只经过 1条边的最短路径长度,dis2[u]
是从 s 到 u 的最多经过 G 中2条边的最短路径长度,当图 G 中没有从源可达的负权图时,从 s 到 u 的最短路径上最多有 n − 1 n-1 n − 1 条边。因此, dist(n-1)[u]
就是从 s 到 u 的最短路径长度,显然,若从源 s 到 u 的边长为 e ( s , u ) e(s,u) e ( s , u ) ,则 d i s 1 [ u ] = e ( s , u ) dis1[u]=e(s,u) d i s 1 [ u ] = e ( s , u ) 。对于 k > 1 k>1 k > 1 ,dis(k)[u]
满足如下递归式, d i s ( k ) [ u ] = m i n { d i s ( k − 1 ) [ v ] + e ( v , u ) } dis(k)[u]=min\{dis(k-1)[v]+e(v,u)\} d i s ( k ) [ u ] = min { d i s ( k − 1 ) [ v ] + e ( v , u )} 。bellman-ford最短路径就是按照这个递归式计算最短路的。
SPFA
的实现如下:用数组dis记录更新后的状态,cnt
记录更新的次数,队列 q 记录更新过的顶点,算法依次从 q 中取出待更新的顶点 v,按照 d i s ( k ) [ u ] dis(k)[u] d i s ( k ) [ u ] 的递归式计算。在计算过程中,一旦发现顶点 K 有 c n t [ k ] > n cnt[k]>n c n t [ k ] > n ,说明有一个从顶点 K 出发的负权圈,此时没有最短路,应终止算法。否则,队列为空的时候,算法得到 G 的各顶点的最短路径长度。
全称Floyd-Warshall
。在离散数学里叫做Warshall
算法,用来计算传递闭包。这个算法用于求所有点对的最短距离。比调用 n 次dijkstra
的优点在于代码简单。 时间复杂度为 O ( n 3 ) O(n^3) O ( n 3 ) 。
最小生成树有两个经典算法:
如果一幅图是非连通的,则只能用这个算法计算所有连通分量的最小生成树,合并在一起叫做最小生成森林 。还有几点要注意的:
两个性质:
用一条边连接树中的任意两个顶点都会产生一个新的环。
图的一种切分是把图的所有顶点分为两个 非空 且 不重复 的集合。横切片是一条连接两个属于不同集合的边。通常,我们指定一个顶点集,然后隐式地认为它的补集是另一个顶点集。给定任意的切分,它的横切边中的权重最小者必然属于图的最小生成树。假设所有边的权重不相同,则每幅连通图都只有唯一的最小生成树。
Prim算法能够得到任意加权无向图的最小生成树。每一步都会为成长中的树加一条边。
Lazy实现:
初始化起始点到可达顶点的距离数组,并标记起始点已访问,找出起始点到可达结点的代价最小的边,将这个边加入 sum,这条边对应一个可达顶点,设置这个顶点已访问visited[pos] = true
。
再到 graph 中去找该顶点可达的顶点(未被访问且当前距离大于新的距离;!visited[i] && distance[i] > G[pos][i]
),那么更新这个当前距离(因为要取代价小的边),否则不更新;
由于除去起始点本身,所以循环 N - 1
次。(题目链接 )
复制 #include <iostream>
#include <vector>
using namespace std;
const int MAX_LEN = 100001;
int Prim(int N, std::vector<int>& dst, std::vector<std::vector<int>>& graph){
vector<bool> visited(N + 1, false);
visited[1] = true;
for(int i = 1; i <= N; ++i) {
dst[i] = graph[1][i];
}
int result = 0;
for(int i = 1; i < N; ++i){
int tmp = MAX_LEN;
int u = 1;
for(int j = 1; j <= N; ++j){
if(!visited[j] && tmp > dst[j]){
tmp = dst[u = j];
}
}
if(tmp == MAX_LEN)
break;
visited[u] = true;
result += tmp;
for(int j = 1; j <= N; ++j){
if(!visited[j] && dst[j] > graph[u][j]){
dst[j] = graph[u][j];
}
}
}
return result;
}
int main(){
int N = 0;
while(cin >> N){
int val = 0;
vector<vector<int>> graph(N + 1, vector<int>(N + 1, MAX_LEN));
for(int i = 1; i <= N; ++i){
for(int j = 1; j <= N; ++j){
cin >> val;
graph[i][j] = val;
}
}
vector<int> dst(N + 1, MAX_LEN);
cout << Prim(N, dst, graph) << endl;
}
return 0;
}
Eager实现:
Kruskal
算法的思想是按照边的权重顺序(从小到大)加入到树中,加入的边不会构成环。
(题目链接 )
c++14 c++11 常规操作
复制 #include <iostream>
#include <vector>
#include <algorithm>
#include <functional>
#include <memory>
using namespace std;
struct Edge{
int st;
int ed;
int cost;
Edge(int s, int e, int c) : st(s), ed(e), cost(c) {}
};
int Kruscal(std::vector<int>& trees, std::vector<Edge>& graph){
int result = 0;
auto recursiveFunc =
std::make_shared<std::unique_ptr< std::function<int(int)> >>();
*recursiveFunc = std::make_unique<std::function<int(int)>>(
[=, &trees] (int a){
return a == trees[a] ? a : (trees[a] = (**recursiveFunc)(trees[a]));
}
);
for(auto edge : graph){
if((**recursiveFunc)(edge.st) != (**recursiveFunc)(edge.ed)){
result += edge.cost;
trees[(**recursiveFunc)(edge.st)] = (**recursiveFunc)(edge.ed);
}
}
return result;
}
int main(){
int N = 0, M = 0;
int N1, N2, V;
while(cin >> N >> M){
vector<Edge> graph;
while(M-- > 0){
cin >> N1 >> N2 >> V;
Edge edge(N1, N2, V);
graph.push_back(edge);
}
// 排序
sort(graph.begin(), graph.end(),
[](const Edge& a, const Edge& b)->bool {return a.cost < b.cost; });
vector<int> root(N + 1);
for(int i = 1; i <= N; ++i){
root[i] = i;
}
cout << Kruscal(root, graph) << endl;
}
return 0;
}
复制 #include <iostream>
#include <vector>
#include <algorithm>
#include <functional>
#include <memory>
using namespace std;
struct Edge{
int st;
int ed;
int cost;
Edge(int s, int e, int c) : st(s), ed(e), cost(c) {}
};
int Kruscal(std::vector<int>& trees, std::vector<Edge>& graph){
int result = 0;
auto recursiveFunc = std::shared_ptr<std::unique_ptr< std::function<int(int)> >>
(new std::unique_ptr< std::function<int(int)> >());
*recursiveFunc = std::unique_ptr<std::function<int(int)>>(
new std::function<int(int)>(
[=, &trees] (int a){
return a == trees[a] ? a : (trees[a] = (**recursiveFunc)(trees[a]));
}
)
);
for(auto edge : graph){
if((**recursiveFunc)(edge.st) != (**recursiveFunc)(edge.ed)){
result += edge.cost;
trees[(**recursiveFunc)(edge.st)] = (**recursiveFunc)(edge.ed);
}
}
return result;
}
int main(){
int N = 0, M = 0;
int N1, N2, V;
while(cin >> N >> M){
vector<Edge> graph;
while(M-- > 0){
cin >> N1 >> N2 >> V;
Edge edge(N1, N2, V);
graph.push_back(edge);
}
// 排序
sort(graph.begin(), graph.end(),
[](const Edge& a, const Edge& b)->bool {return a.cost < b.cost; });
vector<int> root(N + 1);
for(int i = 1; i <= N; ++i){
root[i] = i;
}
cout << Kruscal(root, graph) << endl;
}
return 0;
}
复制 #include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge{
int st;
int ed;
int cost;
Edge(int s, int e, int c) : st(s), ed(e), cost(c) {}
};
int Find(std::vector<int>& pre, int x){
return x == pre[x] ? x : pre[x] = Find(pre, pre[x]);
}
void Union(std::vector<int>& pre, int x,int y){
pre[Find(pre, x)] = Find(pre, y);
}
int Kruscal(std::vector<int>& trees, std::vector<Edge>& graph){
int result = 0;
for(auto edge : graph){
if(Find(trees, edge.st) != Find(trees, edge.ed)){
result += edge.cost;
Union(trees, edge.st, edge.ed);
}
}
return result;
}
int main(){
int N = 0, M = 0;
int N1, N2, V;
while(cin >> N >> M){
vector<Edge> graph;
while(M-- > 0){
cin >> N1 >> N2 >> V;
Edge edge(N1, N2, V);
graph.push_back(edge);
}
// 排序
sort(graph.begin(), graph.end(),
[](const Edge& a, const Edge& b)->bool {return a.cost < b.cost; });
vector<int> root(N + 1);
for(int i = 1; i <= N; ++i){
root[i] = i;
}
cout << Kruscal(root, graph) << endl;
}
return 0;
}
对一个有向无环图 (Directed Acyclic Graph
简称DAG)G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边 < u , v > ∈ E ( G ) <u,v>∈E(G) < u , v >∈ E ( G ) ,则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序 。
有向图的拓扑排序的基本思想是:首先在有向图中选取一个没有前驱(入度为0)的顶点,将其输出,从有向图中删除该顶点,并且删除以该顶点为尾的所有有向图的边。重复以上的步骤,直到图中的所有顶点均输出或是图中的顶点均没有前驱为止。对于后者,说明有向图中存在环,不能进行拓扑排序。
BFS
算法又称Kahn
算法,该算法需要维护一个入度为0的顶点的集合,每次从该集合中取出(没有特殊的取出规则,随机取出也行,使用队列/栈也行,下同)一个顶点,将该顶点放入结果序列中。紧接着循环遍历由该顶点引出的所有边,从图中移除这条边,同时获取该边的另外一个顶点,如果该顶点的入度在减去本条边之后为0,那么也将这个顶点放到入度为0的集合中。然后继续从集合中取出一个入度为0的顶点,重复上述的操作。当集合为空之后,检查图中是否还存在任何边,如果存在的话,说明图中至少存在一条环路。不存在的话则返回结果List,此List中的顺序就是对图进行拓扑排序的结果。
复制 std::vector<int> topologicalSort_bfs(int n, std::vector<std::pair<int, int> >& edges){
vector<int> res;
queue<int> iqueue;
int in_degree[n];
memset(in_degree, 0, sizeof in_degree);
for(auto edge : edges){
in_degree[edge.second]++;
}
for(int i = 0; i < n; ++i){
if(in_degree[i] == 0)
iqueue.push(i);
}
while(!iqueue.empty()){
int tmp = iqueue.front();
iqueue.pop();
res.push_back(tmp);
for(int i = 0; i < edges.size(); ++i){
if(edges[i].first == tmp){
in_degree[edges[i].second]--;
if(in_degree[edges[i].second] == 0)
iqueue.push(edges[i].second);
}
}
}
return res.size() == n ? res : vector<int>();
}
利用DFS
实现拓扑排序,需要使用栈结构来维护一个出度为0的顶点的集合。添加顶点到结果集中的时机是在DFS
方法即将退出之时,DFS
方法本身是个递归方法,只要当前顶点还存在边指向其它任何顶点,它就会递归调用DFS
方法,而不会退出。因此,退出DFS
方法,意味着当前顶点没有指向其它顶点的边了,即当前顶点是一条路径上的最后一个顶点。
复制 void dfs(vector<vector<int> >&v, stack<int> &s, int *isVisited, int u, bool &isCircled){
if(isCircled)
return;
isVisited[u] = -1;
for(int i = 0; i < v[u].size(); ++i){
if (isVisited[v[u][i]] == 0) {
dfs(v, s, isVisited, v[u][i], isCircled);
}else if(isVisited[v[u][i]] == -1){
isCircled = true;
return;
}
}
isVisited[u] = 1;
s.push(u);
}
std::vector<int> topologicalSort_dfs(int n, std::vector<std::pair<int, int> >& edges){
vector<int> res;
stack<int> istack;
int isVisited[n]; // 0为未访问,1为已访问,-1为正在访问,当dfs搜索时遇到了
// 一条边终止顶点对应的isVisited元素为-1时,就说明图中有环了
memset(isVisited, 0, sizeof isVisited);
vector<vector<int> > v_edges(n);
for(auto edge : edges) {
v_edges[edge.second].push_back(edge.first);
}
bool isCircled = false;
for(int i = 0; i < n; ++i){
if(!isVisited[i])
dfs(v_edges, istack, isVisited, i, isCircled);
if(isCircled)
break;
}
if (isCircled)
return vector<int>();
while(!istack.empty()){
res.push_back(istack.top());
istack.pop();
}
return res;
}
两种实现算法的总结:
这两种算法分别使用链表和栈来表示结果集。对于基于DFS的算法,加入结果集的条件是:顶点的出度为0。而Kahn算法中入度为0的顶点集合为结果集。一个是从入度的角度来构造结果集,另一个则是从出度的角度来构造。二者的复杂度均为 O ( V + E ) O(V+E) O ( V + E ) 。
实现上的一些不同之处:
Kahn算法不需要检测图为DAG,如果图为DAG,那么在出度为0的集合为空之后,图中还存在没有被移除的边,这就说明了图中存在环路。而基于DFS的算法需要首先确定图为DAG,当然也能够做出适当调整,让环路的检测和拓扑排序同时进行,毕竟环路检测也能够在DFS的基础上进行。(上述代码也是这样做的)
Course Schedule
复制 bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<int> res;
queue<int> iqueue;
int in_degree[numCourses];
memset(in_degree, 0, sizeof in_degree);
for(auto edge : prerequisites){
in_degree[edge[1]]++;
}
for(int i = 0; i < numCourses; ++i){
if(in_degree[i] == 0)
iqueue.push(i);
}
while(!iqueue.empty()){
int tmp = iqueue.front();
iqueue.pop();
res.push_back(tmp);
for(int i = 0; i < prerequisites.size(); ++i){
if(prerequisites[i][0] == tmp){
in_degree[prerequisites[i][1]]--;
if(in_degree[prerequisites[i][1]] == 0)
iqueue.push(prerequisites[i][1]);
}
}
}
return res.size() == numCourses ? true : false;
}
复制 void dfs(vector<vector<int> >&v, stack<int> &s, int *isVisited, int u, bool &isCircled){
if(isCircled)
return;
isVisited[u] = -1;
for(int i = 0; i < v[u].size(); ++i){
if (isVisited[v[u][i]] == 0) {
dfs(v, s, isVisited, v[u][i], isCircled);
}else if(isVisited[v[u][i]] == -1){
isCircled = true;
return;
}
}
isVisited[u] = 1;
s.push(u);
}
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<int> res;
stack<int> istack;
int isVisited[numCourses];
memset(isVisited, 0, sizeof isVisited);
vector<vector<int> > v_edges(numCourses);
for(auto edge : prerequisites) {
v_edges[edge[1]].push_back(edge[0]);
}
bool isCircled = false;
for(int i = 0; i < numCourses; ++i){
if(!isVisited[i])
dfs(v_edges, istack, isVisited, i, isCircled);
if(isCircled)
break;
}
if (isCircled)
return vector<int>();
while(!istack.empty()){
res.push_back(istack.top());
istack.pop();
}
return res;
}