跳转至

第 94 章 树上算法

配套例题:BISHI96 先序遍历、中序遍历和后序遍历、BISHI124 【模板】最近公共祖先(LCA) 来源:S3 day8 树上算法(DFS 序 / LCA / 倍增 / 树链剖分);S2 tree.cpp 二叉树基本结构;S4 模板.docx「树」 前置90-图的表示与遍历45-倍增61-BFS广度优先搜索39-树状数组与线段树

树是「最简单的图」,也是竞赛里出现频率最高的结构。 它比一般图多出来的性质只有两条——无环任意两点唯一路径—— 但就这两条撑起了一整套算法。

这一章的组织逻辑是:

树的性质与存储
遍历(三种序)          ← 一切树上算法的执行顺序
┌─────────────┬──────────────┬─────────────┐
DFS 序 → 子树区间   直径 / 重心      LCA → 路径长度
(配数据结构)      (全局特征)      (两点关系)
      ↓                                ↓
   树链剖分(113 章)              树上差分(42 章)

贯穿全章的一条 Python 主线:树的高度可以达到 \(n\)(一条链), 所以任何递归写法在 \(n \ge 10^4\) 时都会爆栈。 本章的每一个模板都是非递归的,而且尽可能把循环下沉到 C 层。


94.1 树的性质与判定

\(n\) 个点、无环、连通的无向图。

「三选二」定理

对一个有 \(n\) 个点、\(m\) 条边的无向图,下面三条里任意两条成立就能推出第三条, 此时图是树:

\[\textbf{① 连通}\qquad \textbf{② 无环}\qquad \textbf{③ } m = n-1\]

所以判定一个图是不是树,最省事的写法是:数边数 + 判连通

# [片段] 判树:最省事的两条件写法
# m == n - 1 且连通  <=>  是树
ok = (m == n - 1) and count_components(n, start, adj) == 1

⚠️ 只判 \(m = n-1\) 是不够的:一个三角形加一个孤立点也是 4 点 3 边, 但它既不连通也有环。必须配上连通性判定(BFS 或并查集)。

基本性质表

性质 说明
任意两点间有唯一简单路径 这是树上一切「路径问题」的基础
加任意一条非树边 恰好产生一个环(次小生成树就靠这条,见 92.6
删任意一条树边 恰好分成两个连通块
度数和 \(\sum \deg(v) = 2(n-1)\),所以平均度数 \(< 2\)
叶子 \(\deg(v) = 1\) 的点;\(n \ge 2\) 的树至少有 2 个叶子
树一定是二分图 无环 ⟹ 无奇环(93.5
树的直径端点 一定是叶子
\(n\) 个带标号点的不同树的个数 \(n^{n-2}\)(Cayley 公式),配 Prüfer 序列

有根树与无根树

题目给的边通常是无向的(「\(x\)\(y\) 相连」), 而绝大多数算法需要有根树(父亲、儿子、深度、子树)。 「定根」这个动作只是选一个点当根,然后一次遍历确定所有 parent

概念 定义
par[u] \(u\) 的父亲;根的父亲设为 0(哨兵,省掉边界判断
dep[u] 根到 \(u\) 的边数(或点数,看题目约定)
sz[u] 子树 \(u\) 的点数
换根 把根从 \(u\) 换到相邻的 \(v\)\(sz'[v] = n\)\(sz'[u] = n - sz[v]\)。这是「换根 DP」的核心

怎么找根: - 题目直接给(BISHI124 的 \(R\)); - 有向父子边给出 ⟹ 入度为 0 的点(BISHI96); - 无根树且答案与根无关 ⟹ 随便取 1 号点


94.2 树的存储

方式 结构 适用
父数组 par[] 一个长 \(n+1\)list 只需向上跳(并查集式、LCA 倍增)
儿子表 kids[u] [[] for _ in range(n+1)] \(n \le 10^5\) 的有根树,最好写
CSRstart + adj 两个扁平数组 \(n \ge 2\times10^5\) 必用90.2
二叉树 left[] / right[] 两个长 \(n+1\)list 二叉树专用(BISHI96),左右有序
BFS 序 + 父数组 order[] + par[] 树上 DP 的最优表示(94.4)

二叉树千万不要写成节点类

# [片段] ❌ 竞赛里不要这么写
class Node:                                  # n = 1e5 时是 1e5 个对象,慢且费内存
    def __init__(self, val):
        self.val, self.left, self.right = val, None, None

竞赛里点就是编号 \(1..n\),用 left = [0] * (n+1)right = [0] * (n+1) 两个数组0 表示空。下标访问比属性访问快一倍,内存少一个数量级。


94.3 二叉树的三种遍历(迭代实现)

遍历 顺序 特征
先序(preorder) 根 → 左 → 右 第一个元素是根
中序(inorder) 左 → 根 → 右 BST 的中序是有序
后序(postorder) 左 → 右 → 根 最后一个元素是根;「先处理儿子」的场合

为什么必须写迭代版

一棵二叉树可以退化成一条长度 \(n\) 的链(\(1\to2\to3\to\cdots\to n\)), 此时递归深度就是 \(n\)

问题 后果
CPython 默认递归上限 1000 \(n \ge 1000\) 的链必 RecursionError
sys.setrecursionlimit(200000) 只改计数器,C 栈仍会溢出 → 段错误,没有任何报错
每层递归约 0.5 μs 比迭代慢 2–3 倍

判据:二叉树遍历的递归深度上界就是 \(n\)BISHI96 的 \(n \le 10^5\) 已经远超上限,所以三种遍历全部必须迭代。 (另一条路是 threading.stack_size(1<<26) + 开大栈线程,但要多起一个线程、 代码更绕,本教程一律用显式栈。)

模板一:先序 —— 栈里先压右后压左

def preorder_iter(root, left, right):
    """先序遍历(根 左 右)的迭代版。O(n)。

    栈是后进先出,所以要「先压右孩子再压左孩子」,弹出时才是左先右后。
    """
    out = []
    st = [root]                              # 显式栈代替递归,深度不再受 CPython 限制
    while st:
        u = st.pop()
        out.append(u)                        # 出栈即访问
        r = right[u]
        if r:                                # 0 表示空孩子,不入栈
            st.append(r)                     # ★ 先压右
        l = left[u]
        if l:
            st.append(l)                     # ★ 后压左(先弹出)
    return out

模板二:中序 —— 一路向左压栈

def inorder_iter(root, left, right):
    """中序遍历(左 根 右)的迭代版。O(n)。

    三步循环:一路向左把节点全压进栈 -> 弹出并访问 -> 转向它的右子树。
    """
    out = []
    st = []                                  # 栈里存「已进入、但左子树还没处理完」的点
    u = root                                 # u = 下一棵待展开的子树的根,0 表示空
    while st or u:                           # 栈空且无待展开子树时才算结束
        while u:                             # ① 一路向左
            st.append(u)
            u = left[u]                      # 走到最左端,那里是中序的第一个点
        u = st.pop()                         # ② 弹出即访问(它的左子树已处理完)
        out.append(u)
        u = right[u]                         # ③ 转向右子树;为 0 则下轮直接再弹栈
    return out

模板三:后序 —— 「根右左」再整体反转

def postorder_iter(root, left, right):
    """后序遍历(左 右 根)的迭代版。O(n)。

    技巧:先按「根 右 左」跑一遍先序(把先序模板的左右压栈顺序对换),
    得到的序列整体 reverse 就是「左 右 根」。
    比「双栈法」和「标记访问次数法」都更短、更快。
    """
    out = []
    st = [root]
    while st:
        u = st.pop()
        out.append(u)                        # 此刻 out 正在按「根 右 左」增长
        l = left[u]
        if l:
            st.append(l)                     # ★ 与先序相反:先压左
        r = right[u]
        if r:
            st.append(r)                     # 后压右(先弹出)
    out.reverse()                            # ★ 一次 C 层反转
    return out                               # 「根右左」的逆序恰好是「左右根」

三种后序迭代写法的对比(这是本节最值钱的取舍):

写法 原理 代码量 Python 速度
「根右左」+ reverse() 先序模板对换左右,最后反转 最短 最快reverse 在 C 层)
双栈法 一个栈做「根右左」,另一个栈收集 ⚠️ 多一个栈的 append/pop
标记法 (u, visited) 栈里存二元组,第二次遇到才访问 ❌ 每个点造一个元组,慢 2 倍

为什么「根右左 + 反转」是对的:「根右左」的逆序是「左右根」—— 逆序会把「根在最前」变成「根在最后」,同时把「右在左前」变成「左在右前」, 正好是后序。一分钟就能在三节点的树上手验。

层序遍历

层序(BFS)就是 94.4 的 order,一个列表当队列即可:

# [片段] 层序遍历
out = [root]                                 # 结果和队列合二为一
for u in out:                                # 列表在迭代中扩展是安全的
    if left[u]:                              # 队列只在尾部追加、指针只往前走,
        out.append(left[u])                  # 所以 for 的内部游标不会失效
    if right[u]:
        out.append(right[u])                 # 先左后右 -> 同层内保持左右次序

由遍历序列重建二叉树

已知 能唯一重建吗 原因
先序 + 中序 先序首元素是根,在中序里定位它 ⟹ 左右子树的规模确定
后序 + 中序 后序末元素是根,同理
层序 + 中序 同理
先序 + 后序 无法区分「只有左孩子」和「只有右孩子」
单独任一种 显然

中序是重建的关键:只有中序能把「根左边」和「根右边」切开。 实现时把中序的「值 → 下标」预处理成 dict, 否则每层都要 list.index 扫一遍,退化成 \(O(n^2)\)


94.4 「reversed(BFS 序) 就是后序」

这是 Python 里写树上 DP 的标准姿势,重要程度不亚于任何一个模板。

BFS 序里,父亲一定排在所有儿子之前。 所以倒着遍历 BFS 序,就等价于「所有儿子都处理完之后再处理父亲」—— 也就是后序 / 自底向上。

def bfs_order(n, start, adj, root=1):
    """求 BFS 序与父亲数组(CSR 存图)。O(n),无递归、无栈、无 deque。

    order 里父亲一定排在儿子前面,因此 reversed(order) 就是一个合法后序。
    ★ `for u in order:` 一边迭代一边 append —— BFS 只在尾部追加,安全。
    """
    par = [0] * (n + 1)                      # par[root] = 0,0 号点当「没有父亲」的哨兵
    vis = bytearray(n + 1)
    vis[root] = 1
    order = [root]                           # 结果和队列合二为一
    for u in order:
        for i in range(start[u], start[u + 1]):
            v = adj[i]
            if not vis[v]:                   # 树上唯一的「回头路」就是父亲
                vis[v] = 1
                par[v] = u                   # 建立父子关系,无根树就此定向
                order.append(v)
    return order, par


def subtree_size(n, order, par):
    """子树大小。倒着扫一遍 BFS 序即可,O(n)。"""
    sz = [1] * (n + 1)                       # 每个点先把自己算 1
    for u in reversed(order):                # ★ 儿子一定已经算完
        p = par[u]
        if p:                                # 根的 par 是 0,到根就不再往上累加
            sz[p] += sz[u]                   # 把自己整棵子树的规模并给父亲
    return sz
三种「自底向上」写法 深度限制 速度 评价
递归 DFS \(n \ge 1000\) 爆栈 只能写小数据
迭代 DFS + (u, leaving) 事件栈 中(每点一个元组) 需要「进入/离开」两个时机时才用
BFS 序倒序 最快(两个纯数组循环) 树上 DP 的默认写法

它能做什么:子树大小、子树权值和、树形 DP(没有上司的舞会、树上背包)、 树上差分的「子树求和」收尾、直径的树形 DP…… 凡是「先算儿子再算父亲」的都能用。见 60.4103-区间树形状压DP

它不能做什么子树区间。BFS 序里一棵子树的点不连续, 要连续区间必须用 DFS 先序(94.5)。


94.5 DFS 序与子树区间

三种「序」必须分清

名称 长度 每个点出现几次 核心性质 主要用途
DFS 序 / 先序 / dfn \(n\) 1 次(进入时) 子树 = 连续区间 子树改 → 区间改
括号序 \(2n\) 2 次(进入 + 离开) 祖先关系 = 括号嵌套 判祖先、树上莫队
欧拉序 \(2n-1\) \(\deg\) 次(每次回溯都记) 两点间必经过 LCA LCA → RMQ(94.9)

⚠️ 这三个名字在不同资料里混用得很厉害,看到「DFS 序」时一定确认长度: 长度 \(n\) 是先序,\(2n\) 是括号序,\(2n-1\) 是欧拉序。 LCA 用的是欧拉序\(2n-1\)),这是最容易搞错的一处。

DFS 先序与子树区间

def dfs_order(n, start, adj, root=1):
    """DFS 先序(迭代)与子树大小。O(n)。返回 (tin, sz, seq, par)。

    tin[u] = u 在先序里的下标(0-indexed),seq[tin[u]] == u。
    ★ 关键性质:子树 u 恰好对应连续区间 [tin[u], tin[u] + sz[u] - 1]。
      因为栈式先序会把 u 的整棵子树连续地弹出。
    """
    par = [0] * (n + 1)
    tin = [0] * (n + 1)
    seq = []                                 # seq = 先序序列本身
    vis = bytearray(n + 1)
    vis[root] = 1
    st = [root]
    while st:
        u = st.pop()                         # 栈顶 = 最近压入 -> 优先往深处走
        tin[u] = len(seq)                    # 当前长度就是 u 的先序下标
        seq.append(u)
        for i in range(start[u], start[u + 1]):
            v = adj[i]
            if not vis[v]:
                vis[v] = 1                   # 入栈即标记,同一点不会被压两次
                par[v] = u
                st.append(v)
    sz = [1] * (n + 1)
    for u in reversed(seq):                  # 先序倒序 = 后序
        p = par[u]
        if p:
            sz[p] += sz[u]
    return tin, sz, seq, par


def is_ancestor(u, v, tin, sz):
    """u 是否是 v 的祖先(含 u == v)。O(1)。"""
    # u 的子树占据先序区间 [tin[u], tin[u]+sz[u]),v 落在里面就是它的后代
    return tin[u] <= tin[v] < tin[u] + sz[u]

子树区间这条性质把「树上问题」变成了「序列问题」

树上操作 序列操作 用什么
子树 \(u\) 整体加 \(k\) 区间 \([tin_u,\ tin_u+sz_u-1]\)\(k\) 树状数组 / 线段树(39 章
查询子树 \(u\) 的权值和 区间求和 同上
单点改 + 子树查 单点改 + 区间查 树状数组
路径操作 ⚠️ 不能直接映射 需要树链剖分113 章

「子树 = 区间」很容易,「路径 = 区间」很难,这就是树链剖分存在的理由: 它把每条路径拆成 \(O(\log n)\) 段连续区间。 树剖的 id[u] 同时也是一种 DFS 序,所以子树操作和路径操作能共用同一棵数据结构


94.6 树的直径

直径:树上最长的简单路径(的长度)。

做法一:两次 BFS(无权树 / 非负权)

任意点出发 BFS,找到最远点 \(a\);再从 \(a\) 出发 BFS,找到最远点 \(b\)。 则 \(a \to b\) 就是一条直径。

def tree_diameter_bfs(n, start, adj):
    """两次 BFS 求树的直径(无权树)。返回 (直径边数, 端点 a, 端点 b)。O(n)。

    ⚠️ 正确性依赖「边权非负」。有负权边时这个结论不成立,必须用树形 DP。
    """
    def farthest(s):
        """从 s 出发 BFS,返回 (最远点, 该距离)。"""
        dist = [-1] * (n + 1)                # -1 兼任未访问标记
        dist[s] = 0
        q = [s]                              # 列表当队列:BFS 只在尾部追加
        for u in q:
            d = dist[u] + 1
            for i in range(start[u], start[u + 1]):
                v = adj[i]
                if dist[v] < 0:              # 树上不需要父亲判断,标记就够
                    dist[v] = d
                    q.append(v)
        m = max(dist)                        # ★ max / index 都在 C 层
        return dist.index(m), m              # 有并列时取下标最小的,任选一个都对

    a, _ = farthest(1)                       # 第一次:从任意点找到一个直径端点 a
    b, d = farthest(a)                       # 第二次:从 a 出发,最远点就是另一端 b
    return d, a, b

正确性证明(反证):设真正的直径是 \((s,t)\),从任意点 \(x\) 出发的最远点是 \(a\)。 若 \(a\) 不是任何直径的端点,考察 \(x \to a\)\(s \to t\) 这两条路径:

  • 若它们相交于点 \(p\),则 \(d(p,a) \ge d(p,s)\)(否则 \(x\to s\)\(x\to a\) 更远), 于是把 \(s\) 换成 \(a\) 得到的路径不比直径短,矛盾(\(a\) 也是直径端点);
  • 若它们不相交,设连接两条路径的最短路是 \(p\to q\)\(p\)\(x\to a\) 上,\(q\)\(s\to t\) 上), 则 \(d(p,a) \ge d(p,q)+d(q,s)\),于是 \(d(a,t) = d(a,p)+d(p,q)+d(q,t) \ge d(s,q)+d(q,t) = d(s,t)\), 同样说明 \(a\) 是直径端点。

⚠️ 有负权边时两次 BFS 就是错的(上面的不等式全部依赖非负性)。 竞赛里「树的直径 + 负权」不算罕见,看到负权立刻换树形 DP。

做法二:树形 DP(边权可负)

对每个点 \(u\),「经过 \(u\) 的最长路径」= \(u\) 向下的最长链 + 次长链

def tree_diameter_dp(n, start, adj, wt=None, root=1):
    """树形 DP 求直径(边权可为负)。O(n)。用 BFS 序倒序代替递归。

    down[u] = 从 u 向下走的最长链长度(允许长度 0,即停在 u)。
    技巧:处理 u 的每个孩子 v 时,down[u] 里存的是「已处理过的孩子中的最长」,
          所以 down[u] + (down[v] + w) 就是「次长 + 最长」,
          ★ 先更新答案,再更新 down[u],一趟就同时拿到最长和次长。
    """
    order, par = bfs_order(n, start, adj, root)
    down = [0] * (n + 1)                     # 初值 0 = 「停在 u 不往下走」也是合法的链
    best = 0
    for u in reversed(order):                # 倒序 = 后序,孩子的 down 已经是终值
        du = down[u]                         # 局部变量累积,最后一次性写回
        for i in range(start[u], start[u + 1]):
            v = adj[i]
            if v == par[u]:
                continue                     # 树上不走回父亲,就等于只看孩子
            cand = down[v] + (1 if wt is None else wt[i])   # 经 v 往下的链长
            if du + cand > best:             # ★ 先算答案(此时 du 是次长候选)
                best = du + cand             # du 来自之前的孩子,与 cand 分属两个方向
            if cand > du:                    # 再更新最长
                du = cand
        down[u] = du
    return best                              # 答案是「经过某个点的最长路径」的最大值
两次 BFS 树形 DP
复杂度 \(O(n)\)(两遍) \(O(n)\)(一遍)
负权边 不正确
能得到直径的端点 ✅ 直接给出 ⚠️ 要额外记录
代码量
Python 常数 小(两次纯 BFS)
顺便求「每点的最远距离」 ⚠️ 要两遍 BFS 后取 max ✅ 换根 DP 一并求出

默认选两次 BFS:更短、更好写、端点直接拿到。 只有出现负权或需要「每个点的最长链」时才上树形 DP。


94.7 树的重心与树的中心

这两个概念长得像,完全不是一回事

重心(centroid) 中心(center)
定义 删掉它后,最大子树的点数最小 到最远点的距离最小
等价刻画 使 \(\sum_v d(u,v)\) 最小的点 直径的中点
个数 1 个或 2 个(相邻) 1 个或 2 个(相邻)
求法 一遍求 sz,再扫一遍 求出直径路径,取中点
主要用途 点分治、树的平衡分解 「最小化最远距离」类题
def tree_centroid(n, start, adj, order, par, sz):
    """树的重心:删掉它后最大连通块的点数最小。返回 (重心, 该最小值)。O(n)。

    删掉 u 之后的连通块有两类:
      - 每个孩子 v 的子树,大小 sz[v];
      - 「父亲那一侧」,大小 n - sz[u]。
    """
    best_node = 1
    best_val = n + 1                         # 比任何可能的块大小都大,保证首轮被刷新
    for u in range(1, n + 1):
        mx = n - sz[u]                       # ★ 父亲那一侧:总点数减去 u 的子树
                                             # u 是根时它等于 0,天然正确
        for i in range(start[u], start[u + 1]):
            v = adj[i]
            if v != par[u] and sz[v] > mx:   # 跳过父亲,其余邻居都是孩子
                mx = sz[v]                   # mx = 删掉 u 后最大的那块
        if mx < best_val:
            best_val = mx
            best_node = u
    return best_node, best_val               # best_val <= n//2 是重心的特征性质

重心的性质(点分治的正确性来源):

性质 说明
最大子树大小 \(\le \lfloor n/2 \rfloor\) 所以点分治的递归深度是 \(O(\log n)\)
以重心为根时,所有子树大小 \(\le n/2\) 同上
重心到所有点的距离和最小 「选一个点使总距离最小」直接找重心
两棵树合并后,新重心在原两个重心的路径上 增量维护重心
有两个重心 ⟺ 存在一条边把树分成两个 \(n/2\) \(n\) 必为偶数

BISHI101「从所有叶子出发的多源 BFS」求的是「到最近叶子距离最大的点」, 那个点集与树的中心有关但并不相同——它是「内部深度」的最大点。 见 61-BFS广度优先搜索


94.8 树上两点路径长度

有了 LCA,路径长度就是一个减法:

\[\mathrm{dist}(u,v) = \mathrm{dep}[u] + \mathrm{dep}[v] - 2\,\mathrm{dep}[\mathrm{lca}(u,v)]\]

为什么减两倍\(u \to \text{root}\)\(v \to \text{root}\) 这两条路径在 LCA 上方完全重合,重合部分被算了两次,要减掉两次。

需求 公式
路径边数(无权) \(\mathrm{dep}[u]+\mathrm{dep}[v]-2\mathrm{dep}[l]\)
路径权值和(带权) \(D[u]+D[v]-2D[l]\)\(D\) 是根到该点的权和
路径异或和 \(X[u] \oplus X[v]\)不用减!异或自己抵消,见 73.5
路径点数 边数 \(+1\)
\(w\) 是否在路径 \(u\to v\) \(\mathrm{dist}(u,w)+\mathrm{dist}(w,v) = \mathrm{dist}(u,v)\)
路径的中点 从深的那端向上跳 \(\lfloor \mathrm{dist}/2 \rfloor\) 步(倍增求 \(k\) 级祖先)

树上差分:给路径 \(u\to v\) 上所有点加 \(k\),只在 4 个点打标记,最后一次子树求和:

类型 标记
点差分 \(d_u\mathrel{+}=k\)\(d_v\mathrel{+}=k\)\(d_{l}\mathrel{-}=k\)\(d_{par(l)}\mathrel{-}=k\)
边差分 \(d_u\mathrel{+}=k\)\(d_v\mathrel{+}=k\)\(d_{l}\mathrel{-}=2k\)

收尾的「子树求和」正是 94.4 的 reversed(order) 一趟循环。 完整讨论见 42.6


94.9 最近公共祖先(LCA)

LCA\((u,v)\):既是 \(u\) 的祖先又是 \(v\) 的祖先中深度最大的那个点。

三条主流路线,Python 下的取舍和 C++ 完全不同

路线一:倍增(通用首选)

\(fa_j[v]\) = \(v\) 向上跳 \(2^j\) 步的祖先,\(fa_j[v] = fa_{j-1}[fa_{j-1}[v]]\)

def build_lca_lifting(n, start, adj, root):
    """倍增 LCA 预处理。O(n log n)。返回 (dep, fa, LOG, order)。

    0 号点当哨兵(fa[*][0] = 0),跳出树顶自动停在 0,查询里不用写边界判断。
    """
    LOG = max(1, n.bit_length())             # 跳 2^(LOG-1) 步已经超过树高,够用
    dep = [0] * (n + 1)                      # dep[0] = 0 是哨兵;根的深度设为 1
                                             # 根不取 0,是为了让 dep == 0 能当未访问标记
    fa = [[0] * (n + 1) for _ in range(LOG)]  # fa[j][v] = v 向上 2^j 步的祖先
    f0 = fa[0]                               # 第 0 层就是直接父亲
    dep[root] = 1
    order = [root]
    for u in order:                          # BFS 求深度与父亲,不用递归
        du = dep[u] + 1
        for i in range(start[u], start[u + 1]):
            v = adj[i]
            if dep[v] == 0:                  # dep 兼任访问标记
                dep[v] = du
                f0[v] = u
                order.append(v)
    for j in range(1, LOG):                  # 倍增递推:跳 2^j 步 = 连跳两次 2^(j-1) 步
        prev = fa[j - 1]
        fa[j] = [prev[prev[v]] for v in range(n + 1)]   # ★ 整层列表推导,C 层循环
                                             # 跳出树顶的点会落到 0,并一直停在 0
    return dep, fa, LOG, order


def lca_lifting(x, y, dep, fa, LOG):
    """倍增查询 LCA。O(log n)。"""
    if dep[x] < dep[y]:
        x, y = y, x                          # 统一成「x 更深」,后面只往上提 x
    d = dep[x] - dep[y]                      # 要提升的层数
    j = 0
    while d:                                 # ① 把深的那个提到同一层
        if d & 1:                            # d 的二进制第 j 位为 1 -> 跳 2^j 步
            x = fa[j][x]                     # 层差被拆成若干个 2 的幂,共跳 O(log n) 次
        d >>= 1
        j += 1
    if x == y:                               # ★ 必须先判:y 可能本来就是 x 的祖先
        return x
    for j in range(LOG - 1, -1, -1):         # ② 一起上跳:跳完仍不同才跳
        if fa[j][x] != fa[j][y]:             # 相等说明跳过头了(越过了 LCA),不跳
            x = fa[j][x]                     # 从大步长到小步长,逼近「最深的非公共祖先」
            y = fa[j][y]
    return fa[0][x]                          # 此时两者的父亲就是 LCA

⚠️ 第二个循环是「不同才跳」。我们要找「最深的、仍然不是公共祖先的位置」; 若 \(fa_j[x] = fa_j[y]\) 说明跳过头了。 写成 if fa[j][x] == fa[j][y]: x = fa[j][x] 是最常见的错法。

⚠️ x == y 一定要在第二个循环之前判。若 \(y\) 本来就是 \(x\) 的祖先, 提到同层后两者已相等,此时进第二个循环会返回 fa[0][x](多跳一层),答案偏浅。

倍增的通用讨论(快速幂、ST 表、序列倍增)见 45-倍增

路线二:Tarjan 离线

思想:一遍 DFS,用并查集把「已经回溯完的子树」合并到父亲上。 DFS 到 \(u\) 时,若询问 \((u,v)\)\(v\) 已经访问过,则 \(\mathrm{lca} = \mathrm{find}(v)\)

# [片段] Tarjan 离线 LCA 的骨架(需配迭代 DFS)
# 1) 把所有询问挂到两个端点上:qry[u].append((v, 询问编号))
# 2) 迭代 DFS:
#      进入 u:vis[u] = 1
#      离开 u 的每个孩子 v 之后:parent[v] = u   (并查集合并到父亲)
#      处理 u 时:对 qry[u] 里每个 (v, id),若 vis[v]: ans[id] = find(v)
# 3) 关键点:合并必须发生在「孩子子树完全处理完」之后,
#    所以需要「进入 / 离开」两个时机 -> 事件栈式迭代 DFS(见 90.5 模板二)
Tarjan 离线 评价
复杂度 \(O((n+q)\,\alpha(n))\)理论最优
要求 必须离线(所有询问预先给出)
Python 现实性 ⚠️ 需要事件栈式迭代 DFS + 把 \(q\) 个询问挂到点上,常数不小;\(n,q \le 10^5\) 可行,\(5\times10^5\) 很吃力
代码量 最大(迭代 DFS + 并查集 + 挂询问)

路线三:欧拉序 + ST 表(Python 下最快

核心归约

对树做欧拉序(DFS 进入时记一次,每次从孩子回溯回来也记一次,长度恰好 \(2n-1\)), 则 $\mathrm{lca}(u,v) = $ 欧拉序区间 \([\mathrm{first}[u],\ \mathrm{first}[v]]\)深度最小的那个点。

为什么:从 \(u\) 走到 \(v\) 的这段欧拉序必然经过它们的 LCA(要出这棵子树只能从 LCA 走), 而且不会走到比 LCA 更浅的地方(那要先离开 LCA 的子树,与 \(v\) 在子树内矛盾)。

于是 LCA 变成了 RMQ(Range Minimum Query,区间最值查询:给定数组和一个区间, 问区间内的最小值)。RMQ 可以用 ST 表(Sparse Table,稀疏表:预处理所有 「起点 \(i\)、长度 \(2^j\)」的区间最值)做到 \(O(1)\) 查询,见 45-倍增

编码技巧:把 \((\text{深度},\ \text{点号})\) 压进一个整数 dep << 20 | node (要求 \(n < 2^{20} = 1048576\))。这样:

  • 比较大小就是比较整数,可以直接用内置 min
  • ST 表建表能写成 list(map(min, prev, prev[h:]))整层比较全在 C 层
  • 取到最小值后 & 0xFFFFF 就是 LCA 的点号。
  • 区间里深度最小的点唯一(就是 LCA),所以不会有并列歧义。
def build_lca_euler(n, start, adj, root, SH=20):
    """欧拉序 + ST 表 LCA。预处理 O(n log n),查询 O(1)。

    欧拉序长度 2n-1;元素是 (dep << SH) | node,可直接用 min 比较。
    要求 n < 2^SH。迭代式 DFS,n = 5e5 也不会爆栈。
    """
    par = [0] * (n + 1)
    ptr = start[:]                           # 每个点扫到邻接表的哪里了
                                             # 这就是「手工保存的循环变量」,代替递归现场
    first = [0] * (n + 1)                    # first[u] = u 在欧拉序里首次出现的下标
    euler = []
    push = euler.append
    dep = 0                                  # 当前栈顶的深度,随进出栈同步升降
    stk = [root]
    first[root] = 0
    push(root)                               # (0 << SH) | root,根的深度是 0
    while stk:
        u = stk[-1]                          # 只看栈顶,不弹出:它的孩子可能还没走完
        p = ptr[u]
        if p < start[u + 1]:                 # u 还有没访问过的邻居
            ptr[u] = p + 1                   # 游标右移,下次从后一个邻居继续
            v = adj[p]
            if v != par[u]:                  # 树上不需要 vis,只要不走回父亲
                par[v] = u
                dep += 1
                first[v] = len(euler)        # 记下 v 首次出现的位置
                push((dep << SH) | v)
                stk.append(v)                # 下潜一层
        else:
            stk.pop()                        # u 的邻居扫完了,回溯
            dep -= 1
            if stk:
                push((dep << SH) | stk[-1])  # ★ 回溯到父亲,再记一次
                                             # 这一步让欧拉序长度成为 2n-1
    # ---- ST 表(Sparse Table,稀疏表):st[j][i] = 从 i 开始 2^j 个元素的最小值 ----
    st = [euler]                             # 第 0 层就是欧拉序本身(区间长度 1)
    L = len(euler)
    j = 1
    while (1 << j) <= L:
        prev = st[-1]
        h = 1 << (j - 1)                     # 上一层的区间长度
        st.append(list(map(min, prev, prev[h:])))   # ★ 整层一次算完,全在 C 层
                                             # 两个错开 h 的半区间取 min 即得本层
        j += 1
    return first, st


def lca_euler(u, v, first, st, SH=20):
    """O(1) 查询 LCA。"""
    l = first[u]
    r = first[v]
    if l > r:
        l, r = r, l                          # 保证 l <= r,区间才有意义
    k = (r - l + 1).bit_length() - 1         # ⌊log2(len)⌋
    row = st[k]
    a = row[l]                               # 从左端起的 2^k 个
    b = row[r - (1 << k) + 1]                # 到右端为止的 2^k 个;两段重叠但不影响取 min
    return (a if a < b else b) & ((1 << SH) - 1)   # 低位掩码还原出点号

list(map(min, prev, prev[h:])) 是这条路线在 Python 下取胜的唯一原因map 遇到较短的可迭代对象就停止,正好得到长度 \(L-2^j+1\) 的新层, 而整层的 \(L\) 次比较全部在 C 层完成。 手写 for i in range(...) 要慢 5–10 倍。同一个技巧见 45.2 ST 表

内存问题:\(n\) 很大时 ST 表放不下

欧拉序长 \(2n-1\)\(n = 5\times10^5\)\(L \approx 10^6\), ST 表有 \(\lceil\log_2 L\rceil = 20\) 层,共 \(2\times10^7\) 个元素—— Python 的 list 存这些非缓存整数要 150 MB 以上,建表也要好几秒。

解法:分块 + 只对块间建 ST 表。

朴素 ST 表 分块 + 块间 ST 表
块长 \(B\) 1 64
表的元素数 \(L\log L \approx 2\times10^7\) \(\dfrac{L}{B}\log\dfrac{L}{B} \approx 2\times10^5\)降两个数量级
查询 2 次表访问 两端零散部分 min(切片)C 层\(\le 64\) 个)+ 中间整块查表
查询复杂度 \(O(1)\) \(O(B)\) 但常数极小(切片 min 在 C 层)

这正是 BISHI124 题解采用的结构,完整代码见 94.11。

三条路线的对比

倍增 Tarjan 离线 欧拉序 + ST 表
预处理 \(O(n\log n)\) \(O(n\,\alpha)\) \(O(n\log n)\)
单次查询 \(O(\log n)\) 均摊 \(O(\alpha)\) \(O(1)\)
在线? ❌ 必须离线
顺便能求 \(k\) 级祖先 / 路径最值 只有倍增能
预处理的 Python 层操作量 \(n\log n\) 次(列表推导,半 C 层 \(O(n+q)\) 次(纯 Python) \(L\) 次 DFS + 建表全 C 层
查询的 Python 层操作量 \(q\log n\) 次(纯 Python,最贵 \(O(q\,\alpha)\) \(q \times\) 常数
代码量
Python 推荐场景 \(n,q \le 2\times10^5\);或需要 \(k\) 级祖先 / 路径最值 很少(常数不划算) \(q\) 很大时唯一现实的路线
规模(\(n \approx q\) 倍增 欧拉序 + ST 表
\(10^5\) ✅ 约 1–2 s ✅ 约 1 s
\(2\times10^5\) ⚠️ 约 3–4 s ✅ 约 1.5–2 s
\(5\times10^5\) ❌ 建表 \(10^7\) + 查询 \(10^7\) 次 Python 层操作 ⚠️ 仍然很险(见 BISHI124)

一句话决策查询次数少(\(q \le 10^5\))或需要「跳 \(k\) 步」→ 倍增; 查询次数极大(\(q \ge 3\times10^5\))→ 欧拉序 + 分块 ST 表。 Tarjan 在 C++ 里是最优解,在 Python 里几乎从不划算。


94.10 树链剖分预告

到这里,「子树操作」已经能用 DFS 序 + 数据结构解决(94.5), 「路径查询」能用 LCA 解决(94.8、94.9)。 剩下唯一没解决的是路径上的修改与聚合查询

「把路径 \(u \to v\) 上所有点加 \(k\)」「查询路径 \(u\to v\) 上的最大值」

重链剖分把每条路径拆成 \(O(\log n)\) 段连续区间, 配一棵线段树就能做到 \(O(\log^2 n)\)。它的 id[u] 同时也是 DFS 序, 所以子树操作和路径操作共用同一棵数据结构

完整讲解见 113-树链剖分。 那里也会诚实地讨论 Python 的可行规模—— 懒标记线段树在 CPython 下 \(10^5\) 量级就已经很险(见 39 章), 所以树剖题在 Python 下的现实上界比 C++ 低一个数量级。


94.11 例题

BISHI96 先序遍历、中序遍历和后序遍历(简单)

给定 \(n\ (\le 10^5)\) 个节点的二叉树,以 \(n-1\)有向边 \((a_i,b_i)\) 给出 (父 \(a_i\) 指向子 \(b_i\))。左右关系的规定是: - 父节点有两个孩子 ⟹ 编号较小者为左孩子,较大者为右孩子; - 父节点只有一个孩子,且该孩子编号大于父节点编号 ⟹ 视为孩子;否则为右孩子。

输出先序、中序、后序遍历序列。 题面见 BISHI96 原题(牛客)。 题解见 solutions/BISHI96.py(已通过官方样例验证)。

这题的难点全在两处:左右孩子的规则,和 \(n = 10^5\) 的递归深度。

先把规则整理成表(读错这张表就全错,且样例查不出来):

情况 左孩子 右孩子
两个孩子 \(x < y\) \(x\)小的在左 \(y\)
一个孩子 \(v\),且 \(v > u\) \(v\)
一个孩子 \(v\),且 \(v < u\) \(v\)

⚠️ 判定基准是「孩子编号 vs 父节点编号」,不是「孩子编号 vs 兄弟」, 也不是「独子固定放左边」。两个孩子时比的是兄弟之间, 一个孩子时比的是和父亲——两条规则的比较对象不同,这是本题唯一的阅读陷阱。

根 = 入度为 0 的点(没有出现在任何 \(b_i\) 里)。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    left = [0] * (n + 1)                     # 0 表示空,别用节点类
    right = [0] * (n + 1)
    kids = [[] for _ in range(n + 1)]
    has_parent = bytearray(n + 1)            # 用来找根

    p = 1
    for _ in range(n - 1):
        a = int(data[p]); b = int(data[p + 1]); p += 2
        kids[a].append(b)
        has_parent[b] = 1

    for u in range(1, n + 1):                # ---- 按规则定左右 ----
        ch = kids[u]
        if len(ch) == 2:
            x, y = ch
            if x > y:
                x, y = y, x
            left[u], right[u] = x, y         # 两个孩子:小的在左
        elif len(ch) == 1:
            v = ch[0]
            if v > u:
                left[u] = v                  # 独子且编号 > 父 -> 左
            else:
                right[u] = v                 # 否则 -> 右

    root = 1
    for u in range(1, n + 1):                # 入度为 0 的点就是根
        if not has_parent[u]:
            root = u
            break

    # ---- 先序:根 左 右(栈里先压右再压左)----
    pre = []
    st = [root]
    while st:
        u = st.pop()
        pre.append(u)
        r = right[u]
        if r:
            st.append(r)
        l = left[u]
        if l:
            st.append(l)

    # ---- 中序:一路向左压栈,弹出访问后转右子树 ----
    ino = []
    st = []
    u = root
    while st or u:
        while u:
            st.append(u)
            u = left[u]
        u = st.pop()
        ino.append(u)
        u = right[u]

    # ---- 后序:按「根 右 左」跑先序,再整体逆序 ----
    post = []
    st = [root]
    while st:
        u = st.pop()
        post.append(u)
        l = left[u]
        if l:
            st.append(l)
        r = right[u]
        if r:
            st.append(r)
    post.reverse()                           # ★ 一次 C 层反转

    w = sys.stdout.write
    w(" ".join(map(str, pre)) + "\n")
    w(" ".join(map(str, ino)) + "\n")
    w(" ".join(map(str, post)) + "\n")


main()

复杂度:建树 \(O(n)\),三次遍历各 \(O(n)\)\(n = 10^5\) 瞬间完成。

样例复核(这类题一定要手验):\(n=2\),边 \(1\to2\)。 父 1 只有一个孩子 2,且 \(2 > 1\)孩子。 先序 \(=1\ 2\);中序 \(=\) 左(2) 根(1) \(= 2\ 1\);后序 \(=\) 左(2) 根(1) \(= 2\ 1\)。与样例一致 ✓

四个坑

  1. 三种遍历全部必须迭代。这棵树可以是 \(1\to2\to\cdots\to10^5\) 的链, 递归深度 \(10^5\);即使 setrecursionlimit 调大,C 栈也会崩(无报错的段错误);
  2. 独子的判定基准是父节点编号(见上面的阅读陷阱);
  3. \(n = 1\) 时没有任何边kids 全空、has_parent 全 0, 根取到 1,三行都输出 1。代码天然处理,但一定要在脑子里跑一遍
  4. 输出必须先 join 再一次 write。三行各 \(10^5\) 个数, 逐个 print 会有 \(3\times10^5\) 次系统调用级开销 (20-输入输出处理)。

为什么后序用「根右左 + reverse」而不是双栈:见 94.3 的对比表。 一次 C 层 list.reverse() 比多维护一个栈快,代码也短 5 行。 同一份代码,三个遍历只差左右压栈顺序和一次反转——这是本题最优雅的地方。

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

给定 \(N\) 个点、以 \(R\) 为根的多叉树,\(M\) 次询问两点的 LCA。 \(1 \le R \le N \le 5\times10^5\)\(1 \le M \le 5\times10^5\)。 树以 \(N-1\)无向\((x,y)\) 给出(只表示相连,方向要靠 \(R\) 定)。 时限:C/C++ 3 秒,其他语言 6 秒;空间:其他语言 512 MB。 题面见 BISHI124 原题(牛客)。 题解见 solutions/BISHI124.py(已通过官方样例验证)。

第一步:为什么不能用倍增

\(N = M = 5\times10^5\)\(\log_2 N = 19\)

阶段 Python 层操作量
倍增建表 \(19 \times 5\times10^5\) \(\approx 10^7\) 次(列表推导,约 3–5 s)
\(5\times10^5\) 次查询 \(\times\) 约 20 次跳跃 \(\approx 10^7\) 次(纯 Python 循环,约 8–12 s)
内存:19 层 \(\times\) \(5\times10^5\) \(10^7\) 个列表槽,约 80 MB 指针 + 整数对象

总计 \(2\times10^7\) 次 Python 层操作,时限只有 6 秒——必挂。 45 章 给出的倍增模板在这个规模上确实过不去, 所以本章承担「替代路线」这部分。

第二步:欧拉序 + 分块 ST 表

按 94.9 路线三:欧拉序把 LCA 归约成 RMQ,查询降到 \(O(1)\); 再用「分块 + 块间 ST 表」把表的规模从 \(2\times10^7\) 降到 \(2\times10^5\)

import sys


def main():
    data = sys.stdin.buffer.read().split()
    N = int(data[0]); M = int(data[1]); R = int(data[2])

    # ---- CSR 邻接表:deg 前缀和 + 一次填充(5e5 个小 list 的内存扛不住)----
    ne = 2 * (N - 1)
    es = list(map(int, data[3:3 + ne]))      # 只解析一次,别在两个循环里各 int() 一遍
    deg = [0] * (N + 2)
    for x in es:
        deg[x] += 1
    start = [0] * (N + 2)
    acc = 0
    for v in range(1, N + 1):
        start[v] = acc
        acc += deg[v]
    start[N + 1] = acc
    pos = start[:]
    adj = [0] * acc
    for i in range(0, ne, 2):
        x = es[i]; y = es[i + 1]
        adj[pos[x]] = y; pos[x] += 1
        adj[pos[y]] = x; pos[y] += 1

    # ---- 迭代式 DFS 生成欧拉序(元素 = dep << 20 | node)----
    par = [0] * (N + 1)
    ptr = start[:]                           # 每个点当前扫到邻接表的哪里
    euler = []
    push = euler.append
    first = [0] * (N + 1)
    dep = 0
    stk = [R]
    par[R] = 0
    first[R] = 0
    push(R)                                  # dep = 0
    while stk:
        u = stk[-1]
        p = ptr[u]
        if p < start[u + 1]:
            ptr[u] = p + 1
            v = adj[p]
            if v != par[u]:                  # 树上不需要 vis,不走回父亲即可
                par[v] = u
                dep += 1
                first[v] = len(euler)
                push((dep << 20) | v)
                stk.append(v)
        else:
            stk.pop()
            dep -= 1
            if stk:
                push((dep << 20) | stk[-1])  # 回溯,再记一次父亲

    L = len(euler)
    # ---- 分块 + 块间稀疏表:块长 64,表只有 14 * 1.6e4 ≈ 2e5 个元素 ----
    B = 64
    nb = (L + B - 1) // B
    blk = [min(euler[b * B:(b + 1) * B]) for b in range(nb)]   # 每块最小值,C 层
    st = [blk]
    j = 1
    while (1 << j) <= nb:
        prev = st[-1]
        h = 1 << (j - 1)
        st.append(list(map(min, prev, prev[h:])))              # ★ 整层 C 层建表
        j += 1

    p = 3 + ne
    out = []
    push = out.append
    MASK = (1 << 20) - 1
    for _ in range(M):
        a = int(data[p]); b = int(data[p + 1])
        p += 2
        l = first[a]; r = first[b]
        if l > r:
            l, r = r, l
        bl = l // B
        br = r // B
        if bl == br:                         # 同一块内:直接 C 层扫 <= 64 个
            v = min(euler[l:r + 1])
        else:
            v = min(euler[l:(bl + 1) * B])   # 左端零散部分
            w = min(euler[br * B:r + 1])     # 右端零散部分
            if w < v:
                v = w
            if br - bl > 1:                  # 中间的整块查稀疏表
                k = (br - bl - 1).bit_length() - 1
                row = st[k]
                x = row[bl + 1]
                y = row[br - (1 << k)]
                if x < v:
                    v = x
                if y < v:
                    v = y
        push(v & MASK)                       # 取回点号
    sys.stdout.write("\n".join(map(str, out)) + "\n")


main()

复杂度:预处理 \(O(N)\)(DFS + 分块)\(+\ O(n_b\log n_b)\)(块间 ST 表), 查询 \(O(1)\)(两次 C 层切片 min + 两次表访问)。

第三步:诚实的可行性判断

⚠️ 本题在 CPython 3.9 下大概率 TLE,即使用上了上面这套「理论最优」的做法:

阶段 Python 层操作量
读入 \(2\times5\times10^5 + 2\times5\times10^5 = 2\times10^6\) 个 token 并 int() \(\ge 1\) s
建 CSR(度数 + 前缀和 + 填充) \(\approx 2\times10^6\)
迭代 DFS 生成欧拉序(\(10^6\) 个元素,每个要 push / 判父亲) \(\approx 1.5\times10^6\)
\(5\times10^5\) 次查询 \(\times\)(约 10 次 Python 层操作 + 2 次 C 层切片 min \(\approx 5\times10^6\)

总量约 \(6\times10^6 \sim 8\times10^6\) 次 Python 层操作,实测在 8–15 秒量级, 而时限是「其他语言 6 秒」。

这属于「规模对 Python 不友好」,而不是做法有问题—— 预处理 \(O(N)\) + 查询 \(O(1)\) 已经是理论最优,没有更好的算法了。 换 PyPy 可过;CPython 下这题只能接受。

这题给出的三条判据

判据 内容
算法选择 \(q\) 很大时,把「查询」的复杂度压到 \(O(1)\) 比压预处理更重要。倍增的 \(q\log n\) 全是纯 Python 循环,是本题的绝对瓶颈
把循环下沉到 C 层 list(map(min, prev, prev[h:])) 建表、min(切片) 查零散部分、list(map(int, ...)) 解析——能交给 C 的就一个字节都不留在 Python 里
内存也要算 朴素 ST 表 \(2\times10^7\) 个元素在 512 MB 下必 MLE;分块把它降两个数量级。「\(O(1)\) 查询」在 Python 里必须配「小表」才成立

四个实现坑

  1. 树是无根边给出的\(x\ y\) 只表示相连),根 \(R\) 单独给出。 必须迭代式 DFS——递归深度可达 \(5\times10^5\)
  2. 欧拉序长度是 \(2N-1\)first[]第一次出现的位置。 写成「最后一次」也能对,但混用就错;
  3. 查询时若 first[u] > first[v] 要交换\(u = v\) 时区间退化成一个点,答案是自己,仍然正确;
  4. 编码位宽\(N \le 5\times10^5 < 2^{20}\),所以 dep << 20 | node 安全。 若 \(N\) 能到 \(2\times10^6\),位宽要改成 21——改位宽时 MASK 要同步改

本题也是 45-倍增 的例题。 分工是:45 章负责倍增模板本身(并诚实标注它在这个规模上过不去), 本章负责「替代路线 + 为什么」。同一道题,两种算法,两层理解。


94.12 本章速查

树的性质与存储

要点 结论
树的定义 连通 + 无环
三选二定理 连通 / 无环 / \(m=n-1\) 任意两条 ⟹ 是树
⚠️ 只判 \(m=n-1\) 不够,必须配连通性
任意两点 唯一简单路径
加一条非树边 恰好产生一个环
度数和 \(2(n-1)\),平均度数 \(< 2\)
树是二分图 无环 ⟹ 无奇环
找根 题目给 / 入度为 0(BISHI96)/ 随便取 1
换根 \(sz'[u] = n - sz[v]\)
二叉树存储 left[] + right[] 两个数组,0 表示空;❌ 别用节点类
大树存储 CSR\(n \ge 2\times10^5\) 必用)

遍历与序

要点 结论
三种遍历 先序 根左右 / 中序 左根右 / 后序 左右根
必须迭代 树高上界 \(= n\)\(n \ge 1000\) 递归必爆(BISHI96 的 \(10^5\)
先序迭代 栈里先压右再压左
中序迭代 一路向左压栈 → 弹出访问 → 转右子树
后序迭代 按「根右左」跑先序,最后 reverse()(最短最快)
后序的三种写法 反转法 ✅ > 双栈法 ⚠️ > 标记元组法 ❌
BST 的中序 有序
重建二叉树 先序+中序 ✅、后序+中序 ✅、先序+后序 ❌
reversed(BFS 序) = 后序 树上 DP 的 Python 标准姿势,无递归无栈
DFS 序(先序) \(n\)子树 = 连续区间 \([tin_u, tin_u+sz_u-1]\)
括号序 \(2n\);判祖先、树上莫队
欧拉序 \(2n-1\)LCA → RMQ
判祖先 \(O(1)\) tin[u] <= tin[v] < tin[u] + sz[u]
子树改 → 区间改 DFS 序 + 树状数组 / 线段树
路径改 → 区间改 需要树链剖分113 章
BFS 序能做子树区间吗 不能,子树在 BFS 序里不连续

直径、重心、路径

要点 结论
直径 树上最长简单路径;端点一定是叶子
两次 BFS 任意点 → 最远点 \(a\) → 从 \(a\) 出发的最远点 \(b\)
⚠️ 两次 BFS 的前提 边权非负;有负权必须树形 DP
树形 DP 求直径 最长链 + 次长链;先更新答案再更新 down
重心 删掉后最大块最小;等价于「距离和最小」;至多 2 个
重心的关键性质 最大子树 \(\le \lfloor n/2 \rfloor\)点分治深度 \(O(\log n)\)
中心 到最远点最近;就是直径的中点。⚠️ 与重心不是一回事
路径边数 \(\mathrm{dep}[u]+\mathrm{dep}[v]-2\mathrm{dep}[l]\)
路径权和 \(D[u]+D[v]-2D[l]\)
路径异或和 \(X[u]\oplus X[v]\)不用减
\(w\) 在路径上 \(\mathrm{dist}(u,w)+\mathrm{dist}(w,v)=\mathrm{dist}(u,v)\)
树上点差分 \(+k,+k\)\(u,v\)\(-k\)\(l\)\(-k\)\(par(l)\)
树上边差分 \(+k,+k\)\(u,v\)\(\mathbf{-2k}\)\(l\)

LCA

要点 结论
倍增表 \(fa_j[v]=fa_{j-1}[fa_{j-1}[v]]\)整层列表推导建表
倍增查询 ① 先把深的提到同层
倍增查询 ② fa[j][x] != fa[j][y] 才跳(跳完仍不同才安全)
⚠️ x == y 必须在第二个循环前判,否则答案偏浅
0 号点哨兵 fa[*][0] = 0,跳出树顶自动停住
欧拉序归约 \(\mathrm{lca}(u,v)=\) 欧拉序 \([\mathrm{first}_u,\mathrm{first}_v]\)深度最小的点
编码技巧 dep << 20 \| node直接用内置 min 比较(要求 \(n<2^{20}\)
ST 表建表 list(map(min, prev, prev[h:])) —— 整层在 C 层
\(n\) 大时的内存 朴素 ST 表 \(2\times10^7\) 元素必 MLE ⟹ 分块(\(B=64\))+ 块间 ST 表
Tarjan 离线 理论最优 \(O((n+q)\alpha)\),但必须离线且 Python 常数不划算
只有倍增能做 \(k\) 级祖先、路径最值(次小生成树用的就是它)
选型 \(q \le 10^5\) 或要跳 \(k\) 步 → 倍增\(q \ge 3\times10^5\)欧拉序 + 分块 ST 表
规模 Python 现实性
遍历 / BFS 序倒序,\(n \le 5\times10^5\) ✅ 纯数组循环
DFS 序 + 树状数组(子树改子树查),\(n \le 2\times10^5\)
直径(两次 BFS),\(n \le 5\times10^5\)
重心,\(n \le 5\times10^5\) ✅ 一遍 sz + 一遍扫边
LCA 倍增,\(n,q \le 2\times10^5\) ⚠️ 约 3–4 s
LCA 倍增,\(n,q = 5\times10^5\) \(2\times10^7\) 次 Python 层操作
LCA 欧拉序 + 分块 ST 表,\(n,q = 5\times10^5\) ⚠️ 8–15 s vs 6 s 时限,极险(BISHI124;PyPy 可过)
树链剖分(线段树),\(n \le 10^5\) ⚠️ 见 113 章
点分治,\(n \le 5\times10^4\) ⚠️ \(O(n\log n)\) 但常数大
看到什么 → 想到什么
\(n-1\) 条边 + 连通」
「输出先序/中序/后序」
「求子树的和 / 子树整体修改」
「先算儿子再算父亲」
「树上最长路径」
「删一个点使最大块最小」
「使到所有点距离和最小」
「使到最远点距离最小」
「多次询问两点距离 / 祖先」
「跳 \(k\) 级祖先 / 路径最大边」
「路径上所有点加 \(k\),最后查每点」
「路径修改 + 路径查询」
「树上路径异或最大」