把每小时的 8 小时窗口需求写成上班人数前缀和的差分约束,二分总雇佣人数 total 并把它参数化进跨零点的约束里,建图用 SPFA 判正环决定可行性。
OJ: roj
题目 ID: 3214
难度:提高+/省选-
标签:图论差分约束二分答案SPFApython
创建: 2026-10-02 02:27
更新: 2026-10-10 11:30
形式化题目
管理员给出 24 个小时的最低在岗人数
要求选出一个子集并安排上班,使每小时在岗人数都不低于需求,并最小化选中的人数;
无解输出 No Solution。
等价地,记
样例示意。 样例中
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | … | 22 | 23 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | … | 0 | 1 | |
| 是否覆盖 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | – | … | – | ✓ |
t=23 的班次覆盖
正解
思路
朴素做法为什么不行。 最直接的想法是枚举所有
瓶颈在于约束被拆散了。 每条需求管的是「8 个连续小时的人数之和」,每个
第 1 步:用前缀和把「窗口和」变成「两点之差」。 记
则
对于第
一条标准的两点差约束。
第 2 步:跨零点的窗口要绕回上一轮。 当
这里出现了一个未知量 total。
第 3 步:把 total 当作已知参数。 若假设 total 已知,上面的式子变成
| 约束来源 | 不等式 | 建边 |
|---|---|---|
| 人数非负 | ||
| 人数不超申请人 | ||
| 不跨零点的需求( |
||
| 跨零点的需求( |
||
| 固定总人数 | ||
| 固定总人数 |
第 4 步:差分约束判正环。 系统里所有约束都是 total 不可行;若无正环,取每个点的最长路长度作为 total 可行。实现上用 SPFA 松弛,某点被松弛满「点数」次即说明有正环。
第 5 步:外层二分。 可行性对 total 单调:若 total = 0 表示一个人都不招,需求全为 0 时才可行;二分全部失败即无解。
把上面的判定打包成 shift_ok(total),外层二分就是 min_cashiers。细节上,
第 3 步的两段循环分别对应两个分支:range(SHIFT, HOURS + 1) 处理 range(1, SHIFT) 处理
代码
/**
* 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-10 11:30
* update_at: 2026-10-10 11:30
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int HOURS = 24;
const int SHIFT = 8;
const ll NEG = -1000000000LL;
int need[25];
int hired[25];
// 总雇佣人数为 total 时是否存在满足需求的排班:SPFA 判正环
bool shift_ok(int total) {
vector<pair<int, ll> > edges[25];
for (int i = 0; i < HOURS; i++) {
edges[i].push_back(make_pair(i + 1, 0LL));
edges[i + 1].push_back(make_pair(i, -(ll)hired[i]));
}
for (int i = SHIFT; i <= HOURS; i++)
edges[i - SHIFT].push_back(make_pair(i, (ll)need[i - 1]));
for (int i = 1; i < SHIFT; i++)
edges[i + 16].push_back(make_pair(i, (ll)need[i - 1] - total));
edges[0].push_back(make_pair(HOURS, (ll)total));
edges[HOURS].push_back(make_pair(0, -(ll)total));
ll dist[25];
int relax_count[25];
bool in_queue[25];
for (int i = 0; i <= HOURS; i++) {
dist[i] = NEG; relax_count[i] = 0; in_queue[i] = false;
}
dist[0] = 0; in_queue[0] = true;
deque<int> q;
q.push_back(0);
while (!q.empty()) {
int u = q.front(); q.pop_front();
in_queue[u] = false;
ll du = dist[u];
for (size_t i = 0; i < edges[u].size(); i++) {
int v = edges[u][i].first;
ll w = edges[u][i].second;
if (du + w > dist[v]) {
dist[v] = du + w;
relax_count[v]++;
if (relax_count[v] >= HOURS + 1) return false; // 存在正环
if (!in_queue[v]) {
in_queue[v] = true;
q.push_back(v);
}
}
}
}
return true;
}
int min_cashiers(int total_applicants) {
int low = 0, high = total_applicants, answer = -1;
while (low <= high) {
int mid = (low + high) / 2;
if (shift_ok(mid)) {
answer = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return answer;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// 与 main.py 一致:把输入全部读成 token,格式不符(非本组数据)时提前停止,
// 已读到的组仍照常输出。
vector<int> tokens;
int x;
while (cin >> x) tokens.push_back(x);
size_t idx = 0;
if (tokens.empty()) return 0;
int T = tokens[idx++];
vector<string> out;
for (int case_ = 0; case_ < T; case_++) {
// 读 24 个需求
if (idx + HOURS > tokens.size()) break;
for (int i = 0; i < HOURS; i++) need[i] = tokens[idx++];
if (idx >= tokens.size()) break;
int nn = tokens[idx++];
if (idx + nn > tokens.size()) break;
for (int i = 0; i < HOURS; i++) hired[i] = 0;
bool valid = true;
for (int i = 0; i < nn; i++) {
int t = tokens[idx++];
if (t < 0 || t >= HOURS) { valid = false; break; }
hired[t]++;
}
if (!valid) break;
int sum = 0;
for (int i = 0; i < HOURS; i++) sum += hired[i];
int best = min_cashiers(sum);
out.push_back(best < 0 ? "No Solution" : to_string(best));
}
for (size_t i = 0; i < out.size(); i++) cout << out[i] << "\n";
if (out.empty()) cout << "\n";
return 0;
}#!/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-10-02 02:27
# update_at: 2026-10-02 02:36
import sys
from collections import deque
from collections.abc import Iterator
HOURS = 24 # 一天 24 个时段,前缀和数组长度为 25
SHIFT = 8 # 每人连续工作 8 小时
NEG = -(10 ** 9) # 最长路里的“不可达”哨兵,比任何合法距离都小
def shift_ok(total: int, need: list[int], hired: list[int]) -> bool:
"""总雇佣人数恰好为 total 时,是否存在满足需求的排班。
设 s[i] 是 0..i-1 时段开始上班人数的前缀和(s[0]=0,s[24]=total)。
题面只给“起始人数”的计数 hired[],所以每小时在线人数就是 8 个连续前缀和之差。
把每条需求化成差分约束的边后,用 SPFA 判正环:有正环说明约束自相矛盾。
"""
edges: list[list[tuple[int, int]]] = [[] for _ in range(HOURS + 1)]
# 0 <= s[i+1]-s[i] <= hired[i]:第 i 小时上岗人数非负且不超过可用人数。
for i in range(HOURS):
edges[i].append((i + 1, 0))
edges[i + 1].append((i, -hired[i]))
# 循环变量取 i = h+1 = 8..24,即不跨零点的小时 h = 7..23:
# 覆盖它的班次全部从 [h-7, h] 开始,故 s[h+1]-s[h-7] >= need[h]。
for i in range(SHIFT, HOURS + 1):
edges[i - SHIFT].append((i, need[i - 1]))
# 循环变量取 i = h+1 = 1..7,即跨零点的小时 h = 0..6:
# 绕回来的那段班次从上一轮的 h+17 = i+16 开始,故 s[i]-s[i+16] >= need[h]-total(把常量 total 移右)。
for i in range(1, SHIFT):
edges[i + 16].append((i, need[i - 1] - total))
# 固定 s[24] = total:两条反向边把差值锁死。
edges[0].append((HOURS, total))
edges[HOURS].append((0, -total))
dist = [NEG] * (HOURS + 1)
dist[0] = 0
relax_count = [0] * (HOURS + 1)
in_queue = [False] * (HOURS + 1)
in_queue[0] = True
queue = deque([0])
while queue:
u = queue.popleft()
in_queue[u] = False
du = dist[u]
for v, w in edges[u]:
if du + w > dist[v]:
dist[v] = du + w
relax_count[v] += 1
if relax_count[v] >= HOURS + 1: # 松弛次数达点数即存在正环
return False
if not in_queue[v]:
in_queue[v] = True
queue.append(v)
return True
def min_cashiers(need: list[int], hired: list[int], total_applicants: int) -> int:
"""二分最少雇佣人数:可行性与雇佣人数单调相关,取下界。"""
low, high, answer = 0, total_applicants, -1
while low <= high:
mid = (low + high) // 2
if shift_ok(mid, need, hired):
answer, high = mid, mid - 1
else:
low = mid + 1
return answer
def read_case(data: Iterator[int]) -> tuple[list[int], list[int]]:
"""顺序读一组数据,返回每小时需求与各起始时刻申请人数。"""
need = [next(data) for _ in range(HOURS)] # R(0)..R(23)
n = next(data) # 申请人数量
starts = [next(data) for _ in range(n)] # 各申请人起始时刻
if not all(0 <= t < HOURS for t in starts):
raise ValueError
start_count = [0] * HOURS # 想从第 i 小时开始上班的申请人数
for t in starts:
start_count[t] += 1
return need, start_count
def solve() -> None:
raw = sys.stdin.buffer.read().split()
if not raw:
return
data = iter(map(int, raw))
out: list[str] = []
T = next(data)
for _ in range(T):
try:
need, start_count = read_case(data)
except (StopIteration, ValueError): # 后文格式不符,已读到的组仍照常输出
break
best = min_cashiers(need, start_count, sum(start_count))
out.append("No Solution" if best < 0 else str(best))
sys.stdout.write("\n".join(out) + "\n")
if __name__ == "__main__":
solve()复杂度
一天固定 24 小时、25 个图节点、约 74 条边,所以一次可行性判定是 O(1) 级别的建图开销加输出缓冲。
实测 13 组、最大
总结
- 滑动窗口和 → 前缀和之差:
x_{a}+...+x_b = s_{b+1}-s_a,这是把需求翻译成差分约束的关键一步。 - 跨零点要绕回上一轮:
时窗口有一段落在 ~ 之间,会引入未知量 。 - 未知的总人数用二分消除:把
当参数带进约束,可行性对 total单调, 于是二分最小可行值。 - 正环 ⇔ 无解:
型系统用最长路 SPFA 判正环,比直接求解简单得多。 - 一天只有 24 小时,图极小,所以 Python 完全跑得动;难点在建模而不在常数。
图示解析
下面这张图给出从原始输入到「二分 + 差分约束」的建模路线,以及每步用到的量。
flowchart TD
A["输入 R[0..23] 与申请人数 c[0..23]"] --> B["设 x_h = 从 h 点上岗的人数"]
B --> C["前缀和 s_k = x_0+...+x_{k-1}<br/>s_0=0, s_24=total"]
C --> D{"窗口是否跨零点?"}
D -->|"h >= 7"| E["s_{h+1} - s_{h-7} >= R(h)"]
D -->|"h <= 6"| F["s_{h+1} - s_{h+17} >= R(h) - total"]
E --> G["建边求最长路"]
F --> G
C --> H["0 <= s_{h+1}-s_h <= c_h<br/>s_24 = total"]
H --> G
G --> I{"SPFA 有正环?"}
I -->|"有"| J["total 不可行"]
I -->|"无"| K["total 可行"]
J --> L["二分 [0, N] 找最小可行 total"]
K --> L
L --> M["输出答案或 No Solution"]
观察重点:图中 D 的分岔是本题唯一的建模陷阱——跨零点的窗口必须绕回上一轮的
total 移成右边的常量;H 分支把「总人数」钉死,
否则系统会用「不招满 total 人」的松弛解骗过判定;I 把可行性问题转成图上的判环问题,
使整个判定只在一个 25 点的图上完成。二分(L)之所以能放在最外层,
是因为人数越多需求越容易满足。
第二张图对比两种窗口的起点分布,说明为什么要分成两段循环:
第 h 小时在岗的 8 个班次起点(模 24):
h >= 7(不跨零点):
起点 h-7 .. h 全在同一轮 [0,23]
s_k 轴: ... s_{h-7} ──────────── s_{h+1} ...
└──── 差 = 窗口和 ────┘
h <= 6(跨零点):
起点 h-7..-1 实际是上一轮的 h+17..23
s_k 轴: s_0 ... s_{h+1} s_{h+17} ... s_{24}
└ 0~h 段 ┘ └ h+17~23 段 ┘
窗口和 = (s_{h+1}-s_0) + (s_{24}-s_{h+17})观察重点:跨零点时窗口被切成「本轮开头」和「上一轮结尾」两块,第二块用
total。