图的基本概念
图:
- 由点(
node
,或者vertex
)和连接点的边(edge
)组成。 - 图是点和边构成的网。
树: - 特殊的图
- 树,即连通无环图树的结点从根开始,层层扩展子树,是一种层次关系,这种层次关系,保证了树上不会出现环路。
- 两点之间的路径:有且仅有一条路径。
- 最近公共祖先。
图应用背景
- 地图:路口、道路、过路费
- 计算机网络:路由协议
- 人际关系:六度空间理论
图的种类
- 无向无权图,边没有权值、没有方向;
- 有向无权图,边有方向、无权值;
- 加权无向图,边有权值,但没有方向;
- 加权有向图;
- 有向无环图(Directed Acyclic Graph,DAG)。
图算法的时间分析
图算法的复杂度和边的数量 E、点的数量 V 相关。
O(V+E):几乎是图问题中能达到的最好程度。
O(VlogE)、O(ElogV):很好的算法。
O(V2)、O(E2)或更高:不算是好的算法。
图的存储
能快速访问:图的存储,能让程序很快定位结点 u
和 v
的边(u, v)
。
- 数组存边:简单、空间使用最少;无法快递定位
- 邻接矩阵:简单、空间使用最大;定位最快
dis[a][b]
- 邻接表:空间很少,定位较快
- 链式前向星:空间更少,定位较快
注: 存储方式跟题目相匹配,占用空间少定位快也不一定是问题的最优存储方式。
数组存边
优点:简单、最省空间。
缺点:无法定位某条边。
应用:bellman-ford
算法、最小生成树的 kruskal
算法
struct Edge
{int from, to, dis; //from起始点,to终止点,dis权值
}e[M]; //结构体数组存边cin >> n >> m;for(int i = 1; i <= m; ++ i)cin >> e[i].from >> e[i].to >> e[i].dis;
邻接矩阵
二维数组: graph[NUM][NUM]
无向图: graph[i][j] = graph[j][i]
有向图: graph[i][j] != graph[j][i]
权值: graph[i][j]
存结点i到j的边的权值。 例如 graph[1][2]=3,graph[2][1]=5
等等。 用 graph[i][j]=INF
表示i,j之间无边。
优点:
- 适合稠密图;
- 编码非常简短;
- 对边的存储、查询、更新等操作又快又简单。
缺点:
- 存储复杂度 O(V^2)太高。V=10000 时,空间 100M。
- 不能存储重边。
邻接表和链式前向星
邻接表(指针或数组下标)和链式前向星(容器模拟)的思路一样,只是表达方式不同。
应用场景:大稀疏图
优点:
- 存储效率非常高,存储复杂度O(V+E)
- 能存储重边
struct edge{int from, to; long long w; //起点,终点,权值。起点from并没有用到,e[i]的i就是fromedge(int a, int b,long long c){from=a; to=b; w=c;}
};
vector<edge>e[N]; //用于存储图
最短路问题
最广为人知的图论问题就是最短路径问题。
简单图的最短路径
- 树上的路径:任意 2 点之间只有一条路径
- 所有边长都为 1 的图:用
BFS
搜最短路径,复杂度 O(n+m)
普通图的最短路径 - 边长:不一定等于 1,而且可能为负数
- 算法:
Floyd
、Dijkstra
、SPFA
等,各有应用场景,不可互相替代
最短路算法比较
问题 | 边权 | 算法 | 时间复杂度 |
---|---|---|---|
一个起点,一个终点 | 非负数; 无边权(或边权为 1) | Astar(K短路)/ 普通 | <O((m+n)logn) |
双向广搜 | <O((m+n)logn) | ||
贪心最优搜索 | <O(m+n) | ||
一个起点到其他所有点 | 无边权(或边权为 1) | BFS | O(m+n) |
非负数 | Dijkstra(堆优化 优先队列) | O((m+n)logn) | |
允许有负数 | SPFA | <O(mn) | |
所有点对之间 | 允许有负数 | Floyd-Warshall | O(n^3) |
什么算法也不能解决存在负环图的最短路的问题!最多是判断是否存在,或者找到负环。
网站推荐:CSAcademy Graph Editor
方便图论的学习。
Floyd 算法
- 最简单的最短路径算法,代码仅有 4 行
- 存图:最简单的矩阵存图
- 易懂,比暴力的搜索更简单易懂。
- 效率不高,不能用于大图
- 在某些场景下有自己的优势,难以替代。能做传递闭包问题(离散数学)
for(int k = 1; k <= n; k ++) //floyd的三重循环for(int i = 1; i <= n; i ++)for(int j = 1; j <= n; j ++) // k循环在i、j循环外面dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
Floyd算法
:多源
最短路算法,一次计算能得到图中每一对结点之间(多对多
)的最短路径。
Dijkstra
、Bellman-Ford
、SPFA
算法:单源
最短路径算法(Single source shortest path algorithm
),一次计算能得到一个起点到其他所有点(一对多
)的最短路径。
Floyd 算法思想:动态规划
Floyd
算法的原理
- 动态规划:求图上两点
i
、j
之间的最短距离,按“从小图到全图”的步骤,在逐步扩大图的过程中计算和更新最短路。 - 定义状态:
dp[k][i][j],i、j、k
是点的编号,范围1 ~ n
。状态dp[k][i][j]
表示在包含1 ~ k
点的子图上,点对i、j
之间的最短路。 - 状态转移方程:从子图
1 ~ k-1
扩展到子图1 ~ k
dp[k][i][j]= min(dp[k−1][i][j], dp[k−1][i][k]+dp[k−1][k][j])
首先是包含
1 ~ k-1
点的子图。
dp[k−1][i][j]
:不包含k
点子图内的点对i、j
的最短路;
dp[k−1][i][k]+dp[k−1][k][j]
:经过 k 点的新路径的长度,即这条路径从i
出发,先到k
,再从k
到终点j
。
比较:不经过k
的最短路径dp[k−1][i][j]
和经过k
的新路径,较小者就是新的dp[k][i][j]
。
所以 Floyd
的原理就是每次引入一个新的点,用它去更新其他点的最短距离。
k
从1
逐步扩展到n
:最后得到的dp[n][i][j]
是点对i、j
之间的最短路径长度。
初值dp[0][i][j]
:若i、j
是直连的,就是它们的边长;若不直连,赋值为无穷大。
i、j
是任意点对:计算结束后得到了所有点对之间的最短路。
dp[k][i][j] = min(dp[k−1][i][j], dp[k−1][i][k]+dp[k−1][k][j])
用滚动数组简化:dp[i][j] = min(dp[i][j], dp[i][k]+dp[k][j])
for (int k = 1; k <= n; k ++) //floyd的三重循环for (int i = 1; i <= n; i ++)for (int j = 1; j <= n; j ++) //k循环在i、j循环的外面dp[i][j] = min(dp[i][j], dp[i][k]+dp[k][j]);//比较:不经过k、经过k
特点:
- 在一次计算后求得所有结点之间的最短距离。
- 代码极其简单,是最简单的最短路算法。
- 效率低下,计算复杂度是 O(n^3),只能用于 n < 300 的小规模的图。
- 存图用邻接矩阵
dp[][]
。因为Floyd
算法计算的结果是所有点对之间的最短路,本身就需要n^2
的空间,用矩阵存储最合适。 - 能判断负圈。
- 负圈:若图中有权值为负的边,某个经过这个负边的环路,所有边长相加的总长度也是负数,这就是负圈。在这个负圈上每绕一圈,总长度就更小,从而陷入在负圈上兜圈子的死循环。
Floyd
算法很容易判断负圈,只要在算法运行过程出现任意一个dp[i][i] < 0
就说明有负圈。因为dp[i][i]
是从i
出发,经过其他中转点绕一圈回到自己的最短路径,如果小于零,就存在负圈。
蓝桥公园
【题目描述】
小明来到了蓝桥公园。已知公园有N个景点,景点和景点之间一共有M条道路。小明有Q个观景计划,每个计划包含一个起点st和一个终点ed,表示他想从st去到ed。但是小明的体力有限,对于每个计划他想走最少的路完成,你可以帮帮他吗?
【输入描述】
输入第一行包含三个正整数N,M,Q。
第2到M+1行每行包含三个正整数u,v,w,表示 u、v之间存在一条距离为w的路。
第M+2到M+Q-1行每行包含两个正整类st,ed,其含义如题所述
1≤N≤400,1≤M≤NX(N-1)/2,Q≤10^3, 1≤u,v,st,ed≤n, 1≤w≤10^9
【输出描述】
输出共Q行,对应输入数据中的查询。若无法从st到达ed则输出-1。
#include <bits/stdc++.h>using namespace std;
const long long INF = 0x3f3f3f3f3f3f3f3fLL;
//这样定义INF的好处是:INF <= INF+x 防止溢出
const int N= 405;
long long dp[N][N];
int n, m, q;void input(){}
void floyd()
{for(int k = 1; k <= n; k ++)for(int i = 1; i <= n; i ++)for(intj = 1; j <= n; j ++)dp[i][j]= min(dp[i][j], dp[i][k] + dp[k][j]);
}
int main()
{cin >> n>> m >> q;memset(dp, 0x3f, sizeof(dp)); //初始化for(int i = 1; i <= m; i ++){int u, v;long long w;cin >> u >> V >> W;dp[u][v] = dp[v][u] = min(dp[u][v], w); //防止有重边}floyd();while(q --){int s, t;cin >> s >> t;if(dp[s][t] == INF)cout << "-1" << endl;else if(s == t)cout << "0" << endl; //如果不这样,dp[il[i]不等于0else cout << dp[s][t] << endl;return 0;
}
Dijkstra 算法
- Dijkstra:单源最短路径问题。
- 优点:非常高效而且稳定。
- 缺点:只能处理不含有负权边的图。
- 思路:贪心思想+优先队列。
算法思想
Dijkstra
算法算是贪心思想实现的,
首先把起点到所有点的距离存下来找个最短的,然后松弛一次再找出最短的,
所谓的松弛操作就是,遍历一遍看通过刚刚找到的距离最短的点作为中转站会不会更近,如果更近了就更新距离,这样把所有的点找遍之后就存下了起点到其他所有点的最短距离。
为什么是每次都是找最小的?
因为最小边的不会被其它的点松弛,只有可能最小边去松弛别人。
如果存在一个点 K 能够松弛 ab 的话那么一定有 ak 距离加上 kb 的距离小于 ab,已知 ab 最短,所以不存在 ak+kb<ab。
Dijkstra
算法应用了贪心法的思想,即“抄近路走,肯定能找到最短路径”。
算法高效稳定:
Dijkstra
的每次迭代,只需要检查上次已经确定最短路径的那些结点的邻居,检查范围很小,算法是高效的;- 每次迭代,都能得到至少一个结点的最短路径,算法是稳定的
优先队列实现:
- 每次往队列中放新数据时,按从小到大的顺序放,采用小顶堆的方式,复杂度是 O(logn),保证最小的数总在最前面;
- 找最小值,直接取
B
的第一个数,复杂度是 O(1)。 - 复杂度:用优先队列时,
Dijkstra
算法的复杂度是 O(mlogn),是最高效的最短路算法。
维护两个集合:已确定最短路径的结点集合 A
、这些结点向外扩散的邻居结点集合 B
。
- 把起点
s
放到A
中,s-s的距离是0,相当于找到第一条最短路径,用这条最短路去松弛,把s
所有的邻居放到B
中。此时,邻居到s
的距离就是直连距离。 - 从
B
中找出距离起点s
最短的结点u
,放到A
中。 - 把
u
所有的新邻居放到B
中。显然,u
的每一条边都连接了一个邻居,每个新邻居都要加进去。其中u
的一个新邻居v
,它到s
的距离dis(s, v)
等于dis(s, u) + dis(u, v)
。(要和原始距离dis(s, v)
去比较) - 重复(2)、(3),直到
B
为空时,结束。
![[Pasted image 20240325172845.png]]
A{1}; B{3, 10};
//会从3开始去扩展,因为3是最短的
A{1, 6}; B{3, 10, 4, 9};
//把6节点放进去,6到5是1,6到4是6,1到5是4,1到4是9
//把2节点放进去,6到2是2,1到2是5
A{1, 6}; B{3, 10, 4, 9, 5};
//排序后,从3开始,已经用6把所有松弛完了,所以3就没有了,从4开始
//到5这个点是4,所以把5加进来,用5这个点做松弛,5不能做松弛,因为到所有点都是正无穷
A{1, 6, 5}; B{10, 9, 5};
//用B里的5去松弛,从2开始,2到4是5,2到3是7,1到4是10,1到3是12
//之前的10被松弛了,1到6到2是5,1到2是10
//接下来用4这个点松弛,1到6到2到4到3是14,比12大,松弛不了
//下来用12去松弛...
Dijkstra 的局限性是边的权值不能为负数:
Dijkstra 基于 BFS
,计算过程是从起点 s
逐步往外扩散的过程,每扩散一次就用贪心得到到一个点的最短路。
扩散要求路径越来越长,如果遇到一个负权边,会导致路径变短,使扩散失效。
蓝桥王国 lanqiaoOJ 题号 1122
题目描述
蓝桥王国一共有N 个建筑和M 条单向道路,每条道路都连接着两个建筑,每个建筑都有自己编号,分别为 1∼N。(其中皇宫的编号为 1)国王想让小明回答从皇宫到每个建筑的最短路径是多少,但紧张的小明此时已经无法思考,请你编写程序帮助小明回答国王的考核。
输入描述
输入第一行包含 2 个正整数 N,M。
第 2 到 M+1 行每行包含三个正整数 u,v,w,表示 u→v 之间存在一条距离为 w 的路。
1≤N≤3×105,1≤M≤106,1≤ui,vi≤N,0≤wi≤10^9。
输出描述
输出仅一行,共 N 个数,分别表示从皇宫到编号为 1∼N 建筑的最短距离,两两之间用空格隔开。(如果无法到达则输出 −1)
解题思路:
本题为单源最短路的模板题,直接套模板即可,本题我们采用 Dijkstra。
#include <bits/stdc++.h>
using namespace std;
const long long INF = 0x3f3f3f3f3f3f3f3fLL;
//这样定义INF的好处是: INF <= INF+xconst int N= 3e5+2;
struct edge{int from, to; long long w; //起点,终点,权值。起点from并没有用到,e[i]的i就是fromedge(int a, int b,long long c){from=a; to=b; w=c;}
};
vector<edge>e[N]; //用于存储图
//定义一个排序准则
struct s_node{int id; long long n_dis; //id:结点;n_dis:这个结点到起点的距离s_node(int b,long long c){id=b; n_dis=c;}bool operator < (const s_node & a) const{ return n_dis > a.n_dis;}
};int n,m;
int pre[N]; //记录前驱结点,用于生成路径
void print_path(int s, int t) { //打印从s到t的最短路if(s==t){ printf("%d ", s); return; } //打印起点print_path(s, pre[t]); //先打印前一个点printf("%d ", t); //后打印当前点。最后打印的是终点t
}
long long dis[N]; //记录所有结点到起点的距离
void dijkstra(){int s = 1; //起点s是1bool done[N]; //done[i]=true表示到结点i的最短路径已经找到for (int i = 1; i <= n; i ++) {dis[i]=INF; done[i]=false; } //初始化,所有的距离都初始化为无穷大,都没找到dis[s]=0; //起点到自己的距离是0priority_queue <s_node> Q; //优先队列,存结点信息Q.push(s_node(s, dis[s])); //起点进队列,用它去松弛//当队列不为空的情况下while (!Q.empty()) {//每次在B中去一个最小值s_node u = Q.top(); //pop出距起点s距离最小的结点uQ.pop(); //取队头,用队头去松弛if(done[u.id]) continue; //丢弃已经找到最短路径的结点。即集合A中的结点done[u.id]= true;//没有找到的话,就说明没有被用过,把它标记为使用//用它去松弛所有的点for (int i=0; i<e[u.id].size(); i++) { //检查结点u的所有邻居edge y = e[u.id][i]; //u.id的第i个邻居是y.toif(done[y.to]) continue; //丢弃已经找到最短路径的邻居结点if (dis[y.to] > y.w + u.n_dis) {dis[y.to] = y.w + u.n_dis;Q.push(s_node(y.to, dis[y.to])); //扩展新的邻居,放到优先队列中//被松弛的点,再放到B的数列中来pre[y.to]=u.id; //如果有需要,记录路径}}}// print_path(s,n); //如果有需要,打印路径: 起点1,终点n
}
int main()
{scanf("%d%d",&n,&m);for (int i=1;i<=n;i++) e[i].clear(); //清空while (m--) {int u, v, w; scanf("%d%d%lld", &u, &v, &w);e[u].push_back(edge(u, v, w));//以u为起点,vector里面放了uvw// e[v].push_back(edge(v, u, w)); //本题是单向道路,不需要建反边}dijkstra();for(int i=1;i<=n;i++){if(dis[i]>=INF) cout<<"-1 ";else printf("%lld ", dis[i]);}
}