把 24 小时需求写成前缀和的差分约束,二分总雇佣人数 M 并用 SPFA 最长路判定可行性。
OJ: roj
题目 ID: 1510
难度:提高+/省选-
标签:差分约束最短路二分答案
创建: 2026-09-30 15:31
更新: 2026-10-07 11:01
形式化题目
每天分 24 个小时段,第
有
注意时间是按 24 小时循环的:
记
在满足全部约束的前提下最小化总雇佣人数 No Solution。
样例覆盖关系:下表中 ● 表示该申请人覆盖了对应小时段,R 行是需求。
| 申请人 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | … | 22 | 23 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | ● | ● | ● | ● | ● | ● | ● | ● | |||
| 1 | ● | ● | ● | ● | ● | ● | ● | ||||
| 10 | |||||||||||
| 22 | ● | ● | ● | ● | ● | ● | ● | ● | ● | ||
| 23 | ● | ● | ● | ● | ● | ● | ● | ● | |||
| 1 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 1 |
只雇佣
正解
思路
为什么不能贪心。 一个人覆盖连续 8 小时,所以
第一步:用前缀和把"区间求和"变成"两点之差"。
令前缀和
于是
为什么
第二步:把目标
这样"求最优"就变成了"判定某个
第三步:可行性关于
若
第四步:判定就是差分约束 + 最长路。
把每条不等式
| 含义 | 不等式 | 边 |
|---|---|---|
| 前缀不减( |
||
| 人数上界( |
||
| 同一天需求( |
||
| 跨午夜需求( |
||
| 钉死总人数 |
这类系统的最长路解
因此判定
下面是样例对应的约束图:粗链是前缀不减的
flowchart LR S0["S(0)"] --> S1["S(1)"] --> S2["S(2)"] --> S7["S(7)"] --> Sx["..."] --> S17["S(17)"] --> S18["S(18)"] --> S23["S(23)"] --> S24["S(24)"] S17 -.->|"R(0) - M"| S1 S18 -.->|"R(1) - M"| S2 S23 -.->|"R(6) - M"| S7 S0 -.->|"R(7)"| S8["S(8)"] S16["S(16)"] -.->|"R(23)"| S24
读图要点:需求边不一定沿链的方向。
图中每条链边
权 ,反向的 权 为清晰起见省略。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 15:31
# update_at: 2026-09-30 15:31
import sys
from collections import deque
HOURS = 24 # 一天 24 个整点区间
SHIFT = 8 # 每人连续工作恰好 8 小时
def feasible(adj: list[list[tuple[int, int]]], target: int) -> bool:
"""跑最长路判断约束是否相容,并检查 S(24) 恰好等于 target。
差分约束中 S(24) >= target 与 S(24) <= target 两条边已把 S(24) 钉死;
若约束互相矛盾就会出现正环,此时某点会被反复松弛,超过 HOURS 轮即可判定。
"""
dist = [0] * (HOURS + 1)
in_queue = [True] * (HOURS + 1)
queue = deque(range(HOURS + 1)) # 超级源点:所有点一起入队,等价于各连一条 0 权边
rounds = [0] * (HOURS + 1) # 每个点被成功松弛的次数,超过 HOURS 次说明有正环
while queue:
u = queue.popleft()
in_queue[u] = False
du = dist[u] # 弹出时才读,保证用的是 u 的最新距离
for v, w in adj[u]:
if du + w > dist[v]:
dist[v] = du + w
rounds[v] += 1
if rounds[v] > HOURS:
return False # 正环:约束互相矛盾
if not in_queue[v]:
in_queue[v] = True
queue.append(v)
return dist[HOURS] >= target
def build(need: list[int], supply: list[int], total: int) -> list[list[tuple[int, int]]]:
"""按前缀和 S(i) = x(0)+...+x(i-1) 建图,边 (v, w) 表示 S(v) >= S(u) + w。
need[i] 是第 i 个整点区间的需求 R(i),supply[i] 是 t_i = i 的申请人数,
total 是假定的总雇佣人数 M = S(24)。
"""
adj: list[list[tuple[int, int]]] = [[] for _ in range(HOURS + 1)]
for i in range(HOURS):
adj[i].append((i + 1, 0)) # S(i+1) >= S(i):前缀不减,即 x(i) >= 0
adj[i + 1].append((i, -supply[i])) # S(i) >= S(i+1) - c(i):x(i) <= c(i)
for i in range(HOURS):
start = i - SHIFT + 1 # 覆盖第 i 小时的起点区间左端
if start >= 0:
adj[start].append((i + 1, need[i])) # 同一天:S(i+1) - S(start) >= R(i)
else:
# 跨午夜:S(i+1) + (M - S(i+17)) >= R(i),即 S(i+1) >= S(i+17) + R(i) - M
adj[start + HOURS].append((i + 1, need[i] - total))
adj[0].append((HOURS, total)) # S(24) >= S(0) + M
adj[HOURS].append((0, -total)) # S(0) >= S(24) - M,把 S(24) 锁成 M
return adj
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
out: list[str] = []
T = next(data)
for _ in range(T):
need = [next(data) for _ in range(HOURS)]
n = next(data)
supply = [0] * HOURS # 每个起始时刻的申请者人数,即 x(i) 的上界
for _ in range(n):
supply[next(data)] += 1
# 可行性对 M 单调:多雇一个已申请的人不会破坏任何时段,故二分最小 M。
lo, hi = 0, n
while lo < hi:
mid = (lo + hi) // 2
if feasible(build(need, supply, mid), mid):
hi = mid
else:
lo = mid + 1
out.append(str(lo) if feasible(build(need, supply, lo), lo) else "No Solution")
print('\n'.join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 05:32
* update_at: 2026-10-05 05:32
*/
// 差分约束 + 二分答案:
// 设 S(i) = x(0) + x(1) + ... + x(i-1),其中 x(i) 是 t = i 的被雇佣人数,S(24) = M 为总雇佣人数。
// 每个小时段 i 的在岗人数是起始时刻落在 {i-7, ..., i} (mod 24) 的人数和,写成前缀和之差后,
// 逐条约束都是 S(v) >= S(u) + w 的形式;把 M 用两条互反的边钉死,判定就变成最长路是否出现正环。
// 可行性关于 M 单调(多雇一人只会让人数变多),所以在 [0, N] 上二分最小可行的 M。
#include <bits/stdc++.h>
using namespace std;
const int HOURS = 24; // 一天 24 个小时段
const int SHIFT = 8; // 每人连续工作恰好 8 小时
const int NODES = 25; // 前缀和节点 S(0) ~ S(24)
const int MAXEDGE = 128; // 边数上限:24 * 2 条链边 + 24 条需求边 + 2 条钉死边 = 74
typedef long long ll;
// 链式前向星存图,每条边表示约束 S(v) >= S(u) + w
int head[NODES], to[MAXEDGE], nxt[MAXEDGE], edge_cnt;
int weight[MAXEDGE];
int need[HOURS]; // need[i] = R(i),第 i 个小时段最少需要的在岗人数
int supply[HOURS]; // supply[i] = 起始时刻为 i 的申请者人数,即 x(i) 的上界
ll dist[NODES]; // 最长路距离,最长路解是逐分量最小的可行前缀和
bool in_queue[NODES]; // 该节点当前是否在 SPFA 队列中
int relax_cnt[NODES]; // 每个点被成功松弛的次数,超过 NODES 次说明存在正环
// 加一条 u -> v、权 w 的边。
void add_edge(int u, int v, int w) {
edge_cnt++;
to[edge_cnt] = v;
weight[edge_cnt] = w;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
// 按假定的总雇佣人数 total 建图(total = S(24))。
void build_graph(int total) {
edge_cnt = 0;
for (int i = 0; i < NODES; i++) head[i] = 0;
for (int i = 0; i < HOURS; i++) {
add_edge(i, i + 1, 0); // S(i+1) >= S(i):前缀不减,即 x(i) >= 0
add_edge(i + 1, i, -supply[i]); // S(i) >= S(i+1) - c(i):x(i) <= c(i)
}
for (int i = 0; i < HOURS; i++) {
int start = i - SHIFT + 1; // 覆盖小时段 i 的起始时刻区间左端
if (start >= 0) {
add_edge(start, i + 1, need[i]); // 同一天:S(i+1) - S(start) >= R(i)
} else {
// 跨午夜:S(i+1) + (S(24) - S(i+17)) >= R(i),即 S(i+1) >= S(i+17) + R(i) - M
add_edge(start + HOURS, i + 1, need[i] - total);
}
}
add_edge(0, HOURS, total); // S(24) >= S(0) + M
add_edge(HOURS, 0, -total); // S(0) >= S(24) - M,两条边把 S(24) 锁成 M
}
// 判定总雇佣人数为 total 时是否存在可行方案:跑最长路,有正环则约束矛盾。
bool feasible(int total) {
build_graph(total);
queue<int> q;
for (int i = 0; i < NODES; i++) {
dist[i] = 0; // 全部初值为 0 并入队,等价于超级源点向各点连 0 权边
in_queue[i] = true;
relax_cnt[i] = 0;
q.push(i);
}
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (int e = head[u]; e != 0; e = nxt[e]) {
int v = to[e];
if (dist[u] + weight[e] > dist[v]) {
dist[v] = dist[u] + weight[e];
relax_cnt[v]++;
if (relax_cnt[v] > NODES) return false; // 正环:约束互相矛盾
if (!in_queue[v]) {
in_queue[v] = true;
q.push(v);
}
}
}
}
return dist[HOURS] >= total;
}
void solve() {
int T;
scanf("%d", &T);
while (T--) {
for (int i = 0; i < HOURS; i++) scanf("%d", &need[i]);
int n;
scanf("%d", &n);
for (int i = 0; i < HOURS; i++) supply[i] = 0;
for (int i = 0; i < n; i++) {
int start_hour;
scanf("%d", &start_hour);
supply[start_hour]++;
}
// 可行性对 M 单调:多雇一个申请人不会让任何小时段的人数减少,故二分最小 M。
int lo = 0, hi = n;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (feasible(mid)) hi = mid;
else lo = mid + 1;
}
if (feasible(lo)) printf("%d\n", lo);
else printf("No Solution\n");
}
}
int main() {
solve();
return 0;
}复杂度
设
- 单次判定:SPFA 最长路,无正环时每点最多被松弛
次,每次扫其出边,故 , 且 都是与 无关的常数。 - 二分:
次判定, 时至多 10 次。 - 单组总时间
,空间 。
与 C++ 正解(差分约束 + 二分答案)同阶,只差常数因子。由于
总结
本题的关键是把"连续 8 小时的区间计数"翻译成前缀和的差,从而让每条小时段约束都变成
一对变量之间的不等式;再用"枚举总数
三个容易写错的地方:
- 跨午夜的需求边权必须减
,即 ,漏掉 会让 样例直接判成 No Solution; - 钉死
需要两条方向相反的边,只加一条会让判定对过大的 误判为可行; - 正环检测用"某点被成功松弛超过
次"来判定,不要在 SPFA 里漏掉这个出口, 否则矛盾输入会死循环。
图示解析
跨午夜是本题唯一的几何障碍。下表列出"覆盖小时段
| 小时段 |
覆盖它的起始时刻 |
被午夜切成的两段 | 覆盖人数 |
|---|---|---|---|
| 不切分,就是一段 | |||
观察重点:
- 判断一个起始时刻是否覆盖小时段
,等价于判断 ; 当 时这个集合被午夜切成两段,所以覆盖人数是两个前缀差之和,其中靠前的那段含 。 - 因为
正是要枚举的量,这条边的权里就必然出现 ; 这也是"每个候选 都要重新建图"的原因。 - 对比
与 :前者起点是一段连续下标,后者被拆成 和 两个片段—— 这种"跨过午夜回绕"正是循环时间带来的唯一麻烦,也是前缀和下标要用 而不是 的原因。
验证情况:3 个真实数据文件共 24 组全部通过;与指数级暴力在 1005 组随机小数据上逐行一致,
0 处不符;并实测确认可行性关于