数据结构系列-7 图

一、图的基本概念

:有穷集合V和边E的集合。集合中的点称为顶点(树中为节点)。

有向图和无向图:有向图--边有方向;无向图--边没有方向。

:有向图中的边称为弧;含箭头的顶点称为弧头,另外一边称为弧尾

顶点的度、入度和出度:与顶点相连边的总数称为
有向图中,以顶点为弧头的边的总数称为 入度
有向图中,以顶点为弧尾的边的总数称为 出度

有向完全图和无向完全图
无向图中,n(n-1) /2 条边就可以把所有顶点全部直接连通,有n(n-1) /2 条边的无向图,称为无向完全图;
有向图中,n(n-1) 条弧,就可以把所有顶点都双向直接连通,有n(n-1) 条弧的有向图,称为有向完全图

路径和路径长度
相邻边构成的序列,称为路径。路径上边的数目称为路径长度。

简单路径:路径中没有重复顶点称为,简单路径。

回路:起点和终端一样的路径

连通、连通图和连通分量:两个顶点直接有路径,称为连通;任意两顶点都连通称为连通图。极大连通子图,称为图的连通分量。

强连通图和强连通分量:有向图中,任意两个顶点都相互连通(A到B 、B到A都连通),强连通图

权和网:路径上有个对应数称为权,带权图,称为网。

二、图的存储结构

1.邻接矩阵

2.邻接表

3.邻接多重表

三、图的遍历操作

1.深度优先遍历(DFS)

基本思想:首先访问出发点v,并且标记为访问过,然后选取与v邻接的未被访问的任意一个顶点w,并访问他,重复这个过程,当一个顶点的所有邻接点都被访问过时,依次回退。

递归实现的 伪代码

已知:graph 是所求图 s是任意选取的初始节点

算法开始:
let visited = [n]int{false ...} //初始化visited 记录节点是否已经被访问过。
graphdfs(graph,v): {
    visited[v] = true
    doVisited(v) //访问时执行的逻辑
    for p in graph.GetConnectedPoint(v) { // 循环图中v的相邻节点
        if visited[p] == false { //如果相邻节点没有被访问过,递归访问
            graphdfs(p)
        }
    }

}
graphdfs(s)
    

此处写下树的先序遍历,以做对照:
treePreOrder(p) {
    if p != null {
        visited(p)
        treePreOrder(p->left)
        treePreOrder(p->right)
    }
}

2.广度优先遍历

算法思想:首先访问起始订单v,然后选取与v相邻的全部顶点进行访问,再依次这些相邻顶点的相邻顶点。

算法伪代码:

注意广度优先需要用到队列

已知 graph 是要求的图,s是任意的一个起点。

let visited = [n]{false} //初始化visited 数组记录节点是否被访问过
graphBfs(graph,s) {
    let Q be a queue;    //初始化一个队列
   
    visited[s] = true
    doVisited(s)
    Q.enqueue(s)

    while p = Q.dequeue() {

        for pn in graph.getConnectedPoint(p) {
            if ! visited[pn] {
                visited[s] = true
                doVisited(s)
                Q.enqueue(pn)
            }      
            
        }
    }  
}

四、最小生成树

本节参考:最小生成树(Kruskal(克鲁斯卡尔)和Prim(普里姆))算法动画演示_哔哩哔哩_bilibili

1.prim算法(普利姆算法)

算法思想:

        再图中任意选出一个顶点作为一颗树,选出和这个顶点相连的最短路径,把路径加入树中,边加入选择的边中,此时得到了一颗有两个边的树。然后再从与这颗树相连的边中中选出最段的边,把他的另外一个顶点加入树,边加入选择边,依次类推,直到所有顶点加入到树中。此时得到的树就是最小生成树。

算法步骤:

1. 初始化 三个数组 selected、minDist、prev 
seleted 记录节点是否再已选集合中,初始都为false;
minDist 记录某一时刻,这个点到 已选顶点的集合的 最小权边的权值大小
prev  记录 顶点连接的 父元素的值。初始都为-1,代表不连接任何顶点。

2. 任意选一个顶点

算法伪代码:

//算法的关键是初始化以下三个数组,用于记录算法中状态的流转。
//seleted 记录节点是否再已选集合中,初始都为false;
//minDist 记录某一时刻,这个点到 已选顶点的集合的 最小权边的权值大小
//prev  记录 顶点连接的 父元素的值。初始都为-1,代表不连接任何顶点。

//已知,图的大小为n ,初始任意顶点为s
//graph 为待求图


算法开始:
// 1. 初始化 三个数组
let seleted be [n]bool{false,...} //记录节点是否再已选集合中,初始都为false
let minDist be [n]int{inf,...} //记录某一时刻,这个点到 已选顶点的集合的 最小权边的权值大小
let prev    be [n]int{-l,...} //记录 顶点连接的 父元素的值。初始都为-1,代表不连接任何顶点。

// 2. 初始化任意一个顶点,(1)Add(s); (2) update(s)
selectd[s] = true; // s 设置为已选择
minDist[s] = -1; // s 已经再已选择集合,此点到选择集合意义失效,距离设置为-1,表示失效
prev[s]= -1; //s订单是第一个顶点,没有前驱,-1表示没有意义。 
for  p,w in graph.GetConnectedPointWeight(s) { //p为连接s的边顶点,w为连接s的边权重
    if minDist[p] > w {
        minDist[p] = w
        prev[p] = s
    }  

} 

// 3. 循环执行 scan、Add 、update 操作,一直到selected 全部标记为 true
while selectd.false.length > 0 {   
    // 3.1 scan 操作,再selectd为false的点中,找到minDist最小的点
    mink = n ; minV = inf
    for k,v  in selectd { 
        if v = false { 
          
            if minV > v {
                minV = v
                minK = k
            }
        }
    }
    // 3.2 Add操作
    selectd[minK] = true; // minK 设置为已选择
    minDist[minK] = -1; // minK 的距离失效
    // 3.3 update操作
    for  p,w in graph.GetConnectedPointWeight(minK) { //p为连接minK的边顶点,w为连接s的边权重
        if minDist[p] > w {
            minDist[p] = w
            prev[p] = minK
        }  

    }

    此时数组prev中key为后继,value为前驱确定的 n-1 条边,和所有顶点就组成最小生成树。
    (注意 prev[s] 没有意义)
    
}

2.kruskal算法(克努斯卡尔最小生成树算法)

算法思想:(需要借助并查集,并查集是一种能执行,查找和合并操作的数据结构)

1.初始化:

  • 把所有的边按照,权重,从小到大排列
  • 所有顶点初始化并查集

2.循环每一条边

        如果 边的两个顶点,已经是同一集合 舍弃边;

        如果 边的两个顶点,不再同一集合,执行并查集合并操作,边加入到最小生成树边。

最后边的集合,就形成了最下生成树。

伪代码:

// 设置 E 为边的集合 
// 边的结构为 Edge struct{ begin, end, weight} begin,end为两个顶点weight为权重
// P 为顶点的集合

let treeE be []Edge
let U be  Uion ; U.init(P)

E = Sort(E)

for e in E {
    if !U.Find(e.begin,e.end) {
        treeE.add(e)
        U.Uion(e.begin,e.end)
    }
}

//treeE边和P组成的新图就是最小生成树。

关于并查集请参考:数据结构系列-10 并查集(union find)_gudongkun1121的博客-CSDN博客

示例:

五、最短路径

1.dijkstra 算法(迪杰斯特拉算法)

算法特点:

  • 时间复杂度低
  • 不能处理有负边权的图。

算法描述:

算法核心:把图的点分为已标记为最优顶点,和未标记顶点两部分。初始时,已选择部分只有起始点。

步骤:

1 初始化三个数组
2 对初始顶点做 add、update操作
3 循环,scan、add、update操作 一直到所有节点都标记为最优解

伪代码:


算法核心:初始化三个数组记录状态流转,顶点分为已经是最优路径,和不是最优路径两个部分
    bestPath: 记录节点是否以经进入最优路径,初始都为false
    minDist:  记录节点,到初始节点的最短距离,初始都为inf(无穷大)
    prev:     记录节点,的前驱节点,初始化为-1,代码没有前驱
已知:  s为初始节点(注意连点最短距离算法中,初始点是已知的),顶点数n, graph是所求图

算法开始:
// 1.初始化
let minDist be [n]int{inf,...}; //记录节点,到初始节点s的最短距离,初始都为inf(无穷大);
let bestpath be [n]bool{false,...}; //记录节点是否以经进入最优路径,录到bestpath
let prev be [n]int {-1,...} //记录节点,的前驱节点,初始化为-1,代码没有前驱

// 2. add 和 update 初始节点
// 2.1 add 
bestpath[s] = true; // s到自己的距离最近,所以先记录为最优
minDist[s]  =0;     // s到自己的距离为0
// 2.2 update 
for p,w in graph.GetConnectedPointWeight(s) {
    // 此时s到s的距离 + s 到与他相邻顶点p的距离,比minDist[p]记录的还要小,
    // 说明之前记录的解不是最优,就需要更新minDist[p]
    // p的前驱也需要更新为 s  
    if minDist[s] + w < minDist[p] {
        minDist[p] = minDist[s] + w
        prev[p] = s
    }
}

// 3 循环 scan、 add 、update三个操作,直到bestpath,中所有节点都设置为最优解

while bestpath.false.lenght > 0 {
    // 3.1 scan 找出没被标记为 bestPath中的最小路径。
    minK = n; minV=inf  
    for k,v in bestpath {
        if !v {
            if minDist[k] < minV {
                minK = k;
            }
        }
    }
    // 3.2 add操作
    bestpath[minK] = true; // s到自己的距离最近,所以先记录为最优
    // 3.3 update操作
    for p,w in graph.GetConnectedPointWeight(minK) {
        // 此时minK到s的距离 + s 到与他相邻顶点p的距离,比minDist[p]记录的还要小,
        // 说明之前记录的解不是最优,就需要更新minDist[p]
        // p的前驱也需要更新为 minK  
        if minDist[minK] + w < minDist[p] {
            minDist[p] = minDist[minK] + w
            prev[p] = minK
        }
    }
}

当循环完成后:
        prev 中记录了 顶点到 s 点最短路径的直接前驱;
        minDist 中记录了,顶点到 s 点的最短路长度;
        通过这两个数组,就能推倒出所有订顶点到s最优路径。
        

动画演示:【算法】最短路径查找—Dijkstra算法_哔哩哔哩_bilibili

注意:

dijkstra最短路径算法,和 prim 最小生成树算法有很多相似之处:

  1. 都是分成两部分(prim:已选择、未选择;dijkstra:已标记最优为标记最优)
  2. 都需要出示话三个数组,记录状态流转
  3. 第二部,都需要 对初始节点 执行add、update操作
  4. 第三部,都需要循环执行 scan、add、update操作

但是他们也有很多不同之处:

  1. prim是找出最小生成树;dijkstra是寻找最短路径。
  2. prim的初始节点是任意的,dijkstra初始节点,就是要寻找的最短路径的初始节点
  3. prim中 minDist:  记录节点,到初始节点的最短距离;dijkstra 中 minDist 记录某一时刻,这个点到 已选顶点的集合的 最小权边的权值大小。
  4. scan 不同:prim中 scan 操作为在selectd为false的点中,到初始节点距离最短的顶点; dijkstra 中scan 找出没被标记为 bestPath中的最小路径
  5. update 不同:prim中 minDist[p] = w,prev[p] = minK;dijkstra 中 minDist[p] = minDist[minK] + w ,prev[p] = minK

注意到这些,可以帮助我们记忆,这两个算法。

举例:

2.bellman-ford(贝尔曼福特算法)

算法特点:

  • 时间复杂度高 O(mn)
  • 可以处理负边全情况
  • 不能处理有 负边权回路 的情况(从一点开始走回路,权值为负数)

算法描述:

 /**

已知:

  1. E 是边的集合 ,每条边包含:弧头、弧尾、权重三个信息。
  2. 总顶点数为n
  3. 起点是s

*/

// 1.初始化:设数组dis[n]记录所有顶点到 s的距离,全部初始为无穷大,s到自己的距离设置为0;设置前驱 数组prev[n]记录自己到s的路径的直接前驱,都初始化为0.

E is all edges;

let dis be array ;  dis[] = {\infty} ;dis[s] = 0;

let prev be array; prev[]={0}

//2.循环松弛: 外层循环n-1次,内层循环所有的边,最里面对所有顶点做松弛操作。

//所谓松弛操作:就是,查看每条边 ( 弧头的dis值 + 边的权重 < 弧尾的dis值 )是否成立。

成立则说明 走这条边是更优秀距离,更新 弧尾的dis值,并且把弧尾的prev 成 这条边的弧头。

for (i = 1;i <=n-1;i++) {

        for v,u,w := range E { // v 弧头 u 弧尾 w 边的权重

               if (dis[v] + w < dis[u]) {

                        dis[u] = dis[v] + w

                        prev[u] = v

                }

        }

}

print prev // prev中就有 s点到任意一点的最短距离

为什么是循环n-1次。

  1. 第一次循环时,最坏的情况是 只有s点相邻的一个点被松弛到,依次类推
  2. 如果每次选循环都是最坏情况,才能保证所以的点都被松弛到。
  3. 贝尔曼,是通过,弧头的松弛值+权重 和 弧尾的松弛比较的方式查看,是否是最优解的,所以必须保证所有的点都有松弛值,才有意义。

总结:贝尔曼福特算法是最简单的,最短路径算法,可以处理有负数边权的情况;时间复杂度高O(mn) ,不能处理 负边权回路的情况(走了一圈,反而更省力,就会无限循环走圈)

算法举例:

  1.  第一次循环时:
    1.   (A,B,6)可以松弛B为6;prev[B]=A
    2.   (A,D,7)可以松弛D为7;prev[D]=A
    3. (B,D,8)结果为 14 > 7,不松弛
    4. (B,C,5)可松弛C为 11,prev[C]=B
    5.   (B,E,-4)  可松弛E为2,prev[E]=B
    6.   (D,C,-3)  结果为 4 < 11,可松弛C为 4 ,prev[C]=D
    7.   (D,E,9)  结果为 16 > 2,不可松弛
    8.   (E,C,7)  结果为 9 < 4,不可松弛
    9.   (E,A,7)  结果为 4 > 0,不可松弛

 2.第二次循环,不变

 3.第二次循环,不变

 4.第二次循环,不变

我们发现,这个图,再第一次循环后就已经稳定了。

3.floyd(弗洛伊德算法)

本节参考:最短路径Floyd算法_哔哩哔哩_bilibili

算法特点:

能从path 二维数组中找到任意两个顶点直接的最短路径。

算法描述:

1.初始化两 二维数组:
A       :存储了任意两个顶点之间的当前的最短路径,初始值是邻接矩阵的值
PATH:任意两个顶点之间最短路径的 中间点,初始值都是-1

2. 循环图中每个顶点 设此时顶点为V ,循环内执行以下操作::

       内层循环所有的 顶点对{i,j}(注意不是边,不是<>) 且 i <>j, v<>i, v <> j 循环内执行以下操作:

                 如果 A[i][j] > A[i][v] + A[v][j],(i 到 j 此时的距离,比 i经过v再到j成本还高),则A[i][j] =A[i][v] + A[v][j] ; Path[i][j] = v

3. 如何通过PATH数组,获取一个最短路径。

如 :顶点i , j 之间的路径 通过 v = PATH[i][j],说明i ,j 经过v;

i 和 v 之间经过 w=PATH[i][v];
j 和 v 之间 x = PATH[v][j];

这样递归的获取 中间顶点 ,一直到边为-1(-1表示有之间的边相连)

最终就能获得 i ..w..v..x..j之前的所有顶点

伪代码实现:


已知:graph为所求图,n为顶点总数。

获取path 数组的算法如下
// 1.初始化
let path = [n][n]{-1...}
let A = [n][n]{};

for i = 0; i < n; i++ { // 把A初始化为所求图的邻接矩阵,如果图就是邻接矩阵实现的,可以直接
    for j = 0; j < n; j++ { // 令 A = graph.getMatrix()
        if i == j {
            A[i][j] = 0
        } else if !graph.isConnected(i,j) {
            A[i][j] = inf
        } else {
            A[i][j] = graph.getWeight(i,j)
        }

    }
}

// 2. 循环执行算法
for v = 0; v < n; v++ {
    for i = 0; i < n; i++ { 
        for j = 0; j < n; j++ {
            if v ==i || v==j|| i==j {
                continue;
            }
            if A[i][j] > A[i][v]+A[v][j] {
                A[i][j]  = A[i][v]+A[v][j]
                path[i][j] = v

            }
        }
    } 
}

此时最短路径矩阵path就被求出



通过path 数组打印任意两条之间的最短路径:

printPath(u,v,path) : {
    if path[u][v] == -1 {
        echo(u,v)
    } else {
        mid = path[u][v];
        printPath(u,mid,path);
        printPath(mid,v,path);

    }
}

算法思考:

由于A的初始值是邻接矩阵,直接相连的点,就已经确定,可再path中确定为-1,;

此后循环第一个点v能,找出,和v直接相连的点的 path 和A 信息

第二次 循环 w能找到 i,w,j 或者 i v,w,j ( j i 再邻接矩阵循环中会有对应的值)

以此类推,就能,循环到 i 和 j之间的所有点。

六、拓扑排序

本节参考:图的拓扑排序算法_哔哩哔哩_bilibili

1.AOV-网 :

用顶点表示活动,用弧表示活动时间的优先关系的有向无环图,称为顶点表示活动的网(activity on vertex network),称为AOV-网

应用举例:

2.拓扑排序算法

拓扑排序的定义:

G是有n个顶点的有向图,G的顶点序列(v1,v2,v3,....,vn)称为一个拓扑序列;
当顶点序列满足一下关系时:
<i, j> 是图中 i到j的边或路径,再拓扑序列中i必须排在顶点j 之前。

再一个有向图中寻找一个拓扑序列的过程,称为拓扑排序。

AOV网的特性:

  1. vi 是 vj的先行活动,vj是vk 的先行活动,则vi必须是 vk的先行活动,即先行关系具有可传递性。
  2. AOV-网的拓扑序列不是唯一的

算法思想:

 1)从图中选一个无前驱(入度为0)的顶点输出;
 2)  将此顶点和以它为起点的弧删除;
 3)   重复 1)和 2) ,直到不存在无前驱的顶点;
 4)   若此时输出顶点数,小于有向图中的顶点数,则说明有向图中存在回路,否则输出的顶点的顺序即为一个拓扑序列。

算法步骤:

1.求所有顶点的入度,可以附设一个数组 indegree[]
2.把所有入度为0的顶点入队列或者栈
3.当栈或队列不空时
        1)出栈或出队列顶点为u,输出顶点u
        2)顶点u的所有邻接点入度减1,如果有入度为0的顶点,则入栈或者入队列

4.若若此时输出顶点数,小于有向图中的顶点数,则说明有向图中存在回路,否则输出的顶点的顺序即为一个拓扑序列。

伪代码实现:

已知 graph为所求aov 顶点总数为n

// 1 初始化入度数组
let indegree = []{0}
for p in graph.getPoins() {
    indegree[p] = graph.getIndegreeNum(p)
}
let S be a stack
let Q be a queue // 记录拓扑序列
// 2 入队列所有入度为0的顶点
for i = 0;i<n;i++ {
    if indegree[i] == 0 {
        S.push(i)
        Q.enqueue(i)
    }
}

// 3.选好栈不为空
while (!S.isEmpty()) {
    u = S.pop()
    for p in graph.getConnected(u) {
        indegree[p] -- 
        if indegree[p] == 0 {
            Q.enqueue(p)
        }
    }

}

if Q.length < n  {
    echo "err have cycle"
} else {
    echo Q
}

七、AOE网和关键路径

本节参考:关键路径_哔哩哔哩_bilibili

1.什么是Aoe网

AOE(Activity on edge network) :在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,边上的权值表示活动的持续时间,称这样的有向图叫做边表示活动的网,简称AOE网

AOE网中没有入边的顶点称为始点(或源点);没有出边的点称为终点(或汇点)

2.AOE网的性质

  1. 只有再某顶点所代表的事件发生后,从该顶点出发的活动才能开始;
  2. 只有再进入某顶点的各活动都结束,该顶点所代表的事件才能发生。

3.AOE网可以解决下列问题:

  1. 完成整个工程至少需要多少时间
  2. 为缩短完成工程所需要的时间,应当加快哪些活动

4.关键活动

关键路径:耗时最长的活动,可能不只一条,所以最主要的是要找到,最不能耽误的活动称为关键活动

5.如何求关键活动?

关键活动的四个前导量:

(1)事件A的最早发生时间event_early[B] :event_early[B] = max{event_early[A]+len<A,B>}
(2)事件A的最迟发生时间event_latest[B]:event_latest[B] = min{event_latest[C]-len<A,B>}
(3)活动AB 的最早发生时间 activity_early[AB] :ctivity_early[BC]  = event_early[B]
(4)活动AB 的最早发生时间 activity_latest[AB] :  activity_latest[CD] = event_latest[D] - len[CD]

注:len<A,B>表示 弧AB的长度

(1) event_early分析

event           : A  B  C  D  E  F  G    H    I
event_early : 0  6   4  5   7  7  16  14  18

  1. 从起点开始算,起点时间一定是0
  2. 之后的事件用前一个事件的时间推算如:event_early[B] = max{event_early[A]+len<A,B>}

(2)event_latest分析

event           : A  B  C  D  E   F   G    H    I
event_early : 0  6   4  5   7   7   16  14  18
event_latest: 0  6   6   8  7  10  16  14  18

  1. 从后往前计算,结束事件直接取event_early
  2. 用前一个事件的时间推算如:event_latest[B] = min{event_latest[C]-len<A,B>}
  3. 开始时间,必定是0

(3)activity_early 分析

活动BC 的最早开始时间应该等于 时间B的最早开始时间因此有:activity_early[BC]  = event_early[B]

event           : A  B  C  D  E   F   G    H    I
event_early : 0  6   4  5   7   7   16  14  18
event_latest: 0  6   6   8  7  10  16  14  18

activity           :  AB    AC    AD    BE   CE   DF  EG  EH  FH   GI   HI                       
activity_early :  0       0       0       6      4     5     7      7     7     16   14

(4)  activity_latest分析

activity_latest 要从前往后算

例如活动CD 的最晚开始时间要保证时间D的最迟时间不能拖后所有:activity_latest[CD] = event_latest[D] - len[CD]

event           : A  B  C  D  E   F   G    H    I
event_early : 0  6   4  5   7   7   16  14  18
event_latest: 0  6   6   8  7  10  16  14  18

activity           :  AB    AC    AD    BE   CE   DF  EG  EH  FH   GI   HI                       
activity_early :  0       0       0       6      4     5     7      7     7     16   14
activity_latest:  0       2       3       6      6     8     7     11    10    16   14

关键活动

   最早开始时间和最晚开始时间是一样的称为关键活动。

activity           :  AB    AC    AD    BE   CE   DF  EG  EH  FH   GI   HI                       
activity_early :  0       0       0       6      4     5     7      7     7     16   14
activity_latest:  0       2       3       6      6     8     7      7    10    16   14

  1. 关键活动组成的路径叫关键路径
  2. 虽然会产生多条关键路径,但是多条关键路径的执行时间是一样的。
  3. 工程的总执行时间就是任意选其中一条关键路径的总执行时间。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值