线段树维护每段的 len/pre/suf/best(贴左、贴右、段内最长连续空房),入住时按「左孩子 → 跨中缝 → 右孩子」下降找最左空段,配懒标记做区间染色,单次业务 O(log N)。
OJ: roj
题目 ID: 3124
难度:提高
标签:线段树懒标记区间合并python
创建: 2026-10-01 18:30
更新: 2026-10-10 11:30
形式化题目
有一条长度为
- 入住:给定
,找出第一个长度至少为 的全 0 连续段,返回它的左端点(房间号从 1 开始), 并把该段(前 个位置)全部置为 1;若不存在这样的段,返回 且不改变序列。 - 退房:给定
,把区间 整段置为 0。
「房间号尽可能小」不是额外的贪心条件,它就是查询规则:返回所有合法连续段中最靠左的那个起点。
暴力解法
思路
直接用一个布尔数组 occ[1..N] 模拟:
- 退房逐个清空;
- 入住从 1 开始扫,用
run记录当前位置结尾的连续空房长度,一旦run == D就确定起点i - D + 1。
入住 (1 D):
run = 0
for i in 1..N:
run = run + 1 if occ[i] 是空 else 0
if run == D:
把 [i-D+1, i] 置为已住 ; 输出 i-D+1 ; break
扫完仍未找到: 输出 0
退房 (2 X D):
for i in X..X+D-1: occ[i] = 空正确性显然:run 就是「以
代码
按任务要求本题不提供 brute.cpp:暴力只是「维护 0/1 数组 + 线性扫描」,
思想上没有额外教学价值,瓶颈本身已经说明问题。验证改用真实测试数据。
复杂度
- 时间:单次入住
,单次退房 ,最坏 次访问。 - 空间:
。
瓶颈
慢的不是「改房间」——退房最多改
问题的核心是「第一个长度至少为
要提速,必须让每一段区间自己携带「连续空房」的结构信息,并且这些信息要能合并。
正解
关键观察
观察 1:连续空段可以用四个量概括。 对任意区间
| 量 | 含义 |
|---|---|
| 区间内的实际房间数 | |
| 贴着左端的最长连续空房数 | |
| 贴着右端的最长连续空房数 | |
| 整段内最长连续空房数(不要求贴边) |
就能把它与相邻区间拼起来。
观察 2:合并式只用这四个量。 设左段
以
观察 3:最靠左的可行起点可以在树上贪心求得。 有了
- 左孩子的
⇒ 左孩子内部就有,答案一定在左边; - 否则
⇒ 合法段正好贴着中缝跨越, 起点是 ,可直接返回; - 否则答案只可能在右孩子。
三步的优先级就是「尽可能小」的实现,因为左孩子覆盖的房间号全都小于右孩子。
下面用 8 个房间、房间 problem-analysis-workspace/viz_render.py 从 main.py 打印,与代码一致):
房间: 1 2 3 4 5 6 7 8
状态: . . # # . . . .
|
v
节点1 [1,8] len=8 pre=2 suf=4 best=4
├─ 节点2 [1,4] len=4 pre=2 suf=0 best=2
│ ├─ [1,2] len=2 pre=2 suf=2 best=2
│ └─ [3,4] len=2 pre=0 suf=0 best=0 <- 住满: 三个量全 0
└─ 节点3 [5,8] len=4 pre=4 suf=4 best=4根节点的 best = max(2, 4, 0 + 4) = 4:左孩子的 best = 2、右孩子的 best = 4、
跨中缝的 suf[2] + pre[3] = 0 + 4 = 4,其中右孩子那 4 间就是房间 5–8。
节点 [1,4] 的 pre = 2 而 suf = 0(右端房间 4 已住,贴右后缀为空),
两个贴边量可以完全不同,这正是「贴边」的含义。
换一个状态 ##...#..(房间 1、2、6 已住),它同时能触到三个分支:
| 节点 | ||||
|---|---|---|---|---|
| 4 | 0 | 2 | 2 | |
| 4 | 1 | 2 | 2 | |
| 8 | 0 | 2 | 3 |
根节点的
观察 4:
思路
线段树节点与懒标记
用带懒标记的线段树维护上述四个量。区间赋值天然适合懒标记:整段刷成同一颜色时,
要么整段空(
apply(node, color):
empty = 0 if color == 已住 else len[node]
pre[node] = suf[node] = best[node] = empty
lazy[node] = colorpush 把标记下传给孩子并清空自己的标记;modify 在完整覆盖时直接 apply 并返回,
否则 push 后递归、最后 pull 回来。
查询:先 push 再比较
query(node, l, r, D):
if best[node] < D: return 0 # 这一段连 D 间空房都没有
if l == r: return l # 走到叶子, best >= D 只能是 D == 1
push(node) # 先下传标记, 孩子的量才是最新的
mid = (l + r) // 2
if best[左] >= D: return query(左, l, mid, D)
if suf[左] + pre[右] >= D: return mid - suf[左] + 1
return query(右, mid + 1, r, D)两个容易写错的点:
- 比较之前必须
push,否则孩子身上可能还挂着父节点的懒标记,读到的是过期数据; - 第 2 步的返回值是
,不是 : 跨中缝的空段由「 的贴右后缀 + 的贴左前缀」构成,起点要往左回退 间。 它不需要进入孩子就能算出来,这也是查询只走一条路径、不拆区间的原因。
用样例推一遍
| 步骤 | 业务 | 输出 | 房间 1…10 |
|---|---|---|---|
| 1 | 1 3 |
1 | ###....... |
| 2 | 1 3 |
4 | ######.... |
| 3 | 1 3 |
7 | #########. |
| 4 | 1 3 |
0 | #########. |
| 5 | 2 5 5 |
— | ####...... |
| 6 | 1 6 |
5 | ########## |
(# 表示已入住,. 表示空房。)
第 4 步是关键:只剩第 10 间空房,query 直接返回 0,
且不调用 modify,所以第 5 步退房清空 5–9 号房间后,房间 5–10 恰好连成 6 间,
第 6 步的 1 6 才能在起点 5 入住成功。若查询失败时仍然改动了状态,
或者把补位叶的
代码
/**
* 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
*/
// Hotel:线段树维护连续空房,支持「最靠左的 d 间连续空房」与区间清空。
#include <cstdio>
typedef long long ll;
const ll MAXN = 100005;
const ll NONE = -1; // 延迟标记:-1 无标记,0 整段清空,1 整段入住
const ll OCCUPY = 1, CLEAR = 0;
ll length[MAXN * 4]; // 每个节点覆盖的房间数
ll pre[MAXN * 4]; // 该段从左端起的最长连续空房数
ll suf[MAXN * 4]; // 该段从右端起的最长连续空房数
ll best[MAXN * 4]; // 该段内部的最长连续空房数
ll lazy[MAXN * 4]; // 延迟标记
// 建树:叶子记区间长度(超出 n 的补 0),初始全部空房
ll prepare(ll n) {
ll M = 1; // 叶子数,同时就是房间坐标的上界
while (M < n) M <<= 1;
ll size = M << 1;
for (ll i = 0; i < size; i++) {
length[i] = 0;
pre[i] = suf[i] = best[i] = 0;
lazy[i] = NONE;
}
for (ll i = M; i < size; i++) {
if (1 <= i - M + 1 && i - M + 1 <= n) length[i] = 1;
// 叶子:三个统计量都与区间长度相同(length 只取 0 / 1)
pre[i] = suf[i] = best[i] = length[i];
}
for (ll i = M - 1; i >= 1; i--) { // 自底向上累加出每个节点的区间长度
length[i] = length[i << 1] + length[i << 1 | 1];
pre[i] = suf[i] = best[i] = length[i];
}
return M;
}
// 把 node 整段刷成 color:入住则空房归零,清空则整段都是空房
void apply(ll node, ll color) {
ll empty = (color == OCCUPY) ? 0 : length[node];
pre[node] = suf[node] = best[node] = empty;
lazy[node] = color;
}
// 把 node 的延迟标记下传给孩子,并清掉自己的标记
void push(ll node) {
ll tag = lazy[node];
if (tag != NONE) {
apply(node << 1, tag);
apply(node << 1 | 1, tag);
lazy[node] = NONE;
}
}
// 由两个孩子重算 node 的三个空房统计量
void pull(ll node) {
ll left = node << 1, right = node << 1 | 1;
pre[node] = pre[left] + (pre[left] == length[left] ? pre[right] : 0);
suf[node] = suf[right] + (suf[right] == length[right] ? suf[left] : 0);
ll b = best[left] > best[right] ? best[left] : best[right];
ll cross = suf[left] + pre[right];
best[node] = b > cross ? b : cross;
}
// 把房间区间 [ql, qr] 整段刷成 color
void modify(ll node, ll l, ll r, ll ql, ll qr, ll color) {
if (ql <= l && r <= qr) { // 当前段被完全覆盖,整段打标记
apply(node, color);
return;
}
push(node);
ll mid = (l + r) >> 1;
if (ql <= mid) modify(node << 1, l, mid, ql, qr, color);
if (qr > mid) modify(node << 1 | 1, mid + 1, r, ql, qr, color);
pull(node);
}
// 找最靠左的、能装下 d 个连续空房的起点房间号;装不下返回 0
ll query(ll node, ll l, ll r, ll d) {
if (best[node] < d) return 0;
if (l == r) return l; // 走到叶子说明 d 必为 1
push(node);
ll mid = (l + r) >> 1;
ll left = node << 1, right = node << 1 | 1;
if (best[left] >= d) return query(left, l, mid, d); // 优先最左
if (suf[left] + pre[right] >= d) return mid - suf[left] + 1; // 跨在交界处
return query(right, mid + 1, r, d);
}
int main() {
ll n, m;
if (scanf("%lld %lld", &n, &m) != 2) return 0; // 空输入安全返回
ll M = prepare(n);
ll outs = 0; // 真正输出过的询问个数
for (ll q = 0; q < m; q++) {
ll op;
scanf("%lld", &op);
if (op == 1) {
ll d;
scanf("%lld", &d);
ll start = query(1, 1, M, d);
printf("%lld\n", start);
outs++;
if (start) { // 找到才真的入住
modify(1, 1, M, start, start + d - 1, OCCUPY);
}
} else {
ll x, d;
scanf("%lld %lld", &x, &d);
modify(1, 1, M, x, x + d - 1, CLEAR);
}
}
if (outs == 0) printf("\n"); // 与 Python 的 print('\n'.join([])) 一致
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-01 18:30
# update_at: 2026-10-01 18:30
import sys
NONE = -1 # 延迟标记:-1 无标记,0 整段清空,1 整段入住
OCCUPY, CLEAR = 1, 0 # 与延迟标记同义的业务含义,避免在调用点裸写 1 / 0
length: list[int] = [] # 每个节点覆盖的房间数
pre: list[int] = [] # 该段从左端起的最长连续空房数
suf: list[int] = [] # 该段从右端起的最长连续空房数
best: list[int] = [] # 该段内部的最长连续空房数
lazy: list[int] = [] # 延迟标记,含义见 NONE
def prepare(n: int) -> int:
"""建树:叶子记区间长度(超出 n 的补 0),初始全部空房,标记清空;返回叶子数 M。
线段树按 [1, M] 的坐标递归,M 是大于等于 n 的最小 2 的幂;补出来的 M - n
个假房间长度为 0,永远不可能被选中,却让左右孩子的分界点保持整齐。
"""
global length, pre, suf, best, lazy
M = 1 << (n - 1).bit_length() # 叶子数,同时就是房间坐标的上界
size = M << 1
length = [1 if 1 <= i - M + 1 <= n else 0 for i in range(size)]
for i in range(M - 1, 0, -1): # 自底向上累加出每个节点的区间长度
length[i] = length[i << 1] + length[i << 1 | 1]
pre, suf, best = length.copy(), length.copy(), length.copy()
lazy = [NONE] * size
return M
def apply(node: int, color: int) -> None:
"""把 node 整段刷成 color:入住则空房归零,清空则整段都是空房。"""
empty = 0 if color == OCCUPY else length[node]
pre[node] = suf[node] = best[node] = empty
lazy[node] = color
def push(node: int) -> None:
"""把 node 的延迟标记下传给孩子,并清掉自己的标记。"""
tag = lazy[node]
if tag != NONE:
apply(node << 1, tag)
apply(node << 1 | 1, tag)
lazy[node] = NONE
def pull(node: int) -> None:
"""由两个孩子重算 node 的三个空房统计量。"""
left, right = node << 1, node << 1 | 1
# 左孩子整段全空时,前缀才能越过它继续接上右孩子的前缀
pre[node] = pre[left] + (pre[right] if pre[left] == length[left] else 0)
suf[node] = suf[right] + (suf[left] if suf[right] == length[right] else 0)
best[node] = max(best[left], best[right], suf[left] + pre[right])
def modify(node: int, l: int, r: int, ql: int, qr: int, color: int) -> None:
"""把房间区间 [ql, qr] 整段刷成 color。"""
if ql <= l and r <= qr: # 当前段被完全覆盖,整段打标记
apply(node, color)
return
push(node)
mid = (l + r) >> 1
if ql <= mid:
modify(node << 1, l, mid, ql, qr, color)
if qr > mid:
modify(node << 1 | 1, mid + 1, r, ql, qr, color)
pull(node)
def query(node: int, l: int, r: int, d: int) -> int:
"""找最靠左的、能装下 d 个连续空房的起点房间号;装不下返回 0。"""
if best[node] < d: # 本段最长空房都不够,整段无解
return 0
if l == r:
return l # 走到叶子说明 d 必为 1,就是这一间
push(node)
mid = (l + r) >> 1
left, right = node << 1, node << 1 | 1
if best[left] >= d: # 优先最左:左孩子内部就能装下
return query(left, l, mid, d)
if suf[left] + pre[right] >= d: # 其次:正好跨在左右孩子交界处
return mid - suf[left] + 1
return query(right, mid + 1, r, d)
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
M = prepare(n)
out: list[str] = []
for _ in range(m):
op = next(data)
if op == 1:
d = next(data)
start = query(1, 1, M, d)
out.append(str(start))
if start: # 找到才真的入住,找不到就只是报 0
modify(1, 1, M, start, start + d - 1, OCCUPY)
else:
x, d = next(data), next(data)
modify(1, 1, M, x, x + d - 1, CLEAR)
print('\n'.join(out))
if __name__ == "__main__":
solve()实现说明:
NONE = -1是「无标记」哨兵。0 和 1 已经是真实颜色,不能拿它们当「无」; 因此lazy初始化为而不是 0。 OCCUPY、CLEAR只是给两个颜色起名,避免在调用点裸写 1 / 0。- 五个数组是模块级全局量,
prepare用global一次性重新绑定,避免把大数组 当参数在递归里反复传递。 query与modify里的判断都先取局部变量left、right、mid再比较, 避免在if条件里塞复合表达式。
复杂度
- 建树
:叶子层规模 ,两次线性扫描。 - 单次业务
: modify是标准区间赋值;query沿唯一路径下降到叶子, 每个节点。 - 总计
,空间 ,与标准 C++ 正解同阶。 时约 次节点访问,实测最大测试点 0.39 s / 26 MB, 在 1000 ms / 128 MB 限制内。
总结
- 「第一个长度
的全 0 连续段」不能靠区间求和回答,因为空房个数与 连续空房长度是两件事;必须让区间携带 这类 连续段信息。 - 贴左、贴右两个量是让信息可合并的关键:只有贴着边,两段拼起来才仍然连续;
于是
的第三种情况可以显式写成 , 三个量互相封闭、可结合。 - 有了可合并的
,「尽可能小的房间号」变成树上一次优先级为 「左 → 中缝 → 右」的下降;中缝那一步可以直接算出起点,不必进入孩子。 - 补位叶的
必须为 0,且查询失败时不能真的入住——这两点都源于 「 不一定等于 2 的幂」和「业务失败不改变状态」。
图示解析
从题面到答案的完整路线:
题目要求: 最靠左的长度 >= D 的连续空房
|
v
区间需要携带三个量: pre(贴左) / suf(贴右) / best(段内)
+ len = 实际房间数 (补位叶 len = 0)
|
v
合并式: pre = L.pre + (R.pre if L.pre == L.len else 0)
suf = R.suf + (L.suf if R.suf == R.len else 0)
best = max(L.best, R.best, L.suf + R.pre)
|
v
线段树 + 懒标记 (区间染色: 入住刷 1 / 退房刷 0)
|
+-----------+-----------+
v v
修改: modify 查询: query 沿唯一路径下降
整段覆盖就 apply best[左] >= D -> 走左
否则 push 后递归 suf[左]+pre[右] >= D -> mid-suf[左]+1
回来 pull 否则 -> 走右图中第一层是题目要求的目标;第二层是全题唯一的建模决定——一个区间必须携带哪几个
量才能和邻居拼起来,len 之所以在里面,是因为「整段全空」不能靠 pre 自己判断;
第三层是三个合并式,best 的第三项