算法基础-搜索与图论
Dijkstra
Bellman-Ford
spfa
Floyd
Prim
Kruskal
染色法判定二分图
匈牙利算法
以上是算法基础部分的==搜索与图论==,主要分为搜索,多个最短路算法,最小生成树算法以及二分图。
###1.树与图
首先来考虑==图的存储==
树是一种特殊的图,与图的存储方式相同。同样无向图也是一种特殊的有向图。所以只考虑有向图
对于有向图的存储:
1)邻接矩阵:主要用来存储稠密图
2)邻接表:主要用来存储稀疏图
(对于图的稠密和稀疏的判断,如果图的边和图的点数量差不多,那就是稀疏图。而如果边比点大很多,那就是稠密图)
//邻接矩阵
g[a][b]; //存储从a到b的边
//邻接表,实际上就是二维链表
int h[N], e[N], ne[N], w[M], idx = 0;
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
//初始化
idx = 0;
memset(h, -1, sizeof h);
###2.搜索
再来考虑==图的遍历==
图的遍历主要有DFS和BFS
考虑图的搜索的时间复杂度,我们规定图的点为n个,而边为m个
那么O(n + m),因为遍历整个图需要遍历所有的点
1)DFS
深度优先搜索,就是尽可能先搜索完纵向的元素
//以图的深度优先搜索为例
int dfs(int u)//遍历节点u
{
st[u] = true;//节点u已经被遍历
for (int i = h[u]; i != -1; i = ne[i])
{
int j = e[i];//j为当前与节点u相连的点的关键词
if (!st[j])//没有被遍历
dfs(j);
}
}
2)BFS
宽度优先搜索,就是先遍历完与当前结点相连的所有节点
//以图的宽度优先搜索为例
queue<int> q;
q.push(1);
st[1] = true;
while (q.size())
{
int t = q.front();//t为队列的队头
q.pop();//出队
for (int i = h[t]; i != -1; i = ne[i])//遍历当前节点的所有相邻点
{
int j = e[i];
if (!st[j])
{
st[j] = true;
q.push(j);
}
}
}
图的拓扑排序是BFS的经典应用,拓扑排序就是将按顺序将每个入度为0的点拿出来,如果没有入度为0的点,那么就不满足拓扑排序。
bool topsort()//拓扑排序
{
int q[N], hh = 0, tt = -1;
for (int i = 1; i <= n; i++)
if (!d[i])//如果某个点入度为0
q[++tt] = i;
while (hh <= tt)
{
int t = q[hh++];
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
d[j]--;
if (!d[j])
{
q[++tt] = j;
}
}
}
if (tt == n - 1) return true;
else return false;
}
3.最短路算法
(1)单源最短路(无负权边)
顾名思义,单源最短路算法就是从一个点到另一个点的最短距离,而我们一般处理的是从1号点到n号点的最短路问题。
首先我们考虑没有负权边的情况.。
通常用到dijkstra算法
(a)朴素版dijkstra算法
==基本思想==:
初始化dist数组
创建一个集合S,每次找不在集合S但是距离源点最近的点t,将点t加入到集合S中
并用点t通过松弛操作更新其他所有点到源点的距离。
经过n-1次循环后加入了n-1个点,这样最短路就找到了
==时间复杂度分析==:朴素版dijkstra主要应对的是稠密图的问题,时间复杂度为O(n^2^ + m)
int g[N][N];//邻接矩阵存储边
int dist[N];//dist[i]表示点i到源点的距离
bool st[N];//S集合
void dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;//初始化
for (int i = 0; i < n - 1; i++)//进行n-1次循环
{
int t = -1;//t为-1是为了方便那些不能更新的点加入到点集中,防止死循环
for (int j = 1; j <= n; j++)//找出不在点集S中但是距离源点最近的点
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
st[t] = true;//加入点集S
for (int j = 1; j <= n; j++)//利用点t更新其他点到源点的最短距离
dist[j] = min(dist[j], dist[t] + g[t][j]);//松弛操作
}
}//如果dist[n] == 0x3f3f3f3f 说明没有最短路
//否则最短路长度为dist[n];
(b)堆优化版dijkstra算法
堆优化适合于应对稀疏图
时间复杂度分析:首先是n-1次循环,循环内找最近的点t只要O(1),但是更新其他点到源点的最短距离需要logm。因此总的时间复杂度为O(nlogm)
#include <queue>
typedef pair<int, int> PII;
void dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;//初始化
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1});//这里需要先存距离,因为pair在排序的时候,先看第一个元素
whlie (heap.size())//遍历所有被更新距离的点
{
auto t = heap.top();//O(1)的时间找到最小值
heap.pop();
int distance = t.first, ver = t.second;
if(st[ver]) continue;//如果已经被更新直接跳过
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > distance + w[i])//松弛操作
{
dist[j] = distance + w[i];
heap.push({dist[j], j})//O(logm)
}
}
}
}
(2)单源最短路(有负权边)
处理有负权边的单源最短路有两个主要算法,Bellman-Ford和spfa算法
至于为什么dijkstra算法不能处理负权边的问题,因为dijkstra算法基于贪心思想,在有负权边的问题上,局部最优解不一定能组成全局最优解。
(a)Bellman-Ford算法
==基本思想==:进行n-1次循环,每次循环将图中所有边进行松弛操作,若在n-1次操作之后还能更新,说明有负环。
==注意==:
在判断是否不能达到n号点,判断依据是dist[n] > INF / 2,因为如果n号点相连的边进行了松弛操作,那么一定是小于INF的,但是是在INF的一个量级。
此算法擅长解决有边数限制的最短路问题
==ATTENTION==:由于每次松弛操作是对于所有边的循环,需要用上一次迭代后的dist数组的备份进行松弛操作,否则会发生串联效应。
==时间复杂度分析==:O(nm),进行n次循环,遍历所有的边
struct edge{
int a, b, w;
}edges[N];
void Bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n; i++)//有时候为k,k为限制的边数
{
memcpy(back, dist, sizeof dist);//复制上一次迭代的数组,防止串联效应
for (int j = 0; j < m; j++)
{
auto t = edges[j];
dist[t.b] = min(dist[t.b], back[t.a] + t.w);
}
}
}
//判断是否能到n号点
if (dist[n] > 0x3f3f3f3f / 2) cout << "impossible";
else cout << dist[n];
(b)spfa算法
==基本思想==:实际上是队列优化版的bellman-ford算法
只考虑前驱结点更新的点,即使用队列存储点,当一个点出队,寻找能更新的后继结点,将这些点入队。
使用st数组来进行判断当前点是否已经进入队列,如果进入的话,就不要再次进入,只需要更新一下到源点的距离。st数组就是记录当前正在更新的点,如果已经在队列,就不需要再入队。
==注意事项==:
spfa最后判断能否到达n号点是用dist[n] == 0x3f3f3f3f,因为spfa与上一个算法不同,不是遍历每一条边!
Bellman_ford算法可以直接处理有负权回路的问题,但是如果spfa要判断是否有负权回路,那么需要使用cnt数组记录每个点到源点经过的边数,如果边数到n,说明有负环。(抽屉原理
==时间复杂度分析==:spfa是队列优化版的bellman-ford算法,那么最坏的时间复杂度是O(nm),平均为O(m)。(所以还是很牛的)
void spfa()
{
//依然是先初始化
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
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];
if (!st[j])
{
st[j] = true;
q.push(j);
}
}
}
}
}
==补充==:利用spfa算法判断有无负权回路
但是呢,一般情况下,spfa求最短路和判断负环不在一起进行
bool spfa()
{
queue<int> 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;//有负环
if (!st[j])
{
st[j] = true;
q.push(j);
}
}
}
}
return false;
}
(3)多源最短路(含负权边)
Floyd算法:
==基本思路==:基于动态规划的思路
==时间复杂度分析==:三层循环O(n^3^)
//初始化
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j) dist[i][j] = 0;
else dist[i][j] = INF;
//Floyd
void Floyd()
{
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
4.最小生成树
最小生成树就是在图中找到能生成的权重和最小的树
(1)Prim算法
==基本思路==:与dijkstra算法很像,但是dist数组表示的含义不同
循环n遍,为的是不断为点集S加入点,直到到达n点
找到不在点集S中的点t,点t是距离点集最近的点
将点t加入到S中,并用t更新其他点到点集S的距离
==注意事项==:
dist数组表示到点集的距离
每次对于找到的点t就行判断,如果dist[t] == INF那么直接返回INF
利用点t更新其他点到点集S的距离,这个操作和松弛操作很像,但是不一样,dist[j] = min(dist[j], g[t][j]);
==时间复杂度分析==:O(n^2^+m)与朴素版dijkstra时间复杂度差不多
int prim()
{
int res = 0;
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n; i++)
{
int t = -1;
for (int j = 1; j <= n; j++)//找点t
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
if (dist[t] == INF) return INF;
st[t] = true;
res += dist[t];
for (int j = 1; j <= n; j++)
dist[j] = min(dist[j], g[t][j]);
}
return res;
}
(2)Kruskal算法
==基本思路==
首先对所有的边进行排序
找到最短的边,如果边和生成树的边集不在一个连通块,就直接加入。
知道加入到第n个点相连的边,如果没有加到第n个点,就是无法生成最小生成树
==注意==
由于此算法只考虑边,那么直接用结构体进行存储,而且要用到运算符重载
为了判断是否在同一连通块,那么就要用到并查集的查询操作
如何将新边加入,也要用到并查集的合并操作
==时间复杂度分析==:首先是对边的排序mlogm,并查集的复杂度近乎为1,因此O(mlogm)
struct edge{
int a, b, w;
bool operator <(const edge &W) const
{
return w < W.w;
}
}edges[M];
int find(int x)//并查集!!!
{
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
int Kruskal()
{
sort(edges + 1, edges + 1 + m);
for (int i = 1; i <= n; i++)
p[i] = i;//并查集初始化!!!
for (int i = 1; i <= m; i++)
{
auto t = edges[i];
int a = t.a, b = t.b, w = t.w;
if (find(a) != find(b))
{
p[find(a)] = find(b);
res += w;
cnt ++;
}
}
if (cnt < n - 1) return INF;
else return res;
}
5.二分图
关于二分图,我们只考虑二分图的判别和二分图的最大匹配问题。关于二分图的问题,我们一般使用邻接表的方式存储。
(1)染色法判别二分图
关于染色法,在离散数学中有提到,为了判别二分图,就进行染色,如果相邻的两个点出现相同的颜色,那么就一定不是二分图
==时间复杂度分析==:O(n + m)
bool dfs(int u, int c)//u为当前点的关键词,c为当前点的颜色
{
color[u] = c;//为当前点染色,0为白,1为黑,-1表示没染色
for (int i = h[u]; i != -1; i = ne[i])
{
int j = e[i];
if (color[j] == -1)//如果没有染色
{
if (!dfs(j, 1 - c)) return false;//染色,如果返回false,那么这里也返回false
}
else if (color[j] == c) return false;//如果染色了,但是颜色一样,直接判定为不是二分图
}
return true;//成功遍历
}
bool check()
{
memset(color, -1, sizeof color);
bool flag = true;
for (int i = 1; i <= n; i++)
if (color[i] == -1)
if (!dfs(i, 0))
{
flag = false;
break;
}
return flag;
}
(2)匈牙利算法获取二分图最大匹配
==时间复杂度分析==:O(nm)
int n1, n2;//分别为俩个集合的点的个数
int match[N];//存储第二个集合中每个点当前匹配的第一个点
bool st[N];//表示第二个集合中的每个点是否已经被遍历
bool find(int x)
{
for (int i = h[x]; i != -1; i = ne[i])//遍历x喜欢的女生
{
int j = e[i];
if (!st[j])//如果在这一轮的匹配中,女生没有被预定
{
st[j] = true;
if (match[j] == 0 || find(match[j]))//如果1.当前女生没有匹配的对象或者2.当前女生匹配的对象可以找到别的女生
{
match[j] = x;//将女生j匹配给x
return true;
}
}
}
return false;
}
int res = 0;
for (int i = 1; i <= n1; i++)
{
//每次为一个男生进行匹配之前,需要对st数组进行初始化
memset(st, false, sizeof st);
if (find(i)) res++;
}