专题七(图论)

图论 (Graph theory) 是数学的一个分支,图是图论的主要研究对象。图 (Graph) 是由若干给定的顶点 及连接两顶点的边所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系。顶点用于代表 事物,连接两顶点的边则用于表示两个事物间具有这种关系。

1.基本定义

  • 我们定义一张图G=( V , E ):V代表点集,其中每个元素称作顶点或者节点,简称点 ;E代表边集
  • 图有许多分类,包括无向图,有向图,混合图等等
    • 有向图:每条边 e=(u,v)(或者记作 u\rightarrow v) 是一个有序的二元组,称作有向边,代表点 u 单向可以到达点 v,这里我们把 u 称为起点,v 称为终 点。
    • 无向图:u,v双向可达,它们间的边就是无向边。无向图的每条边都是无向边。
    • 混合图:既有有向边也有无向边
  • 度:一个点相连的边的数量。入度:终点为该点的边的数量。出度:起点为该点的边的数量
  • 图每条边可以有一个长度

2.存图

邻接矩阵

用一个二维数组来储存一个图:若x能到达y,那么(x,y)就代表x到y的距离,如果达到不了,就一般用-1或者无穷大表示。自己到自己为0。以开头那个图为例

12345
101-1-1-1
2-1023-1
31-10-12
4-1-1-104
5-1-1-1-10
  • 邻接矩阵最显著的优点是可以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根树枝,同时根节点与子树也还有一根树枝连接。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值