NeFut Logo NeFut
EN 管理员登录

深入理解树形结构与高效遍历算法

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #Tree #DFS

核心逻辑与数学原理

树形结构本质上是由 $N$ 个节点和 $N-1$ 条边构成的连通无向图。在计算机内存中,非稠密树的存储标准方案为链式前向星或邻接表(动态数组)。由于树的拓扑结构不具备线性的连续内存特征,无法直接利用线段树、树状数组等高级数据结构进行区间维护,因此必须通过遍历技术将其“拍平”为线性序列。

1. DFS 序

DFS 序是指在对树进行深度优先遍历时,节点被首次访问的时间戳顺序。对于任意节点 $u$,其入栈时间戳记为 $ ext{dfn}[u]$,其子树中最后一个被访问节点的入栈时间戳记为 $ ext{out}[u]$。

数学性质:节点 $u$ 的整棵子树在 DFS 序中对应一个连续的闭区间 $[ ext{dfn}[u], ext{out}[u]]$。区间长度即为子树大小 $ ext{siz}[u] = ext{out}[u] - ext{dfn}[u] + 1$。这一性质建立了“树上子树操作”向“线段树区间操作”的同构映射。

2. 欧拉序

欧拉序有两种变体,NOIP 阶段特指“路径欧拉序”,即在 DFS 过程中,无论是首次进入节点还是从子树回溯,只要移动到该节点,就将其记录进序列。对于无根树,一条边会被正反遍历两次,总序列长度严格为 $2N-1$。

数学性质:节点 $u$ 首次出现位置为 $ ext{first}[u]$,最后一次出现位置为 $ ext{last}[u]$。区间 $[ ext{first}[u], ext{last}[u]]$ 包含了且仅包含了 $u$ 及其所有子树节点的所有遍历轨迹。路径欧拉序常用于配合常规 ST 表,将最近公共祖先(LCA)问题转化为区间最值问题(RMQ)。


算法推导与状态设计

定义树的拓扑结构 $G = (V, E)$。

1. 邻接表存图

使用 std::vector<int> G[N]int head[N], to[M], nxt[M]。NOIP 工业标准推荐在非极端卡常题中采用 std::vector。对于带权树,状态结构体定义为:

struct Edge {
    int to;
    long long weight;
};
std::vector<Edge> adj[N];

2. DFS 序转化方程

定义全局时间戳计数器 $ ext{tim}$。对于当前节点 $u$,双向边回溯父节点为 $fa$:

  1. 准入状态:$ ext{tim} ightarrow ext{tim} + 1$,令 $ ext{dfn}[u] = ext{tim}$,并将当前节点映射回序列 $ ext{seq}[ ext{tim}] = u$。
  2. 状态转移:遍历所有满足 $v ext{ in } ext{adj}[u] ext{ and } v eq fa$ 的子节点,递归执行 $ ext{DFS}(v, u)$。
  3. 退出状态:$ ext{out}[u] = ext{tim}$。

3. 欧拉序转化方程

定义全局时间戳计数器 $ ext{cur}$。

  1. 准入状态:$ ext{cur} ightarrow ext{cur} + 1$,令 $ ext{euler}[ ext{cur}] = u$,记录首次位置 $ ext{first}[u] = ext{cur}$。
  2. 状态转移:遍历子节点 $v$。每次从 $v$ 回溯到 $u$ 时,必须显式触发:$ ext{cur} ightarrow ext{cur} + 1$,令 $ ext{euler}[ ext{cur}] = u$。
  3. 退出状态:记录最终位置 $ ext{last}[u] = ext{cur}$。

C++ 标准源码

#include <iostream>
#include <vector>
#include <algorithm>

// 严格限定最大节点数,根据题目数据范围调整
const int MAXN = 200005;

// 邻接表存储结构
std::vector<int> adj[MAXN];

// DFS 序相关数组
int dfn[MAXN], out[MAXN], seq[MAXN], dfn_tim;

// 欧拉序相关数组
int euler[MAXN * 2], first_pos[MAXN], last_pos[MAXN], euler_tim;

// 核心遍历函数
void dfs(int u, int fa) {
    // 激活 DFS 序
    dfn_tim++;
    dfn[u] = dfn_tim;
    seq[dfn_tim] = u;

    // Plan A: 记录首次进入欧拉序
    euler_tim++;
    euler[euler_tim] = u;
    first_pos[u] = euler_tim;

    for (size_t i = 0; i < adj[u].size(); ++i) {
        int v = adj[u][i];
        if (v == fa) continue; // 致命死循环规避:无向图必须跳过父节点

        dfs(v, u);

        // Plan B: 从子树回溯,必须重复压入当前节点 u
        euler_tim++;
        euler[euler_tim] = u;
    }

    // 封闭 DFS 序子树区间
    out[u] = dfn_tim;
    last_pos[u] = euler_tim;
}

int main() {
    // 提升 I/O 效率,解绑标准流
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n;
    if (!(std::cin >> n)) return 0;

    for (int i = 1; i < n; ++i) {
        int u, v;
        std::cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u); // 无向树必须双向建边
    }

    // 初始化计数器并从根节点 1 开始 DFS
    dfn_tim = 0;
    euler_tim = 0;
    dfs(1, 0);

    // 输出验证结果(生产环境可删除)
    for (int i = 1; i <= n; ++i) {
        std::cout << "Node " << i << " Subtree Interval: [" << dfn[i] << ", " << out[i] << "]\n";
    }

    return 0;
}

NOIP 实战避坑指南

  1. 爆栈(Stack Overflow)风险: 树深度过大(例如链状图,$N = 5 \times 10^5$)时,系统默认栈空间极易崩塌。在 Linux 评测环境下,必须注意系统栈限制。若无特殊编译命令,可考虑使用手工栈模拟 DFS。
  2. 欧拉序数组大小未翻倍: 回溯导致的重复入队使欧拉序列最大长度逼近 $2N$。若 euler 数组及相关统计数组仅开辟 MAXN 大小,在数据为随机树或高扇出树时必然触发越界,造成 SIGSEGV 段错误。
  3. 无向边双向建边漏判父节点:for 循环遍历邻接表时,若不加 if (v == fa) continue; 判定,会导致在父子节点间无休止递归,直接引发死循环并爆栈。

经典 NOIP/洛谷 真题

1. 洛谷 P3384 【模板】重链剖分/树链剖分

2. 洛谷 P3258 [JLOI2014] 松鼠的新家

原文链接: local://9.1

[h] 返回首页