图论入门

图的定义

一个图含有\(n\)个点,\(m\)条边。其中每条边连接两个点。记所有点组成的集合为\(V\),所有边组成的集合为\(E\)。如果图上的边有规定方向,即只能从某个点走向另外一个点,那么称整个图是有向图。如果所有的边没有方向的限制,称为无向图。一般的,在储存图的时候,需要指明方向,对于无向图而言,直接当作有两个有向边即可。

图的储存

常见的储存方式有两种,一种是邻接矩阵,一种是邻接表。邻接矩阵的储存方式就是对于一条边,如果他是从\(u\)走向\(v\)长度为\(w\),那么就直接记\(d[u][v] = w\)即以二维数组的方式记录\(u\)\(v\)的边的长度。显然如果整个图有\(n\)个点,那么整个数组的大小就是\(n^2\)

在某些情况下,比如点数很多而边数比较少的时候,以邻接矩阵的方式储存整个图会有大量的位置上的值没有意义,即两个点之间并不直接相连的时候,权值是无意义的。那么为了避免大量的空间的浪费,可以使用邻接表的方式储存整张图,邻接表的思想就是记录每个点出发直接相连的点有哪些。在实际的实现中,以C++为例,C++的STL中有一个结构是vector,他的作用是可变长的数组,即可以直接把一个元素插入到这个序列的末尾,那么在最开始可以给所有点开一个vector,假如有某个点是\(u->v\)那么就向\(u\)位置上的vector的末尾后面插入一个元素v。代码类似如下:

vector<int> Edge[N];// N个点,给每个点开一个vector
//1 通过某个边连向了一个点 2
Edge[1].push_back(2);// push_back表示将某个元素插入这个vector的末尾

那么,这种储存方式,只能储存对应的点的标号是谁,还不能储存权值,我们不妨把上面vector里的元素类型从单个的int换成一个二元组\(pair<int,int>\)以{编号,权值}的方式就可以一并存进去了:

Vector<pii> Edge[N];
// 1通过一个权值是3的边连向了一个点 2
Edge[1].push_back({2,3});

最短路问题

既然我们已经解决了图的储存问题,那么现在可以回到最开始的时候提出的问题,如何求出某些情形下的“最短路”的问题。具体而言,最短路问题可以分成下面两种分别解决。

单源最短路

单源最短路问题指的是,固定一个起点,求从这个起点出发,走到图上任何点上的最短的距离是多少。解决这个问题有两种算法,一种是dijkstra,一种是bellman-ford及其优化spfa。

先来说dijkstra,dijkstra算法的过程是贪心的过程:首先记\(dist[i]\)表示从\(1-i\)的最短距离是多少,其次记两个点的集合S和T,其中S表示已经算完了\(dist[i]\)的点的集合,T表示不在S集合里的点。为了方便这里记起点的编号是\(1\),那么显然有\(dist[1]=0\),其次起点一定一开始就在集合S里了,也就是说一开始S集合就包含一个点\(1\),T集合里包含点\(2-N\)

在最开始的时候,我们只知道\(dist[1]=0\),且与\(1\)直接相连的若干个点的编号,和他们之间的相连的边的权值,于是我们就可以推出来这些相连的点的\(dist[]\)的值,显然就是他们相连的边的权值。其次我们可以发现说,在这些直接相连的点中,存在某一个点\(u\),满足他的\(dist[u]\)小于其他所有点的值,并且这个点\(u\)\(dist[u]\)一定已经是最小的了,也就是说这个\(u\)与起点的最短距离已经求好了,\(u\)可以从T集合中删掉,并加入S集合。之后我们可以如法炮制,第二次拿这个\(u\)当作第一步时的起点,拿他去更新与他直接相连的点的距离。如此可以抽象一下我们的算法过程:

(1)找到当前\(dist[i]\)最小的一个点\(i\),并且\(i\)不在集合S中。

(2)拿\(i\)去更新与他直接相连的\(j\)\(dist[j]\),也就是\(dist[j] = dist[i] + cost(i,j)\)其中\(cost(i,j)\)表示\(i\)\(j\)的边的权值。

(3)把\(i\)从T集合中删掉,并且加入到S集合中。

我们的这个算法如果要保证正确性,那么每次第一步取出来的点\(i\)它的\(dist[i]\)必须要保证是最小的,也就是说当前的取出来的这个点\(i\)其实已经可以放到集合S里去了才可以。那么我们上面最开始介绍这个过程的时候,显然第一步,只有起点的时候是满足的,其次第二步找到的点也是满足这个性质的,那么如果说整个过程都是正确的,其实也就是要归纳证明一下每一步取出来的点都是可以直接放到S集合中去的。那么显然归纳的第一步是正确的,第二步就是假设取出来的前\(k\)个点都满足他们的\(dist[i]\)一定是最小的,要能推出来下一个取出的\(k+1\)的从起点走到他的距离也得满足是最小的,那么通过上面的算法过程我们可以知道,假如说第\(k+1\)次第一步取出来的点是\(v\)的话,那么显然\(dist[v]\)一定是某个属于前\(k\)步取出来的某个点更新过来的,就是说有某个点\(u\)他是前\(k\)步被拿出来的,并且做了一步\(dist[v] = dist[u] + cost(u,v)\)的。假如说这个\(dist[v]\)并不是最小的,也就是违反了我们的规律的话,那么他一定是经过了某个不属于前\(k\)步取出来的点\(g\)算出来的,也就是有某个不属于前\(k\)步的点\(g\),他有\(dist[v] = dist[u] + cost(u,v) > dist[g] + cost(g,v)\)也就是说从\(u\)走到\(v\)的距离比从\(g\)走到\(v\)的距离要严格大,但是根据之前所说的定义,既然\(g\)不属于前\(k\)步取出来的点,那么就一定有\(dist[u] < dist[g]\)否则\(g\)应该属于前\(k\)个取出来的点之中,很显然不可能有某个\(cost(g,v)\)满足加上\(dist[g]\)之后还能比\(dist[u]\)要小,除非他是负数,只要整个图上权都是正数,那么这个条件就一定是不满足的,也就是产生了矛盾,我们通过上面的算法过程取出来的每一个点,都一定有\(dist[v]\)是最小的。

那么既然第一步不会选择已经选择过的点,只要每个点都被取出过一次,也就说明所有点的\(dist[i]\)都已经达到了最小,自然整个算法过程也就结束了,下面给个不一定正确的代码说明:

// dist[i]表示从1走到i的最短距离,假如没有这样的路径,则记为正无穷
// st[i]表示i是否属于S集合,也就是这个点有没有被第一步拿出来
void dijkstra()
{
    dist[1] = 0;
    for(int i = 2;i <= n;++i)	dist[i] = INF;//除了起点1之外的所有点初始的距离都是正无穷
    for(int i = 1;i <= n;++i)
    {
        int t = -1;
        for(int j = 1;j <= n;++j)
            if(!st[j] && t == -1 || dist[t] > dist[j])
                t = j;
        if(t == -1)	break;//假如说已经找不到这样的点t了就直接退出。
        // 找到最小的dist对应的点t
        for(auto& _ : Edge[t])
        {
            // 遍历从t走出去的所有的点,以及对应的边的权值
            int v = _.first,w = _.second;// v表示对应的点的编号,w表示边的权值
            if(dist[v] > dist[t] + w)
            {
                dist[v] = dist[t] + w;
            }
        }
        st[t] = 1;// 标记上t,即把t加入到S集合中
    }
    //执行完毕后,dist[i]就表示从1走到i的最短距离
}

关于dijkstra算法的正确性,必须要保证每个点第一次取出来的时候他的\(dist[i]\)是最小的,这个正确性基于说不存在一个点\(g\)到这个点的距离有负数,因为在正数的时候,必然构成一个三角形两边之和大于第三边,所以就保证了不存在这样的路径更短,但是假如说存在负权边,情况就不是这么简单的了,因为负权边意味着我走负权边的时候距离会减小,这样就不满足三角形的说法了。这个时候dijkstra算法的正确性就被破坏掉了,必须选择其他的算法,常用的是bellman-ford算法,他基于三角不等式以及松弛。具体来说,如果某个从\(u->v\)权值是\(w\)的边有\(dist[v] > dist[u] + w\)那么执行\(dist[v] = dist[u] + w\)的话可以让\(dist[v]\)变得更小并且显然正确。那么这里就有一个猜想:假如说整个图上的最短路都已经求好了,是否说对于所有的边都有\(dist[u] \leq dist[v] + w\),并且反过来,如果所有边都有\(dist[u] \leq dist[v] + w\)是否就说明所有的\(dist[i]\)都已经求好了呢,也就是问所有的边都满足三角不等式的这个条件是否是任意的\(i\)对应的\(dist[i]\)都已经达到最小的充要条件。

现在来简单说明一下:假如说所有的\(dist[i]\)都已经求好了,显然不可能有某个边对应的\(dist[v] > dist[u] + w\),因为你一定可以把这条边松弛,也就是拿右边的值赋给\(dist[v]\)就能让\(dist[v]\)变得更小了。反过来如果所有的边都已经满足三角不等式了,是否对于任意的\(dist[i]\)就没有变小的机会了呢?这里可以从一个宏观的角度上猜测,既然所有的边都已经满足三角不等式了,也就是说所有的\(dist[i]\)都不存在一个机会能变得更小了,那么也就是说对于所有的\(dist[i]\)都不可能变小了。接下来说明一下他的算法流程并附加的说明后面这个反推的过程:

首先还是跟dijkstra一样,我们一开始只知道\(dist[1]=0\),所有其他点都是正无穷。那么我们尝试给图上所有的边进行松弛,也就是遍历一下图上的所有边,去看一下是否有\(dist[v] > dist[u] + cost(u,v)\),如果有就拿右边的去覆盖左边的。但是我们可以发现说第一步尝试对所有边松弛的时候,除了与起点直接相连的点,其他的点由于\(dist[i]=\)正无穷,根本就不会产生松弛,也就是说只有与起点直接相连的点会产生松弛,也就是更新他们的\(dist[i]\)

这里我们可以先抛出一个结论:第一步尝试对所有边进行松弛之后,只有与起点直接相连的点\(dist\)会变小,也就是从正无穷变成一个正常的数值,也就是说现在求的的\(dist[i]\)是与起点相连,并且只经过一条边的能得到的距离。

那么继续,在得到了直接相邻的点的\(dist\)之后,我们再次尝试一下对所有的边进行松弛,这个时候我们就可以尝试更新不是与起点直接相连的点的距离,而是通过了一个直接相连的点连接之后的点的距离了,其实也就是求:与起点直接相连,并且最多只经过两条边得到的最短的距离。

往后我们可以推出一个结论:假设我们把这个遍历所有边尝试松弛的操作执行\(k\)轮,那么此时得到的\(dist[i]\)就表示,从起点出发,最多经过\(k\)条边的前提下,走到\(i\)的最短的距离是多少。那么最多需要执行多少轮呢?如果一个图有\(n\)个点,那么起点最多需要走\(n-1\)条边走到最远的那个点,这个时候整个图构成一个链状,也就是说我们的尝试对所有边松弛的操作最多也只需要执行\(n-1\)次就可以求出所需的\(dist[]\)了。当然,如果某次过程中发现已经没有任何一条边可以拿来松弛了,算法可以提前结束。

但是,假如说这个图上存在一个负环,也就是有一个环路,整个环路的权值之和是负数,这种情况极其特殊,在这种情形下,不存在最短路,因为任何路径,只要能走到一个环路上,就可以通过这个环路不停地把\(dist[i]\)缩小,一直转圈使距离变小。那么在这种情况下,不存在一个时刻,图上所有的边都满足三角不等式(因为还可以缩小),假如bellman-ford算法在执行了\(n-1\)轮之后,仍然没有使所有的边都满足三角形不等式,那么这个时候也就说明存在一个负环。

以下也许是可以拿来参考的代码:

int dist[N];
struct __edge
{
    int u,v,w;
}edge[N];
int n,m,k;
int BF()
{
    memset(dist,0x3f,sizeof dist);
    dist[1] = 0;
    while(k--)
    {
        for(int i = 0;i < m;++i)
        {
            int u = edge[i].u,v = edge[i].v,w = edge[i].w;
            dist[v] = min(dist[v],dist[u]+w);
        }
    }
    if(dist[n] > 0x3f3f3f3f/2)  return -1;// 这里表示起点走不到终点,因为终点可能和某个负权边相连导致dist[n]缩小
    // 进而就有dist[n] != 0x3f3f3f3f,于是这里就判断是不是比正无穷的一半大,如果大那么就认为是正无穷
    // 只不过是被负权边松弛挖掉了一点而已。
    return dist[n];
}
posted @ 2020-11-17 22:00  随处可见的阿宅  阅读(277)  评论(0)    收藏  举报