zoukankan      html  css  js  c++  java
  • 一文讲完最基本的图算法——图的存储、遍历、最短路径、最小生成树、拓扑排序

    预计会有的算法有DFS,BFS,最短路径Dijskra,最小生成树算法Prim,Kruskal,拓扑排序,慢慢更新吧。这些应该都是基础,大学的数据结构内容应该不太会比这个多多少了,再复杂一点的算法我自己也不会了,慢慢学习了,在后面单独开文了。当然这些肯定不能是先复习一遍再写的,依然是能回忆/推导起来的算法,因为不难,所以甚至可能都没忘。代码部分虽然是C++版的,但是改成C语言非常快,只需要把new改成malloc就行了。

    图的表示

    图的概念就不赘述了,应该一搜一大把,我再继续讲概念也没有意义。

    从有没有方向对图分类,可以将图分为无向图和有向图。无向图可以视为有两条有向边——即既有从1到2方向的边,也有从2到1方向的边,其存储也可以按照这种方式。

    图的表示有很多种,最常用的两种为邻接矩阵与邻接表。邻接矩阵存储了所有顶点到顶点之间的关系,其中有边相连记为1,无边相连记为0。例如对左边的无向图,其邻接矩阵的表示如图。

    当然,如果边有权值,就把1记为权值即可;对于这种情况下,无边相连的两个顶点,则可以记为正无穷。记为0也是可以的,但是得注意每次遍历时得判断0是表示无边连接,而不是有权值为0的边。其表示如下:

    struct MGraphNode{
        int mat[MAXN][MAXN];
        int v, e;
    };
    typedef struct MGraphNode MNode, *MGraph;

    这里的v,e分别记录的是顶点数与边数。

    这里BB几句。在很久之前看完浙大的数据结构课并且完成作业之后,我的数据结构能力得到很大的充实。写出来的代码也比以前的trash好了不少。但是现在常常却陷于格式化的定势思维了,例如一棵树就一定想着用链式存储,其实这里是有很大误区的。数据结构本来就是很灵活的东西,如果需要什么信息加入,就存储哪些。存了之后也要考虑到使用是否方便。所以固定化思维是很不可取的。举个例子吧,这里的图中需要哪些信息,都可以往其中添加的。例如顶点数据,边的信息等等。

    甚至来说,为了简便,直接一个int g[maxn][maxn]都能够代表一张图,只不过这种一般就只适合于做题时的小代码了,太不结构化也是不行的。

    邻接表如上图,其实就是每个顶点记录与它相邻的点有哪些,构成一个链表。它的优点是省空间,只存储那些相连的边,而不像邻接矩阵存储了很多不相连的边(值为0),因此遍历也很方便省时间。当然也有坏处就是不能判断两条边是否直接相连。所以得看情况来使用邻接矩阵还是邻接表。

    typedef struct AdjNode ANode, *Adj;
    struct AdjNode {
        int v, weight;
        Adj next;
    };
    
    struct LGraphNode {
        Adj list[MAXN];
        int v, e;
    };
    typedef struct LGraphNode LNode, * LGraph;

    这是一个最简单的邻接表样例,上面的AdjNode表示邻接点节点,与链表的类似,v是相连的顶点,而weight则是两点之间边的权值了。下面的LGraphNode则是存储的图,有N个顶点的链表,v和e分别表示顶点数和边数。当然邻接表比邻接矩阵要丰富更多也更灵活,所以我大一的时候看邻接表都是懵的,其实现在理解透了就很简单。有的用三个结构体分别表示邻接节点,顶点和图,后来我感觉顶点那个有点多余就并没有加进来,起码在做题的时候影响不大。如果要存储顶点数据的话,可以在LGraphNode里面开一个数组来存储也是OK的。所以大家学习的时候用自己最能理解的就好。但是我现在写这个还是会出现不明bug,很迷。

    那么既然链表能表示邻接表的话,用<vector>显然也更简单了。那么理论上讲,一个vector<int> g[maxn]也是一张图,并且可以表示一个邻接表。

    至于其它的表示方式,用的就没那么多了。例如十字链表等。比较常用的可能是边集合,因为它会在kruskal算法中用到。至于稀疏图的稀疏矩阵甚至可以用三元组存,但是这就遇到的更是少之又少了。

    下面分别是建立一个邻接矩阵和邻接表的代码,分别建立的是无向图和有向图。

    MGraph buildGraph() {
        int n, m, v1, v2, w;
        cin >> n >> m;
        MGraph g = new MNode;
        g->v = n;
        g->e = m;
        while (m--) {
            cin >> v1 >> v2 >> w;
            g->mat[v1][v2] = w;
            g->mat[v2][v1] = w;
        }
        return g;
    }
    
    LGraph buildLGraph() {
        int n, m, v1, v2, w;
        LGraph g = new LNode;
        cin >> n >> m;
        g->v = n;
        g->e = m;
        for (int i = 1; i <= n; i++) {
            g->list[i] = new ANode;
            g->list[i]->next = NULL;
        }
        while (m--) {
            cin >> v1 >> v2 >> w;
            Adj e = new ANode;
            e->v = v2;
            e->weight = w;
            e->next = g->list[v1]->next;
            g->list[v1]->next = e;
        }
        return g;
    }

    其中邻接矩阵的当然很好理解了,主要是邻接表,首先初始化一个链表的数组,然后对其中每条链表使用的是头插法。

    这里我们补充一个用<vector>存储邻接表,对于新手来说应该是更容易理解的一种邻接表了。对于这个有向图来说,此时我们只需要一个vector<int> v[maxn]。其中v[1]的vector存储的是他能够直接到达的顶点,也就是相邻的2与3。这样只需要一个vector数组就能将这张图存储下来了。

    如果是带权值的图,那么需要用一个结构体来存储,结构体中包含顶点与权值,这个结构体与邻接节点类似,只不过不用存储next域了。

    ​、

    为了表示能够结构化一点,我还是用了结构体,做题时灵活运用就行。这样存储邻接表应该比上述方式简单,逻辑也更清晰。不过很遗憾,算法部分就不提供这个邻接表的代码了,因为比较重复。

    struct Node {
        int v, weight;
        Node(int v, int w):v(v),weight(w){}
    };
    typedef struct VGraphNode VNode, * VGraph;
    struct VGraphNode {
        vector<Node> lists[MAXN];
        int v, e;
    };
    
    
    
    VGraph buildVGraph() {
        int n, m, v1, v2, w;
        VGraph g = new VNode;
        cin >> n >> m;
        g->v = n;
        g->e = m;
        while (m--) {
            cin >> v1 >> v2 >> w;
            Node e = Node(v2, w);
            g->lists[v1].push_back(e);
        }
        return g;
    }

    图的遍历

    要遍历图的所有节点,一般来说有两种方式,DFS与BFS。分别对应于树的前序遍历与层次遍历。

    DFS就是深度优先搜索,可以说是“一条道走到黑”。在走迷宫的时候,遇到岔路,我们往往就是选择一条岔路,一直往前走,直到走到死胡同才回头,回到刚才的岔路,选择另外一个方向前进。这就是DFS的思路。其实如果玩过迷宫的会知道,有一种一定能走出迷宫的方式(前提是迷宫能走出去),就是沿着右边(或者左边)的墙一路前进,可以在图上画一下就知道,其实这就是一种深度优先搜索。

    其实图的深度优先搜索与算法中的深度优先搜索都是差不多的思路。在算法中,我们是完成了对于当前点的判断之后,马上就前往下一个点。直到发现已经走不通了(也就是前面的点出错,导致再往下搜索也无法得到正确的解了),才会回去检查上一个点,如果还是出错就再回溯找上一个点。这也算是我无师自通的一个算法,当时打蓝桥杯的时候不会DP就是暴力深搜硬混的。具体的应用,可以见之前C2时候写的一个数独程序,算是深搜里面比较复杂的了,如果能弄懂那个的话全排列啊八皇后啊应该都没啥问题。

    回归正题,图中的1号点先开始向2方向搜索,然后搜索到3、4,此时搜索发现是1已经遍历过了,故回溯。4发现没有别的邻接点了,回溯至3,同理一直到1。然后1发现了另外的邻接点5,访问5,然后依次访问6,回溯至5,访问7,回溯至5,回溯至1,发现没有别的邻接点,结束遍历。

    分别用邻接矩阵与邻接表实现,代码如下:

    int visited[MAXN] = { 0 };
    
    void DFS_M(MGraph g, int v) {
        cout << v << " ";
        visited[v] = 1;
        for (int i = 1; i <= g->v; i++) {
            if (g->mat[v][i] == 1 && !visited[i])
                DFS_M(g, i);
        }
    }
    
    void DFS_L(LGraph g, int v) {
        cout << v << " ";
        visited[v] = 1;
        Adj p = g->list[v]->next;
        while (p != NULL) {
            if (!visited[p->v])
                DFS_L(g, p->v);
            p = p->next;
        }
    }

    既然是图的最基本算法那时间复杂度还是不能混过去了...好好分析下,这里假设图有V个节点E条边。

    使用邻接矩阵的话,首先V个节点都要访问到,然后对于每个节点在访问时,在for里面都要遍历V个节点,所以时间复杂度是O(V^{2})。

    使用邻接表的话,首先V个节点都要访问,然后对于每个节点v访问时,需要遍历与它邻接的x个节点。

    补充一个概念,这里的x其实就是deg(v)也就是v的出度,也就是点所指向的节点的个数。对于图来说,其sum_{vepsilon V} {deg(v)},也就是每个节点的出度之和,其实就是边数(因为一个节点要出去的话,一定只对应一条边),入度同理。对于无向图来说,就是出度+入度也就是边数*2。

    那么时间复杂度是O(V * (1+deg(v))) = O(sum_{vepsilon V}deg(v)+V)=O(E+V).

    BFS广度优先搜索不同,它是一种层层推进的遍历方式。如同数树的年轮一样,先从最里面一圈开始数,然后数外边第二圈,第三圈.....。BFS要用到队列的思想,在遍历一个节点时,把它所有的邻接点放进队列里。这样遍历时就会按照第一层,第二层...这样的顺序遍历了。在图中,遍历到1,将邻接的245放进队列。然后访问2,将3放进队里,访问4,访问5,把67放进队里。最后再依次遍历367.

    BFS与算法中的广搜也类似,这也是我打蓝桥杯用的多的算法(当时省赛那题迷宫会做但是题目看错一点,填空题就这样G了,还好问题不大)。有人会说迷宫不是DFS吗,其实BFS也能解决不过就是不同的思路了,BFS是我先看第一步能走到哪(所有能走的),然后再看第二步,然后再看第三步...(实际上我们走迷宫是不可能这样走的,因为没办法一层层的走)。这时我们就发现了,我们可以找到出口在第几层,也就是最少该走几步才能走出去。

    蓝桥杯那次光是迷宫就省赛一题国赛一题,全是BFS,让我恰了不少烂分。这里我们也总结一下广搜与深搜吧

    1.广搜肯定是与队列相关联的,而深搜则是与栈相关联的。而我们深搜一般都是用递归实现的,递归本身就是栈嘛。如果不用递归的话,例如还是上图先访问1,然后把542依次压栈,弹出2,压3,弹出3,弹出4,弹出5,压7,6,弹出6,7.访问顺序依然是1234567的。如果感兴趣的话,可以试试不用递归用栈写深搜。(在二叉树里面也有不用递归用栈的遍历,前序还好,中序应该是比较难的)但是如果是像数独,全排列这种算法,压栈的时候还得把当前状态压栈,很是麻烦。如果感兴趣的话可以试试用栈实现汉诺塔,这我是很久没想明白,最后用树的中序才勉强写出来了。

    2.广搜往往对应的是“最少步数”,比如我博客里面写的这题,在我一开始不懂算法的时候,是递归了所有走法然后选择步数最小的那条,一直是TLE的(废话你不TLE谁TLE),不管怎么剪枝都没用。后来知道BFS之后,才理解了应该怎么做,而且也是在开始用C++之后的。C刷算法题的确很反人类,手写队列啥的浪费时间啊o-o。写C写那么久真佩服自己,习惯STL之后真香。而深搜往往对应的是“共有几种走法”,比如上面那题如果我问从A到B一共有几种跳法,那就只能DFS了,BFS是无法得出答案的。这也是我慢慢摸索出来的一点做题经验(虽然大佬肯定是早就知道了)。

    扯了这么多有点扯远了,其实想说的就是图的DFS,BFS本身就和算法的深搜广搜息息相关,可以相互帮助理解(所以这样算法深搜广搜我就不用写博客了,能水过去咯)

    扯回来吧,既然要用队列实现,那么BFS代码如下。唯一要注意的一点是,放进队列的时候就标记访问,而不是访问时才标记访问,这样能避免重复的放进队列。

    void BFS_M(MGraph g, int v) {
        queue<int> q;
        q.push(v);
        visited[v] = 1;
    
        while (!q.empty()) {
            int cur_v = q.front(); q.pop();
            cout << cur_v << " ";
    
            for (int i = 1; i <= g->v; i++) {
                if (g->mat[cur_v][i] == 1 && !visited[i]) {
                    q.push(i);
                    visited[i] = 1;
                }
            }
        }
    }
    
    void BFS_L(LGraph g, int v) {
        queue<int> q;
        q.push(v);
        visited[v] = 1;
    
        while (!q.empty()) {
            int cur_v = q.front(); q.pop();
            cout << cur_v << " ";
    
            Adj p = g->list[cur_v]->next;
            while (p != NULL) {
                if (!visited[p->v]) {
                    q.push(p->v);
                    visited[p->v] = 1;
                }
                p = p->next;
            }
        }
    }

    能看出来思路一样,只不过根据具体实现细节略有不同。时间复杂度的话与DFS一样。是的,连分析都是一样的。这里不赘述了,篇幅已经够长了。


    最短路径问题

    顾名思义就是求一个点到另一个点的最短路径。这里一般指的是带权路径,如果是不带权的话,用BFS就能很轻松实现,因为BFS本身就是按层次遍历的,如同波纹一样一圈圈扩散。只需要看终点在第几圈就可以了。一道代表性的题目是PTA的六度空间,当时做的时候还是挺有成就感的,不过现在看来并不是特别难的题。另一个比较有意思的题是UVA1599的理想路径,记忆犹新的一道题,WA了不知道多少次。它的最短路径并不是求起点到终点的权值和,而是求路径最短的前提下,路径的字典序最小,非常有意思。这里就不讲思路了,有兴趣的推荐去做一下。

    那么我们这里带权路径指的就不是字典序了,而是权值和。举个我亲身经历过的例子:

    这是北京地铁图,我想从国家图书馆站回到知春路站,有两种方式。一种是走4号线到西直门,转13号线到知春路;另一种是走4号线到海淀黄庄,转10号线到知春路。第一种需要坐4站,第二种则是5站,天真的我选择了在西直门转车。但是在西直门转车不仅要在站内弯上走上很远才能到13号线,而且13号线很慢,可能是也有绕路的原因,到达知春路花了很久。可以说这条路径虽然看起来更短,但是实际上花费的时间一定比另一条长。

    如果我们把两站之间的地铁需要花费的时间当做权值,那么我需要找的则是从一个站到另一个站时间最短的路径,也就是权值和最小的路径。而并非路程、站点最短的路径。

    Dijkstra算法

    对于单源最短路径问题来说,最经典的算法是Dijskra算法(其实是Dijkstra算法),这是一种基于贪心的思路。它的条件是不允许图内有负权值的边,并且只能解决单源最短路径,即只有对源点可以求到其他点的最短路径。

    它的思路非常简单,维护一个dist数组表示初始点到其他所有点的最短路径距离。如果暂时没有边直接相连,则记为无穷。然后每一次,从没有访问过的顶点中,选择dist值最小的一个顶点v进行访问。一旦访问过该节点后,原点到v的最短路径长度就已经确定下来了。在访问该节点时,同时看从原点经过该顶点的v到达每一个v的邻接顶点w时的距离,是否比dist[w]小,如果小则更新。也就是说,从原点经过v到达邻接的w的总路径长度dist[v]+s(v,w)是否比dist[w]小,如果是,则把dist[w]置为dist[v]+s(v,w)。

    如上图所示,开始时只有原点1已访问。下一步选择dist最小的顶点2进行访问,此时由于经过2到达3的路径总长度为5<6,故将到达3的最短路径长度改为5。(同时可以维护一个path数组,表示经过哪个点到达此处的路径最短)。由于1到4原先的距离为无穷,经过2之后,路径改为了2+5=7,更小,故更新dist[4]=7。这一更新的过程其实有个专有名词叫松弛。

    更新之后,就把path数组的值置为更新它的顶点。这样可以溯源回最短路径经过了哪些点。(事实上我们这样就得到了单源最短路径树)

    那么在掌握了思路之后,其实程序的整体流程是很简单的。

    这里把代码也贴出来:

    int path[MAXN];
    int dist[MAXN];
    
    void init_M(MGraph g, int s) {
        memset(visited, 0, sizeof(visited));
        for (int i = 1; i <= g->v; i++) {
            dist[i] = INF;
            path[i] = -1;
        }
        dist[s] = 0;
    }
    
    int Dijkstra_M(MGraph g, int s, int t) {
        init_M(g, s);
        int num = 0;
    
        while (num != g->v) {
            int index = 0;
            int value = INF;
            
            //找到未访问节点中dist最小的点
            for (int i = 1; i <= g->v; i++) {
                if (!visited[i] && dist[i] < value) {
                    value = dist[i];
                    index = i;
                }
            }
            if (index == 0) {
                return INF;
            }
    
            //松弛(更新最短距离)
            visited[index] = 1;
            num++;
            for (int i = 1; i <= g->v; i++) {
                if (g->mat[index][i] != 0 && dist[i] > dist[index] + g->mat[index][i]) {
                    dist[i] = dist[index] + g->mat[index][i];
                    path[i] = index;
                }
            }
        }
        
        for (int i = 1; i <= g->v; i++)
            cout << dist[i] << " ";
        cout << endl;
        for (int i = 1; i <= g->v; i++)
            cout << path[i] << " ";
        cout << endl;
        return dist[t];
    }
    
    void init_L(LGraph g, int s) {
        memset(visited, 0, sizeof(visited));
        for (int i = 1; i <= g->v; i++) {
            dist[i] = INF;
            path[i] = -1;
        }
        dist[s] = 0;
    }
    
    int Dijkstra_L(LGraph g, int s, int t) {
        init_L(g, s);
        int num = 0;
    
        while (num != g->v) {
            int index = 0;
            int value = INF;
            
            //找到未访问节点中dist最小的点
            for (int i = 1; i <= g->v; i++) {
                if (!visited[i] && dist[i] < value) {
                    value = dist[i];
                    index = i;
                }
            }
            if (index == 0) {
                return INF;
            }
    
            //松弛(更新最短距离)
            visited[index] = 1;
            num++;
            Adj p = g->list[index]->next;
            while (p != NULL) {
                if (dist[p->v] > dist[index] + p->weight) {
                    dist[p->v] = dist[index] + p->weight;
                    path[p->v] = index;
                }
                p = p->next;
            }
        }
        
        for (int i = 1; i <= g->v; i++)
            cout << dist[i] << " ";
        cout << endl;
        for (int i = 1; i <= g->v; i++)
            cout << path[i] << " ";
        cout << endl;
        return dist[t];
    }

    可以看到,其实逻辑还是非常清晰的,在while里只有两个部分:找dist最小点,松弛。那么接下来就应该做一些补充说明了。

    1.为什么Dijkstra算法不能处理负值边

    用一个最简单的例子就能说明,从1到4的最短路径应该是1-2-3-4,路径的权值和为2。但是用Dijsktra算法的话,首先会访问3,这时其实我们已经确定了dist[3]=2了。即使在后续的松弛过程中可能会将它松弛成1,但是由于访问顺序出错了,此时更新的dist[3]无法应用于它的松弛过程。dist[3]确定为2后,将dist[4]松弛为3,然后访问4。最后才访问2,但是对2的访问已经没有太大的意义,因为算法已经出错了。

    既然负值边都无法解决,那负值圈就更无法解决了。事实上有负值圈的图不存在最短路径,因为一直沿着这条圈走,路径权值理论上会达到负无穷。所以负值圈问题只能判断,而不能求出路径长度。

    2.Dijkstra算法为什么是正确的

    利用上面1的推导过程,可以看出来为什么在权值都是非负数的情况下,Dijkstra算法的正确性了。这里的证明可能不是很严格,但是对于弄懂它还是没啥大问题的。个人觉得,Dijkstra算法中最核心的内核是“一旦访问过该节点后,原点到v的最短路径长度就已经确定下来了”。正如KMP的算法内核是“string的指针i一定不能回溯,只能向前移动”。

    那么这句话怎么理解呢,换言之就是为什么一旦选择dist[v]最小,那么它的最短路径就一定确定了。这一点可以用反证法证明:

    如图,一旦选到了当前dist[v]最小的v,那么其路径s....xv,一定是最短路径。否则,在选择v之后,dist[v]仍然会因为另一点的松弛而更新的更小,设这个点为y,那么s....yv才是真正的最短路径。此时这个dist[y]一定是大于等于dist[v]的(因为松弛过程发生在选择v之后,故dist[y]不可能小于dist[v])。又因为路径的权值w(y,v)≥0,所以一定有dist[y]+w(y,v)≥dist[v]。这与之前假设的dist[v]会变小矛盾。所以我们就能确定,一旦选择了点v,那么它的dist[v]一定不会再更新。

    事实上,迪杰斯特拉算法的正确性正是由贪心算法的构造策略所保证的。从1的讲解可以看出来,在有负值边的情况下,选择了v之后无法保证dist[v]不会变小,这是Dijkstra算法无法应用于负值边的原因。但是在没有负值边的情况下,选择了v,dist[v]就一定是确定的,不可能变小了。(这也是为什么在代码的松弛过程中,没有要求!visited[u],因为如果u已经访问过,这次松弛一定是失败的。)这样我们每次选择一个点,一定是得到从源点s到它的最短路径的。

    3.算法时间复杂度

    对于邻接矩阵来说很明显,外层的循环要执行节点个数V次。对于内层来说,首先从V个点中找到dist最小的点,需要遍历一遍,执行V次。在松弛过程由于要从矩阵中找到所有邻接点,也要遍历V次。所以时间复杂度为O(V*(V+V))= O(V^2)

    对于邻接表来说,外层的循环也要执行V次。对于内层来说,首先从V个点中找到dist最小的点,同样需要遍历一遍,执行V次。在松弛过程由于要从邻接表中找到所有邻接点,要遍历点v的出度个数deg(v)次。所以时间复杂度为O(V * (V+deg(v))) = O(sum_{vepsilon V}deg(v)+V^2)=O(E+V^2)

    一般来说,在这里我们所使用的图都是简单图(即没有自环,没有重边的图)。那么在这种情况下,图的边数最大的时候为完全图(即任意两点都有一条边直接相连),边数最多为V*(V-1)/2。所以实际上E就是V^2级别的,也就是说O(E+V^2)=O(V^2),所以使用邻接矩阵和邻接表的时间复杂度都为O(V^2)

    4.Dijkstra算法堆优化

    其实从“最小”这两个字能很明显看出Dijkstra可以优化的点:现在我们的算法中寻找dist最小点用的是for循环dist所有值来找一遍,显然不是个很好的方法。如果学过数据结构比较敏感就很容易想到堆这个数据结构,也就是优先队列。如果通过优先队列,那么找到dist[v]最小的顶点v的时间复杂度就没有O(V)那么大了,这就是可以优化的点。

    那么显然这个优化只针对邻接表,对于邻接矩阵来说,很明显,即使我们把这一步的时间复杂度降低了,为了遍历所有邻接点它的时间复杂度仍是O(V),时间复杂度仍为O(V^2)。而邻接表中由于遍历邻接点的时间复杂度为O(deg(v)),是有优化空间的。下面给出一个简单的通过优先队列实现的模板:

    typedef pair<int, int> node;
    
    int dijskra_L(LGraph g, int s, int t) {
        init_L(g, s);
        priority_queue<node, vector<node>, greater<node> > q;
        q.push(node(0, s));
    
        while (!q.empty()) {
            node head = q.top(); q.pop();
            if (head.first > dist[head.second])
                continue;
            //更新最短距离
            int index = head.second;
            Adj p = g->list[index]->next;
            while(p != NULL){
                if (dist[p->v] > dist[index] + p->weight) {
                    dist[p->v] = dist[index] + p->weight;
                    path[p->v] = index;
                    q.push(node(dist[p->v], p->v));
                }
                p = p->next;
            }
        }
        for (int i = 1; i <= g->v; i++)
            cout << dist[i] << " ";
        cout << endl;
        for (int i = 1; i <= g->v; i++)
            cout << path[i] << " ";
        cout << endl;
        return dist[t];
    }

    这里通过C++的pair来进行排序:pair优先使用first键进行排序,所以我们将dist[v]作为排序的键,second键则存储顶点编号v。这里我们采用的是一旦发生松弛,就将(dist[v],v)这样一个node压入堆中。故每次发送松弛时,有可能产生“废”node,如下图所示,给出了一个堆的更新过程。可以看到,2这个顶点v会多次入堆。所以在从堆中取出元素时,需要判断如果当前node的dist[v]比现在存储的大,则抛弃掉该node(用visited实现也可以)。

    其实可以看到,用这种方式其实并不是将单独的顶点存入堆中,而是采用(dist,v)这样一个pair,那么实际上,存入的node数量并不是顶点数V,而是边数E的级别(因为每条边都可能松弛,加入堆中)。那么也就是说插入一个node的时间复杂度应该是O(logE)级别的。由于堆清空需要访问V个顶点(废node已被抛弃),每次访问时需要访问它邻接的deg(v)个邻接点,每次push的时间复杂度又是O(logE),所以时间复杂度应该是:

    O(V * (deg(v)*logE)) = O(sum_{vepsilon V}deg(v)*logE)=O(logE*sum_{vepsilon V}deg(v))=O(ElogE)

    但是上面已经提及过E其实是V^2级别的,所以logE和logV应该是一个量级,也就是说如果说是O(ElogV)问题也不大,不过一般来说,O(ElogV)应该是手写二叉堆可以达到的量级。如果采用斐波那契堆的话,可以达到O(VlogV+E)。(这些是我看教程说的,然而我并不知道斐波那契堆是什么。。。)其实一般情况下优化到O(ElogE)已经是比较不错的优化了,对于一般的Dijkstra问题应该是够用了。

    开始并没有理清楚为什么复杂度是O(ElogV),因为如果直接按照(dist,v)这样存储的话,要在堆里面修改dist的话时间复杂度也是O(V)的(因为要遍历堆中每一个元素才能找到v)。后来才想到还是思维僵化了,其实完全也是没必要按照和优先队列一样的存储方式的,因为自己写的话就很灵活了。这里我给一下二叉堆的思路,堆是只存储顶点v的,但是对于堆的调整则是按照比较child与parent的dist值来调整的。这样的话,我们还需要一个数组pos来记录每个v在堆中的下标,在调整堆中位置时,pos也要相应地调整。具体代码就不写了,有兴趣的话可以自己写一下,因为还是比较复杂的。

    对于C语言用户来说,一般学校的OJ应该不太会卡Dijkstra的优化,能写出来就OK了,所以手写二叉堆优化不是必要的,不过有兴趣还是可以写一下。这里还是很想吐槽C语言刷题反人类。我自己的话手写队列啊栈啊几行代码造轮子不知造了多少,实际上STL会方便很多,不过对于DS初学者来说,多练总不是坏事,尤其还是堆本身也不常写。可能都是在堆排序那里才认识的堆。但是如果DS已经很熟练要刷题的话,造轮子就没必要了。接下来我可能会出一个手写二叉堆的博客,反正不是很难。

    Floyd算法

    本来是不想介绍这个算法的,后来才发现这个算法牛逼之处,所以来简单介绍一下。Dijsktra问题是用来解决单源最短路径问题的,Floyd则是用来解决多源最短路径问题的,也就是说,执行一遍算法之后可以得到任意两点间的最短路径距离。Dijkstra不能解决负值边,Floyd算法能够解决负值边,但是同样不能解决负值圈,这一点我们等下再解释。Floyd算法如此简单,以至于只需要5行就能够实现(当然我这里多一点)。当然有优点也有缺点,Floyd算法只能在邻接矩阵上实现,并且它的时间复杂度是固定的,O(V^3)。看起来效率不算太高,不过解决数据量小的情况下还是挺方便的。这里就直接把代码给出来吧:

    int D[MAXN][MAXN];
    void init_D(MGraph g) {
        for (int i = 1; i <= g->v; i++)
            for (int j = 1; j <= g->v; j++)
                D[i][j] = (g->mat[i][j] == 0 ? INF: g->mat[i][j]);
    }
    void floyd(MGraph g) {
        init_D(g);
        for (int k = 1; k <= g->v; k++)
            for (int i = 1; i <= g->v; i++)
                for (int j = 1; j <= g->v; j++)
                    if (D[i][j] > D[i][k] + D[k][j])
                        D[i][j] = D[i][k] + D[k][j];
    }

    这个代码可以直接用一个矩阵来存图,那么就可以省略掉init那一行,只有五行。这是多数情况下甚至都不需要理解都能背诵记忆的代码。看着代码也非常容易把思路“想出来”:从i到j的最短路径长度,首先看i能否通过顶点1到达顶点j。如果可以的话,看这条路径长度是否比当前短,短则更新。然后看i能否经由顶点2到达顶点j,然后看i能否经由顶点3到达顶点j.....那么一般来说我们可以这样理解,经过i通过了所有顶点k到达了j,我们从中选一条最短的路径。但是这个理解还是有一些问题的,例如为什么k需要在最外层,为什么这样能保证一定是最短路径。

    很多时候我们都没有思考上述的问题,因为感觉这个算法很自然,仿佛就应该是这样的。然而在我上次把kij的顺序写错成ijk之后,我才觉得应该好好反思一下这个代码。有一个冷段子:为什么Floyd算法不是Dijkstra提出来的,因为Dijkstra是ijk而不是kij。

    言归正传,Floyd算法是基于动态规划提出来的,这就是它为什么这么牛逼,然而它的代码却又十分简洁。它的递推式是f[k][i][j] = min(f[k-1][i][j], f[k-1][i][k]+f[k-1][k][j])。其中f[k][i][j]是i到j之间通过1....k中点的最短路径(i,j可能并不一定在1...k这个范围内),这个k就相当于DP中的状态了。f[k][i][j]既可以从f[k-1][i][j]转移而来,i到j的路径只经过1....k-1的节点而不经过k;也可以从f[k-1][i][j]转移而来,i到j的路径一定经过k。当然不管表示成f[k][i][j]还是f[i][j][k]都是一样的,但是要注意这里的k表示的是状态,所以必须放在循环的最外层。

    这里就附送两个参考资料吧,帮助大家理解一下,我觉得应该是写的比我要好的。等我哪一天理解上去了再来补充这一部分。

    参考资料:

    floyd算法:我们真的明白floyd吗?   我认为讲的非常清楚了。

    Floyd算法为什么把k放在最外层?


    最小生成树

    先上定义吧。首先是生成子图,若图G的一个子图包含G的所有顶点,则称该子图为G的一个生成子图。也就是说,从原图中选择任意条自己喜欢的边,但是必须包含所有顶点的子图。那么生成树很明显,就是说这个生成子图必须是一棵树,即连通无环图(或者说边数等于顶点数-1的图)。那么生成树存在的前提很明显就是图必须是连通图。

    最小生成树指的是这棵生成树的权值之和是所有生成树中权值最小的一棵。一般我们这里探讨的是无向图的最小生成树。关于最小生成树其实是有框架与证明的,要涉及到一些概念比如安全边、割...这里我们就不证明了,用比较通俗的方式把算法的思路讲出来吧。

    这个问题同样要用到贪心思想:我们希望权值越小的边能越有可能被选到。然而,我们要保证每次选择一条边之后,选中的边构成的是一棵树,而不能有环。例如下图,我们可以选择1-2,1-3,但是这时就不能选择2-3这一边了。

    选择边有两种方式,一种是以点作为选择对象,另一种是以边作为选择对象。那么我们分别探讨一下:

    Prim算法

    看完上面的Dijkstra算法应该知道,它是以单源作为起点,逐渐的延伸成为了一棵单源最短路径树。那么可不可以基于这种思路构造一棵树呢,当然是可行的。我们同样可以从随便一个顶点出发,看看能不能以它为起点,每次选择一个点加入当前的已选集合,延伸出一棵最小生成树。

    它的思路和Dijkstra算法太相似了,以至于如果理解了Dijkstra算法,理解Prim算法应该是相当轻松的。我们看看刚才的算法描述:

    “它的思路非常简单,维护一个dist数组表示初始点到其他所有点的最短路径距离。如果暂时没有边直接相连,则记为无穷。然后每一次,从没有访问过的顶点中,选择dist值最小的一个顶点v进行访问。一旦访问过该节点后,原点到v的最短路径长度就已经确定下来了。在访问该节点时,同时看从原点经过该顶点的v到达每一个v的邻接顶点w时的距离,是否比dist[w]小,如果小则更新。也就是说,从原点经过v到达邻接的w的总路径长度dist[v]+s(v,w)是否比dist[w]小,如果是,则把dist[w]置为dist[v]+s(v,w)。”

    这里我们只需要把“最短路径距离”,改成最小生成树上,起始点到达指定点的最短边长即可了(权值最短的边的权值)。一旦访问过一个节点,它也就贡献了最小生成树上它所连接的那条边的权值了,在之后不能更改了。这里是与Dijkstra算法不同的地方。然后,在访问该节点时看该顶点的v到达每一个v的未选择的邻接顶点w的边长s(v,w)是否比w的边长dist[w]小,如果是则把dist[w]置为s(v,w)。

    那么我们可以总结一下Prim算法,其实就是每个点希望找到与它邻接的权值最小的边。但是每条边只能被它的一端顶点所贡献,因此另一侧顶点只能贡献其它的边。也就是说,当访问一个顶点时,更新它邻接的所有顶点的最短边权值。如果对侧的更新了,其实是对侧节点贡献的权值变小了,自己这个节点所贡献的权值是已经确定的为dist[2]。

    举个例子。如上图所示,开始时只有原点1已访问。下一步选择边长最小的顶点2进行访问,此时由于边2-3的权值小于边1-3的权值3<6,故将3的最短边长改为5。同时可以维护一个path数组,表示这个点与之前选择的哪个点相连的边最短。由于1到4原先的边长为无穷,经过2之后,边2-4长度为1比较小,故更新dist[4]=1。

    那么为什么更新时要注意不能更新已选择的点呢?Dijkstra算法没有这一限制条件啊?我们以下一步选择4访问为例。在Dijkstra算法时,我们一旦访问4之后,可以知道之前访问的2一定有dist[2]<dist[4],所以从4开始松弛边,一定有dist[4]+s(4,2) > dist[2],故哪怕不加“不能更新已选择的点”这一条件,也不会更新已经选择过的点。而这里不同,当选择4时,我们看到与4相连的2,s(4,2)=1<dist[2],所以如果不加判断条件,它会更新dist[2]=1,path[2]=4.这样的话,虽然以后不会再选择2这个点了,它所贡献的边长也不会改变,但是path[2]被错误的更改为4,这样我们就无法通过path还原出我们的最小生成树了。

    那么在理解这一点后,其实代码是非常非常好写的。

    int Prim_M(MGraph g, int s) {
        init_M(g, s);
        int sum = 0;
        int num = 0;
    
        while (num != g->v) {
            int index = 0;
            int value = INF;
            
            //找到未访问节点中dist最小的点
            for (int i = 1; i <= g->v; i++) {
                if (!visited[i] && dist[i] < value) {
                    value = dist[i];
                    index = i;
                }
            }
            if (index == 0) {
                return INF;
            }
    
            //松弛(更新最短距离)
            sum += dist[index];
            visited[index] = 1;
            num++;
            for (int i = 1; i <= g->v; i++) {
                if (!visited[i] && g->mat[index][i] != 0 && dist[i] > g->mat[index][i]) {
                    dist[i] = g->mat[index][i];
                    path[i] = index;
                }
            }
        }
        
        for (int i = 1; i <= g->v; i++)
            cout << dist[i] << " ";
        cout << endl;
        for (int i = 1; i <= g->v; i++)
            cout << path[i] << " ";
        cout << endl;
        return sum;
    }
    
    int Prim_L(LGraph g, int s) {
        init_L(g, s);
        int sum = 0;
        int num = 0;
    
        while (num != g->v) {
            int index = 0;
            int value = INF;
            
            //找到未访问节点中dist最小的点
            for (int i = 1; i <= g->v; i++) {
                if (!visited[i] && dist[i] < value) {
                    value = dist[i];
                    index = i;
                }
            }
            if (index == 0) {
                return INF;
            }
    
            //松弛(更新最短距离)
            sum += dist[index];
            visited[index] = 1;
            num++;
            Adj p = g->list[index]->next;
            while (p != NULL) {
                if (!visited[p->v] && dist[p->v] > p->weight) {
                    dist[p->v] = p->weight;
                    path[p->v] = index;
                }
                p = p->next;
            }
        }
        
        for (int i = 1; i <= g->v; i++)
            cout << dist[i] << " ";
        cout << endl;
        for (int i = 1; i <= g->v; i++)
            cout << path[i] << " ";
        cout << endl;
        return sum;
    }

    我们可以看到,代码基本是按照Dijkstra算法所改的。并且只更改了松弛部分的公式。那么时间复杂度与Dijkstra算法是完全一致的,这里也就不分析了。

    同样,Prim算法也可以进行堆优化,其优化方式与时间复杂度也是与Dijkstra算法完全一致,这里就不附上代码与推导了。也就是改上三行代码的事情。

    Prim算法是从一个点出发遍历所有顶点的,其最优(斐波那契堆优化)可达到O(VlogV+E)的时间对于稠密图来说是相对合算的。

    Kruskal算法

    与Prim算法相对,Kruskal算法是以边为单位的,它的思路同样很简单:每次选择图中权值最小的边加入最小生成树,但是如果这条边会使现在的树连通,也就是加入这条边就成环了,就抛弃这条边。

    单从思路上看,虽然我们没有理论上的证明,也能够get到这个算法为什么是正确的。因为边都是最小的,而且也不会成环,那树的权值一定是最小的。这个与最短路径问题不同,最短路径问题,一条边s(v,w)长度小,可是之前的dist[v]可能很大,导致可能有另一点k,长度s(k,w)很大,但是dist[k]+s(k,w)<dist[v]+s(v,w)。而最小生成树问题,如果有一条边是最短的(没有并列的情况),那它一定在最小生成树里。即使有并列最小,那在并列边里也一定有至少一条在最小生成树里。这是因为最小生成树的权值和是只考虑所有的边s(v,w)的。

    那么这样一来,选择权值最小的边很容易:对所有边进行排序即可。这里我们使用边集数组存储图,每条边存储两边端点以及权值:

    现在的主要问题是,如何判断一条边加入后,这条树是否会变为图?也就是是否会生成环。在这个问题没有解决之前,这个算法仍然是一个理论上的算法,但是没法实现。当“并查集”这一数据结构出来之时,这个算法才算是真正得以应用了。

    简单解释一下并查集,以后可能也会单开博客讲,但是这里不讲清楚就没法继续算法了。所谓并查集并查集,其核心是一个“集”也就是集合,但是其实并查集是一棵树,只不过与常规的二叉树不同,每个节点只指向它的父亲。它的父亲也只指向父亲的父亲.....最终的祖宗是没有父节点的。一旦这样的一个结构形成,我们就能够判断两个节点是否在同一个集合之中,只需要判断它们的老祖宗是否相同即可。

    例如,1和5的祖宗都是6,那他们在一个集合里。7的祖宗是0,所以1和7不在同一集合里。

    那么我们如何判断加入这条边后会不会成环呢?我们在每次加入一条边之后,就把边的两端加入同一个集合中,也就是并查集,这样表明这两个顶点已经连接过了。这样,以后在选择边时,只需要判断其两个顶点是否已经曾经连接过,就能判断是否加入这条边了。

    那么代码的框架就很简单了,首先弹出当前最小的边,如果边的v1、v2不在同一并查集中,我们就加入这条边,并把v1、v2合并在同一并查集中(简单说,也就是father[v1]=v2;但是实际上,要对并查集做很多优化);如果v1、v2同属同一并查集,说明这条边会使生成树有环,故抛弃该边。

    如果,我们开始选择边3-4,1-2.然后分别构造了并查集{3,4},{1,2}。然后在选择边2-3时,我们合并了并查集{1,2}与{3,4},构成了{1,2,3,4}。接下来弹出2-4这条边,它的两端2、4,在之前都与顶点3连接。这样2与4有公共的祖宗(有可能是3,也有可能是1、2、4)。这就说明加入这条边2-4后,会使得树构成一个环,所以我们不能选择加入这条边。

    算法的思路是很简单的, 代码如下:

    struct Edge{
        int v1, v2, w;
    };
    
    typedef struct EG ENode, *EGraph;
    struct EG{
        Edge elist[MAXE];
        int v, e;
    };
    
    
    struct cmp
    {
        bool operator()(Edge e1, Edge e2) {
            if(e1.w == e2.w && e1.v1 == e2.v1)
                return e1.v2 > e2.v2;
            else if(e1.w == e2.w)
                return e1.v1 > e2.v1;
            return e1.w > e2.w;
        }
    };
    
    int uniSet[MAXN];
    priority_queue<Edge, vector<Edge>, cmp> kq;
    
    EGraph buildEdgeSet(){
        int n, m;
        EGraph g = new ENode;
        cin >> n >> m;
        g->v = n;
        g->e = m;
        for (int i = 0; i < m; i++) {
            cin >> g->elist[i].v1 >> g->elist[i].v2 >> g->elist[i].w;
            kq.push(g->elist[i]);
        }
        for (int i = 1; i <= n; i++)
            uniSet[i] = -1;
        return g;
    }
    
    int findSet(int e){
        if(uniSet[e] < 0)
            return e;
        return findSet(uniSet[e]);
    }
    
    void unionSet(int ra, int rb){
        uniSet[ra] = rb;
    }
    
    int kruskal(EGraph g){
        int e = 0;
        int sum = 0;
    
        while (e != g->v-1 && !kq.empty()) {
            Edge head = kq.top(); kq.pop();
            int v1 = head.v1;
            int v2 = head.v2;
            int w = head.w;
    
            int ra = findSet(v1);
            int rb = findSet(v2);
    
            if(ra == rb)
                continue;
    
            e++;
            sum += w;
            unionSet(ra, rb);
        }
    
        return (e == g->v-1 ? sum : -1);
    }

    注意这里使用堆是一种可行的方案但不唯一,使用sort也是可以的。初始时我们将每个顶点的set设为-1,表示没有祖宗。后续的并查集操作中,findSet表示找到节点的root也就是祖宗,unionSet表示合并两个并查集。这里采用的是最朴素的办法并且没有进行路径压缩。如果按照朴素的方法的话并查集操作的时间复杂度能来到O(V)。实际上我们可以对并查集进行按秩归并和路径压缩的优化,N次合并M查找的时间复杂度为O(Malpha(N)),这里alpha是Ackerman函数的某个反函数,在很大的范围内这个函数的值可以看成是不大于4的。(这一部分是计算机大神Tarjan给出了详细的数学证明,与SPFA的时间复杂度“证明”是不同的,因此可以放心食用)

    那么Kruskal算法的时间复杂度,对边排序需要O(ElogE),而添加边的操作需要O(E*并查集),这里我们完全能给出O(并查集)<O(logE)的结论,也就是说这一部分时间复杂度O(E*并查集)<O(ElogE)。(实际上添加边的操作,时间复杂度是O((V+E)*alpha(V))的,我们完全可以等价为O(V+E))。那么Kruskal算法的时间复杂度就是固定的O(ElogE)了。这是一种对于稀疏图非常合算的算法,因为它的时间复杂度与顶点数完全没关系,只与边数有关系。


    拓扑排序

    基本上是要讲的最后一个问题了,拓扑排序是建立在有向无环图(DAG图)的基础上的,它的含义是对所有节点进行排序,如果有一条u→v的边,那么排序时,u一定在v的前面(有可能不是直接前驱)。通俗的说,一般的排序是按照某个字段进行排序的(例如大小),而拓扑排序则是对图中顶点出现的先后进行排序。如果u与v之间没有直接或者间接的连接关系,即u与v互不可达。那么u与v的顺序谁在前谁在后都可以,因为它们之间不存在先后。这也就是拓扑排序区别于普通排序的地方,拓扑排序不是唯一的。

    它的应用有许多。例如选课时可能会有先修课的概念,比如数据结构的先修课是离散数学和C语言。那么我们上课的顺序应该是C、离散数学、数据结构或者离散数学、C、数据结构。

    那么拓扑排序为什么要进行在有向无环图上也就很明显了。如果是无向图,那么就不存在先后的概念,各个顶点的地位是平等的。(我们也可以理解为无向图既有1→2的边,也有2→1的边,所以一定有环)。如果是有环,那么就会引起“死锁”。例如如果C语言要求你先学过离散数学,而离散数学要求你先学过C语言,那么选课顺序就无法确定了。

    这里我们可以试着总结一下,DFS与BFS的适用范围是所有图。Dijkstra的适用范围是没有负值边的图,Floyd的范围是没有负值圈的图。Bellman-ford与SPFA可以运行在有负值边的图,但是这里“有负值边”一般指的是有向图。因为无向图一旦有负值边,那我们可以马上视其为负值圈——我们只需要在这条比上无限走,权值一定会到达无穷小;如果没有负值边,无向图也是可以适用的。Prim和Kruskal解决的是无向图的最小生成树问题。拓扑排序解决的是有向无环图的排序。

    拓扑排序的思路有基于DFS与基于BFS的,但是一般使用后者,因为更简单。我们可以观察到以下信息:如果一个顶点的入度为0,也就是没有顶点指向它,那么它一定是最前面的点(或者之一)。那么很简单的思路就是不断遍历所有顶点,如果有顶点未访问且入度为0,就输出即可。然后此时它已经弹出图,所以它指向的顶点入度减一。

    但是这样显然时间复杂度太高,所以我们加设一个容器(如果是队列,就类似于BFS了;当然用栈也是可以的),存放所有入度为0的顶点。每次弹出一个输出,并且更新它所指向的顶点的入度即可。

    int inDegree[MAXN] = { 0 };
    int topV[MAXN] = { 0 };
    bool topSort(MGraph g){
        for(int i = 1; i <= g->v; i++)
            for(int j = 1; j <= g->v; j++)
                if(g->mat[i][j] != 0)
                    inDegree[j]++;
         
        int cnt = 0;
        queue<int> top;
        for(int i = 1; i <= g->v; i++)
            if(inDegree[i] == 0)
                top.push(i);
        
        while(!top.empty()){
            int h = top.front(); top.pop();
            topV[cnt++] = h;
    
            for(int i = 1; i <= g->v; i++){
                if(g->mat[h][i] != 0){
                    inDegree[i]--;
                    if(inDegree[i] == 0)
                        top.push(i);
                }
            }
        }
        
        return cnt == g->v;
    }
    
    

    需要注意的是,这里是根据图构造入度的。实际上一般都是在输入图的时候就把入度数组维护好了,所以构造入度数组的时间复杂度不计入内。而使用邻接表得到入度信息,实际上也比较麻烦,这里就不附上代码了。也是只需要把下面的遍历过程改几行即可。

    有兴趣的可以对比一下BFS_M的代码,其实这里也就改了几行,把visited通过inDegree来判断是否加入队列。所以拓扑排序的时间复杂度与BFS是相似的,这里也就不赘述了。同时可以看到,它可以简单的判环。一旦有环,就肯定有顶点无法入队,那么最后cnt肯定与V不同。


    总结

    以上就是图的最基本算法的内容了,这些都是大学本科学的基本的图算法,并且代码基本能附上的都附上了。后续可能会写一些其他的图算法。

    当时我是对图摸不着头脑的,现在已经好很多了。其实图算法之所以难学,最难的地方可能是理解图的存储结构,这也是我当时不太会写代码的原因。现在把存储结构弄明白了,算法本身可能没那么难。无非就是贪心、搜索的两种基本框架(floyd是DP,但是代码很简洁)。这里最复杂的可能是Dijkstra算法,但是它的框架其实也就两步——找最小距离顶点,松弛。总之关于图算法的问题先到这里告一段路吧。

  • 相关阅读:
    2017.4.6下午
    2017.4.6上午
    2017.3.31下午
    2017.4.5下午
    2017.4.5上午
    2017.4.1上午
    2017.3.31上午
    2017.3.28下午
    2017.3.28上午
    3.28上午
  • 原文地址:https://www.cnblogs.com/buaapgone/p/14137850.html
Copyright © 2011-2022 走看看