
Intro
图是由顶点集合及顶点间的关系组成的一种数据结构: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>。
图和树
🌳 树是一种特殊的(无环连通)图
🌳 图不一定是树
🌳 树关注的是节点中存的值
🌳 图关注的是顶点和边的权值
图的概念
完全图
在有n个顶点的无向图中,若有n * (n-1)/2
条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图,比如上图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条边。
图的样例
样例一:G.O.T.地图
本图片来源于Crash Course Computer Science,抽象图结构与GOT联系,易于理解
注:部分图片来源于Crash Course Computer Science,如有侵权请联系我删除
每座城都是一个顶点,两个城之间就是一个边
样例二:社交关系
⚠️ 树可以看作是图,但是图不可以看作是树
图的存储结构
因为图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和边关系即可。节点保存比较简单,只需要一段连续空间即可,那边关系该怎么保存呢?
有人说可以用这样来表示边,实际上是不行的,因为我无法判断两个边是相连的
vector<pair<V,V>> edges;
邻接矩阵
因为节点与节点之间的关系就是连通与否,即为0或者1,因此邻接矩阵(二维数组)即是:先用一个数组将定点保存,然后采用矩阵来表示节点与节点之间的关系。
邻接矩阵的优势 | 邻接矩阵的劣势 |
---|---|
O(1)判断两个顶点的连接关系,并获得权值 | 要找一个顶点连出去的边是O(N) |
适合于边比较多的图(稠密图) | 存储稀疏图浪费 |
邻接矩阵的特点
- 无向图的邻接矩阵是对称的,第i行(列)元素之和,就是顶点i的度。有向图的邻接矩阵则不一定是对称的,第i行(列)元素之后就是顶点i 的出(入)度。
- 如果边带有权值,并且两个节点之间是连通的,上图中的边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替。
- 用邻接矩阵存储图的有点是能够快速知道两个顶点是否连通,缺陷是如果顶点比较多,边比较少时,矩阵中存储了大量的0成为系数矩阵,比较浪费空间,并且要求两个节点之间的路径不是很好求。
邻接矩阵实现
构造函数中首先是通过数组进行初始化
// 邻接矩阵
namespace Matrix
{
template<class V, class W, bool Direction = false>
class Graph
{
public:
Graph(const V* vertexs, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(vertexs[i]);
_vIndexMap[vertexs[i]] = i; //节点值:下标
}
_matrix.resize(n);
for (auto& e : _matrix)
{
e.resize(n);
}
}
size_t GetVertexIndex(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 srcindex = GetVertexIndex(src);
size_t dstindex = GetVertexIndex(dst);
_matrix[srcindex][dstindex] = w;//赋予权值
if (Direction == false)
{
_matrix[dstindex][srcindex] = w;
}
}
private:
map<string, int> _vIndexMap; //顶点映射的下标
vector<V> _vertexs; // 顶点集合
vector<vector<W>> _matrix; // 存储边集合的矩阵
};
}
邻接表
邻接表:使用数组表示顶点的集合,使用链表表示边的关系。一般情况下,都给了出边表,不是入边表,反邻接表
无向图邻接表存储
注意:无向图中同一条边在邻接表中出现了两次。如果想知道顶点vi的度,只需要知道顶点vi边链表集合中结点的数目即可。
有向图邻接表存储
邻接表的优势 | 邻接表的劣势 | |
---|---|---|
适合查找一个顶点连接出去的边 | 不适合确定两个顶点是否相连和权值 | |
适合存储稀疏图 | 存储稀疏图浪费 |
注意:有向图中每条边在邻接表中只出现一次,与顶点vi对应的邻接表所含结点的个数,就是该顶点的出度,也称出度表,要得到vi顶点的入度,必须检测其他所有顶点对应的边链表,看有多少边顶点的dst取值是i。
实现邻接表
namespace LinkTable
{
template<class V, class W, bool Direction = false>
class Graph
{
typedef LinkEdge<W> Edge;
public:
Graph(const V* vertexs, size_t n)
{
_vertexs.reserve(n);
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(vertexs[i]);
_vIndexMap[vertexs[i]] = i;
}
_linkTable.resize(n, nullptr);
}
size_t GetVertexIndex(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 srcindex = GetVertexIndex(src);
size_t dstindex = GetVertexIndex(dst);
// 0 1
Edge* sd_edge = new Edge(w);
sd_edge->_srcIndex = srcindex;
sd_edge->_dstIndex = dstindex;
//头插
sd_edge->_next = _linkTable[srcindex];
_linkTable[srcindex] = sd_edge;
// 1 0
// 无向图
if (Direction == false)
{
Edge* ds_edge = new Edge(w);
ds_edge->_srcIndex = dstindex;
ds_edge->_dstIndex = srcindex;
ds_edge->_next = _linkTable[dstindex];
_linkTable[dstindex] = ds_edge;
}
}
private:
map<string, int> _vIndexMap;
vector<V> _vertexs; // 顶点集合
vector<Edge*> _linkTable; // 边的集合的临接表
};
}
图的遍历
给定一个图G和其中任意一个顶点v0,从v0出发,沿着图中各边访问图中的所有顶点,且每个顶点仅被遍历一次。"遍历"即对结点进行某种操作的意思。 请思考树以前是怎么遍历的,此处可以直接用来遍历图吗?为什么?
图的广度优先遍历
比如现在要找东西,假设有三个抽屉,东西在那个抽屉不清楚,抽屉里面有盒子,盒子里面还有盒子,现在要将其找到,广度优先遍历的做法是:
1.先将三个抽屉打开,在最外层找一遍
2.将每个抽屉中红色的盒子打开,再找一遍
3.将红色盒子中绿色盒子打开,再找一遍直到找完所有的盒子
注意:每个盒子只能找一次,不能重复找
implement BFS
邻接矩阵
BFS 的想法是首先在队列中入一个节点,然后拿出这每一个节点,拿出这个节点对应的矩阵的下标,遍历这个点和其他点之间是否存在相连关系,如果相连就放入队列中,不相连的话就是_matrix[srcindex]
会等于W()
,也就是缺省值0
但是每次遍历我都要访问一遍矩阵未免太麻烦,而且图可能是带环的,也就是说可能陷入死循环,我们能不能避免重复遍历矩阵,我们可以来一个visit数组,代表的是这个带你有没有被访问过
//广度优先遍历
void BFS(const V &src)
{
//开始起点
size_t srcindex = GetVertexIndex(src);
vector<bool> visited; //创建访问数组
visited.resize(_vertexs.size(), false);
//使用队列
queue<int> q;
q.push(srcindex);
visited[srcindex] = true;
//获取层数
size_t d = 1;
size_t dSize = 1;
while (!q.empty())
{
printf("%s的%d度好友:", src.c_str(), d);
while (dSize--)
{
//保存队首节点,并出队
size_t front = q.front();
q.pop();
for (size_t i = 0; i < _vertexs.size(); ++i)
{
//看看没有访问过的节点且是相连的
if (visited[i] == false && _matrix[front][i] != MAX_W)
{
printf("[%d:%s] ", i, _vertexs[i].c_str());
visited[i] = true;
q.push(i);
}
}
}
cout << endl; //一层出完
dSize = q.size(); //下一层的size更新
++d; //层数++
}
cout << endl; //结束
}
测试函数,小张和他的好友
void TestGraphBFS()
{
string a[] = {"张三", "李四", "王五", "赵六", "周七"};
Graph<string, int> g1(a, sizeof(a) / sizeof(string));
g1.AddEdge("张三", "李四", 100);
g1.AddEdge("张三", "王五", 200);
g1.AddEdge("王五", "赵六", 30);
g1.AddEdge("王五", "周七", 30);
g1.BFS("张三");
}
邻接表
void _BFS(queue<int>& q, vector<bool>& visited)
{
while(!q.empty())
{
size_t index = q.front();
if(!visited[index])
{
cout<<_v[index]<<" ";
visited[index] = true;
LinkEdge* pCur = _linkEdges[index];
while(pCur)
{
q.push(pCur->_dst);
pCur = pCur->_pNext;
}
}
q.pop();
}
cout<<endl;
}
void BFS(const V& v)
{
queue<int> q;
vector<bool> visited(_v.size(), false);
q.push(GetIndexOfV(v));
_BFS(q, visited);
// 处理非连通图
for(size_t index = 0; index < _v.size(); ++index)
{
if(visited[index])
continue;
q.push(index);
_BFS(q, visited);
}
}
图的深度优先遍历
比如现在要找东西,假设有三个抽屉,东西在那个抽屉不清楚,要将其找到,广度优先遍历的做法是:
- 先将第一个抽屉打开,在最外层找一遍
- 将第一个抽屉中红盒子打开,在红盒子中找一遍
- 将红盒子中绿盒子打开,在绿盒子中找一遍
- 递归查找剩余的两个盒子
深度优先遍历:将一个抽屉一次性遍历完(包括该抽屉中包含的小盒子),再去递归遍历其他盒子
当走到了无路可走的时候就可以回溯了
implement DFS
####邻接矩阵
void _DFS(size_t srcIndex, vector<bool>& visited)
{
printf("[%d:%s]->", srcIndex, _vertexs[srcIndex].c_str());
visited[srcIndex] = true;
// srcIndex->i
for (size_t i = 0; i < _vertexs.size(); ++i)
{
if (visited[i] == false && _matrix[srcIndex][i] != MAX_W)
{
_DFS(i, visited);
}
}
}
void DFS(const V& src)
{
size_t srcindex = GetVertexIndex(src);
vector<bool> visited;
visited.resize(_vertexs.size(), false);
_DFS(srcindex, visited);
}
不过这里其实还存在这一个问题,那就是可能给的图不是一个连通图,从某一个点开始的话可能没有遍历完成,不保证所有的点都是能够visited,此时应该如何做?
再去遍历visted
邻接表
void _DFS(int index, vector<bool>& visited)
{
if(!visited[index])
{
cout<<_v[index]<<" ";
visited[index] = true;
LinkEdge* pCur = _linkEdges[index];
while(pCur)
{
_DFS(pCur->_dst, visited);
pCur = pCur->_pNext;
}
}
}
void DFS(const V& v)
{
cout<<"DFS:";
vector<bool> visited(_v.size(), false);
_DFS(GetIndexOfV(v), visited);
for(size_t index = 0; index < _v.size(); ++index)
_DFS(index, visited);
cout<<endl;
}
最小生成树
最小生成树:构成生成树的这些边加起来的权值是最小的,仅针对加权连通无向图
生成树就是N-1条边构成的最小联通子图,连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。
最小生成树的规则
**若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。**因此构造最小生成树的准则有三条:
- 只能使用图中的边来构造最小生成树
- 只能使用恰好n-1条边来连接图中的n个顶点
- 选用的n-1条边不能构成回路
构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。
贪心算法
贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。
Kruskal算法
每次都去选最小的边去加入生成树,看是否构成环
核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。
Sort the edges base on weight. Greedily select the smallest one each time and add into the MST as long as it doesn’t form a cycle.
Kruskal’s algoritm uses Union-Find data structure to check whether any additional edge causes a cycle. The logic is to put all connected vertices into the same set (in Union Find’s concept). If the two vertices from a new edge do not belong to the same set, then it’s safe to add that edge into the MST.
The following graph demonstrates the steps:
实现角度:难点是如何判断加入新边是否构成环,使用并查集会好解决,其他方法都不太好
- 先判断加入的边的两个顶点在不在一个集合,在就加入会构成环
- 加入的边,就把他的两个顶点合并
下面是在《算法导论》中的一个很好的例子:
实现Kruskal算法
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);
}
priority_queue<Edge, vector<Edge>, greater<Edge>> minque;
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
if (i < j && _matrix[i][j] != MAX_W)
{
minque.push(Edge(i, j, _matrix[i][j]));
}
}
}
// 选出n-1条边
int size = 0;
W totalW = W();
UnionFindSet ufs(n);
while (!minque.empty())
{
Edge min = minque.top();
minque.pop();
if (!ufs.InSet(min._srci, min._dsti))
{
//cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] <<":"<<min._w << endl;
minTree._AddEdge(min._srci, min._dsti, min._w);
ufs.Union(min._srci, min._dsti);
++size;
totalW += min._w;
}
else
{
//cout << "构成环:";
//cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
}
}
if (size == n - 1)
{
return totalW;
}
else
{
return W();
}
}
测试用例
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; //通过Kruskal,进行生成树
kminTree.Print();
}
Prime算法
核心:先去选最小的第一条边,然后在已经选好的边里面的顶点-临接边中最小的边
我们可以用set来记录,选好一个顶点就从没相连的顶点中删除,然后再去加入已经相连的顶点
Prim’s algorithm doesn’t pre-sort all edges. Instead, it uses a Priority Queue to maintain a running sorted next-possile vertices.
Starting from one vertex, loop through all unvisited neighbours and enqueue a pair of values for each neighbour - the vertex and the weight of edge connecting current vertex to the neighbour. Each time it greedily select the top of the priority queue (the one with least weight value) and add the edge into the final MST if the enqueued neighbour hasn’t been already visited.
The following graph demonstrates the steps:
下面是在《算法导论》中的一个很好的例子:
Prim算法的优势是什么?首先他是一个非全局的贪心算法,这样的方式天然避开了环,这里只需要标记已经选过的顶点和没有选过的顶点,不用并查集了
实现Prime算法
这里我可以使用set来辨别,也可以使用一个vector保存visit,这里推荐使vector直接来。因为set底层红黑树,不仅空间消耗还不比vector快
其实选边才是一个头疼的事情,直接一个优先级队列的话是不太好的,因为边是变化的,直接选边会出现环
假如我要暴力选边肯定也不是太好,因为暴力地选的话O(n2),选个边太消耗了
只能退而求其次,优先级队列把所有的可行边放进去,添加到队列中,判断以下环
W Prim(Self& minTree, const W& src)
{
//获得传入顶点的坐标
size_t srci = GetVertexIndex(src);
size_t n = _vertexs.size();
//初始化树
minTree._vertexs = _vertexs;
minTree._vIndexMap = _vIndexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i)
{
minTree._matrix[i].resize(n, MAX_W);
}
/*可以不用set*/
/*set<int> X;
set<int> Y;
X.insert(srci);
for (size_t i = 0; i < n; ++i)
{
if (i != srci)
{
Y.insert(i);
}
}*/
//为了方便记录边,我们使用两个bool数组
vector<bool> X(n, false); //已构成边的点
vector<bool> Y(n, true); //没有构成边的点
X[srci] = true;
Y[srci] = false;
//选边是一个头疼的事情,直接一个优先级队列的话是不太好的,因为边是变化的
// 从X->Y集合中连接的边里面选出最小的边
priority_queue<Edge, vector<Edge>, greater<Edge>> minq;
// 先把srci连接的边添加到队列中
for (size_t i = 0; i < n; ++i)
{
if (_matrix[srci][i] != MAX_W)
{
minq.push(Edge(srci, i, _matrix[srci][i]));
}
}
/*显示选边过程*/
cout << "Prim开始选边" << endl;
size_t size = 0;
W totalW = W();
while (!minq.empty())
{
//获取一个有关的最小的边
Edge min = minq.top();
minq.pop();
// 最小边的起点和目标点都在X集合,则构成环
if (X[min._dsti])
{
/*构成环的话就可以跳过*/
//cout << "构成环:";
//cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
}
else
{
minTree._AddEdge(min._srci, min._dsti, min._w);
//cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
X[min._dsti] = true;
Y[min._dsti] = false;
++size;
totalW += min._w;
if (size == n - 1)
break;
for (size_t i = 0; i < n; ++i)
{
//下面的不可以是在Y集合中的,因为Y集合中true表示后面会构成环
if (_matrix[min._dsti][i] != MAX_W && Y[i])
{
minq.push(Edge(min._dsti, i, _matrix[min._dsti][i]));
}
}
}
}
if (size == n - 1)
{
return totalW;
}
else
{//没选到n-1条边
return W();
}
}
最后这里的显示选边过程
最短路径
最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是
沿路径各边的权值总和达到最小。
单源最短路径(Dijkstra)
给定一个图和图中的一个源顶点,找到从源到给定图中所有顶点的最短路径
Dijkstra的要求
单源最短路径问题:给定一个图G = ( V , E )
,求源结点s ∈ V
到图中每个结点v ∈ V
的最短路径。
Dijkstra算法就适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。一般在求解最短路径的时候都是已知一个起点和一个终点,所以使用Dijkstra算法求解过后也就得到了所需起点到终点的最短路径。
Dujkstra算法
Dijkstra的算法与Prim 的最小生成树算法非常相似。与 Prim 的 MST 一样,我们生成以给定源为根的SPT(最短路径树)。
来自算法导论的图解:
贪心
针对一个带权有向图G,将所有结点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点s放入,毕竟源节点到自己的代价是0),Q 为其余未确定最短路径的结点集合,每次从Q 中找出一个起点到该结点代价最小的结点u ,将u 从Q 中移出,并放入S中,对u 的每一个相邻结点v 进行松弛操作。Dijkstra算法每次都是选择V-S中最小的路径节点来进行更新,并加入S中,所以该算法使用的是贪心策略。
松弛
松弛即对每一个相邻结点v ,判断源节点s到结点u的代价与u 到v 的代价之和是否比原来s 到v 的代价更小,若代价比原来小则要将s 到v 的代价更新为s 到u 与u 到v 的代价之和,否则维持原样。如此一直循环直至集合Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。
Dijkstra的问题
Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。
Dijkstra实现
在实现之前我们来看一下需要涉及到的存储结构:首先我们让每一个顶点都有一个坐标,然后开辟一个数组,这个数组存储这从我们目标出发点到其他顶点的最短路径,以及一个pPath数组,默认-1,存储着该路径的前一个顶点的下标
初始状态:*
可以表示为我们距离其他点现在是最大值,即还没有找到相连,现在我们假设其实点位是s,且我们的图就是下图为例的一个有向图
下面更新我们的存储数组,找到和s相连的边,分别更新最近的权值,和前一个点的坐标
接着再往下更新y相连的点和t相连的点,发现从y走到t更近,那就继续更新,为了保证疏松算法,我们借助了pPath数组知道上一个经过的点,来判断最短距离的更新,更新的时候就直接减去对应下标,再去比较
几次更新之后找到最优路径
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;
size_t parenti = i;
while (parenti != srci)
{
path.push_back(parenti);
parenti = pPath[parenti];
}
path.push_back(srci);
reverse(path.begin(), path.end());
for (auto index : path)
{
cout << _vertexs[index] << "->";
}
cout << dist[i] << endl;
}
}
}
void Dijkstra(const V &src, vector<W> &dist, vector<int> &pPath)
{
size_t srci = GetVertexIndex(src);
size_t n = _vertexs.size();
dist.resize(n, MAX_W);
pPath.resize(n, -1);
dist[srci] = 0;
pPath[srci] = srci;
// 已经确定最短路径的顶点集合
vector<bool> S(n, false);
for (size_t j = 0; j < n; ++j)
{
// 选最短路径顶点且不在S更新其他路径
int u = 0;
W min = MAX_W;
for (size_t i = 0; i < n; ++i)
{
if (S[i] == false && dist[i] < min)
{
u = i;
min = dist[i];
}
}
S[u] = true;
// 松弛更新u连接顶点v srci->u + u->v < srci->v 更新
for (size_t v = 0; v < n; ++v)
{
if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
{
dist[v] = dist[u] + _matrix[u][v];
pPath[v] = u;
}
}
}
}
测试用例
void TestGraphDijkstra()
{
const char* str = "syztx";
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);
g.PrintShortPath('s', dist, parentPath);
// 图中带有负权路径时,贪心策略则失效了。
// 测试结果可以看到s->t->y之间的最短路径没更新出来
/* const char* str = "sytx";
Graph<char, int, INT_MAX, true> g(str, strlen(str));
g.AddEdge('s', 't', 10);
g.AddEdge('s', 'y', 5);
g.AddEdge('t', 'y', -7);
g.AddEdge('y', 'x', 3);*/
/* vector<int> dist;
vector<int> parentPath;
g.Dijkstra('s', dist, parentPath);
g.PrintShortPath('s', dist, parentPath);*/
}
如果带有负权的话,出问题了
实际s到y最短是10-7=3,但是由于贪心算法,认为从起始点出发,相连的边局部最短的就一定是全局最短的
###负权图的单源最短路径(BellmanFord)
BellmanFord算法
Dijkstra算法只能用来解决正权图的单源最短路径问题,但有些题目会出现负权图。这时这个算法就不能帮助我们解决问题了,而bellman—ford算法可以解决负权图的单源最短路径问题。它的优点是可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。它也有明显的缺点,它的时间复杂度 O(N*E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N3),这里也可以看出来Bellman-Ford就是一种暴力求解更新。
来自算法导论的图解:
BellmanFord实现
BellmanFord的难点在于,有可能更新得到最短路径之后会影响其他的路径,所以说直接对每一条可能存在的路径需要再做一次遍历,其中这个遍历操作优点类似于冒泡算法,我们设置一个bool的flag,如果说更新过松弛的话,那么后序就需要把所有相关涉及路径也要做出修改,反之如果没有修改过的话,就不需要循环,直接跳出就可以了
// 时间复杂度:O(N^3) 空间复杂度:O(N)
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& pPath)
{
size_t n = _vertexs.size();
size_t srci = GetVertexIndex(src);
// vector<W> dist,记录srci-其他顶点最短路径权值数组
dist.resize(n, MAX_W);
// vector<int> pPath 记录srci-其他顶点最短路径父顶点数组
pPath.resize(n, -1);
// 先更新srci->srci为缺省值
dist[srci] = W();
//cout << "更新边:i->j" << endl;
// 总体最多更新n轮
for (size_t k = 0; k < n; ++k)
{
// i->j 更新松弛
bool update = false;
cout << "更新第:" << k << "轮" << endl;
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
// srci -> i + i ->j
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
update = true;
cout << _vertexs[i] << "->" << _vertexs[j] << ":" << _matrix[i][j] << endl;
dist[j] = dist[i] + _matrix[i][j];
pPath[j] = i;
}
}
}
// 如果这个轮次中没有更新出更短路径,那么后续轮次就不需要再走了
if (update == false)
{
break;
}
}
// 还能更新就是带负权回路
for (size_t i = 0; i < n; ++i)
{
for (size_t j = 0; j < n; ++j)
{
// srci -> i + i ->j
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
{
return false;
}
}
}
return true;
}
BellmanFord也不是所有情况都能解决,对于负权回路来说,一旦出现,就是神仙难救了
Bellmanford优化
多源最短路径(Floyd-Warshall)
Floyd-Warshall算法是解决任意两点间的最短路径的一种算法。Floyd算法考虑的是一条最短路径的中间节点,即简单路径p={v1,v2,…,vn}上除v1和vn的任意节点。
设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算法本质是三维动态规划,D[i][j][k]表示从点i到点j只经过0到k个点最短路径,然后建立
起转移方程,然后通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后即得
到所以点的最短路。
算法导论中的算法图解:
Floyd-Warshall实现
void FloydWarshall(vector<vector<W>> &vvDist, vector<vector<int>> &vvpPath)
{
size_t n = _vertexs.size();
vvDist.resize(n);
vvpPath.resize(n);
// 初始化权值和路径矩阵
for (size_t i = 0; i < n; ++i)
{
vvDist[i].resize(n, MAX_W);
vvpPath[i].resize(n, -1);
}
// 直接相连的边更新一下
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)
{//如果是自己的话就初始化为0
vvDist[i][j] = W();
}
}
}
// abcdef a {} f || b {} c
// 最短路径的更新i-> {其他顶点} ->j
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)
{
// 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", 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;
}
}
样题
需要层序遍历解决问题,可以使用BFS算法
参考资料:
Dijkstra’s Shortest Path Algorithm - A Detailed and Visual Introduction