图论(未完工)

Part-1 扫盲

先进行一下图论的基础知识扫盲

扫盲1

扫盲2

扫盲3

Part-2 最短路

前置知识:DFS、BFS、栈与队列

最短路是图论中比较经典的一类问题,应用场景广泛,包括最优化DP、网络流等领域。

1.1 相关定义

先明确最短路问题的核心定义:

  • 带权图:每条边带有权值的图称为带权图(赋权图)。所有边的权值非负的图称为非负权图;所有边的权值为正的图称为正权图
  • 边权:边的权值称为边权,记作 \(w_e\)\(w_{u,v}\);带权边记作 \((u,v,w)\)。若边不带权,默认边权为 1。
  • 路径长度:路径上每条边的权值之和称为路径长度
  • 负环:长度为负数的环称为负环
  • 最短路:在一张图上,称 \(s\)\(t\)最短路为最短的连接 \(s\)\(t\) 的路径。若不存在这样的路径(不连通或不可达),或最小值不存在(存在可经过的负环),则最短路不存在。
  • \(s\) 表示最短路起点,\(t\) 表示最短路终点。

1.2 单源最短路问题

问题描述:给定一个源点 \(s\),求 \(s\) 到图上任意点 \(u\) 的最短路长度 \(D_u\)

\(dis_u\) 表示 \(s\)\(u\) 的估计最短路长度,初始化 \(dis_s=0\)\(dis_u=+\infty \ (u \neq s)\),算法结束时应当满足:

\[dis_u = D_u \]

下面介绍几种常用的最短路求解算法:

1.2.1 Bellman-Ford

Bellman-Ford 是一种暴力求解最短路的算法。

核心操作是不断进行松弛操作:每一轮松弛遍历所有边 \((u,v)\),用 \(dis_u + w_{u,v}\) 去更新 \(dis_v\),重复该过程直到完成 \(n-1\) 轮(\(n\) 为图的节点数)。

Bellman-Ford 可有效判断负环:若完成 \(n-1\) 轮松弛后,第 \(n\) 轮松弛仍能更新某个节点的 \(dis\) 值,则说明图中存在负环。

算法时间复杂度为 \(O(nm)\)\(m\) 为图的边数)。

1.2.2 Dijkstra

Dijkstra 算法适用于 非负权图

扩展结点 \(u\) 为:对 \(u\) 的所有邻边 \((u,v)\),用 \(dis_u + w_{u,v}\) 更新 \(dis_v\)

算法核心逻辑:在满足 \(dis_u = D_u\) 的结点中,取出 \(dis\) 最小且未扩展过的点进行扩展。由于边权非负,取出结点的最短路长度单调不降。

核心证明:当前 \(dis\) 最小且未扩展过的点,其 \(dis_u\) 一定等于 \(D_u\)(最短路由长度更小的最短路更新而来,最小 \(dis\) 结点无法被其他结点更新)。

实现方式:用优先队列维护待扩展结点。扩展 \(u\) 时,若 \(dis_u + w_{u,v} < dis_v\),则将二元组 \((dis_u + w_{u,v}, v)\) 入队(第一元为距离,第二元为节点编号);取出结点时,以距离为关键字取最小值,若取出的距离与该节点当前的 \(dis\) 不相等,说明该节点已扩展过,直接跳过(避免复杂度退化)。

算法时间复杂度:

  • 优先队列实现:\(O(m\log m)\)
  • 稠密图(\(m\) 接近 \(n^2\)):用暴力找最小 \(dis\) 代替优先队列,时间复杂度 \(O(n^2)\)

扩展:01 BFS
当边权仅为 0 或 1 时,可用双端队列代替优先队列:

  • 扩展权值为 0 的边成功时,将节点压入队首;
  • 扩展权值为 1 的边成功时,将节点压入队尾。
    时间复杂度优化为 \(O(n+m)\)
模板题代码(洛谷 P4779)
点击查看代码

#include <bits/stdc++.h>
using namespace std;

// 定义pair<int, int>为pii,简化代码
#define pii pair<int, int>
// 节点数量上限
const int N = 1e5 + 5;

int n, m, s;          // n:节点数,m:边数,s:起点
int dis[N];           // dis[u]:起点s到u的最短路长度
vector<pii> e[N];     // 邻接表:e[u]存储(u, v, w),其中pair的first是v,second是w

int main() {
  // 输入节点数、边数、起点
  cin >> n >> m >> s;
  // 读入所有边,构建邻接表
  for (int i = 1; i <= m; i++) {
    int u, v, w;
    scanf("%d%d%d", &u, &v, &w);
    e[u].push_back(make_pair(v, w));
  }
  // 初始化dis数组:全部设为无穷大(0x3f3f3f3f),起点s的dis设为0
  memset(dis, 0x3f, sizeof(dis));
  dis[s] = 0;
  // 优先队列:小根堆,按pair的first(距离)升序排列
  priority_queue<pii, vector<pii>, greater<pii>> q;
  // 起点入队:(距离0, 节点s)
  q.push(make_pair(0, s));
  while (!q.empty()) {
    // 取出当前距离最小的节点
    auto t = q.top();
    q.pop();
    int id = t.second;  // 节点编号
    int dist = t.first; // 队列中存储的该节点的距离
    // 若队列中的距离不等于当前dis[id],说明该节点已扩展过,跳过
    if (dist != dis[id]) continue;
    // 遍历该节点的所有邻边,进行松弛操作
    for (auto edge : e[id]) {
      int v = edge.first;  // 邻接节点
      int w = edge.second; // 边权
      // 若通过当前节点到v的路径更短,则更新dis[v]并入队
      if (dis[id] + w < dis[v]) {
        dis[v] = dis[id] + w;
        q.push(make_pair(dis[v], v));
      }
    }
  }
  // 输出起点到所有节点的最短路长度
  for (int i = 1; i <= n; i++) {
    cout << dis[i] << " ";
  }
  return 0;
}

1.2.3 SPFA 算法(队列优化的 Bellman-Ford)与负环检测

关于SPFA,它______。

SPFA(Shortest Path Faster Algorithm)本质是 Bellman-Ford 算法的队列优化版本,核心思想是“只处理有价值的节点”,大幅减少无效的松弛操作,是检测负环的核心算法。

一、SPFA 核心思路(形象化理解)

Bellman-Ford 算法的痛点:每一轮都无脑遍历所有边,哪怕这些边对应的节点根本没有机会更新最短路(比如节点的 dis 值还是无穷大)。
SPFA 的优化逻辑(分步骤拆解):

  1. 队列筛选:用队列只维护“有机会更新邻节点最短路”的节点(即刚被松弛过的节点);
  2. 去重标记:给每个节点加 vis 数组(入队标记),避免同一个节点重复入队(比如节点已在队列中,就不再压入,减少冗余);
  3. 按需松弛:只有节点被松弛(dis 值更新)且不在队列中时,才将其压入队列。

👉 通俗比喻:
Bellman-Ford 像老师“逐个人检查全班所有学生的所有作业”;
SPFA 像老师“只把‘有错题需要订正’的学生叫到办公室,订正完后再检查这些学生的同桌是否有连带错题”,效率大幅提升。

二、SPFA 时间复杂度与使用建议
维度 说明
理论复杂度 与 Bellman-Ford 一致,仍为 \(O(nm)\)(最坏情况会退化成 Bellman-Ford)
实际效率 普通图上效率极高,但会被“特殊构造的卡常数据”逼到最坏复杂度
使用场景 1. 处理含负权边的最短路问题;2. 检测图中的负环(核心场景);3. 非负权图优先用 Dijkstra(更稳定)
三、SPFA 关键注意点

若用 SPFA 求解“点对点最短路”(如费用流 EK 算法),队头是目标节点时不能直接结束算法
原因:节点入队仅代表它的 dis 值被更新过,但不代表已取到最终最短路(后续可能有负权边进一步更新)。

四、SPFA 检测负环(核心应用)

负环的本质:绕环走一圈,路径总长度会变小(最短路可无限缩短)。
检测原理:一个节点的最短路径最多经过 \(n-1\) 条边(不重复经过节点),若经过超过 \(n-1\) 条边,说明绕了负环。

两种检测方法对比(个人推荐第二种):

检测方法 核心逻辑 效率
统计节点入队次数 若节点入队次数 ≥ n,说明存在负环 较慢(冗余)
统计最短路边数 记录 \(len_i\) 表示“起点到 \(i\) 的最短路边数”,若 \(len_v = n\),说明存在负环 更快(直接检测核心条件)

注:某不愿意透露姓名的NOI出题人曾表示:带负环的题目业界公约是建议用SPFA,但是不会卡你的Bellman-Ford(毕竟一些情况SPFA会退化),but...

通俗解释:
假设有 5 个节点,从起点到任意节点的最短路径最多走 4 条边(不绕圈)。如果某节点的最短路径走了 5 条边,说明路径里一定有环,且这个环是负环(否则绕环只会让路径变长,不会被选为最短路)。

五、SPFA检测负环 代码
点击查看代码
#include <bits/stdc++.h>
using namespace std;

const int N = 2e3 + 5; // 节点数量上限
int n, m;              // n:节点数,m:边数
int dis[N];            // dis[i]:起点到i的最短路估计值
int len[N];            // len[i]:起点到i的最短路径经过的边数
int vis[N];            // vis[i]:标记i是否在队列中(避免重复入队)
vector<pair<int, int>> e[N]; // 邻接表:e[u]存储(邻节点v, 边权w)

void solve() {
    // 多组测试用例,每次清空邻接表(避免残留数据)
    cin >> n >> m;
    for (int i = 1; i <= n; i++) e[i].clear();
    // 读入所有边,构建邻接表
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        e[u].push_back(make_pair(v, w));
        // 题目特殊条件:若边权非负,添加双向边(根据模板题场景)
        if (w >= 0) e[v].push_back({u, w});
    }
    // 初始化:dis设为无穷大(0x3f3f3f3f),vis设为0(不在队列)
    memset(dis, 0x3f, sizeof(dis));
    memset(vis, 0, sizeof(vis));
    // 起点为1:自身到自身距离为0,最短路边数为0,压入队列
    queue<int> q;
    q.push(1);
    dis[1] = 0;
    len[1] = 0;
    vis[1] = 1; // 标记起点在队列中
    while (!q.empty()) {
        int t = q.front(); // 取出队头节点
        q.pop();
        vis[t] = 0; // 标记为不在队列(后续可重新入队)
        // 遍历t的所有邻边,尝试松弛操作
        for (auto it : e[t]) {
            int to = it.first;  // 邻节点v
            int w = it.second;  // 边(t, v)的权值
            int new_dis = dis[t] + w; // 经过t到to的新距离
            // 松弛条件:新距离比当前dis[to]更小
            if (new_dis < dis[to]) {
                dis[to] = new_dis;          // 更新最短路估计值
                len[to] = len[t] + 1;      // 最短路边数+1(多走了一条边)
                // 关键:边数等于n → 存在负环(最多n-1条边)
                if (len[to] == n) {
                    cout << "YES" << endl;
                    return; // 找到负环,直接返回(无需继续遍历)
                }
                // 若to不在队列中,压入队列并标记
                if (!vis[to]) {
                    vis[to] = 1;
                    q.push(to);
                }
            }
        }
    }
    // 遍历完所有节点,未找到负环
    cout << "NO" << endl;
}
int main() {
    int T; // 测试用例数
    cin >> T;
    while (T--) solve(); // 处理每组测试用例
    return 0;
}
附链:SPFA的几种优化与Hack方式https://www.zhihu.com/question/292283275

1.2.4 三角形不等式(最短路的核心性质)

在单源最短路径问题中,对于图中任意一条边 \((u, v) \in E\),满足:

\[D_u + w_{u,v} \ge D_v \]

该不等式被称为三角形不等式

  • 符号说明:

    • \(D_u\):起点到节点 \(u\)真实最短路长度
    • \(D_v\):起点到节点 \(v\)真实最短路长度
    • \(w_{u,v}\):边 \((u, v)\) 的权值。
  • 核心含义:
    “起点→\(u\)\(v\)” 的路径长度,一定不会比 “起点→\(v\) 的最短路” 更短(否则就违背了“最短路”的定义)。

  • 延伸价值:
    三角形不等式是单源最短路的本质性质;而差分约束问题,正是这个性质的“逆应用”——已知若干类似三角形不等式的约束,求满足条件的变量解。

1.3 差分约束问题(三角形不等式的逆应用)

问题描述

给定若干形如 \(x_a - x_b \le c\)\(x_a - x_b \ge c\) 的不等式约束,求任意一组满足所有约束的变量解 \(\{x_i\}\)(若存在)。

1.3.1 算法核心思路(从不等式到图的转化)

一、不等式标准化(统一形式)

所有差分约束的不等式,都能转化为 \(x_j \le x_i + c\)(或等价的 \(x_i + c \ge x_j\)),这与最短路的三角形不等式 \(D_v \le D_u + w_{u,v}\) 完全一致!

常见约束的转化规则(关键):

原始约束 转化后形式 对应建边方式 核心逻辑
\(x_a - x_b \le c\) \(x_a \le x_b + c\) \(b\)\(a\) 连边,权值为 \(c\) 对应 \(D_a \le D_b + w_{b,a}\)
\(x_a - x_b \ge c\) \(x_b \le x_a - c\) \(a\)\(b\) 连边,权值为 \(-c\) 先变形再套用上述规则
\(x_a = x_b\) \(\begin{cases}x_a - x_b \le 0 \\ x_a - x_b \ge 0\end{cases}\) 双向边:\(b→a\)(0)、\(a→b\)(0) 两个约束同时满足即相等

👉 通俗比喻:
差分约束的每个不等式,就是给图加了一条“规则边”;求解变量解的过程,就是在这张图上求最短路的过程——最短路的结果 \(\text{dis}[i]\) 就是变量 \(x_i\) 的一组合法解。

二、建图的关键补充(解决图不连通问题)

如果直接按约束建边,可能出现“图不连通”(部分节点没有入边/出边),导致无法遍历所有约束。解决方法有两种(二选一):

  1. 超级源点法:新增一个“超级源点 0”,从 0 向所有节点连一条权值为 0 的边(保证所有节点能被遍历);
  2. 全节点入队法:初始化时将所有节点的 \(\text{dis}\) 设为 0,并把所有节点压入 SPFA 队列(无需超级源点,更简洁)。
三、求解与无解判断
  1. 求解算法:由于约束中的 \(c\) 可能为负数(边权可能为负),必须用支持负权边的 Bellman-Ford 或 SPFA 算法;
  2. 无解条件:若图中存在负环,则说明约束矛盾,无解!
    • 原因:负环意味着“绕环一圈,路径长度无限减小”,对应不等式要求“一个数加负数后仍不小于自身”,这是不可能的(比如 \(x_1 \le x_2 + 1\)\(x_2 \le x_1 - 2\),联立得 \(x_1 \le x_1 -1\),矛盾)。
四、时间复杂度

与 Bellman-Ford/SPFA 一致,为 \(O(nm)\)\(n\) 为变量数/节点数,\(m\) 为约束数/边数)。

1.3.2 差分约束模板题代码(洛谷 P5960,带详细注释)

点击查看代码

#include <bits/stdc++.h>
using namespace std;

const int N = 5e3 + 5; // 节点(变量)数量上限
int n, m;              // n:变量数,m:约束数
int vis[N];            // vis[i]:标记节点i是否在SPFA队列中(避免重复入队)
int dis[N];            // dis[i]:最终解(对应变量x_i的取值)
int len[N];            // len[i]:起点到i的最短路边数(用于检测负环)
vector<pair<int, int>> e[N]; // 邻接表:e[u]存储(邻节点v, 边权w)

int main() {
    // 输入变量数n、约束数m
    cin >> n >> m;
    
    // 读入所有约束,转化为边并构建邻接表
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        // 模板题约束为:x_u - x_v ≤ w → 转化为 x_u ≤ x_v + w
        // 对应建边:v → u,权值为w(匹配三角形不等式 D_u ≤ D_v + w_{v,u})
        e[v].push_back(make_pair(u, w));
    }
    
    // SPFA初始化:全节点入队法(解决图不连通问题)
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        q.push(i);    // 所有节点初始入队
        vis[i] = 1;   // 标记在队列中
        // dis默认初始化为0(等价于x_i ≥ 0的隐含约束,不影响最终解的合法性)
    }
    
    // SPFA核心逻辑:遍历队列,松弛+负环检测
    while (!q.empty()) {
        int t = q.front(); // 取出队头节点
        q.pop();
        vis[t] = 0;        // 标记为不在队列(后续可重新入队)
        
        // 遍历t的所有邻边,尝试松弛操作
        for (auto it : e[t]) {
            int to = it.first;  // 邻节点(对应变量x_to)
            int w = it.second;  // 边权(对应约束中的常数c)
            int new_dis = dis[t] + w; // 松弛后的新距离(x_to的候选值)
            
            // 松弛条件:新值更小,更新dis[to]
            if (new_dis < dis[to]) {
                dis[to] = new_dis;          // 更新解(x_to = dis[to])
                len[to] = len[t] + 1;      // 最短路边数+1
                
                // 检测负环:边数≥n → 约束矛盾,无解
                if (len[to] == n) {
                    cout << "NO" << endl;
                    exit(0); // 直接退出程序
                }
                
                // 若节点to不在队列中,压入队列并标记
                if (!vis[to]) {
                    q.push(to);
                    vis[to] = 1;
                }
            }
        }
    }
    
    // 输出一组合法解(dis[i]即为x_i的取值)
    for (int i = 1; i <= n; i++) {
        cout << dis[i] << " ";
    }
    
    return 0;
}

posted @ 2026-01-20 09:29  Rookie青果  阅读(1)  评论(0)    收藏  举报