图论之图与图的遍历(C++) – 每个顶点都是主角
聊图要先聊聊树,一棵树需要有一个根,别的节点都是在根的基础上散发开的,根据节点与根之间和父子节点之间不同的关系形成了很多特殊的树;其实图(连通图)也可以看作是一棵树,无非就是随便选取一个顶点作为根就好了,不同的是树中有根和父子节点的概念,在图中没有,每个顶点都可以是根,也就是说图有着更广泛的性质;
一个图主要的研究对象就是顶点和边,也就是图中有多少个顶点,各个顶点是否相连,相连的边又分是否又方向,如同公路有双向公路和单向公路之分,边还可以有权值,如同两个地点直接距离的大小;基础的图就是无向无权值图,只考虑两个顶点是否有相连的边即可,因为不一定所有的顶点都相连(直接或间接),这时对于图中的顶点就有连通分量的说法,所谓连通分量这个分量中的顶点直接有直接或间接的路径到达彼此;
图的存储方式有两种,分别为邻接矩阵和邻接表,邻接矩阵就是用一个二维数组进行记录,邻接表则用链表记录相连的顶点,这两种方式对应数据量的不同和所进行的操作不同,性能也不同;
以下示例均基于无向无权图,且均给出了建图的类,其中有插入边的方法insertEdge,打印图的方法showGraph和遍历图中一个顶点的连接的所有顶点的迭代器EdgeIterator~~
图的构造 – 邻接矩阵
邻接矩阵比较简单的玩法就是以数组下标为顶点,因为是二维数组,每个下标均存有一个一维数组,这个数组对应的下标中记录true或false表示两个顶点之间是否有连接
#include <iostream>
#include <vector>
#include <cassert>
using namespace std;
// 图(Graph)
// 邻接矩阵:稠密图的小伙伴
// 无权值玩法
class GraphByMatrix {
private:
// 矩阵(图),因为没有权值,只要知道有连接或没连接就行,所以用bool
vector<vector<bool>> g;
// 图的顶点数
int vNum;
// 图的边数
int eNum;
// 是否有向
bool directed;
public:
// 构造函数
GraphByMatrix(int vNum, bool directed) {
assert(vNum >= 0);
this->vNum = vNum;
this->directed = directed;
// 边数初始为0
this->eNum = 0;
// 图初始化
g = vector<vector<bool>>(vNum,vector<bool>(vNum,false));
}
// 析构函数
~GraphByMatrix() {}
// 返回顶点个数
int getVNum() {
return vNum;
}
// 返回边数
int getENum() {
return eNum;
}
// 插入边
void insertEdge(int from, int to) {
assert(from >= 0 && from < vNum);
assert(to >= 0 && to < vNum);
// 顶点自己到自己或边已存在,直接返回
if(from == to || g[from][to] == true)
return;
g[from][to] = true;
// 无向,则反过来也要连起来
if(!directed) {
g[to][from] = true;
}
// 边的数量+1
eNum++;
}
// 遍历图中所有的边并打印
void showGraph() {
if(eNum == 0) {
cout << "empty graph!!!" << endl;
return;
}
// 展示用
cout << "\t";
// 输出顶点信息,用于展示
for(int i; i < vNum; i++)
cout << i << "\t";
cout << endl;
// 遍历,输出各顶点间是否相连,用true和false直接表示,输出后会是1和0
for(int i=0; i < vNum; i++) {
// 输出顶点信息,用于展示
cout << i << "\t";
for(int j=0; j < vNum; j++) {
cout << g[i][j] << "\t";
}
cout << endl;
}
}
// 遍历图中某个顶点与别的顶点的连接情况,与上面showGraph方法不同,这个是获取单个顶点的连接情况,不是获取所有的
// 最简单的办法是将图的变量公有化,这样遍历起来很方便,但带来的问题是外部可以随便修改图,还有一个方式是通过公有方法返回一个复制的图,这样带来的时间和空间消耗巨大
// 较好的方式是以下面的方式自制迭代器
class EdgeIterator {
private:
// 用引用不用指针,在初始化时用显式初始化,这样就只能用不能改,这样能调用到graph中的私有变量
GraphByMatrix &graph;
int v;
int tail;
public:
// 构造函数
EdgeIterator(GraphByMatrix& g, int v):graph(g) {
assert(v >= 0 && v < graph.vNum);
this->v = v;
// 遍历从下标0开始,因为开始时会调用next(),next中会对tail加1,所以初始化为-1
this->tail = -1;
}
~EdgeIterator() {}
// 开始遍历,并返回第一个连接的顶点
int begin() {
// 重置遍历下标
tail = -1;
return next();
}
// 获取一个连接的顶点
int next() {
// 从之前遍历到的下标开始继续获取连接的顶点
for(++tail; tail < graph.getVNum(); tail++) {
if(graph.g[v][tail]) {
return tail;
}
}
// 没有找到有连接的顶点,返回-1
return -1;
}
// 判断是否遍历结束
bool end() {
return tail >= graph.getVNum();
}
};
};
图的构造 – 邻接表
邻接表的思路是用一个数组下标作为各个顶点,每个数组元素存放的是一个链表,链表中的成员就与改下标顶点相连的顶点,所以链表中的元素存放的不是像邻接矩阵中的true或false,而是顶点;示例还是用vector嵌套vector的结构,因为vector是动态的,被嵌套的vector在一开始不分配空间,随着连接顶点增加再增加空间,与链表添加过程相似,可以用链表替代~~
#include <iostream>
#include <vector>
#include <cassert>
using namespace std;
// 图(Graph)
// 邻接表:稀疏图的小伙伴
// 无权值玩法
class GraphByChain {
private:
// 邻接表(图),因为没有权值,这里可以直接存放顶点的值来表示与哪个顶点相连,因为vector是动态的,也有链表的特性,所以这里也用vector,可以改成链表
vector<vector<int>> g;
// 图的顶点数
int vNum;
// 图的边数
int eNum;
// 是否有向
bool directed;
public:
// 构造函数
GraphByChain(int vNum, bool directed) {
assert(vNum >= 0);
this->vNum = vNum;
this->directed = directed;
// 边数初始为0
this->eNum = 0;
// 图初始化
g = vector<vector<int>>(vNum,vector<int>());
}
// 析构函数
~GraphByChain() {}
// 返回顶点个数
int getVNum() {
return vNum;
}
// 返回边数
int getENum() {
return eNum;
}
// 插入边
void insertEdge(int from, int to) {
assert(from >= 0 && from < vNum);
assert(to >= 0 && to < vNum);
// 顶点自己到自己,直接返回
if(from == to)
return;
// 边已存在,直接返回
for(int i=0; i < g[from].size(); i++) {
if(g[from][i] == to) {
return;
}
}
g[from].push_back(to);
// 无向,则反过来也要连起来
if(!directed) {
g[to].push_back(from);
}
// 边的数量+1
eNum++;
}
// 遍历图中所有的边并打印
void showGraph() {
if(eNum == 0) {
cout << "empty graph!!!" << endl;
return;
}
// 遍历,输出链表的形式,也就是每个顶点与别的顶点的连接链条
for(int i=0; i < vNum; i++) {
cout << i;
for(int j=0; j < g[i].size(); j++) {
cout << " - " << g[i][j];
}
cout << endl;
}
}
// 迭代器类
class EdgeIterator {
private:
// 用引用不用指针,在初始化时用显式初始化,这样就只能用不能改,这样能调用到graph中的私有变量
GraphByChain &graph;
int v;
int tail;
public:
// 构造函数
EdgeIterator(GraphByChain& g, int v):graph(g) {
this->v = v;
// 遍历从下标0开始,因为调用next()会+1,所以先设为-1
this->tail = -1;
}
~EdgeIterator() {}
// 开始遍历,并返回第一个连接顶点
int begin() {
// 重置遍历下标
tail = -1;
return next();
}
// 获取一个连接顶点
int next() {
tail++;
if(tail < graph.g[v].size()) {
return graph.g[v][tail];
}
// 没有找到有连接的顶点,返回-1
return -1;
}
// 判断是否遍历结束
bool end() {
return tail >= graph.g[v].size();
}
};
};
通过两种图中打印图的方法showGraph和图中一个顶点的连接顶点迭代器可以看出,对于邻接矩阵,遍历一次图的时间复杂度是O(v^2)(v代表vertex,顶点数),获取一个顶点的连接情况需要遍历一次所有顶点,时间复杂度是O(v);而对于邻接表,因为一个顶点相连的顶点是以链表的形式记录的,所以直接通过链表的节点数就知道与该顶点相连的顶点个数,也就是边数,这样获取一个顶点连接情况的时间复杂度是O(e)(e代表edge,边数),遍历一次图的时间复杂度是O(n*v);
图的连通分量 – 深度优先
给定一堆顶点,再给定一堆这些顶点的边,不一定每个顶点都能通过直接或间接的方式连接,也就是不一定任意两个顶点间都存在路径,这时就有了找出哪些顶点是相互可达到的意义了,这些相互可到达的顶点称为连通分量;
找连通分量的过程可以看作是将有边相连的顶点都访问一遍的过程,这就有两种访问方式了,一种是深度优先,深度优先使用递归的方式,从一个顶点开始往其连接的顶点走,然后再走其连接的顶点的连接顶点走,走到没路了,再回过头来走,与递归的方式契合;在这个过程中因为需要知道哪些顶点是连通的,就要记录一次递归能访问到哪些顶点,所以需要两个数组,一个记录这个顶点是否已访问,一个记录顶点所属的连通分量;
以下的示例适用于上面的邻接矩阵和邻接表,进行遍历时用到了图中的枚举器EdgeIterator,因为两种图的枚举器提供的调用方法一致,所以无需考虑用的是那种图~~
#include <iostream>
#include <cassert>
#include <queue>
using namespace std;
// 图的连通分量寻找与划分
template <typename Graph>
class DiscoverClosedGraph {
private:
// 图引用,只用不改
Graph &graph;
// 记录顶点是否已访问
bool* visited;
// 记录顶点所属连通分量
int* vGraphIndex;
// 连通分量个数(图的个数)
int gCount;
// 深度优先遍历查找连通分量,深度优先搭配递归实现
void DFS(int v) {
// 将当前顶点设为已访问
visited[v] = true;
// 设置当前顶点所属连通分量
vGraphIndex[v] = gCount;
// 在进行Graph调用时,默认是调用import中的类,在前面加了typename就是指用上面自定义类型的类
// 用图中设置的遍历器进行遍历,因为邻接矩阵和邻接表有相同类型的遍历器,所以直接遍历即可,不用考虑图的类型
typename Graph::EdgeIterator ei(graph,v);
for(int i = ei.begin(); !ei.end(); i = ei.next()) {
if(!visited[i]) {
DFS(i);
}
}
}
public:
// 构造函数,图要显式初始化
DiscoverClosedGraph(Graph& g):graph(g) {
visited = new bool[graph.getVNum()];
vGraphIndex = new int[graph.getVNum()];
gCount = 0;
// 初始化
for(int i=0; i < graph.getVNum(); i++) {
visited[i] = false;
vGraphIndex[i] = 0;
}
// 获取连通图
for(int i=0; i < graph.getVNum(); i++) {
// 有未访问的顶点时,说明有一个新的连通分量,连通分量序号+1,进行该连通分量的连通遍历
if(!visited[i]) {
gCount++;
// 这里可以用深度优先或广度优先实现
//DFS(i);
// 这个玩法暂时有bug
BFS(i);
}
}
}
// 析构函数
~DiscoverClosedGraph() {
delete[] visited;
delete[] vGraphIndex;
}
// 返回连通分量个数
int getGraphCount() {
return gCount;
}
// 查询两顶点是否相连
bool isConnected(int v1, int v2) {
assert(v1 >= 0 && v1 < graph.getVNum());
assert(v2 >= 0 && v2 < graph.getVNum());
return vGraphIndex[v1] == vGraphIndex[v2];
}
};
图的连通分量 – 广度优先
广度优先是选一个顶点,先走完这个顶点所有连接的顶点后,再选取一个这个顶点连接的顶点进行相同操作,为了满足先来后到,需要用到队列;这里只给出广度优先的方法,将上面的DFS方法改为下面的BFS方法即可;
// 广度优先遍历查找连通分量,广度优先搭配队列实现
void BFS(int v) {
queue<int> q;
q.push(v);
// 这里用于设置第一个顶点,因为后续的已访问在下面进行连接顶点遍历加入队列时已经设置
// 将当前顶点设为已访问
visited[v] = true;
// 设置当前顶点所属连通分量
vGraphIndex[v] = gCount;
while(!q.empty()) {
int qV = q.front();
q.pop();
// 将队列中弹出的当前顶点作为待遍历连接顶点的顶点
typename Graph::EdgeIterator ei(graph,qV);
// 若连接的顶点未访问,则压入队列
for(int i = ei.begin(); !ei.end(); i = ei.next()) {
if(!visited[i]) {
// 这里要进行顶点已访问设定,如果不设定,在弹出队列前会被认为未访问而重复加入队列,造成重复判断
visited[i] = true;
vGraphIndex[i] = gCount;
q.push(i);
}
}
}
}