总言
主要内容:数据结构,图结构(graph)的相关学习。涉及图的基本概念、图的存储结构、图的遍历(BFS和DFS)、最小生成树和最短路径问题(介绍常见的用于处理这些问题的算法)。
1、图的基本概念
1.1、定义
图是由顶点集合及顶点间的关系组成的一种数据结构:
G
=
(
V
,
E
)
G = (V, E)
G=(V,E),其中:
1、顶点集合
V
=
{
x
∣
x
属于某个数据对象集
}
V = \{x|x属于某个数据对象集\}
V={x∣x属于某个数据对象集} 是有穷非空集合;
2、
E
=
{
(
x
,
y
)
∣
x
,
y
属于
V
}
E = \{(x,y)|x,y属于V\}
E={(x,y)∣x,y属于V},或者
E
=
{
<
x
,
y
>
∣
x
,
y
属于
V
&
&
P
a
t
h
(
x
,
y
)
}
E=\{ <x, y>|x,y属于V \&\& Path(x, y)\}
E={<x,y>∣x,y属于V&&Path(x,y)} 是顶点间关系的有穷集合,也叫做边的集合。
(
x
,
y
)
(x, y)
(x,y) 表示
x
x
x 到
y
y
y 的一条双向通路,即
(
x
,
y
)
(x, y)
(x,y) 是无方向的;
P
a
t
h
(
x
,
y
)
Path(x, y)
Path(x,y) 表示从
x
x
x 到
y
y
y 的一条单向通路,即
P
a
t
h
(
x
,
y
)
Path(x, y)
Path(x,y) 是有方向的。
其它说明:
1、在图中,顶点个数不能为零,但可以没有边。
2、二叉树也是一种特殊的图结构。相对而言,树关注节点(顶点)中存储的值(存储型数据结构),图关注顶点及其边的权值,表示型数据结构(针对具体场景,如交通网络图等)。
3、图(graph)中常用英文:比如顶点、边。
1.2、一些基本概念介绍
1.2.1、顶点、边
顶点(Vertex): 图中的基本单元,通常用于表示实体或对象。
边(Edge): 连接两个顶点的线段,表示顶点之间的关系。在有向图中,边具有方向性。
图中结点称为顶点,第
i
i
i 个顶点记作
v
i
v_i
vi 。两个顶点
v
i
v_i
vi 和
v
j
v_j
vj 相关联称作顶点
v
i
v_i
vi 和顶点
v
j
v_j
vj 之间有一条边,图中的第
k
k
k 条边记作
e
k
e_k
ek ,
e
k
=
(
v
i
,
v
j
)
e_k = (v_i,v_j)
ek=(vi,vj) 或
<
v
i
,
v
j
>
<v_i,v_j>
<vi,vj>。
1.2.2、有向图、无向图
1.2.3、邻接、依附
1.2.4、完全图
在有
n
n
n 个顶点的无向图中,若有
n
∗
(
n
−
1
)
/
2
n * (n-1)/2
n∗(n−1)/2 条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图,比如上图G1;
在
n
n
n 个顶点的有向图中,若有
n
∗
(
n
−
1
)
n * (n-1)
n∗(n−1) 条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如上图G4。
PS:计算方式为等差数列求和。
1.2.5、顶点的度、入度、出度
顶点 v v v 的度:是指与它相关联的边的条数,记作 d e g ( v ) deg(v) deg(v) 或 T D ( v ) TD(v) TD(v)。
在有向图中,顶点的度等于该顶点的入度与出度之和。其中顶点 v v v 的入度是以 v v v 为终点的有向边的条数,记作 i n d e v ( v ) indev(v) indev(v) 或 I D ( v ) ID(v) ID(v)。顶点 v v v 的出度是以 v v v 为起始点的有向边的条数,记作 o u t d e v ( v ) outdev(v) outdev(v) 或 O D ( v ) OD(v) OD(v)。因此: d e v ( v ) = i n d e v ( v ) + o u t d e v ( v ) dev(v) = indev(v) + outdev(v) dev(v)=indev(v)+outdev(v)。
注意:对于无向图,顶点的度,等于该顶点的入度,等于顶点的出度,即
d
e
v
(
v
)
=
i
n
d
e
v
(
v
)
=
o
u
t
d
e
v
(
v
)
dev(v) = indev(v) = outdev(v)
dev(v)=indev(v)=outdev(v)。
1.2.6、权/权值、网图/有权图
在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。这种边上带有权值的图称为带权图,也称网。
1.2.7、路径、路径长度、简单路径、回路
简单路径: 若路径上各顶点v1,v2,v3,…,vm均不重复,则称这样的路径为简单路径。
回路(环): 若路径上第一个顶点v1和最后一个顶点vm重合,则称这样的路径为回路或环。
1.2.8、子图
子图: 设图
G
=
{
V
,
E
}
G = \{V, E\}
G={V,E} 和图
G
1
=
{
V
1
,
E
1
}
G1 = \{V_1,E_1\}
G1={V1,E1},若
V
1
∈
V
V1∈V
V1∈V 且
E
1
∈
E
E_1∈E
E1∈E,则称
G
1
G_1
G1 是
G
G
G 的子图。
1.2.9、连通图、连通分量
PS:
1、连通图是针对无向图而言的(上图右侧示例为非连通图)。
2、完全图要求任意一对顶点间均有边连接,而连通图只要求任意顶点间有路径即可,并不一定有边直接连接这两顶点!
1.2.10、强连通图、强连通分量
PS:
1、强连通图是针对有向图而言的(上图右侧示例为非强连通图)。
1.2.11、生成树、生成森林
PS:
1、生成树是针对无向图的。一个连通的无向图,其生成树不唯一。
1.2.12、稠密图、稀疏图
稠密图和稀疏图是相对的,没有确定的区分条件。我们往往称边的条数远小于顶点个数的平方的图称为稀疏图,稠密图反之。
2、图的存储结构
2.1、如何存储图?
根据图的定义,图是由顶点(节点)和边(节点与节点之间的关系)组成的,因此,在图的存储中,需要分别考虑如何存储顶点、如何存储边。
1、如何存储节点?
节点保存比较简单,只需要一段连续空间即可。
2、那边关系该怎么保存呢?
常见的存储形式有两种:邻接矩阵、邻接表。
一般情况下,稠密图多采用邻接矩阵存储,稀疏图多采用邻接表存储。
2.1.1、邻接矩阵(数组表示法)
1)、基本介绍
邻接矩阵基本思想: 用一个一维数组存储图中顶点的信息,用一个二维数组(称为邻接矩阵)存储图中各顶点之间的邻接关系。
假设图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E) 有
n
n
n 个顶点,则邻接矩阵是一个
n
×
n
n×n
n×n 的方阵,定义:
E
d
g
e
[
i
]
[
j
]
=
{
1
当顶点i与顶点j之间有边时
0
当顶点i与顶点j之间无边时
Edge[i][j] = \begin{cases} 1 &\text{当顶点i与顶点j之间有边时}\\ 0 &\text{当顶点i与顶点j之间无边时}\\ \end{cases}
Edge[i][j]={10当顶点i与顶点j之间有边时当顶点i与顶点j之间无边时
无向图和有向图表示的邻接矩阵如下:
1、无向图的邻接矩阵是对称的,第
i
i
i 行(列)元素之和,就是顶点
i
i
i 的度。
有向图的邻接矩阵则不一定对称,第
i
i
i 行(列)元素之和就是顶点
i
i
i 的出(入)度。
2、如果边带有权值,并且两个节点之间是连通的,上图中的边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替。即:
E
d
g
e
[
i
]
[
j
]
=
{
x
(
权值
)
当顶点i与顶点j之间有边时
∞
当顶点i与顶点j之间无边时
0
i = j时,也存在设其为∞的情况
Edge[i][j] = \begin{cases} x(权值) &\text{当顶点i与顶点j之间有边时}\\ ∞ &\text{当顶点i与顶点j之间无边时}\\ 0 &\text{i = j时,也存在设其为∞的情况}\\ \end{cases}
Edge[i][j]=⎩
⎨
⎧x(权值)∞0当顶点i与顶点j之间有边时当顶点i与顶点j之间无边时i = j时,也存在设其为∞的情况
3、、如何求顶点
i
i
i 的所有邻接点?将数组中第
i
i
i 行元素扫描一遍,所得结果即顶点
j
j
j 为顶点
i
i
i 的邻接点。(判断条件要看具体实现时,邻接矩阵的默认设置)
2)、邻接矩阵存储的优缺点
优点:
a、邻接矩阵存储方式非常适合稠密图
b、邻接矩阵可以以
O
(
1
)
O(1)
O(1) 的时间复杂度判断两个顶点的连接关系,并取到权值。
缺点:
a、相对而言,不适合查找一个顶点连接所有边,需要
O
(
N
)
O(N)
O(N) 的时间复杂度。
2.1.2、邻接表
邻接表存储法是一种顺序存储的与链式分配相结合的存储方法。使用数组表示顶点的集合,使用链表表示边的关系。
无向图邻接表存储: 无向图中同一条边在邻接表中出现了两次。如果想知道顶点
v
i
v_i
vi 的度,只需要知道顶点
v
i
v_i
vi 对应的边链表集合中结点的数目即可。
有向图邻接表存储: 有向图中每条边在邻接表中只出现一次,与顶点
v
i
v_i
vi 对应的邻接表所含结点的个数,就是该顶点的出度,也称出度表。而要得到
v
i
v_i
vi 顶点的入度,必须检测其他所有顶点对应的边链表,看有多少边顶点的dst取值是i。
2)、邻接表存储的优缺点
a、适合存储稀疏图。
b、适合查找一个顶点连接出去的边
c、不适合确定两个顶点是否相连及权值
2.2、邻接矩阵:基本框架实现
2.2.1、类及成员变量
// 邻接矩阵版
namespace matrix
{
template<class V, class W, W MAX_W =INT_MAX, bool Direction = false>
class Graph
{
public:
private:
vector<V> _vertexs;// 顶点的集合
map<V, int> _indexMap;// 顶点映射的下标
vector<vector<W>> _matrix;// 邻接矩阵(用于表示顶点间边的关系)
};
}
模板参数说明:
1、V,即vertex,表示图的顶点;
2、W,即Weight权值,这里使用了MAX_W表示顶点不连通的边(无权值,设为最大。由于权值不一定是整型数据,因此使用了一个非类型模板参数MAX_W);
3、因存在有向图和无向图,这里给定了Direction,默认为无向图。
2.2.2、总述:如何创建创建图
1)、如何创建创建图?
1、IO输入
2、图的结构关系写到文件,读取文件
3、给定顶点集合,使用函数手动添加边
这三种创建图结构的方式各有特点,适用于不同的场景和需求,在实际应用中,可以根据具体情况选择最合适的方式来构建图。以下对上述三种创建图结构方式的简要说明:
1、IO输入创建图:OJ题中常用方式。 允许用户或外部设备通过输入接口提供图的顶点和边信息。这种方式通常适用于需要灵活构建图结构的场景,比如在线绘图工具或图形编辑软件。通过读取用户输入或文件中的数据,程序可以解析出图的顶点集合和边关系,进而在内存中构建出对应的图数据结构。
2、从文件中读取图的结构关系:是一种批量处理的方式,适用于预先存储了图数据的场景。在这种情况下,图的结构关系已经被序列化并保存到文件中,程序只需读取这个文件,解析其中的数据,即可快速构建出图的数据结构。 这种方式常用于大规模图的加载和处理,能够显著提高构建图的效率。
3、给定顶点集合,手动添加边: 这是一种编程式的方式,它要求开发者在代码中显式地指定图的顶点和边关系。 这种方式通常用于需要精确控制图结构的场景,比如算法实现或特定应用的图模型构建。开发者可以通过调用图数据结构的API或函数,逐个添加顶点和边,从而构建出符合要求的图。这种方式虽然相对繁琐,但能够提供最大的灵活性和精确性。
这里,由于我们需要模拟实现,为了方便测试观察,使用第三种方式来创建图。 即,给定顶点集合,调用相关函数为各顶点添加边,最终形成一个图结构。因此,这一小节的内容(下述各接口函数实现)就是为了完成图的创建(在此基础上,才是进一步理解图的遍历、最小生成树、最短路径等问题。)
2.2.3、构造函数、添加边
根据上述,这里的构造至少要有如下情况:给定顶点集合(vertex数组)及该数组的大小,先用其构造图结构。然后,再写一个用于添加边的成员函数。
这样,我们就可以在外部实现手动建图的过程。
1)、构造函数
// 默认构造
Graph() = default;
// 构造:给定顶点及其个数
Graph(const V* vertexs, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(vertexs[i]);// 向顶点集合_vertexs添入顶点
_vIndexMap[vertexs[i]] = i;// 建立顶点及其下标的映射关系
}
// 对顶点间边的关系先初始化:邻接矩阵初始化,开辟n行n列。
_matrix.resize(n);
for (auto& e : _matrix)
e.resize(n, MAX_W);//默认权值为MAX_W
}
2)、添加边
// 根据给定顶点,找顶点对应的下标
// 为什么不直接使用_vIndexMap[]返回?防错检查。
size_t GetVertexIndex(const V& v)
{
auto ret = _vIndexMap.find(v);
if (v != _vIndexMap.end())
{
return v->second;
}
else
{
throw invalid_argument("不存在的顶点");
return -1;
}
}
// 添加边:给定源顶点、目标顶点、权值,为二者添加边的关系,并赋予权值
void AddEdge(const V& src, const V& dst, const W& w)
{
// 获取顶点对应的下标
size_t srci = GetVertexIndex(src);
size_t dsti = GetVertexIndex(dst);
// 根据下标关系与给定权值,在邻接矩阵中为两顶点添加边(这里要注意无向图和有向图)
_matrix[srci][dsti] = w; //(srci, dsti)
if (Direction == false)
{
_matrix[dsti][srci] = w;//若为无向图,则(dsti,srci)也要一并添加
}
}
2.2.4、打印
为了方便观察,我们写一个用于打印图的函数(printf、cout都行,但如果从美观角度来讲,可能需要控制一下输出格式):这里需要打印什么可根据自己的需求来设置,不必按部就班。
// 打印:方便观察
void Print()
{
// 打印顶点及其下标映射关系
for (size_t i = 0; i < _vertexs.size(); ++i)
{
cout << "[" << i << "]" << ": " << _vertexs[i] << endl;
}
cout << endl;
// 打印邻接矩阵:为方便观察将矩阵的下标也打印出来
// 打印矩阵的横下标(方便观察)
cout << " ";
for (size_t i = 0; i < _matrix.size(); ++i)
{
//cout << i << " ";
printf("%4d", i);
}
cout << endl;
for (size_t i = 0; i < _matrix.size(); ++i)
{
cout << i << " ";// 打印矩阵的竖下标(方便观察)
// 打印当前行的
for (size_t j = 0; j < _matrix[i].size(); ++j)
{
if (_matrix[i][j] != MAX_W)
//cout << _matrix[i][j] << " ";
printf("%4d", _matrix[i][j]);
else
//cout << "*" << " ";
printf("%4c", '*');
}
cout << endl;//一行结束,换行。
}
cout << endl;
// 打印矩阵及其边的关系
for (size_t i = 0; i < _matrix.size(); ++i)
{
for (size_t j = 0; j < _matrix[i].size(); ++j)
{
if (Direction == false) // 无向图:只需要打印矩阵的对角线一半
{
if (i < j && _matrix[i][j] != MAX_W)
cout << "[" << i << "]" "->" << "[" << j << "]: " << _vertexs[i] << "->" << _vertexs[j] << " , wight:" << _matrix[i][j] << endl;
}
else// 有向图:均要打印(有向图也可能存在对称的情况)
{
if (_matrix[i][j] != MAX_W)
cout << "[" << i << "]" "->" << "[" << j << "]: " << _vertexs[i] << "->" << _vertexs[j] << " , wight:" << _matrix[i][j] << endl;
}
}
}
}
用以下结果进行初步演示:
void test3()
{
matrix::Graph<char, int, INT_MAX, true> G1("0123", 4);
G1.AddEdge('0', '1', 1);
G1.AddEdge('0', '3', 4);
G1.AddEdge('1', '3', 2);
G1.AddEdge('1', '2', 9);
G1.AddEdge('2', '3', 8);
G1.AddEdge('2', '1', 5);
G1.AddEdge('2', '0', 3);
G1.AddEdge('3', '2', 6);
G1.Print();
}
结果如下:这就是手动添加边的方法。这里i = j
的点可以按照右侧图所示,在构造函数中初始化时将其设置为0
,和无连通的权值为
∞
∞
∞的边区分(但相应的,打印时的判断语句就要做稍微的变动)。
2.3、邻接表:基本框架实现
对邻接表,同样模拟其框架实现。
2.3.1、类及成员变量、构造函数
// 邻接表版
namespace LinkTable
{
// 表示边的类(单个节点,链表结构)
template<class W>
struct Edge
{
public:
int _srci;// 源顶点下标(也可以不用,单独出边表即可。总之根据需求来)
int _dsti;// 目标顶点下标
W _w;// 该条边的权值
Edge<W>* _next;// 指向下一条表的指针
//构造:这里写法不一(构造至少要传递一个权值,边的初始化也可以默认设置为-1,在外部使用时再赋值)
Edge(const W& w)
:_dsti(-1)
,_srci(-1)
,_w(w)
,_next(nullptr)
{}
Edge(const int srci, const int dsti, const W& w)
:_srci(srci)
,_dsti(dsti)
,_w(w)
, _next(nullptr)
{}
};
// 模板参数说明:
// V:顶点 W:权值 Direction:是否有向(默认无向图)
// 邻接表中就不需要MAX_W了,因为链表中链接的每个顶点都是实际邻接的有效顶点。
template<class V, class W, bool Direction = false>
class Graph
{
typedef Edge<W> Edge;
public:
private:
vector<V> _vertex;// 顶点集合
map<V, int> _VIndexMap;// 顶点映射的下标
vector<Edge*> _table;// 邻接表
};
}
2.3.2、添加边、打印
1)、添加边
// 根据给定顶点,找顶点对应的下标
size_t GetIndexVertex(const V& v)
{
auto ret = _VIndexMap.find(v);
if (ret != _VIndexMap.end())
{
return ret->second;
}
else
{
throw invalid_argument("顶点不存在");
return -1;
}
}
// 添加边。给定源顶点、目标顶点、权值,为两顶点添加边关系,并赋予权值
void AddEdge(const V& src, const V& dst, const W& w)
{
// 获取两顶点的下标
size_t srci = GetIndexVertex(src);
size_t dsti = GetIndexVertex(dst);
// 创建边,头插到对应的源顶点链表上(这里选择头插是不需要遍历找尾,相对方便)
Edge* src_eg = new Edge(w);
src_eg->_srci = srci;
src_eg->_dsti = dsti;
src_eg->_w = w;
// 头插
src_eg->_next = _tables[srci];
_tables[srci] = src_eg;
//若为无向图,则A->B和B->A的边相同(同一条边在邻接表中出现了两次)
if (Direction == false)
{
Edge* dst_eg = new Edge(w);
dst_eg->_srci = dsti;// 源、目标顶点要反过来
dst_eg->_dsti = srci;
dst_eg->_w = w;
dst_eg->_next = _tables[dsti];
_tables[dsti] = dst_eg;
}
}
2)、打印
// 打印:方便观察
void Print()
{
// 打印顶点集合
for (size_t i = 0; i < _vertexs.size(); ++i)
{
cout << "[" << i << "]" << ": " << _vertexs[i] << endl;
}
cout << endl;
// 打印邻接表:遍历,将其链表结构关系打印出来
for (size_t i = 0; i < _tables.size(); ++i)
{
cout << "[" << i << "]" << ": " << _vertexs[i] << "->";
Edge* cur = _tables[i];
while (cur)
{
cout << "{[" << cur->_dsti << "]: " << _vertexs[cur->_dsti] << ", 权值:" << cur->_w << "} -> ";
cur = cur->_next;
}
cout << "nullptr" << endl;
}
}
用以下结果进行初步演示:
// 测试邻接表
void test4()
{
string a[] = { "张三", "李四", "王五", "赵六" };
LinkTable::Graph<string, int, true> G2(a, 4);
G2.AddEdge("张三", "李四", 100);
G2.AddEdge("张三", "王五", 200);
G2.AddEdge("王五", "赵六", 30);
G2.Print();
}
上述邻接矩阵中的测试用例:
3、图的遍历
3.1、基本说明
说明: 给定一个图
G
G
G 和其中任意一个顶点
v
0
v_0
v0,从
v
0
v_0
v0 出发,沿着图中各边访问图中的所有顶点,且每个顶点仅被遍历一次。该如何操作?
图的遍历方式类似于二叉树,有广度优先遍历和深度优先遍历两种,各有优缺点,适用于不同的场景。广度优先遍历能够按照层次结构遍历图,适用于寻找最短路径等场景;而深度优先遍历能够深入探索图的内部结构,适用于检测连通性、拓扑排序等场景。在实际应用中,需要根据具体需求选择合适的遍历算法。
广度优先搜索 BFS (Breadth First Search)
:广度优先遍历是一种层次遍历算法,它从图的某一顶点出发,首先访问该顶点,然后依次访问该顶点的所有未被访问过的邻接顶点,接着再依次访问这些邻接顶点的未被访问过的邻接顶点,如此逐层进行,直至图中所有顶点都被访问为止。在实现上,广度优先遍历通常使用队列(Queue)作为辅助数据结构,首先将起始顶点入队,然后在循环中依次出队并访问顶点,将其未被访问过的邻接顶点入队。
深度优先搜索 DFS (Depth First Search)
:深度优先遍历是一种递归遍历算法,它从图的某一顶点出发,首先访问该顶点,然后选择一个未被访问过的邻接顶点作为下一个访问对象,直到当前顶点的所有邻接顶点都被访问过,然后回溯到前一个顶点,继续选择下一个未被访问过的邻接顶点进行访问,如此递归进行,直至图中所有顶点都被访问为止。在实现上,深度优先遍历通常使用栈(Stack)作为辅助数据结构,将需要访问的顶点压入栈中,并在回溯时弹出栈顶元素。
3.2、广度优先遍历(BFS)
3.2.1、思路说明
1)、基本思想
问题描述: 与二叉树的广度优先遍历不同的是,图在遍历过程中,需要记录每个节点是否已经被访问过,以避免重复访问。
解决方法: 通常,可以通过使用一个布尔数组或集合来记录已经访问过的节点,其下标索引或元素对应于图中的节点。
2)、细节说明
关于此处选择入队列就做标记,还是选择出队列时做标记。可凭借编写方式灵活运用,相对而言比较推荐入队列时就做标记(易于实现和理解)。
如果在节点入队时就标记为已访问,可以避免在后续遍历中重复检查该节点是否已经访问过。例如B、C、D入队列时,就对其做标记,由于B的邻接点是A、C、E,此时C在标记容器中被确认为已访问,就不会再入队了。
选择出队时再做标记也不是不行。只是相比之下,B出队列时,标记容器中C未被确认未已访问,会二次入队列。因此,需要每次遍历前,先检查标记容器,再考虑是否遍历当前顶点。
3.2.2、实现version1.0
相关实现:
// 广度优先遍历
void BFS(const V& src)
{
// 获取节点下标
size_t srci = GetVertexIndex(src);
// 借助队列、布尔数组做标记数组
int n = _vertexs.size();
vector<bool> visited(n, false);// 对标记数组:一共有n个顶点,则开辟n个空间,每个默认设为false.
queue<int> q;// 队列:入队、出队为顶点对应的下标
q.push(srci);
visited[srci] = true;
while (!q.empty())
{
// 遍历打印
int front = q.front();// 取队头元素
q.pop();// 删除队列中的该元素
cout << "[" << front << "]: " << _vertexs[front] << endl;
// 把front顶点的邻接顶点入队列
for (size_t i = 0; i < n; ++i)// 遍历邻接矩阵,找当前front顶点的邻接点
{
if (_matrix[front][i] != MAX_W)
{
if (visited[i] != true)// 找到邻接点后要判断该处顶点是否已经遍历过。
{
q.push(i);//若未遍历,则将其入栈
visited[i] = true;// 顺带在入栈时就对其进行标记
}
}
}
}
}
演示结果: 注意if (_matrix[front][i] != MAX_W)
,只用该判断条件,是因为我们把无连通的点以及(i,i)
自身的点都设置成了相同的值MAX_W
,若是(i,i)
位置的值为0
,则这里的判断条件要做一定变动。
string a[] = { "南京", "苏州", "扬州", "杭州", "嘉兴" };
matrix::Graph<string, int> g1(a, sizeof(a) / sizeof(string));
g1.AddEdge("南京", "苏州", 100);
g1.AddEdge("南京", "扬州", 200);
g1.AddEdge("杭州", "苏州", 60);
g1.AddEdge("杭州", "扬州", 40);
g1.AddEdge("杭州", "嘉兴", 20);
g1.Print();
g1.BFS("南京");
3.2.3、实现version2.0
有了上述基础,对于此题理解起来也不难。
题目:链接
只是相对的,需要对BFS再做一点小改动,使其能基于给定顶点,按度层层打印:
// 广度优先遍历版本2
void BFS(const V& src)
{
// 获取顶点下标
size_t srci = GetVertexIndex(src);
// 借助队列、布尔数组做标记数组
int n = _vertexs.size();
vector<bool> visited(n, false);
queue<int> q;
q.push(srci);// 将头顶点(下标)入队
visited[srci] = true;// 标记
int levelSize = 1;// 每次的顶点数量
while (!q.empty())
{
// 按照每层的顶点数量,层层出队
for (int i = 0; i < levelSize; ++i)
{
// 遍历:打印当前顶点
int front = q.front();
q.pop();
cout << "[" << front << "]:" << _vertexs[front] << " ";
// 将其邻接顶点入队
for (size_t i = 0; i < n; ++i)
{
if (_matrix[front][i] != MAX_W)
{
if (visited[i] != true)
{
q.push(i);
visited[i] = true;
}
}
}
}
levelSize = q.size();// 当前队列中的元素数量,即同层显示的顶点数量
cout << endl;
}
}
演示结果如下:
相关测试代码:
char a[9] = { 'A','B','C','D','E','F','G','H','I'};
//char a[] ="ABCDEFGHI";
matrix::Graph<char, int> g1(a, sizeof(a) / sizeof(a[0]));
g1.AddEdge('A', 'B', 30);
g1.AddEdge('A', 'C', 50);
g1.AddEdge('A', 'D', 60);
g1.AddEdge('B', 'C', 40);
g1.AddEdge('B', 'E', 20);
g1.AddEdge('C', 'F', 10);
g1.AddEdge('D', 'F', 60);
g1.AddEdge('E', 'G', 90);
g1.AddEdge('F', 'H', 80);
g1.AddEdge('H', 'I', 70);
g1.Print();
g1.BFS('A');
3.3、深度优先遍历(DFS)
3.3.1、基本实现
在图的深度优先遍历中,使用递归是一种自然且直观的实现方式。但相应的,当图的数据量很大时可能会因为栈空间限制而无法处理。
// 深度优先遍历DFS
void _DFS(size_t srci, vector<bool>& visited)
{
// 打印当前遍历到的顶点,在标记容器中修改其访问状态
cout << "[" << srci << "]:" << _vertexs[srci] << endl;
visited[srci] = true;
// 找一个srci相邻的没有访问过的点,去往深度遍历
for (size_t i = 0; i < _vertexs.size(); ++i)
{
if (_matrix[srci][i] != MAX_W && visited[i] != true)
{
// 当前顶点为src的邻接点,且当前顶点未被访问过。
_DFS(i, visited);
}
}
}
void DFS(const V& src)
{
// 获取顶点下标
size_t srci = GetVertexIndex(src);
// 辅助标记容器
vector<bool> visited(_vertexs.size(), false);
// 递归:深度遍历
_DFS(srci, visited);
}
演示结果:
3.3.2、BFS和DFS完善扩展(图不连通的情况说明)
在上述的DFS和BFS中,我们都是给定某一顶点遍历图的,若图不连通,如何才能遍历完图中所有顶点?
回答:可以通过标记容器检查。(因图不连通,一次遍历可能只找到了当前轮次连通的顶点。此时可以查询标记容器,将尚未标记的顶点找出再次遍历。如此反复直到标记容器将图的所有顶点都标记完。)
// 在外部传入标记数组,一回合BFS/DFS后,检查标记数组,再次遍历,直到遍历完所有数为止。
void BFS(const V& src, vector<bool>& visited);
void DFS(const V& src, vector<bool>& visited);
4、最小生成树
4.1、基本说明
1)、概念回顾
连通图: 在无向图中,若从顶点
v
1
v_1
v1 到顶点
v
2
v_2
v2 有路径,则称顶点
v
1
v_1
v1 与顶点
v
2
v_2
v2 是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。
生成树: 一个连通图的最小连通子图称作该图的生成树(即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。)。有
n
n
n 个顶点的连通图,其生成树有
n
n
n 个顶点和
n
−
1
n-1
n−1 条边。
最小生成树 :构造生成树的这些边,其权值累加的结果最小。(意义:以最小的成本,让图中所有顶点连通)最小生成树算法在许多领域都有应用,如网络设计、电路设计和算法优化等。
2)、最小生成树介绍
图的最小生成树是一个子图,它包含了原图中的所有顶点,并且其边的权重之和是所有可能生成树中最小的。
求最小生成树的方法主要有两个著名算法:Kruskal算法 和 Prim算法。这两个算法都采用了逐步求解的贪心策略(贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。因此,贪心算法不是对所有的问题都能得到整体最优解。)
构造最小生成树的准则有三条:
1、只能使用图中权值最小的边来构造最小生成树
2、只能使用恰好
n
−
1
n-1
n−1 条边来连接图中的n个顶点
3、选用的
n
−
1
n-1
n−1 条边不能构成回路
需要注意的是:最小生成树算法只适用于无向连通图,并且图中的边必须带有权重。 如果图不是连通的,或者边没有权重,那么最小生成树的概念就不适用。此外,如果图中存在负权重的边,那么最小生成树问题就可能变成NP难问题,需要采用其他方法来解决。
4.2、Kruskal算法(克鲁斯卡尔算法)
4.2.1、算法介绍
1)、算法思想说明
基本思想: 设无向连通网为
G
=
(
V
,
E
)
G=(V, E)
G=(V,E),令
G
G
G 的最小生成树为
T
=
(
U
,
T
E
)
T=(U, TE)
T=(U,TE),其初态为
U
=
V
U=V
U=V,
T
E
=
{
}
TE=\{ \}
TE={},然后,按照边的权值由小到大的顺序(若有多条任取其一),考察
G
G
G 的边集
E
E
E 中的各条边:
1、若被考察的边的两个顶点属于
T
T
T 的两个不同的连通分量,则将此边作为最小生成树的边加入到
T
T
T中,同时把两个连通分量连接为一个连通分量;
2、若被考察边的两个顶点属于同一个连通分量,则舍去此边,以免造成回路,如此下去,当
T
T
T 中的连通分量个数为1时,此连通分量便为
G
G
G 的一棵最小生成树。
概括总结: 从 所有边中按照权重从小到大进行选择,如果选择的边不会构成环(即不会形成闭合回路),就将该边加入最小成树中。为了避免环的形成,算法使用了并查集(Disjoint Set)数据结构来判断顶点是否连接。
4.2.2、相关实现
这里,为了方便实现,我们定义了表示边的类。
// 内部类:定义一个边(主要用于最小生成树)
struct Edge
{
size_t _srci;// 源顶点下标
size_t _dsti;// 目标顶点下标
W _w;// 该边的权值
// 构造
Edge(size_t src, size_t dst,const W& w)
:_srci(src)
,_dsti(dst)
,_w(w)
{}
// 比较运算符重载:用于比较两个边的大小(根据权值做比较),这里主要用于优先级队列中建小堆(最小生成树-Kruskal算法中)
bool operator > (const Edge& e) const
{
return _w > e._w;
}
};
Kruskal相关实现:
1、为了方便显示,添加了打印过程做测试(实际可根据需求删除)。
2、使用到的并查集是先前实现过的:博文链接。
// 最小生成树:给定一个图,获取其任意一个最小生成树
W Kruskal(Self& minTree)
{
// 1、准备工作:初始化(此步骤根据实际实现时,传入的minTree来,非必需)
minTree._vertexs = _vertexs;
minTree._vIndexMap = _vIndexMap;
size_t n = _vertexs.size();
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i)
minTree._matrix[i].resize(n, MAX_W);
// 2、建堆:将图中的边放入优先级队列中
priority_queue<Edge, vector<Edge>, greater<Edge>> minque;
// 在邻接矩阵中,将边找出放入优先级队列
// 注意,这里是连通图,A->B 和 B->A 的边是一样的,因此我们只用找矩阵的其中一半即可。即:从对角线划分的上三角矩阵或下三角矩阵。
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
// i < j保证了只取矩阵对角线一半值;后续!=用于确认ij两边是否邻接
if (i < j && _matrix[i][j] != MAX_W)
minque.push(Edge(i, j, _matrix[i][j]));
}
}
// 3、取数:每次从优先级队列中挑选一条权值最小的边。
UnionFindSet ufs(n);// 并查集:用于检验顶点是否连通
int count = 0;// 用于记录选出的边的数量
W total_wight = W();// 用于记录权值总和
while(!minque.empty())
{
// 取堆顶元素:当前权值最小的边
Edge min_edge = minque.top();
minque.pop();
// 判断选出的边是否可以用来构造最小生成树(不会成环)
if (!ufs.InSet(min_edge._srci, min_edge._dsti))
{
// 若当前两顶点不在同一集合,该边符合要求,可用。
minTree._AddEdge(min_edge._srci, min_edge._dsti, min_edge._w);// 为最小生成树添加边
ufs.Union(min_edge._srci, min_edge._dsti);// 将两个顶点合并入同一集合中
total_wight += min_edge._w;// 统计当前权值总和
++count;// 统计当前选出的边
// 打印当前选出的边:主要用于测试检验(可选)
cout << "[" << min_edge._srci << "]" "->" << "[" << min_edge._dsti << "]: " << _vertexs[min_edge._srci] << "->" << _vertexs[min_edge._dsti] << " , wight:" << min_edge._w << endl;
}
else // 主要用于测试检验(可选)
{
cout << "当前边构成环:" << "[" << min_edge._srci << "]" "->" << "[" << min_edge._dsti << "]: " << _vertexs[min_edge._srci] << "->" << _vertexs[min_edge._dsti] << " , wight:" << min_edge._w << endl;
}
}
// 4、检验:对最终结果进行检验(n个顶点,n-1条边,才满足最小生成树。存在给定的树非连通图的情况。)
if (count == n - 1)
return total_wight;
else return W();
}
3、对添加边的函数AddEdge
做了稍微改动: 主要是为了满足minTree._AddEdge(min_edge._srci, min_edge._dsti, min_edge._w);
,这里是直接给定顶点下标和权值添加边的。
// 添加边_1:给定源顶点、目标顶点、权值,为二者添加边的关系,并赋予权值
void AddEdge(const V& src, const V& dst, const W& w)
{
// 获取顶点对应的下标
size_t srci = GetVertexIndex(src);
size_t dsti = GetVertexIndex(dst);
_AddEdge(srci, dsti, w);
}
// 添加边_2:
void _AddEdge(size_t srci, size_t dsti, const W& w)
{
// 根据下标关系与给定权值,在邻接矩阵中为两顶点添加边(这里要注意无向图和有向图)
_matrix[srci][dsti] = w; //(srci, dsti)
if (Direction == false)
{
_matrix[dsti][srci] = w;//若为无向图,则(dsti,srci)也要一并添加
}
}
演示结果如下:
const char* str = "abcdefghi";
matrix::Graph<char, int> g(str, strlen(str));
g.AddEdge('a', 'b', 4);
g.AddEdge('a', 'h', 8);
//g.AddEdge('a', 'h', 9);
g.AddEdge('b', 'c', 8);
g.AddEdge('b', 'h', 11);
g.AddEdge('c', 'i', 2);
g.AddEdge('c', 'f', 4);
g.AddEdge('c', 'd', 7);
g.AddEdge('d', 'f', 14);
g.AddEdge('d', 'e', 9);
g.AddEdge('e', 'f', 10);
g.AddEdge('f', 'g', 2);
g.AddEdge('g', 'h', 1);
g.AddEdge('g', 'i', 6);
g.AddEdge('h', 'i', 7);
matrix::Graph<char, int> kminTree;
cout << "Kruskal:" << g.Kruskal(kminTree) << endl;
kminTree.Print();
4.3、Prim算法(普里姆算法)
4.3.1、算法介绍
1)、算法思想说明
基本思想:Prim 算法就是求最小生成树的,也是使用贪心算法。解题思路就是需要把图中的点分成两部分,一部分是已经选择的,我们用集合
S
S
S 记录,一部分是还没选择的,我们用集合
T
T
T 来记录。
刚开始的时候集合
S
S
S 为空,集合
T
T
T 中包含图中的所有顶点。每次从中
T
T
T 查找与当前生成树集合
S
S
S 相连但尚未加入的最小权重的边,并将该边对应的顶点加入生成树集合
S
S
S中。通过不断选择连接已构建部分与其余顶点集合的最短边来扩展生成树,直到所有顶点都被包含在最小生成树中。
上图演示如下:顶点在两个集合之间变动时,关键点在于如何选处与生成树邻接的权值最小的边?
这里我们提供一个思路:仍旧使用优先级队列来选边,但每次都要对选出的边进行判断,不能使其与原生成树成环。(判断方法:选出边后,判断其目标顶点是否在
S
S
S已经选择的集合中)
2)、一个对比总结
Prim是对点做操作,维护一个在最小生成树中的点的顶点集
S
S
S,以及一个待处理点的顶点集
T
T
T ,每次找出连接这两个集合的最短边,构成最小生成树,并将顶点加入集合
S
S
S,直到所有顶点都处理完毕。
Kruskal是对边做操作,每次选出一条最短边,如果它和当前最小生成树不构成回路就将其加入最小生成树,否则将其删除,直到所有边都处理完毕。
4.3.2、相关实现
// 最小生成树:给定一个图,获取其任意一个最小生成树( Prim算法/普里姆算法 )
W Prim(Self& minTree, const V& src)
{
// 获取顶点下标
size_t srci = GetVertexIndex(src);
// 1、准备工作:初始化(此步骤根据实际实现时,传入的minTree来,非必需)
minTree._vertexs = _vertexs;
minTree._vIndexMap = _vIndexMap;
size_t n = _vertexs.size();
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i)
{
minTree._matrix[i].resize(n, MAX_W);
}
// 2、维护两个集合S、T,分别记录已被选择的顶点和未被选择的顶点(这里我们维护顶点下标)
vector<bool> S(n, false);// 初始时,集合S为空,集合T中包含图中的所有顶点
vector<bool> T(n, true);
// 这里先把传入的顶点 src(维护的是下标) 从T取出,存入S集合中
S[srci] = true;
T[srci] = false;
// 2、维护一个优先级队列用于选边:选出边后,将其目标顶点从T中取出存入S中
priority_queue<Edge, vector<Edge>, greater<Edge>> minque;// 使用优先级队列,每次选出顶点后,将其邻接的边存入
// 这里先把传入的顶点 src(维护的是下标) 邻接的边添加到队列中
for (size_t i = 0; i < n; ++i)
{
if (_matrix[srci][i] != MAX_W)
minque.push(Edge(srci, i, _matrix[srci][i]));
}
// 3、使用优先级队列选边
W total_wight = W();// 用于记录权值总和
int count = 0;// 用于记录选出的边的数量
while (!minque.empty())
{
// 取堆顶元素:当前权值最小的边
Edge min_edge = minque.top();
minque.pop();
// 判断其目标顶点是否和源顶点在同一集合:若在,则当前边不可选取(其目标顶点也不能再加入S集合); 若不在,则当前边的目标顶点可用于构建扩展生成树。
if (!S[min_edge._dsti])
{
// 当前选出的边,其目标顶点不在S集合中。可用于扩展生成树。(下述各环节顺序非固定)
// 打印当前选出的边:主要用于测试检验
cout << "[" << min_edge._srci << "]" "->" << "[" << min_edge._dsti << "]: " << _vertexs[min_edge._srci] << "->" << _vertexs[min_edge._dsti] << " , wight:" << min_edge._w << endl;
minTree._AddEdge(min_edge._srci, min_edge._dsti, min_edge._w);// 为最小生成树添加边
total_wight += min_edge._w;// 统计当前权值总和
++count;// 统计当前选出的边
if (count == n - 1) break;// 选出的边满足n个顶点n-1条边(此时获得的即最小生成树)
// 将当前边的目标顶点从T集合中删除,存入S集合中
S[min_edge._dsti] = true;
T[min_edge._dsti] = false;
// 将目标顶点的邻接边放入优先级队列中(注意去重)
for (size_t i = 0; i < n; ++i)
{
if (_matrix[min_edge._dsti][i] != MAX_W && T[i])// (min_edge._dsti,i)该边是min_edge._dsti依附的边,且该边的目标顶点尚未被选择过(T集合中的顶点)。
minque.push(Edge(min_edge._dsti, i, _matrix[min_edge._dsti][i]));
}
}
else // 主要用于测试检验
{
// 当前选出的边,其目标顶点在S集合中。重新选边。
cout << "当前边构成环:" << "[" << min_edge._srci << "]" "->" << "[" << min_edge._dsti << "]: " << _vertexs[min_edge._srci] << "->" << _vertexs[min_edge._dsti] << " , wight:" << min_edge._w << endl;
}
}
// 4、检验:对最终结果进行检验(n个顶点,n-1条边,才满足最小生成树。存在给定的树非连通图的情况。)
if (count == n - 1)
return total_wight;
else return W();
}
演示结果:
5、最短路径问题
5.1、基本说明
1)、概念介绍
概念说明: 图的最短路径问题是指在一个图中,寻找从一个顶点(源点)到另一个顶点(终点)的路径,使得这条路径上各边的权值总和(称为路径长度)达到最小。 这个问题在图论中是一个重要的研究领域,并且在现实生活中的应用广泛,如路径规划、地图导航等。
一个应用实例·计算机网络传输的问题:怎样找到一种最经济的方式,从一台计算机向网上所有其它计算机发送一条消息。
2)、分类:单源最短路径、多源最短路径
单源最短路径和多源最短路径是图论中解决最短路径问题的两种不同场景。
单源最短路径问题指的是找到从一个特定源点(起始点)到其他所有顶点的最短路径。
多源最短路径问题则更为复杂,它要求找到任意两个顶点之间的最短路径。 也就是说,对于图中的任意两个点,一个作为出发点,一个作为到达点,需要求出这两个点之间的最短路径。
5.2、单源最短路径:Dijkstra算法(迪杰斯特拉算法)
5.2.1、基本介绍
Dijkstra算法: 适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。一般在求解最短路径的时候都是已知一个起点和一个终点,所以使用Dijkstra算法求解过后也就得到了所需起点到终点的最短路径。
初始化: 针对一个带权有向图
G
G
G,将所有结点分为两组
S
S
S和
Q
Q
Q。
S
S
S 是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点src放入,毕竟源节点到自己的权值是0),
Q
Q
Q 为其余未确定最短路径的结点集合。
贪心策略: 每次从
Q
Q
Q 中找出一个起点到该结点权值最小的结点
u
u
u ,将
u
u
u 从
Q
Q
Q 中移出,并放入
S
S
S 中。对
u
u
u 的每一个相邻结点
v
v
v 进行松弛操作。(在每一步中,算法都选择当前看来最优的顶点 [即距离最小的顶点] 进行处理,而不考虑之后的步骤可能会带来的结果。这种局部最优的选择是基于贪心策略的。)
松弛操作: 松弛,即对每一个相邻结点
v
v
v ,判断源节点
s
s
s 经过结点
u
u
u 到
v
v
v 的权值之和是否比原来
s
s
s 到
v
v
v 的权值更小。若权值比原来小,则要将
s
→
v
s→v
s→v 的权值更新为
s
→
u
→
v
s→u→v
s→u→v 的权值之和,否则维持原样。(核心:比较当前已知的最短路径和通过新顶点可能形成的路径哪个更短。如果通过新顶点形成的路径更短,则更新距离值。)
如此一直循环直至集合Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其权值仍为初始设定的值,不发生变化。
具体分析过过程如下:
问题说明: Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。
5.2.2、相关实现
5.2.2.1、迪杰斯特拉算法
// 单源最短路径:Dijkstra算法(迪杰斯特拉算法)。求一个特定源点(起始点)到其他所有顶点的最短路径。
void Dijkstra(const V& src, vector<W>& dist, vector<int>& parentPath)
{
// 准备工作
size_t srci = GetVertexIndex(src);// 获取传入源顶点的下标
size_t n = _vertexs.size();
dist.resize(n, MAX_W);// 初始化:用于记录srci源顶点到其它顶点之间的最短路径权值
parentPath.resize(n, -1);// 初始化:用于记录最短路径中,各顶点的上一节点下标(为了追溯完整的最短路径)
dist[srci] = W();// 初始化源顶点src到自己的路径权值(整型默认为0)
parentPath[srci] = srci;// 源顶点到的父节点即其本身
vector<bool> S(n, false);// 已确定最短路径的结点集合(初始时默认为空)
// 贪心策略+松弛操作:找路径
for (size_t i = 0; i < n; ++i)// n个节点,一共选n次即可
{
// 贪心算法选择最近顶点:基于dist数组中已知的最短路径信息,从不在S集合的顶点中,选择一个srci→u 距离最小的顶点u。
size_t u = 0;// 设待选顶点的下标为u
W min_w = MAX_W;// 用于记录选出的u的路径距离
for (size_t j = 0; j < n; ++j)
{ // 这里每次都遍历查找当前dist集合中满足条件的最短路径
if (S[j] == false && dist[j] < min_w)
{
u = j;
min_w = dist[j];
}
}
S[u] = true;// 将选出的顶点放入S集合中(表示u顶点已经选出srci→u的最短路径)
// 松弛操作更新距离:更新一遍与u邻接的所有顶点v,比较判断 srci→u→v 经过u顶点,v 能否获得更短的连接路径
for (size_t v = 0; v < n; ++v)
{ // 条件一:顶点不在S集合中; 条件二:是u的邻接顶点; 条件三:srci→u→v获取的路径要小于srci→v原先记录的路径
if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
{
dist[v] = dist[u] + _matrix[u][v];
parentPath[v] = u;
}
}
}
}
5.2.2.2、路径打印
为了方便观察,我们需要一个用于打印的数组。能将源顶点到这n个顶点的最短路径均打印出来:
// 打印最短路径
void PrintShortPath(const V& src, const vector<W>& dist, const vector<int>& parentPath)
{
size_t srci = GetVertexIndex(src);// 源顶点下标
size_t n = _vertexs.size();
// 遍历n个顶点,依次打印这n个顶点到源顶点的最短路径
for (size_t i = 0; i < n; ++i)
{
// 获取当前i顶点的路径:
// 通过追溯parentPath数组中的下标,获取src→i所经过的每一顶点(注意,这里获取到的是逆向路径)
vector<int> path;// 用于记录途径顶点的下标
int parenti = i;
while (parenti != srci)
{
path.push_back(parenti);
parenti = parentPath[parenti];
}
path.push_back(srci);// 处理源顶点
// 逆置
reverse(path.begin(), path.end());
// 打印:
for (auto pos : path)
{
cout << "[" << pos << "]:" << _vertexs[pos] << " → ";
}
cout << " weight: " << dist[i] << endl;
}
}
验证结果如下:
const char* str = "ysxzt";
matrix::Graph<char, int, INT_MAX, true> g(str, strlen(str));
g.AddEdge('s', 't', 10);
g.AddEdge('s', 'y', 5);
g.AddEdge('y', 't', 3);
g.AddEdge('y', 'x', 9);
g.AddEdge('y', 'z', 2);
g.AddEdge('z', 's', 7);
g.AddEdge('z', 'x', 6);
g.AddEdge('t', 'y', 2);
g.AddEdge('t', 'x', 1);
g.AddEdge('x', 'z', 4);
vector<int> dist;
vector<int> parentPath;
g.Dijkstra('s', dist, parentPath);
cout << endl;
g.PrintShortPath('s', dist, parentPath);
5.2.3、负权问题
Dijkstra算法在处理带权重的图时,其正确性基于一个重要的假设:所有边的权重都是非负的。
当图中存在负权重的边时,Dijkstra算法可能无法正确计算出最短路径,原因主要有以下几点:
1、贪心策略的限制: Dijkstra算法采用贪心策略,每一步都选择当前距离最短的顶点进行松弛操作。在权重非负的情况下,这种策略是有效的,因为从起点出发,随着距离的增加,不会突然因为某条边的负权重而出现更短的路径。然而,当存在负权重边时,可能会出现从当前已访问顶点出发,通过负权重边能够到达一个距离起点更近的顶点,这种情况下贪心策略就失效了。
2、最短路径的误导: 在Dijkstra算法中,一旦一个顶点被加入到已访问集合中,其最短路径就被认为是确定的,并且不会再被更新。然而,在存在负权重边的情况下,即使一个顶点已经被访问过,也可能通过后续的负权重边找到更短的路径。 因此,Dijkstra算法无法处理这种情况,导致计算出的最短路径可能是错误的。
5.3、单源最短路径:Bellman-Ford算法(贝尔曼-福特算法)
5.3.1、基本介绍
Dijkstra算法只能用来解决正权图的单源最短路径问题,而Bellman-Ford算法可以解决负权图的单源最短路径问题。
其优点是可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。相对的,缺点是时间复杂度为
O
(
N
×
E
)
O(N×E)
O(N×E) (N是点数,E是边数)普遍是要高于Dijkstra算法的时间复杂度
O
(
N
2
)
O(N^2)
O(N2)。比如,若使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是
O
(
N
3
)
O(N^3)
O(N3),这里能看出Bellman-Ford是一种暴力求解更新策略。
Bellman-Ford算法的算法思想主要体现在两个方面:
1、通过连续的松弛操作来逐步逼近最短路径
2、通过检测负权环来判断图是否包含无解的情况。
初始化: 算法初始化所有顶点到源点的距离估计值。通常,源点到自身的距离设为0,源点到其他顶点的距离设为无穷大(或一个足够大的数)。这个初始化步骤为后续的松弛操作提供了起点。
松弛操作: 对N个顶点,需要进行N-1次松弛操作。在每一次松弛操作中,算法会遍历图中的所有边,并尝试通过当前边的起点和权重来更新终点的距离估计值。 如果通过当前边可以得到更短的路径,那么就更新终点的距离估计值。这个过程实际上是不断修正和优化距离估计值,使其逐渐逼近真实的最短路径。
检查负权回路: 经过N-1次松弛操作后,算法基本上得到了从源点到所有顶点的最短路径。然而,这里还需要考虑一个特殊情况,即负权环的存在。负权环是指图中存在一个环,其边的权值之和为负。这样的环会导致路径长度可以无限减少,从而使得算法无法正常收敛。 因此,算法在N-1次松弛操作之后,还会进行一次额外的松弛操作,并检查是否有顶点的距离估计值发生了变化。 如果有变化,则说明图中存在负权环,问题无解;否则,算法结束,输出最短路径结果。
5.3.2、相关实现
// 单源最短路径:Bellman-Ford算法(贝尔曼-福特算法)
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& parentPath)
{
// 准备工作
size_t srci = GetVertexIndex(src);// 获取传入源顶点的下标
size_t n = _vertexs.size();
dist.resize(n, MAX_W);// 初始化:用于记录srci源顶点到其它顶点之间的最短路径权值
parentPath.resize(n, -1);// 初始化:用于记录最短路径中,各顶点的上一节点下标(为了追溯完整的最短路径)
dist[srci] = W();// 初始化源顶点src到自己的路径权值(整型默认为0)
parentPath[srci] = srci;// 源顶点到的父节点即其本身
// 总体最多更新n轮
for (size_t k = 0; k < n; ++k)
{
bool update = false;// 用于判断当前回合的松弛操作是否更新出新的最短路径
cout << "更新第" << k+1 << "轮:" << endl;// 用于测试
// 单次松弛操作:遍历n个节点所有边,尝试通过当前边的起点和权重来更新终点的距离估计值。
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
// 条件一:i,j是邻接顶点; 条件二:srci→i→j 获取的路径要小于 dist中原先记录的 srci→j 路径
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
update = true;// 当前回合发生更新,说明存在影响其它节点的可能,还需要再次进行松弛操作
dist[j] = dist[i] + _matrix[i][j];
parentPath[j] = i;
// 用于测试:打印当前选出的更新过程
cout << "update [" << i << "]" "->" << "[" << j << "]: " << _vertexs[i] << "->" << _vertexs[j] << " , wight:" << _matrix[i][j] << ", totalwight:" << dist[j] << endl;
}
}
}
// 若当前轮次中没有更新出更短路径,那么后续轮次就不需要再走了
if (update == false)
break;
}
// 再更新一波(用于判断负权回路)
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
return false;// 如果还能更新,说明图中带负权回路/负权环
}
}
}
return true;
}
5.3.2.2、关于单次松弛操作说明
若只进行了单次松弛操作,结果错误:
分析原因如下:
5.3.2.3、关于多次松弛操作优化
实际上,Bellman-Ford算法也会通过引入队列进行优化:使用队列来优化松弛操作。在每次松弛操作后,将距离值发生变化的顶点加入队列中。然后,从队列中取出顶点并继续松弛操作,直到队列为空。这种优化可以减少不必要的松弛操作,从而降低算法的时间复杂度。
相关扩展:SPFA优化,Shortest Path Faster Algorithm,最短路径快速算法。
5.3.3、负权回路
负权回路(Negative-weight Cycle) 是图论中的一个概念,它指的是在加权图中存在一个回路(Cycle),其所有边的权重之和为负数。这里的“回路”指的是从一个顶点出发,沿着一些边行进,最终又回到这个顶点的路径。
负权回路的存在对于最短路径问题有重要影响。在存在负权回路的情况下,最短路径问题可能没有解,因为可以通过不断绕行负权回路来得到任意小的路径长度。因此,在设计图算法时,需要特别考虑如何处理负权回路的情况。
需要注意的是,负权边并不等同于负权回路。 负权边是指边的权重为负数的边,而负权回路则是由这些负权边组成的闭合路径。在图中可以有负权边而不存在负权回路,但存在负权回路必然意味着图中至少有一条负权边。
// 可测试负权回路
const char* str = "syztx";
matrix::Graph<char, int, INT_MAX, true> g(str, strlen(str));
g.AddEdge('s', 't', 6);
g.AddEdge('s', 'y', 7);
g.AddEdge('y', 'z', 9);
g.AddEdge('y', 'x', -3);
g.AddEdge('y', 's', 1); // 新增
g.AddEdge('z', 's', 2);
g.AddEdge('z', 'x', 7);
g.AddEdge('t', 'x', 5);
g.AddEdge('t', 'y', -8); //更改
//g.AddEdge('t', 'y', 8);
g.AddEdge('t', 'z', -4);
g.AddEdge('x', 't', -2);
vector<int> dist;
vector<int> parentPath;
if (g.BellmanFord('s', dist, parentPath))
g.PrintShortPath('s', dist, parentPath);
else
cout << "带负权回路" << endl;
5.4、多源最短路径:Floyd-Warshall算法(弗洛伊德算法)
5.4.1、基本介绍
Floyd-Warshall算法是解决任意两点间的最短路径的一种算法。 其可以处理有向图或带有权重的无向图,并能够处理图中存在负权边的情况,但不允许存在负权回路。
弗洛伊德算法基本思想是通过中间节点逐步更新节点对之间的最短路径信息。即对于简单路径
p
=
{
v
1
,
v
2
,
…
,
v
n
}
p=\{v_1,v_2,…,v_n\}
p={v1,v2,…,vn} 上除
v
1
v_1
v1 和
v
n
v_n
vn 的任意节点。设
k
k
k 是
p
p
p 的一个中间节点,那么从
i
i
i 到
j
j
j 的最短路径
p
p
p ,就被分成
i
i
i ~
k
k
k 和
k
k
k ~
j
j
j 的两段最短路径
p
1
p1
p1,
p
2
p2
p2。
p
1
p1
p1 是从
i
i
i 到
k
k
k 且中间节点属于
{
1
,
2
,
…
,
k
−
1
}
\{1,2,…,k-1\}
{1,2,…,k−1}取得的一条最短路径。
p
2
p2
p2 是从
k
k
k 到
j
j
j 且中间节点属于
{
1
,
2
,
…
,
k
−
1
}
\{1,2,…,k-1\}
{1,2,…,k−1}取得的一条最短路径。
即Floyd算法本质是三维动态规划,
D
i
,
j
,
k
D_{i,j,k}
Di,j,k 表示从点
i
i
i 到点
j
j
j 只经过
0
0
0 到
k
k
k 个点最短路径。由此了建立起转移方程,然后通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后即得到所以点的最短路。
5.4.2、相关实现
// 多源最短路径:Floyd-Warshall算法(弗洛伊德算法)
void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvparentPath)
{
// 初始化二维数组
size_t n = _vertexs.size();// 顶点数
vvDist.resize(n);//距离矩阵(dist[][]),该矩阵的每一个元素dist[i][j]表示节点i到节点j的当前已知最短距离。
vvparentPath.resize(n);
for (size_t i = 0; i < n; ++i)
{ // 初始化权值和路径矩阵
vvDist[i].resize(n, MAX_W);
vvparentPath[i].resize(n, -1);
}
// 先更新直接相连的节点对(i, j)的最短路径
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (_matrix[i][j] != MAX_W)
{
vvDist[i][j] = _matrix[i][j];
vvparentPath[i][j] = i;// i→j 由于是直接连边,那么j的前一个节点自然是i。
}
if (i == j)// 顶点自己到自己的距离
vvDist[i][j] = W();
}
}
// 对非直连边,遍历图中的每一个节点,将其作为潜在的中间点k。
// 遍历所有的节点对(i, j),检查是否通过中间点k可以找到从i到j的更短路径:
// 即:比较dist[i][k] + dist[k][j](即经过k的路径长度)和当前的dist[i][j](即不经过k的路径长度)
for (size_t k = 0; k < n; ++k)
{
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//对当前(i,j)两点间的路径,判断比较经过k和不经过k的路径长度
// i→k→j i→j
if (vvDist[i][k] != MAX_W && vvDist[k][j] != MAX_W // i、j之间经过k点的路径要存在
&& vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) // 如此才能进行比较
{
vvDist[i][j] = vvDist[i][k] + vvDist[k][j];
vvparentPath[i][j] = vvparentPath[k][j];//从k到j的最短路径中j的前一个节点是vvparentPath[k][j]
}
}
}
// 以下代码属于非必要操作(主要用来检测):打印权值和路径矩阵观察数据
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (vvDist[i][j] == MAX_W)
{
//cout << "*" << " ";
printf("%3c", '*');
}
else
{
//cout << vvDist[i][j] << " ";
printf("%3d", vvDist[i][j]);
}
}
cout << endl;
}
cout << endl;
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//cout << vvParentPath[i][j] << " ";
printf("%3d", vvparentPath[i][j]);
}
cout << endl;
}
cout << "=================================" << endl;
}
}
演示结果:
const char* str = "12345";
matrix::Graph<char, int, INT_MAX, true> g(str, strlen(str));
g.AddEdge('1', '2', 3);
g.AddEdge('1', '3', 8);
g.AddEdge('1', '5', -4);
g.AddEdge('2', '4', 1);
g.AddEdge('2', '5', 7);
g.AddEdge('3', '2', 4);
g.AddEdge('4', '1', 2);
g.AddEdge('4', '3', -5);
g.AddEdge('5', '4', 6);
vector<vector<int>> vvDist;
vector<vector<int>> vvParentPath;
g.FloydWarshall(vvDist, vvParentPath);
// 打印任意两点之间的最短路径
for (size_t i = 0; i < strlen(str); ++i)
{
g.PrintShortPath(str[i], vvDist[i], vvParentPath[i]);
cout << endl;
}