NOI C++ 课程体系 / 数据结构篇 / 第九讲
数据结构 · 二叉搜索树

二叉搜索树的定义和构造
左小右大,中序自动有序

掌握二叉搜索树“左<根<右”的定义、插入/查找/删除三大操作,以及“中序遍历必升序”这条核心性质。

核心 左<根<右性质 中序升序插入 比大小分流删除 两子→后继风险 退化成链
本讲 · 第 49/56 讲二叉搜索树的定义和构造
01

情景导航

从“怎样把一堆数存起来、查起来又快又能自动有序”出发,理解二叉搜索树为什么是既能插入又能排序的神奇结构。

📖 引入故事:字典里的“二分查找”

你查字典时,不会从第 1 页翻到最后一页——而是先翻到中间,看是大了还是小了,再决定往前还是往后翻。这种“每次砍掉一半”的做法让查找飞快。问题来了:能不能把一堆数字排成一棵树,让“往左一定更小、往右一定更大”,从而一插一查都自动有序?

这正是二叉搜索树(Binary Search Tree,BST)的思想:它的每个结点都满足“左子树全比它小、右子树全比它大”,所以中序遍历一遍就是排好序的序列。

🎯 生活类比:比大小的排队

想象一群人排队领号:新来一个人先和队首比,比队首小就站到队首左边那一队、比队首大就站右边那一队;每队内部又各自继续比。最后从最左一直走到最右,人就是按身高从小到大排好的。二叉搜索树就是把这种“比大小分流”固定成一棵树,任何时刻中序遍历都升序。

关键约束:左 < 根 < 右(严格不等,一般不存重复值)。一旦破坏,就不再是 BST。

📝 编程定义

二叉搜索树(BST):一棵二叉树,满足对任意结点,其左子树所有结点的值 < 该结点的值 < 右子树所有结点的值。

直接推论:对 BST 做中序遍历(左—根—右),得到的序列一定严格递增。这是 BST 最重要的性质,也是验证“它还是不是 BST”的利器。

基本操作:插入(insert)、查找(find)、求最值、删除(erase)。平均时间复杂度 O(log n)(理想平衡时),最坏 O(n)(退化成链)。

核心目标用“左小右大”的树结构,把插入、查找、排序统一起来

🔑 核心口诀

“左小右大是铁律,中序遍历必有序;插入就比大小分流,查找顺着大小走;删点三种情况记,平衡与否定快慢。”

🗺️ 本讲知识地图

知识点你要掌握难度
定义与性质左 < 根 < 右;中序必升序★
插入(构建)从根比大小,落到空位★★
查找与最值顺着大小走;最左最小、最右最大★★
删除(三种情况)无子/一子/两子(后继替身)★★★
中序验证中序升序 ⇔ 仍是 BST★★
拓展平衡树(AVL/红黑树)避免退化★★★
02

知识探究

先确认“中序有序”这条性质,再看插入怎样“比大小分流”、查找怎样顺大小走、删除怎样分三种情况处理。

① 性质:中序遍历必升序

因为对每个结点都满足“左 < 根 < 右”,中序遍历(左—根—右)自然把“比根小的全放在前面、比根大的全放在后面”,整条序列必然严格递增。

反过来,只要一棵树中序遍历是升序的,它就是 BST。所以“中序遍历 + 检查是否递增”是判断 BST 的标准方法。

性质BST 的中序遍历 = 升序序列(去重时严格递增)

② 插入(构建):从根比大小,落到空位

插入 v:从根开始,v 比当前结点小就往左走、大就往右走;走到空指针时,就把 v 作为新结点挂上去。因为每次都“按大小分流”,建好的树自动满足左 < 根 < 右。

图· 插入序列 5 3 7 1 4 6 8 2 9 得到的二叉搜索树(左 < 根 < 右)
537146829
根(5) 普通结点

上图就是把 5 3 7 1 4 6 8 2 9 依次插入得到的 BST。注意:相同输入顺序固定,树形就固定;顺序不同,树形不同。

③ 查找与最值:顺着大小走

查找 v:从根出发,等于就找到;比当前小往左、比当前大往右;走到空还没找到就是不存在。最小值在最左下的叶子、最大值在最右下的叶子。

C++二叉搜索树·查找与求最值
#include <iostream>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
Node* insert(Node* u, int v) {
    if (!u) return new Node(v);
    if (v < u->val) u->l = insert(u->l, v);
    else if (v > u->val) u->r = insert(u->r, v);
    return u;
}
Node* find(Node* u, int v) {
    while (u) { if (v == u->val) return u; u = (v < u->val) ? u->l : u->r; }
    return nullptr;
}
int main() {
    int a[] = {5, 3, 7, 1, 4, 6, 8, 2, 9};
    Node* root = nullptr;
    for (int v : a) root = insert(root, v);
    for (int q : {4, 10}) {
        Node* p = find(root, q);
        cout << "查找 " << q << ":" << (p ? "找到" : "未找到") << endl;
    }
    Node* mn = root; while (mn->l) mn = mn->l;
    Node* mx = root; while (mx->r) mx = mx->r;
    cout << "最小值 = " << mn->val << "   最大值 = " << mx->val << endl;
    return 0;
}
运行结果
查找 4:找到
查找 10:未找到
最小值 = 1   最大值 = 9

查找本质就是“在树上做二分”,每次排除一半子树,理想情况下很快。

④ 删除:三种情况,最麻烦的是“两孩子”

删除值 v 的结点,分三种情况:

  • 无孩子:直接删掉(父指针置空)。
  • 只有一个孩子:用那一个孩子顶替它的位置。
  • 有两个孩子:找它的中序后继(右子树里最小的结点)替身——把后继的值抄过来,再递归删掉后继(后继至多只有一个孩子,退化成前两种情况)。
C++二叉搜索树·删除结点(含两孩子情况)
#include <iostream>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
Node* insert(Node* u, int v) {
    if (!u) return new Node(v);
    if (v < u->val) u->l = insert(u->l, v);
    else if (v > u->val) u->r = insert(u->r, v);
    return u;
}
void inorder(Node* u) { if (!u) return; inorder(u->l); cout << u->val << " "; inorder(u->r); }
Node* minNode(Node* u) { while (u->l) u = u->l; return u; }
Node* erase(Node* u, int v) {
    if (!u) return nullptr;
    if (v < u->val) u->l = erase(u->l, v);
    else if (v > u->val) u->r = erase(u->r, v);
    else {                                   // 找到要删的结点
        if (!u->l) { Node* t = u->r; delete u; return t; }   // 无左孩子
        if (!u->r) { Node* t = u->l; delete u; return t; }   // 无右孩子
        Node* s = minNode(u->r);              // 有两孩子:用右子树最小者(中序后继)替身
        u->val = s->val;
        u->r = erase(u->r, s->val);
    }
    return u;
}
int main() {
    int a[] = {5, 3, 7, 1, 4, 6, 8, 2, 9};
    Node* root = nullptr;
    for (int v : a) root = insert(root, v);
    cout << "删除前中序:"; inorder(root); cout << endl;
    root = erase(root, 3);     // 删除有两个孩子的结点 3
    cout << "删除 3 后中序:"; inorder(root); cout << endl;
    root = erase(root, 5);     // 删除根 5
    cout << "删除 5 后中序:"; inorder(root); cout << endl;
    return 0;
}
运行结果
删除前中序:1 2 3 4 5 6 7 8 9
删除 3 后中序:1 2 4 5 6 7 8 9
删除 5 后中序:1 2 4 6 7 8 9

删除 3(两孩子)时用右子树最小者 4 替身;删除根 5 时同理。删除后中序仍然有序,证明还是 BST。

⑤ 用中序遍历验证“仍是 BST”

每次插入或删除后,只要中序遍历得到升序序列,就说明结构仍然满足左 < 根 < 右。下面把构建出的树中序打印出来,肉眼可见它有序。

C++二叉搜索树·构建并中序遍历(验证有序)
#include <iostream>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
Node* insert(Node* u, int v) {
    if (!u) return new Node(v);
    if (v < u->val) u->l = insert(u->l, v);
    else if (v > u->val) u->r = insert(u->r, v);  // 相等一般不插入(去重)
    return u;
}
void inorder(Node* u) { if (!u) return; inorder(u->l); cout << u->val << " "; inorder(u->r); }
int main() {
    int a[] = {5, 3, 7, 1, 4, 6, 8, 2, 9};
    Node* root = nullptr;
    for (int v : a) root = insert(root, v);
    cout << "插入序列:5 3 7 1 4 6 8 2 9" << endl;
    cout << "中序遍历(应升序):";
    inorder(root);
    cout << endl;
    return 0;
}
运行结果
插入序列:5 3 7 1 4 6 8 2 9
中序遍历(应升序):1 2 3 4 5 6 7 8 9 

输出 1 2 3 4 5 6 7 8 9 严格升序——这正是 BST 性质成立的铁证。

03

实践应用

三道分析编程题:换一组数构建并验证有序、查找最值、删除结点。均给出可编译运行的参考实现。

🧩 题1:序列 9 4 15 2 7 12 20 1 3 6 8,构建并中序验证

直接套用“从根比大小、落到空位”的插入逻辑,最后中序打印。

C++构建 BST 并中序遍历
#include <iostream>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
Node* insert(Node* u, int v) {
    if (!u) return new Node(v);
    if (v < u->val) u->l = insert(u->l, v);
    else if (v > u->val) u->r = insert(u->r, v);
    return u;
}
void inorder(Node* u) { if (!u) return; inorder(u->l); cout << u->val << " "; inorder(u->r); }
int main() {
    int a[] = {9, 4, 15, 2, 7, 12, 20, 1, 3, 6, 8};
    Node* root = nullptr;
    for (int v : a) root = insert(root, v);
    cout << "插入序列:9 4 15 2 7 12 20 1 3 6 8" << endl;
    cout << "中序遍历(应升序):";
    inorder(root);
    cout << endl;
    return 0;
}
运行结果
插入序列:9 4 15 2 7 12 20 1 3 6 8
中序遍历(应升序):1 2 3 4 6 7 8 9 12 15 20

中序得到 1 2 3 4 6 7 8 9 12 15 20,升序成立。

🧩 题2:在题1的树上查找 7、5,并求最值

复用查找函数与“最左最小、最右最大”的结论。

C++查找与最值验证
#include <iostream>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
Node* insert(Node* u, int v) {
    if (!u) return new Node(v);
    if (v < u->val) u->l = insert(u->l, v);
    else if (v > u->val) u->r = insert(u->r, v);
    return u;
}
Node* find(Node* u, int v) { while (u) { if (v == u->val) return u; u = (v < u->val) ? u->l : u->r; } return nullptr; }
int main() {
    int a[] = {9, 4, 15, 2, 7, 12, 20, 1, 3, 6, 8};
    Node* root = nullptr;
    for (int v : a) root = insert(root, v);
    for (int q : {7, 5}) {
        Node* p = find(root, q);
        cout << "查找 " << q << ":" << (p ? "找到" : "未找到") << endl;
    }
    Node* mn = root; while (mn->l) mn = mn->l;
    Node* mx = root; while (mx->r) mx = mx->r;
    cout << "最小值 = " << mn->val << "   最大值 = " << mx->val << endl;
    return 0;
}
运行结果
查找 7:找到
查找 5:未找到
最小值 = 1   最大值 = 20

7 存在、5 不存在;最小值 1、最大值 20。

🧩 题3:删除结点 4,要求删除后仍有序

4 有两孩子,用右子树最小者 6 替身,再中序验证。

C++删除结点并中序验证
#include <iostream>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
Node* insert(Node* u, int v) {
    if (!u) return new Node(v);
    if (v < u->val) u->l = insert(u->l, v);
    else if (v > u->val) u->r = insert(u->r, v);
    return u;
}
void inorder(Node* u) { if (!u) return; inorder(u->l); cout << u->val << " "; inorder(u->r); }
Node* minNode(Node* u) { while (u->l) u = u->l; return u; }
Node* erase(Node* u, int v) {
    if (!u) return nullptr;
    if (v < u->val) u->l = erase(u->l, v);
    else if (v > u->val) u->r = erase(u->r, v);
    else {
        if (!u->l) { Node* t = u->r; delete u; return t; }
        if (!u->r) { Node* t = u->l; delete u; return t; }
        Node* s = minNode(u->r); u->val = s->val; u->r = erase(u->r, s->val);
    }
    return u;
}
int main() {
    int a[] = {9, 4, 15, 2, 7, 12, 20, 1, 3, 6, 8};
    Node* root = nullptr;
    for (int v : a) root = insert(root, v);
    cout << "删除前中序:"; inorder(root); cout << endl;
    root = erase(root, 4);     // 删除有两个孩子的结点 4
    cout << "删除 4 后中序:"; inorder(root); cout << endl;
    return 0;
}
运行结果
删除前中序:1 2 3 4 6 7 8 9 12 15 20
删除 4 后中序:1 2 3 6 7 8 9 12 15 20

删除 4 后中序为 1 2 3 6 7 8 9 12 15 20,依然升序,删除正确。

04

总结提升

把定义、性质、三种操作与“退化风险”一次性理清。

📌 核心要点总结

结论说明
定义左 < 根 < 右(任意结点)
性质中序遍历必升序
插入从根比大小,落到空位
查找顺着大小走;最左最小、最右最大
删除无子/一子直接替;两子用后继替身
复杂度平均 O(log n),退化链 O(n)

🚀 拓展:提高内容(CSP-J 不要求,了解即可)

  • 退化问题:若插入序列本就升序(如 1 2 3 4 5),BST 会退化成一条链,查找退化为 O(n)。
  • 平衡树:AVL 树、红黑树通过“旋转”保持树高 ≈ log n,使操作稳定 O(log n)。属提高/省选内容。
  • 与排序的关系:先建 BST 再中序,等于做了一次“树排序”,但不如快排实用;真正价值在于“动态维持有序”。

⚠️ BST 的“定义、插入、查找、删除”属 CSP-J 应知;平衡树的旋转属提高,初赛可能考概念、复赛一般不要求手写。

💡 思考与启发

  • 为什么“中序升序”就能判定是 BST?只检查每个结点的左右孩子(不看整棵子树)够吗?举反例。
  • 删除“两孩子”结点时,为什么用“中序后继(右子树最小)”而不是“前驱”?用前驱行不行?
  • 插入升序序列会退化成链——那插入什么顺序能尽量平衡?随机顺序与有序顺序差在哪?
05

学习检测

5 大易错点 + 速查表 + 笔记卡,考前一眼回顾。

⚠️ 5 大易错点

易错1

把定义记成“左 ≤ 根 ≤ 右”。BST 一般要求严格不等(左 < 根 < 右),重复值通常不入树。

易错2

插入时“比大小”写反,把小的往右放。必须小往左、大往右,否则中序不再有序。

易错3

删除“两孩子”结点时直接删掉,导致子树丢失。正确做法:用中序后继(右子树最小)替身。

易错4

求最小值时写成“根就是最小”。最小在最左下的叶子,要一直往左走到底。

易错5

以为 BST 查找永远 O(log n)。退化成链时是 O(n),只有平衡树才能保证 O(log n)。

📑 速查表

概念要点
定义左 < 根 < 右
性质中序遍历必升序
插入比大小,落到空位
查找顺大小走;最左最小、最右最大
删除两子→后继替身
复杂度平均 O(log n),退化 O(n)

📌 笔记卡

二叉搜索树 BST

① 定义:左 < 根 < 右(严格)。

② 性质:中序遍历必升序。

③ 插入:从根比大小,空位挂新点。

④ 查找:顺大小走;最左最小、最右最大。

⑤ 删除:两孩子用中序后继替身。

⑥ 退化成链时查找退化为 O(n)。

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1基础·定义

用一句话写出二叉搜索树的定义,并说明它和“普通二叉树”的根本区别。

强调有序约束。

查看参考答案

BST 是“左子树所有值 < 根 < 右子树所有值”的二叉树;普通二叉树没有这个值的大小关系约束。

思路:BST 的约束是“左子树所有值 < 根 < 右子树所有值”,并且对每个结点都递归成立;普通二叉树只有“至多两个孩子”的结构约束,没有值的大小关系。

易错:只要求“左孩子 < 根 < 右孩子”,忽略了整棵子树的约束——这正是判断 BST 的经典陷阱。

A2基础·性质

为什么 BST 的中序遍历一定是升序?反过来,中序升序的二叉树一定是 BST 吗?

从“左<根<右”推导。

查看参考答案

因为左<根<右,中序(左—根—右)自然把小的放前大的放后,故升序。反过来也成立:中序升序 ⇒ 任意结点左<根<右 ⇒ 是 BST。

思路:中序的访问顺序是左—根—右,配合“左 < 根 < 右”,自然把小的排在前面、大的排在后面;反过来若中序升序,则任意结点都满足左 < 根 < 右,也是 BST。

易错:认为“中序升序”只是 BST 的必要条件,忽略它同时也是充分条件。

A3基础·查找

在一棵 BST 中查找最小值,应该往哪个方向一直走?最大值呢?

最小在左端。

查看参考答案

最小值:从根一直往左走到叶子。最大值:从根一直往右走到叶子。

思路:由“左子树的值都更小”,最小值一定在最左端;由“右子树的值都更大”,最大值一定在最右端,一路走到叶子即得。

易错:把 min 与 max 的方向弄反,找最小值时往右走。

A4基础·插入

把序列 4 2 6 1 3 5 7 依次插入空 BST,画出树并写出中序序列。

逐个比大小分流。

查看参考答案

根 4;左 2(其左 1、右 3),右 6(其左 5、右 7)。中序:1 2 3 4 5 6 7。

思路:每个数都从根出发比较大小:比当前结点小就往左、大就往右,走到空位就放进去;插入顺序决定树形,而中序必为 1 2 3 4 5 6 7。

易错:画树时把 1、3 挂错位置(例如直接挂到 4 左子树下但顺序颠倒),中序就无法得到升序。

🟡 B组 · 提高(4题)

B1提高·构建

序列 8 3 10 1 6 14 4 7 插入 BST,写出中序序列并说明它是否升序。

构建后中序。

查看参考答案

中序:1 3 4 6 7 8 10 14,严格升序,符合 BST 性质。

思路:按 8 3 10 1 6 14 4 7 的顺序,每个数从根开始逐层比较大小、落到空位;插完后做一次中序遍历应得严格升序,可用来检验树建得对不对。

易错:插入 4、7 时误挂到 3 或 8 下面(正确是分别成为 6 的左、右孩子),中序就不再升序。

B2提高·删除

在题1的树中删除结点 3(两孩子),删除后中序变成什么?

用右子树最小者替身。

查看参考答案

3 的右子树最小者是 4,替身后中序:1 4 6 7 8 10 14,仍升序。

思路:删除有两个孩子的结点,取它的中序后继(右子树中的最小值)的值覆盖到该结点上,再递归删除那个后继结点,整棵树的中序仍保持升序。

易错:随便挑一个孩子顶上来(如直接用左孩子替换),会破坏左 < 根 < 右的性质。

B3提高·查找

判断 5 是否在一棵中序为 1 3 4 6 7 8 10 14 的 BST 中,并说明理由。

中序升序里没有 5。

查看参考答案

5 不在中序序列里,故该 BST 不含 5(中序恰为所有结点的值)。

思路:BST 的中序序列恰好就是所有结点值的升序排列,所以只要检查 5 是否出现在该序列中即可判定。

易错:试图先把中序序列还原成树形再查找,其实完全没必要。

B4提高·思路

用文字 + 伪代码说明“删除两孩子结点用中序后继替身”的思路(不要求完整 C++)。

后继 = 右子树最小。

查看参考答案

找到要删结点 u(两孩子);s = 右子树最左结点(中序后继);u->val = s->val;递归删除 s(s 至多一个右孩子,退化为无子/一子情况)。

思路:定位到要删的结点 u(有两个孩子)后,在它的右子树里一路向左找到最小结点 s(中序后继),用 s 的值覆盖 u,再递归删除 s——s 至多带一个孩子,退化成了简单情形。

易错:用后继的值覆盖后,忘记把后继原位置的结点真正删掉,树里会出现两个相同的值。

🔴 C组 · 挑战(4题)

C1挑战·判定

只检查“每个结点的左右孩子满足左<根<右”是否足以判定 BST?给出反例或证明。

局部检查不够。

查看参考答案

不够。反例:根 10,左孩子 5(其右孩子 15),右孩子 20。每个结点自身左右孩子都满足,但 15 在 10 左子树却 > 10,不是 BST。必须保证“整棵左子树都小于根”。

思路:不够。反例:根为 10,左孩子 5 且 5 的右孩子为 15,右孩子为 20——每对直接父子都满足大小关系,但 15 位于 10 的左子树里却比 10 大,不是 BST。必须约束整棵子树的取值范围。

易错:只递归检查“当前结点与其左右孩子”,漏掉了来自祖先的跨层约束。

C2挑战·性质

证明:BST 中序遍历序列是严格升序的(假设无重复值)。

数学归纳/递归。

查看参考答案

对任意结点,左子树全<它<右子树全;中序先排左子树(升序且都<根),再根,再右子树(升序且都>根),故整体升序。重复值则非严格。

思路:对任意结点,中序先完整遍历左子树(其中所有值都小于根),再访问根,再遍历右子树(都大于根);左右两部分各自递归升序,拼起来整棵升序。

易错:只说明根与直接孩子的大小关系,没有用到“整棵左子树都小于根”这一递归前提。

C3挑战·编码

手写 C++:读入 n 个数,构建 BST 并输出中序序列(验证升序)。

复用本讲插入+中序。

查看参考答案

定义 Node 结构;insert 递归比大小落空位;inorder 递归左—根—右打印;main 读入 n 个数依次 insert 后 inorder。

思路:定义含 val、left、right 的结点结构;insert 按大小关系递归落到空位;inorder 按左—根—右递归打印;main 读入 n 个数依次 insert 后输出中序即验证升序。

易错:忘记给新结点分配内存,或没把新结点挂回父结点的左/右指针,插入后树没有任何变化。

C4挑战·应用

手写 C++:构建 BST 后,实现“删除值为 v 的结点”,并输出删除前后中序序列对比。

三种情况 + 后继替身。

查看参考答案

在 insert/inorder 基础上加 erase:无子/一子直接替父指针;两子取右子树最小者值覆盖,再递归删后继;main 输出删除前后中序。

思路:在 insert / inorder 的基础上加 erase:无孩子或只有一个孩子时,直接让父指针跳过它;有两个孩子时取右子树最小值的值覆盖后递归删除后继;最后输出删除前后的中序序列做对比。

易错:删除时忘记更新父结点的指针,导致整棵子树被丢掉,树结构被写坏。

🏆 洛谷原题(20道)

1P5076[深基16.例1] 普通二叉搜索树(插入/删除/前驱后继)普及-
2P3369[模板] 普通平衡树(Treap/替代)提高-
3P2234[HNOI2002] 营业额统计(二叉搜索树应用)提高-
4P1305新二叉树普及-
5P4913二叉树深度入门
6P1030求先序排列普及-
7P1827美国血统普及-
8P1229遍历问题提高-
9P2015二叉苹果树提高-
10P3884满二叉树的带权路径长度普及-
11P5018对称二叉树普及-
12P1364医院设置提高-
13P1395会议(树的重心)提高-
14P1040加分二叉树提高-
15P3379最近公共祖先 LCA提高-
16P2052道路铺设提高-
17P1268树的最大独立集提高-
18P2014选课(树形 DP)提高-
19P5536【XR-3】核心城市提高-
20P3372[模板] 线段树(对比:BST 不擅长区间)提高-