算法与数据结构——图的基础操作及图的遍历(广度优先与深度优先)

风陵南·2024-09-11 14:51·166 次阅读

算法与数据结构——图的基础操作及图的遍历(广度优先与深度优先)

图的实现

基于邻接矩阵的实现#

给定一个顶点数量为n的无向图:

  • 初始化:传入n个顶点,初始化长度为n的顶点列表vertices,使用O(n)时间;初始化 n * n 大小的邻接矩阵adjMat,使用O(n2)时间。
  • 添加或删除边:直接在邻接矩阵中修改指定的边即可,使用O(1)时间。而由于是无向图,因此需要同时更新两个方向的边。

  • 添加顶点:在邻接矩阵的尾部添加一行一列,并全部填0即可,使用O(n)时间。
  • 删除顶点:在邻接矩阵中删除一行一列。当删除首行首列时达到最差情况,需要将(n-1)2个元素“向左上移动”,从而使用O(n2)时间。
Copy
/*基于邻接矩阵实现的无向图类*/
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实例,删除某一顶点后就无需改动其他顶点了。

Copy
/*顶点*/
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来记录哪些节点已被访问。

Copy
/*广度优先遍历*/
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
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来记录已被访问的顶点,以避免重复访问顶点。

Copy
/*深度优先遍历辅助函数*/
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|)空间。

posted @   风陵南  阅读(167)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· [翻译] 为什么 Tracebit 用 C# 开发
· 腾讯ima接入deepseek-r1,借用别人脑子用用成真了~
· Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版
· DeepSeek崛起:程序员“饭碗”被抢,还是职业进化新起点?
· RFID实践——.NET IoT程序读取高频RFID卡/标签
点击右上角即可分享
微信分享提示
目录