图论2023年版
什么是图?图实际上是一个二元组
每一条边都可以描述成(u,v),u和v都是V中的元素 (v,w∈V)
点数,即V中元素个数也称为G的阶
图的分类
- 图可以按照边有无方向分类,分为有向图和无向图
无向图: 每条边都是无向边的图称为无向图
有向图: 每条边都是有向边的图称为有向图
如果一张图里有的边是有向的,有的是无向的,那它是混合图 - 图可以按照边的数量分为,简单图和多重图
简单图:没有自环和重边的图
简单图:有自环和重边的图
图的度
在无向图中,与这个结点相连的边的个数,称为结点的度。比如在图1-1中,B的度数为4
在有向图中,结点的度分为入度和出度。
结点的入度:以这个结点为终点的有向边的个数
结点的出度:以这个结点为起点的有向边的个数
性质:图的总度数=边数*2
路径
途径:一个边的序列,满足后一条边的起点是前一条边的终点
迹: 如果一条途径中的边互不相同,就可以被称为迹
路径: 一条迹中,如果经过的点互不相同,就可以被称为“路径”
回路: 起点和终点相同的路径,称为“回路”
环: 若回路中除了起点、重点外,其他点互不相同,称为“环”
连通
无向图中:从任意点,都存在到达其它点的路径,称为连通图。
有向图中:从任意点,都存在到达其它点的路径,称该有向图是强连通的。
子图
对于图G和图G’,如果图G’ 的边集和点集都是图G的边集和点集的子集,那么图G’是图G的子图
导出子图
对于图G和图G’,如果图G’ 的点集都是图G点集的子集,那么图G’是图G的导出子图,其边集不一定是图G边集的子集。
补图
对于图G和图G’,如果2个图的点集相同,边集没有交集,并且边集为完全图的边集,那么图G和图G’ 互为补集。
其它概念
- 权值: 可以看成是边的长度
- 完全图: 无向图中,如果任意两点之间都存在一条边,则是一个无向完全图,无向完全图有
条边;有向图中,如果任意两点之间都存在互通的两条边,则是一个有向完全图,有向完全图有 条边。 - 稠密图: 边数接近于完全图的图
- 稀疏图: 边数远远少于完全图的图
- 强连通分量: 有向图中任意两点都连通的最大子图。
图的存储方式
邻接矩阵:int
G[i][j] | 有边 | 没有边 |
---|---|---|
有权图 | 权值 | 正无穷 |
无权图 | 1 | 0 |
邻接表:邻接矩阵存储的缺点在于,当图是稀疏图时,邻接矩阵的存储效率很低。为此,我们图有另外一种存储方式,邻接表。
无权图
vector<int> g[MAXN];
g[i].push_back(j);//i→j 存在有向边
//g[j].push_back(i); 如果是无向图,该代码也得加上。
#include <vector>
vector<int> G[5];
G[0].push_back(1);
G[1].push_back(3);
G[2].push_back(1);
G[3].push_back(0);
G[3].push_back(2);
G[4].push_back(3);
有权图
struct edge{
int to;
int w;
}
vector<edge> g[MAXN];
g[i].push_back((edge){j,w});//i→j 存在有向边,权值为w
g[j].push_back((edge){i,w});//如果是无向图,该代码也得加上。
当然还有直接存边和链式前向星两种方法
直接存边很好理解,就是直接用结构体存下边的起点、终点和权值(无向图也可不及)
缺点是效率较为低下
链式前向星其实就是用链表实现的邻接矩阵,代码就不给了
复杂度 | 找边 | 遍历一点的出边 | 遍历整张图 | 空间复杂度 |
---|---|---|---|---|
直接存边 | O(m) | O(m) | O(nm) | O(m) |
邻接表 | O(*u) | O(*u) | O(n+m) | O(m) |
邻接矩阵 | O(1) | O(n) | O(n^2) | O(n^2) |
链式前向星 | O(*u) | O(*u) | O(n+m) | O(m) |
备注:n指图中的点数,m指图中边数,u指当前遍历的点,*u指u的出度
所谓深搜,就是每一次向着深度更大的结点前进,特点就是不撞南墙不回头
如果把以上的话写成伪代码,应该是这样的:
DFS(u){//当前点为u
mark u//对u做一个到达标记,防止重复经过
for(遍历所有与u连接的点v){
if(v未被标记过){
DFS(v)
}
}
}
这样更容易用C++把它写出来了,下面以邻接表为例
vector<int> g[N];
bool vis[N];//到达标记
void dfs(int u){
vis[u]=true;
for(int i=0;i<g[u].size();i++){
int v=g[u][i];
if(!vis[v]) dfs(v);
}
}
广搜 全称是广度优先搜索,也叫宽度优先搜索
广搜,就是每次先访问同一层的节点,然后再遍历下一层
伪代码:
BFS(x){
mark x
将起点x加入列表
遍历列表的第一个点u,并删掉u
for(遍历所有与u连接的点v){
if(v未被标记过){
加入列表
}
}
}
那么对于广搜,我们会选择用队列来实现
queue<int> q;
vector<int> g[N];
bool vis[N];
void bfs(int x){
vis[x]=true;
q.push(x);
while(!q.empty()){
int u=q.top(); q.pop();
for(int i=0;i<g[u].size();i++){
int v=g[u][i];
if(!vis[v]) q.push(v);
}
}
}
问题模型
Floyd解决的是多源(多个起点)最短路径问题,并且能够求出任意两点间的最短路径。
缺点:复杂度较高(区区3个for而已)
思路
对于这样的一个最短路径问题,我们可以用类似DP的方式来解决
定义
思考一下,
它可以找一个中转点k,于是
那么我们只需要依次枚举k,x,y,来计算即可
代码:
memset(f,0x3f,sizeof(f));
for(int i=1,u,v,w;i<=n;i++){
cin>>u>>v>>w;
f[u][v]=w;
}
for(int k=1;k<=n;k++){
for(int x=1;x<=n;x++){
for(int y=1;y<=n;y++){
f[x][y]=min(f[x][y],f[x][k]+f[k][y]);
}
}
}
Dijkstra解决的是单源(单个起点)最短路径问题,能求出起点到任意一点的最短路径。
思路
先给出一个引理:一个点的最短路径必然在其需要经过的中转点的最短路径的基础上得到的
图2-1中,要求A->E的最短路径,D为中转点,因为A->D的最短路径为A->B->D,所以A->E的最短路径就一定包含A->B->D
算法实现参考代码
int n;
int cost[MAXN][MAXN];
bool found[MAXN];
int dis[MAXN];
void Dijkstra(int s) {
memset(found, false, sizeof(found));
memset(dis, 0x3f, sizeof(dis));
dis[s] = 0;
while(true) {
int u = -1;
for(int i=1; i<=n; i++)
if(!found[i]&&(u==-1||dis[i]<dis[u]))
u = i;
if(u==-1) return;
found[u] = true;
for(int i=1; i<=n; i++)
// if(cost[u][i]<INF)
dis[i] = min(dis[i], dis[u]+cost[u][i]);
}
}
当Djikstra未优化时,时间大部分花在查找更新为蓝点的白点上了。
因此可以借助堆(优先队列)来优化下一次更新的白点的过程。
堆的本质是一个二叉树(二叉树的查找最小值为O(1),删除和插入为O(logn))
优化的方式是将最短距离用小根堆进行维护。查找更新的白点的过程时间复杂度为O(logn)
综上时间复杂度可以降为O(nlogn+E),可以近似看为 O(Elogn),这样时间复杂度就大大降低了。
Dijkstra的堆优化辅助工具
Dijkstra的堆优化需要用到priority_queue(堆)和pair
每次需要从白点中挑选一个距离最小的白点,需要知道每个点的最短距离以及点的编号,此时用pair存储。
而优先队列里每次需要先比较距离,因此在放入pair时,距离放在编号前面。
typedef pair<int,int> P;//距离,编号
priority_queue<P,vector<P>,greater<P> > q;//定义一个小根堆,类型是P 优先比较距离
此时就可以对Dijkstra进行堆优化了
pair
头文件
#include<vector>
用法 用来存储一对相关的数据,实现方式是结构体的方式
写法:pair<type1,type2> p;
type1,type2是数据类型,p是变量名
p=pair<type1,type2>(a,b); a是type1类型的,b是type2类型的。
p.first 表示a, p.second 表示b
可以比较大小,优先比较p.first,再比较p.second
pair用起来不是很方便,比较长,这时候可以借助typedef 给类型名起别名
typedef pair<int,int> P;//pair<int,int> 起了一个别名P
即:
#include<iostream>
#include<cstdio>
#include<vector>
using namespace std;
typedef pair<int,int> P;
P p1,p2;
int main(){
p1=P(1,2);
p2=P(2,3);
printf("(%d,%d)\n(%d,%d)\n",p1.first,p1.second,p2.first,p2.second);
if(p1>p2){
printf("(%d,%d)>(%d,%d)\n",p1.first,p1.second,p2.first,p2.second);
}
else{
printf("(%d,%d)<=(%d,%d)\n",p1.first,p1.second,p2.first,p2.second);
}
return 0;
}
参考代码
typedef pair<int,int> P;//距离,编号
struct edge{
int to, w;
};
vector<edge> g[MAXN];
int dis[MAXN];
void dijkstra(int s){
memset(dis,0x3f,sizeof(dis));
//fill(dis,dis+MAXN,INF);
dis[s]=0;
q.push(P(0,s));
while(!q.empty()){
P t=q.top(); q.pop();
int u=t.second;//编号
if(d[u]<t.first) continue;
// 小根堆里的队首元素 是最小值,如果满足此条件说明已经被更新过了,就不需要再更新。
for(int i=0;i<g[u].size();i++){
edge e=g[u][i];
if(dis[e.to]>dis[u]+g[u][i]){
dis[e.to]=dis[u]+g[u][i];
q.push(P(dis[e.to],e.to));
}
}
}
}
最小生成树问题
n个点,以及n个点之间存在若干条权值不同的边,权值表示建这条边的代价。
现在求应该选哪些边,可以使得n个点连通,并且总花费最小。
定理: n个点,n-1条边的无向连通图,一定是树。
在一个图上生成出的树叫做这个图的一个生成树,边的权值之和最小的生成树叫做这个图的最小生成树。
Prim算法
首先把所有点分为两类:蓝点和白点
已经选 入最小生成树中的点: 蓝点
还没有选入最小生成树中的点:白点
初始化随机一点到最小生成树的距离为0,其它点到最小生成树的距离为inf
每次从白点的集合中挑选出距离已有的最小生成树距离最近的白点,加入最小生成树中
直到所有点都被加入最小生成树
Prim算法模板
int MST_prim(){
int ans=0;
memset(dis,0x3f,sizeof(dis));
memset(found,false,sizeof(found));
dis[1]=0;
while(1){
int u=-1;
for(int i=0;i<=n;i++){
if(!found[i]&&(u==-1||dis[i]<dis[u])){
u=i;
}
}
if(u==-1) break;
found[u]=true;
ans+=dis[u];
for(int i=0;i<=n;i++){
if(!found[i]&&g[u][i]<dis[i]){
dis[i]=g[u][i];
}
}
}
return ans;
}
拓扑排序算法思想
从DAG图中选择一个没有前驱的顶点并输出
从图中删除该顶点和以该顶点为起点的有向边。
重复1、2,直到当前的DAG为空,或者当前图中不存在有前驱的顶点为止。如果最终图不为空,则必然存在环。
参考代码
int n,m;
vector<int> g[MAXN];
queue<int> q;
int f[MAXN],in[MAXN];
void Topsort(){
for(int i=1;i<=n;i++){
if(in[i]==0){
q.push(i);
}
}
while(!q.empty()){
int t=q.front();
cout<<t<<" ";
q.pop();
for(int i=0;i<g[t].size();i++){
int idx=g[t][i];
if(--in[idx]==0){
q.push(idx);
}
}
}
}
- 若图
中存在这样一条路径,使得它恰通过 中每条边一次,则称该路径为欧拉路径
。若该路径是一个圈,则称为欧拉回路
- 我们定义奇点为度为奇数的点,对于存在欧拉路径的图,有以下两个定理:
- 存在欧拉路径的充要条件:图是连通的,有且只有
个奇点 - 存在欧拉回路的充要条件:图是连通的,有
个奇点
- 找欧拉路径(回路)的算法是深搜,时间复杂度为深搜的时间复杂度(邻接矩阵:
;邻接表:
参考代码
通过上面两个图,发现深搜时先序记录是不对的,只能后序遍历
代码如下:
#include <iostream>
#include <cstdio>
#include <vector>
#define MAXN 5010
using namespace std;
int n,m,start;//start是起点
bool g[MAXN][MAXN];//此图用邻接矩阵存储
int degree[MAXN];//每一个点的度
int path[MAXN],cnt;//记录路径
void find(int x){
for(int i=1;i<=n;i++){
if(g[x][i]){
g[x][i]=g[i][x]=false;//该图是无向图
find(i);
}
}
path[++cnt]=x;//记录当前路径,后序记录路径
//注意:只能后序记录,不能先序记录!先序记录是不对的!
//这是因为,如果遇到分叉口,先序遍历的话,一旦走错,就凉了!
}
int main(){
scanf("%d %d",&n,&m);//n为点的个数,m为边的数量
for(int i=1;i<=m;i++){//输入的时候处理度的问题
int x,y;
scanf("%d %d",&x,&y);
g[x][y]=g[y][x]=true;
degree[x]++;
degree[y]++;
}
for(int i=1;i<=n;i++){
if(degree[i]%2==1){
start=i;//存在奇点,以这个点为起点
break;
}
}
if(start==0)//没有奇点
find(1);//从任何一个点开始均可
else//有两个奇点
find(start);//从一个奇点开始,到另一个奇点结束
for(int i=cnt;i>=1;i--){//如果题目要求你按字典序输出,需要逆序输出
printf("%d ",path[i]);
}
return 0;
}
参考代码
#include <iostream>
using namespace std;
int n, m, g[105][105], ans[105];
bool vis[105];
bool dfs(int x, int t) {
if(t>n) {
if(g[x][1]) return true;
return false;
}
for(int i=1; i<=n; i++) {
if(g[x][i] && !vis[i]) {
ans[t] = i;
vis[i] = true;
if(dfs(i, t+1)) return true;
vis[i] = false;
}
}
return false;
}
int main() {
cin >> n >> m;
int u, v;
for(int i=1; i<=m; i++) {
cin >> u >> v;
g[u][v] = g[v][u] = 1;
}
ans[1] = 1;
vis[1] = true;
if(dfs(1, 2)) {
for(int i=1; i<=n; i++)
cout << ans[i] << ' ';
cout << ans[1];
}
else cout << "NO";
return 0;
}
- Kruscal算法将一个连通块当做一个集合
- Kruscal首先将所有边按从小到大顺序排序(一般使用快排),并认为每一个点都是孤立的,分属于
个独立的集合 - 然后按顺序从小到大枚举每一条边。如果这条边连接着两个不同的集合,那么久把这条边加入最小生成树,这两个不同的集合就合并成了一个集合;如果这条边连接的两个点属于同一个集合,就跳过
- 直到选取了
条边结束
参考代码
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
int n,m;
struct edge{
int u,v,cost;
bool operator<(const edge &tmp)const{
if(cost!=tmp.cost)
return cost<tmp.cost;
if(u!=tmp.u)
return u<tmp.u;
return v<tmp.v;
}
}e[20010];
int fa[5010];
void init(){
for(int i=1;i<=n;i++)
fa[i]=i;
}
int find(int x){
if(x==fa[x])
return x;
return fa[x]=find(fa[x]);
}
bool same(int x,int y){
return find(x)==find(y);
}
void unite(int x,int y){
x=find(x);
y=find(y);
if(x==y)
return;
fa[y]=x;
}
int Kruscal(){
int MST=0;
init();
sort(e+1,e+1+m);//e为边集
for(int i=1;i<=m;i++){
if(same(e[i].u,e[i].v)){
unite(e[i].u,e[i].v);
MST+=e[i].cost;
}
}
return MST;
}
int main(){
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++)
scanf("%d %d %d",&e[i].u,&e[i].v,&e[i].cost);
printf("%d",Kruscal());
return 0;
}
算法解决的是一般情况下的单源最短路径问题,在这里,边的权值可以为负数
算法返回一个布尔值,以表明是否存在一个从原点可以到达的权值为负数的环。- 如果存在这样的环,算法将告诉我们不存在解决方案。如果没有这种环存在,算法将给出最短路径和它们的权值
- 算法执行
次,每次遍历边集,至少会找到一个点,使得新的最短路径比原来的更小
伪代码
for i from 1 to n-1: //算法需要重复执行n-1次
for j from 1 to m: //遍历每一条边
if dis[e[j].u]+e[j].w<dis[e[j].v]: //这个if操作叫做松弛
dis[e[j].v]=dis[e[j].u]+e[j].w
for j from 1 to m: //再次执行一次算法
if dis[e[j].u]+e[j].w<dis[e[j].v]: //此时所有的点的最短路径都算出来了
return false //如果有边还能松弛,则有负环
return true //没有负环
- 通过伪代码可以看出,
的时间复杂度为 ,完全劣于 算法的 时间复杂度(也劣于优化的 时间复杂度)
模板
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
using namespace std;
int n,m,st,dis[1010];
struct edge{
int to,cost;
};
vector<edge> g[1010];
bool Bellman_Ford(int s){
memset(dis,0x3f,sizeof(dis));
dis[s]=0;
for(int i=1;i<=n-1;i++){
for(int j=1;j<=n;j++){
for(int k=0;k<g[j].size();k++){
//两层循环遍历整个邻接表
edge e=g[j][k];
if(dis[j]+e.cost<dis[e.to])
dis[e.to]=dis[j]+e.cost;
}
}
}
for(int j=1;j<=n;j++){
for(int k=0;k<g[j].size();k++){
//两层循环遍历整个邻接表
edge e=g[j][k];
if(dis[j]+e.cost<dis[e.to])
return false;
}
}
return true;
//时间复杂度为O(nm),劣于Dijkstra
}
int main(){
cin>>n>>m>>st;
for(int i=1;i<=n;i++){
int u,v,w;
cin>>u>>v>>w;
g[u].push_back((edge){v,w});
}
if(Bellman_Ford(st)){
for(int i=1;i<=n;i++)
cout<<dis[i]<<" ";
}
else{
cout<<"No";
}
return 0;
}
是 算法的一种队列实现,减少了不必要的冗余计算- 主要思想初始时将起点加入队列。每次从队列中取出一个元素,并对所有与它相邻的点进行修改,若某个相邻的点修改成功,则将其入队。直到队列为空时算法结束
- 这个算法,利用了每个店不会更新次数太多的特点发明的
在形式上和广度优先搜索非常类似,不同的是广度优先搜索中一个点除了队列就不可能重新进入队列,但是 中一个点可能在出队列之后再次被放入队列,也就是说一个点修稿过其它的点之后,过了一段时间可能会获得更短的路径,于是再次用来修改其它的点,这样反复进行下去- 时间复杂度:
, 是边的数量, 是一个常数(不好分析),平均值为
核心代码
struct edge {
int nxt, w;
};
queue<int> q;
int dis[1005];
bool exist[1005];//判断一个点是否已经加入了队列中
void SPFA(int s) {
memset(dis, 0x3f, sizeof(dis));
memset(exist, false, sizeof(exist));
dis[s] = 0;
q.push(s);
exist[s] = true;
while(!q.empty()) {
int h = q.front();
q.pop();
exist[h] = false;
for(int i=0; i<g[h].size(); i++) {
edge e = g[h][i];
if(dis[e.nxt]>dis[h]+e.w) {
dis[e.nxt] = dis[h]+e.w;
if(!exist[e.nxt]) {
q.push(e.nxt);
exist[e.nxt] = true;
}
}
}
}
}
问题模型
最大流是网络流中的一种。
题目:令G=(V,E),是一个有源汇点的网络,希望在G上找到最大的流量
算法:Ford-Fulkson算法
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】