树与二叉搜索树 — 从零精通算法与数据结构——Google 面试系统备战 第5篇
第5章:树与二叉搜索树
本章目标
读完本章你会:
- 手写 BST 的插入、查找、删除,理解 successor/predecessor 的含义
- 用递归和迭代两种方式实现四种树遍历
- 理解 AVL 旋转和红黑树的自平衡思想
- 解决 LCA(最近公共祖先)、序列化/反序列化等经典树问题
- 理解
std::map为什么是 O(log n) —— 红黑树保证的
知识讲解
从一个生活例子开始
你的家族有一本族谱。从你太爷爷开始,往下画:
太爷爷
/ \
爷爷 二爷爷
/ \ / \
爸爸 叔叔 堂叔 堂姑
|
你
这就是一棵树。每个节点只能有一个父节点(你不会同时有两个爸爸),但可以有多个子节点。
二叉搜索树则在族谱上加了规则:所有人按年龄排:左子树都比我小,右子树都比我大。 这样找一个人不用翻全本——从根开始,每次只走一条路。
但如果族谱是"每代只有一个孩子"——所有人排成一条线——那又退化成了链表。这就是为什么需要平衡树:保证树"长得矮胖",让查找路径始终短。
工作原理
5.1 树的基础概念
| 术语 | 含义 |
|---|---|
| 根 | 最顶层的节点(太爷爷) |
| 叶子 | 没有子节点的节点(你) |
| 高度 | 从根到最远叶子的边数 |
| 深度 | 从根到该节点的边数 |
| 度 | 一个节点的子节点数 |
| 完全二叉树 | 除最后一层外每层都满,最后一层左对齐 |
5.2 二叉树遍历
四种遍历方式的本质差异是访问根节点的时机:
A
/ \
B C
/ \ / \
D E F G
前序(根-左-右): A B D E C F G → 用于复制树(先建根)
中序(左-根-右): D B E A F C G → BST 中序 = 升序输出
后序(左-右-根): D E B F G C A → 用于删除树(先删子)
层序(BFS): A B C D E F G → 按层访问
递归实现很干净,但面试中常被要求写迭代版本——展示你对栈/队列的理解:
// 中序遍历——迭代版(栈模拟递归)
void InorderIterative(TreeNode* root) {
std::stack<TreeNode*> st;
TreeNode* curr = root;
while (curr != nullptr || !st.empty()) {
// 一路向左入栈
while (curr != nullptr) {
st.push(curr);
curr = curr->left;
}
curr = st.top(); st.pop();
Visit(curr); // 访问节点
curr = curr->right; // 转向右子树
}
}
5.3 BST 操作详解
查找: 从根开始,target < 当前 → 往左,target > 当前 → 往右。O(h),h 是树高度。
插入: 跟查找一样找到"应该放的位置",然后挂上去。O(h)。
删除——最复杂的操作,三种情况:
| 情况 | 做法 |
|---|---|
| 叶子节点(无子) | 直接删 |
| 只有一个子 | 子节点替上来 |
| 有两个子 | 找后继(右子树最小值)替换,然后删除后继(必然属于前两种情况) |
为什么 successor 是右子树的最小值? 中序遍历的顺序是 左 → 根 → 右。删掉"根"后,要保持有序性,"根"的值必须被"比它大的最小值"取代——正是右子树的最左节点。
5.4 平衡树:为什么需要旋转
退化的 BST(链表): 平衡的 BST:
1 4
\ / \
2 2 6
\ / \ / \
3 1 3 5 7
\
4
AVL 树的思想: 每个节点维护"左右子树高度差"(平衡因子),当 |平衡因子| > 1 时旋转修复。
四种旋转情况:
LL(左-左): 右旋 RR(右-右): 左旋 LR(左-右): 左旋+右旋 RL(右-左): 右旋+左旋
z z z z
/ / \ / / \
y → y T4 → 旋转回平衡 y → y T4
/
x
红黑树的思想: 不用维护数字值(高度差),而是给节点染色 + 维护五条性质(根黑、叶黑、红节点的父必黑、黑高度相等)。保证了树高度 ≤ 2·log₂(n+1),从而 O(log n)。这是 C++ std::map 的底层实现。
面试实话: 90% 的树面试题不需要手写旋转——考察的是"你理解平衡树保证了什么性质"和"如何用 BST 解决问题"。但了解 AVL 旋转的思想有助于在面试官追问时展示深度。
5.5 经典树问题框架
LCA(最近公共祖先):
// 递归解法——利用 BST 的有序性质
TreeNode* LowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
// 两个都在左边 → LCA 也在左边
if (p->val < root->val && q->val < root->val)
return LCA(root->left, p, q);
// 两个都在右边 → LCA 也在右边
if (p->val > root->val && q->val > root->val)
return LCA(root->right, p, q);
// 一个在左一个在右(或一个是根)→ 当前就是 LCA
return root;
}
序列化/反序列化: 用某种遍历顺序记录树的结构(包括 null 标记),使能够从文件重建。这是 LeetCode 高难题,也是 Google 的经典问题——因为它同时考察遍历、递归、和边界处理。
代码实战
在 algo_toolkit 中添加 bst.h——实现 BST 的完整操作。
include/algo/bst.h
#ifndef ALGO_BST_H_
#define ALGO_BST_H_
#include <functional>
#include <memory>
#include <optional>
#include <vector>
namespace algo {
// BST 节点——用 unique_ptr 表达所有权
template <typename T>
struct BstNode {
T value;
std::unique_ptr<BstNode> left;
std::unique_ptr<BstNode> right;
explicit BstNode(T val) : value(std::move(val)) {}
};
// BST 类——封装树操作
template <typename T>
class BinarySearchTree {
public:
// 插入。返回 true 表示新插入,false 表示值已存在(更新)
bool Insert(const T& value);
// 查找。返回指向值的指针,未找到返回 nullptr(指针在树存活期间有效)
const T* Find(const T& value) const;
// 删除。返回 true 表示成功删除
bool Remove(const T& value);
// 四种遍历,结果写入 output
void Inorder(std::vector<T>& output) const;
void Preorder(std::vector<T>& output) const;
void Postorder(std::vector<T>& output) const;
// 查找最小值/最大值
std::optional<T> Min() const;
std::optional<T> Max() const;
// 后继(比 value 大的最小节点值)
std::optional<T> Successor(const T& value) const;
bool empty() const { return root_ == nullptr; }
std::size_t size() const { return size_; }
private:
// 递归辅助函数
void InorderTraverse(const std::unique_ptr<BstNode<T>>& node,
std::vector<T>& output) const;
// 查找并返回节点指针的引用(允许修改)
BstNode<T>* FindNode(const T& value);
const BstNode<T>* FindNodeConst(const T& value) const;
// 找最小节点
BstNode<T>* FindMin(BstNode<T>* node);
std::unique_ptr<BstNode<T>> root_;
std::size_t size_ = 0;
};
} // namespace algo
#include "algo/bst_impl.h"
#endif // ALGO_BST_H_
include/algo/bst_impl.h(核心实现摘要)
#ifndef ALGO_BST_IMPL_H_
#define ALGO_BST_IMPL_H_
namespace algo {
template <typename T>
bool BinarySearchTree<T>::Insert(const T& value) {
if (!root_) {
root_ = std::make_unique<BstNode<T>>(value);
++size_;
return true;
}
BstNode<T>* curr = root_.get();
while (true) {
if (value == curr->value) return false; // 重复值:BST 不允许
if (value < curr->value) {
if (!curr->left) {
curr->left = std::make_unique<BstNode<T>>(value);
++size_;
return true;
}
curr = curr->left.get();
} else {
if (!curr->right) {
curr->right = std::make_unique<BstNode<T>>(value);
++size_;
return true;
}
curr = curr->right.get();
}
}
}
template <typename T>
bool BinarySearchTree<T>::Remove(const T& value) {
// 找到待删节点及其父节点
BstNode<T>* parent = nullptr;
BstNode<T>* curr = root_.get();
while (curr && curr->value != value) {
parent = curr;
curr = (value < curr->value) ? curr->left.get() : curr->right.get();
}
if (!curr) return false; // 未找到
// 情况 3:两个子节点 → 找后继替换
if (curr->left && curr->right) {
BstNode<T>* succ_parent = curr;
BstNode<T>* succ = curr->right.get();
while (succ->left) {
succ_parent = succ;
succ = succ->left.get();
}
curr->value = succ->value; // 用后继的值覆盖
// 现在要删的是后继节点(必然属于情况 1 或 2)
parent = succ_parent;
curr = succ;
}
// 情况 1 和 2:取 curr 唯一的子节点(或 nullptr)
std::unique_ptr<BstNode<T>> child =
curr->left ? std::move(curr->left) : std::move(curr->right);
// 挂到父节点上
// parent == nullptr 表示要删除的是根节点——此时用 root_ 承接
// 获取父节点中指向 curr 的那个指针(left 或 right)的引用
auto& parent_ptr = (!parent) ? root_ :
(parent->left.get() == curr) ? parent->left : parent->right;
parent_ptr = std::move(child);
--size_;
return true;
}
} // namespace algo
#endif // ALGO_BST_IMPL_H_
本章小结
- 树是用指针(或引用)组织的层级数据结构,每个节点最多一个父节点
- BST 的查找/插入/删除都是 O(h),h 是树高——平衡时 O(log n),退化时 O(n)
- 四种遍历的区别是访问根的时机:前序先根、中序中间、后序最后、层序逐层
- 删除有两个子的节点 = 找后继替换 + 删除后继
- AVL 通过旋转修复不平衡,红黑树通过染色 + 少量旋转保证 O(log n)
- C++
std::map= 红黑树,JavaTreeMap= 红黑树
关键术语
| 术语 | 释义 |
|---|---|
| BST | 二叉搜索树:左小右大的二叉树,支持 O(log n) 动态集合操作 |
| AVL 树 | 第一个自平衡 BST(1962),严格保持高度差 ≤ 1 |
| 红黑树 | 宽松平衡 BST,通过染色规则保证 O(log n),插入最多 3 次旋转 |
| 旋转 | 在不改变中序遍历顺序的前提下重排子树节点 —— 平衡树的原子操作 |
| LCA | 最近公共祖先,树中离两个节点最近的共同祖先 |
| 后继/前驱 | 中序遍历中的下一个/上一个节点 |

浙公网安备 33010602011771号