跳转至

第 113 章 树链剖分

配套例题:BISHI124 【模板】最近公共祖先(LCA)、BISHI126 【模板】动态区间和Ⅱ 来源:S3 day8《树上算法》第 57–79 页(树链剖分原理与实现);S4 模板.docx「树 → 树链剖分(两个模板)」 前置94-树上算法39-树状数组与线段树45-倍增

113.0 这一章为什么存在

S3 day8 用了 23 页讲树链剖分,S4 的模板清单里它是「树」一节里最长的两个模板之一。 在 NOIP 复赛里,树剖是处理树上路径修改/查询的唯一通用武器

但牛客题单里没有树剖题。最接近的是 BISHI124(纯 LCA,lowest common ancestor, 最近公共祖先:两点在树上共同的祖先里最深的那个)和 BISHI126(纯序列区间操作)—— 恰好是树剖的两个半成品:树剖 = 「树 → 序列」的映射 + 一维数据结构。

所以这一章的定位是:

目标 说明
补上 S3 day8 / S4 的知识缺口 树上路径操作的完整解法
用 BISHI124 演示剖分本身 树剖求 LCA 是最简单、常数最小的用法,不需要任何数据结构
用 BISHI126 演示底层结构 剖分之后落在哪棵一维结构上
给出诚实的可行性判断 树剖 + 线段树在 Python 下常数极大,\(n\) 到多少还能用?

先给结论(下面会展开):

在 Python 里,「树剖 + 线段树」的实用上限大约是 \(n, q \le 3\times10^4\)。 但「树剖求 LCA」(不带数据结构)可以轻松做到 \(n, q = 5\times10^5\) 这两者差了一个数量级半,原因就是那个 \(\log\) 和它背后的常数。


113.1 思路:把树拆成链

S3 day8 第 57–60 页:

树链剖分是用于处理树上路径操作的利器。 顾名思义,将树分解成一条一条的链,从而可以使用线性数据结构来处理树上路径问题。 为了方便处理,这些链都没有转折的地方,都是沿着向上的方向。某些链可能只有一个点。

第 61–62 页:

此时树上的边分为两种:一类是链内部的边,称为重边;另一类则是链外部的边,称为轻边。 处理一条树上路径的时候,通过这些链将路径切分成许多区间

核心思想一句话

\[\text{树上路径} \xrightarrow{\text{剖分}} O(\log n) \text{ 个连续区间} \xrightarrow{\text{线段树/树状数组}} O(\log^2 n)\]

113.2 重儿子的选法与 \(O(\log n)\) 保证

S3 day8 第 64–68 页:

每个节点下面只能有一条重边。通常选择这条重边的策略是根据儿子子树的大小, 选择其中大小最大的儿子,这个儿子也称为重儿子

为什么要这么选择呢?设当前节点为 \(x\),重儿子为 \(u\), 显然子树大小超过 \(\frac{1}{2}size[x]\) 的儿子只能有一个, 因此除了 \(u\) 之外,其它的子树大小不超过 \(\frac{1}{2}size[x]\)

轻边的性质非常好:从任意一个节点到根节点的路径上至多经过 \(\lceil \log n\rceil\) 条轻边。

除了最顶上的链,其余链上的区间都是因为轻边的原因而被截断的。 因此轻边的数量决定了将树上路径拆分成线性区间的数量。 根据轻边的性质,每条树上路径最多被拆分成 \(2\lceil \log n\rceil\) 个区间

如果是树上路径加的数据结构题,再利用线段树或者树状数组就可以以单次操作 \(O(\log^2 n)\) 的时间复杂度解决了。

证明轻边数 \(\le \lceil\log n\rceil\):每走一条轻边向上,子树大小至少翻倍 (因为轻儿子的子树大小 \(\le \frac{1}{2}\) 父亲的), 从大小 1 翻到 \(n\) 最多 \(\log n\) 次。

这条性质就是「跳链次数是 \(\log\) 级」的全部理由,值得把因果链摆清楚:

  1. 按子树最大选重儿子 → 一个点的其余儿子子树都 \(\le\) 自己的一半;
  2. → 沿轻边向上一次,子树规模至少翻倍 → 从任一点到根最多 \(\lceil\log n\rceil\) 条轻边;
  3. 重链在序列上是连续的,走重链不花代价;每换一条链恰好跨一条轻边
  4. → 跳链次数 = 轻边数 \(\le \lceil\log n\rceil\),一条路径两端各跳一次,共 \(2\lceil\log n\rceil\)

换句话说,「重儿子优先」不是为了让链变长好看,是为了让轻边变稀, 而跳链的代价只由轻边数决定。

上界
点到根的轻边 \(\lceil\log n\rceil\)
点到根经过的 \(\lceil\log n\rceil + 1\)
任意两点路径拆成的区间 \(2\lceil\log n\rceil\)
单次路径操作的复杂度 \(O(\log n)\) 个区间 \(\times\ O(\log n)\) 每个 \(= O(\log^2 n)\)

\(n = 5\times10^5\)\(\log n \approx 19\),一次路径操作要碰 38 个区间、 每个区间在线段树上 \(\approx 40\) 次访问 → 约 1500 次基本操作。 记住这个数字,113.7 的可行性判断全靠它。


113.3 两遍 DFS

S3 day8 第 70–73 页:

一般树链剖分都是两遍 DFS: 第一遍算出一些重要的信息,如子树大小 size、每个节点的父亲 father、节点的深度 depth 等。

第二遍则正式进行树链剖分。为了方便寻找区间,对每个点记录 top 表示自己所处的链的顶端的节点。 为了能拆分区间,同一条链上的节点按照顺序依次编号,得到 id, 这样一条链在序列上就是连续的。 DFS 的时候先找出重儿子并且优先进入。这样 id 实际上也是 DFS 序。

于是每个点要记 6 个数组:

数组 含义 第几遍算
par[u] 父亲 第一遍
dep[u] 深度 第一遍
sz[u] 子树大小 第一遍
heavy[u] 重儿子(无儿子则 0) 第一遍
top[u] 所在重链的顶端 第二遍
id[u] 在序列上的位置 第二遍

id 同时是 DFS 序,这是树剖的一大红利: 子树 \(u\) 恰好对应连续区间 \([id[u],\ id[u]+sz[u]-1]\), 所以子树操作和路径操作可以用同一棵数据结构。 见 94-树上算法 的 DFS 序小节。

Python 必须写迭代 DFS

\(n = 5\times10^5\) 的链状树,递归深度就是 \(5\times10^5\)——必然爆 C 栈。 两遍 DFS 都要改成迭代。技巧和 112 章 一样, 但树剖有个更省事的写法:

先用一次迭代 DFS 求出「先序遍历序列」order, 然后 order 逆序扫一遍算 sz/heavy,正序扫一遍算 dep/top/id。 三个循环全是平坦的 for,一行递归都没有。

这是本章最实用的一个技巧:先序列化,再批处理。理由:

  • 先序保证「父亲在儿子之前」→ 正序扫可以自顶向下传播(deptop);
  • 逆序保证「儿子在父亲之前」→ 逆序扫可以自底向上汇总(szheavy)。

113.4 模板:树链剖分(迭代版)

# [片段]
def hld(n, root, start, to):
    """树链剖分(三趟平坦循环,零递归)。图用 CSR (start, to) 存,点编号 1..n。

    返回 (par, dep, sz, heavy, top, tid, seq):
      tid[u]  —— u 在序列上的位置(1..n),同时是 DFS 序;
      seq[i]  —— 序列位置 i 上的点(tid 的逆),用于建初始数组;
      子树 u 对应区间 [tid[u], tid[u] + sz[u] - 1]。

    复杂度 O(n)。Python 下 n <= 5e5 约 2-3 秒(含建图)。
    """
    # ---- 趟 1:迭代 DFS 求先序 order 与父亲 par ----
    par = [0] * (n + 1)                      # par[root] 留 0,0 号点当哨兵,永不出现在树上
    order = [0] * n                          # order[i] = 先序里第 i 个点(i 从 0 数)
    vis = bytearray(n + 1)                   # 无向边存了两个方向,靠 vis 防止往父亲回走
    stk = [root]
    vis[root] = 1
    cnt = 0
    while stk:
        u = stk.pop()
        order[cnt] = u; cnt += 1             # 出栈顺序即先序:父亲一定早于它的所有后代
        for i in range(start[u], start[u + 1]):
            v = to[i]
            if not vis[v]:
                vis[v] = 1
                par[v] = u
                stk.append(v)

    # ---- 趟 2:逆先序汇总 sz 与 heavy(儿子一定在父亲之后)----
    sz = [0] + [1] * n                       # 每点自身算 1;sz[0] = 0 是给下面的比较当底
    heavy = [0] * (n + 1)                    # 0 表示还没有重儿子(叶子就一直是 0)
    for i in range(n - 1, 0, -1):            # 倒着扫:处理 u 时它的子树已经全部汇总完
        u = order[i]                         # i 从 n-1 数到 1,跳过 order[0] = root(没有父亲)
        f = par[u]
        sz[f] += sz[u]                       # 子树大小自底向上累加
        if sz[u] > sz[heavy[f]]:             # sz[0] = 0,heavy[f] 初值 0 天然最小
            heavy[f] = u                     # 于是 f 的第一个儿子必定先当选,之后再择大者

    # ---- 趟 3:正先序传播 dep 与 top,并按「重儿子优先」重新编号 ----
    dep = [0] * (n + 1)
    top = [0] * (n + 1)
    tid = [0] * (n + 1)                      # tid[u]:u 在剖分序列上的位置,1..n
    seq = [0] * (n + 1)                      # seq 是 tid 的逆,用来照序列顺序取初值建树
    top[root] = root                         # 根自己是第一条重链的链顶
    timer = 0
    stk = [root]
    while stk:                               # 这一趟必须重新 DFS:编号要求重儿子优先
        u = stk.pop()
        timer += 1                           # 编号从 1 开始,和树状数组/线段树的下标习惯一致
        tid[u] = timer
        seq[timer] = u
        h = heavy[u]
        f = par[u]
        tu = top[u]                          # 缓存下来,下面的循环里要反复用
        # 先把轻儿子压栈,最后压重儿子 —— 栈是后进先出,重儿子会被最先弹出
        for i in range(start[u], start[u + 1]):
            v = to[i]
            if v != f and v != h:            # 排除父亲(无向边的回头方向)与重儿子
                dep[v] = dep[u] + 1
                top[v] = v                   # 轻儿子自成新链的顶端
                stk.append(v)
        if h:
            dep[h] = dep[u] + 1
            top[h] = tu                      # 重儿子继承父亲的链顶
            stk.append(h)                    # 最后压,于是紧接着就被弹出,重链编号连续
    return par, dep, sz, heavy, top, tid, seq

趟 3 的「先压轻儿子、后压重儿子」是关键。 栈后进先出,最后压的重儿子会被最先弹出, 于是重链上的点获得连续tid——这正是整个算法成立的前提。 写反了不会报错,只会让链在序列上碎成一段一段,复杂度退化。


113.5 路径操作:跳链

S3 day8 第 73–74 页:

拆分简单路径 \(u - v\) 时,如果 \(u\)\(v\) 在同一条链上,那么区间的端点就是 id[u]id[v]

如果不在同一条链上,就需要尝试跳到同一条链上。选取 top[u]top[v]深度较大者, 因为它不可能在另外一条深度小的链的上方。假设是 top[u],那么让 \(u\) 跳到链的顶端, 路径在链上拆分出一个区间(从 \(u\)top[u]),然后 \(u\) 走过一条轻边来到下一条链上。 直到出现第一种情况为止。

# [片段]
def path_ranges(u, v, top, dep, par, tid):
    """把树上路径 u-v 拆成若干个 [l, r] 区间(在剖分序列上)。

    返回区间列表,共 O(log n) 个。区间之间无序,但对「区间加 / 区间和」
    这类可交换的操作无所谓;对「区间赋值 + 顺序敏感」的操作需另行处理。
    """
    res = []
    tu, tv = top[u], top[v]                  # 链顶缓存,循环里每步都要读
    while tu != tv:                          # 链顶不同 = 两点还不在同一条重链上
        if dep[tu] >= dep[tv]:               # ★ 跳链顶更深的那一边
            res.append((tid[tu], tid[u]))    # u 到本链链顶是一段连续区间(重链编号连续)
            u = par[tu]; tu = top[u]         # 跨一条轻边跳到上一条链,循环最多 log n 轮
        else:
            res.append((tid[tv], tid[v]))
            v = par[tv]; tv = top[v]
    # 此时 u、v 在同一条链上,LCA 就是深度较小的那个
    if dep[u] <= dep[v]:
        res.append((tid[u], tid[v]))         # 同链上区间的左右端由深度定序,保证 l <= r
    else:
        res.append((tid[v], tid[u]))
    return res


def lca(u, v, top, dep, par):
    """树剖求 LCA,O(log n),**不需要任何数据结构,常数极小**。"""
    tu, tv = top[u], top[v]
    while tu != tv:                          # 和 path_ranges 同一套跳链,只是不收集区间
        if dep[tu] >= dep[tv]:
            u = par[tu]; tu = top[u]
        else:
            v = par[tv]; tv = top[v]
    return u if dep[u] < dep[v] else v       # 落到同一条链后,浅的那个就是 LCA

「跳链顶更深的那一边」不能写成「跳自己更深的那一边」。 反例:\(u\) 在一条很深的短链上、\(v\) 在一条起点很浅的长链上, 此时 \(dep[u] > dep[v]\)\(top[u]\) 反而更浅,跳 \(u\) 会直接跳过 LCA。 判断依据必须是 dep[top[·]]

三类操作的写法

操作 做法
路径修改/查询 path_ranges 拆成 \(O(\log n)\) 个区间,逐个丢给数据结构
子树修改/查询 一个区间:\([tid[u],\ tid[u]+sz[u]-1]\)
单点 区间 \([tid[u], tid[u]]\)
边权题 把边权下放到深度较大的端点,路径操作时最后一段要去掉 LCAl+1r

边权下放后最容易忘的一步:路径 \(u\to v\) 上的边共有 \(dep[u]+dep[v]-2dep[lca]\) 条, 而点有这个数量 \(+1\) 个。所以最后那段区间要写成 \([tid[lca]+1,\ tid[\cdot]]\)把 LCA 本身排除掉——它代表的是 LCA 与其父亲之间的边,不在路径上。


113.6 树剖 vs 倍增 LCA

树剖 LCA 倍增 LCA 欧拉序 + ST 表
预处理 \(O(n)\) \(O(n\log n)\) \(O(n\log n)\)
单次查询 \(O(\log n)\) \(O(\log n)\) \(O(1)\)
空间 \(O(n)\) \(O(n\log n)\) \(O(n\log n)\)
Python 实际常数 最小(每次约 \(2\log n\) 次简单循环) 中(每次 \(\log n\) 次二维索引) 大(建表 \(n\log n\) 次)
能扩展到路径操作 ❌(只能求 LCA 和 \(k\) 级祖先)

在 Python 里,树剖是 LCA 的首选: 预处理只有三趟线性扫描(无 \(\log\)),空间只有 \(O(n)\),查询循环体极短。 \(n = 5\times10^5\) 时倍增法的 up[19][5e5] 数组光建表就要 \(10^7\) 次 Python 层赋值, 而树剖的预处理只有 \(3\times5\times10^5 = 1.5\times10^6\) 次。差 6 倍以上。


113.7 Python 下的可行规模(本章最重要的一节)

树剖的复杂度写作 \(O(\log^2 n)\),但常数藏在两层里:外层跳链、内层数据结构。

用法 单次操作的 Python 层迭代数(\(n=5\times10^5\) 可行的 \(n, q\)
树剖 LCA(无数据结构) \(\approx 2\log n \approx 38\) \(5\times10^5\)
树剖 + 树状数组(单点改 / 路径和,可减信息) \(2\log n \times 2\log n \approx 1400\) \(3\times10^4\) ⚠️
树剖 + 非递归线段树(路径加 / 路径和) \(2\log n \times 6\log n \approx 4300\) \(10^4\) ⚠️,\(3\times10^4\) 已很险
树剖 + 递归线段树 \(\approx 10^4\)函数调用 完全不可行
子树操作(只有一个区间) \(\approx 2\log n \approx 40\) \(10^5\)

估算示范\(n = q = 10^5\)、树剖 + 非递归线段树的路径加: \(10^5 \times 4300 = 4.3\times10^8\) 次 Python 层迭代 \(\approx\) 40–60 秒。 即使牛客给「其他语言」10 秒,也差 5 倍。

诚实结论

场景 Python 判断
只要 LCA / 只要子树操作 放心用树剖\(5\times10^5\) 都没问题
路径操作,\(n,q \le 10^4\) ✅ 可行
路径操作,\(n,q \le 3\times10^4\) ⚠️ 险,必须用非递归线段树或树状数组
路径操作,\(n,q \ge 10^5\) 放弃树剖,另找出路

\(n \ge 10^5\) 的路径操作题,Python 的出路是「离线」: - 只有查询没有修改 → 预处理根到点的前缀和,路径和 \(= s_u + s_v - 2s_{lca} + w_{lca}\), 单次 \(O(\log n)\)(只花在 LCA 上),\(5\times10^5\) 可行; - 修改集中在前面、查询集中在后面 → 差分 + 一次 DFS 统计; - 树上差分:路径加 + 最后统一查,用 \(d_u{+}{=}x,\ d_v{+}{=}x,\ d_{lca}{-}{=}x,\ d_{par(lca)}{-}{=}x\), 最后一次子树求和搞定,全程 \(O(n\log n)\)没有数据结构

树上差分是 Python 选手对付「路径加 + 最后查询」的标准答案, 比树剖快一个数量级。42-前缀和与差分


113.8 例题

BISHI124 【模板】最近公共祖先(LCA)(中等)

\(N, M \le 5\times10^5\),给定以 \(R\) 为根的树,\(M\) 次询问两点的 LCA。 时限:C/C++ 3 秒,其他语言 6 秒;空间 C/C++ 256M,其他语言 512M。 题面见 BISHI124 原题(牛客)

ℹ️ solutions/BISHI124.py 用的是另一条路线——欧拉序 + 分块稀疏表,查询 \(O(1)\)—— 已在牛客用 Python 3 通过。下面这份树剖版由 scripts/verify_docs.py官方样例实测通过,未单独提交牛客;两者的取舍见本节末尾。

这题是树剖的最佳展示场:只要剖分,不要任何数据结构。

对比三种做法在 \(n = m = 5\times10^5\) 下的开销:

做法 预处理 查询总量 空间
树剖 \(3n = 1.5\times10^6\) \(m \times 2\log n = 1.9\times10^7\) \(O(n)\) 约 7 个数组
倍增 \(n\log n = 10^7\) \(m\log n = 10^7\) \(O(n\log n)\) = \(10^7\) 个指针 ≈ 80MB
Tarjan 离线 \(O(n\alpha)\) \(O(m\alpha)\) \(O(n+m)\)

树剖在预处理和空间上都赢,而且倍增法的 \(10^7\) 个指针在 512M 下已经开始危险。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); m = int(data[1]); root = int(data[2])

    # ---- 1. CSR 邻接表 ----
    deg = [0] * (n + 2)                        # 多开两格:下标 0 空着,n+1 放哨兵
    xs = [0] * (n - 1); ys = [0] * (n - 1)     # n 个点的树恰有 n-1 条边
    p = 3                                      # 前三个 token 是 n、m、root
    for i in range(n - 1):
        x = int(data[p]); y = int(data[p + 1]); p += 2
        xs[i] = x; ys[i] = y                   # 边先存起来,度数统计完才知道各自的槽位
        deg[x] += 1; deg[y] += 1               # 无向边两端的度都要加
    start = [0] * (n + 2)
    acc = 0
    for i in range(1, n + 1):
        start[i] = acc                         # 度数的前缀和(不含自己)就是起始下标
        acc += deg[i]
    start[n + 1] = acc                         # 哨兵,让 start[u+1] 对 u = n 也成立
    cur = start[:]
    to = [0] * acc                             # acc = 2(n-1),一次开够
    for i in range(n - 1):
        x = xs[i]; y = ys[i]
        to[cur[x]] = y; cur[x] += 1            # 一条无向边写成两个方向
        to[cur[y]] = x; cur[y] += 1

    # ---- 2. 迭代 DFS:先序 order 与父亲 par ----
    par = [0] * (n + 1)                        # par[root] 保持 0,0 号点当哨兵
    order = [0] * n
    vis = bytearray(n + 1)                     # 邻接表含回头边,靠 vis 拦住往父亲走
    stk = [root]
    vis[root] = 1
    cnt = 0
    while stk:
        u = stk.pop()
        order[cnt] = u; cnt += 1               # 出栈序即先序,父亲一定排在后代之前
        for i in range(start[u], start[u + 1]):
            v = to[i]
            if not vis[v]:
                vis[v] = 1
                par[v] = u
                stk.append(v)

    # ---- 3. 逆先序:子树大小与重儿子 ----
    sz = [0] + [1] * n                         # sz[0] = 0 是给下面的 sz[heavy[f]] 当比较底
    heavy = [0] * (n + 1)
    for i in range(n - 1, 0, -1):              # 倒序 = 自底向上;i 停在 1,root 没有父亲
        u = order[i]
        f = par[u]
        sz[f] += sz[u]
        if sz[u] > sz[heavy[f]]:               # 严格大于:同大小时保留先到的那个儿子
            heavy[f] = u

    # ---- 4. 正先序:深度与链顶 top(求 LCA 不需要 tid,省一趟)----
    dep = [0] * (n + 1)
    top = [0] * (n + 1)
    top[root] = root                           # 根是自己所在重链的链顶
    for i in range(n):                         # 正序 = 自顶向下,处理 u 时 top[u] 已定
        u = order[i]
        f = par[u]
        h = heavy[u]
        tu = top[u]
        for j in range(start[u], start[u + 1]):
            v = to[j]
            if v != f:                         # 跳过父亲,剩下的都是儿子
                dep[v] = dep[u] + 1
                top[v] = tu if v == h else v   # 重儿子继承链顶,轻儿子自成链顶
    # ---- 5. 跳链求 LCA ----
    out = []
    push = out.append                          # 绑定成局部名字,省去每次的属性查找
    for _ in range(m):
        a = int(data[p]); b = int(data[p + 1]); p += 2
        ta = top[a]; tb = top[b]
        while ta != tb:                        # 链顶相同时两点已在同一条重链上,退出
            if dep[ta] >= dep[tb]:             # ★ 比的是 dep[top],不是 dep 本身
                a = par[ta]; ta = top[a]       # 链顶更深的一方整条链都在 LCA 下方,可安全跳过
            else:
                b = par[tb]; tb = top[b]
        push(a if dep[a] < dep[b] else b)      # 同链上浅的那个即 LCA
    sys.stdout.write("\n".join(map(str, out)) + "\n")   # 一次性输出,逐行 print 会慢十倍


main()

样例复核\(N=5\)\(R=4\),边 3-1、2-4、5-1、1-4): 树的形状是 \(4 \to 1 \to \{3, 5\}\)\(4 \to 2\)。 询问 \((2,4)\to4\)\((3,2)\to4\)\((3,5)\to1\)\((1,2)\to4\)\((4,5)\to4\), 与样例输出 4 4 1 4 4 完全一致 ✓。

四个工程要点

  1. 求 LCA 不需要 tid,所以第 4 趟可以直接在 order 上平坦地扫, 连第二次 DFS 都省了(top 的传播只依赖父亲,先序保证父亲先算好);
  2. ta = top[a] 缓存下来:循环体里 top[a] 要读两次,缓存能省一半索引;
  3. sz[heavy[f]] 依赖 sz[0] = 0,所以 sz 必须写成 [0] + [1] * n 而不是 [1] * (n+1)——否则每个点的第一个儿子都当不成重儿子;
  4. \(n = 1\)n - 1 = 0xs = ys = [],所有循环自然退化,无需特判。

Python 现实性判断

量级 估时
读入 \(3\times10^6\) 个 token C 层 split 1.0 s
建 CSR \(\approx 3n = 1.5\times10^6\) 1.5 s
三趟扫描 \(\approx 4n = 2\times10^6\) 1.5 s
\(5\times10^5\) 次查询,每次约 \(2\times19 = 38\) 次跳链 \(1.9\times10^7\) 2–4 s
输出 \(5\times10^5\) 行 join 0.3 s

总计 6–8 秒,在 6 秒限制下余量很小。 缓解因素是 \(2\log n = 38\) 次跳链只是最坏界:随机形态的树上重链很长, 实测每次询问只跳 3–5 次,只有链状/星状的极端数据才逼近上界。

稳妥的替代路线是「欧拉序 + 分块稀疏表」:预处理线性,单次查询 \(O(1)\), 把每次询问的几十次跳链换成两次 min(切片) 的 C 层扫描。 solutions/BISHI124.py 就是这么写的,已在牛客用 Python 3 通过。 还有一条离线路线是 Tarjan 离线 LCA(配并查集,见 38-并查集,均摊近似常数), 代价是要把 \(5\times10^5\) 个询问全存下来再一次性回答。

BISHI126 【模板】动态区间和Ⅱ ‖ 区间修改 + 区间查询(较难)

\(n, q \le 5\times10^5\)\(|a_i|, |x| \le 10^7\)1 l r x 区间加,2 l r 区间求和。 时限:C/C++ 5 秒,其他语言 10 秒;空间 C/C++ 1024M,其他语言 2048M。 题面见 BISHI126 原题(牛客)

ℹ️ solutions/BISHI126.py 用的正是下面这套双树状数组,已在牛客用 Python 3 通过。 下面这份为便于讲解,初始数组用 \(n\) 次单点加建树;题解文件里换成了 \(O(n)\) 建树, 少掉约 \(1.9\times10^7\) 次循环。两份都由 scripts/verify_docs.py官方样例实测通过。

这题为什么放在树剖章:它就是树剖的底座。 把树剖的 path_ranges 拆出来的每个区间丢给它,就得到了「树上路径加 + 路径求和」。 反过来说,如果这道一维题在 Python 下都很险,那树剖版就完全没戏—— 这正是 113.7 结论的实证。

题面自己给了提示:

我们可以使用线段树解决……您也可以尝试使用区间扩展版的树状数组解决本题, 其运行时的常数更小

在 Python 里这不是「也可以」,是「必须」。用双树状数组(39 章 形态三)。

推导:设 \(d\) 是差分数组(\(d_j = a_j - a_{j-1}\)),则

\[\sum_{i=1}^{k} a_i = \sum_{j=1}^{k}(k-j+1)d_j = k\sum_{j=1}^{k}d_j - \sum_{j=1}^{k}(j-1)d_j\]

所以维护两棵树:\(B_1\)\(d_j\)\(B_2\)\((j-1)d_j\)

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); q = int(data[1])
    N = n + 1                                # 多留一格,range_add 会碰 r+1
    t1 = [0] * (N + 1)                       # 维护差分 d[j]
    t2 = [0] * (N + 1)                       # 维护加权差分 (j-1)*d[j]

    def range_add(l, r, v):
        """a[l..r] 全部 += v。差分视角:d[l] += v,d[r+1] -= v。"""
        i = l; w = v * (l - 1)               # 位置 l 的加权量是 (l-1)*v,配合 t2 的定义
        while i <= N:
            t1[i] += v; t2[i] += w; i += i & -i   # i & -i 取最低位的 1,即这一格管辖的长度
        i = r + 1; w = v * r                 # 在 r+1 处撤销,差分才只影响 [l, r]
        while i <= N:
            t1[i] -= v; t2[i] -= w; i += i & -i

    def pre(i):
        """a[1] + ... + a[i]。★ 两棵树在同一个 while 里走,循环次数减半。"""
        s1 = 0; s2 = 0; j = i
        while j > 0:
            s1 += t1[j]; s2 += t2[j]; j -= j & -j  # 减 lowbit 逐段回退,最多 log n 步
        return s1 * i - s2                   # 即 i·Σd - Σ(j-1)d,正文推导的那个式子

    for i in range(1, n + 1):                # 初始数组视作 n 次单点加
        v = int(data[1 + i])                 # data[0] 是 n,data[1] 是 q,数组从下标 2 起
        if v:                                # 跳过 0,省掉一趟树状数组
            range_add(i, i, v)               # 单点加就是长度为 1 的区间加

    p = 2 + n                                # 跳过 n、q 与 n 个初值,指到第一条操作
    out = []
    push = out.append
    for _ in range(q):
        if data[p] == b"1":                  # 与 bytes 直接比较,比 int(...) 再判快
            range_add(int(data[p + 1]), int(data[p + 2]), int(data[p + 3]))
            p += 4                           # 操作 1 有 4 个 token
        else:
            l = int(data[p + 1]); r = int(data[p + 2]); p += 3   # 操作 2 只有 3 个
            push(pre(r) - pre(l - 1))        # 区间和 = 两个前缀和相减
    sys.stdout.write("\n".join(map(str, out)) + "\n")


main()

Python 现实性判断

Python 层循环迭代数
初始化(\(n\) 次单点加 = 2 趟树状数组) \(5\times10^5 \times 2\times19 \approx 1.9\times10^7\)
\(q\) 次修改(4 趟) \(5\times10^5 \times 4\times19 \approx 3.8\times10^7\)
\(q\) 次查询(2 次双树走) \(5\times10^5 \times 2\times19 \approx 1.9\times10^7\)

总量约 \(7.6\times10^7\) 次,按 \(10^7\) 次/秒是 7–8 秒。10 秒限制下余量不宽裕, 但这是 Python 里唯一有希望的做法,solutions/BISHI126.py\(O(n)\) 建树把第一行的 \(1.9\times10^7\) 次省掉后已通过牛客判题机。

对照非递归懒标记线段树:每次操作约 \(6\log n = 114\) 次迭代, \(10^6\) 次操作就是 \(1.1\times10^8\)——必然超时

现在把树剖套上去看看:树上路径加 + 路径求和,每次操作要拆成 \(2\log n = 38\) 个区间,每个区间做一次上面的 range_add\(4\times19 = 76\) 次迭代), 单次操作 \(\approx 2900\) 次迭代。\(q = 5\times10^5\) 就是 \(1.5\times10^9\) 次—— 约 150 秒,差了 15 倍。

这就是 113.7 那张表的来历:Python 的树剖路径操作,\(n,q\) 上限大约是 \(3\times10^4\)


113.9 树剖的替代方案速查

拿到「树上路径操作」的题,按这个顺序考虑

条件 方案 Python 可行规模
只查询、不修改 根前缀和 + LCA \(5\times10^5\)
路径加,最后统一查 树上差分 + 一次子树求和 \(5\times10^5\)
单点改 + 子树查 DFS 序 + 树状数组 \(10^5\)
子树加 + 子树查 DFS 序 + 双树状数组 \(10^5\) ⚠️
路径加 + 路径查,\(n\le3\times10^4\) 树剖 + 双树状数组 ⚠️
路径加 + 路径查,\(n\ge10^5\) Python 放弃
路径最值 / 不可减信息 树剖 + 线段树 \(10^4\) ⚠️
动态加边删边 LCT(动态树) ❌ Python 不现实

「树上差分」为什么这么好用:路径 \(u\to v\)\(x\),只需

# [片段]
d[u] += x               # 路径两端各打一个「起点」标记
d[v] += x
l = lca(u, v)
d[l] -= x               # LCA 被两条支路各算了一次,扣掉一次
d[par[l]] -= x          # 再扣一次,挡住标记继续往 LCA 上方传;par[root] = 0,需要一个哨兵位
最后按逆先序扫一遍把 d 往上累加,每个点的值就是它被加过的总量。 全程 \(O(n + q\log n)\),且 \(\log\) 只花在 LCA 上,没有任何数据结构


113.10 本章速查

要点 结论
核心思想 树上路径 → \(O(\log n)\)连续区间
重儿子 子树最大的儿子
重边 / 轻边 通往重儿子 / 其他儿子
轻边性质 点到根最多 \(\lceil\log n\rceil\) 条轻边
路径拆成的区间数 \(\le 2\lceil\log n\rceil\)
单次路径操作 \(O(\log^2 n)\)
六个数组 par dep sz heavy top tid
tid 同时是 DFS 序 → 子树是连续区间 \([tid_u, tid_u+sz_u-1]\)
编号的关键 重儿子优先遍历(用栈时要最后压重儿子
跳链的判据 dep[top[u]] vs dep[top[v]],不是 dep[u] vs dep[v]
边权题 边权下放到深端点,最后一段去掉 LCA
Python 实现 必须三趟平坦循环,零递归
三趟的分工 先序 order → 逆序算 sz/heavy → 正序算 dep/top/tid
树剖 LCA Python 下 LCA 的首选\(O(n)\) 预处理、\(O(n)\) 空间
树剖 + 数据结构 Python 上限约 \(n,q \le 3\times10^4\)
\(n \ge 10^5\) 的路径题 树上差分 / 根前缀和,别写树剖
递归线段树 + 树剖 ❌ 绝对不可行
数据规模 → Python 现实性(树上操作)
树剖求 LCA,\(n,m \le 5\times10^5\)
树上差分(路径加 + 最后查),\(n \le 5\times10^5\)
DFS 序 + 树状数组(子树操作),\(n,q \le 10^5\)
树剖 + 双树状数组(路径加/查),\(n,q \le 3\times10^4\)
树剖 + 线段树,\(n,q \ge 10^5\)
LCT / 动态树