MPTT 不是万能编号:一棵百万节点树的增删改与批量事务

上一篇:《WASM 不是银弹:一棵百万节点 Vue 树的边界账本与虚拟滚动动画》 算的是边界账:MPTT 把一棵树变成了一段区间,虚拟滚动只渲染视口。但那只解决了"读"。真正难写的是"写"------新增一个节点、删除一棵子树、导入 10 万行数据。这篇文章拆开 appendChild / removeSubtree / beginStructureBatch,讲清楚三件事:为什么同一个插入操作有时 O(1)、有时 O(N);为什么"批量"能把 100 次操作从 675ms 压到 6ms;以及一个我在写这篇文章时用探针复现出来的顶层追加边界问题。

0. 三个写操作

如果你的树只读,MPTT 很舒服:区间查子树、单遍算可见性、O(1) 算总高度。但后台系统里的树总要写:

  1. 新增节点:用户点"新建",挂在某个父节点下,或者直接挂在根上。
  2. 删除子树:删掉一个部门,它的所有下级一起消失。
  3. 批量导入 / 同步:一次接口拉回 10 万行,或者拖拽调整层级。

这三个操作分别对应三种成本:边界重编号、数组移动、派生状态重建。把它们混在一起谈"O(1)/O(N)"没有意义------同一个 appendChild,在事务外是 O(N),在事务内是 O(1)。

先给结论表(N = 全树节点数,k = 被操作的子树大小,d = 深度):

操作 边界重编号 数组移动 派生状态同步 事务内单次
顶层追加 O(1) 均摊 O(1) O(N) O(1)
中间插入 O(N) O(N) O(N) O(N)
删除子树 O(N) O(N) O(N) O(N)
修改 name/disabled 无 无 O(1) / O(N) ---

这张表里最关键的一列是"事务内单次":它告诉你哪些成本是结构本身固有的,哪些只是同步策略造成的。这个项目里 100 次顶层追加,逐次调用 675ms,放进一个事务 6ms,差了 111 倍------差别不在 MPTT,而在每次操作后都重建了一遍派生索引。


1. MPTT 的编号约定

先把编号说清楚,否则后面的 ±2 和 +1 都是玄学。

项目里的编号不是教科书里常见的 [left, right] 闭区间加 1 递增,而是每个节点占两个编号 :进入时分配 leftNode,子树处理完后把当前游标写进 rightNode。核心循环在 assembly/tree-builder.ts:

ts 复制代码
mptt.leftNode = next
next++
stack.push(mptt)
// ...处理子节点...
const completed = stack.pop()
completed.rightNode = next
next = completed.rightNode + 1

这带来几个直接推论:

  • 叶子节点的 rightNode - leftNode === 1;
  • 节点的后代数 = (rightNode - leftNode - 1) / 2;
  • 判断 X 是否在 P 的子树里:X.leftNode >= P.leftNode && X.rightNode <= P.rightNode;
  • 按 leftNode 升序排列,就是前序序列,子树在数组里是连续的一段。

举个实测例子。输入邻接表 A → B、D → E,两个顶层节点都挂在虚拟根 root 下,编号结果是:

text 复制代码
A[0,3]  B[1,2]  D[4,7]  E[5,6]

A[0,3] 占住 0 和 3,B[1,2] 占住 1 和 2;D[4,7] 与 E[5,6] 同理。注意 A 和 D 之间没有空隙:A.rightNode = 3,下一个顶层节点从 4 开始。

把上面的编号画成图,就是一棵"每个节点占两个时间戳"的树:

前序序列 A[0,3] → B[1,2] → D[4,7] → E[5,6] 与数组顺序一致,任意子树都对应一段连续区间。

如果只记一句话:MPTT 的编号是"进入/离开"两个时间戳,不是"第几个节点"。 所有增删改都在维护这两个时间戳的相对顺序。


2. 插入:为什么有时 O(1)、有时 O(N)

2.1 顶层追加:本可以 O(1)

把新节点挂在 root 下,前序序列上它排在整棵树的最后。既然它不插到任何现有区间中间,就不需要给谁让位(节选):

ts 复制代码
let insertIndex = this.fullTree.length
let left = this.fullTree.length > 0
  ? this.fullTree[this.fullTree.length - 1].rightNode
  : 1
let needsShift = false

if (parentId !== this.root) {
  // 中间插入:计算插入点、左边界和深度,见 2.2
  needsShift = true
}

if (needsShift) {
  for (let i = 0; i < this.fullTree.length; i++) {
    const node = this.fullTree[i]
    if (node.leftNode >= left) node.leftNode += 2
    if (node.rightNode >= left) node.rightNode += 2
  }
}

const node = new MpttTree()
node.leftNode = left
node.rightNode = left + 1
this.fullTree.push(node)

逻辑很直白:needsShift 为假时,只做一次 push,不碰任何已有节点的边界。复杂度 O(1)。

但这是"边界重编号"的 O(1),不是整个 appendChild 的 O(1)。 函数最后一行是 this._finishStructureMutation(),事务外它会触发 _syncAfterStructureMutation():重排 fullIndex、重建可见数组、compactStore.load()、重算勾选聚合......全是 O(N)。100k 节点实测,单次顶层追加 P50 约 6.9ms ,而事务内单项只要 0.007ms------差了近 1000 倍,成本几乎全在同步。

2.2 中间插入:为什么要整体平移

把新节点挂到父节点 P 下时,前序序列上它应该出现在 P 之后、P 现有子节点之前(或者按业务约定放在最后)。无论放哪,都需要在 P 的区间内部挤进两个编号。

项目的做法是插在父节点的闭合边界:

ts 复制代码
if (parentId !== this.root) {
  const parent = this.fullTree[parentIndex]
  // 找到父节点之后的第一个位置
  insertIndex = parentIndex + 1
  while (
    insertIndex < this.fullTree.length &&
    this.fullTree[insertIndex].leftNode < parent.rightNode
  )
    insertIndex++
  // 新节点占用父节点原来的闭合位置
  left = parent.rightNode
  depth = parent.deep + 1
  needsShift = true
}

然后对所有 leftNode >= left 或 rightNode >= left 的节点 +2。这一步是 O(N) 扫描;fullTree 的数组插入还要 memmove,也是 O(N)。

为什么不能像顶层追加那样只改局部?因为 MPTT 的编号是全局坐标 。你在 A 的区间里插两个数,A 之后所有节点的编号都必须顺延,否则 D[4,7] 会和被挤大的 A 重叠。这是嵌套集模型的固有代价------它用"读得快"换"写得贵"。

用图看更清楚:在 A 的区间里挤进两个编号后,A 之后的所有节点都要整体右移 2 格。

这就是中间插入无法做到 O(1) 的根本原因:编号是全局坐标,局部插入必然引发全局平移。

实测 100k 节点,单次中间插入 P50 约 15.4ms ;事务内单项约 0.604ms。事务内仍然比顶层追加慢两个数量级,因为 O(N) 平移没法摊掉。

2.3 一个真实踩坑:顶层追加的 1 格偏移

我在准备这篇文章的例子时,用探针跑了一个最小用例:

text 复制代码
初始:  A[0,3]  B[1,2]  D[4,7]  E[5,6]
appendChild(F, parentId = root)
结果:  A[0,3]  B[1,2]  D[4,7]  E[5,6]  F[6,7]

F 是 root 的子节点,编号却落在了 D[4,7] 的区间里。后果不是"看起来不对",而是范围查询直接把 F 当成 D 的后代:

text 复制代码
getSubtreeIds('D') → ['D', 'E', 'F']

原因就在 2.1 的代码:left 取的是前序数组最后一个节点 的 rightNode。最后节点是 E,E.rightNode = 6,但整棵树的最大边界是 D.rightNode = 7。正确取值应该是"最后一个顶层节点的 rightNode + 1",即 8:

ts 复制代码
// 修复方向(示意)
let lastTop = -1
for (let i = this.fullTree.length - 1; i >= 0; i--) {
  if (this.fullTree[i].parentId === this.root) {
    lastTop = i
    break
  }
}
let left = lastTop >= 0 ? this.fullTree[lastTop].rightNode + 1 : 1

这个例子说明一件事:MPTT 的增删改必须配边界不变量的测试 。只测"新节点在不在数组里"是不够的,要断言任意两个节点的区间要么包含、要么不相交,以及 getSubtreeIds 的结果与父子关系一致。项目现有的 CRUD 测试覆盖了中间插入和删除,但顶层追加恰好没有用例,于是这个偏移活了下来。

读者如果在自己的实现里抄了"left = last.rightNode",建议立刻补一个顶层追加的区间断言,别等范围查询把新节点算进别人的子树。


3. 删除:用区间切掉一整段

删除比插入简单,因为不需要"挤进两个编号",只需要"抹掉一段"。

ts 复制代码
const target = this.fullTree[index]
const right = target.rightNode
const width = target.rightNode - target.leftNode + 1

let end = index
while (end < this.fullTree.length && this.fullTree[end].leftNode < right)
  end++

for (let i = index; i < end; i++) this.idToIndex.delete(this.fullTree[i].id)
this.fullTree.splice(index, end - index)

for (let i = 0; i < this.fullTree.length; i++) {
  const node = this.fullTree[i]
  if (node.leftNode > right) node.leftNode -= width
  if (node.rightNode > right) node.rightNode -= width
}

三个细节:

  1. width 是闭区间宽度 。叶子占 left 和 right 两个编号,所以删一个叶子要回收 2 个槽位,不是 1 个。
  2. 后代在数组里连续 。leftNode < right 就是"在目标子树内",所以一个 while 就能找到整段,不需要递归。
  3. idToIndex 局部重映射 。删除区间内的条目直接 delete,区间之后的条目索引整体前移,重新 set 一遍。这一步是 O(N),但比"重建整张 Map"少一次哈希分配。

删除本身是 O(子树大小 + N),其中 N 来自边界平移和索引重映射。100k 节点实测,删一个叶子 P50 约 7ms ,删一棵 121 节点的子树约 6--15ms。又是"同步占大头"。

删除的流程可以概括为"定位区间 → 切掉连续段 → 平移后续边界":


4. 批量事务:把 O(N) 的同步摊掉

4.1 一个批次,一次同步

beginStructureBatch / endStructureBatch 的实现很朴素:

ts 复制代码
beginStructureBatch(): void {
  if (this._structureBatchDepth === 0) this._materializeLazyCheckboxRanges()
  this._structureBatchDepth++
}

endStructureBatch(): void {
  if (this._structureBatchDepth <= 0) return
  this._structureBatchDepth--
  if (this._structureBatchDepth === 0 && this._structureDirty) {
    this._structureDirty = false
    this._syncAfterStructureMutation()
  }
}

批次内每次 appendChild 仍然会改 fullTree 和 idToIndex,但 _finishStructureMutation() 只把 _structureDirty 置真,不同步派生状态。于是:

批量事务的核心就是"把 N 次同步合并成 1 次":

  • 100 次顶层追加:逐次 675 ms ,批量 6.08 ms ,约 111×;
  • 批量内每次顶层追加:0.0071 ms;
  • 批量内每次中间追加:0.604 ms(O(N) 平移仍在)。

4.2 批次内 compactStore 是故意过期的

这里有一个容易忽略的设计:批次内 compactStore 不更新。appendChild 找插入位置时不能用 compactStore.left/right,只能用 fullTree 上的 MPTT 边界:

ts 复制代码
// compactStore is intentionally stale inside a structure batch. Find the
// preorder end from MPTT boundaries so a child added earlier in this
// transaction can itself receive children.
insertIndex = parentIndex + 1
while (
  insertIndex < this.fullTree.length &&
  this.fullTree[insertIndex].leftNode < parent.rightNode
)
  insertIndex++

这条注释解决了一个真实需求:批量导入时经常是"先建父、再建子",而父节点可能是本批次刚创建的。只要 fullTree 的边界是新的,就能继续往下挂;compact 数组等批次结束再一次性重建。

4.3 一次同步到底做了什么

_syncAfterStructureMutation() 是整个写路径的核心,按顺序做六件事:

  1. 重排 fullIndex(fullTree[i].fullIndex = i);
  2. 清空 searchTree、失效搜索候选索引;
  3. 用 resetShownFlagsAndCollect 单遍 重建可见性,同时收集 shownNodes;
  4. compactStore.load(fullTree) 单遍重建全部紧凑数组和索引;
  5. CHECKBOX 模式:recomputeAggregates 从后往前重算所有分支;RADIO/SELECT 模式:只重新定位那一个选中节点;
  6. 搜索态:用原关键词重建 searchTree。

这六步的顺序与依赖关系如下:

其中第 3、4 步值得单独看,因为它们决定了"一次同步"为什么能压到毫秒级。

可见性单遍:用一个栈记录"折叠祖先的右边界"。遇到超出边界的节点就弹栈;栈空则可见。没有递归,没有二次扫描:

ts 复制代码
const collapsedBoundaries: i32[] = []
for (let i = 0; i < fullTree.length; i++) {
  const node = fullTree[i]
  while (
    collapsedBoundaries.length > 0 &&
    node.leftNode >= collapsedBoundaries[collapsedBoundaries.length - 1]
  )
    collapsedBoundaries.pop()
  if (collapsedBoundaries.length === 0) {
    node.shown = true
    shownNodes.push(node)
    if (node.collapsed && node.rightNode - node.leftNode > 1)
      collapsedBoundaries.push(node.rightNode)
  }
}

紧凑存储单遍 :compactStore.load() 在写数值的同时,用两个栈顺便建立 parent、subtreeEnd、disabledPrefix 和三组子节点计数。它不需要再遍历一次树(为可读性,下面的指针读写简化为 rightOf / depthOf):

ts 复制代码
// 按 MPTT 边界完成子树 → subtreeEnd
while (open.length > 0 &&
       rightOf(open[open.length - 1]) <= node.leftNode)
  subtreeEnd[open.pop()] = i

// 按深度找父节点 → parent + 子节点计数
while (stack.length > 0 && depthOf(stack[stack.length - 1]) >= node.deep)
  stack.pop()
if (stack.length > 0) {
  const parentIndex = stack[stack.length - 1]
  parent[i] = parentIndex
  if (disabled === 0) {
    childTotal[parentIndex]++
    if (checked === 2) childChecked[parentIndex]++
    else if (checked === 1) childHalf[parentIndex]++
  }
}

第 5 步的 recomputeAggregates 从数组尾部向前走:子节点一定在父节点之后(前序),所以"从后往前"天然是自底向上。每个分支只读三个计数,不递归:

ts 复制代码
for (let i = this.checked.length - 1; i >= 0; i--) {
  if (childTotal[i] === 0) continue
  const previous = checked[i]
  const next = aggregateStateAt(childChecked, childTotal, childHalf, i)
  checked[i] = next
  tree[i].checked = next
  updateChildStateAt(childChecked, childHalf, parent[i], previous, next)
}

5. 修改内容:不是所有写都值得走结构路径

updateNodeName 和 updateNodeDisabled 是两条不同的路径:

  • 改名字 :只写 fullTree[index].name,失效搜索候选和 JSON 缓存,O(1);
  • 改禁用 :要更新 disabledPrefix(从 index+1 到末尾整体加减,O(N))、父节点的 childTotal(O(1)),再沿祖先链重算勾选聚合(O(depth))。

disabledPrefix 的更新是 O(N) 的,因为它是前缀和数组,改一个点会影响后面所有项。项目选择了"写时 O(N)、查时 O(1)"的取舍:查询子树里有没有禁用节点只差两个前缀和,而禁用状态变更远少于勾选操作。

如果禁用变更也是高频写入,这个取舍就要反过来------改用 Fenwick 树把单点更新降到 O(log N),代价是查询也变成 O(log N)。没有免费的索引,只有把成本放到哪一侧。


6. 什么时候不该用 MPTT

MPTT(嵌套集)适合"读多写少、层级深、经常查子树"的场景。以下情况应该换模型:

场景 更合适的结构 原因
频繁在中间插入/移动节点 邻接表 + 递归 CTE / 闭包表 MPTT 每次中间插入 O(N) 重编号
多写少读 邻接表 写入只改一行
需要并发写 邻接表 / 物化路径 全局编号天然排斥并发
层级极深(>10^9) 物化路径 / 闭包表 i32 编号会溢出
只查直接子节点 邻接表 + 索引 不需要区间

这个项目选 MPTT,是因为目标场景是"百万节点、读多写少、频繁算可见性和子树"。如果你的业务是"拖拽排序 + 实时协作",MPTT 的写放大大概率会先把你拖垮。


7. 实测与口径

环境 :macOS arm64,Node v26.7.0,AssemblyScript release 构建(build/release.wasm),2026-10-22。树为 100,000 节点宽树(每个父节点 10 个子节点),lineHeight = 26,单进程,预热后 15 轮采样取 P50/P95。不同机器、不同 asc 版本不可直接横向比较。

操作 P50 P95 说明
单次顶层追加 6.88ms 11.86ms 含一次 O(N) 派生同步
单次中间插入 15.41ms 16.58ms 含 O(N) 平移 + 同步
单次删除叶子 6.96ms 18.79ms 含 O(N) 边界回收 + 同步
删除 121 节点子树 6.24--15.52ms --- 3 轮样本
事务内顶层追加 0.0071ms/次 --- 1000 次/批,3 轮
事务内中间插入 0.604ms/次 --- 200 次/批,3 轮
100 次顶层追加(逐次) 675.29ms --- 5 轮
100 次顶层追加(事务) 6.08ms --- 5 轮,约 111×

复现命令(仓库内已有基准脚本,本文的 CRUD 探针可按同样方式构造):

bash 复制代码
pnpm run asbuild:release
node --expose-gc scripts/benchmark-capacity.mjs 100000

8. 决策表

你的情况 建议
批量导入 / 接口同步 必须用 beginStructureBatch / endStructureBatch,否则每行一次 O(N)
单个新增、删除 接受一次 O(N) 同步;如果卡顿,考虑把 UI 操作也包进事务,再统一刷新
拖拽排序、频繁移动 不要用 MPTT;换邻接表或物化路径
顶层追加 确认 left 取的是"最后一个顶层节点的右边界 + 1",并补区间断言
删除子树 用 leftNode < right 切连续段,别递归;记得 width 是闭区间宽度
改 disabled 接受前缀和 O(N) 更新;如果高频,改 Fenwick
测试 不只测"节点存在",要测区间不变量:任意两节点区间要么包含、要么不相交

9. 写在最后

MPTT 的增删改没有魔法,只有三笔账:

  • 边界账:插入/删除要维护全局编号,中间操作天然 O(N);
  • 移动账:数组插入/删除要 memmove,也是 O(N);
  • 同步账:每次结构变更后派生索引要重建,这一笔最容易被忽略,也最容易被批量事务摊掉。

把这三笔账分开算,你就不会问"为什么我的 MPTT 插入是 O(N)"------它当然是;你该问的是"我能不能把 N 次插入合并成一次同步"。

下一篇:《复选框的半选状态:从递归到计数器,再到区间覆盖》。从"递归扫子树"到"三组计数器",再到"区间覆盖 + 按需物化",以及为什么 RootOnly 输出能把 1.2 秒压到 14 毫秒。


项目:vue3-giant-tree-wasm(Vue 3 + AssemblyScript/WASM,BSD-2-Clause)。本文代码来自 assembly/giant-tree.ts、assembly/tree-builder.ts、assembly/tree-visibility.ts、assembly/compact-store.ts,为便于阅读做了节选和简化。

项目地址:github.com/jktantan/vu... 在线文档与演示:jktantan.github.io/vue3-giant-... npm:vue3-giant-tree-wasm

相关推荐
FPGA小徐1 小时前
【一生一芯 / PA】AM 核心文件逐行解析(上):从 start.S 到 gpu.c
前端·算法
Tairitsu_H2 小时前
[动态规划#1] 子数组 | 最大子数组和 | 环形子数组的最大和 | 乘积最大子数组 | 乘积为正数的最长子数组
算法·动态规划
凯瑟琳.奥古斯特2 小时前
动态调优IoU提升OCR文本合并精度
开发语言·python·算法
布莱克6052 小时前
A* 算法详解:从原理到实现
开发语言·c++·算法
段一凡-华北理工大学3 小时前
高炉炼铁机器视觉与智能识别十八讲~系列文章15:可靠性篇:防误报、容错与识别系统的鲁棒性设计
人工智能·算法·机器学习·高炉大模型·高炉机器视觉·高炉炼铁模型开发
某林2123 小时前
局部路径规划器 DWB:速度采样与评分权重的取值分析
人工智能·算法·架构·机器人·技术复盘
AI职业加油站3 小时前
模数共振行动解读|大模型应用工程师证书,匹配行业 RAG/Agent 落地人才需求
人工智能·学习·算法·职场和发展·职场发展
lengxuenong4 小时前
树状数组 · 信息学学习手册2-离散化与逆序对
c++·学习·算法
琳达爱分享4 小时前
五个线程同时去换 token,四个被踢下线:刷新接口其实不是幂等的
后端·算法·架构