目录
存图方式
图的遍历:宽搜与广搜
最短路
dijisktra 的优缺点:
Bellman Ford算法 —— 解决负权图方法的朴素算法
SPFA算法 —— 最短路快速算法
判断负环
图的内容比较多,但成体系,数据结构课程正好也上完了图论,遂记录之。
邻接矩阵
用一个二维数组去记录,数组中的一二维坐标是点(n),数组中的值是边(m)的信息。因为开的是二维数组,n的数目不能太大,e[1000][1000]已经很大了,要是n取到100000这样,肯定是存不了的。一般用于存节点比较少的稠密图(m~n^2)。
初始化
初始化操作:最后那个e[i][j] = INF(自己定义的正无穷)也可以,一般常用e[i][j] = 0x3f3f3f3f。
cin >> n >> m; // n是顶点,m是边数
for (int i = 1; i <= n; i++) // 为什么i<=n 原因是边由点引出的.
for (int j = 1; j <= n; j++)
if (i == j) e[i][j] = 0; //自己到自己的距离,这里不考虑自环
else e[i][j] = -1;
存图
这里给的例子是无向图的例子,如果是有向图的话,只记录e[a][b]即可,表示一条从a到b的边。这里a到b边的权值赋值为1,只是为了表示是否连通,具体也可以给其他的权值。
for (int i = 1; i <= m; i++) //m是边数
{
cin >> a >> b;
e[a][b] = 1;
e[b][a] = 1;
}
邻接表
相当于是每个节点连接的邻点都用一个单链表去存储。这样的存储不用二维数组,只需要为每个点开个单链表就可以。可以用来存储点数多的图跟稀疏图(m~n)。
初始化
这里用链式前向星的方式实现,也可以用vectot去模拟。
h数组下标表示当前记录的节点是哪个,eg:节点i的h[i]表示的是以i为头结点的单链表。h数组存的是公共索引idx,用于找与a邻接的点,e数组通过公共索引idx具体存储的与a邻接的点b,ne存的是公共索引,w存的是ab边的权。这个方法类似于数组模拟单链表的头插法。一开始h[a] 指向-1,表示没有相邻节点。插入点的话就是先给这个节点赋值:e[idx] = b,然后把这个节点插到h[a]的后面:ne[idx] = h[a],最后头指针移动一位:h[a] = idx++。
所以初始化操作就是一开始要给所有的头结点都赋值-1,表示所有点现在没有邻点。这里用memset赋值,memset按字节赋值,一般初始化0或者-1的时候用。
这里add函数就是表示加一条从a到b的边,如果有权的话,再加一个参数就行了。
const int N = 10010;
int h[N], e[N], ne[N], w[N], idx;
//无权有向边 从a到b的一条边,如果是无向边,则再加一条ba即可
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
//有权边的建立 从a指向b的一条边,权值是v
void add(int a, int b, int v) // v为权值
{
e[idx] = b, w[idx] = v, ne[idx] = h[a], h[a] = idx++;
}
//初始化操作
idx = 0;
memset(h, -1, sizeof h);
存图
这里存单向图的m条边,如果是双向的,add(a,b);add(b,a);就行了
for (int i = 0; i < m; i++)
{
int a, b;
cin >> a >> b;
add(a, b);
}
宽搜是层搜,实现基于队列。每次扩展与点n相邻的点,依次加入队列中。做完处理后出队,直到队列为空,这个时候所有点都会被遍历一遍了。 由于每个点跟边只遍历一遍,所以时间复杂是O(n+m)其中n,m分别是点数与边数。
宽搜的性质决定了从源点到n点的距离是最短路,但是边权一定要是1才可以用bfs求最短路。
深搜在树种引用比较多,但是树也是一个特殊的图(没有回路的连通图),所以用深搜也是可以的。由于每个点跟边只遍历一遍,所以时间复杂是O(n+m)其中n,m分别是点数与边数。
实现
BFS:
邻接矩阵:这里要用到两个数组,一个是存图的二维数组,一个标记数组。遍历的动力是数码呢?用一个循环1-n个点就可以了。
void bfs(int u)
{
queue q;
st[1] = 1;
q.push(u);
while (q.size())
{
auto t = q.front();
cout << t << " ";
q.pop();
for (int i = 1; i <= n; i++) // 用循环去遍历1-n个点
{
if (e[t][i] == 1&&!st[i]) // e[t][i]==1表示当前点t相邻的点
{
st[i] = 1; // 访问过的就不用访问了
q.push(i);
}
}
}
}
邻接表: 就是遍历一下以n为节点头的点链表。
void bfs(int start)
{
st[start] = 1;
queue q;
q.push(start);
while(q.size())
{
auto t = q.front();
q.pop();
for(int i = h[t];~i;i=ne[i])
{
int j = e[i];
if(!st[j])
{
st[j] = 1;
//具体操作
cout<
DFS:
邻接矩阵: dfs函数可以加很多参数,这里用一个sum来判断是否应该结束。每次找到一个点,下一层的dfs第二个参数就+1,找到了n个点sum==n就结束,开始回溯了。这里sum从0或者1开始都可以,个人喜欢从1开始。
void dfs(int u,int sum)
{
cout << u << " ";
sum++;
if (sum == n) return;
for (int i = 1; i <= n; i++)
{
if (!st[i]&&e[u][i]==1)
{
st[i] = 1;
dfs(i,sum+1);
//st[i] = 0;
}
}
return;
}
邻接表:这里不需要设置sum了,单链表的以-1结束,所以结束条件是i = -1,也可以写成~i。
void dfs(int u)
{
//先标记
st[u] = 1;
for (int i = h[u]; i != -1; i = ne[i]) // 这个循环是对以i为节点的h[i]的遍历,通过这个遍历就遍历完整个树/图了
{
int j = e[i];
if (!st[j])
{
cout << j << " ";
dfs(j);
}
}
}
最短路问题是图论中常考的题目之一。一般有三种做法,两种优化方法。一共五种模板。可以背模板,但是关键是要去理解记忆。这里直接贴出来。每种模板优劣势不一样,适用范围也不同,且听我慢慢道来。
多源汇最短路——Floyd算法
这应该是最简单的最短路算法了,实现难度也不大。最终得到的二维矩阵中的值e[i][j]就代表了i->j的最短路了。
算法思想:两个点的距离如果可以缩短,那么一定是要经过第三个点才可以缩短的。所以Floyd算法就是对于所有点对,枚举所有点,看看能不能缩短两点的距离。所以要经过n轮循环。
算法时间复杂度为O(n^3),对于点数较小的图来说,这种写法很好,既快又简洁。而且可以去计算负权边,但是无法解决有负环的图。
初始化:
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) e[i][j] = 0;
else e[i][j] = INF;
// 算法结束后,e[a][b]表示a到b的最短距离
void floyd()
{
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
e[i][j] = min(e[i][j], e[i][k] + d[k][j]);
}
算法思想: 将所有点分为两块,一块是已经是最短路中的点(确定点集),另一块是没有确定最短路的点(未知点集)。标定好源点以后,每次选择离源点最近的一个点加入到确定点集中,并用这个点去更新一下源点到其他点的距离。重复这个过程,直到确定点集中的点数等于n。最开始dist都是INF,这时候都是“估计值”。源点确定以后就用源点去更新。更新过的点用st【i】记录一下。更新完后dist对应的值从估计值变为"确定值"。
朴素dijkstra算法:
首先初始化一下dist数组,用于存源点到各个点的距离。dist[1] = 0表示这里选取的源点是1这个点。1到1的距离是0。然后进行n次循环。(为什么是n次循环呢?因为每次循环都可以找到一个点,n次就找完了所有点加入确定点集中)。用t找到离源点最近的一个点,标记一下以后去更新一下源点到其他点的距离。如果最后dist[n]还是等于INF,说明源点无法到达n这个点。
dist数组: 存的是源点到其他点的最短距离。
st数组:判断当前点是否在当前判断的点是否已经在确定点集中。
每次开始都需要找到离当前源点最近的一个点,是O(n)的。然后再去更新边。由于循环n轮,把所有边都遍历到。故最终的复杂度为O(n^2+m)的。适用于稠密图,所以用二维数组去存。
注:轮数写n-1也是可以的。因为每次确定了一个点的含义是确定了这个点离源点的最近距离,既然是最短的,那么这个距离后面是不会改变的,也就是说确定了一个点的同时也确定了一些边。那么等到遍历到最后一个点的时候,其他点的距离都是不会变的了,最后一个点的临边遍历了也不会改变dist的其他值了。这也可以理解为什么会把边全遍历一遍了。
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n-1; i++) //这里轮数的确定?n与n-1都可以
{
int t = -1; // t是标志位,表示没有找到这个路径
for (int j = 1; j <= n; j++)
{
if (!st[j] && (t == -1 || dist[j] < dist[t])) t = j;
}
//用t来更新其他点的距离
st[t] = 1;
for (int j = 1; j <= n; j++)
dist[j] = min(dist[j], dist[t] + g[t][j]);
}
if (dist[n] == 0x3f3f3f3f) return -1;
else return dist[n];
}
自己模拟一遍过程就是这样的:每次加入一个点,就有dist[i]从估计值变为确定值。
堆优化的dijkstra算法:
朴素算法就是严格按照算法思想去模拟的。最慢的点就在于用循环找到离当前源点最近的一个点,n次循环是O(n^2)的。这里可以用堆这个数据结构去找一段数据中的最小值。时间复杂度为O(1),每次往堆里插入边,复杂度为logm,m次的话复杂度就会变为mlogm。这个算法一般适合用于稀疏图,m~n 所以复杂度一般认为是O(mlogn)。
C++中的堆是可以用优先队列去实现的。每次发现可以更新的点,就把那个点放到堆中就行了。这里要注意每个点只需要进堆一次就行了。所以要加个判断。这里判断的位置还是很有意思的。第一感觉是放到for里面也是可以的。但是再想想,把一个已经重复点放进去再判断,可能会把更多重复的点都放进来,所以放外面也算是一种剪枝策略吧。
// 要记录点的编号跟该点到源点的距离,所以用二元组更合适
typedef pair PII;
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue, greater> heap; // PII位置为数据类型,默认为大根堆,小根堆自己写下greater
//为什么用pair?因为要存两个数据:距离跟编号
heap.push({ 0,1 }); // 规定first为距离,second为序号
while (heap.size())
{
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = 1; //每个点应该就进来一次就行了
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > distance + w[i]) // distance表示当前点到源点距离,w[i]表示当前点到j点距离
{
dist[j] = distance + w[i];
heap.push({ dist[j],j });
}
}
}
if (dist[n] == 0x3f3f3f3f3) return -1;
return dist[n];
}
dijisktra 的优缺点:
优点:可以计算出一个点到其他点的最短距离,如果没有最短距离也可以判断出来。
缺点:只可以处理正权图,不能处理负权的情况!
这是为什么呢?因为dijkstra算法是基于贪心算法的。贪心算法我认为是不能很好处理所有问题的,换言之只能处理规定的情况。dijkstra算法遇到有负权边时,局部最优不一定是全局最优。算法核心思想是:当已经确定一个点以后,这个点到源点的距离也被确定了。后面确定的点更新边的时候也不会影响到之前的点。这种假设的前提是:所有边都是正数,才可以认为①当前离源点最近的点的距离不能被后面的点改变②后面的点离源点距离一定比之前的距离要大。但是如果出现负数了的情况,后面出现的点就可以通过负权边去更新前面点到源点的距离,但是根据算法,之前的dist又是确定的,改不了的。这就会导致计算错误。
举一个反例就行了。(也有的负权图是可以用dijkstra解决的)
算法思想:通过n-1次(点数)更新操作,每次更新都用所有的边去“松弛”一下源点与其他间的距离。(“松弛”:就是让两个点的距离变得更短的操作)。最后得到的dist数组就是源点到其他点的最短距离。(如果存在的话)
模拟过程:
这里的存图方式比较简单,只需要存边就可以了。与后面介绍的Kruskal算法存图一样。为什么这样存呢?与dijkstra算法不一样。dijkstra算法的思想是找点去松弛边,而Bellmanford算法就是用所有的边去松弛就可以了。所有我们关注的不是点,是边。
用一个结构体去存一下所有边,记录一下起始点,结束点,权重。
外循环进行n-1次,内循环进行m次。每次判断一下从后面的点(一个边的结束点)能不能用前面的点(一个边的起始点)松弛一下。
注:① 为什么只进行n-1次就可以?因为一个n个点的最短路最多只有n-1条边!(注意这里是从边的角度去思考问题的)
② last数组的作用是什么?其实是一种备份,目的是保留上一次的状态。否则会发生串联,(串联:更新完一次后让两点直接连通了,下一次的操作因为上一次的操作导致在连通的基础上又松弛了一次。)对于获得最短路径的最终状态来说,串联没有影响。但是Bellman算法最大的用途在于解决有边数限制的最短路问题。而对于有边数限制的最短路来说,串联会导致得到的结果是不符合要求的,超过边数限制的最短路。
③ 为什么最后是if判断条件是dist[n] > 0x3f3f3f3f / 2而不是dist[n] == 0x3f3f3f3f呢?因为这个松弛操作可能会让两个源点无法到达且之间的负权边的点离源点的距离发生改变!5和n当前是源点无法到达的,但是之间的距离-5,这会被松弛,导致当前源点到n的距离是INF-5。所以我们认为dist[n] > 0x3f3f3f3f / 2就满足条件就行了。
外循环n-1次,内循环m次,所以最终复杂度为O(nm)。仔细想想可以发现:这种算法是很暴力的。因为很多循环是无用的。每次循环只能确定一些点到源点的最短距离。上面的例子中,5个点5条边的情况下,25次循环只能找到4个点的最短路径。所以现在BellmanFord算法一般用于求边数限制的最短路。我们把第一重循环的n-1改成k,表示边数限制为k次求最短路就行了。
//可以处理负权边,可以判断负权环
//存图比较简单,用结构体就行
struct Edge {
int a, b, w; // a表示出点,b表示入点,w表示边的权重
}edges[N];
int last[N];
//求1到n的最短路,如果无法走到,返回-1
int Bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[0] = 1;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n - 1; i++)
{
memcpy(last, dist, sizeof dist); // 为什么要备份一下呢? 因为防止发生串联,比如改完1到2,3都是INF,改完2以后2不是INF以后,又可以改3了。但是
//应该是不能改3的,因为是按照上一次的状态来的。
for (int j = 0; j < m; j++)
{
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
if (dist[b] > last[a] + w)
dist[b] = last[a] + w;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1; // 这里dist[n]的值可能不是0x3f3f3f3f,因为会被松弛掉 比如1到5,6两点都是INF,但是5->6的边是负权边,会被更新掉
return dist[n];
}
SPFA就是Bellman_Ford算法的队列优化形式。
算法思想:既然Bellman_Ford算法浪费了很多次循环,那么我们能不能只对边数更新成功的边进行操作呢?这样就能省下很多次循环的操作了。因为那些操作其实对边数的更新是不成功的。我们将那些更新成功的点加入队列中,因为这些点到源点的距离发生了改变的话,那么这些点的出点离源点的距离也一定也会变短。所以这里我们选择用邻接表的方式,记录一下这些点邻接的点。
这里要注意一下:一个点同时在队列中出现多次是无意义的。所以我们在入队的时候要判断下,避免重复入队。
一般这个算法的复杂度为O(m),最坏的情况是O(mn)。如果被卡成O(mn),换成堆优化dijkstra就行了。
int spfa()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue q;
q.push(1);
st[1] = 1; // 进队就要标记下
while (q.size()) // 只需要对前驱改变的点做松弛操作
{
auto t = q.front();
q.pop();
st[t] = 0; // 出队为false 这一步很重要:dijkstra是对每个点进行操作,所以不论是朴素还是堆优化,点都会被只有一次,但是spfa中不是记录点,而是记录有改变边影响的点,这样的点的出边
也会减小,所以一个点是可以多次入队的
for (int i = h[t]; ~i; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
if (!st[j]) //避免重复入队
{
q.push(j);
st[j] = 1;
}
}
}
}
if (dist[n] == 0x3f3f3f3f3) return -1;
return dist[n];
}
Bellman算法跟SPFA算法都是可以判断负环的。这里贴出Bellman判断负环核心代码跟SPFA完整代码。
Bellman_Ford
如果n-1次之后再进行一次还可以if判断成功的话,说明还可以被松弛。就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n - 1; i++)
{
memcpy(last, dist, sizeof dist); // 为什么要备份一下呢? 因为防止发生串联,比如改完1到2,3都是INF,改完2以后2不是INF以后,又可以改3了。但是
//应该是不能改3的,因为是按照上一次的状态来的。
for (int j = 0; j < m; j++)
{
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
if (dist[b] > last[a] + w)
dist[b] = last[a] + w;
}
SPFA
直接贴y总代码,说明的比我清楚。
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N], cnt[N]; // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N]; // 存储每个点是否在队列中
// 如果存在负环,则返回true,否则返回false。
bool spfa()
{
// 不需要初始化dist数组
// 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。
queue q;
for (int i = 1; i <= n; i ++ )
{
q.push(i);
st[i] = true;
}
while (q.size())
{
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
if (cnt[j] >= n) return true; // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
未完待续,还有拓扑排序,最小生成树,二分图,有空再写吧,自己也需要理解总结。(马上要考试了,要开始预习了)(bushi
如有错误,请批评指正。