零、基础:
1、图的存储方式:
1)、邻接表:
vector<int> h(n, -1); // 邻接表表头指针
vector<int> ne(n); // next指针
vector<int> e(n); // value
vector<int> w(n); // 边的权值,有时不需要
int idx = 0; // 结点的全局标识符
// 1、增加一条边
void add(int a, int b, int c) {
ne[idx] = h[a], e[idx] = b, w[idx] = c, h[a] = idx++;
}
// 2、遍历与t点相连的所有边
for (int i = h[t]; i != -1; i = ne[i]) {
int j = e[i];
// t, j就是相连的两个点
}
2)、邻接矩阵:
vector<vector<int>> g;
// 1、初始化
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i == j) g[i][j] = 0;
else g[i][j] = 1e8;
}
}
// 2、增加一条边
g[i][j] = w;
// 3、遍历t点所有边
// 邻接矩阵无法记住相邻关系,所有只有全部遍历
for (int i = 0; i < n; i++) {
cout << g[t][i];
}
3)、结构体:
有些算法(例如Bellman-Ford算法)不一定需要将图完整表示出来,我们只关注边的信息
struct Edge {
int from, to, w;
bool operator< (const Edge& e) const {
return w < e.w;
}
}
一、最短路算法:
1、Dijkstra算法:
int dijkstra(...) {
vector<int> dist(n, 1e8); // 距离起点的距离
vector<bool> vis(n, false); // 是否确定了最短路径
priority_queue<PII, vector<PII>, greater<>> q; // 优先队列,确定离起点最近的非树结点
dist[0] = 0;
q.push({0, 0}); // {dist[i], i},dist置于pair第一维,用于排序
while (!q.empty()) {
int t = q.top().second;
q.pop();
if (!vis[t]) {
// 遍历结点,根据图的不同存储方式,有两种形式,参考上文,这里使用邻接表形式
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];
q.push({dist[j], j});
}
}
vis[t] = true;
}
}
return dist[n - 1];
}
2、朴素Bellman-Ford算法:
- 朴素Bellman-Ford算法效率较低,但是有一个特别的应用场景:有边数限制的最短路算法,假设边数限制为k。
- Bellman-Ford只关注边,因此选用上述结构体方式存图。
vector<Edge> edges; // 图已建好
int bellman_ford(...) {
vector<int> dist(n, 1e8);
dist[0] = 0;
for (int i = 0; i < k; i++) {
auto last = dist; // 必须使用上一次的dist,因为这一轮的dist可能被更新
for (auto& edge : edges) {
auto [a, b, c] = edge;
dist[b] = min(dist[b], last[a] + c);
}
}
if (dist[n - 1] >= 1e8 / 2) return -1; // 不直接判断相等是因为在权值为负情况下,有可能会被更新
return dist[n - 1];
}
3、SPFA算法(队列优化Bellman-Ford算法):
- 优化思路:假设源点s到点b之间有点a,当dist[a]没有被更新时,dist[b]更新没有意义。
- 由于使用队列,需要表示点,因此这里选择邻接表。还需要一个数组inQue表示是否在队列中。
- SPFA与Dijkstra一样使用数组标识一个结点的状态,不同的是:前者表示是否在队列中,因此每次往队列中加入元素j时,inQue[j] = true; 后者表示该点最小路径是否确定,因此必须是t结点被pop出时确定。
int spfa(...) {
vector<int> dist(n, 1e8);
vector<bool> inQue(n, false);
queue<int> q;
dist[0] = 0;
q.push(0);
inQue[0] = true;
while (!q.empty()) {
int t = q.front();
q.pop();
inQue[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 (!inQue(j)) {
q.push(j);
inQue[j] = true;
}
}
}
}
if (dist[n - 1] >= 1e8 / 2) return -1; // 不直接判断相等是因为在权值为负情况下,有可能会被更新
return dist[n - 1];
}
- Bellman-Ford算法还有一个应用,一般用SPFA来写,即判断负权环是否存在。只要在上述代码上做些修改即可。
bool spfa(...) {
vector<int> dist(n, 1e8);
dist[0] = 0;
vector<int> cnt(n); // 从源点到点x的边数
// 为了防止负权环并不在 点0 ~ 点n-1的路径上,把所有点都加入队列中
for (int i = 0; i < n; i++) {
q.push(i);
inQue[i] = true;
}
while (!q.empty()) {
int t = q.front();
// ...
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 (!inQue(j)) {
// ...
}
}
}
}
return false
}
4、Floyd算法:
-
Floyd算法一般用来求多源最短路径,即可以求图中任意两点的最短路径。
-
使用邻接矩阵,类似于动态规划
void floyd() {
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
g[i][j] = min(g[i][j], g[i][k] + g[k][j])
}
}
}
}
二、最小生成树算法:
1、Prim算法:
- 类似于Dijkstra算法,实际上Dijkstra也再次发现了Prim算法。
- Prim算法每次将非最小生成树中的dist最小点加入最小生成树,Dijkstra算法将非最短路径树的最小点加入最短路径树。
- 基于邻接表实现的时间复杂度在边非常多时容易TLE,这里给出邻接矩阵实现方式。
int prim() {
vector<int> dist(n, 1e8); // 距离起点的距离
vector<bool> vis(n, false); // 是否确定了最短路径
int res = 0;
dist[0] = 0;
for (int i = 0; i < n; i++) {
int t = -1;
// 遍历所有节点,找到距离生成树集合最近的节点
for (int j = 0; j < n; j++) {
if (!vis[j] && (t == -1 || dist[j] < dist[t])) {
t = j;
}
}
// 不存在可以加入最小生成树的点,直接返回false
if (dist[t] == 1e8) return -1;
res += dist[t];
vis[t] = true;
// 松弛操作
for (int j = 0; j <= n; j++) {
dist[j] = min(dist[j], g[t][j]);
}
}
return res;
}
2、Kruskal算法:
- 基于并查集实现。
vector<int> p(n);
int find(int x) {
return x == p[x] ? x : (p[x] = find(p[x]));
}
// 对m条边进行排序,每次取最小边加入最小生成树
Edge edges[m];
int kruskal() {
// res:最小生成树代价,cnt:最小生成树中节点个数,如果最后小于n,则返回-1
int res = 0, cnt = 0;
sort(edges, edges + m);
for (int i = 0; i < n; i++) p[i] = i;
for (int i = 0; i < m; i++) {
int a = edges[i].from, b = edges[i].to, c = edges[i].w;
a = find(a), b = find(b);
// 如果两个点不属于一个连通分量中,则加入最小生成树
if (a != b) {
p[a] = b;
res += c;
cnt++;
}
}
if (cnt < n - 1) return -1;
return res;
}
三、拓扑排序:
- 基本要点很简单,首先存图,接着将所有点放入优先队列中,取出队头入度为0的节点,所有与该节点相连的节点的入度--。不断重复该操作。
- 但是需要注意的是,在STL自带优先队列中动态更改值比较麻烦。因此我们需要每次手动遍历寻找ind为0的节点,因此使用普通的queue,甚至数组都是可以的,这里我们使用模拟队列。
// 邻接表存图
// 每个点的入度
int h[N], ne[M], e[M], ind[N], idx;
void add(int a, int b) {
// b节点入度++
ne[idx] = h[a], e[idx] = b, ind[b]++, h[a] = idx++;
}
bool top_sort() {
// 模拟队列
int q[N], hh = 0, tt = -1;
for (int i = 0; i < n; i++) {
if (!ind[i]) {
q[++tt] = i;
}
}
while (hh <= tt) {
int t = q[hh++];
for (int i = h[t]; i != -1; i = ne[i]) {
int j = e[i];
ind[j]--;
if (!ind[j]) {
q[++tt] = j;
}
}
}
// 队列中应包含所有节点
return tt == n - 1;
}