有空再补充最小生成树和最小路径树
基本概念
图的分类
- 有向/无向
- 加权/不加权
- 两两组合,一共有4种图
图的表示
- 邻接表
- 邻接链表数组, A【2】里面放的是 一个链表,是和节点2相连的节点
- 适用于稀疏图,也就是顶点数大于边数的情况
- 代码表示:
- vector<vector> adjs
- adjs【i】 是一个vector, 里面存着第i个节点所有边的另一个节点编号
- 邻接矩阵
- 二维矩阵,n个节点,nxn个矩阵。
- 假如无向图, i和j相连,那么A【i】【j】, A【j】【i】 为true
- 根据题目输入,转为邻接表方法
一般题目给的输入是边的矩阵edges= [[1,0],[2,0],[3,1],[3,2]] 和 节点个数 num
1) 如果是无向图
a) 假如给了节点个数
转为:vector<vector<int>> adjs(num);
转换方法:
for (auto &edge: edges)
{
int i = edge[0], j = edge[1];
adjs[i].push_back(j);
adjs[j].push_back(i);
}
adjs【i】 是一个vector, 里面存着第i个节点所有边的另一个节点编号
b) 假如没给节点个数
转为:unordered_map<int, vector<int>> adjs;
2)有向图
struct Edge
{
Edge(int val, int from, int to): val(val), from(from), to(to) {}
int val; // 有权 要这个数据成员, 无权图不要
int from, to;
};
a) 给了节点个数用 vector<vector<Edge>> adjs(num);
b) 没给节点个数用 unordered_map<int, vector<Edge>> adjs;
图的遍历
- 遍历跟节点s 连通的所有节点
- 以下代码 图均用邻接表表示
- visited的用途是因为。
- 无向图,必须使用visited数组,因为肯定会重复遍历节点,进入死循环。
- 有向图
- 有向有环图,必须用。
- 有向无环图,可以不用visited数组。
DFS
void travese(vector<vector<int>> &adjs, int s){
vector<bool> visited(adjs.size(), false);
visited[s] = true;
dfs(adjs, visited, s);
}
void dfs(vector<vector<int>> &adjs, vector<bool> &visited, int cur){
// do something
for (int next: adjs[cur]){
if (!visited[next]){
visited[next] = true;
dfs(adjs, visited, next);
}
}
}
- 时间复杂度: O(V+E),其中 V 是图中的顶点数,E 是图中的边数。
- 空间复杂度: O(V)。 栈中最多可以有 V 个元素。所以所需的空间是 O(V)。
BFS
- 基本思路
- 一圈一圈的扫荡。首先扫荡距离起点为1的点,然后扫荡距离起点为2的点,按照与起点的距离的顺序来遍历所有的顶点
void travese(vector<vector<int>> &adjs, int s){
vector<bool> visited(adjs.size(), false);
visited[s] = true;
queue<int> q{{s}};
while (q.size()){
int cur = q.front();
q.pop();
// do something
for (int i: adjs[cur]){
if (!visited[i]){
visited[i] = true;
q.push(i);
}
}
}
}
- 时间复杂度: O(V+E),其中 V 是图中的顶点数,E 是图中的边数。
- 空间复杂度: O(V)。 队列中最多可以有 V 个元素。所以所需的空间是 O(V)。
并查集
-
应用场景:
- 给出两个节点,判断它们是否连通,如果连通,不需要给出具体的路径
- 如果需要给出具体的路径,可以用dfs,bfs
-
算法流程:
0. 让每个节点,构成单元素的集合- 依次合并相连的两个节点
-
并查集就是两个操作,union和find
- union的时候,把小树挂到大树,防止增加树的高度, 同时union是c++的关键字,所以用union_
- find的时候,加入路径压缩,如果当前节点p的父节点不是p自己的话,那把p的父节点设为原先p的爷爷节点
class UF{
public:
/*
可以把每个连通分量,想象成一颗树
id[i]表示的含义是以下两者之一
a) 第i个节点的父节点
b) 假如id【i】 = i 那就表示他是根节点,同时作为这个连通分量的编号
size【根节点】 表示的是这个连通分量有多少个节点
size【其他】 没有意义
count 表示当前有多少个连通分量
*/
UF(int k):size(k,1),count(k){
for(int i=0;i<k;i++){
id.push_back(i);
}
}
int find(int p){
while(p != id[p]){
id[p]=id[id[p]]; //加入路径压缩,如果当前节点p的父节点不是p自己的话,那把p的父节点设为原先p的爷爷节点
p=id[p];
}
return p;
}
void union_(int p,int q){
int i=find(p);
int j=find(q);
if(i!=j){
// 小树挂在大树上
if(size[i]>size[j]){
id[j]=i;size[i] += size[j];
} else {
id[i]=j;size[j] += size[i];
}
--count;
}
}
int number(){
return count;
}
private:
vector<int> id;
vector<int> size;
int count;
};
应用
求两点之间是否连通
- DFS,BFS均可
- 如果是无向图,且无需给出一条路径,那么也可以用并查集
- 例题: 面试题 04.01. 节点间通路
给出一条路径DFS版
vector<int> isReachable(vector<vector<int>> &adjs, int s, int d){
vector<bool> visited(adjs.size(), false);
vector<int> path;
dfs(adjs, visted, path, s, d);
return path;
}
bool dfs(vector<vector<int>> &adjs, vector<bool> &visited, vector<int> &path, int s, int d){
path.push_back(s);
visited[s] = true;
if (s == d){
return true;
}
for (int next: adjs[s]){
if (!visited[next]){
if (dfs(adjs,visited, path, next, d)
return true;
}
}
path.pop_back();
// visted[s] = false; 不需要重置, 因为只需要给出一条路径即可,
return false;
}
给出一条路径BFS版
vector<int> isReachable(vector<vector<int>> &adjs, int s, int d){
vector<bool> visited(adjs.size(), false);
vector<int> edge_to(adjs.size(), -1); // edge_to[w] = k 表示 k到w的一条边, -1表示未设置
visted[s] = true;
queue<int> q{{s}};
while (q.size()){
int cur = q.front();
q.pop();
for (int i: adjs[cur]){
if (!visited[i]){
visited[i] = true;
edge_to[i] = cur;
if (i == d){
return get_path(edge_to, s, d);
}
q.push(i);
}
}
}
return {};
}
vector<int> get_path(vector<int> &edge_to, int s, int d){
vector<int> ret;
for (int i= d; i != s; i = edge_to[i]){
ret.push_back(i);
}
ret.push_back(s);
reverse(ret.begin(), ret.end());
return ret;
}
求两点之间所有路径
- 用DFS比较方便
- 例题: 797. 所有可能的路径
// 找出所有从节点 0 到节点 n-1 的路径
class Solution {
public:
vector<vector<int>> allPathsSourceTarget(vector<vector<int>>& graph) {
vector<vector<int>> ret;
vector<int> path;
// 假如是有向无环图,就不需要visited数组
vector<bool> visited(graph.size(), false);
visited[0] = true;
path.push_back(0);
dfs(ret, path, graph, visited, 0);
return ret;
}
void dfs(vector<vector<int>> &ret, vector<int> &path, vector<vector<int>> &graph, vector<bool> &visited, int cur){
if (cur == graph.size() - 1){
ret.push_back(path);
return;
}
for (int next: graph[cur]){
if (!visited[next]){
visited[next] = true;
path.push_back(next);
dfs(ret, path, graph, visited, next);
visited[next] = false; // 找出所有路径是需要重置visited数组的
path.pop_back();
}
}
}
};
求两点之间最短路径
无权图
- 跟求两点之间是否连通BFS版代码一致
有权图
有无环
- 环是指一条至少含有一条边且起点和终点相同的路径
无向图(这个一般不会考)
- 不考虑有自环和平行边
- 自环:一条连接一个顶点和自身的边
- 平行边: 连接同一对顶点的两条边
class Cycle
{
private Graph graph;
private bool[] visited;
private bool hasCycle;
public Cycle(Graph g)
{
this.graph = g;
visited = new bool[g.Verts];//已访问标记,大小为图的顶点数
//考虑一个图有多个连通分量,需要对每个没访问到的点都分别进行DFS才能保证访问到所有顶点
for (int i = 0; i < g.Verts; i++)
{
if (!visited[i])
DFS(i, i);
}
}
private void DFS(int v, int w)
{
//参数v是当前访问顶点,w是上一个访问顶点
visited[v] = true;
foreach (var item in graph.Adj(v))
{
if (!visited[item])
DFS(item, v);
else if (item != w)//如果一个顶点已经被访问过,且这个顶点不是上一个访问顶点,则有环
hasCycle = true;
}
}
public bool HasCycle()
{
return hasCycle;
}
}
-
若在深度优先搜索的过程中遇到回边(即指向已经访问过的顶点的边),则必定存在环
从树的角度很容易理解上面的代码,树就是一个无环图,而树的前中后序遍历就是DFS,我们从根节点开始进行DFS,对于树中任意一个节点,只与他的父节点以及他的子节点连通,基于DFS的回溯方式,实际上不会访问到父节点外的重复节点,若遇到已访问的节点且不是该点的“父节点”,则树不成立,而是一个有环图。
有向图
BFS (重要)
- 看拓扑排序的Kahn算法,能找到拓扑排序的有向图就是无环图,否则就是有环图
DFS (没时间不用看)
- 具体办法:
- 在DFS的基础上添加一个bool数组来保存在递归调用期间正在遍历路径的所有顶点,若遇到一个顶点已被访问过而且还在调用栈上,则视为有环。
- 因为使用DFS遍历的时候, 系统维护的递归栈,保存的其实是当前正在遍历的有向路径,假如我们当前正在访问节点v的所有边,有一个边 v->w, w是之前访问过的节点,且在当前正在遍历的递归栈里,那就是有环。因为栈里表示的是一条w到v的有向路径, 而v->w正好补全了这个环。
- 考虑一个图有多个连通分量,所以需要对每个没访问到的点都分别进行DFS才能保证访问到所有顶点。
- 如果需要返回某个环的路径,那么只需要在遍历的时候,同时记录edge_to数组即可。
- 例题:207. 课程表
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
// 转为邻接表
vector<vector<int>> adjs(numCourses, vector<int>());
for (vector<int> &edge: prerequisites){
adjs[edge[1]].push_back(edge[0]);
}
vector<bool> visited(numCourses, false); // 防止重复遍历,陷入死循环
vector<bool> onPath(numCourses, false); // 保存正在遍历路径上的所有节点.
// 需要遍历每个节点。
for (int i= 0; i != numCourses; i++){
if (!visited[i]){
if (dfs(adjs, visited, onPath, i))
return false;
}
}
return true;
}
bool dfs(vector<vector<int>> &adjs, vector<bool> &visited, vector<bool> &onPath, int cur){
visited[cur] = true;
onPath[cur] = true;
for (int next: adjs[cur]){
if (!visited[next]){
if (dfs(adjs, visited, onPath, next))
return true;
} else if (onPath[next]) {
return true;
}
}
onPath[cur] = false;
return false;
}
};
拓扑排序
-
首先必须是有向无环图才能有拓扑排序.
-
概念:
-
拓扑排序:所有顶点排序,保证所有的有向边都是排在前面的顶点指向后面的顶点。
-
入度:在有向图中,表示其他顶点直接指向某个顶点的边的数目
-
出度:在有向图中,表示从某个顶点出发指向其他顶点的边的数目
-
Kahn算法 (BFS)
-
算法流程
- 设置邻接表,入度数组(nums[i] 表示 第i个节点的入度),结果数组Ret
- 初始化队列,将入度为0的顶点全都入队(顺序不重要)
- 出队队首元素,放入ret数组,同时把这个元素的所有邻接点入度减-1,假如入度为0,就入队。
- 循环步骤2 直到队列为空
- 如果ret数组长度与节点数目一致,ret数组就是其中一个拓扑排序,否则就是有环。
-
时间复杂度:
O(V+E) V表示顶点数,E表示边数。
-
空间复杂度:
O(V+E)
-
例题:
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> adjs(numCourses, vector<int>()); // 邻接表
vector<int> nums(numCourses, 0); // 入度数组
for(auto &edge: prerequisites){
adjs[edge[1]].push_back(edge[0]);
nums[edge[0]]++;
}
queue<int> q;
vector<int> ret;
for (int i = 0; i < numCourses; i++){
if (nums[i] == 0){
q.push(i);
}
}
while(q.size()){
int cur = q.front();
q.pop();
ret.push_back(cur);
for (int next: adjs[cur]){
nums[next]--;
if (nums[next] == 0){
q.push(next);
}
}
}
if (ret.size() == numCourses)
return ret;
else
return {};
}
};
二分图判定
-
概念
- 如果能将一个图的节点集合分割成两个独立的子集 A 和 B ,并使图中的每一条边的两个节点一个来自 A 集合,一个来自 B 集合,就将这个图称为 二分图 。
-
算法的流程如下:
- 我们任选一个节点开始,将其染成红色,并从该节点开始对整个无向图进行遍历;
- 在遍历的过程中,如果我们通过节点 u 遍历到了节点 v(即 u 和 v 在图中有一条边直接相连),那么会有两种情况:
- 如果 v未被染色,那么我们将其染成与 u 不同的颜色,并对 v 直接相连的节点进行遍历;
- 如果v被染色,并且颜色与 u相同,那么说明给定的无向图不是二分图。我们可以直接退出遍历并返回False 作为答案。
- 当遍历结束时,说明给定的无向图是二分图,返回 True 作为答案。
-
我们可以使用「深度优先搜索」或「广度优先搜索」对无向图进行遍历
-
例题:
-
BFS代码
class Solution { public: bool isBipartite(vector<vector<int>>& graph) { vector<int> colors(graph.size(), UNKNOW); for (int i = 0; i< graph.size(); i++){ if (colors[i] == UNKNOW){ colors[i] = RED; queue<int> q{{i}}; while (q.size()){ int cur = q.front(); q.pop(); int next_color = colors[cur] == RED?BLUE:RED; for (int next: graph[cur]){ if (colors[next] == UNKNOW){ colors[next] = next_color; q.push(next); } else if (colors[next] != next_color) return false; } } } } return true; } private: enum {UNKNOW, RED, BLUE}; };
最小生成树
概念
-
生成树 指的是「无向图」中,具有该图的 全部顶点 且 边数最少 的连通子图。
-
最小生成树指的是「加权无向图」中总权重最小的生成树。
-
连通图,从图的任意一个顶点能到任意另一个顶点
-
最小生成树针对的是加权连通无向图
-
prim算法
-
kruskal算法
最小路径树
- 针对加权有向图
- 解决方法:
- Dijkstra算法 (限制权值非负)
- Bellman-Ford算法 (适用于所有情况,权值可以负,可以有环,但别有负权重环,存在负权重环,最短路径树就不存在了)
参考资料:
-
https://www.drflower.top/posts/8c9bd54f/#%E6%9C%89%E5%90%91%E5%9B%BE%E6%98%AF%E5%90%A6%E5%AD%98%E5%9C%A8%E7%8E%AF
-
https://labuladong.gitee.io/algo/2/19/41/
-
https://leetcode-cn.com/leetbook/read/graph/r3cr3r/
-
https://www.geeksforgeeks.org/find-if-there-is-a-path-between-two-vertices-in-a-given-graph/