算法与数据结构——图的基础操作及图的遍历(广度优先与深度优先)
图的实现
基于邻接矩阵的实现#
给定一个顶点数量为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|)空间。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· [翻译] 为什么 Tracebit 用 C# 开发
· 腾讯ima接入deepseek-r1,借用别人脑子用用成真了~
· Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版
· DeepSeek崛起:程序员“饭碗”被抢,还是职业进化新起点?
· RFID实践——.NET IoT程序读取高频RFID卡/标签