图论理论基础
基本概念、种类
基本概念:
二维坐标中,两点可以连成线,多个点连成的线就构成了图。
图可以只有一个节点,也可以没有节点(空图)。
图的种类:
整体上一般分为 有向图 和 无向图。
图的度
无向图:
连接某节点的边数,即为该节点的【度】。
(无向图中,有5条边连接节点4,则节点4的度为5。)
有向图:
出度:从该节点出发的边的个数。
入度:指向该节点边的个数。
(有向图中,
从节点3出发的边的个数是2,则节点3的入度为2;
指向节点3的边的个数是5,则出度为5)
连通性
(无向图)
连通图:在无向图中,任何两个节点都是可以到达的图。
非连通图:在无向图中,存在有节点不能到达其他节点的图。
![]()
非连通图(节点1 不能到达节点4) ![]()
连通图 (有向图)
强连通图:在有向图中,任何两个节点是可以相互到达的图。
![]()
不是强连通图(节点5 不能到 节点1 )
连通分量
在无向图中的极大连通子图称之为该图的一个连通分量。
在有向图中的极大强连通子图称之为该图的强连通分量。
连通分量:
该无向图中:
节点1、2、5 构成的子图就是 该无向图中的一个连通分量,该子图所有节点都可相互达到。
同理,节点3、4、6 构成的子图 也是该无向图中的一个连通分量。
(无向图中 节点3 、4 构成的子图 不是该无向图的联通分量。)
因为必须是极大联通子图才能是连通分量,所以 必须是节点3、4、6 构成的子图才是连通分量。
在图论中,连通分量是一个很重要的概念,例如岛屿问题其实就是求连通分量。
强连通分量:
该有向图中:
节点1、2、3、4、5 构成的子图是强连通分量,因为这是强连通图,也是极大图。
节点6、7、8 构成的子图 不是强连通分量(因为这不是强连通图,且节点8 不能达到节点6)
节点1、2、5 构成的子图 也不是 强连通分量(因为这不是极大图)
图的存储
边数量较 少为稀疏图,接近或等于完全图(所有 节点皆相连或几乎都相连)为 稠密图
n 为节点数量。
当 n 很大,边 的数量 也很多的时候是(稠密图);
但 n 很大,边 的数量 很小的时候是(稀疏图)。
邻接表(适合稀疏图)
使用 数组 + 链表 的方式来表示。
从边的数量来表示图,有多少边 才会申请对应大小的链表。
优点:
- 对于稀疏图的存储,只需要存储边,空间利用率高
- 遍历节点连接情况相对容易
缺点:
- 检查任意两个节点间是否存在边,效率相对低,需要 O(V)时间,V表示某节点连接其他节点的数量。
- 实现相对复杂,不易理解
这里表达的图是:
- 节点1 指向 节点3 和 节点5
- 节点2 指向 节点4、节点3、节点5
- 节点3 指向 节点4
- 节点4 指向 节点1
需要构造一个数组,数组里的元素是一个链表:
// 节点编号从1到n,所以申请 n+1 这么大的数组 vector<list<int>> graph(n + 1); // 邻接表,list为C++里的链表
输入m个边,构造方式如下:
while (m--) { cin >> s >> t; // 使用邻接表 ,表示 s -> t 是相连的 graph[s].push_back(t); }
边有权值的情况:
(pair 为键值对,可以存放两个int,分别表示指向的节点编号和该边对应的权值)
vector<list<pair<int,int>>> grid(n + 1);
- 节点1 指向 节点3 权值为 1
- 节点1 指向 节点5 权值为 2
- 节点2 指向 节点4 权值为 7
- 节点2 指向 节点3 权值为 6
- 节点2 指向 节点5 权值为 3
- 节点3 指向 节点4 权值为 3
- 节点5 指向 节点1 权值为 10
如此 把图中权值表示出来。
------------
若认为使用
pair<int, int>
很容易搞混,第一个int 表示什么,第二个int表示什么,导致代码可读性很差。则 可以 定一个类 来取代
pair<int, int>。
类(或者说是结构体)定义如下:
struct Edge { int to; // 链接的节点 int val; // 边的权重 Edge(int t, int w): to(t), val(w) {} // 构造函数 }; vector<list<Edge>> grid(n + 1); // 邻接表
邻接矩阵(适合稠密图)
使用 二维数组来表示图结构。
从节点的角度来表示图,有多少节点就申请多大的二维数组。
优点:
- 检查任意两个顶点间是否存在边的操作非常快。
- 适合稠密图,在边数接近顶点数平方的图中,邻接矩阵是一种空间效率较高的表示方法。
缺点:
- 在 边少,节点多的情况下,会导致申请过大的二维数组,造成空间浪费。
- 寻找节点连接情况时,需遍历整个矩阵,即 n * n 的时间复杂度,同样造成时间浪费。
(遇到稀疏图,会导致申请过大的二维数组造成空间浪费 且遍历 边 的时候需要遍历整个n * n矩阵,造成时间浪费)
例:
有 n 个节点,节点标号从1开始。
为了节点标号和下标对齐,我们申请 n + 1 * n + 1 这么大的二维数组。
vector<vector<int>> graph(n + 1, vector<int>(n + 1, 0));
输入m个边,构造方式如下:
while (m--) { cin >> s >> t; // 使用邻接矩阵 ,1 表示 节点s 指向 节点t graph[s][t] = 1; }
并查集
判断两个元素是否在同一个集合里(解决连通性问题)
并查集可以解决什么问题:两个节点是否在一个集合,也可以将两个节点添加到一个集合中。
参考链接:
概论
定义:
并查集(Union-Find)既是一种树型的数据结构,也是一种算法思想的体现。
它主要用于处理一些不相交集合(Disjoint Sets)的合并及查询问题(即所谓的并、查)。
--------------------
主要构成:
并查集主要由一个整型数组 pre[ ] 和 两个函数 find( )、join( ) 、isSame( ) 构成。
数组 pre[ ] :记录每个点的前驱节点是谁;
函数 find(x) :查找指定节点 x 属于哪个集合;
函数 join(x,y) :合并两个节点 x 和 y 。
函数 isSame(int u, int v):判断两个节点是否同一个根节点。
--------------------
作用:
并查集的主要作用是求连通分支数(如果一个图中所有点都存在可达关系(直接或间接相连),则此图的连通分支数为1;如果此图有两大子图各自全部可达,则此图的连通分支数为2……)
代码模板:
int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好 vector<int> father = vector<int> (n, 0); // C++里的一种数组结构 // 并查集初始化 void init() { for (int i = 0; i < n; ++i) { father[i] = i; } } // 并查集里寻根的过程 int find(int u) { return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩 } // 判断 u 和 v是否找到同一个根 bool isSame(int u, int v) { u = find(u); v = find(v); return u == v; } // 将v->u 这条边加入并查集 void join(int u, int v) { u = find(u); // 寻找u的根 v = find(v); // 寻找v的根 if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回 father[v] = u; }
find( )函数的定义与实现
实现:
首先需要定义一个数组:int pre[1000]; (数组长度依题意而定)。该数组记录了每个人的上级是谁。这些人从0或1开始编号(依题意而定)。
比如:
pre[16]=6 表示:16号的上级是6号。
如果一个人的上级就是他自己,那说明他就是教主了,查找到此结束。
每个人都只认自己的上级。要想知道自己教主的名称,只能一级级查上去。
因此可以视 find(x) 这个函数就是找教主(代表元)用的。
int find(int x) //查找x的代表元 { while(pre[x] != x) //如果x的上级不是自己(则说明找到的人不是代表元) x = pre[x]; //x继续找他的上级,直到找到代表元为止 return x; //已找到 }
以上为未优化版本,以下为优化后代码:
// 并查集里寻根的过程 int find(int u) { if (u == father[u]) return u; // 如果根就是自己,直接返回 else return find(father[u]); // 如果根不是自己,就根据数组下标一层一层向下找 }
期望寻根时更快,路径压缩,将非根节点的所有节点直接指向根节点:
// 并查集里寻根的过程 int find(int u) { if (u == father[u]) return u; else return father[u] = find(father[u]); // 路径压缩 }
再用三元表达式来精简一下,则代码如下:
int find(int u) { return u == father[u] ? u : father[u] = find(father[u]); }
join( )函数的定义与实现
join(x,y) 的执行逻辑如下:
1、寻找 x 的代表元(即教主);
2、寻找 y 的代表元(即教主);
3、如果 x 和 y 不相等,则随便选一个人作为另一个人的上级,如此一来就完成了 x 和 y 的合并。void join(int x,int y) //我想让x和y所处的两派合并 { int fx=find(x), fy=find(y); //找到x的代表元是fx,y的代表元是fy if(fx != fy) //fx 和 fy 不是同一个人 pre[fx]=fy; //其中一人成为另一人的上级,即合并成功 }
isSame( )函数的定义与实现
判断两个元素是否在同一个集合里。
通过 find函数 找到 两个元素的跟,若属于同一个根,则这两个元素为同一个集合。
// 判断 u 和 v是否找到同一个根 bool isSame(int u, int v) { u = find(u); v = find(v); return u == v; }
路径压缩算法之一(优化find( )函数)
join(x,y)的执行逻辑如下:
1、寻找 x 的代表元(即教主);
2、寻找 y 的代表元(即教主);
3、如果 x 和 y 不相等,则随便选一个人作为另一个人的上级,如此一来就完成了 x 和 y 的合并。if(fx != fy) pre[fx]=fy;
这里并没有明确谁是谁的前驱(上级)的规则,而是我直接指定后面的数据作为前面数据的前驱(上级)。那么这样就导致了最终的树状结构无法预计,即有可能是良好的 n 叉树,也有可能是单支树结构(一字长蛇形)。试想,如果最后真的形成单支树结构,那么它的效率就会及其低下(树的深度过深,那么查询过程就必然耗时)。
而我们最理想的情况就是所有人的直接上级都是教主(代表元),这样一来整个树的结构就只有两级,此时查询教主只需要一次。因此,这就产生了路径压缩算法。路径压缩所实现的功能就是:
查询代表元只需询问一级,经过一次查询后整个树的高度大大降低。
实现:
当从某个节点出发去寻找它的根节点时,会途径一系列的节点。在这些节点中,除了根节点外(即曹操),其余所有节点(即夏侯惇、郭嘉、曹植)都需要更改直接前驱为曹操(根节点即代表元)。
因此,基于这样的思路,可以通过递归的方法来逐层修改返回时的某个节点的直接前驱(即pre[x]的值)。简单说来就是将x到根节点路径上的所有点的pre(上级)都设为根节点。
int find(int x) //查找结点 x的根结点 { if(pre[x] == x) return x; //递归出口:x的上级为 x本身,即 x为根结点 return pre[x] = find(pre[x]); //此代码相当于先找到根结点 rootx,然后pre[x]=rootx }
该算法存在一个缺陷:
只有当查找了某个节点的代表元(教主)后,才能对该查找路径上的各节点进行路径压缩。换言之,第一次执行查找操作的时候是实现没有压缩效果的,只有在之后才有效。
路径压缩算法之二(加权标记法)
将所有黑线换成红线,如下图:
即
主要思路:
加权标记法需要将树中所有节点都增设一个权值,用以表示该节点所在树中的高度(比如用rank[x]=3表示 x 节点所在树的高度为3)。这样一来,在合并操作的时候就能通过这个权值的大小来决定谁当谁的上级。在合并操作的时候,假设需要合并的两个集合的代表元(教主)分别为 x 和 y,则只需要令pre[x] = y 或者pre[y] = x 即可。但我们为了使合并后的树不产生退化(即:使树中左右子树的深度差尽可能小),那么对于每一个元素 x ,增设一个rank[x]数组,用以表达子树 x 的高度。在合并时,如果rank[x] < rank[y],则令pre[x] = y;否则令pre[y] = x。
实现:
加权标记法的核心在于对rank数组的逻辑控制,其主要的情况有:
1、如果 rank[x] < rank[y] ,则令pre[x] = y;
2、如果 rank[x] == rank[y],则可任意指定上级;
3、如果 rank[x] > rank[y] ,则令pre[y] = x;
在实际写代码时,为了使代码尽可能简洁,我们可以将第1点单独作为一个逻辑选择,然后将2、3点作为另一个选择(反正第2点任意指定上级嘛),所以具体的代码如下:void union(int x,int y) { x=find(x); //寻找 x的代表元 y=find(y); //寻找 y的代表元 if(x==y) return ; //如果 x和 y的代表元一致,说明他们共属同一集合,则不需要合并,直接返回;否则,执行下面的逻辑 if(rank[x]>rank[y]) pre[y]=x; //如果 x的高度大于 y,则令 y的上级为 x else //否则 { if(rank[x]==rank[y]) rank[y]++; //如果 x的高度和 y的高度相同,则令 y的高度加1 pre[x]=y; //让 x的上级为 y } }
总结
1 、用集合中的某个元素来代表这个集合,则该元素称为此集合的代表元;
2 、一个集合内的所有元素组织成以代表元为根的树形结构;
3 、对于每一个元素 x,pre[x] 存放 x 在树形结构中的父亲节点(如果 x 是根节点,则令pre[x] = x);
4 、对于查找操作,假设需要确定 x 所在的的集合,也就是确定集合的代表元。可以沿着pre[x]不断在树形结构中向上移动,直到到达根节点。
因此,基于这样的特性,并查集的主要用途有以下两点:
1、维护无向图的连通性(判断两个点是否在同一连通块内,或增加一条边后是否会产生环);
2、用在求解最小生成树的Kruskal算法里。
模板一:按秩(rank)合并
一般来说,一个并查集对应三个操作:
1、初始化( Init()函数 )
2、查找函数( Find()函数 )
3、合并集合函数( Join()函数 )const int N=1005 //指定并查集所能包含元素的个数(由题意决定) int pre[N]; //存储每个结点的前驱结点 int rank[N]; //树的高度 void init(int n) //初始化函数,对录入的 n个结点进行初始化 { for(int i = 0; i < n; i++){ pre[i] = i; //每个结点的上级都是自己 rank[i] = 1; //每个结点构成的树的高度为 1 } } int find(int x) //查找结点 x的根结点 { if(pre[x] == x) return x; //递归出口:x的上级为 x本身,则 x为根结点 return find(pre[x]); //递归查找 } int find(int x) //改进查找算法:完成路径压缩,将 x的上级直接变为根结点,那么树的高度就会大大降低 { if(pre[x] == x) return x; //递归出口:x的上级为 x本身,即 x为根结点 return pre[x] = find(pre[x]); //此代码相当于先找到根结点 rootx,然后 pre[x]=rootx } bool isSame(int x, int y) //判断两个结点是否连通 { return find(x) == find(y); //判断两个结点的根结点(即代表元)是否相同 } bool join(int x,int y) { x = find(x); //寻找 x的代表元 y = find(y); //寻找 y的代表元 if(x == y) return false; //如果 x和 y的代表元一致,说明他们共属同一集合,则不需要合并,返回 false,表示合并失败;否则,执行下面的逻辑 if(rank[x] > rank[y]) pre[y]=x; //如果 x的高度大于 y,则令 y的上级为 x else //否则 { if(rank[x]==rank[y]) rank[y]++; //如果 x的高度和 y的高度相同,则令 y的高度加1 pre[x]=y; //让 x的上级为 y } return true; //返回 true,表示合并成功 }
模板二:路径压缩
由模板二可知,并查集主要有三个功能:
- 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个
- 将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上
- 判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点
int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好 vector<int> father = vector<int> (n, 0); // C++里的一种数组结构 // 并查集初始化 void init() { for (int i = 0; i < n; ++i) { father[i] = i; } } // 并查集里寻根的过程 int find(int u) { return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩 } // 判断 u 和 v是否找到同一个根 bool isSame(int u, int v) { u = find(u); v = find(v); return u == v; } // 将v->u 这条边加入并查集 void join(int u, int v) { u = find(u); // 寻找u的根 v = find(v); // 寻找v的根 if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回 father[v] = u; }
模板三:按秩(rank)合并
rank 表示树的高度,即树中结点层次的最大值。
rank 小的树合入 到 rank大 的树,这样可以保证最后合成的树rank 最小,降低在树上查询的路径长度。
int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好 vector<int> father = vector<int> (n, 0); // C++里的一种数组结构 vector<int> rank = vector<int> (n, 1); // 初始每棵树的高度都为1 // 并查集初始化 void init() { for (int i = 0; i < n; ++i) { father[i] = i; rank[i] = 1; // 也可以不写 } } // 并查集里寻根的过程 int find(int u) { return u == father[u] ? u : find(father[u]);// 注意这里不做路径压缩 } // 判断 u 和 v是否找到同一个根 bool isSame(int u, int v) { u = find(u); v = find(v); return u == v; } // 将v->u 这条边加入并查集 void join(int u, int v) { u = find(u); // 寻找u的根 v = find(v); // 寻找v的根 if (rank[u] <= rank[v]) father[u] = v; // rank小的树合入到rank大的树 else father[v] = u; if (rank[u] == rank[v] && u != v) rank[v]++; // 如果两棵树高度相同,则v的高度+1,因为上面 if (rank[u] <= rank[v]) father[u] = v; 注意是 <= }
注意:上面的模板代码中,是没有做路径压缩的,因为一旦做路径压缩,rank记录的高度就不准了,根据rank来判断如何合并就没有意义。
也可以在 路径压缩的时候,再去实时修生rank的数值,但这样在代码实现上麻烦了不少,关键是收益很小。
总结:
优化并查集查询效率时,只用路径压缩的思路就够了,不仅代码实现精简,而且效率足够高。
按秩合并的思路并没有将树形结构尽可能的扁平化,所以在整理效率上是没有路径压缩高的。
最小生成树
以最小权值和为前提,连接所有节点生成有向树。
prim 算法 (维护节点的集合)
prim算法是从节点的角度采用贪心的策略每次寻找距离最小生成树最近的节点并加入到最小生成树中。
prim三部曲
- 第一步,选距离生成树最近节点
- 第二步,最近节点加入生成树
- 第三步,更新非生成树节点到生成树的距离(即更新minDist数组)
prim算法最核心要点所在:
minDist 数组用来记录 每一个非生成树节点距离最小生成树的最近距离。
同时也是 最小生成树的 边的权值。
注意,我们根据minDist数组,选取距离生成树最近的节点加入生成树,
那么minDist数组里记录的其实也是最小生成树的边的权值(图中把权值对应的是哪两个节点也标记出来)。
Kruskal 算法 (维护边的集合)
1
kruscal的思路:
- 边的权值排序,因为要优先选最小的边加入到生成树里
- 遍历排序后的边
- 如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环
- 如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合
总结 (边少的稀疏图用Kruskal, 边数节点数接近或节点皆相连的稠密图用prim)
求最小生成树
Kruskal 与 prim 的关键区别在于:
prim 维护的是节点的集合,而 Kruskal 维护的是边的集合。
如果 一个图中,节点多,但边相对较少,则 使用 Kruskal 更优。
例:
如上图,明显边没有那么多,即 相对节点来说,边的数量更少。
则该情况下使用 Kruskal 更优。
为什么边少使用 Kruskal 更优呢?
因为 Kruskal 是对边根据边权值进行从小到大排序后,
进行遍历操作,依据边的两端节点是否处于同一集合来决定该边是否加入到最小生成树中。
边如果少,则遍历操作的次数就少。
在节点数量固定的情况下,图中的边越少,Kruskal 需要遍历的边也就越少。
----------------
而 prim 算法是对节点进行操作的,节点数量越少,prim算法效率就越优。
所以在 稀疏图中,用Kruskal更优。 在稠密图中,用prim算法更优。
边数量较少为稀疏图,接近或等于完全图(所有节点皆相连或几乎都相连)为稠密图
Prim 算法 时间复杂度为 O(n^2),其中 n 为节点数量,它的运行效率和图中边树无关,适用稠密图。
Kruskal算法 时间复杂度 为 nlogn,其中 n 为边的数量,适用稀疏图。
拓扑排序
- 将有向图转成线性排序
- 判断有向图中是否存在环
拓扑排序是经典的图论问题。
给出一个 有向图,把这个【有向图转成线性的排序】 就叫【拓扑排序】。(有向图转成线性的排序)
拓扑排序 也是图论中【判断有向无环图】的常用方法。
只要能把 有向无环图 进行线性排序 的算法 都可以叫做 拓扑排序。
实现拓扑排序的算法有两种:卡恩算法(BFS)和DFS。(卡恩1962年提出这种解决拓扑排序的思路)
拓扑排序的过程,其实就两步:
- 找到入度为0 的节点,加入结果集
- 将该节点从图中移除
循环以上两步,直到 所有节点都在图中被移除了。
结果集的顺序,就是我们想要的拓扑排序顺序 (结果集里顺序可能不唯一)
自己理解的拓扑排序过程:
在循环中找到入度为0的节点,
同时将 以该节点为出发点所连接的 节点的入度 做 减一 的操作。(即从原图中将入度为0的节点删除掉)
直到 已经找不到任何 入度为0的节点。
此时 所有节点集有【结果集】和 原图本身存在的【原图节点集】。
若【结果集】元素个数 不等于 【原图节点集】个数,则说明图中一定有 有向环!
若【结果集】元素个数 与 本身原图节点数 相同,则 图中没有环,能顺利转成线性排序。
拓扑排序判断有向环:
循环至找不到入度为0 的节点了,发现结果集元素个数 不等于 图中节点个数,则可以认定图中一定有 有向环。
最短路径算法
给出一个有向图,已知起点和终点,问起点到终点的最短路径。
dijkstra 算法 (不可有负权值)
dijkstra 朴素版
dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。(权值非负数的有权图中求最短路径)
需要注意两点:
- dijkstra 算法可以同时求 起点到所有节点的最短路径
- 权值不能为负数
(不可有负权值)的原因:因为访问过的节点 不能再访问,导致错过真正的最短路。
dijkstra三部曲:
- 第一步,选源点到哪个节点近且该节点未被访问过
- 第二步,该最近节点标记为已访问过
- 第三步,更新非访问节点到源点的距离(即更新minDist数组)
prim 和 dijkstra 确实很像,思路也是类似的。
------------
dijkstra 与 prim 算法的唯一区别:(第三步) 更新minDist数组。
因为 prim是求 非访问节点到最小生成树的最小距离;
而 dijkstra是求 非访问节点到源点的最小距离。
prim 更新 minDist数组的写法:
for (int j = 1; j <= v; j++) { if (!isInTree[j] && grid[cur][j] < minDist[j]) { minDist[j] = grid[cur][j]; } }
最小生成树中的prim 因为 minDist表示 【节点到最小生成树的最小距离】,所以 新节点cur的加入,只需要 使用 grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成树后,生成树到 节点j 的距离。
dijkstra 更新 minDist数组的写法:
for (int v = 1; v <= n; v++) { if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { minDist[v] = minDist[cur] + grid[cur][v]; } }
最短路径中的dijkstra 因为 minDist表示 【节点到源点的最小距离】,所以 新节点 cur 的加入,需