想象一个车库小游戏:一共 2^20 个车位(约一百万个),车辆不断驶入驶出。每当一辆车进场,调度器要在一瞬间回答两个问题——”离我最近的空位在哪?”(后继查询 successor)和”我左边最近的一辆已停车是哪辆?”(前驱查询 predecessor)。用有序数组,插入是 O(n);用红黑树,每次 O(log n) = 20 次比较;用哈希表,前驱后继无从谈起。而 van Emde Boas(下称 vEB)树把这三个操作全压到 O(log log U)——对 U = 2^20,只有 5 步。
这个数字不是常数优化,而是复杂度的量级跳变:O(log log U) 意味着即使 U 涨到 2^65536(天文数字的地址空间),查询也只需要 16 步。代价是它要求键必须是 [0, U) 范围内的整数,并且空间是 O(U),不能像平衡树那样按元素个数 O(n) 伸缩。对”密集整数键 + 需要前驱后继”的场景(路由表、进程调度、内存分配、游戏中的空间索引),它是最锋利的工具。
本文从”为什么二分查找不够快”讲起,层层还原 vEB 的三层递归结构,重点讲透那个最容易写错的核心技巧——min 元素不存进簇(cluster),最后给出可直接运行的 Java 实现与车库调度的完整演示。
一、前驱后继问题的下界:为什么 O(log U) 还不够
在 U 个可能键上维护一个动态集合,”成员查询”在比较模型下已有 Ω(log U) 的下界。但注意这个下界的前提是只能比较。如果我们允许使用”键是整数”这个额外信息——具体说,允许按二进制位做位操作和数组下标寻址——下界被打破。
思路极其朴素:既然键是 k 位二进制数,那就把”值域”当成一棵高 n/2 位决定走哪个儿子、低 n/2 位决定在儿子内部怎么走的树。这样树的层数是 log U / log(√U) = 2,不是 log U!
具体地,设 U = 2^k,令 hi = ⌈k/2⌉、lo = ⌊k/2⌋:
- 一个键 x 可拆为高
hi位high(x) = x >> lo与低lo位low(x) = x & (2^lo - 1) - 顶层维护 √U 个簇,第
high(x)个簇负责所有低位落在[0, √U)的键 - 再维护一个 summary,它是规模 √U 的”簇索引集合”,记录”哪些簇非空”
于是成员查询变成:先问 summary 里有没有这个簇,再问对应簇里有没有这个低位——两层递归,每层值域开平方。递归下去,层数是 log log U:U=2^16 时 k=16 → 8 → 4 → 2,只有 3~4 层。
代价是”每个节点维护 √U 个指针”:T(U) = √U · T(√U) + O(√U)·(summary 开销),解出来是 O(U) 空间。这就是 vEB 的空间换时间契约。
二、结构定义与三条不变式
VEBNode(U):
min, max ⟵ 集合中的最小/最大键(延迟存储,见下)
summary ⟵ 规模为 ceil(√U) 的 vEB,记录"哪些簇非空"
cluster[0..√U) ⟵ 每个都是规模为 floor(√U) 的 vEB
三条不变式是整个算法的骨架,必须背下来:
不变式 1(min 的懒惰性):节点内的 min 不出现在任何子结构中。也就是说,如果 x == node.min,那么 cluster[high(x)] 里不含 low(x)。max 则不同,max 老老实实存在簇里。
不变式 2(summary 的正确性):cluster[i] 非空 ⟺ summary 包含 i。
不变式 3(递归基):U = 2 时不再递归,只有 min/max 两个位(min 可能是 0 或 1,max 同理;空集合时二者为 null)。
为什么会设计不变式 1?因为 min 查询要 O(1)。若 min 也老老实实递归下去,min() 就变成 O(log log U) 了。而把 min 提在节点外面存,min() 直接读字段即可。代价是 insert/delete 里要额外处理这个”特殊元素”,容易出 bug——本文的实现会专门标注三处易错点。
三、五个操作的递推
设 U > 2,hi = high(x),lo = low(x),c = cluster[hi]。
member(x) —— O(log log U)
if min == null: return false // 空集合
if x == min or x == max: return true // 两个延迟元素直接命中
if U == 2: return false // 基例
return c != null && c.member(lo) // 递归进簇
successor(x) —— 求大于 x 的最小元素,O(log log U)
if U == 2: return (x == 0 && max == 1) ? 1 : null
if min != null && x < min: return min // x 比全集合都小,答案就是 min
c = cluster[high(x)]
if c != null && c.max != null && low(x) < c.max:
return index(high(x), c.successor(low(x))) // 答案还在同一个簇里
s = summary != null ? summary.successor(high(x)) : null
if s == null: return null // 没有更靠后的非空簇
return index(s, cluster[s].min) // 跳到下一个非空簇,取它的 min
这里第二行 x < min 的提前返回,正是不变式 1 的直接红利:min 不在簇里,若不加这个特判就会漏掉它。
predecessor(x) 完全对称,但有一个不对称的陷阱:
if empty: return null
if U == 2: return (x == 1 && min == 0) ? 0 : null
if x > max: return max // 比全集合都大
c = cluster[high(x)]
if c != null && c.min != null && low(x) > c.min:
return index(high(x), c.predecessor(low(x)))
p = summary != null ? summary.predecessor(high(x)) : null
if p == null: return (x > min) ? min : null // ★ 兜底必须是 min,不是父层的 max
return index(p, cluster[p].max) // 别的簇里要取它的 max
注意 ★ 那行,以及最后一行取 cluster[p].max 而不是 .min ——这是 successor 与 predecessor 唯一的两处非对称。写反了,测试会在大规模随机用例上偶发失败(本人第一次实现就在这里翻车,用一个”阶梯式”随机序列才复现出来)。
insert(x) —— O(log log U)
if min == null: min = max = x; return // 空集合直接接管
if x < min: swap(x, min) // 新元素比 min 小 → min 顶替,旧 min 继续往下沉
if U > 2:
hi = high(x); lo = low(x)
if cluster[hi] == null: cluster[hi] = new VEB(√U)
if cluster[hi].min == null: // 该簇原本为空
summary.insert(hi) // 先登记到 summary
cluster[hi].insert(lo) // 再递归插入
if x > max: max = x // ★ 必须放在递归之后,不能提前 return
★ 那一行是最常见的错误:很多人写完 cluster[hi].insert(lo) 就 return 了,忘记更新 max。因为 insert 会一路递归下去,中途不会触碰本层的 max。
delete(x) —— O(log log U),但要处理”删 min”这个特殊情形
if min == max: min = max = null; return // 只剩一个元素,直接清空
if U == 2: min = max = (x == 0 ? 1 : 0); return // 基例:剩下另一个位
if x == min:
first = summary.min // 找到最小的非空簇
x = index(first, cluster[first].min) // 真正的 min 是它的 min
min = x // min 指针改为指向它
hi = high(x); c = cluster[hi]
c.delete(low(x)) // 递归删除(此时 x 一定在簇里)
if c.min == null: // 簇空了
summary.delete(hi); cluster[hi] = null
if x == max: // 删的正好是 max
if summary == null || summary.min == null: max = min
else:
m = summary.max
max = index(m, cluster[m].max)
删 min 的技巧值得单独说明:我们不想真的去递归删除 min(因为它压根不在簇里),而是”提升”次小元素为新 min。次小元素藏在”最小的非空簇”的 min 里——这正是 summary.min 的作用。一次”删除最小”被转化成了”把 summary.min 对应的簇的 min 提上来”,min 指针滑动而结构不变,全程只递归一层。
四、完整 Java 项目结构
parking-veb/
├── pom.xml
└── src
├── main/java/com/example/veb
│ ├── VEBTree.java // 核心结构
│ ├── SlotAllocator.java // 车库调度封装(位图 + vEB 混合)
│ └── ParkingDemo.java // 演示入口
└── test/java/com/example/veb
└── VEBTreeTest.java
pom.xml 与上一篇相同(JDK 17 + JUnit 5),此处略。
4.1 核心实现
package com.example.veb;
/**
* van Emde Boas 树:在 [0, U) 的整数全域上支持
* member / insert / delete / successor / predecessor,均为 O(log log U)。
*
* 约束:U 必须是 2 的幂(U >= 2)。
* 空间:O(U)。若 U 过大(如 2^24 以上),请改用 y-fast trie 或位图分块。
*/
public final class VEBTree {
private final int universe; // U
private final int lowBits; // floor(log2 U) 的一半,即簇内位宽
private final int highBits; // 剩余的位宽,即"簇个数"的指数
private int min = -1; // -1 表示"无"
private int max = -1;
private VEBTree summary; // 规模 2^highBits,记录非空簇下标
private VEBTree[] clusters; // 长度 2^highBits,每个规模 2^lowBits
private int size; // 元素个数(可选,便于断言)
public VEBTree(int universe) {
if (universe < 2 || (universe & (universe - 1)) != 0) {
throw new IllegalArgumentException("universe 必须是 >= 2 的 2 的幂: " + universe);
}
this.universe = universe;
int bits = Integer.numberOfTrailingZeros(universe); // log2(U)
this.lowBits = bits / 2;
this.highBits = bits - lowBits;
if (universe > 2) {
this.clusters = new VEBTree[1 << highBits];
}
}
// ---------- 位拆分 ----------
private int high(int x) { return x >>> lowBits; }
private int low(int x) { return x & ((1 << lowBits) - 1); }
private int index(int h, int l) { return (h << lowBits) | l; }
public boolean isEmpty() { return min == -1; }
public int size() { return size; }
public int min() { return min; } // -1 表示空
public int max() { return max; }
/** 成员查询。O(log log U)。 */
public boolean contains(int x) {
if (x < 0 || x >= universe) return false;
return containsInternal(x);
}
private boolean containsInternal(int x) {
if (min == -1) return false; // 空集合
if (x == min || x == max) return true; // 两个延迟元素(不变式 1 的红利)
if (universe == 2) return false; // 基例:U=2 只有 min/max 两位
VEBTree c = clusters[high(x)];
return c != null && c.containsInternal(low(x));
}
/** 插入。O(log log U)。重复插入是幂等操作(也可改成计数语义)。 */
public void insert(int x) {
if (x < 0 || x >= universe) throw new IllegalArgumentException("越界: " + x);
insertInternal(x);
}
private void insertInternal(int x) {
if (min == -1) { // 空集合:min/max 直接接管
min = max = x; size = 1; return;
}
if (x == min) return; // 已存在,幂等
if (x < min) { int t = min; min = x; x = t; } // 新元素下沉:旧 min 继续往下插
if (universe > 2) {
int h = high(x), l = low(x);
VEBTree c = clusters[h];
if (c == null) {
c = new VEBTree(1 << lowBits);
clusters[h] = c;
}
if (c.min == -1) { // 该簇原本为空 → 先登记 summary(不变式 2)
if (summary == null) summary = new VEBTree(1 << highBits);
summary.insertInternal(h);
size += c.size(); // c 为空,加 0
}
int before = c.size();
c.insertInternal(l);
size += c.size() - before;
} else {
size++; // U=2 基例
}
if (x > max) max = x; // ★ 必须在递归之后
}
/** 删除。不存在则无操作。O(log log U)。 */
public void delete(int x) {
if (x < 0 || x >= universe) return;
deleteInternal(x);
}
private void deleteInternal(int x) {
if (min == -1 || !containsInternal(x)) return; // 不存在,静默返回
if (min == max) { // 只剩一个元素
min = max = -1; size = 0; return;
}
if (universe == 2) { // 基例:剩下"另一位"
min = max = (x == 0 ? 1 : 0); size = 1; return;
}
if (x == min) { // ★ 删 min 的特殊路径
int first = summary.min(); // 最小的非空簇
x = index(first, clusters[first].min()); // 真正的次小元素
min = x; // 指针滑动,不走递归
}
int h = high(x), l = low(x);
VEBTree c = clusters[h];
int before = c.size();
c.deleteInternal(l);
size -= before - c.size();
if (c.min == -1) { // 簇空了 → 摘除登记
summary.deleteInternal(h);
clusters[h] = null;
}
if (x == max) { // 删的正好是 max
if (summary == null || summary.min() == -1) {
max = min; // 只剩 min 一个
} else {
int m = summary.max();
max = index(m, clusters[m].max());
}
}
}
/** 大于 x 的最小元素;无则 -1。O(log log U)。 */
public int successor(int x) {
if (x < 0) return min; // 比全域下界还小 → 最小元素
return successorInternal(x);
}
private int successorInternal(int x) {
if (universe == 2) { // 基例
return (x == 0 && max == 1) ? 1 : -1;
}
if (min != -1 && x < min) return min; // ★ 覆盖"min 不在簇里"
int h = high(x), l = low(x);
VEBTree c = clusters[h];
if (c != null && c.max != -1 && l < c.max) {
return index(h, c.successorInternal(l)); // 答案在同一簇内
}
int s = (summary == null) ? -1 : summary.successorInternal(h);
if (s == -1) return -1;
return index(s, clusters[s].min()); // 跳下一个非空簇,取其 min
}
/** 小于 x 的最大元素;无则 -1。O(log log U)。与 successor 严格对称。 */
public int predecessor(int x) {
if (x <= 0) return -1;
return predecessorInternal(x);
}
private int predecessorInternal(int x) {
if (min == -1) return -1;
if (universe == 2) {
return (x == 1 && min == 0) ? 0 : -1;
}
if (x > max) return max; // 比全域上界还大 → 最大元素
int h = high(x), l = low(x);
VEBTree c = clusters[h];
if (c != null && c.min != -1 && l > c.min) {
return index(h, c.predecessorInternal(l));
}
int p = (summary == null) ? -1 : summary.predecessorInternal(h);
if (p == -1) {
return (x > min) ? min : -1; // ★ 兜底是 min,不是 -1 就是空
}
return index(p, clusters[p].max()); // ★ 前一个簇里取 max
}
/** 前驱后继的对称性自检:返回 true 表示结构自洽。 */
public boolean checkInvariants() {
if (min == -1) return max == -1 && size == 0;
if (max < min) return false;
if (min == max && size != 1) return false;
int count = 1; // min 本身
if (universe > 2) {
for (int i = 0; i < clusters.length; i++) {
VEBTree c = clusters[i];
boolean has = c != null && !c.isEmpty();
boolean registered = summary != null && summary.containsInternal(i);
if (has != registered) return false; // 不变式 2
if (c != null) {
if (!c.checkInvariants()) return false;
count += c.size();
// 不变式 1:min 的低位不能出现在簇中
if (i == high(min) && c.containsInternal(low(min))) return false;
}
}
if (max != min && clusters[high(max)] == null) return false;
} else {
if (min == max) return true;
count = 2;
}
return count == size;
}
}
4.2 车库调度封装
真实车库的 U 是几万到几百万,直接开一个 O(U) 的 vEB 是可行的(U=2^20 时内存约几十 MB),但更常见的是位图 + vEB 混合:用 vEB 只管理”每个 64 位字(word)里是否有空位”,字内的具体空位用 long 位图 + numberOfTrailingZeros 一次定址。这样空间降到 O(U/64),查询依旧是 O(log log(U/64)) + O(1)。
package com.example.veb;
/**
* 车库车位分配器。
* - vebLevel:登记"哪些 word 内还有空位",全域 = wordCount
* - words[i]:第 i 个 64 位字,1 表示"已占用"
* 查询"离 pos 最近的空位":先用位图在同 word 内 O(1) 求,跨 word 时用 vEB 求前驱后继。
*/
public final class SlotAllocator {
private final int capacity;
private final long[] words;
private final VEBTree freeWords; // 存放"仍有空位的 word 下标"
private final int wordCount;
public SlotAllocator(int capacity) {
this.capacity = capacity;
this.wordCount = (capacity + 63) >>> 6;
this.words = new long[wordCount];
int u = Integer.highestOneBit(Math.max(2, wordCount));
if (u < wordCount) u <<= 1; // vEB 全域必须是 2 的幂
this.freeWords = new VEBTree(u);
for (int i = 0; i < wordCount; i++) freeWords.insert(i);
}
/** 标记占用;返回 false 表示已被占用。O(log log U)。 */
public boolean park(int slot) {
int w = slot >>> 6, b = slot & 63;
long mask = 1L << b;
if ((words[w] & mask) != 0) return false;
words[w] |= mask;
if (words[w] == -1L) freeWords.delete(w); // 该 word 满了,从 vEB 摘除
return true;
}
/** 释放车位。O(log log U)。 */
public boolean leave(int slot) {
int w = slot >>> 6, b = slot & 63;
long mask = 1L << b;
if ((words[w] & mask) == 0) return false;
words[w] &= ~mask;
if (words[w] != -1L) freeWords.insert(w); // 重新可用(幂等)
return true;
}
/** 离 pos 最近(向右找)的空位;无则 -1。 */
public int nextFree(int pos) {
if (pos < 0) pos = 0;
if (pos >= capacity) return -1;
int w = pos >>> 6, b = pos & 63;
// 同一 word 内:把 [0, b] 位屏蔽掉,剩下的最低置零位就是答案
long avail = ~words[w] & (-1L << b);
if (avail != 0) {
int cand = (w << 6) + Long.numberOfTrailingZeros(avail);
return cand < capacity ? cand : -1;
}
int nw = freeWords.successor(w); // 跨 word:vEB 求后继
if (nw == -1) return -1;
long word = ~words[nw];
if (word == 0L) return -1; // 理论不可达(vEB 保证非空)
int cand = (nw << 6) + Long.numberOfTrailingZeros(word);
return cand < capacity ? cand : -1;
}
/** 离 pos 最近(向左找)的空位;无则 -1。 */
public int prevFree(int pos) {
if (pos >= capacity) pos = capacity - 1;
if (pos < 0) return -1;
int w = pos >>> 6, b = pos & 63;
long avail = ~words[w] & ((1L << (b + 1)) - 1); // 只保留 [0, b] 位
if (avail != 0) return (w << 6) + 63 - Long.numberOfLeadingZeros(avail);
int pw = freeWords.predecessor(w);
if (pw == -1) return -1;
long word = ~words[pw];
return (pw << 6) + 63 - Long.numberOfLeadingZeros(word);
}
public int occupiedCount() {
int n = 0;
for (long w : words) n += Long.bitCount(w);
return n;
}
}
4.3 演示入口
package com.example.veb;
import java.util.Random;
public final class ParkingDemo {
public static void main(String[] args) {
final int CAP = 1 << 20; // 约 105 万个车位
SlotAllocator lot = new SlotAllocator(CAP);
Random rnd = new Random(20261103L);
// 随机占用 30% 车位,制造"有空有满"的真实分布
for (int i = 0; i < CAP / 3; i++) lot.park(rnd.nextInt(CAP));
System.out.println("已占用: " + lot.occupiedCount() + " / " + CAP);
// 1) 单次查询延迟
long t0 = System.nanoTime();
int probes = 200_000, hits = 0;
for (int i = 0; i < probes; i++) {
int pos = rnd.nextInt(CAP);
int slot = lot.nextFree(pos);
if (slot >= 0) hits++;
}
long t1 = System.nanoTime();
System.out.printf("nextFree %d 次, %.1f ns/次, 命中 %d%n",
probes, (t1 - t0) / (double) probes, hits);
// 2) 对照:朴素线性扫描(同样的查询量,仅取样 2000 次)
int samples = 2000; long sum = 0;
long t2 = System.nanoTime();
for (int i = 0; i < samples; i++) {
int pos = rnd.nextInt(CAP);
for (int s = pos; s < CAP; s++) { if (lot.prevFree(s) == s || true) { sum += s; break; } }
}
long t3 = System.nanoTime();
System.out.printf("线性扫描 %d 次, %.1f us/次 (仅供对比)%n",
samples, (t3 - t2) / 1000.0 / samples);
// 3) 结构与不变式自检
VEBTree t = new VEBTree(1 << 10);
for (int i = 1; i < 900; i += 7) t.insert(i);
System.out.println("vEB 不变式自检: " + t.checkInvariants());
System.out.println("successor(500) = " + t.successor(500)
+ ", predecessor(500) = " + t.predecessor(500));
}
}
4.4 单元测试(关键是对拍)
package com.example.veb;
import org.junit.jupiter.api.Test;
import java.util.TreeSet;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.*;
class VEBTreeTest {
/** 与 TreeSet 对拍:member / successor / predecessor / min / max 全部一致。 */
@Test
void fuzzAgainstTreeSet() {
Random rnd = new Random(7L);
int[] universes = {2, 4, 8, 16, 64, 256, 4096};
for (int u : universes) {
VEBTree veb = new VEBTree(u);
TreeSet<Integer> ref = new TreeSet<>();
for (int step = 0; step < 3000; step++) {
int x = rnd.nextInt(u);
double r = rnd.nextDouble();
if (r < 0.45) { veb.insert(x); ref.add(x); }
else if (r < 0.75) { veb.delete(x); ref.remove(x); }
else {
Integer expSucc = ref.higher(x);
int gotSucc = veb.successor(x);
assertEquals(expSucc == null ? -1 : expSucc.intValue(), gotSucc, "successor@" + u + " x=" + x);
Integer expPred = ref.lower(x);
int gotPred = veb.predecessor(x);
assertEquals(expPred == null ? -1 : expPred.intValue(), gotPred, "predecessor@" + u + " x=" + x);
}
assertEquals(ref.isEmpty() ? -1 : ref.first().intValue(), veb.min());
assertEquals(ref.isEmpty() ? -1 : ref.last().intValue(), veb.max());
assertEquals(ref.size(), veb.size());
assertTrue(veb.checkInvariants(), "不变式被破坏 step=" + step);
}
}
}
@Test
void deleteMinKeepsStructureConsistent() {
VEBTree t = new VEBTree(1 << 12);
for (int i = 100; i < 200; i++) t.insert(i);
for (int i = 100; i < 190; i++) {
assertEquals(i, t.min());
t.delete(i); // 反复删 min,检验 summary.min 提升逻辑
assertTrue(t.checkInvariants());
}
assertEquals(190, t.min());
assertEquals(199, t.max());
}
/** 边界:全域两端、单元素、连续增删。 */
@Test
void edges() {
VEBTree t = new VEBTree(16);
assertEquals(-1, t.successor(0));
t.insert(0); t.insert(15);
assertEquals(15, t.successor(0));
assertEquals(0, t.predecessor(15));
assertEquals(-1, t.predecessor(0));
assertEquals(-1, t.successor(15));
t.delete(0); t.delete(15);
assertTrue(t.isEmpty());
}
}
五、复杂度与实测
| 操作 | 有序数组 | 红黑树/TreeSet | 位图 | vEB | 位图+vEB 混合 |
|---|---|---|---|---|---|
| insert | O(n) | O(log n) | O(1) | O(log log U) | O(log log(U/64)) |
| delete | O(n) | O(log n) | O(1) | O(log log U) | O(log log(U/64)) |
| successor | O(log n) 二分 | O(log n) | 最坏 O(U/64) | O(log log U) | O(log log(U/64)) |
| min/max | O(1) | O(log n) 或 O(1) | 最坏 O(U/64) | O(1) | O(1) |
| 空间 | O(n) | O(n) | O(U/8) 字节 | O(U) | O(U/8) 字节 |
递推式 T(U) = T(√U) + O(1) 解得 T(U) = O(log log U)。展开看数字:U=2^32 时 log log U = 5;U=2^64 时是 6。log log U 增长得慢到近乎常数,这是 vEB 的唯一也是最大的卖点。
但空间表里那个 O(U) 是硬伤:U=2^32 需要约 40 亿个指针槽,没人这么干。所以工程上的真实形态有两种:
- y-fast trie:把全域按”每 √U 个元素一个块”切分,块内用平衡树(O(log n) 但 n 很小),块间用 vEB 索引(只对块索引做 log log)。空间降到 O(n),查询 O(log log U) 的期望值——这正是 Redis 的
zset在大规模下、以及 IP 路由表(Linux 的x_tables早期桶设计)的思路。 - 位图 + vEB 分层(本文
SlotAllocator):把”是否存在”下沉到 64 位字,vEB 只索引字。空间 O(U/64),字内查询靠numberOfTrailingZeros一条指令搞定。这也是很多高性能内存分配器(如 jemalloc 的位图分级)的做法。
六、五个必踩的坑
坑 1:min 不存进簇,导致 successor/predecessor 漏解。 若不写 if (x < min) return min,当 x 比集合最小元素还小(或恰好落在 min 所在簇的边界)时,会返回 -1 或错误跳簇。这句话不是优化,是正确性必需。
坑 2:delete 删 min 时直接递归。 直接 cluster[high(min)].delete(low(min)) 会误删数据(因为 min 根本不在簇里),或者在簇为空时抛空指针。正确路径是上面代码的”提升次小元素”。
坑 3:predecessor 跨簇取错端点。 跨簇找前驱要取目标簇的 max,跨簇找后继要取目标簇的 min——一个取最大一个取最小,两者不对称。写成统一模板必然出错。
坑 4:U 不是 2 的幂。 位拆分逻辑依赖 U = 2^k。若直接传 1000,lowBits/highBits 的划分会让簇覆盖不均,导致某些键映射到不存在的簇。正确做法是把 U 向上取到 2 的幂(SlotAllocator 里就是这么做的),或改写成”非均匀 √ 划分”的变体。
坑 5:不顾空间直接用。 U=2^20 的 vEB 会有约 1024 个顶层簇 + 每簇 1024 指针,实际内存数十 MB 且初始化慢(构造时就分配数组)。U 大于 2^16 时优先考虑混合方案;U 小于 2^10 时直接用位图或桶排序,vEB 的常数开销反而不划算。
七、小结
vEB 树的思想可以一句话概括:把”值域大小”而不是”元素个数”作为递归变量,每层开平方,于是深度只剩 log log U。它换来的代价是 O(U) 空间和一个必须小心维护的不变式(min 延迟存储)。理解了 min 不进簇 这条规则,你就同时理解了为什么它的 min 查询是 O(1)、为什么 delete-min 只是”指针滑动”、为什么 successor 里必须特判 x < min。
这个结构在游戏里不只是车库调度:任何”在一维/低维离散空间里找最近的可用位置”的场景都适用——棋盘上找最近空位、RTS 里找最近空闲建造格、背包格子里找可堆叠槽位、关卡编辑器的对象槽复用。它和 Bitboard(位棋盘)搭配时尤其顺手:位图负责微观的 64 位段内定位,vEB 负责宏观的段级索引,两种”位技巧”各管一层。
下一步可以尝试:把本文的
VEBTree改造为 x-fast trie(在 vEB 的每层加哈希表,查询降到 O(log U) 但插入 O(1) 摊还),或实现 y-fast trie 把空间压到 O(n)。再进一步,可以把它和本文第 4 节的位图结合起来,做一个支持”批量分配区间”的分配器——这是游戏服务端管理实体 ID 池的标准做法。