这篇文章很早就写好了,因为中间在研究一些算法可视化的技术,想着将图论相关的一些算法以可视化的方式呈现出来应该会很酷。后面又经历了换工作等一连串的事就给耽搁了,一直拖到现在。
图是一种高级数据结构,有时候我也叫图论,通常说的图论是包含了图这种数据结构和图相关的各种算法。图论是一个很大的话题,相关的研究和论文也是非常的多,我个人觉得图论讲得比较好的教材是《离散数学及其应用》这本书里面对图的讲解,如果对数学不感冒可以跳过数学证明的部分,逻辑依然还是完整的,可以作为这篇文章的补充资料。
首先和基础数据结构一样,我们需要先建出一张图,然后才能在此基础上对其进行相应的各种操作。这篇文章我们使用两种经典的建图方式分别是邻接矩阵和邻接表,下面我们就来分别对这两种建图的方式进行说明。
邻接矩阵
使用一个二维数组来表示图,这样我们就在二维平面上就建立起了一个平面坐标系,只不过我们只关注x>=0且y>=0的区域,如下图所示:
图中,红色圆叫作图的节点(或者有些人叫顶点)一张图里的节点集合我们可以使用V(G)来表示,节点与节点之间的连线叫做边,一张图中所有边的集合我们可以使用E(G)来表示,其中G代表就是一张图,V代表节点,E代表边。和节点相连的边的数量我们叫节点的度,比如上图中节点1的度为3,节点0的度为1。如果我们要将节点0和1连接在一起,那么我们只需要将[0][1]和和[1][0]所在的位置填上1就可以了,如果是要断开连接就重新置回0,通过这种方式我们就可以表示节点与节点之间的连接关系了。
要注意的是,上面的图是一个无向无权图也就是节点与节点之间是没有方向的,每条边之间也没有权重之分。关于有向无权图和有向带权图后面我们在讲最小生成树和最短路径算法的时候会再介绍。
可以看到对于邻接矩阵实现的图有很多空的位置,也就是值为0的地方,想象一下,如果这张图比较大,而边E(G)也就是边比较少的情况下,会造成大量空间的浪费。
下面是邻接矩阵建图的核心代码:
class DenseGraph {
private:
int n; // 表示节点数
int m; // 表示边数
bool directed; // 是否是有向图
vector<vector<bool>> g; // 邻接矩阵
public:
DenseGraph(int n, bool directed) {
this->n = n;
this->m = 0;
this->directed = directed;
for (int i = 0; i < n; i ++)
g.push_back(vector<bool>(n, false));
}
~DenseGraph() {}
// 获取节点数
int V() { return n;}
// 获取边数
int E() { return m;}
// 添加一条边
void addEdge(int v, int w) {
assert(v >= 0 && v < n);
assert(w >= 0 && w < n);
if (hasEdge(v, w))
return;
g[v][w] = true;
// 如果是无向图,需要互相连接
if (!directed)
g[w][v] = true;
m++;
}
bool hasEdge(int v, int w) {
assert(v >= 0 && v < n);
assert(w >= 0 && w < n);
return g[v][w];
}
};
这里要说明一下,之前的代码都是使用java,这里我们使用的是c++,这主要是前段时间在用c++写项目。所以,在准备这个系列的代码的时候也就顺便用c++写了。但这应该不影响整体逻辑,相信还是很好理解的。在我的github项目里也有java版本的实现,你可以在文章结尾找到链接。
邻接表
接下来,我们介绍图的另一种实现方式——邻接表。邻接表也是使用一个数组来维护所有节点,数组对应的每个元素是一个链表或者动态数组,链表用来表示当前节点都和谁相连,如下图:
我们看到,和1相连的有0、3、4,对应的下标为0、2、3,那么其对应的链表里就维护0、2、3来表示和这些节点相连。然后我们看节点3,因为节点1、4和它各有一条边,对应的下标为1和2,所以3对应的链表就是1、2。通过这种方式我们就能得到两个节点之间的连接关系了。
下面是邻接表实现的核心代码:
class SparseGraph {
private:
int n; // 节点数
int m; // 边数
bool directed; // 是否是有向图
vector<vector<int>> g; // 邻接表
public:
SparseGraph(int n, bool directed) {
this->n = n;
this->m = 0;
this->directed = directed;
g = vector<vector<int>>(n, vector<int>());
}
~SparseGraph() {}
int V() {
return n;
}
int E() {
return m;
}
void addEdge(int v, int w) {
assert(v >= 0 && v < n);
assert(w >= 0 && w < n);
g[v].push_back(w);
if (v != w && !directed)
g[w].push_back(v);
m++;
}
bool hasEdge(int v, int w) {
assert(v >= 0 && v < n);
assert(w >= 0 && w < n);
for (int i = 0; i < n; i ++)
if (g[v][i] == w)
return true;
return false;
}
};
我们看到,在初始化图的时候,邻接矩阵是初始化了n*n大小的二维数组,n表示节点数量。而邻接表只初始化了一个一维数组,数组中每个元素是一个容量为0的vector,具体原因我们前面已经解释过了,你可以对照代码再过一遍,这是邻接矩阵和邻接表最大的不同。另外在判断两个节点是否有边的时候,邻接矩阵直接使用下标就可以定位到,而邻接表是使用了一个循环来遍历vector,如果存在就表示他们之间是有一条边的。
那么,这两种实现方式分别有什么优势或者缺点呢?
实际上,从稠密程度上,图又分为了稠密图和稀疏图,什么意思呢?稠密图就是尽可能多的将节点之间都相互连接起来的图。那么相反,稀疏图就是节点之间的连接可能不是那么多,换句话说就是图中的边的数量不多。这样还是有些抽象,我们来看一张图:
图中左边的是一张稀疏图,右边是一张稠密图,可以看到右边那张图每个节点几乎都和其它节点有一条边,对应我们上面邻接矩阵的实现方式想象一下,此时二维数组里所有的位置都是1,我们把二维数组里所有的位置都填满了,再看左边的图,虽然有些节点有很多边,但相对节点的数量来讲,我们的二维数组里依然会有非常多的空的位置,也就是等于0的位置。
对于稠密图,因为节点之间尽可能多的连接在一起,如果我们使用邻接表来实现,那每个节点对应的链表都会很长,我们在判断两个节点之间是否存在边就会遍历大量的数据,从而影响查询的效率。这种情况下如果我们使用邻接矩阵,判断两个节点之间是否连接只需要使用对应的下标访问二维数组的元素就可以了,非常高效。所以,稠密图比较理想的实现方式是使用邻接矩阵。那么对于稀疏图来讲,如果我们使用邻接矩阵来实现的话就会出现大量空的位置,造成空间的浪费,这时邻接表就派上用场了,每个元素对应一个链表,只有两个节点相连才会记录下来,在边不是很多的情况下,邻接表更加合适。
讲完了如何建图,我们接下来再看一下如何遍历一张图,图的遍历通常也有两种方式,深度优先遍历和广度优先遍历。
深度优先遍历
深度优先遍历,是一次性遍历到底,然后再回来从下一个节点开始遍历,显然这是一个天然的递归过程,我们看一个例子,如图:
要注意的是,我们在遍历的过程中,要将已经遍历过的节点记录下来,下次再遍历到的时候要判断一下,是否已经遍历过,如果遍历过就不用再遍历了。
下面是深度优先遍历的核心代码:
private void dfs(int v) {
visited[v] = true;
// 深度优先前序遍历
pre.add(v);
for (int w: g.adj(v))
if (!visited[w])
dfs(w);
// 深度优先后序遍历
post.add(v);
}
深度优先遍历的核心点是需要一个额外的数组来记录节点是否被访问过,代码里我们使用的是visited数组,元素对应的是布尔值,如果访问过就置为true, 只有没被访问过才会进行下一轮递归。
广度优先遍历
和树的广度优先遍历一样,图的广度优先遍历同样需要借助队列来完成,我们看下面的例子,如图:
首先将0放入队列
然后将0取出来放到我们的结果里,再把和0连接的所有节点依次放入队列,如下图:
然后,我们按入队顺序将1、2、5、6出队。
当我们拿到节点5的时候,我们发现还有3和4和它是相连的,此时我们将3和4依次入队,如下图:
然后我们将6、3、4依次出队,如下图:
此时,我们就得到了广度优先遍历得到的结果。
核心代码如下:
ShortPathBFS(Graph &graph, int s):G(graph) {
assert(s >= 0 && s < graph.V());
this->s = s;
visited = new bool[graph.V()];
from = new int[graph.V()];
ord = new int[graph.V()];
for (int i = 0; i < graph.V(); i ++) {
visited[i] = false;
from[i] = -1;
ord[i] = -1;
}
queue<int> q;
q.push(s);
visited[s] = true;
ord[s] = 0;
while(!q.empty()) {
int v = q.front();
q.pop();
typename Graph::adjIterator adj(G, v);
for (int i = adj.begin();
!adj.end();
i = adj.next()) {
if (!visited[i]) {
q.push(i);
visited[i] = true;
from[i] = v;
ord[i] = ord[v] + 1;
}
}
}
}
好了,图的遍历我们就讲到这里了,下面我们介绍图论中两类经典的算法,最小生成树和最短路径。
在开始之间,我们先搞清楚一个概念,就是连通分量,连通分量就是一张图中互不相连的区域的个数,我们来看一张图就明白了:
上图中,连通分量就是3,因为其有三个部分是互不相连的。
最小生成树
要搞明白最小生成树算法,我们先要弄清楚一个概念,叫切分定理,我们将一张图切分成两部分,满足切分后两个端点分属切分之后的两部分,就叫做切分定理,两部分的节点之间的边叫横切边。那么最小生成树就是给定任意切分,横切边中权值最小的边必然属于最小生成树,看完这段话你可能心中无数CNM奔腾而过。没关系,我们看一张图就明白了,如图:
图中,红色和蓝色就是被切后的两部分,其中蓝色和红色节点之间的边就是横切边,用绿色表示。
好了,我们搞清楚了最小生成树是什么之后,我们再来看前面讲到的连通分量又有什么用呢?切分定理说“满足切分后两个端点分属切分之后的两部分”。显然,如果连通分量为2是无法满足切分定理的。所以,最小生成树只能存在于连通分量等于1的图里面。
搞清楚了切分定理、和连通分量这些概念之后,我们还要解决一个问题,那就是实现一个有向带权图,我们前面实现的图节点与节点之间的边是没有方向也没有权值的。接下来我们就来实现一个有向带权图。首先,我们声明一个Edge对象,用来表示每条边,代码如下:
class Edge {
private:
int a, b; // 边的两个端点
Weight weight;
public:
Edge(int a, int b, Weight weight) {
this->a = a;
this->b = b;
this->weight = weight;
}
Edge(){}
~Edge(){}
int v() {return a;} // 返回第一个顶点
int w() {return b;} // 返回第二个顶点
Weight wt(){return weight;} // 返回权值
// 给定一个顶点,获取另外一个顶点
int other(int x) {
assert(x == a || x == b);
return x == a ? b : a;
}
// 比较运算符重载
bool operator<(Edge<Weight>& e){
return weight < e.wt();
}
bool operator<=(Edge<Weight>& e){
return weight <= e.wt();
}
bool operator>(Edge<Weight>& e){
return weight > e.wt();
}
bool operator>=(Edge<Weight>& e){
return weight >= e.wt();
}
bool operator==(Edge<Weight>& e){
return weight == e.wt();
}
};
每条边都有两个端点a和b,还有一个权重值weight,这样每条边就可以有不同的权重值了,接下来我们来看一下,如何使用Edge类来实现一张图,代码如下:
class WeightDenseGraph {
private:
int n; // 表示节点数
int m; // 表示边数
bool directed; // 是否是有向图
vector<vector<Edge<Weight> *>> g;
public:
WeightDenseGraph(int n, bool directed) {
assert(n >= 0);
this->n = n;
this->m = 0;
this->directed = directed;
g = vector<vector<Edge<Weight> *>>(n, vector<Edge<Weight> *>(n, NULL));
}
// 获取节点数
int V() { return n;}
// 获取边数
int E() { return m;}
// 添加一条边
void addEdge(int v, int w, Weight weight) {
assert(v >= 0 && v < n);
assert(w >= 0 && w < n);
// 防止重复计算
if (hasEdge(v, w)) {
delete g[v][w];
if (v != w && !directed)
delete g[w][v];
m--;
}
g[v][w] = new Edge<Weight>(v, w, weight);
// 如果是无向图,需要互相连接
if (v != w && !directed)
g[w][v] = new Edge<Weight>(w, v, weight);
m++;
}
bool hasEdge(int v, int w) {
assert(v >= 0 && v < n);
assert(w >= 0 && w < n);
return g[v][w] != NULL;
}
};
我们剔除了没用的代码,成员变量g现在是一个元素类型为Edge的二维数组,在添加边的时候,我们需要先声明一个Edge对象,放到我们的邻接矩阵里去。还有个小细节,就是使用了一个成员变量directed来表示图的方向,如果是无向图,那我们需要维护两个方向的边,如果是有向图,我们只需要维护传进来的节点方向上的边就可以了。
好了,前置知识就已经介绍完了。那么,最小生成树算法具体要怎么实现呢?下面我们来介绍几种最经典的最小生成树算法。
Lazy Prim 算法
Lazy Prim算法我们需要借助一个小顶堆,先遍历所有节点,然后放入小顶堆,使用栈遍历拿到顺序的结果。下面是核心代码:
class LazyPrimMST {
private:
Graph &G; // 图的引用
MinHeap<Edge<Weight>> pq; // 最小堆
bool *marked; // 标记数组,在运行过程中标记是否被访问过
vector<Edge<Weight>> mst; // 最小生成树包含的所有边
Weight mstWeight; // 最小生成树的权值
void visit(int v) {
assert(!marked[v]);
marked[v] = true;
typename Graph::adjIterator adj(G, v);
for (Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next())
if (!marked[e->other(v)])
pq.insert(*e);
}
public:
LazyPrimMST(Graph &graph): G(graph), pq(MinHeap<Edge<Weight>>(graph.E())) {
marked = new bool[G.V()];
for (int i = 0; i < G.V(); i ++)
marked[i] = false;
mst.clear();
visit(0);
while(!pq.isEmpty()) {
// 使用最小堆找出已经访问的边中权值最小的边
Edge<Weight> e = pq.extractMin();
// 如果这条边的两端都已经访问过了,则扔掉这条边
if (marked[e.v()] == marked[e.w()])
continue;
// 否则,这条边则应该存在最小生成树中
mst.push_back(e);
// 访问和这条边连接的还没有被访问过的节点
if (!marked[e.v()])
visit(e.v());
else
visit(e.w());
}
// 计算最小生成树的权值
mstWeight = mst[0].wt();
for(int i = 1; i < mst.size(); i ++)
mstWeight += mst[i].wt();
}
~LazyPrimMST() {
delete[] marked;
}
vector<Edge<Weight>> mstEdges() {
return mst;
}
Weight result() {
return mstWeight;
}
};
Prim算法
Prim算法使用到了一个索引小顶堆,同样遍历所有节点,然后放入小顶堆,使用栈遍历拿到顺序结果,代码如下:
class PrimMST {
Graph &G; // 图的引用
IndexMinHeap<Weight> ipq; // 最小索引堆,算法辅助数据结构
vector<Edge<Weight>*> edgeTo; // 访问的点所对应的边,算法辅助数据结构
bool* marked; // 标记数组,在算法运行过程中标记节点i是否被访问
vector<Edge<Weight>> mst; // 最小生成树所包含的所有边
Weight mstWeight; // 最小生成树的权值
void visit(int v) {
assert(!marked[v]);
marked[v] = true;
// 将各节点v相连接的未访问的另一端点,和与之相连的边,放入最小堆中
typename Graph::adjIterator adj(G, v);
for (Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next()) {
int w = e->other(v);
// 如果边的另一端点未被访问
if (!marked[w]) {
// 如果从没有考虑过这个端点,直接将这个端点和与之相连的边加入索引堆
if (!edgeTo[w]) {
edgeTo[w] = e;
ipq.insert(w, e->wt());
}
// 如果曾经考虑这个端点,但现在的边比之前考虑的边更短,则进行替换
else if (e->wt() < edgeTo[w]->wt()) {
edgeTo[w] = e;
ipq.change(w, e->wt());
}
}
}
}
public:
PrimMST(Graph &graph):G(graph), ipq(IndexMinHeap<double>(graph.V())) {
assert(graph.E() >= 1);
marked = new bool[G.V()];
for(int i = 0; i < G.V(); i ++) {
marked[i] = false;
edgeTo.push_back(NULL);
}
mst.clear();
visit(0);
while(!ipq.isEmpty()) {
// 使用最小索引堆找出已经访问的边中权值最小的边
// 最小索引堆中存储的是点的索引,通过点的索引找到相对的边
int v = ipq.extractMinIndex();
assert(edgeTo[v]);
mst.push_back(*edgeTo[v]);
visit(v);
}
mstWeight = mst[0].wt();
for(int i = 0; i < mst.size(); i ++)
mstWeight += mst[i].wt();
}
~PrimMST() {
delete[] marked;
}
vector<Edge<Weight>> mstEdges() {
return mst;
}
Weight result() {
return mstWeight;
}
};
Kruskal算法
Kruskal算法会使用一个小顶堆和并查集,先使用小顶堆排序,然后判断两个节点是否连通,代码如下:
class KruskalMST {
vector<Edge<Weight>> mst;
// 最小生成树所包含的所有边
Weight mstWeight;
// 最小生成树的权值
public:
KruskalMST(Graph &graph) {
// 将图中的所有边存放到一个最小值,达到排序的效果
MinHeap<Edge<Weight>> pq(graph.E());
for (int i = 0; i < graph.V(); i ++) {
typename Graph::adjIterator adj(graph, i);
for (Edge<Weight> *e = adj.begin(); !adj.end(); e = adj.next())
if (e->v() < e->w())
pq.insert(*e);
}
// 创建一个并查集,来查看已经访问过的节点是存在环
UnionFind uf = UnionFind(graph.V());
while(!pq.isEmpty() && mst.size() < graph.V() - 1) {
// 从最小堆中依次从小到大取出所有的边
Edge<Weight> e = pq.extractMin();
// 如果该边的两个端点是联通的,说明加入这条边将产生环,扔掉这条边
if (uf.isConnect(e.v(), e.w()))
continue;
// 否则,将这条边添加进最小生成树,同时标记边的两个元素的连通
mst.push_back(e);
uf.unionElm(e.v(), e.w());
}
mstWeight = mst[0].wt();
for (int i = 0; i < mst.size(); i ++)
mstWeight += mst[i].wt();
}
~KruskalMST(){}
vector<Edge<Weight>> mstEdges() {
return mst;
}
Weight result() {
return mstWeight;
}
};
最后我们分析一下最小生成树的几种算的时间复杂度:
介绍完了最小生成树之后,我们接着来介绍图论中最经典的算法——求最短路径
Dijkstra算法
相信只要是学习图论一定听过这个算法,使用Dijkstra算法的前提是图中不能有负权边,Dijkstra算法的核心是松驰操作,什么是松驰操作呢?松驰操作就是尝试从别的点到目标点如果比直接到目标点距离更近,那么就成功找到了一条更短的路径。如果你学习过贪心算法一定觉得非常熟悉,实际上,Dijstra算法就是一种经典的贪心算法。
下面我们尝试寻找节点0到所有节点的最短路径。首先,我们先遍历一遍所有相邻节点。此时,我们可以通过判断边的权值,找到当前最短的那一条边。
上图中,经过一轮遍历,我们可以得到如下结论
- 0~0的最短路径是0
- 0~1的最短路径暂时为5
- 0~2的最短路径为2
- 0~3的的最短路径暂时为6
上面00的路径和02的路径有没有可能更小呢?答案是不可能,因为前面我们限定了条件,Dijkstra算法不能负权边,也就是说每条边的权值一定是大于等于0的,所以不可能经过另外一条边到达当前节点的路径会比直接到达当前节点的距离更小。
接下来就是Dijkstra算法最核心的松驰操作了,我们上一步说0~1的最短路径暂时为5,这是因为我们还需要进行一轮松驰操作,我们再遍历一遍和2相邻的节点,然后我们发现经过节点2到节点1比直接从节点0到节点1路径要短。
此时,我们通过一轮松驰操作就找到了一条比0直接到节点1更短的一条路径,路径的长度为2+1=3。由于节点1不能通过其它节点再回到自己得到一条更短的路径。所以,最后我们得到0~1的最短路径为3。
这一轮松驰操作还没有结束,我们继续看节点3。同样的,我们经过节点2再回到节点3比直接从节点0到节点3要更近,距离为2+3=5,所以我们又找到了一条节点0到节点3更短的路径,最终0~3的最短路径为5。
好了,到这里和0相邻的节点我们都已经找到最短路径,还剩下节点4,在上一轮松驰操作我们已经遍历了和2相邻的所有节点,我们暂时认为节点2到节点4的最短路径是5,然后我们进行一轮松驰操作。
经过第二轮松驰操作,我们发现,节点2到节点4最短路径是节点2经过节点1再到节点4,距离为1+1=2,由于02的最短距离为2,所以04的最短距离就是2+2=4。体会一下这个地方,其实这就是Dijkstra算法对贪心算法的应用。
我们前面说,Dijkstra算法不能有负权边,为什么呢?我们看下面这张图,下面这张图在节点1到节点2这个方向上有一条权值为-2的边,我们套用上面的寻路过程。假设此时我们遍历了0的相邻节点暂时得到02有最短路径为2,然后我们进行一轮松驰操作,发现01~2这条路径的距离为1,我们就得到了一条更小的路径,又回到了节点2,如果我们再进行一轮,经过节点1回来又会得到更小的一条路径,这样就永远有一条更小的路径,陷入死循环了。所以,Dijkstra是不能有负权边的。
最后我来看一下代码实现:
class Dijkstra {
Graph &G; // 图
int s; // 源点,路径的开始节点
Weight *distTo; // distTo[i] 存储从起始点s到i的最短路径
bool *marked; // 标记数组,在算法运行过程中标记节点i是否被访问
vector<Edge<Weight>*> from; // from[i] 记录最短路径中,到达i点的边是哪一条,可以用来恢复整个最短路径
public:
Dijkstra(Graph &graph, int s):G(graph) {
assert(s >= 0 && s < G.V());
this->s = s;
distTo = new Weight[G.V()];
marked = new bool[G.V()];
// 初始化
for (int i = 0; i < G.V(); i ++) {
distTo[i] = Weight(); // Weight()获取泛型的默认值
marked[i] = false;
from.push_back(NULL);
}
// 使用索引堆记录当前找到的到达每个顶点的最短距离
IndexMinHeap<Weight> ipq(G.V());
// 对于起始点s进行初始化
distTo[s] = Weight();
from[s] = new Edge<Weight>(s, s, Weight());
ipq.insert(s, distTo[s]);
marked[s] = true;
while(!ipq.isEmpty()) {
int v = ipq.extractMinIndex();
// distTo[v]就是s到v的最短距离
marked[v] = true;
// 对v的所有相邻节点进行更新
typename Graph::adjIterator adj(G, v);
for (Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next()) {
int w = e->other(v);
// 如果从s点到w点的最短路径还没有找到
if (!marked[w]) {
// 如果w点以前没有访问过
// 或者访问过,但是通过当前的v点到w点距离更短,则进行更新
if (from[w] == NULL || distTo[v] + e->wt() < distTo[w]) {
distTo[w] = distTo[v] + e->wt();
from[w] = e;
if (ipq.contain(w))
ipq.change(w, distTo[w]);
else
ipq.insert(w, distTo[w]);
}
}
}
}
}
~Dijkstra() {
delete[] distTo;
delete[] marked;
delete from[0];
}
// 返回从s点到w点的最短路径长度
Weight shortPathTo(int w) {
assert(w >= 0 && w < G.V());
assert(hasPathTo(w));
return distTo[w];
}
// 判断从s点到w点是否联通
bool hasPathTo(int w) {
assert(w >= 0 && w < G.V());
return marked[w];
}
void shortPath(int w, vector<Edge<Weight>> &vec) {
assert(w >= 0 && w < G.V());
assert(hasPathTo(w));
// 通过from数组逆向找到从s到w的路径,存放到栈中
stack<Edge<Weight>*> s;
Edge<Weight> *e = from[w];
while(e->v() != this->s) {
s.push(e);
e = from[e->v()];
}
s.push(e);
// 从栈中依次取出元素,获得顺序的从s到w的路径
while(!s.empty()) {
e = s.top();
vec.push_back(*e);
s.pop();
}
}
void showPath(int w) {
assert(w >= 0 && w < G.V());
assert(hasPathTo(w));
vector<Edge<Weight>> vec;
shortPath(w, vec);
for (int i = 0; i < vec.size(); i ++) {
cout << vec[i].v()<< "->";
if (i == vec.size()-1) {
cout << vec[i].w()<<endl;
}
}
}
};
Bellman-Ford算法
前面我们讲Dijkstra不能有负权边,Bellman-Ford就没有这个限制,但Bellman-Ford不能有负权环。实际上即使有负权环算法也是可以正常运行的,我们后面再来分析为什么。什么是负权环呢?如下图:
通过对节点进行多次松驰操作之后又回到原节点,就说明出现了负权环。这是因为负权环会无限求得最小路径,陷入死循环。但我们可以分析一下,假设一张图里面有V个节点,V-1条边,那么理论上我们把所有的边都遍历一遍就应该得到一条最短路径,如果遍历了V-1条边,我们再进行一轮松驰操作,如果还能找到最短路径,就说明出现了负权环。Bellman-Ford算法就是通过这个原理来实现即使有负权环算法也能正常运行的。
下面我们来看一下Bellman-Ford算法的原理,Bellman-Ford算法的核心思想也是松驰操作,假设我们要找02的最短路径,肉眼看下来,02的最短路径是1,因为12的方向上有一条负权边值为-4。和Dijkstra算法一样,我们首先找到0的所有邻边,暂时得到02的最短路径为2。
然后我们进行一轮松驰操作。此时,我们找到了一条012的路径,比我们第一次找的最短路径2要小,而经过节点3之后的所有节点都不可能比这个更小。所以,最终0~2的最短路径就是1。
要注意Dijkstra和Bellman-Ford之间的差别,最大的差别就是Bellman-Ford是支持负权边的,你可以对照代码仔细体会一下。
最后我们看一下代码实现:
class BellmanFord{
private:
Graph &G; // 图
int s; // 起始点
Weight* distTo; // distTo[i]存储从起始点s到i的最短路径长度
vector<Edge<Weight>*> from; // from[i]记录最短路径中,到达i点的边是哪一条
bool hasNegativeCycle; // 标记图中是否有负权环
bool detectNegativeCycle() {
for (int i = 0; i < G.V(); i ++) {
typename Graph::adjIterator adj(G, i);
for (Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next()) {
if (from[e->v()] && distTo[e->v()] + e->wt() < distTo[e->w()])
return true;
}
}
return false;
}
public:
BellmanFord(Graph &graph, int s):G(graph) {
this->s = s;
distTo = new Weight[G.V()];
// 初始化所有的节点s都不可达,由from数组来表示
for (int i = 0; i < G.V(); i ++)
from.push_back(NULL);
// 设置distTo[s] = 0, 并且from[s]不为NULL,表示初始节点可达且距离为0
distTo[s] = Weight();
// 这里我们from[s]的内容是new出来的,注意要在析构函数delete
from[s] = new Edge<Weight>(s, s, Weight());
// Bellman-Ford的过程
// 进行v-1次循环,每一次循环求出从起点到其余所有点,最多使用pass步可到达的最短距离
for (int pass = 1; pass < G.V(); pass ++) {
// 每次循环中对所有边进行一遍松弛操作
// 遍历所有边的方式是先遍历所有的顶点,然后遍历和所有顶点相邻的所有边
for (int i = 0; i < G.V(); i ++) {
typename Graph::adjIterator adj(G, i);
for (Edge<Weight>* e = adj.begin(); !adj.end(); e = adj.next()) {
// 对于每一个边首先判断e->v()可达
// 之后看如果e->w()以前没有到达过,显然我们可以更新distTo[e->w()]
// 或者e->w()以前虽然到过,但是通过这个e我们可以获得一个更短的距离,即可以进行一次松弛操作,我们可以更新distTo[e->w()]
if (from[e->v()] && (!from[e->w()] || distTo[e->v()] + e->wt() < distTo[e->w()])) {
distTo[e->w()] = distTo[e->v()] + e->wt();
from[e->w()] = e;
}
}
}
}
hasNegativeCycle = detectNegativeCycle();
}
~BellmanFord(){
delete[] distTo;
delete from[s];
}
bool negativeCycle() {
return hasNegativeCycle;
}
Weight shortPathTo(int w) {
assert(w >= 0 && w < G.V());
assert(!hasNegativeCycle);
assert(hasPathTo(w));
return distTo[w];
}
bool hasPathTo(int w) {
assert(w >= 0 && w < G.V());
return from[w] != NULL;
}
void shortPath(int w, vector<Edge<Weight>> &vec) {
assert(w >= 0 && w < G.V());
assert(!hasNegativeCycle);
assert(hasPathTo(w));
stack<Edge<Weight>*> s;
Edge<Weight> *e = from[w];
while(e->v() != this->s) {
s.push(e);
e = from[e->v()];
}
s.push(e);
while(!s.empty()) {
e = s.top();
vec.push_back(*e);
s.pop();
}
}
void showPath(int w) {
assert(w >= 0 && w < G.V());
assert(!hasNegativeCycle);
assert(hasPathTo(w));
vector<Edge<Weight>> vec;
shortPath(w, vec);
for (int i = 0; i < vec.size(); i ++) {
cout << vec[i].v()<< "->";
if (i == vec.size()-1)
cout << vec[i].w()<< endl;
}
}
};
最短路径算法时间复杂度对比
| Dijkstra | 有向无向图均可 | O(ElogV) |
|---|---|---|
| Bellman-Ford | 有向图 | O(EV) |
由于Bellman-Ford算法最坏的情况下需要对每个顶点进行V-1次松驰操作,所以最终时间复杂度为O(EV),这个时间复杂度是比Dijkstra的时间复杂度高很多的。
下面,我用Java可视化编程实现了一些小例子,限于篇幅,我就不详细展开了,有兴趣可以看github查看详细的实现代码。
生成随机迷宫
自动走迷宫找到最短路径
扫雷游戏
总结一下
这篇文章我们介绍了图的两种实现方式,介绍了两类经典的图论算法,最小生成树和最短路径,但图论的应用远远不止这些,比如单源路径求解、求路径长度、哈密尔顿问题、拓扑排序、最大网络流、欧拉路径等等问题。
在这篇文章里我们讲的邻接矩阵和邻接表的实现的方式,只是实现图的其中两种学习中比较经典的方式,实际上图的实现还可以使用我们前面讲的AVL树,红黑树这类数据结构,在实际工程中可能有更实际的意义。
在图论中,用到了很多基础的、高阶的数据结构,比如数组、链表、红黑树、并查集、小顶堆、索引堆、队列、栈等,这也是为什么大家说起图论都觉得它比较难的原因吧!因为这首先需要掌握大量的基础、进阶的数据结构和算法,然后还要灵活的将这些数据结构和算法结合在一起处理一个问题。
如果对更多图相关的问题感兴趣的话,可以去我的github,地址:github.com/seepre/data…