[算法基础系列]图论基础算法详解

一,图的基本概念

需要熟悉的概念:

(1)有向图和无向图

(2)子图

(3)连通,连通图和连通分量

(4)生成树

(5)顶点的度,入度和出度

(6)边的权和网

(7)稠密图和稀疏图yon

(8)路径长度和回路

二,图的存储及基本操作

1,邻接矩阵法

所谓邻接矩阵存储,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息,存储顶点之间邻接关系的二维数组称为邻接矩阵。对于带权图而言,若顶点vi和vj之间有边相连,则邻接矩阵中对应项存放着该边对应的权值。若不相连,则通常用无穷来代替这两个顶点之间不存在边。

小tips:

(1)在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可忽略)

(2)当邻接矩阵的元素仅表示相应边是否存在时,edgetype可采用值为0和1的枚举类型

(3)无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储

(4)稠密矩阵适合使用邻接矩阵的存储表示

2,邻接表法

所谓邻接表,是指对图G中每个顶点vi建立一个单链表,这个单链表就称为顶点vi的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点。

3,十字链表和邻接多重表

这两种算法题中很少使用

三,图的遍历

1,广度优先搜索

无论是邻接表还是邻接矩阵的存储方式,BFS算法都需要借助一个辅助队列Q。采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),故时间复杂度为O(|V|),在搜索中任意一个顶点的邻接点时,每条边至少访问一次,故时间复杂度为O(|E|)。算法总时间复杂度为(O|V|+O|E|)。采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为O(|V|),故算法总的时间复杂度为O(|V2|)。

2,深度优先搜索

同上面的分析类似,DFS的时间复杂度是O(N2),DFSL的时间复杂度是O(N+E)

四,生成树

1,普里姆(prim)算法

算法的基本思想是:首先从V中任选一个顶点u0,将生成树T置为仅有一个结点u0的树,即置U={u0}。然后只要U是V的真子集,就在所有那些其一个端点u已在T,另一个端点v还未在T的边中,找一条最短的边,并把该条边(u,v)和其不在T中的顶点v,分别并入T的边集TE和顶点集U。MST性质保证上述过程求得的T是G的一棵最小生成树。

关键在于如何找到连接U和V-U的最短边来扩充生成树T。

构造一个较小的候选紫边集,且保证最短紫边属于该候选集。(将所有n-k个蓝点所关联的最短紫边作为候选集)

代码实现
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
const int N=510,INF=0x3f3f3f3f;

int n,m;
int g[N][N];//邻接表
int dist[N]; //候选集
bool st[N]; //U集

int prim()
{
    memeset(dist,0x3f,sizeof dist); //按字节填充
    dist[1]=0; //随机选择一个u0
    int res=0;
    for(int i=0;i<n;i++) //可以理解为U集从u0扩充到V,每次加入一个顶点
    {
        int t=-1;
        for(int j=1;j<=n;j++)
            if(!st[j] && (t==-1 || dist[t] >dist[j]))
                t=j;//找出候选集中最小的一条边对应的未加入U的顶点
        if(dist[t]==INF) return INF; //说明没有连通
        res+=dist[t];
        st[t]=true;

        //更新候选集
        for(int j=1;j<=n;j++)
            dist[j]=min(dist[i],g[t][j]);
    }
    return res;
}

int main()
{
    cin>>n>>m;
    memset(g,0x3f,sizeof g);
    while(m--)
    {
        int a,b,c;
        cin>>a>>b>>c;
        g[a][b]=g[b][a]=min(g[a][b],c);
    }
    int t=prim();
    if(t==INF) cout<<"-1 ";
    else cout<<t<<endl;
}


2,克鲁斯卡尔算法

算法思想:设G=(V,E)是连通网络,令最小生成树的初始状态为只有n个顶点而无边的非连通图T,T中每个顶点自成一个连通分量。我们按照长度递增的顺序依次选择E中的边(u,v),若该边端点u,v分别是当前T中的两个连通分量的顶点,则将该边加入到T中,两个连通分量也由此边连接成一个连通分量。依次类推,直到T中所有顶点都在同一连通分量上为止。

并查集

这里会用到并查集的思想,后续会有章节单独介绍

代码实现
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;
const int N=100010,M=200010,INF=0x3f3f3f3f;

int n,m;
int p[N]; //并查集中使用的父亲结点,也会使用压缩变成祖宗结点

struct Edge
{
    int a,b,w;
    bool operator< (const Edge &W)const
    {
        return w<W.w;
    }
}edges[M];
//这里为什么要用一个结构体,因为我们每次是找到最短的一条边,使用一个统一的结构来进行排序会简化我们的代码实现。其实不用结构体,用vector也是可以的

int find(int x)
{
    if(p[x] !=x) p[x]=find(p[x]);
    return p[x];
}


int kruskal()
{
    sort(edges,edges+m);
    for(int i=1;i<=n;i++) p[i]=1;/初始化每个顶点都是一个连通分量
    
    int res=0,cnt=0;
    for(int i=0;i<m;i++)
    {
        int a = edges[i].a, b = edges[i].b, w = edges[i].w;

        a = find(a), b = find(b); //如果最短边的两个端点不在一个连通分量中
        if (a != b)
        {
            p[a] = b;
            res += w;
            cnt ++ ;
        }
    }
    if (cnt < n - 1) return INF;
    return res;
}
int main()
{
    scanf("%d%d", &n, &m);

    for (int i = 0; i < m; i ++ )
    {
        int a, b, w;
        scanf("%d%d%d", &a, &b, &w);
        edges[i] = {a, b, w};
    }

    int t = kruskal();

    if (t == INF) puts("impossible");
    else printf("%d\n", t);

    return 0;
}

五,最短路径

1,单源最短路径-迪杰斯特拉算法

迪杰斯特拉算法首次提出按路径长度递增序产生诸顶点的最短路径算法。算法的基本思想是,设置并逐步扩充一个集合S,存放已求出其最短路径的顶点,则尚未确定最短路径的顶点集合是V-S。为了直观起见,我们设想S中顶点均被涂成红色,V-S中的顶点均被涂成蓝色。算法初始化时,红点集仅有一个源点,以后的每一步都是按最短路径长度递增的顺序,逐个地把蓝点集中的顶点涂成红色后,加入到红点集中。

扩充红点集的方法,即每一步只要在当前蓝点集中选择一个具有最小距离值的蓝点k扩充到红点集合中,k被涂成红色之后,剩余的蓝点的距离值可能由于增加了新红点k而发生变化(即减少)。因此,我们必须调整当前蓝点集中各蓝点的距离值。调整距离值的方法:对蓝点集扫描检查,若某蓝点j的原距离值D[J-1]大于新路径的长度D[k-1]+边<k,j>上的权,则将D[j-1]修改为此长度值。

代码实现
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=510;
int n,m;
int g[N][N];//g[ 1 ][ 2 ]是指从1节点指向2节点的距离,也可以表示不存在
int dist[N];//distance(距离)的缩写,代表每一个点到源点的距离
bool st[N];//state(状态)的缩写,当st[n]为true时说明这个点到源点的距离最小值就已经确定了
int dijkstra(){
    memset(dist,0x3f,sizeof(dist));//存储每一个点到源点的距离
    dist[1]=0;//源点到自己的距离为0
    for(int i=0;i<n-1;i++){//其实这条语句唯一的作用就是循环n-1次(优化了)
    //所以写成for(int i=0;i<n;i++)也可以,因为如果下面的语句循环了n-1次的话,那么所有点都能得到最小值了
    //可以这么理解,每次循环都会确定一个最小值,还会再创造一个最小值(留给下一次循环去确定)
    //当循环n-1次时,情况是已经确定了n-1个点的最小值了,还创造了一个最小值(此时还有1个点等着下一次去确定)
    //那么就不需要下一次循环了,毕竟剩下的就一个点,在1个点的集合中知道有一个点是最小值,顺理成章了
    //当然你想写成for(int i=0;i<n;i++)也能AC~~小声说~~
        int t=-1;
        for(int j=1;j<=n;j++){
            if(!st[j] and (t==-1 or dist[t]>dist[j])){
                t=j;//!st[j]指的是最近距离还没有确定的点,and后面就是找符合!st[j]条件的距离最小的点
                //这一个操作就是找到未确定最小值的 `点集`中的最小点,t==-1是当第一次遇到未确定~的点时能够被初始化
            }
        }
        //(1)
        for(int j=1;j<=n;j++){//现在找到t了,遍历一遍所有点,有一下几种情况
        //1.j点和t点之间无连接,那么g[t][j]=0x3f3f3f3f,特别大,会被pass
        //2.dist[j]<=dist[t]+g[t][j],源点到j点的距离,如果经过t后距离更长了,那么不考虑
        //3.dist[j]<=dist[t]+g[t][j],,~~,经过t点距离更短了,那么修改dist[j]的值
            dist[j]=min(dist[j],dist[t]+g[t][j]);
        }
        st[t]=true;//当前t点已经把其余点全部遍历了一遍,此点变成确定距离为最小的点了,这条语句放在(1)处也能AC
    }
    if(dist[n]==0x3f3f3f3f){//当前点n没被修改,说明到不了点n,输出-1
        return -1;
    }else{
        return dist[n];//易证
    }
}
int main(){
    cin>>n>>m;//n存点数,m存边数
    memset(g,0x3f,sizeof(g));//将点之间的距离的每一个值设置成很大的数,此知识点之前讲过
    while(m--){
        int a,b,c;
        cin>>a>>b>>c;
        g[a][b]=min(g[a][b],c);//有效解决多条边的问题,保留最短边
    }
    cout<<dijkstra()<<endl;
    return 0;
}

链接:https://www.acwing.com/activity/content/code/content/48488/
来源:AcWing
堆优化版本
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>

using namespace std;
typedef pair<int,int> PII;
const int N=1e6+10;

int n, m;
int h[N], w[N], e[N], ne[N], idx;
int dist[N];
bool st[N];

void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1});

    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.second, distance = t.first;

        if (st[ver]) continue;
        st[ver] = true;

        for (int i = h[ver]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[ver] + w[i])
            {
                dist[j] = dist[ver] + w[i];
                heap.push({dist[j], j});//在pair中把存储距离放在first,节点编号放在second是为了能直接取出小根堆的堆顶元素就是在朴素算法中第一个for循环中找的最小的j
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

int main()
{
    scanf("%d%d", &n, &m);

    memset(h, -1, sizeof h);
    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        add(a, b, c);
    }

    printf("%d\n", dijkstra());

    return 0;
}

作者:yxc
链接:https://www.acwing.com/activity/content/code/content/48493/
来源:AcWing

2,单源最短路径-bellman-ford(有边数限制时使用)

图论详解——Bellman-Ford(清晰易懂)_bellman-ford流程图-CSDN博客

代码实现
#include<cstring>
#include<iostream>
#include<algorithm>

using namespace std;
const int N=510,M=10010;

struct Edge
{
    int a,b,c;
}edges[M];

int n,m,k;
int dist[N];
int last[N];

void bellman_ford()
{
    memset(dist,0x3f,sizeof dist);// 初始时啥也不知道
    dist[1]=0; //作为出发点,距离是已经知道的
    for(int i=0;i<k;i++) //在k条边的限制下
    {
        memcpy(last,dist,sizeof dist);//把前面的结果先保存,因为一次只能更新一步
        for(int j=0;j<m;j++)
        {
            auto e=edges[j];
            dist[e.b]= min(dist[e.b], last[e.a] + e.c);//其实类似于动态规划,在不超过i步的限制下,源点到e点的距离逐步被松弛,每次只选择最松弛的那一步
        }
    }
}

int main()
{
     scanf("%d%d%d", &n, &m, &k);

    for (int i = 0; i < m; i ++ )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        edges[i] = {a, b, c};
    }

    bellman_ford();

    if (dist[n] > 0x3f3f3f3f / 2) puts("impossible");
    else printf("%d\n", dist[n]);

    return 0;
}

3,单源最短路径-spfa算法(更常用,还可以用来判断负环)

SPFA 算法详解( 强大图解,不会都难!)_图解spfa-CSDN博客

适用范围:给定的图存在负权边,这时类似Dijkstra等算法便没有了用武之地,而Bellman-Ford算法的复杂度又过高,SPFA算法便派上用场了。 我们约定有向加权图G不存在负权回路,即最短路径一定存在。

代码实现
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>

using namespace std;

const int N = 100010;

int n, m;
int h[N], w[N], e[N], ne[N], idx;
int dist[N];
bool st[N];

void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q;
    q.push(1);
    st[1] = true;

    while (q.size())
    {
        int t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                if (!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    return dist[n];
}

链接:https://www.acwing.com/activity/content/code/content/48498/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
常见问题:
(1)堆优化版Dijkstra因为算法需要根据节点到源点的距离进行动态选择和排序,最小堆依据这个距离来决定下一个处理的节点。
SPFA算法只需要普通队列来存储节点编号,因为它主要关注哪些节点的最短距离在上一次迭代中被更新,队列中节点的顺序并不基于距离,而是基于它们被发现和更新的顺序
(2)st数组比较好理解的解释:队列中有重复的点没有意义,因为前面假如出现了可以把二这个点变小的值,那就更新,但不用加入队列,因为队列里已经有二这个点了,他肯定会遍历到,然后去更新与他相邻的节点之间的距离,这样就提高了效率,而被淘汰的点之后又会被加入队列是因为此时他的最短距离又被更新了,那么自然和他相连的节点距离也会更新,所以需要把他重新加入队列之中

4,所有顶点对之间的最短路径-floyd算法

思想:递归

代码实现
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 210, INF = 1e9;

int n, m, Q;
int d[N][N];

void floyd()
{
    for (int k = 1; k <= n; k ++ )
        for (int i = 1; i <= n; i ++ )
            for (int j = 1; j <= n; j ++ )
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}

int main()
{
    scanf("%d%d%d", &n, &m, &Q);

    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= n; j ++ )
            if (i == j) d[i][j] = 0;
            else d[i][j] = INF;

    while (m -- )
    {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        d[a][b] = min(d[a][b], c);
    }

    floyd();

    while (Q -- )
    {
        int a, b;
        scanf("%d%d", &a, &b);

        int t = d[a][b];
        if (t > INF / 2) puts("impossible");
        else printf("%d\n", t);
    }

    return 0;
}

作者:yxc
链接:https://www.acwing.com/activity/content/code/content/48531/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
5,拓扑排序算法

邻接表作AOV网的存储结构,讨论拓扑排序算法的实现。为了便于考察每个顶点的入度,我们在顶点表中增加一个入度域id,以指示各个顶点当前的入度值,每个顶点的入度域的值随邻接表动态生成过程中累计得到。在算法中,找入度为零的顶点只要对顶点表的入度域扫描即可。但为了避免在每一步选入度为零的顶点时进行重复扫描,我们可以设置一个链栈来存储所有入度为零的顶点,在进行拓扑排序之前,只要对顶点表扫描一遍,将所有入度为零的顶点都推入栈中,以后每次选入度为零的顶点,就可直接从栈顶取出。一旦排序过程中出现新的入度为零的顶点,也同样将其推入栈中。

代码实现
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 100010;

int n, m;
int h[N], e[N], ne[N], idx;
int d[N];
int q[N];

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

bool topsort()
{
    int hh = 0, tt = -1;

    for (int i = 1; i <= n; i ++ )
        if (!d[i])
            q[ ++ tt] = i;

    while (hh <= tt)
    {
        int t = q[hh ++ ];

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (-- d[j] == 0)
                q[ ++ tt] = j;
        }
    }

    return tt == n - 1;
}

int main()
{
    scanf("%d%d", &n, &m);

    memset(h, -1, sizeof h);

    for (int i = 0; i < m; i ++ )
    {
        int a, b;
        scanf("%d%d", &a, &b);
        add(a, b);

        d[b] ++ ;
    }

    if (!topsort()) puts("-1");
    else
    {
        for (int i = 0; i < n; i ++ ) printf("%d ", q[i]);
        puts("");
    }

    return 0;
}

作者:yxc
链接:https://www.acwing.com/activity/content/code/content/47106/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 26
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值