数据结构与算法 | 第七章:图

本文参考网课为 数据结构与算法 1 第六章树,主讲人 张铭 、王腾蛟 、赵海燕 、宋国杰 、邹磊 、黄群。

本文使用IDE为 Clion,开发环境 C++14

更新:2024 / 03 / 23



图的定义和术语

由表示数据元素的集合 V 和表示数据之间关系的集合 E 来组成。即,G = (V,E)

  • V顶点vertex )的集合
  • Eedge )的集合

根据不同的标准可以分为以下几个分类:

  • 完全图complete graph
    任何两个顶点间都有边相关联的图,我们将其称为 完全图完全图 显然具有最大的边数。
  • 稀疏图sparse graph
    边数相对较少的图,我们将它称为 稀疏图
    它可以使用 稀疏度稀疏因子 )来表征稀疏程度。
    其边条数小于完全图的5%
  • 稠密图dense graph
    边数相对较多的图,我们将其称为 稠密图
  • 无向图
    若代表一条边的顶点序偶是无序的,我们将其称为 无向图无向图 的偶对通常用圆括号来表示。也可以理解为图中的边表示是双向的。
    在这里插入图片描述
  • 有向图directed graph
    若代表一条边的顶点序偶是有序的,我们将其称为 有向图。有序的偶对通常用尖括号来表示。有向图 的边也可以称为
    边涉及顶点的偶对是有序的。

在这里插入图片描述

  • 带权图weighted graph
    如果边或者弧是带权的,则可以用于表示从一个点到另外一个点的距离、代价或者耗费等等。
    每条边或者弧都带权的图,我们称为 带权图

的相关术语有:

  • 顶点的 degree
    某个顶点的 ,是与该顶点相关联的边的数目。
    又分为 入度in degree )和 出度out degree )。
    对于有向图,我们把某个顶点为终点的弧的数目称为该顶点的 入度,把以该定点为始点的弧的数目称为该顶点的 出度
  • 子图( subgraph
    G=(V,E)G‘ = (V‘,E‘) 中,若 V‘ <= V,E‘ <= E,并且E‘ 中的关联的顶点都在V’ 中,则称图G‘ 是图G的子图。

在这里插入图片描述

  • 路径( Path
    从顶点 Vp 到顶点 Vq 的路径是一个顶点序列。这个序列使得每条边都首尾相连,那么我们将这个序列称为从顶点Vp到顶点Vq的路径。
    路径长度定义为路径上的边或弧的数目。
    序列中顶点不重复出现的路径称为简单路径。

  • 回路( Cycle
    除了第一个顶点和最后一个顶点外,其余顶点不重复出现的回路,称为简单回路( Simple Cycle )。
    不带回路的图称为无环图( acyclic graph )。不带回路的有向图称为有向无环图( directed acyclic graph ),简称为 DAG

  • 有根图
    一个有向图中,若存在一个顶点V0,从此顶点有路径可以到达图中其他所有顶点,则称此有向图为有根的图,V0 称作图的根。

  • 连通图
    对无向图 G = (V,E) 而言,如果从 V1 到 V2 有一条路径,则称 V1 到 V2 是连通的( connected )。

  • 有向图的强连通分量
    有向图 G(V,E) 中,如果两个顶点 Vi,Vj 间有一条 Vi 到 Vj 的有向途径,同时也有一条 Vj 到 Vi 的有向路径,则称两个顶点强连通。
    非强连通图有向图的极大强连通子图,称为强连通分量( strongly connected components ) 。

  • 网络
    带权的 连通图 被称为 网络

在这里插入图片描述


图的抽象数据类型

与其他结构类似,图的基本操作主要是插入、删除和查找等等。
下面给出的图的抽象数据类型定义中列出的成员函数是针对图的一些基本操作:

class Graph{                                                    // 图的ADT
public:
    int VerticesNum();                                          // 返回图的顶点个数
    int EdgesNum();                                             // 返回图的边数
    Edge FirstEdge(int oneVertex);                              // 第一条关联边
    Edge NextEdge(Edge preEdge);                                // 下一条兄弟边
    bool setEdge(int fromVertex, int toVertex, int weight);     // 添一条边
    bool delEdge(int fromVertex, int toVertex);                 // 删一条边
    bool IsEdge(Edge oneEdge);                                  // 判断oneEdge是否为Edge
    int FromVertex(Edge oneEdge);                               // 返回边的起始点
    int toVertex(Edge oneEdge);                                 // 返回边的终点
    int Weight(Edge oneEdge);                                   // 返回边的权重
};

图的存储结构

图常用的存储结构有 相邻矩阵邻接表十字链表


相邻矩阵

概念

图的 相邻矩阵adjacency matrix )表示顶点之间的邻接关系,即表示顶点之间有边或者没有边的情况。

G = <V,E> 是一个有 n 个顶点的图,则图的相邻矩阵是一个数组 A[n,n],定义如下:
在这里插入图片描述
对于n个顶点的图,相邻矩阵的空间代价为 O(n2),与边数无关。


示例

以下面的示例为例来理解 相邻矩阵


有向图的相邻矩阵

在这里插入图片描述


带权值的无向图的相邻矩阵

在这里插入图片描述


邻接表

概念

在这里插入图片描述
邻接表 是链式存储结构。
邻接表 分为 顶点表边表

  • 顶点表
    它有两个域,顶点数据域 data 和指向此顶点边表指针域。
  • 边表
    把依附于同一个顶点 vi 的边(即 相邻矩阵 中同一行的非0元素组织成一个单链表。
    它有两个主要的域:
    • 与顶点 vi 邻接的另一顶点的序号
    • 指向边表中下一个边表目的指针

示例

稀疏图的邻接表

对于稀疏图,在边较少的前提下如果采用相邻矩阵来记录顶点之间的关系,相邻矩阵会出现大量的零元素,零元素将耗费大量的存储空间和时间。因此可以采用 邻接表 结构。


无向图的邻接表

假设有如下的 无向图
在这里插入图片描述
假设(vi,vj)是无向图中的一条边:
在顶点 vi 的边表里,存储边 (vi,vj)对应的边结点。
在顶点 vj 的边表里,存放 (vj,vi)对应的边结点。
因此,对于无向图,同一条边在邻接表中出现了两次。
如下所示:

在这里插入图片描述


带权图的邻接表

假设有如下的 带权图
在这里插入图片描述
边表 中存储边的权值。
如下所示:

在这里插入图片描述


有向图的邻接表

假设有如下的 有向图


一条弧在邻接表中只出现一次。

顶点 vi 的边表的表目个数,是该顶点的出度。因此,有向图邻接表 也称为 出边表
如下所示:
在这里插入图片描述
为了便于确定顶点节点的入度,可以建立 有向图逆邻接表。在 逆邻接表 中,顶点的边表中表示的是以该顶点为终点的边,因此 逆邻接表 也称为 入边表
如下所示:
在这里插入图片描述


邻接表的空间代价

若图中有n个顶点和e条边,

  • 如果该图为 无向图,则需要n个顶点和2e个边结点
  • 如果该图为 有向图,不考虑 逆邻接表,只需要n个顶点和e条边。
    因此,当边数e很小时,可以节省相当多的存储空间。

十字链表

概念

在这里插入图片描述
十字链表Orthogonal List )可以看成是 邻接表逆邻接表 的结合。

  • 对应于有向图的每一条弧有一个表目,共有5个域:头 headvex、尾 tailvex、下一条共尾弧 tailnextarc、下一条共头弧 headnextrc、弧权值等 info 域。
  • 顶点表目由3个域组成:data 域、firstinarc 第一条以该顶点为终点的弧、firstoutarc 第一条以该顶点为起始点的弧。

示例

稀疏图的十字链表

十字链表 有两组链表组成:

  • 行和列的指针序列
  • 每个结点都包含两个指针:同一行的后继,同一行的后继

假设有如下的 稀疏图

十字链表 如下所示:

在这里插入图片描述


图的遍历

概念

图的遍历( graph transversal ) 是指从图中的一个顶点出发,按照一定的策略,访问图当中的每一个顶点,使得每一个顶点的访问且只被访问一次。

图的遍历算法是很多图算法的基础,主要有以下几种算法:

  • 深度优先遍历
  • 广度优先遍历
  • 拓扑排序

算法框架

图的遍历,要从一个顶点出发,试探性地访问其余顶点,同时需要考虑到下列情况:

  • 从一个顶点出发,可能无法到达所有其他的顶点,比如在 非连通图
  • 也有可能会陷入死循环,比如在存在回路的图中

为了解决上面的问题,有以下几种相应的解决方法:

  • 为每个顶点保留一个标志位( mark bit
  • 算法开始时,所有顶点的标志位置零
  • 在遍历的过程中,当某个顶点被访问时,其标志位就被标记为已访问,以避免被重复地访问

以代码来表示上面的思想,即:

void graph_transverse(Graph& G){
    for(int i=0; i<G.VerticesNum();i++)
        G.Mark[i] = UNVISITED;              // 对图内所有顶点的标志位进行初始化,即将每个节点设置为未访问,

    for(int i=0;i<G.VerticesNum();i++)      // 检查图当中所有的顶点是否有被访问过
        if(G.Mark[i] == UNVISITED)          // 如果未被访问
            do_tranverse(G,i);              // 自从该未被访问的顶点开始继续遍历
}

深度优先遍历( depth-first search )

概念

深度优先遍历,简称 DFS,类似于树的先根次序遍历,尽可能先对纵深方向进行搜索。

  • 从图中的某个顶点 v0 出发
    • 访问并标记顶点 v0
    • 递归深度搜索遍历 v0 邻接到的其他顶点
    • 重复上述过程,直至从 v0 有路径可达的顶点都已被访问过
  • 选取其他未访问顶点作为源点做深度搜索,直至所有顶点都被访问过

算法实现

void DFS(Graph& G, int v){
    G.Mark[v] = VISITED;                                           // 从顶点v开始,将顶点v的标记位标置为已访问
    Visit(G,v);                                 				   // 访问顶点v
    for (Edge e = G.FirstEdge(v); G.isEdge(e); e = G.NextEdge(e)); // 递归遍历与顶点v连接的其他顶点 
    if (G.Mark[G.ToVertex(e)] == UNVISITED)
        DFS(G, G.ToVertex(e));
    PostVisit(G,v)                                                 // 对顶点v的后访问
}

示例

以下面的图为例对深度优先遍历进行深入理解:

在这里插入图片描述
深度优先搜索的顺序是:a -> b -> c -> f -> d -> e -> g


广度优先遍历( breath-first search )

概念

广度优先遍历,简称 BFS,其遍历过程如下:

  • 从图中的某个顶点 v0 出发
    • 访问并标记顶点 v0
    • 层层横向搜索 v0 的所有邻接点
    • 对这些邻接点层层横向搜索,直至所有由 v0 有路径可达的顶点都已被访问过
  • 选取其他未访问顶点作为源点做广度搜索,直至所有顶点都被访问过

算法实现

void BFS(Graph&G, int v){
    using std::queue; queue<int> Q;                                     // 初始化queue,用于保存已访问过的顶点,从而使得先访问的顶点的邻接点在下一轮被优先访问到
    Visit(G,v);                                                         // 访问顶点v
    G.Mark[v] = VISITED; Q.push(v);                                     // 将顶点v的标记位置为已访问,并入队列
    while (!Q.empty()){                                                 // 如果队列非空
        int u = Q.front();                                              // 获取队列顶部元素
        Q.pop();                                                        // 队列顶部元素出队
        for (Edge e = G.FirstEdge(u); G.IsEdge(e)); e = G.NextEdge(e))  // 递归访问队列顶部元素的邻接点们
            if (G.Mark[G.ToVertex(e)] == UNVISITED){                    // 如果该邻接点的标记位为未被访问过
                Visit(G, G.ToVertex(e))                                 // 访问该邻接点
                G.Mark[G.ToVertex(e)] = VISITED;                        // 将该邻接点的标记位置为已访问
                Q.push(G.ToVertex(e));                                  // 将该邻接点并入队列
            }
    }
}

拓扑排序

概念

拓扑排序topological sort )是将一个 有向无环图 中所有顶点在不违反先决条件关系的前提下排成线性序列的过程。

对于 有向无环图 G =(V,E),V里顶点的线性序列,称为拓扑序列,该顶点序列满足 —— 若在有向无环图G中从顶点 vi 到 vj 有一条路径,则在序列中顶点 vi 必在顶点 vj 之前。

以下面的大学选课为例,理解拓扑排序:

在这里插入图片描述
如果想要选C4数据结构,需要先修C2程序设计和C3离散数学。
这些课程之间的先后关系,可以以下面的有向无环图进行表示,如下所示:

在这里插入图片描述
任何 有向无环图DAG ),其顶点都可以排在一个拓扑序列里,其拓扑排序的方法是:

  • 从图中选择任意一个入度为0的顶点且输出之;
  • 从图中删掉此顶点及其所有的出边,将其入度减少1
  • 回到第1步,重复执行前2个步骤

算法实现

void TopSortbyQueue(Graph& G){
    for (int i=0; i<G.VerticesNum(); i++)
        G.Mark[i] = UNVISITED;                                          // 图中所有顶点标记位初始化,置为未访问
    using std::queue; queue<int> Q;                                     // 使用STL中的队列
    for (i=0; i<G.VeriticesNum(); i++)
        if (G.Indegree[i] == 0)
            Q.push(i);                                                  // 图中顶点的入度为0的顶点入队
    while (!Q.empty()){                                                 // 如果队列非空
        int v = Q.front();                                              // 获得队列顶部元素,记为v
        Q.pop();                                                        // 队列顶部元素出队
        Visit(G, v);
        G.Mark[v] = VISITED;                                            // 将v的标记位置为VISITED
        for (Edge e = G.FirstEdge(v); G.IsEdge(e); e = G.NextEdge(e)){
            G.Indegree[G.ToVertex[e]]--;                                // 相邻的顶点入度减1
            if (G.Indegree[G.ToVertex[e]] == 0)                         // 顶点入度减为0,则入队
                Q.push(G.ToVertex(e));
        }
    }
    for (i=0; i<G.VerticesNum(); i++)                                   // 在上面的循环执行完毕后
        if (G.Mark[i] == UNVISITED){                                    // 如果图中还有未被访问过的点
            cout << "此图有环";                                          // 说明该图不是有向无环图,图中存在环
            break;
        }
}

示例

以下面的图为例对拓扑排序进行深入理解:

在这里插入图片描述
图中可以看出 c1 和 c2 没有前驱。那么,从 c1 和 c2 中选择一个进行输出。
假设,先输出 c1,删除顶点 c1 和 弧 c1 c3、c1 c8 之后,顶点 c2 和 c8 都没有前驱了。那么不妨选择 c2

先输出 c2,删除 c2 和 弧 c2 c3、c2 c4、c2 c5。以此类推。直至得到有向无环图的拓扑有序的序列。

时间复杂度分析

图搜索:深度优先遍历 & 广度优先遍历

图搜索的时间复杂度分析与图采用的存储结构是相关的。

DFSBFS 中,每个顶点访问一次、每条边处理一次( 无向图的每条边从两个方向处理 )。

  • 采用邻接表表示时,有向图总代价为 Θ(n+e),无向图总代价为 Θ(n+2e)
  • 采用相邻矩阵表示时,处理所有的边需要 Θ(n2) 的时间,所以总代价为 Θ(n+n2) = Θ(n2)

拓扑排序

DFS 相同,每个顶点访问一次、每条边处理一次

  • 采用邻接表表示时,总代价为 Θ(n+e)
  • 采用相邻矩阵表示时,总代价为 Θ(n2)

图的应用

图的最短路径的计算

单源最短路径

单源最短路径single-source shortest paths ), 给定带权图( G=<V,E> ),其中每条边( vi,vj )上的权 W[vi,vj] 是一个非负实数。计算从任意给的一个源点 s 到所有其他各结点的最短路径。

在这里插入图片描述
以一个现实生活里的例理解这一概念:
假设一个游客想要选择一条路径最短的路线从 a 点到 b 点,对这样的问题进行建模就需要用到带权图。用顶点表示各个位置,用边表示各个位置之间的交通的线路,线路的长度就是边的权值,那么,游客的目标就转化为从这个带权图当中找到 a 点和 b点 之间路径上权值之和最小的那个方案。

在这个例子当中,我们看,如果从 v0 到 v4,能看出来有很多路径,但是最短的那一条是从 v0 到 v1 到 v3 到 v2 再到 v4,如下所示:
在这里插入图片描述
我们的目标便是用算法找到这样的路径。


Dijkastra算法

用来计算 单源最短路径 的经典算法之一是 Dijkastra 算法。
它是由 Dijkstra 提出的一种按照路径长度递增的次序产生到各个顶点最短路径的一种贪心算法。

在这里插入图片描述
把图的顶点集合化分成两个集合 S 和 V-S:

  • 第一个集合 S 表示最短距离已经确定的顶点集,即一个顶点如果属于 S 当且仅当从源点 S 到 该顶点的最短路径已知
  • 其余的顶点放在另一个集合 V-S 中。初始时,集合 S 只包含源点,即 S = {s},这时只有源点到自己的最短距离是已知的。设 v 是 V 中的某个顶点,把从源点 s 到顶点 v 且中间只经过集合 S 中顶点的路径称为从源点到 v 的特殊路径,并用数组 D 来记录当前所找到的从源点 s 到每个顶点的最短特殊路径长度。

从尚未确定最短路径长度的集合 V-S 中取出一个最短特殊路径长度最小的顶点 u,将 u 加入集合 S,同时修改数组 D 中由 s 可达的最短路径长度。


示例

在这里插入图片描述
首先,初始状态 V0 到自身的距离为0、到其他结点最短路径未知而设为∞。
我们选最小的距离0对应的点 V0 进入第一组。经过 V0 可到达的结点就有了 V1 和 V2,最短路径的长度分别为50和10。如此,将最小的10对应的 V2 进入第一组。那么,第一组现在有 V0 和 V2。如下所示:

在这里插入图片描述
经过 V2 可到达 V3,距离比最初小了。我们改为当前最短路径长度为25,V3 的前驱改为 V2。如下所示:

以此类推,我们可以以下面的表格来表示上面示例中的单源最短路径迭代过程:

在这里插入图片描述


算法实现
class Dist{                                                         // dist 类,用于保存每个结点的当前最短路径的信息
public:
    int index;                                                      // 存储当前
    int length;                                                     // 存储当前最短路径的长度
    int pre;                                                        // 存储路径最后经过的结点
};

void Dijkstra(Graph& G, int s, Dist* &D){                           // s是源点
    D = new Dist[G.VerticesNum()];                                  // 记录当前最短路径长度
    for (int i=0; i<G.VerticesNum(); i++){                          // 使用最小值堆 每次找出长度最小的路径
        G.Mark[i] = UNVISITED;
        D[i].index = i;
        D[i].length = INFINITE;
        D[i].pre = s;
    }
    D[s].length = 0;                                                // 源点到自身的路径长度置为0
    MinHeap<Dist> H(G.EdgeNum());                                   // 最小值堆用于找出最短路径
    H.Insert(D[s]);

    for (i=0; i<G.VerticesNum(); i++){
        bool FOUND = false;
        Dist d;
        while (!H.isEmpty()){
            d = H.RemoveMin();                                      // d用于获得到s路径长度最小的结点
            if (G.Mark[d.index] == UNVISITED){                      // 如果未访问过,则跳出循环
                FOUND = true;
                break;
            }
        }
        if (!FOUND)                                                 // 若没有符合条件的最短路径
            break;                                                  // 跳出本次循环
        int v = d.index;
        G.Mark[v] = VISITED;                                        // 将标记位设置为 VISITED,然后设置一个新的循环
        for (Edge e = G.FirstEdge(v); G.IsEdge(e); e=G.NextEdge(e)) // 刷新最短路径
            if (D[G.ToVertex(e)].length > (D[v].length + G.weight(e))){
                D[G.ToVertex(e)].length = D[v].length + G.Weight(e);
                D[G.ToVertex(e)].pre = v;
                H.Insert(D[G.ToVertex(e)]);
            }
    }
}

时间复杂度分析

对于n个顶点e条边的图,图中的任何一条边都可能在最短路径当中出现。因此,最短路径算法对每条边至少都要检查一次。
那我们采用最小值堆来选择权值最小的边。每次改变最短路径长度,需要对堆进行一次重排,那么这个时间复杂度为 O((n+e)log|E|)


每对结点之间的最短路径

1.

Dijkstra 算法 用于计算单源顶点的最短路径。
如果要求计算任意两个顶点间的最短路径,那么就是每对顶点间的最短路径问题。解决这个问题,可以每次以一个顶点为源点,重复地执行 Dijstra 算法 n次,这样就可以求得所有的顶点对与之间的最短路径及其长度。它的时间复杂度是 On3

2.

还有一个用于求所有顶点对之间最短路径的一个新算法,称为 Floyd 算法
这是一个典型的动态规划法。算法的时间复杂度也是 O3,但是形式上非常简洁。
Floyd 算法 用相邻矩阵 adj 来表示带权有向图。这个算法的基本思想是:

  • 初始化 adj(0) 为相邻矩阵 adj
  • 在矩阵 adj(0) 上做n次迭代,递归地产生一个矩阵序列 adj(1),…,adj(k),…,adj(n)
  • 其中经过第k次迭代,adj(k)[i, j] 的值等于从结点vi 到结点 vj 路径上所经过的结点序号不大于k的最短路径长度。

由于第k次迭代时已求得矩阵adj^(k-1),那么从结点 vi 到 vj 中间结点的需要不大于 k 的最短路径有两种情况:

  • 中间不经过结点 vk,那么此时就有:
    adj(k)[i, j] = adj(k-1)[i, j]
  • 中间经过结点 vk,此时adj(k)[i, j] < adj(k-1)[i, j],那么这条由结点 vi 经过 vk 到结点 vj 的中间结点需要不大于 k 的最短路径由两段组成:
    adj(k)[i, j]= adj(k-1)[i, k] + adj(k-1)[k, j]

除了计算最短路径的长度,还需要确定最短路径所经过的那些结点。那么我们可以设置一个 n*n 的矩阵 Path。这个 Path 是由顶点 vi 到 顶点 vj 的最短路径上排在顶点 vj 前面的那个顶点。

代码实现如下所示:

void Floyd(Graph& G, Dist** &D){
    int i, j, v;
    D = new Dist*[G.VerticesNum()];         // 为数组申请空间
    for (i=0;i<G.VerticesNum();i++)
        D[i] = new Dist[G.VerticesNum()];
    for (i=0;i<G.VerticesNum();i++)         // 初始化数组D
        for (j=0;j<G.VerticesNum();j++){
            if (i==j){
                D[i][j].length = 0;
                D[i][j].pre = i;
            } else{
                D[i][j].length = INFINITE;
                D[i][j].pre = -1;
            }
        }
    for (v=0;v<G.VerticesNum();v++)
        for (Edge e = G.FirstEdge(v); G.IsEdge(e); e = G.NextEdge(e)){
            D[v][G.ToVertex(e)].length = G.Weight(e);
            D[v][G.ToVertex(e)].pre = v;
        }
    // 加入新结点后,更新那些变短的路径长度
    for (v=0;v<G.VerticesNum();v++)
        for (i=0;i<G.Vertices)
            for (j=0;j<G.VerticesNum();j++)
                if (D[i][j].length > (D[i][v].length+D[v][j].length)){
                    D[i][j].length = D[i][v].length + D[v][j].length;
                    D[i][j].pre = D[v][j].pre;
                }

最小生成树的计算

假设当前有n座城市,需要在n个城市间建立一个通信网络,那么连通n个城市只需要n-1条线路。如何在这n-1条线路中达到最低建设成本?这个问题的本质就是本节将要介绍的最小生成树问题。

图G的生成树是一颗包含G的所有顶点的树,树上所有权值总和表示代价,那么在G的所有生成树中:
代价最小的生成树称为图G的 最小生成树minimum-cost spanning tree,简称 MST )。如下所示:

在这里插入图片描述
左图有v0 ~ v6结点,每条边的权值不同。
右图为连接v0 ~ v6的总权值最低的最小生成树。

Prim算法

Prim算法与Dijkstra算法类似,也是贪心算法。

  • 从图中任意一个顶点开始(例如v0),首先把这个顶点包括在MST,记到U=(V*,E*)里:
    初始 V* = {v0},E* = {}
  • 然后在那些其一个端点已在MST里,另一个端点还不是MST里的边,找权最小的一条边( vp,vq),并把此 vq 包括进MST里
  • 如此进行下去,每次往MST里加一个顶点和一条权最小的边,直到把所有的顶点都包括进MST里

算法结束时 v* = v,E* 包含了G中的n-1条边。


Prim算法的MST性质如下:

在这里插入图片描述

  • 设T(V*,E*)是一颗正在构造的生成树。
  • E中有边e=(vx,vy),其中vx 属于 v*,vy 不属于V*。
    e的权 w(e) 是所有一个端点在V里,另一端不在V里的边的权中最小者
  • 则一定存在G的一颗包括T的MST包括边e=(vx,vy)

Prim算法的代码实现如下:

void Prim(Graph& G, int s, Edge* &MST){     // s是始点,MST存边
    int MSTtag = 0;                         // 最小生成树的边计数
    MST = new Edge[G.VerticesNum()-1];      // 为数组MST申请空间
    Dist *D;
    D = new Dist[G.VerticesNum()];          // 为数组D申请空间
    for (int i=0;i<G.VerticesNum();i++){    // 初始化Mark和D数组
        G.Mark[i] = UNVISITED;
        D[i].index = i;
        D[i].length = INFINITE;
        D[i].pre = s;
    }
    D[s].length = 0;
    G.Mark[s] = VISITED;                    // 开始顶点标记为VISITED
    int v = s;
    for (i=0;i<G.VerticesNum()-1;i++){      // 因为v的加入,因为刷新与v相邻接的顶点的D值
        for (Edge e = G.FirstEdge(v); G.IsEdge(e); e=G.NextEdge(e))
            if (G.Mark[G.ToVertex(e)] != VISITED) && (D[G.ToVertex(e)].length > e.weight)){
    D[G.ToVertex(e)].length = e.weight;
    D[G.ToVertex(e)].pre = v;
            }
    v = minVertex(G, D)                     // 在D数组中找最小值记为v
    if (v == -1) return;                    // 非连通,有不可达顶点
    G.Mark[v] = VISITED;                    // 标记访问过
    Edge edge(D[v].pre, D[v].index, D[v].length);   // 保存边
    AddEdgetoMST(edge, MST, MSTtag++);             // 将边加入MST
    }
}

在Dist数组中找最小值:

int minVertex(Graph& G, Dist* &D){
    int i, v = -1;
    int MinDist = INFINITY;
    for (i=0; i<G.VerticesNum(); i++)
        if ((G.Mark[i] == UNVISITED) && (D[i] < MinDist)){
            v = i;                                         // 保存当前发现的最小距离顶点
            MinDist = D[i];
        }
    return v;
}

Prim算法非常类似于Dijkstra算法

  • Prim算法框架与Dijkstra算法相同
  • Prim算法中的距离值不需要累积,直接用嘴笑边

Prim算法通过直接比较D数组元素,确定代价最小的边就需要总时间O(n2);取出权最小的顶点后,修改D数组共需要时间 O(e),因此共需要花费 O(n2) 的时间

  • 算法适合于稠密图
  • 对于稀疏图,可以像Dijkstra算法那样用堆来保存距离值

Kruskal算法

首先将G中的n个顶点看成是独立的n个连通分量,这时的状态是有n个顶点而无边的森林,记为T=<V,{}>。
然后在E中选择代价最小的边,如果该边依附于两个不同的连通分支,那么将这条边加入到T中,否则舍去这条边而选择下一条代价最小的边。
以此类推,直到T中所有顶点都在同一个连通分量中为止,此时就得到图G的一颗最小生成树。

以下面的例为例理解Kruskal算法:
在这里插入图片描述
首先将图中所有的顶点看成是独立的连通分量,如下所示:

在这里插入图片描述

然后开始选择代价最小的边 —— 长度为1的边加进来、再选长度为2的边、再选长度为4的边、再选长度为6的边,长度为8的边不再选,因为会造成回路,所以应该舍弃。以此类推,最终得到图的一颗最小生成树,如下所示:

在这里插入图片描述

Kruskal算法的代码实现如下所示:

void Kruskal(Graph& G, Edge* &MST){                             // MST存最小生成树的边
    ParTree<int> A(G.VerticesNum());                            // 等价类
    MinHeap<Edge> H(G.EdgesNum());                              // 最小堆
    MST = new Edge(G.VerticesNum()-1);                          // 为数组MST申请空间
    int MSTTag = 0;                                             // 最小生成树的边计数
    for (int i=0; i<G.VerticesNum(); i++)                       // 将所有边插入最小堆H中。开始有n个独立顶点的等价类。
        for (Edge e=G.FirstEdge(i);G.IsEdge(e);e=G.NextEdge(e))
            if (G.FromVertex(e)<G.ToVertex(e))                  // 防重复边
                H.Insert(e);
    int EquNum = G.VerticesNum();                               // 开始有n个独立顶点等价类

    while (EquNum>1){                                           // 当等价类的个数大于1时,合并等价类
        if (H.isEmpty()){
            cout << "不存在最小生成树" << endl;
            delete [] MST;
            MST = NULL;                                         // 释放空间
            return;
        }
        Edge e = H.RemoveMin();                                 // 每次取权最小的边
        int from = G.FromVertex(e);                             // 记录该条边的信息
        int to = G.ToVertex(e);
        if (A.Different(from, to)){                             // 如果边e的两个顶点不在一个等价类中
            A.Union(from,to);                                   // 合并边的两个顶点所在的等价类
            AddEdgetoMST(e, MST, MSTtag++);                     // 将边e加到MST
            EquNum--;                                           // 等价类的个数减1
        }
    }
}

现在再看看下面的例子:

在这里插入图片描述
开始的时候将图中所有的顶点看成是独立的等价类,然后选择代价最小的边。那么,长度最小的5这条边加进来,D和E就合并等价类。再通过最小值堆,选出长度为7的这条边。这个边的两端也不在一个等价类当中。以此类推,得到如下所示的等价类:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
对于长度为12的边,C、B两端已经在同一个等价类当中了,如上所示。所以,放弃该边。再依次地计算。
最后,我们就得到该图的最小生成树,如下所示:
在这里插入图片描述

Kruskal算法的时间复杂度为O(eloge),这个算法的时间复杂度主要取决于边数。因此,Kruskal算法适合于构造稀疏图的最小生成树。


参考链接


  1. 数据结构与算法 ↩︎

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值