ODT(Ordered Disjoint Tree,也常被戏称为 Chtholly Tree)很会抓住一种幸运时刻:数组里相同的值恰好连成了长段。它不承诺把任意区间操作都变成魔法;它只是把“许多格恰好一样”压进一张卡片。今天既要看它何时聪明,也要亲手把它弄到最狼狈。
学完后,你能:
前置:数组下标、区间、排序、C++ 的有序集合 std::set 的直觉。暂时不需要会写迭代器。
在纸上写初始数组:
位置:1 2 3 4 5 6 7 8 9
数值:1 1 1 4 4 2 2 2 2
把它压为三张段卡:
[1,3,val=1] [4,5,val=4] [6,9,val=2]
纸上四栏仍是:对象(哪些段卡)、状态(每张 l,r,value)、动作(切、遍历、删、插)、账本(卡片覆盖是否刚好是 1..n、是否重叠)。贯穿任务是先 add(2,7,+3),再 assign(3,8,9),最后问 kth(1,9,5)。
请把“ODT 很快”这句话暂时锁进抽屉。今天先确认它到底少做了哪一类工作,又在哪些数据上根本少不了。
若要给 [6,9] 每格加 3,数组做法会访问四个格;若这四格本来同为 2,它们经历的是一模一样的动作。我们不妨只写一句“[6,9] 从 2 变为 5”。
停顿 0:初始数组中若 query(1,9) 只问“值为 2 的元素有多少”,逐格看需要读几格?段卡要读几张卡?它省掉的是比较,还是重复保存的状态?
一张卡 (l,r,value) 的合同是:
若相邻两张卡 value 相同,可以合并成更长段,得到“极大等值段”的更整洁表示。很多经典 ODT 实现的正确性并不强制每次加法后立刻合并相等邻段;它只要求每张卡内部等值、覆盖不乱。赋值会直接把一整段范围压为一张卡,才是常见性能来源。把“可合并的优化”误当成“每一步必须合并的不变量”,很容易写乱迭代器。
停顿 1:对 [4,5,val=4],若只把位置 5 改为 7,改后至少需要几张段卡?请写出每张的左右端点和值。
要在 pos 开始操作,先找到包含 pos 的段 [l,r,v]:
若 pos=n+1,允许把它理解成“尾后迭代器”,不新建任何卡。
split(pos):
it = the segment [l,r,v] containing pos
if pos == l: return it
replace [l,r,v] by [l,pos-1,v] and [pos,r,v]
return iterator of [pos,r,v]
停顿 2:初始段卡上执行 split(2) 后会出现哪四张或几张卡?执行 split(8) 呢?每一次切开后,所有位置的数值有没有改变?
先令 begin=split(l),再令 end=split(r+1)。此时从 begin 走到 end 之前,恰好是一串被完整覆盖的段卡:
begin = split(l)
end = split(r + 1)
# [begin, end) 里的所有段,刚好拼成原数组 [l,r]
这比“判断每一张卡和区间相交多少”干净得多。加法可以逐卡改 value;赋值可删除 begin 到 end 的全部卡,再插入一张 [l,r,x];第 k 小则收集每张的 (value,length),按 value 排序并扣长度。
若每次询问都临时问区间第 k 小、临时幂次的幂和,常规线段树很难维护一个小而固定的摘要;逐元素则太慢。ODT 换研究对象:不直接按元素数 n 工作,而按当前段数 k 工作。区间赋值在很多随机过程里会抹掉一串旧断点、压缩 k,因此某些题故意把随机生成方式写进题面。
但这不是“ODT 的 O(log n) 定理”。它的有序集合定位/切边界约 O(log k),之后仍要逐张访问覆盖的 k 段;k 可以是 n。
对 add(l,r,x),切边界后遍历中央段,令 value+=x。每张段内所有位置都收到同一个加数,所以语义正确。可是不同段加同一个 x 后通常仍不同;段数不一定下降。
对 assign(l,r,x),先切边界,擦掉中央所有卡,再插入 [l,r,x]。这一步把可能很多段压缩为一张;若相邻卡也恰为 x,可选择再合并。它是 ODT 在随机大区间赋值里常常轻盈的原因。
从 1,2,3,…,n(每格不同)开始,重复做 add(1,n,1)。每次 n 个值仍互不相同,段数始终 Θ(n),每次都必须扫 Θ(n) 张卡。正确性一丝不差,复杂度却会退化。
停顿 3:为什么“统一给全数组加 1”不能把 1,2,3,… 合并?请写出加后相邻两格的差。若改成 assign(1,n,0),段数怎样变化?
| 说法 | 是否总成立 | 含义 |
|---|---|---|
| 段卡语义正确 | 是 | 每段内等值,段卡有序、不重、完整覆盖 |
| split 不改数组 | 是 | 只把一张等值卡分成两张同值卡 |
| 一次操作很快 | 不总是 | 取决于被访问段数、以及题目给出的随机/压缩契约 |
若当前操作触及 k 张段卡,边界定位约 O(log k),遍历约 O(k),第 k 小额外有排序代价。最坏情况下 k=Θ(n)。所以做题前必须写清:输入是否随机生成?是否有频繁覆盖长区间的赋值?能否构造大量互异段?如果不能给出令人信服的段数控制,就把 ODT 当作实验工具,而非提交方案。
从 [1,3,1]、[4,5,4]、[6,9,2] 开始,执行 add(2,7,+3)。填写:
| 阶段 | 段卡序列 | 哪些卡被实际改值 |
|---|---|---|
| split(2) 后 | ||
| split(8) 后 | ||
| 加 3 后 |
接着 assign(3,8,9)。先预测它会删掉中央几张卡、再留下几张;再问 kth(1,9,5) 时,别把“第 5 张段卡”误当成“第 5 小元素”。
运行 把相同的一段装进一个盒子.py。实验室使用可读的段卡列表,不是 OJ 的 std::set 代码;它会显示每次 split、被触及段数和普通数组验算。可输入:
先猜“这次会碰几张卡”,再执行命令;它比背 split 的四行代码更能告诉你 ODT 是否适合一题。