图
点赞👍👍收藏🌟🌟关注💖💖
你的支持是对我最大的鼓励,我们一起努力吧!😃😃
1. 图的基本概念
图是由顶点集合及顶点间的关系组成的一种数据结构:G = (V, E),其中:
顶点集合V = {x|x属于某个数据对象集}是有穷非空集合;
E = {(x,y)|x,y属于V}或者E = {<x, y>|x,y属于V && Path(x, y)}是顶点间关系的有穷集合,也叫做边的集合。
(x, y)表示x到y的一条双向通路,即(x, y)是无方向的;Path(x, y)表示从x到y的一条单向通路,即Path(x, y)是有方向的。
顶点和边:图中结点称为顶点,第i个顶点记作vi。两个顶点vi和vj相关联称作顶点vi和顶点vj之间有一条边,图中的第k条边记作ek,ek = (vi,vj)或<vi,vj>。
有向图和无向图:在有向图中,顶点对<x, y>是有序的,顶点对<x,y>称为顶点x到顶点y的一条边(弧),<x, y>和<y, x>是两条不同的边,比如下图G3和G4为有向图。在无向图中,顶点对(x, y)是无序的,顶点对(x,y)称为顶点x和顶点y相关联的一条边,这条边没有特定方向,(x, y)和(y,x)是同一条边,比如下图G1和G2为无向图。注意:无向边(x, y)等于有向边<x, y>和<y, x>。
下面是一些常见的图,G2看着是一颗二叉树为什么也说是图呢?
可以这样理解,树是一种特殊(无环连通)的图,图不一定是树。树关注的节点(顶点)中存的值以及连通关系,图关注的是顶点及边的权值。(边由三部分组成:两个顶点、权值)
树是一种存储式数据结构,节点内存值,然后构成二叉搜索树,AVL树,红黑树。
图是一种表示型数据结构,表示某种场景。
比如说下面的图,顶点可能表示城市,边表示城市之间一个关系(高铁距离、高铁价格、高铁时间。。。)
有了这个东西,提出DFS,BFS遍历,最小生成树(最小代价把图连图),最短路径(一个顶点到其他顶点 或者 多个顶点之间 最短路径)的问题。
图还可以用来表示社交关系
顶点:人
边:表示两个人是好友
边权值:亲密度等
微信,qq等关系->无向图(强社交关系)
微博,抖音等关系->有向图(弱社交关系、媒体社交)
完全图(任意两个顶点都有边):在有n个顶点的无向图中,若有n * (n-1)/2条边(n个顶点 1->n-1,2->n-2 … n->0 等差数列),即任意两个顶点之间有且仅有一条边,则称此图为无向完全图,比如上图G1;在n个顶点的有向图中,若有n * (n-1)条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如上图G4。
邻接顶点:在无向图中G中,若(u, v)是E(G)中的一条边,则称u和v互为邻接顶点,并称边(u,v)依附于顶点u和v;在有向图G中,若<u, v>是E(G)中的一条边,则称顶点u邻接到v,顶点v邻接自顶点u,并称边<u, v>与顶点u和顶点v相关联。
顶点的度:顶点v的度是指与它相关联的边的条数,记作deg(v)。在有向图中,顶点的度等于该顶点的入度与出度之和,其中顶点v的入度是以v为终点的有向边的条数,记作indev(v);顶点v的出度是以v为起始点的有向边的条数,记作outdev(v)。因此:dev(v) = indev(v) + outdev(v)。注意:对于无向图,顶点的度等于该顶点的入度和出度,即dev(v) = indev(v) = outdev(v)。
路径:在图G = (V, E)中,若从顶点vi出发有一组边使其可到达顶点vj,则称顶点vi到顶点vj的顶点序列为从顶点vi到顶点vj的路径。
路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一条路径的路径长度是指该路径上各个边权值的总和。
简单路径与回路:若路径上各顶点v1,v2,v3,…,vm均不重复,则称这样的路径为简单路径。若路径上第一个顶点v1和最后一个顶点vm重合,则称这样的路径为回路或环。
子图:设图G = {V, E}和图G1 = {V1,E1},若V1属于V且E1属于E,则称G1是G的子图。
连通图(连通图是针对无向图来说的):在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。
强连通图(强连通图是针对有向图来说的):在有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj到vi的路径,则称此图是强连通图。
生成树:一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n-1条边。
2. 图的存储结构
因为图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和边关系即可。节点保存比较简单,只需要一段连续空间即可,那边关系该怎么保存呢?
顶点我们可以像并查集哪里一样用vector和map保存,那边如何保存呢?
//V 顶点类型, W 权值类型, Direction 表示有向/无向
template<class V,class W,bool Direction>
class Graph
{
private:
vector<V> _vertexs;//顶点集合
map<V, int> _IndexMap;//顶点与下标映射
};
2.1 邻接矩阵
因为节点与节点之间的关系就是连通与否,即为0或者1,因此邻接矩阵(二维数组)即是:先用一个数组将顶点保存(将顶点转化成对应的下标,比如说顶点是abcd编号就是0123),然后采用矩阵来表示节点与节点之间的关系。
注意:
- 无向图的邻接矩阵是对称的,第i行(列)元素之和,就是顶点i的度。有向图的邻接矩阵则不一定是对称的,第i行(列)元素之后就是顶点i 的出(入)度。
- 如果边带有权值,并且两个节点之间是连通的,上图中的边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替
3. 用邻接矩阵存储图的有点是能够快速知道两个顶点是否连通,缺陷是如果顶点比较多,边比较少时,矩阵中存储了大量的0成为系数矩阵,比较浪费空间,并且要求两个节点之间的路径不是很好求。
优点:
邻接矩阵存储方式非常适合稠密图
邻接矩阵O(1)判断两个顶点的连接关系,并取到权值
缺点:
相对而言不适合查找一个顶点连接所有边 — O(N)
假设有n个顶点,是不是要所有顶点遍历一遍才知道某个顶点到底和那些顶点相连。
时间复杂度是O(N),N是顶点个数。
假设有100个顶点,我这个顶点只和三个顶点相连只有三条边,但也要遍历100次,能不能有个方法快速把与之相连的三条边都找到呢?
2.2 邻接表
邻接表:使用数组表示顶点的集合,使用链表表示边的关系。
邻接表和哈希桶类似。使用一个指针数组,把自己和连接的顶点边都挂在下面。
- 无向图邻接表存储
注意:无向图中同一条边在邻接表中出现了两次。如果想知道顶点vi的度,只需要知道顶点vi边链表集合中结点的数目即可。
优点:
适合存储稀疏图
适合查找一个顶点连接出去的边
缺点:
不适合确定两个顶点是否相连及权值
- 有向图邻接表存储
注意:有向图中每条边在邻接表中只出现一次,与顶点vi对应的邻接表所含结点的个数,就是该顶点的出度,也称出度表,要得到vi顶点的入度,必须检测其他所有顶点对应的边链表,看有多少边顶点的dst取值是i。
一般情况下有向图,存储一个出边表即可。
总结一下:邻接矩阵和邻接表其实属于相辅相成,各有优缺点的互补结构。具体还是看场景选择用邻接矩阵和邻接表
2.3 邻接矩阵的实现
//类型模板参数: V 顶点类型(int,char...), W 权值类型(int,double...), Direction 表示有向/无向(默认无向)
//非类型模板参数: MAX_W 默认权值
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; //邻接矩阵(边的集合)
};
图的创建有下面几种方式:
- IO输入 ------ (自己写不方便测试,oj中更适合)
- 图的结构关系写到文件,读取文件
- 手动添加边 (我们采用的方式)
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(a[i]);
_IndexMap[a[i]] = i;
}
_matrix.resize(n);
for (size_t i = 0; i < n; ++i)
{
_matrix[i].resize(n, MAX_W);
}
}
添加边
首先我们要找到边对应两个顶点的下标,然后才在矩阵添加边的信息,注意区分有向图还是无向图。
size_t GetVertexindex(const V& v)
{
//不能直接用[]去查,万一不在就成插入了
auto it = _IndexMap.find(v);
if (it != _IndexMap.end())
{
return it->second;
}
else
{
throw invalid_argument("不存在的顶点");
return -1;
}
}
void _AddEdge(const size_t& srci, const size_t& dsti, const W& w)
{
_matrix[srci][dsti] = w;
if (Direction == false) // 无向图
{
_matrix[dsti][srci] = w;
}
}
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);
}
打印
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 < _vertexs.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)
{
//cout << _matrix[i][j] << " ";
if (_matrix[i][j] == MAX_W)
{
//cout << "* ";
printf("%4c", '*');
}
else
{
//cout << _matrix[i][j] << " ";
printf("%4d", _matrix[i][j]);
}
}
cout << endl;
}
cout << endl;
}
下面我们测试一下
void TestGraph()
{
Graph<char, int, INT_MAX, true> g("0123", 4);
g.AddEdge('0', '1', 1);
g.AddEdge('0', '3', 4);
g.AddEdge('1', '3', 2);
g.AddEdge('1', '2', 9);
g.AddEdge('2', '3', 8);
g.AddEdge('2', '1', 5);
g.AddEdge('2', '0', 3);
g.AddEdge('3', '2', 6);
g.Print();
}
完整代码
//类型模板参数: V 顶点类型(int,char...), W 权值类型(int,double...), Direction 表示有向/无向(默认无向)
//非类型模板参数: MAX_W 默认权值
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph
{
public:
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(a[i]);
_IndexMap[a[i]] = i;
}
_matrix.resize(n);
for (size_t i = 0; i < n; ++i)
{
_matrix[i].resize(n, MAX_W);
}
}
size_t GetVertexindex(const V& v)
{
//不能直接用[]去查,万一不在就成插入了
auto it = _IndexMap.find(v);
if (it != _IndexMap.end())
{
return it->second;
}
else
{
throw invalid_argument("不存在的顶点");
return -1;
}
}
void _AddEdge(const size_t& srci, const size_t& dsti, const W& w)
{
_matrix[srci][dsti] = w;
if (Direction == false) // 无向图
{
_matrix[dsti][srci] = w;
}
}
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);
}
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 < _vertexs.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)
{
//cout << _matrix[i][j] << " ";
if (_matrix[i][j] == MAX_W)
{
//cout << "* ";
printf("%4c", '*');
}
else
{
//cout << _matrix[i][j] << " ";
printf("%4d", _matrix[i][j]);
}
}
cout << endl;
}
cout << endl;
}
private:
vector<V> _vertexs; //顶点集合
map<V, int> _IndexMap; //顶点与下标映射
vector<vector<W>> _matrix; //邻接矩阵(边的集合)
};
2.4 邻接表的实现
邻接表实际也是一个哈希桶,这里实现很简单
//存储边的信息
template<class W>
struct Edge
{
size_t _srci;//起始点
size_t _dsti;//目标点的下标
W _w;//权值
Edge<W>* _next;
Edge(const size_t& srci,const size_t& dsti,const W& w)
:_srci(srci)
,_dsti(dsti)
,_w(w)
,_next(nullptr)
{}
};
template<class V,class W,bool Direction = false>
class Graph
{
typedef Edge<W> Edge;
public:
Graph(const V* a, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(a[i]);
_IndexMap[a[i]] = i;
}
_tables.resize(n, nullptr);
}
size_t GetVertexindex(const V& v)
{
auto it = _IndexMap.find(v);
if (it != _IndexMap.end())
{
return it->second;
}
else
{
throw invalid_argument("不存在的顶点");
return -1;
}
}
void _AddEdge(const size_t& srci, const size_t& dsti,const W& w)
{
//头插
Edge* edge = new Edge(srci, dsti, w);
edge->_next = _tables[srci];
_tables[srci] = edge;
if (Direction == false) // 无向图
{
Edge* new_edge = new Edge(dsti, srci, w);
new_edge->_next = _tables[dsti];
_tables[dsti] = new_edge;
}
}
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);
}
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 << _vertexs[i] << "[" << i << "]->";
Edge* cur = _tables[i];
while (cur)
{
cout << "[" << _vertexs[cur->_dsti] << ":" << cur->_dsti << ":" << cur->_w << "]->";
cur = cur->_next;
}
cout << "nullptr" << endl;
}
}
private:
vector<V> _vertexs; //顶点集合
map<V, int> _IndexMap; //顶点与下标映射
vector<Edge*> _tables; //邻接表(哈希桶)
};
接下来图的遍历,最小生成树,最短路径我们都以邻接矩阵构的图去实现。
3. 图的遍历
给定一个图G和其中任意一个顶点v0,从v0出发,沿着图中各边访问图中的所有顶点,且每个顶点仅被遍历一次。"遍历"即对顶点进行某种操作的意思。
3.1 图的广度优先遍历
有树的基础就知道广度优先遍历必然要借助队列来实现。广度优先遍历就是以某个点为起点,当这个顶点出队列就把和这个顶点的邻接顶点都入队列,然后一层一层往外走。但是要注意的是已经入过队列的顶点下一次不能在入队列,否则就会死循环,因此还要来一个标记bool类型数组。当一个顶点入队列后就标记一下。
void BFS(const V& src)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
//队列和标记数组
queue<int> q;
vector<bool> vis(n, false);
q.push(srci);
vis[srci] = true;
while (!q.empty())
{
size_t front = q.front();
q.pop();
cout << front << ":" << _vertexs[front] << endl;
//把和front顶点的临界顶点入队列
for (size_t i = 0; i < n; ++i)
{
if (_matrix[front][i] != MAX_W && !vis[i])
{
q.push(i);
vis[i] = true;
}
}
}
}
void TestBDFS()
{
string a[] = { "张三", "李四", "王五", "赵六", "周七" };
Graph<string, int> g1(a, sizeof(a) / sizeof(string));
g1.AddEdge("张三", "李四", 100);
g1.AddEdge("张三", "王五", 200);
g1.AddEdge("王五", "赵六", 30);
g1.AddEdge("王五", "周七", 30);
g1.Print();
g1.BFS("张三");
//g1.DFS("张三");
}
接下来看一道题图的BFS应用题
举一个例子,告诉我们一度好友、二度好友。。。是什么样的,让我们找到小点的六度好友。这就是一个典型的图BFS应用。回想一下刚才我们的BFS顶点出队列是怎么出的?是一个一个出的。对于这道题我们出队列的就要求一层一层出。那怎么一层一层出呢?很简单出队列之前计算一下当前队列内元素的个数。每次出队列内元素个数次。
void BFS(const V& src)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
queue<int> q;
vector<bool> vis(n, false);
q.push(srci);
vis[srci] = true;
while (!q.empty())
{
//出队列之前计算队列内元素个数,一层一层出
size_t k = q.size();
while (k--)
{
size_t front = q.front();
q.pop();
cout << front << ":" << _vertexs[front] << endl;
for (size_t i = 0; i < n; ++i)
{
if (_matrix[front][i] != MAX_W && !vis[i])
{
q.push(i);
vis[i] = true;
}
}
}
}
}
3.2 图的深度优先遍历
图的深度优先遍历和树的前序遍历一样。先往深走,当走到不能走的就回溯换条路走,最终直到所有顶点遍历完然后返回。因此我们用递归来实现,这里我们还是需要一个标记bool类型数组,已经访问过的不能在访问否则就会死递归。
void _DFS(size_t srci, const size_t& n, vector<bool>& vis)
{
cout << srci << ":" << _vertexs[srci] << endl;
vis[srci] = true;
//找一个srci相邻的且没有被访问过的顶点,去往深度遍历
for (size_t i = 0; i < n; ++i)
{
if (_matrix[srci][i] != MAX_W && !vis[i])
{
_DFS(i, n, vis);
}
}
}
void DFS(const V& src)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
vector<bool> vis(n, false);
_DFS(srci, n, vis);
}
其实这里还有一个遗漏的问题,如果无向图是一个连通图或者有向图是一个强连通图,一次BFS和DFS就可以把所有顶点遍历一遍。但是如果图不是连通的。那以某个点为起点就没有办法一次BFS或者DSF把所有顶点遍历一遍,那如何把图中所有顶点都访问到呢?
其实很简单,不是有标记数组吗。把标记数组在遍历一遍,如果还有顶点没有被遍历到那就以这个顶点在做一次BFS或DFS。
4. 最小生成树
首先生成树对应的一定是连通图。连通图中找生成树。
连通图(连通图是针对无向图来说的):在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。
生成树:一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n-1条边。(最少的边连通起来)
最小生成树:构成生成树的边的权值加起来是最小的。(最小的成本让n个顶点连通)
连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三条:
- 只能使用图中的权值最小的边来构造最小生成树
- 只能使用恰好n-1条边来连接图中的n个顶点
- 选用的n-1条边不能构成回路
构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。
贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体
最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。
最小生成树不唯一,但是权值是唯一的。
4.1 Kruskal算法
任给一个有n个顶点的连通网络N={V,E},
首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量,
其次不断从E中取出权值最小的一条边(若有多条任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中。如此重复,直到所有顶点在同一个连通分量上为止。
核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。
其实上面说了这么多,Kruskal算法核心思想:就是每次都从边中选权值最小的边(全局选最小)。
那怎么去选最小的边呢?可以把所有的边拿出来排序。但是这不是好方法。更好的方法就是用优先级队列。建一个小堆。每次拿堆顶元素,然后pop堆顶元素,再拿次小的边。
但是这里还有一个问题,可能选的边会造成回路!
比如选择 i - g 权值为6的这条边,构成了回路!
如何判断选择的边构成了回路呢?
使用并查集 -> 判环
刚开始每一个顶点都是一个独立的集合,选边的时候判断一下构成这个边的两个顶点是否是一个集合,如果不是一个集合就可以选这个边。然后把对应的两个顶点合并成一个集合。
struct Edge
{
size_t _srci;
size_t _dsti;
W _w;
Edge(size_t& srci, size_t& dsti, W& w)
:_srci(srci)
,_dsti(dsti)
,_w(w)
{}
bool operator>(const Edge& e) const
{
return _w > e._w;
}
};
//把最小生成树权值和返回去
W Kruskal(Self& minTree)
{
size_t n = _vertexs.size();
//最小生成树是连通图的一个子图,信息是一样的,先初始化
minTree._vertexs = _vertexs;
minTree._IndexMap = _IndexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i)
{
minTree._matrix[i].resize(n, MAX_W);
}
//建小堆,因为Edge是自定义类型,库中的greater不支持自定义类型比较,所以写一个对应的仿函数
priority_queue<Edge, vector<Edge>, greater<Edge>> heap;
//并查集
UnionFindSet ufs(n);
//将所有边入堆
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//无向图的邻接矩阵是一个对称矩阵
//因此只用存矩阵上半部分或者下半部分就行了
if (i < j && _matrix[i][j] != MAX_W)
{
heap.push(Edge(i, j, _matrix[i][j]));
}
}
}
//选出n-1条边
size_t sz = 0;
W total = W();
while (!heap.empty())
{
Edge minedge = heap.top();
heap.pop();
//构成边的两个顶点不在一个集合,说明不构成环,可以选
if (!ufs.IsSet(minedge._srci, minedge._dsti))
{
//可以打印一下看选了那条边
cout << _vertexs[minedge._srci] << "->" << _vertexs[minedge._dsti] << ":" << minedge._w << endl;
minTree._AddEdge(minedge._srci, minedge._dsti, minedge._w);
ufs.Union(minedge._srci, minedge._dsti);
sz++;
total += minedge._w;
}
else
{
cout << "构成环:";
cout << _vertexs[minedge._srci] << "->" << _vertexs[minedge._dsti] << ":" << minedge._w << endl;
}
}
if (sz == n - 1)
{
return total;
}
else
{
return -1;
}
}
void TestGraphMinTree()
{
const char* str = "abcdefghi";
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);
Graph<char, int> kminTree;
cout << "Kruskal:" << g.Kruskal(kminTree) << endl;
kminTree.Print();
//Graph<char, int> pminTree;
//cout << "Prim:" << g.Prim(pminTree, 'a') << endl;
//pminTree.Print();
//cout << endl;
//for (size_t i = 0; i < strlen(str); ++i)
//{
// cout << "Prim:" << g.Prim(pminTree, str[i]) << endl;
//}
}
4.2 Prim算法
Prim算法也是用的贪心,但是它跟Kruskal算法不一样,Kruskal的核心思想是每次都在全局选最小,Prim是给一个起点,然后从这个起点开始去找最小边(局部选最小)。它选边是先把所有顶点归成两个集合,一个集合是已经被选择的顶点(已经加入到最小生成树的顶点),剩下顶点是一个集合。它是在两个集合之间去选最小边。每次都从两个集合各选一个顶点构成的最小边。
为什么会这样选边的呢?也就说这个地方贪心不是一个全局的贪心,是一个局部的贪心。以某个点为起点去找周围最小的边。而之前是全局贪心。那局部贪心的优势是什么?
它的优势就是选边不会构成环。
它是在两个集合之间去选最小边。每次都从两个集合各选一个顶点构成的最小边。天然避环。
那怎么去区分已经加入到最小生成树的顶点集合和剩余顶点的集合呢?
我们可以搞两个vector,一个X集合,一个Y集合。
X表示已经加入最小生成树顶点的结合
Y表示剩余顶点的集合
刚开始所以顶点都没加入到最小生成树也就是都在Y集合,因此Y集合的所有顶点都标记成true,如果某个顶点加入到最小生成树就把对应顶点从Y中变成false,而在X中变为true。
那如何从X->Y集合连接的边里面选出最小的边?
搞一个优先级队列(小堆)把已经加入最小生成树顶点相连的边加入到队列中,这样去选最小边可不可以?其实不行!
加入h到X集合的时候 a - h 就已经在一个集合了,这条边就不该在这个队列里面了,但是你又不能控制把它删除。
所以直接用优先级队列也不太好。
第二种方式就是每次去遍历,因为我们这里是矩阵很方便,每次去遍历去找X集合的顶点与Y集合的顶点构成最小的边。但是时间复杂度挺高的。
其实我们还是用优先级队列,不过选边的时候要判一下环,如果选出来最小的边的两个顶点在一个集合是构成环的,不能选!
W Prim(Self& minTree, const V& src)
{
size_t n = _vertexs.size();
minTree._vertexs = _vertexs;
minTree._IndexMap = _IndexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i)
minTree._matrix[i].resize(n, MAX_W);
//从X->Y集合中连接的边里面选出最小的边
vector<bool> X(n,false);
vector<bool> Y(n, true);
priority_queue<Edge, vector<Edge>, greater<Edge>> heap;
size_t srci = GetVertexindex(src);
//先把srci连接的边添加到队列中
for (size_t i = 0; i < n; ++i)
{
if(_matrix[srci][i] != MAX_W)
heap.push(Edge(srci, i, _matrix[srci][i]));
}
X[srci] = true;
Y[srci] = false;
size_t sz = 0;
W total = W();
while (!heap.empty())
{
Edge minedge = heap.top();
heap.pop();
if (!X[minedge._dsti])//每次从两个集合中各选一个顶点构成的最小边,防止成环
{
cout << _vertexs[minedge._srci] << "->" << _vertexs[minedge._dsti] << ":" << minedge._w << endl;
minTree._AddEdge(minedge._srci, minedge._dsti, minedge._w);
sz++;
total += minedge._w;
X[minedge._dsti] = true;
Y[minedge._dsti] = false;
for (size_t i = 0; i < n; ++i)
{
//已经选过的最小边,不要重复添加
if (_matrix[minedge._dsti][i] != MAX_W && Y[i])
heap.push(Edge(minedge._dsti, i, _matrix[minedge._dsti][i]));
}
}
else
{
cout << "构成环:";
cout << _vertexs[minedge._srci] << "->" << _vertexs[minedge._dsti] << ":" << minedge._w << endl;
}
}
if (sz == n - 1)
{
return total;
}
else
{
return -1;
}
}
5. 最短路径
最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
一般而言:
最小生成树 -> 无向图
最短路径 -> 有向图
5.1 单源最短路径–Dijkstra算法
单源最短路径:一个起点到其他所有点,最短路径。
单源最短路径问题:给定一个图G = ( V , E ) G=(V,E)G=(V,E),求源结点s ∈ V s∈Vs∈V到图中每个结点v ∈ V v∈Vv∈V的最短路径。Dijkstra算法就适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。一般在求解最短路径的时候都是已知一个起点和一个终点,所以使用Dijkstra算法求解过后也就得到了所需起点到终点的最短路径。
针对一个带权有向图G,将所有结点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点s放入,毕竟源节点到自己的代价是0),Q 为其余未确定最短路径的结点集合,每次从Q 中找出一个起点到该结点代价最小的结点u ,将u 从Q 中移出,并放入S 中,对u 的每一个相邻结点v 进行松弛操作。松弛即对每一个相邻结点v ,判断源节点s到结点u 的代价与u 到v 的代价之和是否比原来s 到v 的代价更小,若代价比原来小则要将s 到v 的代价更新为s 到u 与u 到v 的代价之和,否则维持原样。如此一直循环直至集合Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。Dijkstra算法每次都是选择V-S中最小的路径节点来进行更新,并加入S中,所以该算法使用的是贪心策略。
Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径
比如这里是以s为源点去找和其他点的最短路径。
刚开始S集合里面只有起点s,s到其他起点初始都是∞指的是还没有最短路径。s是自己到自己可以初始为0,初始选择a这个起点,以这个点去做松弛操作,去遍历与s相连的顶点去更新s到其他顶点的最短路径,然后从未被加入到S里面的点里面去选一个从源点到这些顶点的最短路径。以这个顶点开始在去做松弛操作。
Dijkstra算法贪心策略:每次去选从起点->还没加入到最短路径的顶点中去选最短路径那个顶点,去更新其连接的路径(做松弛操作)
用最小的边在去更新其他边也是相对很小。
如何存储起点到其他顶点最短路径的权值和存储最短路径呢?
它这里用的是抽象表示,用了两个数组,本来是二维的。但是压缩成了一维。
每个顶点都确定过一个下标。根据下标搞了一个数组,把起点到其他顶点的最短路径的权值存储到这个dist数组里。在根据下标搞一个Ppath记录起点到其他顶点的路径,数组里面存的是路径前一个顶点下标。
//顶点个数是 N, 时间复杂度 O(N^2) 空间复杂度 O(N)
void Dijkstra(const V& src, vector<W>& dist, vector<int>& Ppath)
{
size_t n = _vertexs.size();
size_t srci = GetVertexindex(src);
//dist,记录srci-其他顶点最短路径权值数组
dist.resize(n, MAX_W);
//Ppath 记录srci-其他顶点最短路径父顶点数组
Ppath.resize(n, -1);
//初始化
dist[srci] = 0;
Ppath[srci] = srci;
//已经确定最短路径的顶点集合
vector<bool> S(n, false);
// n个顶点更新N次
for (size_t i = 0; i < n; ++i)
{
//每次去选从起点->还未加入到最短路径的顶点中去选最短路径的那个顶点,去更新其连接的路径(松弛操作)
W min = MAX_W;
size_t u = -1;
for (size_t j = 0; j < n; ++j)
{
if (!S[j] && dist[j] < min)
{
u = j;
min = dist[j];
}
}
S[u] = true;//选到的顶点加入到最短路径
for (size_t v = 0; v < n; ++v)
{
//松弛操作,已经加入到最短路径的顶点路径已经是最小不用更新,其他顶点如果 s -> u + u -> v < s -> v 更新
if (!S[v] && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
{
dist[v] = dist[u] + _matrix[u][v];
Ppath[v] = u;
}
}
}
}
打印最短路径,各个顶点的最短路径是倒着存的,存的是前一个顶点的下标。我们把路径算出来之后还要逆置一下才能把路径找到。
void PrintShortPath(const V& src,const vector<W>& dist,const vector<int>& Ppath)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
for (size_t i = 0; i < n; ++i)
{
if (i != srci)//自己到自己不算
{
//找出i顶点的路径,和并查集类似
vector<int> path;
int parent = i;
while (parent != srci)
{
path.push_back(parent);
parent = Ppath[parent];//前一个顶点的下标
}
path.push_back(srci);
reverse(path.begin(), path.end());
for (auto& e : path)
{
cout << _vertexs[e] << "->";
}
cout << "权值和 " << dist[i] << endl;
}
}
}
Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径
可以看到 s -> y 并不是最短路径。Dijkstra算法本身用了一个贪心,如果对已加入到最小路径的顶点更新这个贪心就失效了。如果边的权值都是正的,以其他边去更新已经加入最小路径的顶点就比之前更大没有必要更新,但是有负数就不一样了,贪心就失效了。
5.2 单源最短路径–Bellman-Ford算法
Dijkstra算法只能用来解决正权图的单源最短路径问题,但有些题目会出现负权图。这时这个算法就不能帮助我们解决问题了,而bellman—ford算法可以解决负权图的单源最短路径问题。它的优点是可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。它也有明显的缺点,它的时间复杂度 O(N*E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),这里也可以看出来Bellman-Ford就是一种暴力求解更新。
s -> { j } 其他顶点的集合,要么是直接相连,要么是找中间边边去更新,Dijkstra是去找s->某个顶点特别短那就优先拿这条边去作为起始边去更新,它是我到这个顶点边是最短的,那我从这个顶点到其他顶点也是最短的。Bellman-Ford是去找终止边暴力去更新。
Dijkstra 最小起始边,贪心
Bellman-Ford 终止边,暴力
终止边就是以 i -> j 去进行更新,i -> j 就是图中所有边
s -> { j } 其他顶点,要么直接相连,要么 s -> i i -> j,这个时候仅需要探测s -> i 是通的 i -> j 也是通的 它们加起来比 s -> j 更小,就松弛更新一下。i -> j 代表图中所有边,拿图中所有边去暴力更新一遍。
Bellman-Ford算法借助终止边(i -> j ,图中所有边)暴力更新起点 -> { j } 所有顶点。要么直接相连,要么借助终止边。
但是拿所有边走一遍并不是说就一定能更新出来!
void BellmanFord(const V& src, vector<W>& dist, vector<int>& Ppath)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
// vector<W> dist, 记录srci -> 其他顶点最短路径权值数组
dist.resize(n, MAX_W);
// vector<int> pPath 记录srci -> 其他顶点最短路径父顶点数组
Ppath.resize(n, -1);
// 先更新srci->srci为缺省值
dist[srci] = W();
Ppath[srci] = srci;
// i -> j 更新一轮
// 借助终止边i->j(图中所有顶点之间的边),更新srci到所有顶点的最小路径(做松弛操作)
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//srci -> i + i -> j < srci -> j
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
dist[j] = dist[i] + _matrix[i][j];
Ppath[j] = i;
}
}
}
}
最短路径似乎更新出来了, 但是为什么s->z的权值不对呢?
接下来画图分析一下
第一次更新是 s->s s->y,因为更新规则是 dist[i] + _matrix[i][j] < dist[j],我们初始的时候是把 dist[srci] = W()给了初始值。先先更新与s直连的边。
s->x x->t,这一句的更新就导致了问题。4 + (-2)< 6, s->t 最短路径更新成2,t的前一个顶点变成x。也就是从s到t的路径变成了 s -> y -> x -> t, s -> t 最短路径更新成2。
但是注意到 s -> t 的最短路径更新到2 ,而从 s -> z 要经过t,s -> z 路径因为我们更新了 s -> t 的路径,而变成了 s -> y -> x -> t -> z,但是s -> z 最短路径可没有更新,依旧是上次 s ->(直连) t -> z的最短路径2。所以 s -> t 有了路径更新,但是 s -> t 最短路径没有更新。权值和路径对不上。
只要你更新出了一条更短路径,可能就会影响其他路径。 如何解决?
s -> z, 在更新一次就变成了 s -> y -> x -> t -> z 的权值 -2了。
在更新一次就修正了,但是新更新路径又可能会影响其他路径,所以还要继续更新,最多更新n轮(极端情况下最多用n条边去更新某一个顶点)。
这里还有一个优化,可能某一轮就不会在更新了也不会影响其他路径。因此可以增加一个标记位,某一轮没有更新就结束更新。
//时间复杂度 O(N^3), 空间复杂度 O(N)
void BellmanFord(const V& src, vector<W>& dist, vector<int>& Ppath)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
// vector<W> dist, 记录srci -> 其他顶点最短路径权值数组
dist.resize(n, MAX_W);
// vector<int> pPath 记录srci -> 其他顶点最短路径父顶点数组
Ppath.resize(n, -1);
// 先更新srci->srci为缺省值
dist[srci] = W();
Ppath[srci] = srci;
// 总体最多更新n轮
for (size_t k = 0; k < n; ++k)
{
//优化
bool update = false;
// i -> j 更新一轮
// 借助终止边i->j(图中所有顶点之间的边),更新srci到所有顶点的最小路径(做松弛操作)
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//srci -> i + i -> j < srci -> j
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
//只要更新出一条更短路径,可能会影响其他路径,在更新一次就修正了
//但是新更新的路径又可能会影响其他路径,所以还要继续更新,最多更新n轮
dist[j] = dist[i] + _matrix[i][j];
Ppath[j] = i;
update = true;
}
}
}
// 如果这个轮次中没有更新出更短路径,那么后续轮次就不需要再走了
if (update == false)
break;
}
}
还有一个优化思路,第一个轮次所有边都会参与更新,但是第二个轮次并一定所有边都参与更新,只有那些第一个轮次更新的最短路径的会影响其他路径的,然后第二轮去更新就好了。具体可以搞一个队列优化。
第一轮更新:所有边入队列
后面的轮次:更新出最短路径的边入队列
Bellman-Ford算法它的优点是可以解决有负权边的单源最短路径问题,但是解决不了带负权回路的的单源最短路径问题。因此可以用来判断是否有负权回路。
s->s 每次都会更新。
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& Ppath)
{
size_t srci = GetVertexindex(src);
size_t n = _vertexs.size();
// vector<W> dist, 记录srci -> 其他顶点最短路径权值数组
dist.resize(n, MAX_W);
// vector<int> pPath 记录srci -> 其他顶点最短路径父顶点数组
Ppath.resize(n, -1);
// 先更新srci->srci为缺省值
dist[srci] = W();
Ppath[srci] = srci;
// 总体最多更新n轮
for (size_t k = 0; k < n; ++k)
{
//优化
bool update = false;
// i -> j 更新一轮
// 借助终止边i->j(图中所有顶点之间的边),更新srci到所有顶点的最小路径(做松弛操作)
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//srci -> i + i -> j < srci -> j
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
//只要更新出一条更短路径,可能会影响其他路径,在更新一次就修正了
//但是新更新的路径又可能会影响其他路径,所以还要继续更新,最多更新n轮
dist[j] = dist[i] + _matrix[i][j];
Ppath[j] = i;
update = true;
}
}
}
// 如果这个轮次中没有更新出更短路径,那么后续轮次就不需要再走了
if (update == false)
break;
}
//更新n轮后还能更新就是带负权回路
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
//srci -> i + i -> j < srci -> j
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
return false;
}
}
}
return true;
}
5.3 多源最短路径–Floyd-Warshall算法
Floyd-Warshall算法是解决图中任意两点间的最短路径的一种算法,也可以解决带负权路径。
Dijkstra算法和BellmanFord算法也可以以所有点为起点也可以求出任意两点之间的最短距离。但是Dijkstra算法不能带负权,BellmanFord算法效率低一点。
Floyd-Warshall算法真正优势在于同时更新多源,既然要记录多源的权值和数组那就意味着一维已经不行了,那这个时候就要搞成一个二维的。二维就能记录任意两个点。它和一维的区别就是以前算 srci -> i i -> j 要去矩阵里面取,现在就去dist这个矩阵里面去取。
Floyd算法考虑的是一条最短路径的中间节点,即简单路径p={v1,v2,…,vn}上除v1和vn的任意节点。
任意两点之间要么直接相连,要么最多经过其它点(n - 2个顶点)。
Dijkstra算法是用最小起始边来算
BellmanFord算法是用终止边来算
Floyd-Warshall算法使用中间点来算
设k是p的一个中间节点,那么从i到j的最短路径p就被分成i到k和k到j的两段最短路径p1,p2。p1是从i到k且中间节点属于{1,2,…,k-1}取得的一条最短路径。p2是从k到j且中间节点属于{1,2,…,k-1}取得的一条最短路径。
Floyd-Warshall算法本质还是用了动态规划。距离都在dsti里面去取,i -> j 要么直接相连,要么经过 ((1…k)集合中的顶点(n-2个顶点)) i -> k,k - > j。取两种情况中的最小值为最短路径。
具体做法如下:
- 先将直接相连的 i -> j 的 dist ,Ppath初始化
- 最短路径更新,i -> 中间点 -> j,k作为中间点尝试更新 i -> j 的路径, 如果 i -> k,k -> j < i -> j 更新 dist[i][j]和Ppath[i][j],注意如果是i -> k,k -> j < i -> j,Ppath[i][j] 更新要注意,Ppath要的是跟 j 相连的上一个邻接顶点,如果 k 与 j 直接相连 Ppath[k][j]存的就是 k ,如果 k -> j 没有直接相连,k -> … -> x - > j,Ppath[k][j] 存的就是 x。所以Ppath[i][j] = Ppath[k][j] ,而不是直接等于 k。
void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvPpath)
{
size_t n = _vertexs.size();
//初始化权值和路径矩阵
vvDist.resize(n, vector<W>(n, MAX_W));
vvPpath.resize(n, vector<int>(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];
vvPpath[i][j] = i;
}
if (i == j)
{
vvDist[i][j] = W();
}
}
}
// 最短路径的更新i-> {其他顶点} ->j
// K严格来说最多是n-2个,但是不能循环n-2次,要循环n次,因为 i -> j 一直在变,要把所有点作为中间点
// abcdef a->f k这次是a或者f 对于a->f也没有影响, a->a a->f, a->f f->f,
for (size_t k = 0; k < n; ++k)
{
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
// k 作为的中间点尝试去更新 i->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];
// 找跟j相连的上一个邻接顶点
// 如果k->j 直接相连,上一个点就k,vvpPath[k][j]存就是k
// 如果k->j 没有直接相连,k->...->x->j,vvpPath[k][j]存就是x
vvPpath[i][j] = vvPpath[k][j];
}
}
// 打印权值和路径矩阵观察数据
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (vvDist[i][j] == MAX_W)
{
printf("%3c", '*');
}
else
{
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)
{
printf("%3d", vvPpath[i][j]);
}
cout << endl;
}
cout << "=================================" << endl;
}
}
}
void TestFloydWarShall()
{
const char* str = "12345";
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;
}
}