跳转至

# 树

树是层次化数据结构，是文件系统、数据库、编译器和无数面试题背后的基础。本文件涵盖二叉树、二叉搜索树、平衡树、前缀树、线段树、树状数组和并查集，包括遍历模式、递归思维以及逐步增加难度的题目。

- 树是一个连通的无环图（第13章）。最重要的变体是二叉树：每个节点最多有两个子节点（左和右）。树无处不在：编译器中的解析树、浏览器中的 DOM 树、机器学习中的决策树以及数据库中的 B 树。

- 解决树问题的关键洞察：大多数树问题都可以递归解决。结构是递归的（树是一个根节点加上两棵子树），因此解法也应是递归的。掌握"解决左子树、解决右子树、合并结果"的模式，你就能解决大多数树问题。

## 二叉树遍历

- 有四种标准的访问每个节点的方式：

  - 中序遍历（左、根、右）：对于 BST，这会按排序顺序访问节点。
  - 前序遍历（根、左、右）：用于序列化和复制树。
  - 后序遍历（左、右、根）：用于删除和计算大小。
  - 层序遍历（BFS）：使用队列逐层访问节点。

    class TreeNode:
        def __init__(self, val=0, left=None, right=None):
            self.val = val
            self.left = left
            self.right = right

    def inorder(root):
        if not root:
            return []
        return inorder(root.left) + [root.val] + inorder(root.right)

    def preorder(root):
        if not root:
            return []
        return [root.val] + preorder(root.left) + preorder(root.right)

    def postorder(root):
        if not root:
            return []
        return postorder(root.left) + postorder(root.right) + [root.val]

    from collections import deque

    def level_order(root):
        if not root:
            return []
        result, queue = [], deque([root])
        while queue:
            level = []
            for _ in range(len(queue)):
                node = queue.popleft()
                level.append(node.val)
                if node.left:
                    queue.append(node.left)
                if node.right:
                    queue.append(node.right)
            result.append(level)
        return result

- 陷阱：上面的递归遍历在每一步都创建新列表（由于 `+` 拼接），这是 \(O(n^2)\)。为了效率，传递一个结果列表并原地追加：

    def inorder_efficient(root, result=None):
        if result is None:
            result = []
        if root:
            inorder_efficient(root.left, result)
            result.append(root.val)
            inorder_efficient(root.right, result)
        return result

### 简单：二叉树的最大深度

    def max_depth(root):
        if not root:
            return 0
        return 1 + max(max_depth(root.left), max_depth(root.right))

- 递归模式：基本情况（null → 0），递归子节点，合并（1 + max）。同样的模式适用于数十种树问题。

### 简单：翻转二叉树

    def invert_tree(root):
        if not root:
            return None
        root.left, root.right = invert_tree(root.right), invert_tree(root.left)
        return root

### 中等：二叉树的最近公共祖先

- 问题：找到既是 \(p\) 又是 \(q\) 的祖先的最低节点。

- 模式：如果 \(p\) 和 \(q\) 都在左子树中，则 LCA 在左子树中。如果都在右子树中，则在右子树中。如果它们分开了（一个在左，一个在右），则当前节点就是 LCA。

    def lowest_common_ancestor(root, p, q):
        if not root or root == p or root == q:
            return root

        left = lowest_common_ancestor(root.left, p, q)
        right = lowest_common_ancestor(root.right, p, q)

        if left and right:
            return root  # p 和 q 在不同子树中
        return left if left else right

- 陷阱：这假设 \(p\) 和 \(q\) 都在树中。如果它们可能不在，你需要额外的检查。

### 困难：二叉树中的最大路径和

- 问题：找出任意两个节点之间的最大路径和（路径不需要经过根节点）。

    def max_path_sum(root):
        best = [float('-inf')]

        def dfs(node):
            if not node:
                return 0
            left = max(dfs(node.left), 0)   # 忽略负路径
            right = max(dfs(node.right), 0)

            # 经过当前节点的路径（可能作为"转弯点"）
            best[0] = max(best[0], node.val + left + right)

            # 返回到父节点的最大增益
            return node.val + max(left, right)

        dfs(root)
        return best[0]

- 关键洞察：在每个节点，有两个问题：(1) 经过这个节点的最佳路径是什么（左 + 节点 + 右）？(2) 这个节点可以贡献给其父节点的最佳路径是什么（节点 + max(左, 右)，因为路径不能在两个层级分叉）？混淆这两者是最常见的错误。

## 二叉搜索树（BST）

- BST 满足：对于每个节点，左子树中的所有值都较小，右子树中的所有值都较大。这实现了 \(O(\log n)\) 的搜索、插入和删除（当平衡时）。

    def search_bst(root, target):
        if not root:
            return None
        if target < root.val:
            return search_bst(root.left, target)
        elif target > root.val:
            return search_bst(root.right, target)
        else:
            return root

    def insert_bst(root, val):
        if not root:
            return TreeNode(val)
        if val < root.val:
            root.left = insert_bst(root.left, val)
        else:
            root.right = insert_bst(root.right, val)
        return root

- 陷阱：BST 操作仅在树平衡时才是 \(O(\log n)\)。由已排序插入构建的 BST 退化为链表：每次操作 \(O(n)\)。这就是平衡 BST（AVL、红黑树）存在的原因。

### 中等：验证二叉搜索树

    def is_valid_bst(root, lo=float('-inf'), hi=float('inf')):
        if not root:
            return True
        if root.val <= lo or root.val >= hi:
            return False
        return (is_valid_bst(root.left, lo, root.val) and
                is_valid_bst(root.right, root.val, hi))

- 陷阱：只检查 `left.val < root.val < right.val` 是错误的。约束条件是左子树中所有节点都更小，而不仅仅是直接子节点。`lo`/`hi` 边界将这个约束向下传递。

### 中等：二叉搜索树中第 K 小的元素

- 模式：BST 的中序遍历按排序顺序访问节点。访问的第 \(k\) 个节点就是答案。

    def kth_smallest(root, k):
        count = [0]
        result = [None]

        def inorder(node):
            if not node or result[0] is not None:
                return
            inorder(node.left)
            count[0] += 1
            if count[0] == k:
                result[0] = node.val
                return
            inorder(node.right)

        inorder(root)
        return result[0]

## 前缀树（Trie）

- 前缀树逐字符地将字符串存储在树中。每条边代表一个字符，从根到标记节点的路径代表存储的字符串。前缀树实现了 \(O(L)\) 的查找，其中 \(L\) 是字符串长度，无论存储了多少个字符串。

    class TrieNode:
        def __init__(self):
            self.children = {}
            self.is_end = False

    class Trie:
        def __init__(self):
            self.root = TrieNode()

        def insert(self, word):
            node = self.root
            for char in word:
                if char not in node.children:
                    node.children[char] = TrieNode()
                node = node.children[char]
            node.is_end = True

        def search(self, word):
            node = self.root
            for char in word:
                if char not in node.children:
                    return False
                node = node.children[char]
            return node.is_end

        def starts_with(self, prefix):
            node = self.root
            for char in prefix:
                if char not in node.children:
                    return False
                node = node.children[char]
            return True

- 何时使用：自动补全、拼写检查、单词游戏、IP 路由表。每当你需要基于前缀的操作时。

### 困难：单词搜索 II

- 问题：给定一个字符板和一个单词列表，找出所有可以通过遍历相邻单元格形成的单词。

- 模式：从单词列表构建一个前缀树，然后从每个单元格使用前缀树进行 DFS，尽早剪枝分支（如果没有单词以当前前缀开头，则停止）。

- 陷阱：没有前缀树的话，你需要为每个单词单独进行 DFS：\(O(w \cdot m \cdot n \cdot 4^L)\)。前缀树跨单词共享前缀计算，大幅减少了工作量。

## 并查集（不相交集合）

- 并查集跟踪一组不相交集合。两个操作：`find(x)` 返回 \(x\) 所在集合的代表元，`union(x, y)` 合并包含 \(x\) 和 \(y\) 的集合。

    class UnionFind:
        def __init__(self, n):
            self.parent = list(range(n))
            self.rank = [0] * n
            self.count = n  # 连通分量数

        def find(self, x):
            if self.parent[x] != x:
                self.parent[x] = self.find(self.parent[x])  # 路径压缩
            return self.parent[x]

        def union(self, x, y):
            rx, ry = self.find(x), self.find(y)
            if rx == ry:
                return False  # 已经连通
            # 按秩合并
            if self.rank[rx] < self.rank[ry]:
                rx, ry = ry, rx
            self.parent[ry] = rx
            if self.rank[rx] == self.rank[ry]:
                self.rank[rx] += 1
            self.count -= 1
            return True

- 通过路径压缩和按秩合并，两个操作都是平摊 \(O(\alpha(n)) \approx O(1)\)（反阿克曼函数，实际上是常数）。

- 何时使用：连通分量、无向图中的环检测、Kruskal 最小生成树、分组等价项。

### 中等：连通分量数量

    def count_components(n, edges):
        uf = UnionFind(n)
        for u, v in edges:
            uf.union(u, v)
        return uf.count

### 中等：冗余连接

- 问题：找出从图中移除后使图成为树的那条边（即，创建环的那条边）。

- 模式：逐一处理边。第一条两个端点已经在同一分量中的边就是创建环的边。

    def find_redundant(edges):
        uf = UnionFind(len(edges) + 1)
        for u, v in edges:
            if not uf.union(u, v):
                return [u, v]  # 已经连通 → 这条边创建了环

## 线段树和树状数组

- 线段树支持区间查询（子数组上的和、最小值、最大值）和单点更新，两者都是 \(O(\log n)\)。

- 树状数组（二叉索引树）是前缀和查询和单点更新的更简单、更快的替代方案。它使用一种巧妙的位操作技巧：每个位置存储一个部分和，覆盖范围由最低设置位决定。

    class FenwickTree:
        def __init__(self, n):
            self.n = n
            self.tree = [0] * (n + 1)

        def update(self, i, delta):
            i += 1  # 1-indexed
            while i <= self.n:
                self.tree[i] += delta
                i += i & (-i)  # 加上最低设置位

        def prefix_sum(self, i):
            i += 1
            total = 0
            while i > 0:
                total += self.tree[i]
                i -= i & (-i)  # 移除最低设置位
            return total

        def range_sum(self, l, r):
            return self.prefix_sum(r) - (self.prefix_sum(l - 1) if l > 0 else 0)

- 何时使用：需要带更新的重复区间查询的问题。当你只需要前缀和时首选树状数组；当你需要任意区间操作（最小值、最大值、GCD）时使用线段树。

## 常见陷阱总结

陷阱 | 示例 | 修复
--- | --- | ---
BST 只检查直接子节点 | `left.val < root.val` 遗漏了深层违规 | 传递 `lo`/`hi` 边界
递归中 \(O(n^2)\) 列表拼接 | `inorder(left) + [val] + inorder(right)` | 追加到共享列表
忘记基本情况 | 空树上的无限递归 | `if not root: return`
混淆经过路径和到父节点的路径 | 最大路径和：在两个层级分叉 | 向父节点返回单分支，单独跟踪双分支
树状数组 1-indexed vs 0-indexed | 树数组中的差一错误 | 入口处始终 `i += 1`
并查集没有路径压缩 | 最坏情况下每次 `find` 是 \(O(n)\) | `self.parent[x] = self.find(self.parent[x])`

## 课后练习题（NeetCode）

### 二叉树模式

- 翻转二叉树 — 基础递归
- 二叉树的最大深度 — 递归深度
- 相同的树 — 同步遍历
- 另一棵树的子树 — 嵌套递归
- 二叉树的层序遍历 — 带层级跟踪的 BFS
- 二叉树中的最大路径和 — 带全局最优的 DFS
- 序列化与反序列化二叉树 — 前序遍历 + null 标记

### BST 模式

- 验证二叉搜索树 — 边界传播
- 二叉搜索树中第 K 小的元素 — 中序遍历
- 二叉搜索树的最近公共祖先 — 利用 BST 排序性质

### 前缀树

- 实现 Trie — 基础前缀树操作
- 设计添加和搜索单词 — 前缀树 + 带通配符的 DFS
- 单词搜索 II — 前缀树引导的回溯

### 并查集

- 连通分量数量 — 基础并查集
- 冗余连接 — 通过并查集检测环