差分约束:把不等式组变成最短路
代码模板把 x_v-x_u≤w 看成三角不等式,用最短路求差值上限、用负环判无解,并整理常见的不等式变形。
一句话算法
把
问题模型
给定
其中
- 判断这个不等式组有没有解;
- 有解时,求出某个差值
的最大可能值,或任意给出一组可行解。
注意约束比较的永远是两个变量之差,所以单独一个变量的绝对值没有意义。若
核心直觉:最短路自己就是一串不等式
单源最短路跑完之后,每条边都满足
这个式子不用额外证明:跑完之后若还有
反过来把方向对上:给出的约束是 dis 就是一个满足全部约束的解。
为什么最短路上界最紧
看三个变量、三条约束:
建图后
任取一条
所以第一句话可以拆成两个动作:
- 求差值上界 → 跑最短路,答案是
dis[s](在为源点、边按 建好之后)。 - 判断有没有解 → 跑最短路时若出现负环,约束矛盾。
算法步骤
求 的最大值
- 对每条
,建边 ,权值为 。 - 先做全局可行性检查:所有点距离初始化为
,全部入队,跑 SPFA。若出现负环,说明约束自相矛盾,无解。 - 再以
为源点跑一次普通单源最短路(其余点距离为 INF)。 dis[s]就是答案;若它仍是INF,说明没有路径约束,差值可以任意大。
求任意一组可行解
- 同样建图。
- 所有点距离初始化为
,全部入队跑一次 SPFA。 - 若出现负环,无解;否则
dis[1..n]就是一组可行解。
算法证明
关键不变量: 当所有点距离初始为
- 队列排空意味着没有任何边还能松弛,即
对全部边成立。 - 每条边对应一条约束
,取 后,每条约束都被满足。所以 dis是一组可行解。 - 若过程中出现负环,沿环把环上约束相加:环的边权和为负,而每个
相加后得到 负数和,矛盾。所以负环存在即无解。
再说明为什么
- 上界:任取一组可行解
和一条 的路径,路径上每条边都是一个已成立的约束 。把路径逐边相加,就得到 这条路径的长度。所有路径里最短的那条给出最紧的上界,所以任何可行解都满足 。 - 可取到:取
(不可达的点随便取值),每条边都满足 ,即 ,约束全部成立。而这时 正好等于 。 - 结论:
既是一个上界,又能被某组可行解取到,所以它就是最大值。
第 2 步说明:最短路跑出来的距离本身就是一组可行解,这也是“求最短路”与“求最大值”能互相转化的原因。
为什么必须判负环
若图中存在负环,可以沿环无限绕圈让距离不断变小,dis 没有下限,“最短距离”根本不存在,上一步的论证也就整体失效——不是答案变成某个很负的数,而是题目无解。
复杂度分析
SPFA 最坏时间复杂度为
当图的结构允许时,可以换成更稳定的算法:
| 情形 | 可以选择 | 时间复杂度 |
|---|---|---|
| 一般差分约束,含负权边 | SPFA | 最坏 |
| 只有权值不小于 |
Dijkstra | |
| 约束全形如 |
拓扑序 DP |
第三种就是“缩点后是 DAG”的常见优化,P3275 会用到它。
不等式标准化速查
只看两种写法:
| 题面写法 | 归一到“不超过”(跑最短路) | 归一到“不小于”(跑最长路) |
|---|---|---|
| 不变,边 |
||
| 不变,边 |
||
| 两个方向各一条 |
同左 | |
| 取对数后归入第一行 | 取对数后归入第二行 |
对照方法:最短路一侧的边
最后一行提醒:遇到乘法或除法先想取对数,把
代码模板
两个版本接口一一对应:add_leq、add_geq、add_eq 负责把不同写法的不等式统一成 feasible() 做全局可行性检查并留下可行解;shortest(s) 求
feasible() 与 shortest(s) 分开,是因为很多题只要求某个点在自己连通块里无矛盾,分开写才能自由选择检查范围。两者都用 path_len 记录当前距离对应的路径边数,达到
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <algorithm>
#include <queue>
#include <utility>
#include <vector>
// 差分约束:把 x_v - x_u <= w 统一成边 u -> v 权 w,跑单源最短路判可行性、求差值上界。
// 点编号 1..n。add_leq / add_geq / add_eq 只负责统一不等号方向,不检查输入。
// 边权可正可负,所以用 SPFA;出现负环表示约束矛盾。
struct DiffConstraints {
using ll = long long;
static constexpr ll INF = (1LL << 62);
struct Edge {
int to;
ll w;
};
int n;
std::vector<std::vector<Edge>> g;
std::vector<ll> dis; // feasible() 后是一组可行解;shortest(s) 后是 s 到各点的最短路
std::vector<int> cnt; // 当前 dis 对应的路径边数,达到 n 说明绕出了环
std::vector<char> in_queue;
explicit DiffConstraints(int n)
: n(n), g(n + 1), dis(n + 1, 0), cnt(n + 1, 0), in_queue(n + 1, false) {}
// 约束 x_v - x_u <= w,也就是 x_v <= x_u + w。
void add_leq(int u, int v, ll w) {
g[u].push_back({v, w});
}
// 约束 x_u - x_v >= w,也就是 x_v <= x_u - w。
void add_geq(int u, int v, ll w) {
g[u].push_back({v, -w});
}
// 约束 x_u == x_v,拆成 x_u - x_v <= 0 与 x_v - x_u <= 0。
void add_eq(int u, int v) {
add_leq(u, v, 0);
add_leq(v, u, 0);
}
// 全局可行性:所有点距离取 0 并全部入队,等价于加一个到所有点都是 0 权边的超级源点。
// 返回 false 表示存在负环、约束无解;返回 true 时 dis 就是一组可行解。
bool feasible() {
std::fill(dis.begin(), dis.end(), 0);
std::fill(cnt.begin(), cnt.end(), 0);
std::fill(in_queue.begin(), in_queue.end(), true);
std::queue<int> q;
for (int u = 1; u <= n; ++u) q.push(u);
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& e : g[u]) {
if (dis[u] + e.w >= dis[e.to]) continue; // 没有严格改善
dis[e.to] = dis[u] + e.w;
cnt[e.to] = cnt[u] + 1;
if (cnt[e.to] >= n) return false; // 更短的路径用了至少 n 条边,必然有负环
if (!in_queue[e.to]) {
q.push(e.to);
in_queue[e.to] = true;
}
}
}
return true;
}
// 单源最短路:dis[source] = 0,其余为 INF。返回 false 表示从 source 可达负环。
// 与 feasible() 分开是因为很多题只要求某个点在它自己的连通块里无矛盾。
bool shortest(int source) {
std::fill(dis.begin(), dis.end(), INF);
std::fill(cnt.begin(), cnt.end(), 0);
std::fill(in_queue.begin(), in_queue.end(), false);
dis[source] = 0;
std::queue<int> q;
q.push(source);
in_queue[source] = true;
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& e : g[u]) {
if (e.w >= INF - dis[u]) continue; // 先判断再相加,避免溢出
ll candidate = dis[u] + e.w;
if (candidate >= dis[e.to]) continue;
dis[e.to] = candidate;
cnt[e.to] = cnt[u] + 1;
if (cnt[e.to] >= n) return false;
if (!in_queue[e.to]) {
q.push(e.to);
in_queue[e.to] = true;
}
}
}
return true;
}
};
Python 版不包装成对象,三个 add_* 直接往传入的邻接表上添边,不需要先建一个模板实例;feasible() 与 shortest(s) 都返回解,无解时返回 None,省掉了“先判 bool 再读成员”的两步写法。负环仍然靠路径边数判定,shortest 里不需要像 C++ 那样先判断相加是否溢出,int 是任意精度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 差分约束:把 x_v - x_u <= w 统一成边 u -> v 权 w,跑单源最短路判可行性、求差值上界。
# 点编号 1..n(dis[0] 不用)。add_leq / add_geq / add_eq 只负责统一不等号方向,不检查输入。
# 边权可正可负,所以用 SPFA;出现负环表示约束矛盾。
# Python 的 int 是任意精度,不必像 C++ 那样在相加前判溢出。
from collections import deque
INF = 1 << 62 # 哨兵,比任何合法差值都大
type WeightedAdj = list[list[tuple[int, int]]] # graph[u]:若干 (终点 v, 边权 w)
def add_leq(graph: WeightedAdj, u: int, v: int, w: int) -> None:
"""约束 x_v - x_u <= w,也就是 x_v <= x_u + w。"""
graph[u].append((v, w))
def add_geq(graph: WeightedAdj, u: int, v: int, w: int) -> None:
"""约束 x_u - x_v >= w,也就是 x_v <= x_u - w。"""
graph[u].append((v, -w))
def add_eq(graph: WeightedAdj, u: int, v: int) -> None:
"""约束 x_u == x_v,拆成 x_u - x_v <= 0 与 x_v - x_u <= 0。"""
add_leq(graph, u, v, 0)
add_leq(graph, v, u, 0)
def feasible(graph: WeightedAdj) -> list[int] | None:
"""全局可行性:返回一组可行解 dis(dis[0] 不用);约束自相矛盾时返回 None。
所有点距离取 0 并全部入队,等价于加一个到每个点都是 0 权边的超级源点:
初值只是假定值,整体平移不影响合法性,所以取 0 最省事。
用路径边数判负环:更短的路走了至少 n 条边,路径上必然绕出了负环。
"""
n = len(graph) - 1
dis = [0] * (n + 1)
path_len = [0] * (n + 1) # 当前 dis 对应的路径边数
in_queue = [True] * (n + 1)
q = deque(range(1, n + 1))
while q:
u = q.popleft()
in_queue[u] = False
for v, w in graph[u]:
shorter = dis[u] + w
if shorter >= dis[v]:
continue # 这条边给不出更紧的上界
dis[v] = shorter
path_len[v] = path_len[u] + 1
if path_len[v] >= n:
return None
if not in_queue[v]:
q.append(v)
in_queue[v] = True
return dis
def shortest(graph: WeightedAdj, source: int) -> list[int] | None:
"""单源最短路:返回 dis(dis[source] = 0,不可达为 INF);有可达负环时返回 None。
与 feasible() 分开,是因为很多题只要求某个点在它自己的连通块里无矛盾:
这里只从 source 出发,别的连通块里的负环发现不了。
"""
n = len(graph) - 1
dis = [INF] * (n + 1)
path_len = [0] * (n + 1)
in_queue = [False] * (n + 1)
dis[source] = 0
in_queue[source] = True
q = deque([source])
while q:
u = q.popleft()
in_queue[u] = False
for v, w in graph[u]:
shorter = dis[u] + w
if shorter >= dis[v]:
continue
dis[v] = shorter
path_len[v] = path_len[u] + 1
if path_len[v] >= n:
return None
if not in_queue[v]:
q.append(v)
in_queue[v] = True
return dis
代码实现
下面的程序读入一组约束,输出 NO(矛盾)或 UNBOUNDED(没有上界)。
输入: 第一行 n m s t;接下来 a b c,表示
输出: 一个整数、NO 或 UNBOUNDED。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// 差分约束求 x_s - x_t 的最大值。
// 输入:第一行 n m s t;接下来 m 行 a b c,表示约束 x_a - x_b <= c(点编号 1..n)。
// 输出:x_s - x_t 的最大值;约束矛盾输出 NO;差值没有上界输出 UNBOUNDED。
#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
const long long INF = (1LL << 62);
struct Edge {
int to;
long long w;
};
std::vector<std::vector<Edge>> g;
std::vector<long long> dis;
std::vector<int> cnt;
std::vector<char> in_queue;
void add_leq(int u, int v, long long w) {
g[u].push_back({v, w});
}
// 全局可行性:所有点距离取 0 并全部入队,等价于加一个 0 权超级源点。
bool feasible(int n) {
std::fill(dis.begin(), dis.end(), 0);
std::fill(cnt.begin(), cnt.end(), 0);
std::fill(in_queue.begin(), in_queue.end(), true);
std::queue<int> q;
for (int u = 1; u <= n; ++u) q.push(u);
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& e : g[u]) {
if (dis[u] + e.w >= dis[e.to]) continue;
dis[e.to] = dis[u] + e.w;
cnt[e.to] = cnt[u] + 1;
if (cnt[e.to] >= n) return false;
if (!in_queue[e.to]) {
q.push(e.to);
in_queue[e.to] = true;
}
}
}
return true;
}
// 单源最短路;返回 false 表示从 source 可达负环。
bool shortest(int n, int source) {
std::fill(dis.begin(), dis.end(), INF);
std::fill(cnt.begin(), cnt.end(), 0);
std::fill(in_queue.begin(), in_queue.end(), false);
dis[source] = 0;
in_queue[source] = true;
std::queue<int> q;
q.push(source);
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& e : g[u]) {
if (e.w >= INF - dis[u]) continue;
long long candidate = dis[u] + e.w;
if (candidate >= dis[e.to]) continue;
dis[e.to] = candidate;
cnt[e.to] = cnt[u] + 1;
if (cnt[e.to] >= n) return false;
if (!in_queue[e.to]) {
q.push(e.to);
in_queue[e.to] = true;
}
}
}
return true;
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n, m, s, t;
if (!(std::cin >> n >> m >> s >> t)) return 0;
g.assign(n + 1, {});
dis.assign(n + 1, 0);
cnt.assign(n + 1, 0);
in_queue.assign(n + 1, false);
for (int i = 0; i < m; ++i) {
int a, b;
long long c;
std::cin >> a >> b >> c;
add_leq(b, a, c); // x_a - x_b <= c 就是 x_a <= x_b + c
}
if (!feasible(n)) {
std::cout << "NO\n"; // 约束自相矛盾
return 0;
}
shortest(n, t); // 已确认无负环,t 到 s 的最短路就是 x_s - x_t 的上界
if (dis[s] == INF) {
std::cout << "UNBOUNDED\n"; // t 到不了 s,差值可以被拉得任意大
} else {
std::cout << dis[s] << '\n';
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#!/usr/bin/env python3
# 差分约束求 x_s - x_t 的最大值。
# 输入:第一行 n m s t;接下来 m 行 a b c,表示约束 x_a - x_b <= c(点编号 1..n)。
# 输出:x_s - x_t 的最大值;约束矛盾输出 NO;差值没有上界输出 UNBOUNDED。
import sys
from collections import deque
INF = 1 << 62 # 哨兵,比任何合法差值都大
type WeightedAdj = list[list[tuple[int, int]]] # graph[u]:若干 (终点 v, 边权 w)
def feasible(graph: WeightedAdj) -> bool:
"""全局可行性:所有点距离取 0 并全部入队,等价于加一个 0 权超级源点。
用路径边数判环:更短的路走了至少 n 条边,路径上必然绕出了负环。
"""
n = len(graph) - 1
dis = [0] * (n + 1)
path_len = [0] * (n + 1) # 当前 dis 对应的路径边数
in_queue = [True] * (n + 1)
q = deque(range(1, n + 1))
while q:
u = q.popleft()
in_queue[u] = False
for v, w in graph[u]:
shorter = dis[u] + w
if shorter >= dis[v]:
continue # 这条边给不出更紧的上界
dis[v] = shorter
path_len[v] = path_len[u] + 1
if path_len[v] >= n:
return False
if not in_queue[v]:
q.append(v)
in_queue[v] = True
return True
def shortest(graph: WeightedAdj, source: int) -> list[int]:
"""单源最短路:返回 dis(dis[source] = 0,不可达为 INF)。
调用前必须已用 feasible() 排除全图负环,所以这里不再重复判环。
"""
n = len(graph) - 1
dis = [INF] * (n + 1)
in_queue = [False] * (n + 1)
dis[source] = 0
in_queue[source] = True
q = deque([source])
while q:
u = q.popleft()
in_queue[u] = False
for v, w in graph[u]:
shorter = dis[u] + w
if shorter >= dis[v]:
continue
dis[v] = shorter
if not in_queue[v]:
q.append(v)
in_queue[v] = True
return dis
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
s, t = next(data), next(data)
graph: WeightedAdj = [[] for _ in range(n + 1)]
for _ in range(m):
a, b, c = next(data), next(data), next(data)
graph[b].append((a, c)) # x_a - x_b <= c 就是 x_a <= x_b + c
if not feasible(graph):
print('NO') # 约束自相矛盾
return
dis = shortest(graph, t) # 已确认无负环,t 到 s 的最短路就是 x_s - x_t 的上界
print('UNBOUNDED' if dis[s] == INF else dis[s])
if __name__ == "__main__":
solve()
测试用例
普通情况:两条路径取更紧的
4 5 4 1
2 1 2
3 1 7
4 1 8
3 2 3
4 3 2
输出:
7
三角不等式的最小例子
这里求
3 3 3 1
2 1 2
3 2 3
3 1 9
输出:
5
边界情况:矛盾与无上界
矛盾的约束:
2 2 2 1
1 2 1
2 1 -3
NO。
只有单个变量时:
1 0 1 1
输出 0。
从
3 1 3 1
2 1 5
输出 UNBOUNDED。
应用分类详解
差分约束本身不是求某个具体答案,而是把一堆“差不超过多少”翻译成一张图。判断题目是否适用时,先找“差”,再统一不等号方向。
一、判断不等式组是否有解
识别信号: 给出若干大小关系,问“能否同时满足”、“是否存在一种方案”。答案只有 Yes/No 两种。
这类题不需要求具体数值,只要建图后跑一次全点入队的 SPFA,看会不会出现负环。全点入队而不是单源,是因为题目要求所有约束都成立,不连通的部分也要检查。
二、求两个量的最大差
识别信号: 要求“
把约束写成 dis[s] 是 INF 时要按题意处理成“无上界”或题面指定的标记,不能直接输出一个巨大的数。
三、求两个量的最小差
识别信号: 要求“至少相差多少”、“最短距离”、“最少需要多少”。
两条路都可以走:
- 把约束统一成
(即边 权 ),跑最长路,出现正环则无解。 - 或者全部乘
换成 ,仍然跑最短路。
两者是同一个系统的对偶写法。选定一种后全部约束都要换到同一侧,混用会出现方向错误。
口诀:
求最短路得到最大值,无解是负环; 求最长路得到最小值,无解是正环。
四、区间计数类(前缀和建模)
识别信号: 题目出现“区间
设
识别关键是把“选了多少个”变成前缀差,一旦变成前缀差,区间约束就自动成了两个变量之差。
五、比值约束(取对数)
识别信号: 约束形如
两边取对数:
经典例题
1. P5960 【模板】差分约束
直接求任意一组可行解。重点是建边方向(
2. P1993 小 K 的农场
三种输入分别是“至少”、“至多”、“相等”,正好对应三种不等式的统一动作:
| 题意 | 转化 | 建边 |
|---|---|---|
| 不变 | ||
| 两个方向的差都不超过 |
只输出 Yes/No,所以只跑可行性检查。这道题适合练习“先把题面三种说法翻译成同一种不等号”:模板的 add_geq(a, b, c)、add_leq(b, a, c)、add_eq(a, b) 正好对应上表三行。
3. 「一本通 3.4 练习 2」排队布局
这道题有“必须按顺序站”、“两人距离不超过 -1、无上界 -2、否则输出最大值。正好把本文的两步完整用一遍:
- 顺序约束
建边 权 ; - 上限约束建
权 ; - 下限约束
变成 ,建 权 。
先全点入队判负环(无解输出 -1),再以 INF 说明 -2。这道题的三种输出正好对应本文的三种结局。
4. P3275 [SCOI2011] 糖果
要求每人至少一颗糖,并满足五类大小关系,求最小总糖果数。这是“求最小差”那一类,约束统一写成
| 输入 | 含义 | 建边 |
|---|---|---|
| 两个方向各一条 |
||
本题