loading...

06图

6.1 图

图G由顶点集V和边集E组成,即为G=(V,E),其中V(G)表示图G中顶点的有限非空集,E(G)表示图G中顶点之间的关系(边)集合。若V={\(v_1,v_2,...,v_n\)},则用|V|表示图G中顶点的个数,也称为图的阶;若E={(u,v)|u∈V,v∈V},则用|E|表示图G中边的条数

图不可为空,V一定是非空集,但E可以是空集

6.1.1 图基本概念

图的基本分类

  • 无向图
    • 若E是无向边(简称边)的有限集合时,则图G为无向图
    • 边是顶点的无序对,记为(v,w)或(w,v),其中v、w是顶点

      可以说顶点w和顶点v互为邻接点。边(v, w)依附于顶点w和v,或者说边(v, w)和顶点v、w相关联。

  • 有向图
    • 若E是有向边(也称弧)的有限集合时,则图G为有向图
    • 弧是顶点的有序对,记为<v, w>,其中v、w是顶点,他是有方向的

      <v, w>称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v

  • 简单图
    • 图中,不存在重复的边,也不存在顶点到自身的边

      有向图初发于不同顶点的边不是重复边,考研一般只考虑简单图

  • 多重图
    • 两个节点之间的边数多于一条,且允许顶点通过同一条边和自己相连
  • 稀疏图:边很少的图
  • 稠密图:边较多的图

图的基本性质

对于无向图,

  • 度:顶点v的度指依附在该顶点的边的条数,记为TD(V)
  • 对于具有n个顶点,e条表的无向图,\(\displaystyle \sum^{n}_{i=1}\) TD(\(V_i\))=2e,即无向图全部顶点的度之和等于边数的两倍

对于有向图,顶点的度等于出度与入度的和,即TD(V)=ID(V)+OV(V)

  • 入度:以顶点V为终点的边的条数,记为ID(V)
  • 出度:以顶点V为起点的边的条数,记为OD(V)
  • 对于具有n个顶点,e条表的有向图,\(\displaystyle \sum^{n}_{i=1}\) ID(\(V_i\)) = \(\displaystyle \sum^{n}_{i=1}\) OV(\(V_i\)) = e,即有向图出度等于入度

图的路径

  • 路径:顶点\(v_p\)到顶点\(v_q\)之间的一条路径指的是路过的顶点序列
    • 对于有向图来说,路径也是有向的
    • 有向图,无向图中的任意两个顶点之间也有可能不存在路径
    • 简单路径:路径序列中不存在重复出现的顶点的路径
  • 回路:第一个顶点和最后一个顶点相同的路径称之为回路或者环
    • 简单回路:除了第一个顶点和最后一个顶点外,其余顶点不重复出现的回路
  • 路径长度:路径上边的数目
  • 点到点的距离:从顶点u触发到顶点v的最短路径若存在,则路径长度称为从u到v的距离;若从u到v不存在路径,则距离为∞
  • 无向图中,若从顶点v到顶点w有任意路径存在,则称v和w是连通的
  • 有向图中,若从顶点v到顶点w,且从顶点w到顶点v,均有任意路径存在,则称v和w是强连通的

图的连通

  • 连通图:若无向图G中任意两个顶点都是连通的,则称图G为连通图,否则为非连通图

    • 对于n个顶点的连通图,最少有n-1条边
    • 对于n个顶点的非连通图,最多边数为\(C_{n-1}^{2}=\frac{(n-1)!}{2!(n-3)!}=\)(n-1)(n-2)/2
      • 因此确保n个顶点的图为连通图,至少需要\(C_{n-1}^{2} + 1 =\frac{(n-1)!}{2!(n-3)!}+1=\)(n-1)(n-2)/2+1
  • 强连通图:若有向图G中任意两个顶点都是强连通的,则称图G为强连通图

    • 对于n个顶点的强连通图G,最少有n条弧,且形成回路
  • 子图:设有两个图G=(V,E)和G'=(V',E'),若V'是V的子集,且E'是E的子集,则称G'是G的子图

    • 并不是任意的边和顶点构成的子集就能成为原先图的子图,需要符合图本身的定义
    • 子图概念适用于有向图、无向图
    • 若存在满足上述条件V(G')=V(G)的子图G',则称其为G的生成子图
  • 连通分量:无向图中极大连通子图

    • 极大连通子图:一般针对于非连通图所做的定义,该子图必须连通且包含尽可能多的边和顶点
      • 如果本身就是连通图,则本身就是其连通分量
      • 而非连通图的各个连通图作为其组成部分均为其连通分量
    • 一个图的所有连通分量合并构成该图
  • 强连通分量:有向图中极大强连通子图

  • 生成树:包含图中所有顶点的极小连通子图

    • 极小连通子图:
      • 一般针对连通图所做的定义,该子图的边需要尽可能的少,但是要保持连通
      • 因此带环的子图一定不是最小连通子图
    • 包含n个顶点的生成树,含有n-1条边,生成树不唯一
    • 对于生成树而言,若去掉一条边则会变成非连通图;若加上一条边会产生回路
  • 生成森林:在非连通图中,连通分量的生成树构成了非连通图的生成森林

带权图

  • 边的权:图中每条边都可以表上具有某种含义的数值,该数值称之为权值
  • 带权图:图的边带有权值,称之为带权图,也称之为网
  • 带权路径长度:带权图一条路径上所有边的权值之和,称之为路径的带权路径长度

特殊的图

  • 无向完全图:无向图中任意两个顶点均存在边
    • 对于n个顶点的无向完全图,则边数共有\(C_{n}^{2}=\frac{n!}{2!(n-2)!}=\)n(n-1)/2
  • 有向完全图:有向图中任意两个顶点均存在方法给相反的两条弧
    • 对于n个顶点的有向完全图,则边数共有\(2C_{n}^{2}=\frac{n!}{(n-2)!}=\)n(n-1)
  • 树:不存在回路的且连通的无向图
    • n个顶点的树有n-1条边。对于n个顶点的图,若边数大于n-1,则必有回路
  • 有向树:一个顶点的入度为0,其余顶点的入度均为1的有向图

6.1.2 图的存储

邻接矩阵法

  • 邻接矩阵法存储图

    • 假设图有N个顶点,使用NxN的二维数组存放边表,长度为N的一维数组存放顶点信息,二维数组的行、列按照一维数组分别对应。二维数组中值为1说明该横纵坐标对应的两个顶点之间存在边,值为0表示不存在边

    只要确定了顶点的编号,邻接矩阵就是唯一的

    #define MaxVertexNum 100
    typedef struct{
      ElemType Vex[MaxVertexNum]; //顶点表
      bool Edge[MaxVertexNum][MaxVertexNum];   //邻接矩阵,边表
      int vexnum,edgenum; //定点数、边/弧数
    }MGraph;
    

    求顶点的度/出度/入度的时间复杂度为O(n)

    • 第i个节点的度=第i行/第i列的非零元素的个数
    • 第i个结点的入度= 第i列的非零元素个数
    • 第i个结点的度= 第i行、第i列的非零元素个数之和
  • 邻接矩阵法存储带权图

    • 与之前的方法类似,若不存在表的值为无穷大(或者极大的一个值);若存在边值为边的权值
    #define MaxVertexNum 100
    #define INFINITY 10^10    //宏定义常量无穷
    typedef struct{
      ElemType Vex[MaxVertexNum]; //顶点表
      int Edge[MaxVertexNum][MaxVertexNum];   //邻接矩阵,边表
      int vexnum,edgenum; //顶点数、边/弧数
    }MGraph;
    

邻接矩阵存储图的空间复杂度为O(\(n^2\)),其中n为顶点数,与实际的边数无关。邻接矩阵法比较适合存储稠密图

无向图的邻接矩阵其实是一个对称矩阵,且由于一般考察简单图,对接线元素均为0,所以可使用压缩矩阵存储上三角或下三角区
邻接矩阵删除图G的边很方便,但是删除顶点需要调整的数据很多

邻接矩阵法性质

  • 设图G的邻接矩阵为P(矩阵元素为0/1),则An的元素\(A^n_{i,j}\)等于由顶点i到顶点j的长度为n的路径的数目

    例如,\(P=\begin{bmatrix}0 & 1 & 0 & 0 \\1 & 0 & 1 & 1 \\0 & 1 & 0 & 1 \\0 & 1 & 1 & 0\end{bmatrix}\),则有\(P^2=\begin{bmatrix}1 & 0 & 1 & 1 \\0 & 3 & 1 & 1 \\1 & 1 & 2 & 1 \\1 & 1 & 1 & 2\end{bmatrix}\)
    假定图G的顶点A、B、C、D分别对应下标1、2、3、4
    \(P^2_{1,4}=a_{1,1}a_{1,4} + a_{1,2}a_{2,4}+ a_{1,3}a_{3,4}+ a_{1,4}a_{4,4}= 1\),表示从顶点A出发到达顶点D,长度为2的路径条数
    不难发现,\(a_{1,1},a_{4,4}\)是无意义的,因此只需关注 \(a_{1,2}a_{2,4},a_{1,3}a_{3,4}\) 分别表示A-B-D和A-C-D两条路径,但由于A-C不连通,因此条数只有一条
    \(P^2_{2,2}=a_{1,1}a_{1,4} + a_{1,2}a_{2,4}+ a_{1,3}a_{3,4}+ a_{1,4}a_{4,4}= 3\),同理可表示从顶点B出发到顶点B,长度为2的路径条数共3条

邻接表法

  • 类似于树的孩子表示法,通过链式存储和顺序存储结合进行图的存储

    并且由于邻接表边的顺序不固定,因此构造的邻接表不唯一

    #define MaxVertexNum 100
    //边数据结构,链表存储
    typedef struct ArcNode{
      int index;  //边指向的节点下标
      ArcNode *next;  //指针域,指向下一条边
      InfoType info;   //带权边的权值
    }ArcNode;
    //顶点数据结构,顺序存储
    typedef struct VNode{
      ElemType data; //数据域
      ArcNode *frist; //指针域,指向第一条边
    }VNode,AdjList[MaxVertexNum];
    
    typedef struct{
      AdjList vertices;//顶点集
      int vexnum,edgenum; //顶点数、边/弧数
    }
    

对于无向图,使用邻接表法存储,边的存储时有冗余的,空间复杂度为O(n+2e),其中n为顶点数,e为边数
对于有向图,使用邻接表法存储,空间复杂度为O(n+e),其中n为顶点数,e为边数

因此,邻接表适合存储稀疏图
邻接表进行边/顶点的的删除均不方便

  • 计算顶点的出度:线性表找到该顶点,统计边的数量即可,时间复杂度为O(\(e_i\)),其中\(e_i\)表示第i顶点的边数
  • 计算顶点的入度:对于无向图而言,某顶点的出度等于入度;对于有向图而言,需要遍历整个邻接表,统计该顶点的个数,时间复杂度为O(e+n)
  • 寻找相邻的边:对于无向图而言,相邻的边直接找该顶点的边链表即可;对于有向图而言,出边比较好找,但是找入边需要遍历整个表

十字链表、邻接多重表

十字链表:只用于存储有向图

  • 顶点节点,每个顶点包含是数据域、指针域,指针域有两个,分别指向该顶点作为弧头的第一条弧、作为弧尾的第一条弧;每个弧节点包含弧尾顶点编号、弧头顶点编号、权值、指向弧头相同的下一条弧的指针、指向弧尾相同的下一条弧的指针
  • 十字链表法空间复杂度为O(n+e),其中n表示顶点数,e表示弧数
  • 该表示法相比于邻接表的优势在于,优化了找到顶点的所有出边、入边均只要访问指定顶点的一个链表即可

邻接多重表:只用于存储无向图

  • 和十字链表类似,使用顺序组存储顶点节点,每个顶点包含数据域、指针域,指针域指向与该顶点相连的第一条边;边节点包含边相连的两个顶点编号i,j、权值、指向依附于顶点i的下一条边的指针、指向依附于顶点j的下一条边的指针

十字链表法和邻接多重表吸收了邻接表的优点,并且将有向图、无向图的空间复杂度均压缩到了O(n+e),同时由于是链表的特性,优化了邻接矩阵、邻接表删除顶点/边时的复杂性

6.1.3 图的基本操作

以下基本操作一般用于邻接矩阵和邻接表

bool Adjacent(G,x,y);//判断图G是否存在边<x, y>或(x, y)
void Neighbors(G,x);//列出图G中与结点x邻接的边
void InsertVertex(G,x);//在图G中插入顶点x
void DeleteVertex(G,x);//从图G中删除顶点x
void AddEdge(G,x,y);//若无向边(x, y)或有向边<x, y>不存在,则向图G中添加该边。
void RemoveEdge(G,x,y);//若无向边(x, y)或有向边<x, y>存在,则从图G中删除该边
int FirstNeighbor(G,x);//求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1
int NextNeighbor(G,x,y);//假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1
ElemType Get_edge_value(G,x,y);//获取图G中边(x, y)或<x, y>对应的权值
bool Set_edge_value(G,x,y,v);//设置图G中边(x, y)或<x, y>对应的权值为v

6.2 图的遍历

6.2.1 广度优先遍历算法BFS

BFS,Breadth-First-Search。由于树本身就是一种特殊的图,因此图的广度优先遍历实现思路与树的层序遍历类似。但图是可能存在回路的,需要在遍历过程中需要搜索已访问过的节点

算法要点:

  • 找到与某顶点相邻的所有顶点
  • 标记那些顶点已经被访问
  • 需要一个辅助队列存放相连顶点
bool visited[MAX_VERTEX_NUM];   //标记访问数组,初值为false
void BFS(Graph G,int v){   //v表示从序号为v的节点出发进行广度优先遍历
    visit(G,v);
    visited[v]=true;
    Queue Q;
    InitQueue(Q);
    EnQueue(Q,v);
    while(!IsEmptyQueue(Q)){
        DeQueue(Q,v);
        for(int i=FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i)){
            if(!visited[i]){
                EnQueue(Q,i);
                visit(G,i);
                visited[i]=true;
            }
        }
    }
}

对于BFS算法而言,同一个图从同一个出发进行遍历序列是和存储结构相关的:

  • 对于邻接矩阵存储的图,由于查找与顶点相连的其他顶点是按照序号从小到大排列的,因此BFS的序列是固定的
  • 对于邻接表存储的图,每个顶点的出度节点链没有强调先后顺序,因此会导致BFS的序列不固定

上述代码的缺陷:对于非连通图,无法完成所有节点的遍历。因此进行下面的改进

bool visited[MAX_VERTEX_NUM];   //标记访问数组,初值为false
void BFS(Graph G,int v){   //v表示从序号为v的节点出发进行广度优先遍历
    visit(G,v);
    visited[v]=true;
    Queue Q;
    InitQueue(Q);
    EnQueue(Q,v);
    while(!IsEmptyQueue(Q)){
        DeQueue(Q,v);
        for(int i=FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i)){
            if(!visited[i]){
                EnQueue(Q,i);
                visit(G,i);
                visited[i]=true;
            }
        }
    }
}
void BFSTraverse(Graph G){
    for(int i=0;i<G.vexnum;i++)
        visited[i]=false;   //初始化访问记录数组
    for(int i=0;i<G.vexnum;i++)
        if(!visited[i]){    //遇到非连通图,将其余非连通节点依次BFS遍历
            BFS(Q,i);
        }
}

空间复杂度

  • 最坏情况是除了起点其余相连的点均要插入队列中,空间复杂度为O(|v|),其中|v|为顶点数

时间复杂度

  • 主要考虑访问顶点时间和探索边时间
    • 对于邻接矩阵存储结构:共有|v|个顶点,首先需要访问|v|个顶点,需要O(|v|);每个顶点的查找相邻节点仍需要O(|v|),因此整体时间复杂度为O(\(|v|^2\))
    • 对于邻接表存储结构:共有|v|个顶点,首先需要访问|v|个顶点,需要O(|v|);每个顶点的查找相邻节点则需要O(|e|),因此整体时间复杂度为O(|e|+|v|)

广度优先生成树

  • 由于BFS算法要求遍历过程不重复,即不存在环。若遍历的序列包含所有顶点,则顶点和路径直接构成了一棵树,该树被称为广度优先生成树

    由于广度优先序列是不唯一的,因此广度优先生成树也是不唯一的。若使用邻接矩阵,则可以唯一确定一棵广度优先生成树

广度优先生成森林

  • 对于非联通图而言,BFS算法可得到一个广度优先生成森林

BFS算法对无向图、有向图所需要进行执行BFS算法的次数是不同的。往往有向图需要执行的次数更多

6.2.2 深度优先遍历算法DFS

DFS,Depth-First-Search。图的DFS深度遍历类似于树的先根遍历,由于也需要避免环,需要增加访问记录数组

bool visited[MAX_VERTEX_NUM];   //标记访问数组,初值为false
void DFS(Graph G,int v){   //v表示从序号为v的节点出发进行深度优先遍历
    visit(G,v);
    visited[v]=true;
    for(int i=FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i)){
        if(!visited[i]){    
            DFS(Q,i);
        }
    }
}
void DFSTraverse(Graph G){
    for(int i=0;i<G.vexnum;i++)
        visited[i]=false;   //初始化访问记录数组
    for(int i=0;i<G.vexnum;i++)
        if(!visited[i]){    //遇到非连通图,将其余非连通节点依次DFS遍历
            DFS(Q,i);
        }
}

空间复杂度

  • 最坏情况是所有顶点连成一条链,递归深度/空间复杂度为O(|v|),其中|v|为顶点数
  • 最好情况其他所有顶点均直接与起点直接相连,空间复杂度为O(1)

时间复杂度

  • 主要考虑访问顶点时间和探索边时间,和BFS一样
    • 对于邻接矩阵存储结构,时间复杂度为O(\(|v|^2\))
    • 对于邻接表存储结构,时间复杂度为O(|e|+|v|)

和BFS一样,对于邻接矩阵存储结构DFS的序列唯一,深度优先生成树/森林唯一;对于邻接表存储结构DFS的序列不唯一,深度优先生成树/森林不唯一

总结
对于无向图的BFS/DBF遍历,调用BFS/DFS的次数=连通分量数。特别的,对于连通图只需要调用一次BFS/DFS
对于有向图,若起始点到各顶点均有路径,则只需要调用依次BFS/DFS函数;否则,调用次数需要具体分析。特别的,对于强连通图只需要调用一次BFS/DFS

6.3 图的应用

各类算法时间复杂度汇总,其中,n个顶点,e条边

算法 DFS(深度遍历) BFS(广度遍历) Prim(单点扩散最小生成树) Kruskal(选边最小生成树) Dijkstra(单源最短路径) Floyd(每对最短路径) 拓扑排序 关键路径
邻接矩阵 O(\(n^2\)) O(\(n^2\)) O(\(n^2\)) - O(\(n^2\)) O(\(n^3\)) O(\(n^2\)) O(\(n^2\))
邻接表 O(\(n\)+\(e\)) O(\(n\)+\(e\)) - O(\(elog_e\)) - - O(\(n^2\)) O(\(n\)+\(e\))

6.3.1 最小生成树

对于n个顶点的带权连通图,图的边带有权值,要求用n-1条边构成包含所有顶点且边权值之和最小的路径,该路径称之为最小生成树/最小代价树,Minimum-Spaning-Tree,MST

  • 最小生成树不唯一,但边权值之和一定是相同且最小
  • 最小生成树减少一条边则不连通,增加一条边则出现环
  • 如果一个连通图本身就是一棵树,那么自身就是最小生成树

Prim算法,普里姆算法

手算算法思路:

  • 从某个顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入其中
  • 算法时间复杂度为O(\(|v|^2\)),适合边稠密图

机器算法思路:

  • 用一个一维数组isJoin标记节点是否已经加入生成树,初值为false。再用一个一维数组lowCost记录各节点加入树的代价,初值为无穷大
  • 假设从顶点\(v_0\)开始,查找与顶点\(v_0\)相连的所有边,更新数组lowCost相连的节点代价,数组isJoin更新节点\(v_0\)为true,初始化完成

    其中,未连通的节点代价为无穷大,与自己相连代价为无穷大(虽然后序该值都不会改变,但是为了防止后序比较最小边出现问题,直接设置为无穷大)

  • 查找数组lowCost中代价最小的边,且确认该边所连顶点没有加入生成树中,更新数组isJoin,并查找与新加入节点的所有边,如果比原先还未加入顶点的边代价更小,则更新数组lowCost中代价
  • 重复上一过程直至数组isJoin中所有节点均已加入树中

该机器算法,从\(v_0\)开始,共需要n-1轮。每一轮处理所需要的时间复杂度为O(2n),总时间复杂度则为O(\(n^2\))

Kruskal算法,克鲁斯卡尔算法

算法思路:

  • 每次选择权值最小的边,使边的两头连通(原本已经连通的顶点不选),直到所有节点都连通
  • 算法时间复杂度为O(|e|\(log_2|e|\)),适合边稀疏图

机器算法思路:

  • 将所有的边按照权值升序进行排序,利用并查集检查第一条边两个顶点是否连通,如果不连通则进行并操作
  • 依次遍历剩下的边,如果边所对应的两个顶点属于并查集中不同的集合,即相互不连通,则进行并操作
  • 只要边两边的顶点同属于一个并查集,就直接跳过该边,直至遍历所有边

该机器算法共需要执行|e|轮,每轮判定两个顶点是否同属于一个集合共需要O(\(log_2|e|\)),总时间复杂度则为O(|e|\(log_2|e|\))

6.3.2 最短路径

单源最短路径

  • BFS算法,广度优先算法,针对无权图(或者权值均相同的的图)

    • 对于无权图可视为边权值均为1的图
    #define INFINITY 10^10    //宏定义常量无穷
    bool visited[MAX_VERTEX_NUM];   //标记访问数组,初值为false
    int d[MAX_VERTEX_NUM];    //表示从某点出发到其他顶点之间距离,初值为无穷大
    int path[MAX_VERTEX_NUM]; //表示顶点在路径上的前驱节点下标,初值为-1
    
    void BFS_min_Distance(Graph G,int v){
      for(int i=0;i<G.vexnum;i++){
          visited[i]=false;
          d[i]=INFINITY;  
          path[i]=-1;
      }
      Queue Q;
      InitQueue(Q);
      d[v]=0; //初始化起点到起点距离为0
      path[v]=-1; //起点没有前驱,初始化为-1
      EnQueue(Q,v);
      for(int i =FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i)){
          DeQueue(Q,v);
          if(!visited[i]){
              d[i]=d[v]+1;    //第i顶点和第v顶点相连,距离为从该点到起点的距离+1
              path[i]=v;  //第i顶点前驱为第v顶点
              EnQueue(Q,i);
              visited[i]=true;
          }
      }
    }
    

    在上述代码中,path数组其实是一个并查集,用于合并集合最后变为一棵生成树。对于邻接矩阵时间复杂度为O(\(|v|^2\)),对于邻接表时间复杂度为O(|v|+|e|)

    该生成树不一定是最小生成树,而是一棵从某顶点出发到其他点权值之和最少的生成树。对于无权图而言,就是一棵以该起点为根的高度最小的生成树

  • Dijkstra算法,迪杰斯特拉算法,适用带权图/无权图

    • 初始化三个数组finaldistpath,分别用于标记已找到路径、记录最短路径长度、记录最短路径上的直接前驱。final初值为false,dist初值为无穷大,path初值为-1。假设从顶点\(v_0\)开始,规定下标为0,设置dist[0]=0,path[0]=-1
    • 接下来进行n次循环,循环变量v初值为0,每次循环做如下操作:
      • 遍历与当前顶点v的相连的顶点\(v_j\),当前节点v到邻接节点的距离为G.Edge[v][j]。若final[v]==false且dist[j]>dist[v]+G.Edge[v][j],即该邻接顶点\(v_j\)还没有最短路径,且顶点\(v_0\)原先到顶点\(v_j\)距离小于,从顶点\(v\)到顶点\(v_j\)的距离与顶点\(v\)最短路径长度之和,则更新dist[j]的值为更短的距离,path[j]=v
      • 遍历数组dist,找到最短路径长度最短的节点,作为下一次循环的v
    #define MaxVertexNum 100
    #define INFINITY 10^10    //宏定义常量无穷
    bool final[MAX_VERTEX_NUM];   //标记已找到最短路径数组,初值为false
    int dist[MAX_VERTEX_NUM];    //表示从某点出发到其他顶点之间距离,初值为无穷大
    int path[MAX_VERTEX_NUM]; //表示顶点在路径上的前驱节点下标,初值为-1
    
    void Dijkstra(Graph G,int v){
      for(int i=0;i<G.vexnum;i++){
          final[i]=false;
          dist[i]=INFINITY;  
          path[i]=-1;
      }
      dist[v]=0;
      for(int i=0;i<n;i++){
          int min=INFINITY;
          final[v]=true;
          for(i=FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i)){
              if(!final[i] && dist[i]>dist[v]+G.Edge[v][i]){
                  dist[i]=dist[v]+G.Edge[v][i];
                  path[i]=v;
              }
          }
          for(int i=0;i<G.vexnum;i++) if(min>dist[i]) v=i;
      }
    }
    

考虑以下算法复杂度,

  • 空间复杂度上,额外使用到了多个一维数组,都与|v|有关,因此空间复杂度为O(|v|)。
  • 时间复杂度上,
    • 外层循环共执行了|v|次,时间复杂度O(|v|)
    • 内层循环做了两件事,一个是查找数组dist中路径长度最短的点,时间复杂度为O(|v|);另一个是更新数组dist中的值,需要遍历所有与当前节点相连的边依次比较,时间复杂度为O(|v|)
    • 不论是稀疏图还是稠密图,整体时间复杂度均接近于O(\(|v|^2\))

如果带权图中,有边/弧的权值为负数,则上述两种算法将会失效

每对顶点间的最短路径

  • Floyd算法,弗洛伊德算法,适用带权图/无权图
    • 考虑动态规划思想,把问题分阶段处理
      • 初始化两个二维矩阵数组distpath,分别存放不同顶点间最短路径长度、不同顶点间最短路径中转点下标。初始阶段,考虑不同顶点之间直接相连的最短路径,即邻接矩阵赋值给数组dist;数组path由于目前不考虑中转,所以初值为-1
      • 按照\(v_1,v_2,...,v_{|v|}\)的次序依次遍历,每次遍历允许相应的\(v_k\)可以作为中转,比较增加中转节点后的路径长度是否比数组dist中最短路径距离更短,若dist[i][j]>dist[i][k]+dist[j][k],则更新数组dist,并更新中转节点path[i][j]=k
    #define MaxVertexNum 100
    int dist[MAX_VERTEX_NUM][MAX_VERTEX_NUM];   //结构同邻接矩阵,用于记录不同顶点之间的最短路径长度,初值为邻接矩阵的值
    int path[MAX_VERTEX_NUM]; //结构也同邻接举证,用于记录两个顶点最短路径的中转点,初值为-1
    
    void Floyd(Graph G,int v){
      for(int i=0;i<G.vexnum;i++){
        for(int j=0;j<G.vexnum;j++){
          dist[i][j]=G.Edge[i][j];  
          path[i][j]=-1;
        }
      }
      for(int k=0;k<G.vexnum;k++){  //每次允许作为中转的顶点v[k]
        for(int i=0;i<G.vexnum;i++){  //遍历整个dist矩阵,i为行号,j为列号
          for(int j=0;j<G.vexnum;j++){
            if(dist[i][j]>dist[i][k]+dist[k][j]){
              dist[i][j]=dist[i][k]+dist[k][j];
              path[i][j]=k;
            }
          }
        }
      }
    }
    

该算法的时间复杂度为O(\(|v|^3\)),空间复杂度为O(\(|v|^2\))

  • 弗洛伊德算法可以用于带负权值图的最短路径求解问题,但是可能无法解决带权回路的图
  • 由于存在回路,并且有负值,可能会导致死循环,相当于永动机

对于Dijkstra算法,如果重复|v|次,也可以求解所有顶点间的最短路径问题,时间复杂度也是O(\(|v|^3\))

6.3.3 图描述表达式

有向无环图:有向图中不存在环,简称为DAG图,Directed Acyclic Graph

  • 对于四则运算表达式,除了可以用树进行表示,也可以使用图表示,并进行空间压缩。使用DAG来表述表达式,算法思路如下:
    • 将表达式中各个操作数不重复的排列出来,并标出表达式中各个运算符的执行顺序
    • 将运算符依次加入到操作数集合中,类似于哈夫曼树一样的归并在一起,根为运算符,运算符由于小括号的原因需要分层
    • 从底向上依次合并同一层后继相同的运算符,后继不同的保留

该题型暂无算法编写要求,只需考虑手算过程即可

6.3.4 拓扑排序

AOV网:用顶点表示现实意义中"活动"概念的网结构,Activity On Vertex Network

  • 用DAG表示现实意义中"工程"概念,顶点表示"活动",有向边<\(V_i,V_j\)>表示活动\(V_i\)必须先于活动\(V_j\)进行

拓扑排序

  • 拓扑排序由一个DAG的顶点组成的序列,且必须满足以下条件:
    • 每个顶点有且只出现一次
    • 顶点A在序列中排在顶点B之后,则图中不存在顶点B到顶点A的路径
  • 每个AOV网都有一个或多个拓扑排序序列
  • 算法思路:
    • 从AOV网中选择一个没有前置活动的顶点并输出,即入度id=0或者没有前驱的顶点
    • 从网中删除该顶点和所有与该顶点相连的弧
    • 重复上述两个过程,直至AOV网为空或者当前不存在无前驱的顶点
  • 最终AOV不为空且当前不存在无前驱的顶点,说明原AOV网中存在环
  • 如果一个图所有顶点入度均大于0,则说明该图不存在拓扑排序;对于DAG图,一定存在一个或多个拓扑序列
    #define MaxVertexNum 100
    typedef struct ArcNode{
      int adjvex;
      struct ArcNode *next;
      InfoType weight;  //拓扑排序可以忽略权值
    }ArchNode;
    typedef struct{
      ElemType data;
      ArchNode *firstArc;
    }VexNode,VexList[MaxVertexNum];
    typedef struct {
      VexList vertices;
      int vexnum,arcnum;
    }Graph;
    
    int indegree[MaxVertexNum];  //存放各顶点入度,初值为0
    int path[MaxVertexNum]; //记录拓扑序列,初值为-1
    
    bool TopoSort(Graph G){
      Stack S;
      InitStack(S);
      int count=0;  //记录入度为0的节点数,即进入拓扑序列的顶点数
      //初始化数组indegree,path
      for(int i=0;i<G.vexnum;i++){
        indegree[i]=0;
        path[i]=-1;
      }
      //更新indegree
      for(int i=0;i<G.vexnum;i++)
        for(int j=FisrtNeighbor(G,i);j>=0;j=NextNeighbor(G,i,j))
          indegree[j]++;
      //将入度为0的顶点入栈
      for(int i=0;i<G.vexnum;i++)
        if(!indegree[i]){
          push(S,i);
          count++;
        }
      while(!StackEmpty(S)){
        int index;
        pop(S,index); //从AOV网删除最后入栈的入度为0的顶点
        for(int i=FisrtNeighbor(G,index);i>=0;i=NextNeighbor(G,index,i)){
          indegree[i]--;   //删除与删除顶点入边,所连顶点入度-1
          if(!indegree[i]){
            push(S,i);  //删除边后顶点入度为0,直接入栈
            path[i]=index;  //记录拓扑前驱顶点下标
            count++;
          }
        }
      }
      //如果进入拓扑序列的顶点数小于实际AOV顶点数,说明图存在回路,排序失败
      return count==G.vexnum;
    }
    

    如果不考虑数组indegreepath的初始化(包括顶点入度统计),对于邻接表的核心算法需要先统计一边所有起点边,花费O(|v|);再遍历所有边,花费O(|e|),整体时间复杂度为O(|v|+|e|)。若采用邻接矩阵,则需要时间复杂度为O(\(|v|^2\))

  • 逆拓扑排序
    • 相比普通拓扑排序,该排序起点从出度为0的顶点开始,每次遍历过程删除与之相连的边
    • 逆拓扑排序对于邻接矩阵而言,时间复杂度依旧是O(\(|v|^2\));对于邻接表而言,由于查找入边为删除顶点的顶点每次都需要遍历一遍整个邻接表,导致时间复杂度会上升为O(\(|v||e|\))
    • 现有另外一种存储图的数据结构为逆邻接表,结构和邻接表一样,但是指针域指向所有入边,使用该数据结构可让逆拓扑排序时间复杂度变为O(|v|+|e|)
    • 通过DFS算法实现逆拓扑排序
      #define MaxVertexNum 100
      bool visited[MaxVertexNum];
      void DFSTopoSort(Graph G){
        for(int i=0;i<G.vexnum;i++)
          visited[i]=false;
        for(int i=0;i<G.vexnum;i++){  //假设v0正是入度为0的点
          if(!visited[i])
            DFS(G,i);
        }
      }
      void DFS(Graph G,int v){
        visit(G,i);
        visisted[i]=true;
        for(int i=FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i))
          if(!visited[i])
            DFS(G,i);
        print(v);
      }
      
      • 上述算法确实可以实现你拓扑排序,但是不难发现如果出现环,依旧会正常打印所有节点,因此需要加一个条件来判断是否存在逆拓扑排序
      #define MaxVertexNum 100
      bool visited[MaxVertexNum]; //记录整个拓扑各顶点的访问标记
      bool tag[MaxVertexNum]; //记录拓扑排序过程中每轮遍历是否存在环路
      int count;  //栈层序号
      void DFSTopoSort(Graph G){
        for(int i=0;i<G.vexnum;i++)
          visited[i]=false;
        for(int i=0;i<G.vexnum;i++){  //假设v0正是入度为0的点
          if(!visited[i]){
            //初始化每次拓扑进行前栈序号,初值为0
            count=0;
            //初始化环路标记数组,初值为false
            for(int j=0;j<G.vexnum;j++)
              tag[j]=false;
            //开始拓扑排序
            DFS(G,i);
          }
        }
      }
      void DFS(Graph G,int v){
        visit(G,i);
        visisted[i]=true;
        for(int i=FirstNeighbor(G,v);i>=0;i=NextNeighbor(G,v,i)){
          if(visited[i]){
            //如果本次遍历过程有环路,直接结束DFS遍历,给tag数组赋值true
            tag[count++]=true;
            break;
          }
          DFS(G,i);
        }
        //由于递归调用过程,最先执行该部分的是最后一个访问的顶点
        //tag数组记录了当次拓扑排序的所有递归栈中出现过环路的情况
        //如果tag数组中有一个出现环路,后序的print操作均不进行
        bool flag=false;
        for(int i=0;i<G.vexnum;i++){
          flag=tag[i];
          if(flag) break;
        }
        if(!flag) print(v);
      }
      

6.3.5 关键路径

AOE网:带权有向图中,顶点表示事件,有向边表示活动,以边上的权值表示完成该活动的开销,称之为用边表示活动的网络,AOE网,Activity On Edge NetWork

  • 只有再某顶点所代表的事件发生后,从该顶点出发的各有向边代表的活动才能开始
  • 只有在进入某顶点的各有向边代表的活动结束时,该顶点所代表的事件才能发生
  • 活动允许同时进行
  • AOE网中仅有一个入度为0的顶点,该点称之为开始顶点/源点,它表示整个工程的开始
  • 同时,有且仅有一个出度为0的顶点,该点称之为结束顶点/汇点,它表示整个工程的结束

关键路径:从源点到汇点的有向路径可能有多条,所有路径中具有最大路径长度的路径称为关键路径,关键路径上的活动称为关键活动

  • 完成整个工程的最短事件就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间会延长
  • 事件\(v_k\)最早发生时间ve(k),决定了所有从\(v_k\)开始的活动能够开工的最早时间
  • 事件\(v_k\)最晚发生时间vl(k),指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间
  • 活动\(a_i\)最早开始时间e(i),指该活动弧的起点所表示的时间的最早发生时间
  • 活动\(a_i\)最迟开始时间l(i),指该活动弧的终点所表示的事件最晚发生事件减去该活动所需要的时间
    • 活动\(a_i\)的活动余量d(i)=l(i)-e(i),表示在不增加整个工程所需的总时间情况下,活动\(a_i\)可以拖延的时间
    • 若某个活动的时间余量为0,则说明该活动必须如期完成,该活动被称作关键活动
    • 由关键活动组成的路径就是关键路径

关键路径算法思路

  • 求出所有事件的最早发生时间

    • 通过拓扑排序确定拓扑序列,然后根据拓扑序列求出个顶点的ve(k)

    ve(源点)=0,
    ve(k)=Max{ve(j)+Weight(\(v_k,v_j\))},\(v_j\)\(v_k\)的任意前驱

  • 求出所有事件的最晚发生时间

    • 通过逆拓扑排序确定逆拓扑序列,然后根据逆拓扑序列求出个顶点的vl(k)

    vl(汇点)=ve(源点),
    vl(k)=Min{vl(j)+Weight(\(v_k,v_j\))},\(v_j\)\(v_k\)的任意后继

  • 求出所有活动的最早开始时间以及最迟开始时间e(i),l(i),若边<\(v_k\),\(v_j\)>表示活动\(a_i\)

    • 活动的最早开始时间e(i)=ve(k)
    • 活动的最晚开始时间l(i)=vl(j)-Weight(\(v_k,v_j\))
  • 求出所有活动的时间余量

    • 边<\(v_k\),\(v_j\)>表示活动\(a_i\),d(i)=l(i)-e(i),若d(i)=0则说明该活动为关键活动,关键活动所构成的路径则为关键路径

关键活动的特点

  • 关键活动耗时增加,整个工程工期将增加
  • 缩短关键活动的时间,可以缩短整个工程的工期
  • 当关键活动缩短到一定程度,关键活动可能变为非关键活动

关键路径的特点

  • AOE网可以存在多条关键路径,只缩短一条关键路径上关键活动的时间并不一定能缩短整个工程的工期,只有缩短所有关键路径上的关键活动才能达到缩短工期的目的

6.4 可视化演示

https://www.cs.usfca.edu/~galles/visualization/Algorithms.html

posted @ 2024-06-25 22:05  GK_Jerry  阅读(62)  评论(0)    收藏  举报