【第五节】C/C++数据结构之图

目录

一、图的基本概念

[1.1 图的定义](#1.1 图的定义)

[1.2 图的其他术语概念](#1.2 图的其他术语概念)

二、图的存储结构

[2.1 邻接矩阵](#2.1 邻接矩阵)

[2.2 邻接表](#2.2 邻接表)

三、图的遍历

[3.1 广度优先遍历](#3.1 广度优先遍历)

[3.2 深度优先遍历](#3.2 深度优先遍历)

四、最小生成树

[4.1 最小生成树获取策略](#4.1 最小生成树获取策略)

[4.2 Kruskal算法](#4.2 Kruskal算法)

[4.3 Prim算法](#4.3 Prim算法)

五、最短路径问题

[5.1 Dijkstra算法](#5.1 Dijkstra算法)

[5.2 Bellman-Ford算法](#5.2 Bellman-Ford算法)

[5.3 Floyd-Warshall算法](#5.3 Floyd-Warshall算法)

六、AOV网络和AOE网络

[6.1 AOV网络(Activity On Vertex Network)](#6.1 AOV网络(Activity On Vertex Network))

[6.2 AOE网络(Activity On Edge Network)](#6.2 AOE网络(Activity On Edge Network))

[6.3 异同点](#6.3 异同点)

七、总结


一、图的基本概念

1.1 图的定义

数据结构中图的定义是:图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为G(V, E),其中G表示一个图,V表示顶点的集合(也称为顶集或Vertices Set),E表示顶点之间边的集合(也称为边集或Edges Set)。

  • 顶点(Vertex):图中的数据元素,也称为节点或点。
  • 边(Edge):顶点之间的逻辑关系,用来表示两个顶点之间的连接关系。在无向图中,边没有方向,用无序偶对(u, v)来表示;在有向图中,边具有方向,用有序偶<u, v>来表示,其中u称为弧尾(Tail),v称为弧头(Head)。

此外,图还有以下一些相关的概念和定义:

  • 有向图(Directed Graph):图中任意两个顶点之间的边都是有向边。
  • 无向图(Undirected Graph):图中任意两个顶点之间的边都是无向边。
  • 阶(Order):图G中点集V的大小称作图G的阶。
  • 子图(Sub-Graph):当图G'=(V', E'),其中V'包含于V,E'包含于E,则G'称作图G=(V, E)的子图。
  • 度(Degree):一个顶点的度是指与该顶点相关联的边的条数。在无向图中,顶点的度就是其边的数量;在有向图中,顶点的度分为入度和出度,入度是指以其为终点的边数,出度是指以该顶点为起点的边数。

1.2 图的其他术语概念

**完全图:**在无向图中,假设顶点数量为N,那么有N*(N-1)/2,条边,即任意两个顶点之间都有边相连,那么就称其为无向完全图。在有向图中,任意两个顶点之间都有两条指向相反的连接线,即有N个顶点的有向图有N*(N-1)条边,称这样的图结构为有向完全图。

**邻接顶点:**在无向图中,若存在边(A,B),则顶点A与顶点B互为邻接顶点。在有向图中,若存在边<A,B>,则称顶点A邻接到顶点B,而顶点B邻接自顶点A,表示A指向B的连接关系。

**顶点的度:**顶点的度定义为与该顶点相连的边的数量,记作deg(V),代表顶点V的度。在有向图中,顶点V的度为其入度与出度之和。出度是以V为起点的边的数量,记作outdeg(V);入度是以V为终点的边的数量,记作indeg(V)。因此,deg(V) = outdeg(V) + indeg(V)。在无向图中,由于边无方向,顶点的度等同于其出度和入度,即deg(V) = outdeg(V) = indeg(V)。

**路径:**在图G = { V, E }中,如果从顶点vi出发,能够经过一系列顶点到达顶点vj,则这一系列顶点构成的序列称为从顶点vi到顶点vj的路径。

**路径长度:**对于无权图,路径长度指的是从源顶点到目标顶点所经过的边的数量。而在带权图中,路径长度则是源顶点到目标顶点所经过的边的权值之和。权值通常作为边的附加信息,用于表示某种特定的度量或属性。

**简单路径与回路:**假设顶点v1和vm相连,路径v1, v2, ... , vm没有重复的顶点,那么称v1, v2, ... , vm为简单路径,如果v1,v2, ..., v1,路径从起始点开始又回到了起始点,那么就是回路。

无向图的连通性
路径:在无向图 G=(V,{E}) 中由顶点 v v'' 的顶点序列。
回路或环:第一个顶点和最后一个顶点相同的路径。
简单回路或简单环:除第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路。
连通:顶点 v v'' 之间有路径存在
连通图:无向图图 G 的任意两点之间都是连通的,则称 G 是连通图。
连通分量:极大连通子图

有向图的连通性
路径:在有向图 G=(V,{E}) 中由顶点 v 经有向边至 v'' 的顶点序列。
回路或环:第一个顶点和最后一个顶点相同的路径。
简单回路或简单环:除第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路。
连通:顶点 v v'' 之间有路径存在
强连通图:有向图图 G 的任意两点之间都是连通的,则称 G 是强连通图。
强连通分量:极大连通子图

**最小生成树:**对于连通图,能够将每个顶点连接在一起的最小连通子图,称为最小生成树,对于有N个顶点的连通图,其最小生成树应该有N-1条边。

二、图的存储结构

图的存储结构主要有两种:邻接矩阵(Adjacency Matrix)和邻接表(Adjacency List)。

2.1 邻接矩阵

  • 定义:邻接矩阵使用一个二维数组来存储图中顶点间的关系(边或弧)。对于无向图,邻接矩阵是对称的;对于有向图,邻接矩阵可能不是对称的。
  • 特点:
    • 无向图的邻接矩阵对称且唯一。
    • 有向图的邻接矩阵的第i行非零元素个数为第i个顶点的出度;第j列非零元素个数为第j个顶点的入度1。
    • 对于带权图,邻接矩阵的元素可以用来存储权值;如果两结点无连接,可以用无穷大(∞)表示。
  • 适用场景:稠密图(即边数较多的图)更适合用邻接矩阵存储。

邻接矩阵的优缺点: 邻接矩阵能够快速查找两个顶点是否直接相连,但是如果边较少的时候,邻接矩阵中会有大量的浪费空间,且使用邻接矩阵不容易求得两个顶点之间的路径。

代码实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <optional>
#include <stdexcept>

namespace Matrix
{
    // Graph 类模板定义
    // V - 顶点类型
    // W - 权重类型
    // MAX_W - 权重的最大值,默认为 INT_MAX
    // Direction - 是否为有向图,默认为无向图 (false)
    template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
    class Graph
    {
    public:
        // 构造函数,初始化图的顶点
        // arr - 顶点数组
        // size - 顶点数组的大小
        Graph(const V* arr, size_t size)
        {
            for (size_t i = 0; i < size; ++i)
            {
                _vertex.push_back(arr[i]);
                _valIndexMap[arr[i]] = i;
            }
            
            // 初始化邻接矩阵,所有权重设为 MAX_W
            _edges.resize(size, std::vector<W>(size, MAX_W));
        }

        // 获取顶点值对应的索引
        // val - 顶点值
        // 返回顶点值对应的索引,如果顶点不存在则返回 std::nullopt
        std::optional<size_t> GetIndex(const V& val) const
        {
            auto pos = _valIndexMap.find(val);
            if (pos != _valIndexMap.end())
            {
                return pos->second;
            }
            return std::nullopt; // 顶点不存在
        }

        // 添加边到图中
        // src - 源顶点
        // dst - 目标顶点
        // w - 边的权重
        void AddEdge(const V& src, const V& dst, const W& w)
        {
            auto srci = GetIndex(src);
            auto dsti = GetIndex(dst);

            // 检查顶点是否存在
            if (!srci || !dsti)
            {
                throw std::runtime_error("One or both vertices not found in the graph.");
            }

            // 添加边
            _edges[*srci][*dsti] = w;

            // 如果是无向图,还需要添加反向边
            if (!Direction)
            {
                _edges[*dsti][*srci] = w;
            }
        }

        // 打印邻接矩阵
        void Print() const
        {
            size_t n = _vertex.size();
            for (size_t i = 0; i < n; ++i)
            {
                for (size_t j = 0; j < n; ++j)
                {
                    // 如果权重为 MAX_W,则打印 '*' 表示无边
                    if (_edges[i][j] == MAX_W) std::cout << "*  ";
                    else std::cout << _edges[i][j] << "  ";
                }
                std::cout << std::endl;
            }
        }

    private:
        std::vector<V> _vertex; // 顶点数组
        std::unordered_map<V, size_t> _valIndexMap; // 顶点到索引的映射
        std::vector<std::vector<W>> _edges; // 邻接矩阵
    };
}

整个类的设计侧重于使用邻接矩阵来表示图,这在顶点数量较少时很有效,但对于边数远少于顶点对数的稀疏图,这种表示方法可能会浪费大量内存。此外,类模板的灵活性允许用户定义顶点和边权重的数据类型,并选择图的方向性。

2.2 邻接表

  • 定义:邻接表是一种顺序分配和链式分配相结合的存储结构。如果表头结点所对应的顶点存在相邻顶点,则把相邻顶点依次存放于表头结点所指向的单向链表中。
  • 特点:
    • 邻接表是为了节省存储空间而引入的,对于稀疏图(即边数较少的图),相对于邻接矩阵,无需耗费大量存储空间。
    • 对于有向图,还有逆邻接表的概念,逆邻接表可以得到图的入度。
  • 适用场景:稀疏图更适合用邻接表存储

代码示例

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <optional>
#include <stdexcept>

namespace LinkTable
{
    // Edge 结构体代表图中的边
    template<class W>
    struct Edge
    {
        size_t _dsti;  // 目标顶点在数组中的下标
        W _w;          // 边的权重
        Edge* _next;   // 链表中的下一条边

        // 构造函数初始化边
        Edge(size_t dsti, const W& w)
            : _dsti(dsti), _w(w), _next(nullptr)
        { }
    };

    // Graph 类模板定义
    template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
    class Graph
    {
    public:
        typedef Edge<W> EdgeType;

        // 构造函数,初始化图的顶点
        Graph(const V* arr, size_t size)
        {
            for (size_t i = 0; i < size; ++i)
            {
                _vertex.emplace_back(arr[i]);
                _valIndexMap[arr[i]] = i;
            }

            // 初始化邻接表
            _edges.resize(size, nullptr);
        }

        // 析构函数,负责释放所有动态分配的边
        ~Graph()
        {
            for (auto& edge : _edges)
            {
                while (edge)
                {
                    EdgeType* temp = edge;
                    edge = edge->_next;
                    delete temp;
                }
            }
        }

        // 获取顶点值对应的索引
        std::optional<size_t> GetIndex(const V& val) const
        {
            auto pos = _valIndexMap.find(val);
            if (pos != _valIndexMap.end())
            {
                return pos->second;
            }
            return std::nullopt; // 顶点不存在
        }

        // 添加边到图中
        void AddEdge(const V& src, const V& dst, const W& w)
        {
            auto srci = GetIndex(src);
            auto dsti = GetIndex(dst);

            if (!srci || !dsti)
            {
                throw std::runtime_error("One or both vertices not found in the graph.");
            }

            // 添加边从src到dst
            EdgeType* edge1 = new EdgeType(*dsti, w);
            edge1->_next = _edges[*srci];
            _edges[*srci] = edge1;

            // 如果是无向图,添加边从dst到src
            if (!Direction)
            {
                EdgeType* edge2 = new EdgeType(*srci, w);
                edge2->_next = _edges[*dsti];
                _edges[*dsti] = edge2;
            }
        }

        // 打印邻接表
        void Print() const
        {
            size_t n = _edges.size();
            for (size_t i = 0; i < n; ++i)
            {
                std::cout << _vertex[i] << ":";
                EdgeType* cur = _edges[i];
                while (cur)
                {
                    std::cout << " -> [" << _vertex[cur->_dsti] << ":" << cur->_w << "]";
                    cur = cur->_next;
                }
                std::cout << " -> nullptr" << std::endl;
            }
        }

    private:
        std::vector<V> _vertex;   // 顶点数组
        std::unordered_map<V, size_t> _valIndexMap;   // 顶点到索引的映射
        std::vector<EdgeType*> _edges;   // 邻接表
    };
}

这个 Graph 类的设计使用邻接表来表示图,这比邻接矩阵更适合表示稀疏图,因为它可以减少内存占用,并可能提高遍历边的效率。与之前的邻接矩阵实现相比,这种实现方式在处理大量顶点和边时通常更高效。

三、图的遍历

3.1 广度优先遍历

广度优先遍历(Breadth-First Search, BFS)是一种用于遍历或搜索树或图的算法。这个算法从图的某一顶点(源顶点)开始,首先访问起始顶点,然后访问其所有相邻顶点,接着再访问这些相邻顶点的未访问过的相邻顶点,依此类推,直到所有顶点都被访问为止。

广度优先遍历通常使用队列(Queue)来实现。下面是广度优先遍历的基本步骤:

  1. 创建一个队列Q,并将起始顶点v加入队列Q。
  2. 创建一个集合visited来记录已被访问的顶点,并将v标记为已访问。
  3. 当队列Q非空时,重复以下步骤:
    a. 从队列Q中取出一个顶点u。
    b. 访问顶点u。
    c. 对于u的每一个未被访问过的相邻顶点v,将v加入队列Q,并标记v为已访问。
  4. 当队列Q为空时,算法结束。此时,所有可达的顶点(从起始顶点开始)都已被访问。

示例代码:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
#include <optional>

// ...(其他代码保持不变)...

template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph
{
    // ...(其他成员和方法保持不变)...

    // 图的广度优先遍历,src为遍历起点
    void BFS(const V& src)
    {
        std::optional<size_t> srcIndexOpt = GetIndex(src);  // 获取起点的索引
        if (!srcIndexOpt.has_value()) {
            throw std::runtime_error("The source vertex does not exist in the graph.");
        }
        size_t srcIndex = srcIndexOpt.value();  // 起点索引

        size_t n = _vertex.size();  // 顶点个数
        std::vector<bool> visited(n, false);  // 记录每个顶点是否已访问
        std::queue<size_t> q;  // 队列,用于存储将要访问的顶点索引
 
        q.push(srcIndex);  // 将起点索引入队
        visited[srcIndex] = true;  // 标记起点为已访问

        size_t level = 0;  // 当前层级
        // 当队列不为空时,循环执行
        while (!q.empty())
        {
            size_t levelSize = q.size();  // 当前层的顶点数量
            std::cout << "第 " << level << " 层:";
            for (size_t i = 0; i < levelSize; ++i)
            {
                size_t currentVertexIndex = q.front();  // 获取队列前端的顶点索引
                q.pop();  // 将当前顶点索引从队列中移除
                std::cout << _vertex[currentVertexIndex] << " ";  // 打印当前顶点

                // 遍历当前顶点的所有邻接边
                for (EdgeType* edge = _edges[currentVertexIndex]; edge != nullptr; edge = edge->_next)
                {
                    size_t adjacentIndex = edge->_dsti;  // 获取邻接顶点的索引
                    // 如果邻接顶点未被访问,则将其加入队列
                    if (!visited[adjacentIndex])
                    {
                        visited[adjacentIndex] = true;  // 标记邻接顶点为已访问
                        q.push(adjacentIndex);  // 将邻接顶点索引入队
                    }
                }
            }
            std::cout << std::endl;
            level++;  // 层级加一
        }
    }
};

// ...(其他代码保持不变)...

3.2 深度优先遍历

图的深度优先遍历(Depth-First Search, DFS)是一种用于遍历或搜索树或图的算法。这个算法会尽可能深地搜索图的分支。当节点v的所在边都已被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。

深度优先遍历通常使用栈(Stack)来实现,但也可以使用递归。以下是深度优先遍历的基本步骤:

  1. 创建一个集合visited来记录已被访问的顶点。
  2. 选择一个起始顶点v,并将其标记为已访问。
  3. 递归地(或使用栈)访问v的所有未访问过的相邻顶点。对于每个这样的顶点u,如果u未被访问过,则标记u为已访问,并递归地(或使用栈)访问u的所有未访问过的相邻顶点。
  4. 当所有可访问的顶点都已被访问时,算法结束。

代码示例:

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <optional>

// ...(其他代码保持不变)...

template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph
{
    // ...(其他成员和方法保持不变)...

    // 深度优先遍历算法子函数
    // curi为当前遍历节点的下标,visited为记录节点是否被遍历过的数组
    void _DFS(size_t curi, std::vector<bool>& visited)
    {
        // 标记当前节点为已访问
        visited[curi] = true;
        // 输出当前节点
        std::cout << _vertex[curi] << " ";

        // 遍历与当前节点相连的所有节点
        for (EdgeType* edge = _edges[curi]; edge != nullptr; edge = edge->_next)
        {
            size_t u = edge->_dsti;  // 获取相连节点的索引
            // 如果相连节点未访问,则递归调用DFS
            if (!visited[u])
            {
                _DFS(u, visited);
            }
        }
    }

    // 深度优先遍历函数,src为起始点
    void DFS(const V& src)
    {
        // 获取起始点的索引,如果不存在则抛出异常
        std::optional<size_t> srcIndexOpt = GetIndex(src);
        if (!srcIndexOpt.has_value()) {
            throw std::runtime_error("The source vertex does not exist in the graph.");
        }
        size_t srcIndex = srcIndexOpt.value();

        // 初始化访问标记数组
        size_t n = _vertex.size();
        std::vector<bool> visited(n, false);

        // 从起始点开始执行DFS
        _DFS(srcIndex, visited);
    }
};

// ...(其他代码保持不变)...

四、最小生成树

4.1 最小生成树获取策略

所谓最小生成树,是对于无向连通图的概念,即:路径权值和最小的、连通的子图。这就要求最小生成树满以下条件:

如果原图有N个顶点,那么其最小生成树有N-1条边。

最小生成树中的边不能构成回路。

必须是满足前两个条件,边权值和最小的生成树。

获取最小生成树的算法有Kruskal算法(克鲁斯卡尔算法)和Prim算法(普里姆算法),这两种算法都是采用"贪心"策略,即寻找局部最优解,即:当前图中满足一定条件的权值最小的边。但是要注意,Kruskal算法和Prim算法都是局部贪心算法,能够取得局部最优解,但是不一定获取的是全局最优解,它们获取的结果只能说是非常接近于最小生成树,而不一定就是最小生成树。

4.2 Kruskal算法

Kruskal算法的思想就是在整个图的所有边中,筛选出权值最小的边,同时在选边的过程中避免构成环,等到筛选出N-1条边后,就可以获取最小生成树。图4.1为Kruskal算法的选边过程,其中红色加粗的线为被选择的边。

Kruskal算法核心:每次都筛选权值最小的、且不构成回路的边,加入生成树。

通过Kruskal算法获取最小生成树需要使用 小根堆 + 并查集 来辅助进行,其中小根堆负责每次在所有尚未选取的边中筛选权值最小的边,并查集用于避免生成回路(环)。需要定义struct Edge类来记录边的属性信息,struct Edge的成员包括起始顶点下标srci、目标顶点下标dsti以及权重w,重载> 运算符,用于比较权重大小。在Kruskal算法的代码中首先要将所有的边插入小根堆,每次从堆顶拿出一条边,使用并查集检查两个顶点是否会构成环(属于同一个集合),如果不会构成环,那么就将这条边添加到生成树中去。之后,将此时的srci和dsti归并到并查集的同一集合中去以避免成环,然后选边计数器+1,进行权重累加。假设总共有N个顶点,如果选出生成树有N-1条边,说明成功获得了最小生成树,返回每个边的权重之和,否则就是获取最小生成树失败,返回MAX_W。

下面代码为Kruskal算法及其配套被调函数及自定义类型的实现,其中Graph的其余不相关函数省略

cpp 复制代码
#include "UnionFindSet.hpp"
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
#include <algorithm>
#include <cassert>

namespace Matrix
{
    // 自定义类型 -- 顶点与顶点之间的边
    template<class W>
    struct Edge
    {
        size_t _srci;   // 源顶点下标
        size_t _dsti;   // 目标顶点下标
        W _w;           // 权重

        // 构造函数
        Edge(size_t srci, size_t dsti, const W& w)
            : _srci(srci), _dsti(dsti), _w(w)
        { }

        // 大于比较运算符重载函数,用于构建小根堆
        bool operator>(const Edge<W>& w) const
        {
            return _w > w._w;
        }
    };

    template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
    class Graph
    {
        typedef Edge<W> Edge;
        typedef Graph<V, W, MAX_W, Direction> Self;

    public:
        // 强制生成默认构造函数
        Graph() = default;

        // ....
        // 与Kruskal算法不相关的成员函数全部省略

        // 根据下标添加边的函数
        void _AddEdge(size_t srci, size_t dsti, const W& w)
        {
            _edges[srci][dsti] = w;
            if (!Direction) // 如果图是无向的,则需要在邻接矩阵中添加两个方向的边
            {
                _edges[dsti][srci] = w;
            }
        }

        // Kruskal算法获取最小生成树
        // 返回值为最小生成树的权值和,minTree为输出型参数,用于获取最小生成树
        // 如果无法获取最小生成树,那么就返回MAX_W
        W Kruskal(Self& minTree)
        {
            // 初始化minTree中的每个成员
            size_t n = _vertex.size();
            minTree._vertex = _vertex;
            minTree._valIndexMap = _valIndexMap;
            minTree._edges.resize(n, std::vector<W>(n, MAX_W));

            // 将所有边的信息(源顶点、目标顶点、权值)插入到小根堆中去
            std::priority_queue<Edge, std::vector<Edge>, std::greater<Edge>> minHeap;
            for (size_t i = 0; i < n; ++i)
            {
                for (size_t j = i + 1; j < n; ++j)
                {
                    if (_edges[i][j] != MAX_W)
                    {
                        minHeap.emplace(i, j, _edges[i][j]);
                    }
                }
            }

            UnionFindSet ufs(n);    // 用于避免构成回路的并查集
            size_t count = 0;       // 计数器,用于统计选取了多少条边
            W totalW = W();         // 总权值计数器

            std::cout << "Kruskal开始选边:" << std::endl;
            while (!minHeap.empty() && count < n - 1)
            {
                // 小根堆堆顶为当前尚未被筛选且权值最小的边
                Edge curEdge = minHeap.top();
                minHeap.pop();

                // 检查当前两个节点是否位于同一并查集的集合中
                if (!ufs.InSet(curEdge._srci, curEdge._dsti))
                {
                    std::cout << "[" << _vertex[curEdge._srci] << "->" << _vertex[curEdge._dsti] << "]:" << curEdge._w << std::endl;

                    // 向最小生成树中添加srci->dsti的边
                    minTree._AddEdge(curEdge._srci, curEdge._dsti, curEdge._w);

                    // 将srci和dsti归为同一集合
                    ufs.Union(curEdge._srci, curEdge._dsti);

                    // 选边计数器+1,权值累加
                    ++count;
                    totalW += curEdge._w;
                }
                else
                {
                    std::cout << "构成环  " << "[" << _vertex[curEdge._srci] << "->" << _vertex[curEdge._dsti] << "]:" << curEdge._w << std::endl;
                }
            }

            // 如果选择了n-1条边,那么说明获取了最小生成树,否则获取最小生成树失败
            if (count == n - 1) {
                return totalW;
            }
            else {
                return MAX_W;
            }
        }

    private:
        std::vector<V> _vertex;    // 存储顶点值的一维数组
        std::unordered_map<V, size_t> _valIndexMap;   // 顶点值与其在数组下标中的映射关系
        std::vector<std::vector<W>> _edges;        // 邻接矩阵
    };
}

并查集的实现代码如下

cpp 复制代码
#pragma once

#include <vector>
#include <algorithm>

class UnionFindSet {
public:
    // 构造函数,初始化n个元素的并查集
    UnionFindSet(size_t n) : _ufs(n, -1) {}

    // 合并两个元素所在的集合
    void Union(int x1, int x2) {
        int root1 = FindRoot(x1);
        int root2 = FindRoot(x2);

        // 如果两个元素已经在同一个集合中,则无需合并
        if (root1 == root2)
            return;

        // 按秩合并,将秩较小的根节点合并到秩较大的根节点上
        if (abs(_ufs[root1]) < abs(_ufs[root2]))
            std::swap(root1, root2);

        // 更新集合的秩,并将root2的根节点指向root1
        _ufs[root1] += _ufs[root2];
        _ufs[root2] = root1;
    }

    // 查找元素x的根节点
    int FindRoot(int x) {
        int root = x;
        // 寻找根节点
        while (_ufs[root] >= 0) {
            root = _ufs[root];
        }

        // 路径压缩,将查找路径上的每个节点直接连接到根节点
        while (_ufs[x] >= 0) {
            int parent = _ufs[x];
            _ufs[x] = root;
            x = parent;
        }

        return root;
    }

    // 检查两个元素是否属于同一集合
    bool InSet(int x1, int x2) {
        return FindRoot(x1) == FindRoot(x2);
    }

    // 获取并查集中集合的数量
    size_t SetSize() {
        size_t size = 0;
        for (size_t i = 0; i < _ufs.size(); ++i) {
            if (_ufs[i] < 0) {
                // 集合的根节点的值为负数,其绝对值表示集合的大小
                size++;
            }
        }
        return size;
    }

private:
    std::vector<int> _ufs; // 并查集数组,非负值表示父节点的索引,负值的绝对值表示集合的大小
};

4.3 Prim算法

Prim算法(普里姆算法)的思路与Kruskal算法基本一致,采用的都是贪心策略,与Kruskal算法不同的是,Prim算法会选定一个起始点src,并将已经连通的顶点和尚未被连通的顶点划分到两个集合中去,分别记为S和U,每一次筛选,都会选出从si->ui的边中权值最小的那个,由于对已经连通和尚未连通的顶点进行了划分,因此选边建立连接的过程中不需要并查集来辅助就能够避免成环。下图为Prim算法的选边过程,红色加粗的实线为被选择的边。

Prim算法的实现

cpp 复制代码
// Prim算法获取最小生成树
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
W Graph<V, W, MAX_W, Direction>::Prim(const V& src, Self& minTree) {
    // 初始化minTree的每个成员
    size_t n = _vertex.size();
    minTree._vertex = _vertex;
    minTree._valIndexMap = _valIndexMap;
    minTree._edges.resize(n, std::vector<W>(n, MAX_W));

    // 检查源顶点是否存在
    auto it = _valIndexMap.find(src);
    if (it == _valIndexMap.end()) {
        throw std::runtime_error("源顶点不存在!");
    }
    size_t srci = it->second;

    // visited数组记录每个顶点是否已经被访问
    std::vector<bool> visited(n, false);
    visited[srci] = true; // 标记源顶点为已访问

    // 使用小根堆选取最短边
    std::priority_queue<Edge, std::vector<Edge>, std::greater<Edge>> minHeap;
    // 将源顶点的所有邻边加入小根堆
    for (size_t i = 0; i < n; ++i) {
        if (_edges[srci][i] != MAX_W) {
            minHeap.emplace(srci, i, _edges[srci][i]);
        }
    }

    size_t count = 0; // 已选择的边数
    W totalW = W();   // 最小生成树的总权重

    std::cout << "Prim开始选边:" << std::endl;
    // 循环直到所有顶点都被访问或者堆为空
    while (!minHeap.empty() && count < n - 1) {
        // 获取堆顶元素(最短边)
        Edge curEdge = minHeap.top();
        minHeap.pop();

        size_t u = curEdge._srci;
        size_t v = curEdge._dsti;
        W w = curEdge._w;

        // 如果终点v未被访问,则这条边是最小生成树的一部分
        if (!visited[v]) {
            std::cout << "[" << _vertex[u] << "->" << _vertex[v] << "]:" << w << std::endl;

            // 在minTree中添加这条边
            minTree._AddEdge(u, v, w);

            // 更新访问状态,边数和总权重
            visited[v] = true;
            ++count;
            totalW += w;

            // 将新访问到的顶点v的所有邻边加入小根堆
            for (size_t k = 0; k < n; ++k) {
                if (!visited[k] && _edges[v][k] != MAX_W) {
                    minHeap.emplace(v, k, _edges[v][k]);
                }
            }
        }
    }

    // 如果选取的边数等于顶点数减一,则成功构建了最小生成树
    if (count == n - 1) {
        return totalW;
    } else {
        throw std::runtime_error("无法构建最小生成树!");
    }
}

五、最短路径问题

在所有类型的图上,最短路径问题都是寻找从一个顶点(或一组顶点)到另一个顶点(或一组顶点)的路径,使得该路径上所有边的权重之和最小,权值非负情况。这通常通过使用适当的算法(如Dijkstra、Bellman-Ford、Floyd-Warshall等)来实现。

5.1 Dijkstra算法

Dijkstra算法(迪杰斯特拉算法),用于求单源最短路径,即:给定一个起点,计算以这个顶点为起点,图中其余任意顶点为终点的路径中,权值之和最小的那一条路径。注意,Dijkstra算法要求不能带有负权值。

Dijkstra算法的核心思想是贪心算法,其大致的流程为:将一个有向图G中的顶点分为S和Q两组,其中S为已经确定了最短路径的顶点,Q为尚未确定最短路径的顶点,最初先将处源顶点srci以外所有顶点都加入Q,源顶点srci加入S。每次从Q中找出一个源顶点到该顶点最小的顶点u,将其从Q中移出放入到S中,对与u相邻的顶点v进行松弛操作。所谓松弛操作,就是比较srci->u + s->v的和是否比原来srci->v的路径和小,如果是,那么就更新srci->v的最短路径,反复进行松弛操作,直到Q集合中没有顶点。下图为Dijkstra算法松弛迭代的过程,黑色填充的顶点为已经确定最短路径的顶点,灰色填充为本轮遍历的源顶点。

代码实现

Dijkstra算法的具体实现,该函数接收三个参数,分别为起始点、最小路径dist(输出型参数)、每个顶点的父亲顶点pPath(输出型参数),这里使用pPath的目的是为了避免存储全部的路径,达到节省空间,降低算法编码难度的目的。为了观察结果,实现了PrintPath函数,用于打印顶点src到任意顶点的最短路径。

cpp 复制代码
// Dijkstra算法求最短路径
// dist为路径和,pPath为每个顶点前导顶点的下标
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
void Graph<V, W, MAX_W, Direction>::Dijkstra(const V& src, std::vector<W>& dist, std::vector<size_t>& pPath) {
    size_t n = _vertex.size();
    dist.assign(n, MAX_W);
    pPath.assign(n, std::numeric_limits<size_t>::max());

    // 获取源顶点的下标
    auto srci = GetIndex(src);
    if (srci >= n) {
        throw std::runtime_error("源顶点不存在!");
    }
    dist[srci] = 0;
    pPath[srci] = srci;

    std::vector<bool> visited(n, false);

    for (size_t k = 0; k < n; ++k) {
        // 找出未访问顶点中dist最小的
        W minDist = MAX_W;
        size_t u = std::numeric_limits<size_t>::max();
        for (size_t i = 0; i < n; ++i) {
            if (!visited[i] && dist[i] < minDist) {
                minDist = dist[i];
                u = i;
            }
        }
        
        // 所有顶点都访问过或者剩下的顶点都不可达
        if (u == std::numeric_limits<size_t>::max()) break;

        visited[u] = true;

        for (size_t v = 0; v < n; ++v) {
            if (!visited[v] && _edges[u][v] != MAX_W && dist[u] + _edges[u][v] < dist[v]) {
                dist[v] = dist[u] + _edges[u][v];
                pPath[v] = u;
            }
        }
    }
}

// 路径打印函数
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
void Graph<V, W, MAX_W, Direction>::PrintPath(const V& src, const std::vector<W>& dist, const std::vector<size_t>& pPath) {
    size_t srci = GetIndex(src);
    size_t n = _vertex.size();

    for (size_t i = 0; i < n; ++i) {
        if (dist[i] == MAX_W) {
            std::cout << _vertex[srci] << "->" << _vertex[i] << " 不可达" << std::endl;
            continue;
        }

        std::vector<size_t> path;
        for (size_t v = i; v != srci; v = pPath[v]) {
            if (pPath[v] == std::numeric_limits<size_t>::max()) {
                std::cout << _vertex[srci] << "->" << _vertex[i] << " 不可达" << std::endl;
                break;
            }
            path.push_back(v);
        }
        path.push_back(srci);
        std::reverse(path.begin(), path.end());

        for (size_t j = 0; j < path.size(); ++j) {
            std::cout << _vertex[path[j]];
            if (j < path.size() - 1) std::cout << "->";
        }
        std::cout << " 权重:" << dist[i] << std::endl;
    }
}

5.2 Bellman-Ford算法

Dijkstra算法不能解决带有负权的图的问题,为此,Bellman-Ford算法(贝尔曼-福特算法)被提了出来,这种算法可以解决带有负权的图的最小路径问题,这种算法也是用于解决单源最短路径问题的,即:给定一个起始点src,获取从src到每一个顶点的最短路径。

Bellman-Ford算法实际上是一种暴力求解的算法,对于有N个顶点的图,要暴力搜索顶点vi和顶点vj,迭代更新最短路径。Bellman-Ford算法的时间复杂度为O(N^3),而Dijkstra算法的时间复杂度为O(N^2),因此对于不带有负权的图,应当使用Dijkstra求最短路径而非使用Bellman-Ford算法

Bellman-Ford算法无法解决负权回路,所谓负权回路,就是图结构中的某个环,其所有边的权值累加起来小于0,就是负权回路。如下所示的图,a->b->d->a就是一个负权回路,a->b->d->a的权值加起来为-2,这样就存在一种诡异的现象,即每一次从a出发再回到a,路径权值之和都会变小,这样理论上a->a的路径可以无限小,对于存在负权回路的图,没有任何办法可以解决其最小路径问题。

代码实现:Bellman-Ford算法的实现

cpp 复制代码
// BellmanFord算法求单源最短路径
// dist为路径长度数组,pPath为各顶点的前导顶点下标
template<class V, class W, W MAX_W = std::numeric_limits<W>::max()>
bool Graph<V, W, MAX_W>::BellmanFord(const V& src, std::vector<W>& dist, std::vector<size_t>& pPath) {
    size_t n = _vertex.size();
    dist.assign(n, MAX_W);
    pPath.assign(n, std::numeric_limits<size_t>::max());

    size_t srci = GetIndex(src);    // 获取源顶点的下标
    if (srci >= n) {
        throw std::runtime_error("源顶点不存在!");
    }
    dist[srci] = 0;                 // 源点到自身的距离为0
    pPath[srci] = srci;             // 源点的前导节点设为自身

    // 进行n-1轮松弛操作,确保所有的最短路径都被找到
    for (size_t k = 0; k < n - 1; ++k) {
        bool updated = false;       // 用于标记本轮是否有更新
        // 遍历所有边进行松弛操作
        for (size_t i = 0; i < n; ++i) {
            for (size_t j = 0; j < n; ++j) {
                if (dist[i] != MAX_W && _edges[i][j] != MAX_W &&
                    dist[i] + _edges[i][j] < dist[j]) {
                    dist[j] = dist[i] + _edges[i][j];
                    pPath[j] = i;
                    updated = true;
                }
            }
        }
        // 如果本轮没有更新,则提前退出
        if (!updated) {
            break;
        }
    }

    // 检查负权回路,如果存在则返回false
    for (size_t i = 0; i < n; ++i) {
        for (size_t j = 0; j < n; ++j) {
            if (_edges[i][j] != MAX_W && dist[i] + _edges[i][j] < dist[j]) {
                return false;  // 存在负权回路
            }
        }
    }

    return true;  // 不存在负权回路
}

5.3 Floyd-Warshall算法

Floyd-Warshall算法(弗洛伊德算法),是用于计算多源最短路径的算法,其基本原理为三维动态规划算法:

设D_{ijk}为,从顶点i到定点j,仅以 {1,2,...,k}顶点为中间顶点的情况下的最短路径和。

若i->j的最短路径经过k,那么D_{i,j,k} = D_{i,j,k-1}+D_{k,j,k-1}

如i->j的最短路径不经过k,那么D_{i,j,k}=D_{i,j,k-1}

状态转移方程为:D_{i,j,k}=min(D_{i,j,k-1}+D_{k,j,k-1}, D_{i,j,k-1})

Floyd-Warshall算法的本质是三维动态规划算法,D[i][j][k]表示的是从顶点i到顶点j,在只经过0~k个中间顶点的情况下的最短路径。通过优化将最后一维k优化掉,这是只需要二维数组D[i][j]就可以计算出多源最短路径,Floyd-Warshall算法的时间复杂度为O(N^3),空间复杂度为O(N^2),且Floyd-Warshall算法可以解决带有负权的图的问题。

代码实现:Floyd-Warshall算法的实现

cpp 复制代码
// FloydWarshall算法计算所有顶点对之间的最短路径
template<class V, class W, W MAX_W = std::numeric_limits<W>::max()>
void Graph<V, W, MAX_W>::FloydWarshall(std::vector<std::vector<W>>& vvDist, std::vector<std::vector<size_t>>& vvPath) {
    size_t n = _vertex.size();
    vvDist.assign(n, std::vector<W>(n, MAX_W));
    vvPath.assign(n, std::vector<size_t>(n, std::numeric_limits<size_t>::max()));

    // 初始化距离和路径矩阵
    for (size_t i = 0; i < n; ++i) {
        for (size_t j = 0; j < n; ++j) {
            if (_edges[i][j] != MAX_W) {
                vvDist[i][j] = _edges[i][j];
                vvPath[i][j] = i;
            }
            if (i == j) {
                vvDist[i][j] = 0;  // 顶点到自身的距离为0
                vvPath[i][j] = i;  // 顶点到自身的路径是自己
            }
        }
    }

    // 使用动态规划方法更新所有顶点对之间的最短路径
    for (size_t k = 0; k < n; ++k) {
        for (size_t i = 0; i < n; ++i) {
            for (size_t j = 0; j < n; ++j) {
                if (vvDist[i][k] != MAX_W && vvDist[k][j] != MAX_W &&
                    vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) {
                    vvDist[i][j] = vvDist[i][k] + vvDist[k][j];
                    vvPath[i][j] = vvPath[k][j];  // 更新路径
                }
            }
        }
    }
}

六、AOV网络和AOE网络

AOV网络和AOE网络是数据结构中用于表示和分析工程计划和实施过程的有向无环图(DAG)的两种不同方式。DAG图差异如下所示

以下是关于这两种网络的详细解释:

6.1 AOV网络(Activity On Vertex Network)

定义:AOV网络是用顶点表示活动,用有向边表示活动之间的先后关系的有向图123。在实际应用中,例如工程或项目的计划中,各个子工程或任务被表示为图中的顶点,而它们之间的依赖关系或执行顺序则用有向边来表示。

特点

  • 顶点表示活动(或任务)。
  • 有向边表示活动之间的先后关系。
  • 可以通过拓扑排序获得活动的执行顺序。
  • 在AOV网中,并发活动可以被表示为互不相连的顶点。

应用:AOV网络常被用于现代化管理,来描述和分析一项工程的计划和实施过程,形象地反映出整个工程中各个活动之间的先后关系。

6.2 AOE网络(Activity On Edge Network)

定义:AOE网络是在带权有向图中,用顶点表示事件(即活动的起始和结束时间),用有向边表示活动,边上的权值表示活动的持续时间。这样的图用来估算工程的最短工期以及哪些活动是影响工程进展的关键。

特点

  • 顶点表示事件(活动的起始和结束时间)。
  • 有向边表示活动,边上的权值表示活动的持续时间。
  • 需要进行关键路径的计算,以确定整个项目的最短完成时间和关键活动。
  • 在AOE网中,并发活动可以通过将多个活动指向同一个事件节点来表示。

应用:AOE网络用于描述由许多交叉活动组成的复杂计划和工程的方法,如计算工程的最短工期和识别关键路径等。

6.3 异同点

  • 定义:AOV网将活动表示为图中的顶点,活动之间的依赖关系表示为有向边;而AOE网将活动表示为图中的边,边上的权值表示活动的持续时间,顶点表示事件。
  • 表示方式:在C语言中,AOV网常用邻接表或邻接矩阵来表示;而AOE网则需要引入事件节点,用邻接表表示图。
  • 拓扑排序:AOV网可以通过拓扑排序获得活动的执行顺序;而AOE网则需要进行关键路径的计算。

总之,AOV网络和AOE网络在数据结构中的表示和计算方式上有一些不同。AOV网更关注活动的依赖关系和执行顺序,而AOE网更关注活动的持续时间和项目的最短完成时间。有兴趣的可以自行了解各方面的应用。

七、总结

图是一种用于存储顶点和边之间关系的数据结构,记作G={V,E},其中V代表顶点的集合,E代表边的集合。根据边是否带有权重以及边的方向性,图可以进一步细分为带权图与无权图、有向图与无向图。

图的存储结构主要有两种:邻接表和邻接矩阵。这两种方式各有其适用场景和优缺点。在表示稀疏图时,邻接表因其节省空间的特性而常用;然而,在表示稠密图时,由于邻接矩阵的索引方式简单直观,且便于计算图中任意两点之间的路径,因此通常选择邻接矩阵作为存储结构。

在无向连通图中,最小生成树是一个特殊的子图,它包含了原图中的所有顶点,并且这些顶点之间通过边相连,形成一个没有回路的树形结构。同时,这棵树的边权值之和是所有可能的树中最小的。计算最小生成树常用的算法有Kruskal算法和Prim算法,这两种算法都采用了局部贪心的策略。

Dijkstra算法和Bellman-Ford算法是解决单源最短路径问题的经典算法。Dijkstra算法适用于边权值为非负的图,能够高效地计算出从指定源点到图中其他所有顶点的最短路径。然而,当图中存在负权边时,Dijkstra算法将不再适用。Bellman-Ford算法则能够处理带有负权边的图,但相对于Dijkstra算法,其时间复杂度较高。Dijkstra算法的时间复杂度为O(N^2)(其中N为顶点的数量),而Bellman-Ford算法的时间复杂度为O(N^3)。

Floyd-Warshall算法则用于解决多源最短路径问题,即计算图中任意两点之间的最短路径。该算法基于动态规划的思想,能够处理带有负权边的图。其时间复杂度为O(N^3),其中N为顶点的数量。

AOV网络和AOE网络都是用于描述和分析工程项目中活动之间关系的有向无环图数据结构。

AOV网络侧重于活动之间的依赖关系和执行顺序,通过拓扑排序确定活动的合理执行顺序。

AOE网络侧重于活动的持续时间和项目的最短完成时间,通过计算关键路径来估算工程完成时间和确定关键活动。在实际应用中,根据项目需求的不同,可以选择使用AOV网络或AOE网络来进行项目规划和分析。

相关推荐
浅念同学1 小时前
算法-常见数据结构设计
java·数据结构·算法
Java资深爱好者1 小时前
如何在std::map中查找元素
开发语言·c++
UndefindX1 小时前
PAT甲级1006 :Sign In and Sign Out
数据结构·算法
杨和段3 小时前
简介空间复杂度
数据结构
Overboom4 小时前
[数据结构] --- 线性数据结构(数组/链表/栈/队列)
数据结构
安步当歌4 小时前
【FFmpeg】av_write_trailer函数
c语言·c++·ffmpeg·视频编解码·video-codec
心死翼未伤5 小时前
【MySQL基础篇】多表查询
android·数据结构·数据库·mysql·算法
Beast Cheng6 小时前
07-7.1.1 查找的基本概念
数据结构·笔记·考研·算法·学习方法
shuguang258006 小时前
C++ 函数高级——函数重载——基本语法
开发语言·c++·visualstudio
抽风侠6 小时前
C++左值右值
开发语言·c++