图的实现
基于邻接矩阵的实现
给定一个顶点数量为n的无向图:
- 初始化:传入n个顶点,初始化长度为n的顶点列表
vertices
,使用O(n)时间;初始化 n * n 大小的邻接矩阵adjMat
,使用O(n2)时间。
- 添加或删除边:直接在邻接矩阵中修改指定的边即可,使用O(1)时间。而由于是无向图,因此需要同时更新两个方向的边。
- 添加顶点:在邻接矩阵的尾部添加一行一列,并全部填0即可,使用O(n)时间。
- 删除顶点:在邻接矩阵中删除一行一列。当删除首行首列时达到最差情况,需要将(n-1)2个元素“向左上移动”,从而使用O(n2)时间。
/*基于邻接矩阵实现的无向图类*/
class GraphAdjMat{
vector<int> vertices; // 顶点列表,元素代表顶点值,索引代表顶点索引
vector<vector<int>> adjMat; // 邻接矩阵,行列索引代表对应的顶点索引
public:
/*构造方法*/
GraphAdjMat(const vector<int> &vertices, const vector<vector<int>> &edges){
// 添加顶点
// 添加边
}
/*获取顶点数量*/
int size(){
return vertices.size();
}
/*添加顶点*/
void addVertex(int val){
int n = size();
vertices.push_back(val);
// 在邻接矩阵中添加一行0
adjMat.push_back(vector<int>(n, 0));
// 在邻接矩阵中添加一列0
for (vector<int> &row : adjMat){
row.push_back(0);
}
}
/*删除顶点*/
void removeVertex(int index){
if (index >= size()){
cout << "顶点不存在" << endl;
return;
}
// 在顶点列表中删除索引为index的顶点
vertices.erase(vertices.begin() + index);
// 邻接矩阵中删除索引 index 的行
adjMat.erase(adjMat.begin() + index);
// 邻接矩阵中删除索引 index 的列
for (vector<int> &row : adjMat){
row.erase(row.begin() + index);
}
}
/*添加边*/
void addEdge(int i, int j){
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j){
cout << "顶点不存在" << endl;
return;
}
adjMat[i][j] = 1;
adjMat[j][i] = 1;
}
/*删除边*/
void removeEdge(int i, int j){
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j){
cout << "顶点不存在" << endl;
return;
}
adjMat[i][j] = 0;
adjMat[j][i] = 0;
}
};
基于邻接表的实现
设无向图的顶点总数为n,边总数为m,则可根据以下各图实现各种操作。
-
初始化:在邻接表中创建n个顶点和2m条边,使用O(n+m)时间。
-
添加边:在顶点对应链表的末尾添加边即可,使用O(1)时间。因为是无向图,所以需要同时添加两个方向的边。
-
删除边:在顶点对应链表中查找并删除指定边,使用O(m)时间。在无向图中,需要同时删除两个方向的边。
-
添加顶点:在邻接表中添加一个链表,并将新增顶点作为链表头节点,使用O(1)时间。
-
删除顶点:需遍历整个邻接表,删除包含指定顶点的所有边,使用O(n+m)时间。
以下是邻接表的代码实现:
- 为了方便添加与删除顶点,以及简化代码,我们使用列表(动态数组)来代替链表。
- 使用哈希表来存储邻接表,
key
为顶点实例,value
为该顶点的邻接顶点列表(链表)。
另外,我们在邻接表中使用Vertex
类来表示顶点,这样做的原因是:如果与邻接矩阵一样,用列表索引来区分不同顶点,那么假设要删除索引为i的顶点,则需要遍历整个邻接表,将所有大于i的索引全部减1,效率很低。如果每个顶点都是唯一的Vertex
实例,删除某一顶点后就无需改动其他顶点了。
/*顶点*/
class Vertex{
int val;
};
/*基于邻接表实现的无向图类*/
class GraphAdjList{
public:
// 邻接表; key:顶点,value:该顶点的所有邻接顶点
unordered_map<Vertex*, vector<Vertex*>> adjList;
/*构造方法*/
GraphAdjList(const vector<vector<Vertex*>> &edges){
// 添加所有顶点和边
for (const vector<Vertex*> &edge : edges){
addVertex(edge[0]);
addVertex(edge[1]);
addEdge(edge[0], edge[1]);
}
}
/*在vector中删除指定节点*/
void remove(vector<Vertex*> &vec, Vertex *vet){
for (int i = 0; i < vec.size(); i++){
if (vec[i] == vet){
vec.erase(vec.begin() + i);
break;
}
}
}
/*获取顶点数量*/
int size(){
return adjList.size();
}
/*添加边*/
void addEdge(Vertex* vet1, Vertex* vet2){
// 顶点不存在 或 两个顶点相同
if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2){
cout << "顶点不存在" << endl;
return;
}
adjList[vet1].push_back(vet2);
adjList[vet2].push_back(vet1);
}
/*删除边*/
void removeEdge(Vertex* vet1, Vertex* vet2){
// 顶点不存在 或 两个顶点相同
if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2){
cout << "顶点不存在" << endl;
return;
}
remove(adjList[vet1], vet2);
remove(adjList[vet2], vet1);
}
/*添加顶点*/
void addVertex(Vertex* vet){
if (adjList.count(vet)){
cout << "顶点已存在" << endl;
return;
}
// 在邻接表中添加一个新链表
adjList[vet] = vector<Vertex*>();
}
/*删除顶点*/
void removeVertex(Vertex *vet){
// 顶点不存在 或 两个顶点相同
if (!adjList.count(vet) ){
cout << "顶点不存在" << endl;
return;
}
// 在邻接表中删除顶点vet对应的链表
adjList.erase(vet);
// 遍历其他顶点的链表,删除所有包含 vet 的边
for (auto &adj : adjList){
remove(adj.second, vet);
}
}
};
效率对比
设图中共有n个顶点和m条边,下表对比了邻接矩阵和邻接表的时间效率和空间效率。
邻接矩阵 | 邻接表(链表) | 邻接表(哈希表) | |
---|---|---|---|
判读是否邻接 | O(1) | O(m) | O(1) |
添加边 | O(1) | O(1) | O(1) |
删除边 | O(1) | O(m) | O(1) |
添加顶点 | O(n) | O(1) | O(1) |
删除顶点 | O(n2) | O(n+m) | O(n) |
内存空间占用 | O(n2) | O(n+m) | O(n+m) |
由表,似乎邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需要一次数组访问或赋值即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。
图的遍历
树代表的是“一对多”的关系,而图具有更高的自由度,可以表示任意“多对多”关系。因此,我们可以把树看做图的一种特例。显然,树的遍历操作也是图的遍历操作的一种特例。
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式也可分为两种:广度优先遍历和深度优先遍历。
广度优先遍历
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。
如图,从左上角顶点出发,首先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
算法实现
BFS通常借助队列来实现。队列具有“先入先出”的性质,这与BFS的“由远及近”思想异曲同工。
- 将遍历起始顶点
startVet
加入队列,并开启循环。 - 在循环的每轮迭代中,弹出队首顶点,并记录访问,然后将该顶点的所有邻接顶点加入到队尾部。
- 循环上一步骤,直到所有顶点被访问完毕后结束。
为了防止重复遍历顶点,我们需要借助一个哈希表visited
来记录哪些节点已被访问。
/*广度优先遍历*/
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
vector<Vertex*> graphBFS(GraphAdjList &graph, Vertex *startVet){
// 顶点遍历序列
vector<Vertex*> res;
// 避免重复哈希表 用于记录访问过的顶点
unordered_set<Vertex*> visited;
// 队列
queue<Vertex*> que;
que.push(startVet);
while (!que.empty()){
Vertex* vet = que.front();
que.pop();
res.push_back(vet);
for (Vertex* v : graph.adjList[vet]){
if (!visited.count(vet)){
que.push(v); // 只入队为访问的顶点
visited.emplace(v); // 标记该顶点已被访问
}
}
}
return res;
}
注意:广度优先遍历的序列是否唯一?
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序允许被任意打乱。
复杂度分析
时间复杂度:所有顶点都会出队并入队一次,使用O(|V|)时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问2次,使用O(2|E|)时间,总体使用O(|V|+|E|)时间。
空间复杂度:列表res
,哈希表visited
,队列que
中的顶点数量最多为|V|,使用O(|V|)空间。
深度优先遍历
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。如图所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。
算法实现
这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中,我们也需要借助一个哈希表visited
来记录已被访问的顶点,以避免重复访问顶点。
/*深度优先遍历辅助函数*/
void dfs(GraphAdjList &graph, unordered_set<Vertex*> &visited, vector<Vertex*> &res, Vertex * vet){
res.push_back(vet); // 记录访问顶点
visited.emplace(vet); // 标记该顶点已被访问
// 遍历该顶点的所有邻接顶点
for (Vertex* adjVet : graph.adjList[vet]){
if (visited.count(adjVet))
continue; // 跳过已被访问的顶点
// 递归访问邻接顶点
dfs(graph, visited, res, adjVet);
}
}
/*深度优先遍历*/
// 使用邻接表来表示图,以便获取指定顶点的邻接顶点
vector<Vertex*> graphDFS(GraphAdjList &graph, Vertex* startVet){
// 顶点遍历序列
vector<Vertex*> res;
// 哈希表,用于记录被访问过的顶点
unordered_set<Vertex*> visted;
dfs(graph, visted, res, startVet);
return res;
}
深度优先遍历的算法流程如图所示:
- 直虚线代表向下递推,表示开启了一个新的递归方法来访问新顶点。
- 曲虚线代表向上回溯,表示此递归方法已经返回回溯到开启此方法的位置。
深度优先遍历序列的顺序同样不是唯一的。给定某点,先往哪个方向探索都可以,即邻接顶点的顺序可以任意打乱,都是深度优先遍历。
复杂度分析
时间复杂度:所有顶点都会被访问一次,使用O(|V|)时间;所有边都会被访问两次,使用O(2|E|)时间;总体使用O(|V|+|E|)时间。
空间复杂度:列表res
,哈希表visited
顶点数量最多为|V|,递归最大深度为|V|,因此使用O(|V|)空间。