图中的概念很多,包括有向图和无向图、完全图和有向完全图、稀疏图和稠密图、入度和出度、路径和简单路径、回路(环)、(强)连通图和(强)连通分量、生成树和生成森林等,还有哈密顿图、欧拉图等又特殊性质的图。面试中考核的是两种搜索算法:深度优先搜索和广度优先搜索。
一、图的存储结构
图是由 ( 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、邻接矩阵
无向图顶点 Vi 的度为第 i 个单链表中的结点数
无向图中顶点 Vi 的出度为第 i 个单链表中的结点个数,顶点 Vi 的入度为全部单链表中连接点域值是 i 的结点个数
逆邻接表:有向图中对每个结点建立以 Vi 为头的弧的单链表
邻接矩阵和邻接表
边结点和顶点结点如下示:
其中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 的真子孙的所有回退边都指向 v 或者比 v 更深层次(离树根更远)的顶点,则 v 是关节点。若有的回退边指向 v 的真祖先,则 v 不是关节点。
算法步骤:
对图进行深度优先搜索,计算每个顶点v的深度优先编号 dfn[v]
,形成深度优先生成树
计算所有顶点 v 的low[v]
编号是在深度优先生成树上按后根遍历顺序进行的。low[v]
取下述三值的最小者:
计算完low
编号后,求关节点,根据性质,可知:
非树根顶点 v 是关节点,当且仅当 v 有某个儿子 y,使low[y] >= dfn[v]
,这里其实就是说其真子孙的回退边都指向 v 或者比 v 更深层次的顶点
连接两个强分量的边叫做分支横边
。通过构造G的归约图 ,可以展示各强分量间的联系。归约图中每个强分量用一个顶点表示,显然,归约图中不存在环路。
Kosaraju
算法步骤:
对有向图 G 进行深度优先搜索并且对顶点进行逆编号 (即记录它们的离开时间)。
将 G 中的每条边取反方向,构造一个新有向图 Gr。
根据前面的编号,从编号最大的顶点开始对 G,进行一次深度优先搜索,凡是能到达的所有顶点,都形成一棵深度优先搜索树;若本次搜索没有到达所有顶点,从图中删除这些顶点及相连的边,继续重复该动作。
在 Gr 的深度优先森林中,每棵树对应 G 的一个强连通分量。
图的连通性判断方法主要有:并查集、 DFS
、 BFS
、 WARSHALL
。
最短路径树(SPT):在加权有向图中,有一个顶点 s,以 s 为起点的最短路径树是图的一幅子图,包含 s 和从 s 到达的所有顶点。这棵树的根结点是 s,树的每条路径都是有向图中的一条最短路径。
基本思想:每次找到离源点(如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 ;
}
最小生成树有两个经典算法:
如果一幅图是非连通的,则只能用这个算法计算所有连通分量的最小生成树,合并在一起叫做最小生成森林 。还有几点要注意的:
两个性质:
用一条边连接树中的任意两个顶点都会产生一个新的环。
图的一种切分是把图的所有顶点分为两个 非空 且 不重复 的集合。横切片是一条连接两个属于不同集合的边。通常,我们指定一个顶点集,然后隐式地认为它的补集是另一个顶点集。给定任意的切分,它的横切边中的权重最小者必然属于图的最小生成树。假设所有边的权重不相同,则每幅连通图都只有唯一的最小生成树。
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 ;
}
有向图的拓扑排序的基本思想是:首先在有向图中选取一个没有前驱(入度为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;
}
两种实现算法的总结:
实现上的一些不同之处:
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;
}