图论 (Graph theory) 是数学的一个分支,图是图论的主要研究对象。图 (Graph) 是由若干给定的顶点 及连接两顶点的边所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系。顶点用于代表 事物,连接两顶点的边则用于表示两个事物间具有这种关系。
1.基本定义
- 我们定义一张图G=( V , E ):V代表点集,其中每个元素称作顶点或者节点,简称点 ;E代表边集
- 图有许多分类,包括无向图,有向图,混合图等等
- 有向图:每条边 e=(u,v)(或者记作
) 是一个有序的二元组,称作有向边,代表点 u 单向可以到达点 v,这里我们把 u 称为起点,v 称为终 点。
- 无向图:u,v双向可达,它们间的边就是无向边。无向图的每条边都是无向边。
- 混合图:既有有向边也有无向边
- 有向图:每条边 e=(u,v)(或者记作
- 度:一个点相连的边的数量。入度:终点为该点的边的数量。出度:起点为该点的边的数量
- 图每条边可以有一个长度
2.存图
邻接矩阵
用一个二维数组来储存一个图:若x能到达y,那么(x,y)就代表x到y的距离,如果达到不了,就一般用-1或者无穷大表示。自己到自己为0。以开头那个图为例
1 | 2 | 3 | 4 | 5 | |
1 | 0 | 1 | -1 | -1 | -1 |
2 | -1 | 0 | 2 | 3 | -1 |
3 | 1 | -1 | 0 | -1 | 2 |
4 | -1 | -1 | -1 | 0 | 4 |
5 | -1 | -1 | -1 | -1 | 0 |
- 邻接矩阵最显著的优点是可以O(1)查询一条边的存在。
- 不适合有重边(或者重边可以忽略)的图或者稀疏图(空间较大),一般只会在稠密图(边比点多很多)上使用邻接矩阵。
int G[N][N];
void add(int x,int y) {
G[x][y] = 1;
}
void init() {
//初始化整张图
memset(G,0x3f,sizeof G);
for (int i = 1;i <= n;++i) G[i][i] = 0;
}
//遍历全图
for (int i = 1;i <= n;++i) {
for (int j = 1;j <= n;++j) {
if (i == j) continue;
}
}
邻接表
邻接表本质上是链表,比邻接矩阵更高效。
邻接表由点表和边表组成。Head数组储存以该点为起点(按照时间顺序)最后加入的一条边(也就是表头)。Next数组是以该边起始点为起点的上一条边,也就是链表对应的下一个元素
int head[N],nxt[N],to[N],tot;
//加边
void add(int x,int y) {
++tot;
nxt[tot] = head[x];
head[x] = tot;
to[tot] = y;
}
//遍历x连出的所有边
for (int i = head[x];i;i = nxt[i]) {
int y = to[i];//i代表 x -> y这条边
}
vector <int> G[N];
//如果需要存带边权的用 pair 就可以啦!
void add(int x,int y) {
G[x].push_back(y);
}
for (int y : G[x]) {
}
- 第一个是数组实现,第二个是vector
- 时间复杂度:查询边:O(d(x)) ;遍历所有出边 O(d(x)) ;遍历全图 O(n+m) ;空间复杂度 O(m)
3.最短路径
Floyd
用来求任意两结点间的最短路。复杂度较高,但是常数小,容易实现。适用于任何图,但是最短路径必须存在(不能有个负环)
初始化:
- 不可以直接到达的dis设为正无穷大
- 自己到自己的距离为零
- 根据题目给定的边对dis进行赋值
算法思想:枚举中转节点k,检查由x点经过此点到y点的路径是否比原先优。
同时,不难得出第一维实际上对结果没有影响,所以可以直接二维
void Floyd() {
for (int k = 1;k <= n;++k) {
for (int i = 1;i <= n;++i) {
for (int j = 1;j <= n;++j) {
f[i][j] = min(f[i][j],f[i][k] + f[k][j]);
}
}
}
}
Dijkstra
算法原理:
- 将顶点划分为两堆,起初第一堆只有起点S这一个点
- 每次从第二堆里距离S点最近的点取出,放入第一堆,并更新最短路径,直到第二堆中没有顶点为止
- 此时维护处的dist[ i ]就是S到 i 点的最小距离
该算法只能处理边为正的图,同时在第二步中取最近点时可以使用优先队列,优化时间复杂度
代码实现:
int n,m,s,dis[N];
bool vis[N];
struct node {
int pos,dis;
friend bool operator < (const node &a,const node &b) {
return a.dis > b.dis;
}
};
priority_queue <node> q;
void Dijkstra(int s) {
memset(dis,0x3f,sizeof dis);
memset(vis,0,sizeof vis);
q.push((node){s,dis[s] = 0});
while (!q.empty()) {
node p = q.top();q.pop();
int x = p.pos;
if (vis[x]) continue;
vis[x] = 1;
for (auto e : G[x]) {
int y = e.first,w = e.second;
if (dis[y] > dis[x] + w) {
dis[y] = dis[x] + w;
q.push((node){y,dis[y]});
}
}
}
}
Bellman Ford && SPFA
Bellman Ford:不断尝试对图上每一条边进行松弛操作(就是上面的更新最短路径),每轮循环将所有边都跑一遍,如果一次循环未发现可以松弛的边,就可以停止操作了。由于每轮至少更新一条最短路径,所以时间复杂度是O(nm)。
如果图中存在一个从S出发可以到达的环,同时环的权值之和为负的话,就会进行无数轮循环。所以需通过检查第n轮是否还存在可以松弛的边的方法来判断是否存在负环。
struct Edge {
int u, v, w;
};
vector<Edge> edge;
int dis[MAXN], u, v, w;
constexpr int INF = 0x3f3f3f3f;
bool bellmanford(int n, int s) {
memset(dis, 0x3f, (n + 1) * sizeof(int));
dis[s] = 0;
bool flag = false; // 判断一轮循环过程中是否发生松弛操作
for (int i = 1; i <= n; i++) {
flag = false;
for (int j = 0; j < edge.size(); j++) {
u = edge[j].u, v = edge[j].v, w = edge[j].w;
if (dis[u] == INF) continue;
// 无穷大与常数加减仍然为无穷大
// 因此最短路长度为 INF 的点引出的边不可能发生松弛操作
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
flag = true;
}
}
// 没有可以松弛的边时就停止算法
if (!flag) {
break;
}
}
// 第 n 轮循环仍然可以松弛时说明 s 点可以抵达一个负环
return flag;
}
SPFA:很多时候可能不需要那么多无用的松弛操作。只有上一次被松弛的点,所连接的边才有可能引起下一次松弛操作。所以我们可以使用队列,来确保访问必要的边了。同时,也可以判断S点能否到达一个负环,记录下最短路径经过了多少条边,当经过了至少n条边时,说明S点可以到达一个负环。
在随机图的情况下,时间复杂度为O(km),k是一个不大的常数。但是最坏情况下复杂度为O(nm),容易卡时间,所以在没有负权边的情况下最好还是使用Dijkstra
struct edge {
int v, w;
};
vector<edge> e[MAXN];
int dis[MAXN], cnt[MAXN], vis[MAXN];
queue<int> q;
bool spfa(int n, int s) {
memset(dis, 0x3f, (n + 1) * sizeof(int));
dis[s] = 0, vis[s] = 1;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop(), vis[u] = 0;
for (auto ed : e[u]) {
int v = ed.v, w = ed.w;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
cnt[v] = cnt[u] + 1; // 记录最短路经过的边数
if (cnt[v] >= n) return false;
// 在不经过负环的情况下,最短路至多经过 n - 1 条边
// 因此如果经过了多于 n 条边,一定说明经过了负环
if (!vis[v]) q.push(v), vis[v] = 1;
}
}
}
return true;
}
三种算法的比较
Floyd算法
- 优点1.多源最短路径:Floyd算法可以求解所有顶点对之间的最短路径,即多源最短路径问题。
- 优点2.适合负权边:Floyd算法可以处理图中存在负权边的情况,但不能处理负权环。
- 优点3.实现简单:Floyd算法的实现相对简单,代码结构清晰。
- 缺点1.时间复杂度较高:Floyd算法的时间复杂度为O(N^3) ,其中V是顶点数。对于大规模图,Floyd算法 的效率较低。
- 缺点2.空间复杂度较高:Floyd算法需要储存一个矩阵,空间复杂度为O(N^2)
Dijkstra
- 优点1. 时间复杂度较低:Dijkstra算法使用优先队列(如二叉堆)时,时间复杂度为 O((V+E)logV ) 其中 是顶点数, 是边数。对于稀疏图(边数较少),Dijkstra算法效率较高。
- 优点2. 单源最短路径:Dijkstra算法适用于求解单源最短路径问题,即从一个起点到其他所有顶点的最短 路径。
- 优点3. 适合正权图:Dijkstra算法要求图中边的权重为非负数,因此在正权图中表现良好。
SPFA
- 优点:可以求解带负权边的图:本质上是 Bellman-Ford 算法,所以可以在带有负权边的图跑
总结:
- Dijkstra算法适用于单源最短路径问题,尤其是在正权稀疏图中表现良好,经常在图的大小为10^6,10^5 左右跑。
- Floyd算法适用于多源最短路径问题,尤其是在小规模图或存在负权边的情况下,经常在 n=500 左右跑。
- SPFA 算法适用于存在负权边的单源最短路径问题,主要是在含有负权边的图跑,正常情况下最好 不要在无负权边的图用
4.树
定义
无根树:一个没有固定根节点的树称作无根树。它有几种等价的形式化定义
- 有 n 个结点,n-1条边的连通无向图
- 无向无环的连通图
- 任意两个结点之间有且仅有一条简单路径的无向图
有根树:在无根树的基础上指定一个结点为根,就形成了一棵有根树。大多以无向图表示,只是规定了结点间的上下级关系。以下是有根树的一些相关概念
- 父亲(parent node):对于除根以外的每个结点,定义为从该结点到根路径上的第二个结 点。根结点没有父结点
- 祖先(ancestor):一个结点到根结点的路径上,除了它本身外的结点。根结点的祖先集合 为空。
- 子结点(child node):如果 x 是 y 的父亲,那么 y 是 x 的子结点。子结点的顺序一般不加 以区分,二叉树是一个例外。
- 结点的深度(depth):到根结点的路径上的边数。
- 树的高度(height):所有结点的深度的最大值。
- 兄弟(sibling):同一个父亲的多个子结点互为兄弟。
- 直径:树上任意两节点之间最长的简单路径即为树的「直径」。
- 有根二叉树(rooted binary tree):每个结点最多只有两个儿子(子结点)的有根树称为二叉 树。常常对两个子结点的顺序加以区分,分别称之为左子结点和右子结点。 大多数情况下,二叉树 一词均指有根二叉树。
- 完整二叉树(full/proper binary tree):每个结点的子结点数量均为 0 或者 2 的二叉树。换言 之,每个结点或者是树叶,或者左右子树均非空。
- 完全二叉树(complete binary tree):只有最下面两层结点的度数可以小于 2,且最下面一层的 结点都集中在该层最左边的连续位置上。
- 完美二叉树(perfect binary tree):所有叶结点的深度均相同,且所有非叶节点的子节点数量均 为 2 的二叉树称为完美二叉树。
储存以及遍历
只储存父节点
用一个数组 fa[N] 记录每个结点的父亲结点。
这种方式可以获得的信息较少,不便于进行自顶向下的遍历。常用于自底向上的递推问题中。
邻接表
当作无向图来存就 OK 了。两种方法可以一起用
如何遍历?直接 dfs !过程中记得记录父亲是谁避免重复访问
void dfs(int x,int fa) {
for (auto y : G[x]) {
if (y == fa) continue;
dfs(y,x);
}
}
调用:dfs(root,0);
也可以 bfs 因为有天然的层次性:
queue <int> q;
void bfs(int root) {
q.push(root);
while (!q.empty()) {
int x = q.front();q.pop();
//do something
}
}
调用:bfs(root)
左孩子右兄弟表示法
也叫树的二叉树表示法
树的左指针指向自己的第一个孩子,右指针指向与自己相邻的兄弟。
结构的最大优点是:它和二叉树的二叉链表表示完全一样。可利用二叉树的算法来实现对树的操作
首先,给每个结点的子结点确定一个顺序。
此后每个结点用数组child[ u ]记录第一个子节点,sib[ u ]记录下一个兄弟结点
遍历:
int v = child[u]; // 从第一个子结点开始
while (v != EMPTY_NODE) {
// ...
// 处理子结点 v
// ...
v = sib[v]; // 转至下一个子结点,即 v 的一个兄弟
}
二叉树遍历
- 先序遍历:根、左、右
- 中序遍历:左、根、右
- 后序遍历:左、右、根
例子按照先中后顺序
DFS 序
DFS 序是指 DFS 调用过程中访问的节点编号的序列。我们发现,每个子树都对应 DFS 序列中的连续一段 (一段区间)。
树是一种非线性的数据结构,它的一些数据调用肯定是没有线性结构来得方便的。所以基于 DFS 函数, 我们可以在遍历的同时记录下每个节点进出栈的时间序列。然后我们就把一棵树变成了一个序列,你就 可以用很多数据结构做很多问题啦
void dfs(int x,int fa) {
dfn[x] = ++cnt;
for (auto y : G[x]) {
if (y == fa) continue;
dfs(y,x);
}
}
树形DP
树形 DP,即在树上进行的 DP。由于树固有的递归性质,树形 DP 一般都是递归进行的。
具体来说,在树形动态规划当中,我们一般先算子树再进行合并,在实现上与树的后序遍历相似,都是 先遍历子树,遍历完之后将子树的值传给父亲。简单来说我们动态规划的过程大概就是先递归访问所有 子树,再在根上合并。
例题1:
例题2:
5.习题
(1)Stockbroker Grapevine
#include<bits/stdc++.h>
using namespace std;
const int inf = 0x3f3f3f3f;
int n,m;
int dist[110][110];
int main(){
cin>>n;
while(n){
for(int i=1 ;i<=n ;i++){
for(int j=1 ;j<=n ;j++){
dist[i][j] = inf;
}
}
for(int i=1 ;i<=n ;i++){
cin>>m;
while(m--){
int x,y;
cin>>x>>y;
dist[i][x] = y;
}
}
for(int i=1 ;i<=n ;i++) dist[i][i] = 0;
for(int k=1 ;k<=n ;k++){
for(int i=1 ;i<=n ;i++){
for(int j=1 ;j<=n ;j++){
dist[i][j] = min(dist[i][k]+dist[k][j],dist[i][j]);
}
}
}
int ans[110],max;
for(int i=1 ;i<=n ;i++){
max=0;
for(int j=1 ;j<=n ;j++){
if(dist[i][j]==inf){
max = 0;
break;
}else {
if(dist[i][j]>max) max=dist[i][j];
}
}
ans[i] = max;
}
int min=inf,imin;
for(int i=1 ;i<=n ;i++){
if(ans[i]!=0 && ans[i]<min){
min = ans[i];
imin = i;
}
}
if(min == inf) cout<<"disjoint\n";
else cout<<imin<<" "<<min<<"\n";
cin>>n;
}
}
省流:有向图找出一个点,使得该点到最远点所花的时间在所有点中最小。
解题思路:由于n较小,同时也需要多源的最短路,所以可以采用Floyd。找出各个点的最短路,最后比较就好了。
(2)树的直径
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
vector <int> a[N];
bool vis[N];
int n,dot,ans;
void add(int x,int y){
a[x].push_back(y);
a[y].push_back(x);
}
void dfs(int x,int cn){
vis[x]=1;
int cnt=cn+1;
int l=a[x].size(),flag=0;
for(int i=0 ;i<l ;i++){
if(!vis[a[x][i]]){
flag=1;
vis[a[x][i]]=1;
dfs(a[x][i],cnt);
vis[a[x][i]]=0;
}
}
if(flag==0) {
if(cnt>ans){
ans = cnt;
dot = x;
}
}
}
int main(){
cin>>n;
for(int i=1 ;i<n ;i++){
int x,y;
cin>>x>>y;
add(x,y);
}
for(int i=1 ;i<=n ;i++) vis[i]=0;
ans=0;
dfs(1,0);
for(int i=1 ;i<=n ;i++) vis[i]=0;
dfs(dot,0);
cout<<ans-1;
}
省流:求出一棵树的直径长度
解题思路:根据下面的结论,对树进行两次dfs即可
- 从树的任一结点进行dfs,最后到达的结点必是直径的其中一个端点。
(3)Invitation Cards(反图)
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 1e6+5;
int tot,n,m;
int head[MAXN];
struct edge{
int to,next;
int w;
}e[MAXN];
int dis[MAXN],c[MAXN];
int vis[MAXN];
int a[MAXN],b[MAXN];
void init(){
tot = 0;
memset(head,-1,sizeof(head));
}
void add(int u,int v,int w){
++tot;
e[tot].to = v;
e[tot].w = w;
e[tot].next = head[u];
head[u] = tot;
}
struct Node{
int id;
int d;
bool operator < (const Node &rhs) const{
return d > rhs.d;
}
};
void dfs(int s){
memset(vis,0,sizeof(vis));
memset(dis,0x3f3f3f3f,sizeof(dis));
priority_queue<Node> q;
dis[s] = 0;
q.push({s,dis[s]});
while(!q.empty()){
int id = q.top().id;
q.pop();
if(vis[id]) continue;
vis[id] = 1;
for(int i=head[id];~i;i=e[i].next){
int to = e[i].to;
if(dis[to]>dis[id]+e[i].w){
dis[to] = dis[id]+e[i].w;
q.push({to,dis[to]});
}
}
}
}
void solve(){
cin>>n>>m;
for(int i=1;i<=m;i++){
cin>>a[i]>>b[i]>>c[i];
}
init();
for(int i=1;i<=m;i++) add(a[i],b[i],c[i]);
long long ans=0;
dfs(1);
for (int i=1 ;i<=n ;i++) ans+=dis[i];
init();
for(int i=1;i<=m;i++) add(b[i],a[i],c[i]);
dfs(1);
for (int i=1 ;i<=n ;i++) ans+=dis[i];
cout<<ans<<"\n";
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int t;
cin>>t;
while(t--) solve();
}
省流:在有向图中找到一个结点到其他各个结点的最短路,以及其他结点到该结点的最短路
解题思路:数据较大,同时没有负权边,所以可以采用Dijkstra来求最短路。至于其他结点到指定结点的最短路,我们可以通过反图来解决,将原先图中的有向边的方向反过来,这时再通过Dijkstra得到该点到其他点的最短路就是原先图中其他点到该点的最短路。
(4)战略游戏
#include<bits/stdc++.h>
using namespace std;
const int N=1510;
vector<int> G[N];
int f[N][2],a[N],n;
//0 表示不选
void DP(int x,int fa){
f[x][0] = 0;
f[x][1] = 1;
for (auto y:G[x]){
if (y==fa) continue;
DP(y,x);
f[x][0] += f[y][1];
f[x][1] += min(f[y][0],f[y][1]);
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=0 ;i<n ;i++){
int x,cnt;
cin>>x>>cnt;
for (int j=1 ;j<=cnt ;j++){
int y;
cin>>y;
G[x].push_back(y);
G[y].push_back(x);
}
}
DP(0,-1);
cout<<min(f[0][0],f[0][1]);
return 0;
}
省流:通过尽可能少的点来联通整棵树
解题思路:通过树的DP来解决。f[ x ][ 0/1 ]表示不选/选该点时联通整棵树需要的点。由此可以得出f[ x ][ 0 ] = sum ( f[ y ][ 1 ] ),f [ x ][ 1 ] = sum( min (f[ y ][ 1 ] , f[ y ][ 0 ] ) ) .
(5)飞行路线
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+5;
vector<pair<int,int>> mp[N];
int n,m,k,s,t,dis[N][15];
bool vis[N][15];
struct node {
int dis,num,cnt;
friend bool operator < (const node &a,const node &b){return a.dis>b.dis;}
};
void djst(){
memset(dis,0x3f,sizeof(dis));
priority_queue<node> q;
q.push({dis[s][0]=0,s,0});
while(!q.empty()){
node p=q.top();q.pop();
int x = p.num;
int c = p.cnt;
if (vis[x][c]) continue;
vis[x][c] = 1;
for (auto i:mp[x]){
int y = i.first;
if (c<k && dis[y][c+1] > dis[x][c]){
dis[y][c+1] = dis[x][c];
q.push({dis[y][c+1],y,c+1});
}
if (dis[y][c] > dis[x][c] + i.second){
dis[y][c] = dis[x][c] + i.second;
q.push({dis[y][c],y,c});
}
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n>>m>>k;
cin>>s>>t;
s++;t++;
for (int i=1 ;i<=m ;i++){
int x,y,z;
cin>>x>>y>>z;
x++;y++;
mp[x].push_back({y,z});
mp[y].push_back({x,z});
}
djst();
int ans = 0x3f3f3f3f;
for (int i=0 ;i<=k ;i++) ans = min(ans,dis[t][i]);
cout<<ans;
}
解题思路:构建二维的图,当低处的图升向高处的图时,可以不计权值,同时无法从高处到低处,用上升的次数来代表免票的次数,然后再通过DP。f [ x ][ y ]代表在免票了y次的情况下,到达x的花费总和。
(6)二叉苹果树
#include<bits/stdc++.h>
using namespace std;
const int N=110;
vector<pair<int,int>> tree[N];
int n,m,f[N][N],cnt[N];
void dp(int x,int fa){
for (auto xx:tree[x]){
int to = xx.first;
int val = xx.second;
if (to==fa) continue;
dp(to,x);
cnt[x] += cnt[to]+1;
for (int j=min(m,cnt[x]) ;j>=0 ;j--){
for (int k=min(j-1,cnt[to]) ;k>=0 ;k--){
f[x][j] = max(f[x][j],f[x][j-k-1]+f[to][k]+val);
}
}
}
}
int main(){
cin>>n>>m;
for (int i=1 ;i<=n-1 ;i++){
int x,y,z;
cin>>x>>y>>z;
tree[x].push_back({y,z});
tree[y].push_back({x,z});
}
dp(1,0);
cout<<f[1][m];
}
解题思路:树的DP。用f[ x ][ y ]表示以x为根节点时,砍掉y根树枝后剩余的果子数。 f[x][j] = max(f[x][j],f[x][j-k-1]+f[to][k]+val) :当其中一颗子树砍掉k根树枝,同时根节点与子树也还有一根树枝连接。