文章目录
- 图
- 图的基本概念
- 图的存储结构
- 邻接矩阵
- 邻接表
- 图的遍历
- 广度优先遍历
- 深度优先遍历
- 最小生成树
- Kruskal算法
- Prim算法
- 最短路径
- 单源最短路径-Dijkstra算法
- 单源最短路径-Bellman-Ford算法
- 多源最短路径-Floyd-Warshall算法
图
图的基本概念
图的基本概念
图是由顶点集合和边的集合组成的一种数据结构,记住G = (V, E)。
有向图和无向图:
- 在有向图中,顶点对<x, y> 是有序的,顶点对<x, y> 称为顶点x到顶点y的一条边,<x, y> 和<y, x>是两条不同的边。
- 在无向图中,顶点对(x,y)是无序的,顶点对(x,y)称为顶点x和顶点y相关联的一条边,这条边没有特定的方向,(x,y)和(y, x)是同一条边。
如下图:
完全图:
- 在有n个顶点的无向图中,若有n*(n-1)/2条边,即任意两个顶点之间都有直接相连的边,则称此图为无向完全图。
- 在有n个顶点的有向图中,若有n*(n-1)条边,即任意两个顶点之间都有双向的边,则称此图为有向完全图。
如下图:
邻接顶点:
- 在无向图中,若(u,v)是图中的一条边,则称u和v互为邻接顶点,并称边(u,v)依附于顶点u和顶点v。
- 在有向图中,若<u, v>是图中的一条边,则称顶点u邻接到顶点v,顶点v邻接自顶点u,并称边<u, v>与顶点u和顶点v相关联。
顶点的度:
- 在有向图中,顶点的度等于该顶点的入度与出度之和,顶点的入度是以该顶点为终点的边的条数,顶点的出度是以该顶点为起点的边的条数。
- 在无向图中,顶点的度等于该顶点相关联的边的条数,同时也等于该顶点的入度和出度。
路径与路径长度:
- 若从顶点vi出发有一组边使其可到达顶点vj,则称顶点vi到顶点vj的顶点序列为从顶点vi到vj的路径。
- 对于不带权的图,一条路径的长度是指该路径上的边的条数,对于带权图,一条路径的长度是指该路径上各个边权值的总合。
带权图示例:
简单路径与简单回路:
- 若路径上的各个顶点均不相同,则称这样的路径为简单路径。
- 若路径上第一个顶点与最后一个顶点相同,则称这样的路径为回路或环。
如下图:
子图:
- 设图G = (V,E)和图G1 = (V1, E1),若V1属于V且E1属于E,则称G1是G的子图。
如下图:
连通图和强连通图:
- 在无向图中,若从顶点 v 1 v1v1 到顶点 v 2 v2v2 有路径,则称顶点 v 1 v1v1 与顶点 v 2 v2v2 是连通的,如果图中任意一对顶点都是连通的,则称此图为连通图。
- 在有向图中,若每一对顶点 v i vivi 和 v j vjvj 之间都存在一条从 v i vivi 到 v j vjvj 的路,也存在一条从 v j vjvj 到 v i vivi 的路,则称此图是强连通图。
生成树与最小生成树:
- 一个连通图的最小连通子图称为该图的生成树,有n个顶点的连通图的生成树有n个顶点和n-1条边。
- 最小生成树指的是一个图的生成树当中,总权值最小的生成树。
图的相关应用场景
图常见的表示场景如下:
- 交通网络:图中的每个顶点表示一个地点,图中的边表示这两个地点之间是否有直接相连的公路,边的权值可以是这两个地点之间的距离、高铁时间等。
- 网络设备拓扑:图中的每个顶点表示网络中的一个设备,图中的边表示这两个设备之间是否可以互传数据,边的权值可以是这两个设备之间传输数据所需的时间、丢包的概率等。
- 社交网络:图中的每个顶点表示一个人,图中的边表示这两个人是否互相认识,边的权值可以是这两个人之间的亲密度、共同好友个数等。
关于有向图和无向图:
- 交通网络对应的图可以是有向图,也可以是无向图,无向图对应就是双向车道,有向图对应就是单向车道。
- 网络设备拓扑对应的图通常是无向图,两个设备之间有边表示这两个设备之间可以互相收发数据。
- 社交网络对应的图可以是有向图,也可以是无向图,无向图通常表示一些强社交关系,比如QQ、微信等(一定互为好友),有向图通常表示一些弱社交关系,比如微博、抖音(不一定互相关注)。
图的其他相关作用:
- 在交通网络中,根据最短路径算法计算两个地点之间的最短路径,根据最小生成树算法得到将各个地点连通起来所需的最小成本。
- 在社交网络中,根据广度优先搜索得到两个人之间的共同好友进行好友推荐,根据入边表和出边表得知有哪些粉丝以及关注了哪些博主。
图与树的联系与区别
图与树的主要联系与区别如下:
- 树是一种有向无环且连通的图(空树除外),但图并不一定是树。
- 有 n 个结点的树必须有 n − 1 条边,而图中边的数量不取决于顶点的数量。
- 树通常用于存储数据,并快速查找目标数据,而图通常用于表示某种场景。
图的存储结构
图由顶点和边组成,存储图本质就是将图中的顶点和边存储起来。
邻接矩阵
邻接矩阵
- 用一个数组存储顶点集合,顶点所在位置的下标作为该顶点的编号(所给顶点可能不是整形)。
- 用一个二维数组matrix存储边的集合,其中matrix[i][j]表示编号为i和j的两个顶点之间的关系。
如下图:
说明一下:
- 对于不带权的图,两个顶点之间要么相连,要么不想连,可以用0和1表示,matrix[i][j]为1表示编号i和j的两个顶点相连,为0表示不相连。
- 对于带权图,连接两个顶点的边会带有一个权值,可以用这个权值来设置对于matrix[i][j]的值,如果两个顶点不相连,则使用不会出现的权值进行设置即可(图中为无穷大)。
- 对于无向图来说,顶点i和顶点j相连,那么顶点j就和顶点i相连,因此无向图对应的邻接矩阵是一个对称矩阵,即matrix[i][j]的值等于matrix[j][i]的值。
- 在邻接矩阵中,第i行元素中有效权值的个数就是编号为i的顶点的出度,第i列元素中有效元素的个数就是编号为i的顶点的入度。
邻接矩阵的优缺点
邻接矩阵的优点:
- 邻接矩阵适合存储稠密图,因为存储稠密图和稀疏图时所开辟的二维数组大小是相同的,因此图中的边越多,邻接矩阵的优势越明显。
- 邻接矩阵能够O(1)的判断两个顶点是否相连,并获得相连边的权值。
邻接矩阵的缺点:
- 邻接矩阵不适合查找一个顶点连接出去的所有边,需要遍历矩阵中对应的一行,该过程的时间复杂度是O(N),其中N表示顶点的个数。
邻接矩阵的实现
邻接矩阵所需成员变量:
- 数组vertexs:用于存储顶点集和,顶点所在位置的下标作为该顶点的编号。
- 映射关系indexMap:用于建立顶点与其下标的映射关系,便于根据顶点找到其对应的下标编号。
- 邻接矩阵matrix:用于存储边的集和,matrix[i][j]表示编号i和j的两个顶点之间的关系。
邻接矩阵的实现:
- 为了支持任意类型的顶点类型以及权值,可以将图定义为模板,其中V和W分别表示顶点和权值的类型,MAX_W表示两个顶点没有连接时邻接矩阵中存储的值,将MAX_W的缺省值设置为INT_MAX(权值一般为整型),Direction表示图是否为有向图,将Direction的缺省值设置为false(无向图居多)。
- 在构造函数中完成顶点集和的设置,并建立各个顶点与其下标的映射关系,同时为邻接矩阵开辟空间,将矩阵中的值初始化为MAX_W,表示刚开始时各个顶点之间均不相连。
- 提供一个接口用于添加边,在添加边时先分别获取源顶点和目标顶点对应的下标编号,然后再将邻接矩阵中对应位置设置为边的权值,如果图为无向图,则还需要在邻接矩阵中添加目标顶点到源顶点的边。
- 在获取顶点对应的下标时,先在indexMap中进行查找,如果找到了对应的顶点,则返回该顶点对应的下标编号,如果没有找到对应的顶点,则说明所给顶点不存在,此时可以抛出异常。
代码如下:
//邻接矩阵
namespace matrix
{
//顶点值,权值类型设置成模板
template<class V, class W, W MAX_W = INT_MAX, bool direction = false> //不存在的边设置为无穷大,默认是无向图
class Graph
{
public:
//构造
Graph(const vector<V>& v)
:_vertexs(v.begin(), v.end())
,_matrix(v.size(), vector<W>(v.size(), MAX_W))
{
for (int i = 0; i < v.size(); i++)
_indexMap[v[i]] = i; //建立顶点下标映射
}
//顶点值获得下标
int getIndex(const V& v) //提供该接口转换顶点和下标,不能直接_indexMap[v]不存在会插入
{
auto iter = _indexMap.find(v); //怕给错了顶点
if (iter != _indexMap.end()) //存在该顶点
return iter->second;
else
{
throw invalid_argument("不存在此顶点");
return -1;
}
}
//增加边
void addEdge(const V& src, const V& dst, const W& weight)
{
int srci = getIndex(src), dsti = getIndex(dst);
_matrix[srci][dsti] = weight; //设置边的权值
if (direction == false)
{
_matrix[dsti][srci] = weight; //无向图
}
}
//打印图
void print()
{
int n = _vertexs.size();
//先打印顶点
for (int i = 0; i < n; i++)
{
cout << "[" << i << "]->" << _vertexs[i] << endl;
}
cout << endl;
printf("%4c", ' ');
//打印索引
for (int i = 0; i < n; i++)
printf("%4d", i);
cout << endl;
for (int i = 0; i < n; i++)
{
printf("%4d", i);
//打印边
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] == MAX_W)
printf("%4c", '*');
else
printf("%4d", _matrix[i][j]);
}
cout << endl;
}
cout << endl;
}
private:
vector<V> _vertexs; //顶点表
vector<vector<W>> _matrix; //邻接矩阵
unordered_map<V, int> _indexMap; //建立顶点和下标映射关系
};
说明一下:
- 为了方便观察,可以在类中增加一个print接口,用于打印顶点集和和邻接矩阵。
- 后续图的相关算法都会以邻接矩阵为例进行讲解,因为一般只有比较稠密的图才会存在最小生成树和最短路径的问题。
邻接表
邻接表
邻接表存储图的方式如下:
- 用一个数组存储顶点集和,顶点所在的位置的下标作为该顶点的编号(所给顶点可能不是整型)。
- 用一个出边表存储各个顶点连接出去的边,出边表中下标为i的位置存储的是从编号为i的顶点连接出去的边。
- 用一个入边表存储连接各个顶点的边,入边表中下标为i的位置存储的是连接到编号为i的顶点的边。
如下图:
说明一下:
- 出边表和入边表类似于哈希桶,其中每个位置存储的是一个链表,出边表中下标为i的位置的链表中存储的都是从编号为i的顶点连接出去的边,入边表中下标为i的位置的链表存储的都是连接到编号为i的顶点的边。
- 在邻接表中,出边表中下标为i的位置的链表中元素的个数就是编号为i的顶点的出度,入边表中下标为i的位置的链表中元素的个数就是编号为i的顶点的入度。
- 在实现邻接表时,一般只需要用一个出边表来存储从各个顶点连接出去的边即可,因为大多数情况下都是需要从一个顶点出发找与其相连的其它顶点,所以一般不需要存储入边表。
邻接表的优缺点
邻接表的优点:
- 邻接表适合存储稀疏图,因为邻接表存储图时开辟的空间大小取决于边的数量,图中边的数量越少,邻接表存储边时所需的内存空间就越少。
- 邻接表适合查找一个顶点连接出去的所有边,出边表中下标为i的位置的链表中存储的就是从顶点连接出去的所有边。
邻接表的缺点:
- 邻接表不适合确定两个顶点是否相连,需要遍历出边表中源顶点对应位置的链表,该过程的时间复杂度是O(E),其中E表示从源顶点连接出去的边的数量。
邻接表的实现
链表结点所需成员变量:
- 目标顶点下标dsti:表示边的目标顶点。
- 权值weight:表示边的权值。
- 指针next:连接下一个结点。
代码如下:
//邻接表
namespace link_table
{
//邻接表中描述一条邻接的点
//模板类,边的权值类型不确定
template<class W>
struct Edge
{
int _dsti; //邻接点的编号
W _weight; //邻接点边的权值
Edge* _next; //指向下一个点的
//构造函数
Edge(int dsti, W weight)
:_dsti(dsti)
,_weight(weight)
,_next(nullptr) //new出来一个结点,next默认指向空
{}
};
}
说明一下:
- 对应出边表来说,下标为i的位置的链表中存储的边的源顶点都是顶点i,所有链表结点中的源顶点成员可以不用存储。
邻接表所需成员变量:
- 数组vertexs:用于存储顶点集和,顶点所在位置的下标作为该顶点的编号。
- 映射关系indexMap:用于建立顶点与其下标的映射关系,便于根据顶点找到其对应的下标编号。
- 邻接表(出边表)linktable:用于存储边的集和,linktable[i]链表中存储的边的源顶点都是顶点i。
邻接表的实现:
- 为了支持任意类型的顶点类型以及权值,可以将图定义为模板,其中V和W分别表示顶点和权值的类型,direction表示图是否为有向图,将direction的缺省值设置为false(无向图居多).
- 在构造函数中完成顶点集和的设置,并建立各个顶点与其对应下标的映射关系,同时为邻接表开辟空间,将邻接表中的值都初始化为空指针,表示刚开始时各个顶点之间均不相连。
- 提供一个接口用于添加边,在添加边时先分别获取源顶点和目标顶点对应的下标编号,然后在源顶点对应的链表中头插一个边结点,如果图为无向图,则还需要在目标顶点对应的链表中头插一个边结点。
代码如下:
//邻接表
namespace link_table
{
//邻接表中描述一条邻接的点
//模板类,边的权值类型不确定
template<class W>
struct Edge
{
int _dsti; //邻接点的编号
W _weight; //邻接点边的权值
Edge* _next; //指向下一个点的
//构造函数
Edge(int dsti, W weight)
:_dsti(dsti)
,_weight(weight)
,_next(nullptr) //new出来一个结点,next默认指向空
{}
};
template<class V, class W, bool direction = false> //默认是无向图,顶点值和权值设置为模板类
class Graph
{
typedef Edge<W> Edge;
public:
//构造函数
Graph(const vector<V>& v)
:_vertexs(v.begin(), v.end()) //初始化顶点集
,_linktable(v.size(), nullptr)
{
//建立顶点值和下标之间的映射关系
for (int i = 0; i < v.size(); i++)
_indexMap[v[i]] = i;
}
//顶点值获得下标
int getIndex(const V& v) //提供该接口转换顶点和下标,不能直接_indexMap[v]不存在会插入
{
auto iter = _indexMap.find(v); //怕给错了顶点
if (iter != _indexMap.end()) //存在该顶点
return iter->second;
else
{
throw invalid_argument("不存在此顶点");
return -1;
}
}
//增加边
void addEdge(const V& src, const V& dst, const W& weight)
{
int srci = getIndex(src), dsti = getIndex(dst); //顶点值转下标
Edge* sdEdge = new Edge(dsti, weight); //new 一个结点头插到邻接表src位置
sdEdge->_next = _linktable[srci];
_linktable[srci] = sdEdge;
//如果是无向图,反着再来一下
if (direction == false)
{
Edge* dsEdge = new Edge(srci, weight);
dsEdge->_next = _linktable[dsti];
_linktable[dsti] = dsEdge;
}
}
//打印图
void print()
{
int n = _vertexs.size();
for (int i = 0; i < n; i++)
{
cout << "["<<i<<"]" << ":" << _vertexs[i] << "->";
Edge* cur = _linktable[i];
while (cur)
{
cout << "[" << cur->_dsti << "]" << ":" << _vertexs[cur->_dsti] << "W:" << cur->_weight << "->";
cur = cur->_next;
}
cout << "nullptr" << endl;
}
}
private:
vector<V> _vertexs; //顶点表
vector<Edge*> _linktable; //邻接表
unordered_map<V, int> _indexMap; //顶点值下标映射集合
};
void testGraph()
{
vector<string> v = { "张三", "李四", "王五", "赵六", "田七", "雷儿子" };
Graph<string, int> g(v);
g.addEdge("张三", "雷儿子", 100);
g.addEdge("张三", "李四", 63);
g.addEdge("王五", "田七", 109);
g.addEdge("赵六", "雷儿子", 500);
g.addEdge("张三", "王五", 60);
g.addEdge("王五", "李四", 10);
g.print();
}
}
说明一下:
- 为了方便观察,可以在类中增加一个print接口,用于打印顶点集和和邻接表。
图的遍历
图的遍历指的是遍历图中的顶点,主要有广度优先遍历和深度优先遍历两种方式。
广度优先遍历
广度优先遍历
广度优先遍历又称BFS,其遍历过程类似于二叉树的层序遍历,从起始顶点开始一层一层向外进行遍历。
如下图:
广度优先遍历的实现:
- 广度优先遍历需要借助一个队列和一个标记数组,利用队列先进先出的特点实现一层一层向外遍历,利用标记数组来记录各个顶点是否被访问过。
- 刚开始时将起始顶点入队列,并将起始顶点标记为访问过,然后不断从队列中取出顶点进行访问,并判断该顶点是否有邻接顶点,如果有邻接顶点并且该邻接顶点没有被访问过,则将该邻接顶点入队列,并在入队列后立即将该邻接顶点标记为访问过。
代码如下:
void BFS(const V& src)
{
int n = _vertexs.size();
int srci = getIndex(src); //从起点开始bfs
queue<int> q;
vector<bool> vis(n, false);
q.push(srci);
vis[srci] = true;
while (!q.empty())
{
int front = q.front();
q.pop();
cout << "[" << front << "]" << ":" << _vertexs[front] << "->";
for (int i = 0; i < n; i++)
{
if (_matrix[front][i] != MAX_W && !vis[i])
{
q.push(i);
vis[i] = true;
}
}
}
cout << "nullptr" << endl;
}
说明一下:
- 为了防止顶点被重复加入队列导致死循环,因此需要一个标记数组,当一个顶点被访问过后就不应该再将其加入队列了。
- 如果当一个顶点从队列中取出访问时才再将其标记为访问过,也可能会存在顶点被重复加入队列的情况,比如当图中的顶点B出队列时,顶点C作为顶点B的邻接顶点并且还没有被访问过(顶点C还在队列中),此时顶点C就会再次被加入队列,因此最好在一个顶点被入队列时就将其标记为访问过。
- 如果所给图不是一个连通图,那么从一个顶点开始进行广度优先遍历,无法遍历完图中的所有顶点,这时可以遍历标记数组,查看哪些顶点还没有被访问过,对于没有被访问过的顶点,则从该顶点处继续进行广度优先遍历,直到图中所有的顶点都被访问过
深度优先遍历
深度优先遍历
深度优先遍历又称DFS,其遍历过程类似于二叉树的先序遍历,从起始顶点开始不断对顶点进行深入遍历。
如下图:
深度优先遍历的实现:
- 深度优先遍历可以通过递归实现,同时也需要借助一个标记数组来记录各个顶点是否被访问过
- 从起始顶点处开始进行递归遍历,在遍历过程中先对当前顶点进行访问,并将其标记为访问过,然后判断该顶点是否有邻接顶点,如果有邻接顶点并且该邻接顶点没有被访问过,则递归遍历该邻接顶点
代码如下:
void _DFS(int srci, vector<bool>& vis)
{
cout << "[" << srci << "]" << ":" << _vertexs[srci] << "->";
vis[srci] = true;
for (int i = 0; i < _vertexs.size(); i++)
{
if (_matrix[srci][i] != MAX_W && !vis[i])
_DFS(i, vis);
}
}
void DFS(const V& src)
{
int n = _vertexs.size();
int srci = getIndex(src);
vector<bool> vis(n, false);
_DFS(srci, vis);
}
说明一下:
- 如果所给图不是一个连通图,那么从一个顶点开始进行深度优先遍历,无法遍历完图中的所有顶点,这时可以遍历标记数组,查看哪些顶点还没有被访问过,对于没有被访问过的顶点,则从该顶点处继续进行深度优先遍历,直到图中所有的顶点都被访问过。
最小生成树
最小生成树
关于最小生成树:
- 一个连通图的最小连通子图称为该图的生成树,若连通图由 n nn 个顶点组成,则其生成树必含 n nn 个顶点和 n − 1 n-1n−1 条边,最小生成树指的是一个图的生成树中,总权值最小的生成树。
- 连通图中的每一棵生成树都是原图的一个极大无环子图,从其中删去任何一条边,生成树就不再连通,在其中引入任何一条新边,都会形成一条回路。
说明一下:
- 对于各个顶点来说,除了第一个顶点之外,其他每个顶点想要连接到图中,至少需要一条边使其连接进来,所以由 n nn 个顶点的连通图的生成树有 n nn 个顶点和 n − 1 n-1n−1 条边。
- 对于生成树来说,图中的每个顶点已经连通了,如果再引入一条新边,那么必然会使得被新边相连的两个顶点之间存在一条直接路径和一条间接路径,即形成回路。
- 最小生成树是图的生成树中总权值最小的生成树,生成树是图的最小连通子图,而连通图是无向图的概念,有向图对应的是强连通图,所以最小生成树算法的处理对象都是无向图。
构成最小生成树的准则
- 构造最小生成树的准则如下:
- 只能使用恰好 n − 1 n-1n−1 条边来连接图中的 n nn 个顶点。
- 选用的 n − 1 n-1n−1 条边不能构成回路。
构造最小生成树的算法有Kruskal算法和Prim算法,这两个算法都采用了逐步求解的贪心策略。
Kruskal算法
Kruskal算法(克鲁斯卡尔算法)
Kruskal算法的基本思想如下:
- 构造一个含 n 个顶点、不含任何边的图作为最小生成树,对原图中的各个边按权值进行排序。
- 每次从原图中选出一条最小权值的边,将其加入到最小生成树中,如果加入这条边会使得最小生成树中构成回路,则重新选择一条边。
- 按照上述规则不断选边,当选出 n − 1 n-1n−1 条合法的边时,则说明最小生成树构造完毕,如果无法选出 n − 1 n-1n−1 条合法的边,则说明原图不存在最小生成树。
如下图的最小生成树如下:
Kruskal算法的实现:
- 根据原图设置最小生成树的顶点集合,以及顶点与下标的映射关系,开辟最小生成树的邻接矩阵空间,并将矩阵中的值初始化为 M A X _ W,表示刚开始时最小生成树中不含任何边。
- 遍历原图的邻接矩阵,按权值将原图中的所有边添加到优先级队列(小堆)中,为了避免重复添加相同的边,在遍历原图的邻接矩阵时只应该遍历矩阵的一半。
- 使用一个并查集来辅助判环操作,刚开始时图中的顶点各自为一个集合,当两个顶点相连时将这两个顶点对应的集合进行合并,使得连通的顶点在同一个集合,这样通过并查集就能判断所选的边是否会使得最小生成树中构成回路,如果所选边连接的两个顶点本就在同一个集合,那么加入这条边就会构成回路。
- 使用 c o u n t countcount 和 t o t a l W e i g h t totalWeighttotalWeight 分别记录所选边的数量和最小生成树的总权值,当 c o u n t countcount 的值等于 n − 1 n-1n−1 时则停止选边,此时可以将最小生成树的总权值作为返回值进行返回。
- 每次选边时从优先级队列中获取一个权值最小的边,并通过并查集判断这条边连接的两个顶点是否在同一个集合,如果在则重新选边,如果不在则将这条边添加到最小生成树中,并将这条边连接的两个顶点对应的集合进行合并,同时更新count 和 t o t a l W e i g h t 的值。
- 当选边结束时,如果 count 的值等于n−1 ,则说明最小生成树构造成功,否则说明原图无法构造出最小生成树。
代码如下
//Kruskal算法
W Kruskal(Graph<V, W, MAX_W, direction>& minTree)
{
int n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap; //最小生成树和原图的顶点集和,映射都是一样的,只是一条边还没有
minTree._matrix.resize(n, vector<W>(n, MAX_W));
int size = 0; //记录选了多少条边
int totalWeight = 0; //记录总权值
priority_queue<Edge, vector<Edge>, greater<Edge>> q; //声明一个小根堆,传入greater<Edge>
UnionFindSet<V> ufs(_vertexs); //声明一个并查集,刚开始每个点都是一个集和
//将原图所有的边如堆
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (i < j && _matrix[i][j] != MAX_W) //对于无向图来说,对称矩阵,入一半就是所有的边
{
q.push(Edge(i, j, _matrix[i][j]));
}
}
}
while (!q.empty() && size < n - 1) //只有堆为空或者是选择到了n-1条边此时循环结束
{
//每次选择最小的那条边
Edge minE = q.top();
q.pop();
int srci = minE._srci, dsti = minE._dsti;
int weight = minE._weight;
if (!ufs.isSameSet(_vertexs[srci], _vertexs[dsti]))
{
//不在一个集和的两个点形成的边可以选
minTree._addEdge(srci, dsti, weight);
ufs.unionSet(_vertexs[srci], _vertexs[dsti]);
size++;
totalWeight+=weight;
cout << "选边: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
else
{
cout << "成环: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
}
if (size == n - 1) //如果是由于选择了n-1条边结束循环就是构成最小生成树成功
{
cout << "构成最小生成树成功:" << totalWeight << endl;
return totalWeight;
}
else
{
cout << "无法构成最小生成树" << endl;
return -1;
}
}
说明一下:
- 在获取图的最小生成树时,会以无参的方式定义一个最小生成树对象,然后用原图对象调用上述Kruskal函数,通过输出型参数的方式获取原图的最小生成树,由于我们定义了一个带参的构造函数,使得编译器不再生成默认构造函数,因此需要通过default关键字强制生成Graph类的默认构造函数。
- 一条边包含两个顶点和边的权值,可以定义一个Edge结构体来描述一条边,结构体内包含边的源顶点和目标顶点的下标以及边的权值,在使用优先级队列构造小堆结构时,需要存储的对象之间能够支持 > >> 运算符操作,因此需要对Edge结构体的 > >> 运算符进行重载,将其重载为边的权值的比较。
- 当选出的边不会构成回路时,需要将这条边插入到最小生成树对应的图中,此时已经知道了这条边的源顶点和目标顶点对应的下标,可以在Graph类中新增一个_addEdge子函数,该函数支持通过源顶点和目标顶点的下标向图中插入边,而Graph类中原有的addEdge函数可以复用这个_addEdge子函数。
- 最小生成树不一定是唯一的,特别是当原图中存在很多权值相等的边的时候,比如对于动图中的图来说,将最小生成树中的 b c bcbc 边换成 a h ahah 边也是一棵最小生成树。
- 上述代码中通过优先级队列构造小堆来依次获取权值最小的边,你也可以通过其他排序算法按权值对边进行排序,然后按权值从小到大依次遍历各个边进行选边操作。
- 上述代码中使用的并查集UnionFindSet类,在博主的另一篇博客中有讲解,不了解并查集的博友可以跳转到博主的这篇博客:高阶数据结构 ——— 并查集。
Prim算法
Prim算法(普里姆算法)
Prim算法的基本思想如下:
-
构造一个含n个顶点、不含任何边的图作为最小生成树,将图中的顶点分为两个集合,forest集合中的顶点是已经连接到最小生成树中的顶点,remain集合中的顶点是还没有连接到最小生成树中的顶点,刚开始时forest集合中只包含给定的起始顶点。
-
每次从连接forest集合与remain集合的所有边中选出一条权值最小的边,将其加入到最小生成树中,由于选出来的边对应的两个顶点一个属于forest集合,另一个属于remain集合,因此是不会构成回路的。
-
按照上述规则不断选边,当选出n-1条边时,所有的顶点都已经加入到了forest集合,此时最小生成树构造完毕,如果无法选出n-1条边,则说明原图不存在最小生成树。
Prime算法的实现: -
根据原图设置最小生成树的顶点集合,以及顶点与下标的映射关系,开辟最小生成树的邻接矩阵空间,并将矩阵中的值初始化为 M A X _ W MAX_WMAX_W ,表示刚开始时最小生成树中不含任何边。
-
使用一个 forest 数组来表示各个顶点是否在 forest 集合中,刚开始时只有起始顶点在 forest 集合中,并将所有从起始顶点连接出去的边加入优先级队列(小堆),这些边就是刚开始时连接forest 集合与 remain集合的边。
-
使用 count 和totalWeight 分别记录所选边的数量和最小生成树的总权值,当 count 的值等于n−1 时则停止选边,此时将最小生成树的总权值作为返回值进行返回。
-
每次选边时从优先级队列中获取一个权值最小的边,将这条边添加到最小生成树中,并将这条边的目标顶点加入forest 集合中,同时更新count 和totalWeight 的值。此外,**还需要将从这条边的目标顶点连接出去的边加入优先级队列,但是需要保证加入的边的目标顶点不能在forest 集合,**否则后续选出源顶点和目标顶点都在forest 集合的边就会构成回路。
-
需要注意的是,每次从优先级队列中选出一个权值最小的边时,还需要保证选出的这条边的目标顶点不在forest 集合中,避免构成回路。虽然向优先级队列中加入边时保证了加入的边的目标顶点不在forest 集合中,但经过后续不断的选边,可能会导致之前加入优先级队列中的某些边的目标顶点也被加入到了forest 集合中。
-
当选边结束时,如果 c o u n t countcount 的值等于 n − 1 n-1n−1 ,则说明最小生成树构造成功,否则说明原图无法构造出最小生成树。
代码如下:
//这里定义Edge,描述一条边
struct Edge
{
int _srci; //边的起点
int _dsti; //边的终点
int _weight; //边的权值
//构造函数
Edge(int srci, int dsti, int weight)
:_srci(srci)
,_dsti(dsti)
,_weight(weight)
{}
//这里自定义一个Edge边按照权值比较大小的operator>
bool operator>(const Edge& e) const
{
return _weight > e._weight;
}
};
//Prim算法
W Prim(Graph<V, W, MAX_W, direction>& minTree, const V& start)
{
int n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap; //最小生成树和原图的顶点映射都是一样
//只是刚开始没有一条边
minTree._matrix.resize(n, vector<W>(n, MAX_W));
vector<bool> forest(n, false);
vector<bool> remain(n, true);
priority_queue<Edge, vector<Edge>, greater<Edge>> q;
//刚开始forest集合只有一个起点,堆只有起点出发的边
int starti = getIndex(start);
forest[starti] = true;
remain[starti] = false;
//刚开始将起点出发的边都加入队列
for (int i = 0; i < n; i++)
{
if (_matrix[starti][i] != MAX_W)
q.push(Edge(starti, i, _matrix[starti][i]));
}
int size = 0;
W totalWeight = W();
while (!q.empty() && size < n - 1)
{
//每次取出最小的边
Edge minE = q.top();
q.pop();
int srci = minE._srci, dsti = minE._dsti;
W weight = minE._weight;
//如果选出的这条边的终点不在forest集合中就可以选择
if (!forest[dsti])
{
minTree._addEdge(srci, dsti, weight);
forest[dsti] = true;
remain[dsti] = false;
size++;
totalWeight += weight;
//将以dsti为起点的发散所有边又入堆
for (int i = 0; i < n; i++)
{
if (_matrix[dsti][i] != MAX_W&&!forest[i])
q.push(Edge(dsti, i, _matrix[dsti][i]));
}
cout << "选边: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
else
{
//边的目标顶点已经在forest集合中,加入的话会成环
cout << "成环: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
}
if (size == n - 1) //如果是由于选择了n-1条边结束循环就是构成最小生成树成功
{
cout << "构成最小生成树成功:" << totalWeight << endl;
return totalWeight;
}
else
{
cout << "无法构成最小生成树" << endl;
return W();
}
}
说明一下:
- Prim算法构造最小生成树的思想在选边时是不需要判环,但上述利用优先级队列实现的过程中仍需判环,如果在每次选边的时候能够通过某种方式,从连接forest 集合和remain 集合的所有边中选出权值最小的边,那么就无需判环,但这两个集合中的顶点是不断在变化的,每次选边时都遍历连接两个集合的所有边,该过程的时间复杂度较高。
- Kruskal算法本质是一种全局的贪心,每次选边时都是在所有边中选出权值最小的边,而Prim算法本质是一种局部的贪心,每次选边时是从连接forest 集合和remain 集合的所有边中选出权值最小的边。
最短路径
最短路径
关于最短路径:
- 最短路径问题:从带权有向图中的某一顶点出发,找出一条通往另一顶点的最短路径,最短指的是路径各边的权值总合达到最小,最短路径可分为单源最短路径和多源最短路径。
- 单源最短路径指的是从图中某一顶点出发,找出通往其他所有顶点的最短路径,而多源最短路径指的是,找出图中任意两个顶点之间的最短路径。
单源最短路径-Dijkstra算法
Dijkstra算法(迪杰斯特拉算法)
使用前提:图中所有边的权值非负。
Dijkstra算法的基本思想如下:
- 将图中的顶点分为两个集合,集合 S 中的顶点是已经确定从源顶点到该顶点的最短路径的顶点,集合 Q 中的顶点是尚未确定从源顶点到该顶点的最短路径的顶点。
- 每个顶点都有一个估计值,表示从源顶点到该顶点的可能最短路径长度,每次从集合 Q 中选出一个估计值最小的顶点,将其加入到集合 S 中,并对该顶点连接出去的顶点的估计值和前驱顶点进行松弛更新。
- 按照上述步骤不断从集合 Q QQ 中选取估计值最小的顶点到集合 S SS 中,直到所有的顶点都被加入到集合 S SS 中,此时通过各个顶点的估计值就可以得知源顶点到该顶点的最短路径长度,通过各个顶点的前驱顶点就可以得知最短路径的走向。
Dijkstra算法的实现:
- 使用一个 d i s t distdist 数组来记录从源顶点到各个顶点的最短路径长度估计值,初始时将源顶点的估计值设置为权值的缺省值(比如int就是0),表示从源顶点到源顶点的路径长度为0,将其余顶点的估计值设置为MAX_W,表示从源顶点暂时无法到达其他顶点。
- 使用一个 p a r e n t P a t h parentPathparentPath 数组来记录到达各个顶点路径的前驱顶点,初始时将各个顶点的前驱顶点初始化为-1,表示各个顶点暂时只能自己到达自己,没有前驱顶点。
- 使用一个bool 数组来记录各个顶点是否在S 集合中,初始时所有顶点均不在 S 集合,表示各个顶点都还没有确定最短路径。
- 每次从 Q QQ 集合中选出一个估计值最小的顶点 u uu,将其加入到 S SS 集合,并对顶点 u uu 连接出去的各个顶点 v vv 进行松弛更新,如果能够将顶点 v vv 更新出更小的估计值,则更新其估计值,并将被更新的顶点 v vv 的前驱顶点改为顶点 u uu,因为从顶点 u uu 到顶点 v vv 能够得到更小的估计值,所以在当前看来(后续可能还会更新)到达顶点 v vv 的最短路径的前驱顶点就应该是顶点 u uu ,如果不能将顶点 v vv 更新出更小的估计值,则维持原样。
- 当所有的顶点都加入集合 S SS 后,d i s t distdist 数组中存储的就是从源顶点到各个顶点的最短路径长度,p a r e n t P a t h parentPathparentPath 数组中存储的就是从源顶点到各个顶点的最短路径的前驱顶点,通过不断查找各个顶点的前驱顶点,最终就能得到从源顶点到各个顶点的最短路径。
代码如下:
//单源最短路径(只有正权边)Dijkstra
void Dijkstra(const V& src, vector<W>& dist, vector<int>& pPath)
{
int n = _vertexs.size();
dist.resize(n, MAX_W); //开始所有的每确定,最短路径设为无穷大
pPath.resize(n, -1); //该开始每个点都每确定,所有人的父亲都是-1
int srci = getIndex(src);
dist[srci] = W(); //刚开始只有起点一个确定
vector<bool> S(n, false); //记录确定了点的集合
for (int i = 0; i < n; i++) //确定n次n个点就确定完了
{
int u = -1;
W minW = MAX_W;
for (int j = 0; j < n; j++)
{
//遍历一遍还没有确定的点,找到最小估计值的点,进行确定
if (S[j] == false && dist[j] < minW)
{
minW = dist[j];
u = j;
}
}
//此时找到了最小没确定的点,此时设为确定
S[u] = true;
//松弛更新,u邻接的所有点
for (int v = 0; v < n; v++)
{
//将u所有没有确定的邻接点进行松弛更新
if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
{
//srci->u, u->v srci->v
dist[v] = dist[u] + _matrix[u][v];
pPath[v] = u;
}
}
}
}
//打印最短路径及路径权值
void printShortPath(const V& src, const vector<W>& dist, const vector<int>& pPath)
{
int n = _vertexs.size();
int srci = getIndex(src);
//输出每个点到起点的最短路径,并且把路径打出来
for (int i = 0; i < n; i++)
{
vector<int> path; //存储路径
int cur = i;
while (cur != -1)
{
path.push_back(cur);
cur = pPath[cur];
}
//存储路径是每一点到起点的最短路径,所以反着打印
reverse(path.begin(), path.end());
for (int j = 0; j < path.size(); j++)
cout << _vertexs[path[j]] << "->";
cout << "路径总权值:" << dist[i] << endl;
}
}
说明一下:
- 为了方便观察,可以在类中增加一个 printShortPath 接口,用于根据 dist 和 parentPath 数组来打印最短路径及路径权值。
- 对于从源顶点 s 到目标顶点 j 的最短路径来说,如果最短路径经过了顶点 i ,那么最短路径中从源顶点 s 到顶点 i 的这条子路径一定是源顶点 s 到顶点 i 的最短路径,因此可以通过存储前驱顶点的方式来表示从源顶点到各个顶点的最短路径。
- Dijkstra算法每次需要选出一个顶点,并对其连接出去的顶点进行松弛更新,因此其时间复杂度是 O ( N 2 ) ,空间复杂度是 O ( N )。
Dijkstra算法的原理
- Dijkstra算法每次从集合 Q 中选出一个估计值最小的顶点 u ,将该顶点加入到集合 S 中,表示确定了从源顶点到顶点 u 的最短路径。
- 因为图中所有边的权值非负(使用Dijkstra算法的前提),所以对于估计值最小的顶点 u uu 来说,其估计值不可能再被其他比它估计值更大的顶点松弛更新得更小,因此顶点 u uu 的最短路径就是当前的估计值
- 而对于集合 Q QQ 中的其他顶点来说,这些顶点的估计值比顶点 u uu 的估计值大,因此顶点 u uu 可能将它们的估计值松弛更新得更小,所以顶点 u uu 在加入集合 S SS 后还需要尝试对其连接出去的顶点进行松弛更新。
单源最短路径-Bellman-Ford算法
Bellman-Ford算法(贝尔曼福特算法)
Bellman-Ford算法的基本思想如下:
- Bellman-Ford算法本质是暴力求解,对于从源顶点s到目标顶点j的路径来说,如果存在从源顶点s到顶点i的路径,还存在一条从顶点i到顶点j的边,并且其权值之和小于当前从源顶点s到目标顶点j的路径长度,则可以对顶点j的估计值和前驱顶点进行松弛更新。
- Bellman-Ford算法根据路径的终边来进行松弛更新,但是仅对图中的边进行一次遍历可能并不能正确更新出最短路径,最坏的情况下需要对图中的边进行n-1轮遍历(n表示图中的顶点个数)。
Bellman-Ford算法的实现:
- 使用一个dist数组来记录从源顶点到各个顶点的最短路径长度估计值,初始化时将源顶点的估计值设置为权值的缺省值(比如int就是0),表示从源顶点到源顶点的路径长度为0,将其余顶点的估计值设置为MAX_W,表示从源顶点暂时无法到达其它顶点。
- 使用一个pPath数组来记录到达各个顶点路径的前驱顶点,初始化时将各个顶点的前驱顶点初始化为-1,表示各个顶点暂时只能自己到达自己,没有前驱顶点。
- 对图中的边进行n-1轮遍历,对于i->j的边来说,如果存在s->i的路径,并且s->i的路径权值与边i->j的权值之和小于当前s->j的路径长度,则将顶点j的估计值进行更新,并将顶点j的前驱顶点改为顶点i,因为i->j是图中的一条直接相连的边,在这条路径中顶点j的上一个顶点就是顶点i。
- 再对图中的边进行一次遍历,尝试进行松弛更新,如果还能更新则说明图中带有负权回路,无法找到最短路径。
代码如下:
//Bellman-Ford算法(贝尔曼福特算法)
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& pPath)
{
int n = _vertexs.size();
int srci = getIndex(src);
dist.resize(n, MAX_W); //开始所有点估计值初始化无穷大
pPath.resize(n, -1); //所有点的父都是-1
dist[srci] = W(); //源顶点自己到自己为0
for (int k = 0; k < n - 1; k++) //n-1次暴力+1次更新
{
bool update = false; //跳出优化
//i->j代表所有边
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
dist[j] = dist[i] + _matrix[i][j];
pPath[j] = i; //更新估计值+更新父
update = true;
}
}
}
if (update == false) //说明当前轮没有更新,可以结束了
break;
}
//检测是否存在负权边
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
//还能更新,存在负权边,贝尔曼失败
return false;
}
}
}
return true; //不存在负权边,贝尔曼成功
}
说明一下:
- Bellman-Ford算法是暴力求解,可以解决带有负权边的单源最短路径问题。
- 负权回路指的是在图中形成回路的各个边的权值之和为负数,路径每绕一圈回路其权值都会减少,导致无法找到最短路径,由于最多需要进行 n−1 轮松弛更新,因此可以在 n − 1 轮松弛更新后再进行一轮松弛更新,如果还能进行更新则说明带有负权回路。
- Bellman-Ford算法需要对图中的边进行 n 轮遍历,因此其时间复杂度是 O(N×E),由于这里是用邻接矩阵实现的,遍历图中的所有边的时间复杂度是 O ( N 2 ) ,所以上述代码的时间复杂度是 O ( N 3 ) ,空间复杂度是O(N) 。
为什么最多进行 n − 1 n-1n−1 轮松弛更新?
从一个顶点到另一个顶点的最短路径中不能包含回路:
- 如果形成回路的各个边的权值之和为负数,则该回路为负权回路,找不到最短路径。
- 如果形成回路的各个边的权值之和为非负数,则多走这个回路是“徒劳”的,可能会使得路径长度变长。
例如下图中,顶点 A B、C、D、E 的下标分别是0、1、2、3、4,现在要计算以顶点 E 为源顶点的单源最短路径。
对于上述图来说,Bellman-Ford算法在第一轮松弛的时候只能更新出 E − > D 这条边,在第二轮的时候只能更新出 D − > C ,以此类推,最终就会进行4轮松弛更新(建议通过代码调试观察)。
说明一下:
- 由于只有当前轮次进行过更新,才有可能会影响其他路径,因此在代码中使用update 标记每轮松弛算法是否进行过更新,如果没有进行过更新,则无需进行后面轮次的更新。
- Bellman-Ford算法还有一个优化方案叫做SPFA(Shortest Path Faster Algorithm),其用一个队列来维护可能需要松弛更新的顶点,避免了不必要的冗余计算,大家可以自行了解。
多源最短路径-Floyd-Warshall算法
Floyd-Warshall算法(弗洛伊德算法)
Floyd-Warshall算法的基本思想如下:
- Floyd-Warshall算法解决的是任意两点间的最短路径的算法,其考虑的是路径的中间顶点,对于从顶点 i 到顶点 j 的路径来说,如果存在从顶点 i 到顶点 k 的路径,还存在从顶点 k 到顶点 j 的路径,并且这两条路径的权值之和小于当前从顶点 i 到顶点 j 的路径长度,则可以对顶点 j 的估计值和前驱顶点进行松弛更新。
- loyd-Warshall算法本质是一个简单的动态规划,就是判断从顶点 i 到顶点 j 的这条路径是否经过顶点 k ,如果经过顶点 k 可以让这条路径的权值变得更小,则经过,否则则不经过。
Floyd-Warshall算法的实现:
- 使用一个vvDist 二维数组来记录从各个源顶点到各个顶点的最短路径长度的估计值,vvDist[i][j] 表示从顶点 i 到顶点 j 的最短路径长度的估计值,初始时将二维数组中的值全部初始化为MAX_W,表示各个顶点之间暂时无法互通。
- 使用一个vvpPath 二维数组来记录从各个源顶点到达各个顶点路径的前驱顶点,初始时将二维数组中的值全部初始化为-1,表示各个顶点暂时只能自己到自己,没有前驱顶点。
- 根据邻接矩阵对vvDist 和vvParentPath 进行初始化,如果从顶点 i 到顶点 j 有直接相连的边,则将vvDist[i][j] 初始化为这条边的权值,并将 vvParentPath[i][j] 初始化为 i ,表示在i−>j 这条路径中顶点 j 前驱顶点是 i ,将vvDist[i][i] 的值设置为权值的缺省值(比如int就是0),表示自己到自己的路径长度为0。
- 依次取各个顶点 k 作为i−>j 路径的中间顶点,如果同时存在 i−>k 的路径和 k−>j 的路径,并且这两条路径的权值之和小于当前 i−>j 路径的权值,则更新 vvDist[i][j] 的值,vvParentPath[i][j] 的值更新为 vvParentPath[k][j] 的值。
代码如下:
//FloydWarshall多源最短路径
void FloydWarshall(vector<vector<W>>& vvdist, vector<vector<int>>& vvpPath)
{
int n = _vertexs.size();
vvdist.resize(n, vector<W>(n, MAX_W));
vvpPath.resize(n, vector<int>(n, -1));
//根据邻接矩阵初始化直接相连的顶点
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (_matrix[i][j] != MAX_W)
{
vvdist[i][j] = _matrix[i][j];
vvpPath[i][j] = i;
}
if (i == j)
{
vvdist[i][j] = W(); //i->i的路径权值设置为权值的缺省值
}
}
}
//依次取各个顶点作为i->j路径的中间顶点
for (int k = 0; k < n; k++)
{
//暴力所有i->j边
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
//经过k结点
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];
vvpPath[i][j] = vvpPath[k][j];
}
}
}
}
}
说明一下:
- Bellman-Ford算法是根据路径的终边来进行松弛更新的,而Floyd-Warshall算法是根据路径经过的中间顶点来进行松弛更新的,因为根据Bellman-Ford算法中的 dist 只能得知从指定源顶点到某一顶点的路径权值,而根据Floyd-Warshall算法中的vvDist 可以得知任意两个顶点之间的路径权值。
- Floyd-Warshall算法的时间复杂度是 O ( N 3 ),空间复杂度是 O ( N 2 ) 。虽然求解多源最短路径也可以以图中不同的顶点作为源顶点,去调用Dijkstra算法或Bellman-Ford算法,但Dijkstra算法不能解决带负权的图,Bellman-Ford算法调用 N NN 次的时间复杂度又太高。