如果要选出数据结构里"性价比"最高的一棵贵族,二叉搜索树绝对排得上号:接口只有三四个,代码不过一百行,却能把"查找、插入、删除"这三个最常用的操作,从数组的 O(N) 一路优化到接近 O(log N)。它半只脚踩在有序链表上,半只脚又够着哈希表——是整个 STL 里 map、set、multimap、multiset 的老祖宗。

但二叉搜索树也是一棵"有脾气的树"。它在理想状态下美得像一部平衡的诗,可一旦插入序列稍有恶意,它就可能堕落成一条歪歪扭扭的链子,所有光辉瞬间崩塌。为什么?别急,这正是我们今天要一路走到深处去解开的谜。我会带你从零搭出它、喂饱它、删空它,再亲手把它拷贝一份、把它析构干净——最后再把下一课 AVL 树和红黑树的大门,用手指推开一条缝。

在动手之前,我们先补齐一块必须拼上的前置拼图:二叉树到底是什么,以及递归这个"自己给自己立规矩"的玩法。这两样,我们马上要反复用到。

首先要搞懂:二叉树和递归

二叉搜索树不是凭空冒出来的,它的底子是一棵普通的二叉树。所谓二叉树(Binary Tree),就是一棵每个结点最多有两个孩子的树,这两个孩子一个叫左孩子(left),一个叫右孩子(right)。想象你把一张纸对折再对折:每一次对折,一个结点最多裂成两个子结点,这就是二叉树的形状来源。也正因为"最多两个",二叉树是可以用指针"/左右孩子"这个最简单结构完全描述的树形结构——三叉、多叉固然存在,但二叉在所有树里天然地最贴近"二分"。

顺带交代一个术语:你后来会看到名词"M 叉树"。二叉就是 M=2 的特例——每个结点最多两个支路。二叉搜索树之所以选"二叉"而非"三叉""多叉",是因为它要利用"左小右大"的二路比较去逼近二分查找的效果,这个道理马上会讲到。

树的术语你得先立个底。我们说高度(height),通常指"从根结点走到某个叶子结点,最多需要经过的边数";说深度(depth),指某个结点到根的边数;说层(level),根算第 0 层还是第 1 层各教材不一。它们之间只差一个定义的偏移,不必纠结,你只需要抓住最重要的一条直觉:树越高,从根走到叶子就越远;结点挤得越"矮胖",查找就越快。 这一条直觉,将贯穿整篇复杂度的讨论。

有了二叉树,数据结构里最经典的一个"招式"就能登场了——递归(Recursion)。递归说白了就是"函数自己调用自己",但它不是盲目地调用,而是必须满足两个铁律:

  1. 有明确的结束条件(叫"边界"、base case 或递归出口),否则会无限地自己调自己,把调用栈撑爆——这在 C/C++ 里叫栈溢出(stack overflow),程序直接崩溃。
  2. 每次递归都要向结束条件靠近一步,也就是问题规模在缩小。

我想把"为什么递归会栈溢出"从原理层面讲透,因为很多人背过"要写递归出口",却不真懂背后发生了什么。当你调用一个普通函数时,计算机会在内存里一块叫**调用栈(call stack)**的区域压入一个"栈帧",里面记录这个函数的参数、局部变量和"返回到哪儿去"。函数返回时,这个栈帧弹出。递归调用本质上就是一连串"还没返回的函数又调了下一个函数",于是调用栈上叠了一摞栈帧。只要递归出口存在而且真的会走到,这一摞终将一个个弹出、释放;可一旦出口缺失或永远走不到,栈帧只进不出,栈空间耗尽,就发生了栈溢出——程序以崩溃收场。所以那两条铁律不是教条,而是"别把栈压爆"的现实要求。

顺带一提:正因为递归要占调用栈,它是有空间开销的。对一棵树来说,递归深入的高度是多少,调用栈最深就有多深,所以后续我们说某个递归算法的"空间复杂度",往往就是 O(树高),请先在这儿种下这颗种子。

二叉树天生就是递归的宠儿:一棵二叉树,就等于"一个根结点 + 一棵左子树 + 一棵右子树",而左右子树本身又是二叉树。你看,它在用树的定义解释"什么是树"本身——这就是最地道的递归思想。后面我们写查找、删除、遍历,全都是在这条"自己拆解自己"的路子上打转。

所以请你务必形成这个肌肉记忆:二叉树相关的算法,第一反应先想递归,再想能不能改循环。其中的关键工具是中序遍历,我们很快会用到。

我们还可以顺手把"递归三要素"第一次落地成一个实实在在、能独立编译的程序——用递归求一棵二叉树的高度。它把所有递归的价值浓缩成了六行:

#include <iostream>
 
struct Node
{
    int _key;
    Node* _left = nullptr;
    Node* _right = nullptr;
    explicit Node(int k) : _key(k) {}
};
 
// 递归求树的高度:空树高度为 0,否则等于"左右子树高度的较大者 + 1"
int Height(Node* root)
{
    if (root == nullptr)
    {
        return 0;                       // 递归边界:空子树高度记 0
    }
    int leftH = Height(root->_left);    // 问题缩小:先算左子树高度
    int rightH = Height(root->_right);  // 再算右子树高度
    return (leftH > rightH ? leftH : rightH) + 1;  // 取较大者,再算上自己这一层
}
 
int main()
{
    Node* root = new Node(1);
    root->_left = new Node(2);
    root->_right = new Node(3);
    root->_left->_left = new Node(4);
    root->_left->_right = new Node(5);
 
    std::cout << "树的高度 = " << Height(root) << std::endl;  // 输出 3
    delete root->_left->_left;
    delete root->_left->_right;
    delete root->_left;
    delete root->_right;
    delete root;
    return 0;
}

请盯着这段代码里最关键的一句:return (leftH > rightH ? leftH : rightH) + 1;。它表达的是"一棵树的高度 = 较高那棵子树的高度 + 自己这层"。这正是一棵树的定义告诉我们的——树由根和子树组成,所以树的性质也能由子树递归算出。这叫分而治之(divide and conquer):把大问题拆成同构的小问题,算出小问题的结果再组合回大问题。你以后会看到,BST 的查找、插入、删除,全都是同一个分治模板的变体。这里 manual 里的 delete 我是手动逐个释放的,等你学到本文的 BST 类,我们会用"析构函数统一释放整棵树",那是后话。

什么是二叉搜索树

现在,给这棵普通二叉树加上一条"交通规则",它就变成二叉搜索树(Binary Search Tree,简称 BST),课件里也叫二叉排序树。这条规则只有一句,但威力极大:

对树上任意一个结点:它左子树上的所有结点值都小于等于它,右子树上的所有结点值都大于等于它。

注意这里说的是"所有",不是"左孩子",这是新手最容易理解偏差的地方。写成数学语言,一棵二叉搜索树要么是空树,要么同时满足三条性质:

  • 若左子树非空,则左子树上所有结点的值都小于等于根结点的值;
  • 若右子树非空,则右子树上所有结点的值都大于等于根结点的值;
  • 它的左右子树也各自都是二叉搜索树。

第三条尤其关键——它告诉我们规则是"递归地、层层套用"下去的,不是只对根生效一次。

我想特别停下来,陪你把"为什么必须说所有,而不能只说左孩子"掰扯清楚,因为这是一道最经典的陷阱题。一棵树如果想靠"左小右大"实现二分查找,就必须保证:你在任何一个结点做比较时,一旦往左走了,那么以这一下比较为准,你能确定左子树里不可能藏着一个"该往右走"的元素。 也就是说,规则必须对整个子树成立,而不仅仅对直接孩子成立。举个例子:根是 8,左孩子是 3,如果 3 的右孩子是 9——那么单看"左孩子比根小"这条局部规则它满足(3<8),可 9 在根 8 的左子树里却大于 8,这就是一棵假 BST:你去找 9,先比 8 往左走,就再也不会看到 9 了,它成了永远查不到的孤儿。所以"所有"两个字,是 BST 能高效查找的命根子。

顺着这个劲头,我们顺便做一个特别有教学价值的小实验:给你一棵二叉树,你怎么判断它到底是不是一棵合格的 BST? 最严谨的思路是"范围封锁法"(也有人叫"上下界法"):从根出发,根没有上下界的限制;走左子树时,把根的 key 设为新的"上界";走右子树时,把根的 key 设为新的"下界"。任何一个结点,它的值必须严格落在当前被封锁的 (min, max) 区间里,否则就不是 BST。我们用两个指针当"上下界哨兵"来实现,空指针就表示"这一侧没有边界限制":

#include <iostream>
 
struct Node
{
    int _key;
    Node* _left = nullptr;
    Node* _right = nullptr;
    explicit Node(int k) : _key(k) {}
};
 
// 判断是否是严格的二叉搜索树(不允许相等的重复值)
// min / max 分别表示"当前允许的下界、上界";为 nullptr 表示该侧无边界
bool isBST(Node* root, Node* min, Node* max)
{
    if (root == nullptr)
    {
        return true;                       // 空子树永远合格
    }
    if (min != nullptr && root->_key <= min->_key)
    {
        return false;                      // 比下界还小 = 违规
    }
    if (max != nullptr && root->_key >= max->_key)
    {
        return false;                      // 比上界还大 = 违规
    }
    // 深入左子树时,根成为新的上界;深入右子树时,根成为新的下界
    return isBST(root->_left, min, root) && isBST(root->_right, root, max);
}
 
int main()
{
    // 构造一棵合格的 BST:8, 3, 10, 1, 6
    Node* good = new Node(8);
    good->_left = new Node(3);
    good->_right = new Node(10);
    good->_left->_left = new Node(1);
    good->_left->_right = new Node(6);
    std::cout << "good 是 BST 吗?" << (isBST(good, nullptr, nullptr) ? "是" : "否") << std::endl;
 
    // 人为破坏:让左子树里冒出一个大于根的值 9
    Node* bad = new Node(8);
    bad->_left = new Node(3);
    bad->_left->_right = new Node(9);   // 9 > 8,却藏在了根的左子树里
    std::cout << "bad 是 BST 吗?" << (isBST(bad, nullptr, nullptr) ? "是" : "否") << std::endl;
 
    delete good->_left->_left; delete good->_left->_right; delete good->_left;
    delete good->_right; delete good;
    delete bad->_left->_right; delete bad->_left; delete bad;
    return 0;
}

运行结果会是:good 是 BST 吗?是,bad 是 BST 吗?否。你看,bad 那棵树"根 8,左孩子 3,左孩子的右孩子 9"——单看相邻两层全都不违反局部规则,但因为 9 越过了上界 8,整棵树就不合格。这正是"所有"二字的分量。等我们写完正常的插入,你永远不会在 BST 里造出这种树,因为插入算法的每一步比较都会天然守好这个范围。

那相等的值怎么办?

你可能注意到了,性质里写的是"小于等于""大于等于",带着等号。这里其实藏着一个设计决策:二叉搜索树到底允不允许数值相等的结点存在? 答案是:两种情况都有,取决于使用场景。这也是为什么课件专门点出,STL 里 map/set 底层是二叉搜索树但不允许插入相等的 key;而 multimap/multiset 才允许。

允许相等时的处理也很讲究:插入相等的值时,可以统一往右走,也可以统一往左走,但必须全程保持一致。换句话说,你不能这次遇到相等的往右拐,下次又往左拐——那样树的结构会乱,逻辑自相矛盾。一致性是这里唯一的硬性约束。为了叙述简洁,本篇文章主要的演示实现采用"不允许重复值"的版本;允许重复的版本思路完全一样,只是在相等分支上的选择不同而已,我们会在"查找"一节专门演示允许重复时如何查找。请把这张对照表记在心里:

容器底层 BST是否允许相等 key本课对应的实现倾向
set / map唯一 key 的 BST不允许本文主版本
multiset / multimap允许多值的 BST允许查找一节的扩展版

结点的定义与中序遍历

学任何数据结构,第一步永远是定义"零件长什么样"。二叉搜索树的结点中保存一个关键码(我们叫它 key),再加两个指针分别指向左右孩子。因为 key 的类型可能是 int、string 乃至自定义类型,我们用模板把它抽象出来——这也是 C++ 里最自然的选择:

template<class K>
struct BSTNode
{
    K _key;                        // 结点中保存的关键码
    BSTNode<K>* _left;             // 指向左孩子
    BSTNode<K>* _right;            // 指向右孩子
 
    BSTNode(const K& key)          // 构造函数:构造时把两个孩子先置空
        : _key(key)
        , _left(nullptr)
        , _right(nullptr)
    {
    }
};

这段代码里有三个值得你咀嚼的工程细节:

第一,为什么结点用 struct 而不用 class?因为结点只承载"数据 + 指针",我们希望直接访问成员,不需要复杂封装。C++ 里 struct 的成员默认公有(public),class 默认私有(private),所以用 struct 最省事。

第二,为什么 key 用 const K& 来接收?因为 key 可能是 string 这样的"肥"对象,直接传值会整份拷贝,浪费;传常量引用既省一次拷贝,又保证函数内部不会意外改动实参。这是 C++ 里"能传引用就不传值"的典型体现。

第三,构造函数里把 _left、_right 都初始化为 nullptr(空指针)。这一步极其重要:如果你忘了初始化指针,它会是一个悬空值(垃圾地址),后面一访问就崩溃。所以"新建结点时孩子先置空"是铁律。

有了结点,我们立刻被一个问题击中:怎么把一棵树"原样"地打印出来,好验证我们的操作对不对?答案是中序遍历(In-order Traversal)。遍历就是按某种顺序把每个结点都访问一遍。中序遍历的顺序口诀是三个字:"左、根、右"——先递归访问左子树,再访问当前结点,最后递归访问右子树:

template<class K>
class BSTree
{
    typedef BSTNode<K> Node;     // 给结点类型起个短别名,方便书写
public:
    void InOrder()               // 对外接口:打印整棵搜索树
    {
        _InOrder(_root);
        std::cout << std::endl;
    }
 
private:
    void _InOrder(Node* root)    // 真正的递归实现
    {
        if (root == nullptr)     // 递归边界:空结点直接返回
        {
            return;
        }
        _InOrder(root->_left);                  // 先访问左子树
        std::cout << root->_key << " ";         // 再打印当前结点
        _InOrder(root->_right);                 // 最后访问右子树
    }
 
    Node* _root = nullptr;       // 这棵树的根节点指针
};

很多人第一次看见这个 _InOrder 会懵:为什么外面包一个 InOrder,里面又有一个 _InOrder?因为递归需要一个"根节点作为参数",而用户通常不关心也不想亲手把 _root 喂进来。于是对外接口 InOrder() 负责把初始的 _root 传进去,真正的递归函数 _InOrder 只负责干重活。这是面向对象封装很典型的一个手法,后面每个私有递归函数基本都是这个套路。下划线开头的 _InOrder 是一种"暗示私有/内部"的命名习惯,不是语法强制,只是为了让人一眼看出"这是内部实现"。

你先把"左根右"三个字刻进脑子,因为马上你会惊讶地发现:对二叉搜索树做中序遍历,得到的竟然是一个从小到大排好序的序列。 这可不是巧合,而是 BST 性质的直接推论:左子树里全是比根小的,根先于右子树打印,所以"左 < 根 < 右",递归地看整棵中序序列必然升序。这一句话,就解释了为什么它又叫"二叉排序树",也是我们后面"排序""去重"应用的地基。

你还可以顺带注意到:中序遍历在递归实现下是有空间代价的——递归深度最深会到树的高度 h,所以它的"额外空间"是 O(h)。这是"用栈换逻辑清晰"的例子,前面二叉树那一节已经埋过伏笔了。

树的"善终":析构、深拷贝与内存安全

到这里,我们其实已经潜在一个不小的隐患:上面每段代码都用 new 造结点,却从没释放过。一个结点就漏一块堆内存,一棵大树的泄漏量是 O(N)。如果你正在学操作系统里"内存泄漏"的概念,这里就是最好的教材现场。C++ 的绝活叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化):让对象的构造函数拿到资源、析构函数释放资源,这样对象一死,资源必被回收,程序员就少操心一半内存问题。基于 BST 这类"每个结点都是 new 出来"的类,最要紧的两件事一件是析构函数(释放整棵树),另一件是拷贝构造/赋值(防止浅拷贝)。课件里的完整 BSTree 就是奔着这个目标去的,我们把它补全、讲透。

先看析构。释放一棵多结点树的顺序必须是后序:先递归释放左右子树,最后再删根。为什么不能先删根?因为根被 delete 后,它的内存立刻被回收、里面的 _left/_right 指针就变成"指针悬空"了,你再顺着它们去删子树,就是访问已释放的内存(悬空指针/use-after-free),轻则乱结果,重则崩溃。所以必须"先孩子后父母"——这正好就是中序/后序遍历里的"后序"(左、右、根)顺序。

再看拷贝。C++ 里只要你写了拷贝构造的语义,就得注意:编译器默认生成的拷贝构造是浅拷贝——它只把 _root 这个指针原样复制过去。结果就是两个 BST 对象共享同一棵树的结点。一旦其中一个析构把树删了,另一个对象里的指针就成了悬空指针;哪怕两个都还活着,谁先删都会让另一个 "use-after-free"。更要命的是,如果两个对象各自析构,同一块内存被 delete 两次,这叫双重释放(double free),程序直接崩溃。所以是拷贝赋值或拷贝构造一个 BST 时,必须做深拷贝(deep copy):一字一句地把整棵树重新复制一份,让两个对象各自拥有独立的内存。

下面,我们把一个"能独立编译、整类齐活"的 BST 一次性端给你。它把前面讨论的 Insert/Find/Erase/InOrder 全部装上,再补齐析构 _Destroy、深拷贝 _Copy、拷贝解析交换式赋值,最后用一个 main 把三大件都验证一遍:

#include <iostream>
#include <utility>     // 提供 std::swap
 
template<class K>
struct BSTNode
{
    K _key;
    BSTNode<K>* _left;
    BSTNode<K>* _right;
    BSTNode(const K& key)
        : _key(key), _left(nullptr), _right(nullptr)
    {}
};
 
template<class K>
class BSTree
{
    typedef BSTNode<K> Node;
public:
    BSTree() = default;                    // 默认构造:_root 用自带默认值 nullptr
 
    BSTree(const BSTree& t)                // 深拷贝构造
        : _root(_Copy(t._root))
    {
    }
 
    BSTree& operator=(BSTree t)            // 赋值:拷贝交换(copy-and-swap)
    {
        std::swap(_root, t._root);         // 换走旧树,旧树随形参 t 析构释放
        return *this;
    }
 
    ~BSTree()                              // 析构:后序释放整棵树
    {
        _Destroy(_root);
    }
 
    bool Insert(const K& key)
    {
        if (_root == nullptr)
        {
            _root = new Node(key);
            return true;
        }
        Node* parent = nullptr;
        Node* cur = _root;
        while (cur)
        {
            if (cur->_key < key)
            {
                parent = cur;
                cur = cur->_right;
            }
            else if (cur->_key > key)
            {
                parent = cur;
                cur = cur->_left;
            }
            else
            {
                return false;              // 不允许重复 key
            }
        }
        cur = new Node(key);
        if (parent->_key < key)
            parent->_right = cur;
        else
            parent->_left = cur;
        return true;
    }
 
    bool Find(const K& key)
    {
        Node* cur = _root;
        while (cur)
        {
            if (cur->_key < key)
                cur = cur->_right;
            else if (cur->_key > key)
                cur = cur->_left;
            else
                return true;
        }
        return false;
    }
 
    bool Erase(const K& key)
    {
        Node* parent = nullptr;
        Node* cur = _root;
        while (cur)
        {
            if (cur->_key < key) { parent = cur; cur = cur->_right; }
            else if (cur->_key > key) { parent = cur; cur = cur->_left; }
            else
            {
                if (cur->_left == nullptr)      // 左空:右孩子顶上(含叶子)
                {
                    if (parent == nullptr) _root = cur->_right;
                    else if (parent->_left == cur) parent->_left = cur->_right;
                    else parent->_right = cur->_right;
                    delete cur;
                    return true;
                }
                else if (cur->_right == nullptr)  // 右空:左孩子顶上
                {
                    if (parent == nullptr) _root = cur->_left;
                    else if (parent->_left == cur) parent->_left = cur->_left;
                    else parent->_right = cur->_left;
                    delete cur;
                    return true;
                }
                else
                {
                    // 双孩子:用右子树最小结点替换
                    Node* rightMinP = cur;
                    Node* rightMin = cur->_right;
                    while (rightMin->_left)
                    {
                        rightMinP = rightMin;
                        rightMin = rightMin->_left;
                    }
                    cur->_key = rightMin->_key;
                    if (rightMinP->_left == rightMin)
                        rightMinP->_left = rightMin->_right;
                    else
                        rightMinP->_right = rightMin->_right;
                    delete rightMin;
                    return true;
                }
            }
        }
        return false;
    }
 
    void InOrder() { _InOrder(_root); std::cout << std::endl; }
 
private:
    void _InOrder(Node* root)
    {
        if (root == nullptr) return;
        _InOrder(root->_left);
        std::cout << root->_key << " ";
        _InOrder(root->_right);
    }
 
    void _Destroy(Node* root)                // 后序释放整棵树
    {
        if (root == nullptr) return;
        _Destroy(root->_left);
        _Destroy(root->_right);
        delete root;
    }
 
    Node* _Copy(Node* root)                  // 深拷贝:逐一重建每个结点
    {
        if (root == nullptr) return nullptr;
        Node* newNode = new Node(root->_key);
        newNode->_left = _Copy(root->_left);
        newNode->_right = _Copy(root->_right);
        return newNode;
    }
 
    Node* _root = nullptr;
};
 
int main()
{
    BSTree<int> t;
    int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
    for (int x : a) t.Insert(x);
    t.InOrder();                        // 1 3 4 6 7 8 10 13 14
 
    BSTree<int> copy(t);                // 深拷贝构造:copy 和 t 各自独立
    copy.Erase(8);
    copy.InOrder();                     // 1 3 4 6 7 10 13 14
    t.InOrder();                        // 原树完好不受影响:1 3 4 6 7 8 10 13 14
 
    BSTree<int> assigned;
    assigned = t;                       // 拷贝交换式赋值,同样深拷贝
    assigned.InOrder();                 // 1 3 4 6 7 8 10 13 14
    return 0;                           // 三个对象析构,各自释放自己的树,无泄漏无双重释放
}

把注意力放在三类操作上。

_Destroy 使用后序释放,顺序是"左、右、根",和前面讲的原则一一对应;它必须在 delete 之前先递归处理两个孩子,否则根一删、孩子就悬空了。

_Copy 是一个对称的后序重建:它先复制出当前结点,再递归复制左子树、右子树,把新的孩子挂回到新结点上。因为每个 _Copy(root->_left) 都返回一棵全新的子树,最终得到的是与原树结构完全相同、但内存完全独立的一棵树。这就是"深"。它和 _InOrder 一样,都体现了"一整棵树可以被递归地解剖成根 + 两棵子树"的分治思想。

operator= 用了一个叫拷贝交换(copy-and-swap)的经典技法:参数 BSTree t 是按值传入的,那个传入过程会触发拷贝构造——如果你传入一个已有的树,t 就是它的深拷贝副本。然后我们把 _root 和 t._root 用 std::swap 换一下:此刻 _root 指向副本(内容正确),而原来那棵旧树被塞进了 t,当 operator= 返回、形参 t 析构时,旧树被顺带 _Destroy 释放回收。这样一箭三雕:既完成了深拷贝,又自动释放了旧树,还天然具备异常安全(万一拷贝中途抛出异常,_root 根本没被改动)。这就是 C++ 为什么要讲"RAII 与三/五法则",你值得把这段小代码反复读几遍。

**三/五法则(Rule of Three/Five)**顺带点破:如果某个 class 你需要自定义析构函数来释放资源,那么拷贝构造和赋值通常也必须一并自定义,因为编译器默认给的是危险的浅拷贝。现在这个 BSTree 三者齐备,就是一个"内存安全"的 C++ 类了——它和 STL 容器让你放心的理由是一样的。

查找:递归与非递归

查找是 BST 所有操作里最直白的一个,逻辑可以浓缩成一句话:从根出发,要查的 key 比当前结点大就往右子树走,比当前结点小就往左子树走,相等就找到了;一路走到空还没找到,说明不存在。

课件里还给了一个硬指标:查找最多走"树的高度"次。想想就是——每走一步就抛弃一侧子树,把候选范围折半,那么从根到一个叶子(或空位)最多经过"高度"条边。所以 BST 查找的速度,直接由树高决定。这句话,是连接"查找"与"复杂度、退化"两章之间的桥梁,请你把它当成半句定理来记。

非递归版(循环实现)

先看迭代版本。它的优势是逻辑清晰、没有函数调用开销,而且容易加"记住父节点"的能力(这一点在删除里至关重要):

bool Find(const K& key)
{
    Node* cur = _root;            // 从根结点开始比较
    while (cur)                   // 一旦 cur 走到空,说明找完了
    {
        if (cur->_key < key)      // 当前值比 key 小,目标是右子树
        {
            cur = cur->_right;
        }
        else if (cur->_key > key) // 当前值比 key 大,目标是左子树
        {
            cur = cur->_left;
        }
        else                      // 相等,找到了
        {
            return true;
        }
    }
    return false;                 // 走到空仍未找到,说明不存在
}

每一次比较都淘汰掉当前结点的整个子树,候选范围从 N 缩到 N/2、再缩到 N/4……这正是"每次比较排除一半候选"。为什么它天生接近二分查找?因为 BST 的布局本身就把"比根小的"和"比根大的"各归一边,比较一次就能断定该去哪半边。所以查找走过的步数,最坏等于树的高度——这个"树的高度"将成为全篇文章的心脏,先记住它。

递归版

递归版几乎是把上面的话"翻译"过来的,但翻译过程必须遵守"递归三要素":结束条件(空指针)、调用自身、问题缩小。它的代码更短、更贴近数学定义,初学阶段强烈建议把它和迭代版对照着背:

bool FindR(const K& key)
{
    return _FindR(_root, key);    // 入口,传入真正的根
}
 
private:
bool _FindR(Node* root, const K& key)
{
    if (root == nullptr)          // 递归边界:走到空仍然没找到
    {
        return false;
    }
    if (root->_key < key)         // 当前值小,去右子树找
    {
        return _FindR(root->_right, key);
    }
    else if (root->_key > key)    // 当前值大,去左子树找
    {
        return _FindR(root->_left, key);
    }
    else                          // 相等,找到了
    {
        return true;
    }
}

注意递归版每次返回的都是"下一次递归调用的结果"这种写法叫尾递归(tail recursion)——即递归调用发生在函数的"最后一句话、返回之前",函数体后半段不再需要用到这次调用的返回值做任何加工。这样的形式,编译器能把"压栈调用"优化成"跳转复用同一栈帧",叫尾调用优化(tail call optimization),让递归的栈开销几乎归零、效率逼近迭代。你现在先不必深究优化细节,只要理解"递归=用调用栈帮你记路"就行。你可以理解为:非递归是自己甩指针走,递归是编译器帮你甩指针走,殊途同归。

顺带举个反例加深印象:上面求 Height 的递归就不是尾递归,因为它在 Height 返回之后还要做 +1 的比较与加法,最后一句不是"裸调用本身"。有经验的树算法,常常能靠把"递归调用放最后"把代码优化得更接近循环。

支持重复 key 的 BST:怎么"查找中序第一个"?

前面在讲重复值的时候留了个尾巴,现在来兑现。课件明确指出:如果一棵 BST 支持插入相等的值(比如 multiset),那么查找相等 key 时,一般期望返回"中序序列里的第一个(最靠前的)那个 x"。打个比方:名单里同姓同名的人有好几个,你得按某种确定顺序挑出一个"代表"来返回,否则每次查到哪个都不确定,程序行为就飘忽了。"中序第一个"就是个自洽、确定的规则。

实现思路也不难:我们仍然从根出发比较,但当恰好遇到 key == cur->_key 时,先把这个结点记下来当候选,然后不要急着返回,继续往左走——因为中序是先左后根,左子树里若还有相等的值,它一定比当前这个更靠前。如此一路找下去,候选被不断"刷新"成最左、最靠前的那一个,最后返回的就是中序第一个。下面给你一个可独立编译的允许重复值的迷你 BST,专注演示这种"找第一个"查找:

#include <iostream>
 
struct Node
{
    int _key;
    Node* _left = nullptr;
    Node* _right = nullptr;
    explicit Node(int k) : _key(k) {}
};
 
Node* g_root = nullptr;
 
// 支持重复值的插入:相等时统统一律往右走,保持一致性
void Insert(int key)
{
    if (g_root == nullptr)
    {
        g_root = new Node(key);
        return;
    }
    Node* parent = nullptr;
    Node* cur = g_root;
    while (cur)
    {
        parent = cur;
        if (cur->_key <= key)    // “小于等于”都往右:让相等的凑在一起且保持一致
            cur = cur->_right;
        else
            cur = cur->_left;
    }
    if (parent->_key <= key)
        parent->_right = new Node(key);
    else
        parent->_left = new Node(key);
}
 
// 返回中序第一个等于 key 的结点;找不到返回 nullptr
Node* FindFirst(int key)
{
    Node* cur = g_root;
    Node* result = nullptr;
    while (cur)
    {
        if (cur->_key < key)
            cur = cur->_right;
        else if (cur->_key > key)
            cur = cur->_left;
        else
        {
            result = cur;        // 先记下这个相等的结点
            cur = cur->_left;    // 继续向左,寻找更靠前的相等结点
        }
    }
    return result;
}
 
void InOrder(Node* root)
{
    if (!root) return;
    InOrder(root->_left);
    std::cout << root->_key << " ";
    InOrder(root->_right);
}
 
int main()
{
    int a[] = { 5, 3, 5, 7, 5, 3, 9 };
    for (int x : a) Insert(x);
    InOrder(g_root);             // 输出:3 3 5 5 5 7 9(值相等但有序,去重不去序)
    std::cout << std::endl;
 
    Node* f = FindFirst(5);
    std::cout << (f ? "找到中序第一个 5" : "未找到 5") << std::endl;
    return 0;                    // 释放逻辑未在 demo 展开,请勿用于生产,重点看查找思路
}

注意两件事:第一,插入时别忘"保持一致"——这里全部用 <= 往右走,那么所有相等的值都靠右堆放,中序序列里它们自然相邻且有序;第二,FindFirst 在相等后仍往左试探,这保证了在多值情形下返回的一定是"最左、最靠前"的那个,行为可复现。这就是 multiset/multimap 底层那种"多值 BST + 可确定的查找序"的小样。要细心的话,你还会发现:即便插入了重复值,中序遍历的结果依然是"非降序"的——这跟二叉排序树"中序有序"的核心承诺一脉相承。

插入:找到那个空位

插入是"为无处安放的新元素找到它命中注定的空位"。过程分成两大步:

  1. 树是空的:直接让根指向这个新结点,插入完成。
  2. 树不空:用查找的逻辑一路比较(大往右、小往左),同时死死记住当前结点的父节点(因为一个结点并不知道谁是自己的爸爸——嘛,单链表里也这样,孩子不知道自己爹是谁)。一路走到一个位置,发现它的 left 或 right 是空指针,这个空位就是新结点的家。最后比较一下 key 和父节点的大小,决定挂到父节点的左边还是右边。

其中"记住父节点"这一步极其关键,它是新手做插入时的第一道坎。很多同学会写成找到 cur 变空就 cur = new Node(key)——这是经典错误!cur 只是一个指针的拷贝,你把它重新赋值,改变的只是局部变量,树里根本没有任何指针指向这个新结点,结点直接"游离"在内存里,还造成了内存泄漏。正确做法是:cur 指向空位,用 parent 来挂接。

我把这一个坑再掰开揉碎讲一遍,因为它背后是 C 语言/C++ 指针的宇宙级第一课:参数传的是变量的"值拷贝"。你可以把 Node* cur 想成一个"写有你对象地址的小纸条"。cur = new Node 只是在这张纸条上换个地址写,可这张纸条原本是从 parent->_right(或 _left)那里抄来的地址——你改的只是"纸条副本",原始成员指针还指着旧地址(之前是空)。看看下面这个错误版本,脑子里把每一步的指针指向过一遍:

// 错误写法(示意,请勿运行):cur 只是地址的拷贝,改它改不到树
Node* cur = _root;
while (cur)
{
    if (cur->_key < key) cur = cur->_right;
    else if (cur->_key > key) cur = cur->_left;
    else return false;   // 重复,返回失败
}
cur = new Node(key);     // 只改局部的"纸条",树的对应孩子指针仍是原来的空指针!

所以正确的全局:要"挂上去",必须同时知道"挂到谁的哪个槽位",而那个槽位只能通过父节点来写。 这就是 parent 存在的唯一理由。写插入,请在每个转弯点先 parent = cur; 再 cur = cur->_xxx;,让 parent 永远停在 cur 的前任位置。

bool Insert(const K& key)
{
    // 情况一:树为空,直接让根指向新结点
    if (_root == nullptr)
    {
        _root = new Node(key);
        return true;
    }
 
    // 情况二:树不空,先找空位,同时记下父结点
    Node* parent = nullptr;       // 记录 cur 的父结点,用于最后挂接
    Node* cur = _root;
    while (cur)
    {
        if (cur->_key < key)      // 目标更大,往右走,先更新 parent 再前进
        {
            parent = cur;
            cur = cur->_right;
        }
        else if (cur->_key > key) // 目标更小,往左走
        {
            parent = cur;
            cur = cur->_left;
        }
        else                      // 相等:本实现不允许重复值,直接返回失败
        {
            return false;
        }
    }
 
    // 找到空位,创建新结点,挂到 parent 的正确一侧
    cur = new Node(key);
    if (parent->_key < key)       // 新值比父节点大,挂右边
    {
        parent->_right = cur;
    }
    else                          // 否则挂左边
    {
        parent->_left = cur;
    }
    return true;
}

我们用一组经典数据亲手验证一遍。课件给的数组是 {8, 3, 1, 10, 6, 4, 7, 14, 13},插入后再中序遍历,如果结果是从小到大的,说明整棵树搭对了。这里我特意把类定义也一并放在 main 前面,让你这是一份可以独立编译的验证(沿用上一节那颗"内存安全"的完整 BSTree,只是 main 换成只插不删):

#include <iostream>
 
// 假设上面那颗完整的 BSTree<int> 类定义在此(含 Insert/Find/Erase/InOrder/析构/拷贝)
// 为聚焦插入验证,此处用同接口的最简版亦可
int main()
{
    BSTree<int> tree;                        // 一棵只存 int 的搜索树
    int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };  // 课件经典数组
    for (int x : a)                          // 依次插入每个元素
    {
        tree.Insert(x);
    }
 
    tree.InOrder();                          // 中序遍历打印
    return 0;
}

运行结果:

1 3 4 6 7 8 10 13 14

输入乱糟糟的 {8,3,1,10,...},输出却是整整齐齐的递增序列——这就是"二叉排序树"这个名字的由来,也第一次兑现了它"我天生自带排序"的承诺。你盯着这行输出三秒钟:它其实已经暗示了 BST 的第一大应用——排序。

插入的递归版

既然前面说"二叉树相关算法先想递归",那插入和查找的递归版就是最好的演练场。递归插入的思路比循环更艺术:"把新结点插到以当前结点为根的子树里"。如果当前结点是空,说明这就是空位,new 一个返回;否则比较大小,决定插入到左子树还是右子树,并把结果挂回当前结点的孩子上:

bool InsertR(const K& key)
{
    return _InsertR(_root, key);   // 入口,注意这里要传引用
}
 
private:
bool _InsertR(Node*& root, const K& key)   // root 是引用,才能原地修改指针
{
    if (root == nullptr)                   // 递归边界:到达空位
    {
        root = new Node(key);              // 因为 root 是引用,直接挂接成功
        return true;
    }
    if (root->_key < key)                  // 目标更大,往右子树插
    {
        return _InsertR(root->_right, key);
    }
    else if (root->_key > key)             // 目标更小,往左子树插
    {
        return _InsertR(root->_left, key);
    }
    else                                   // 重复值,返回失败
    {
        return false;
    }
}

这里有个"智取"的设计,务必看懂:参数 Node*& root 是指针的引用。正因为传的是引用,当 root 是空指针时,root = new Node(key) 修改的是调用者那个指针本身,而不是它的拷贝。于是递归版不再需要显式的 parent、也不需要事后判断挂左边还是右边——空位处就是"该挂新结点的那个槽位",原地替换即可。对比循环版到处小心 parent,递归版干净得像做了一道消消乐。这也是"引用传参抛开拷贝、直接操作原对象"的一个绝佳示范。

这里其实藏着一个很微妙的"闭环"逻辑:_InsertR(_root->_right, key) 这一整句话的职责是"确保 key 被正确插入到这棵右子树中,并让右子树依然是一棵合格的 BST"。当子树为空时,新结点成为右孩子;当子树非空时,进一步下沉。函数两层栈返回时,新结点已经被安然窝进整棵树深处,这就是"分而治之"在插入里的又一次现身。

删除的三种情况:直删、顶替、还是替换?

查找和插入都是把树"喂饱",真正让二叉搜索树"成熟"、也是本篇文章最核心、最考验功底的操作,是删除。因为在 BST 里,删除不能只想着"把结点摘掉"。每摘一个结点,都必须在它后代里选一个"接盘侠"补上来,否则整棵树就散架了、不再满足"左小右大"的性质。

我们把要删除的结点记作 N,分三种典型情况(课件把第一种细拆成左右都空,但实际上它完全可以被并入后两种,逻辑上更统一,我们按三种来掌握):

先说结论,再逐个拆解:

  • 删除的三种情况
    • 情况①:N 没有孩子(叶子结点) —— 直接删。父节点对应孩子指针置空即可。
    • 情况②:N 只有一个孩子 —— 让这个唯一的孩子顶上来,父节点绕过 N 直接指向它,然后删 N。
    • 情况③:N 有两个孩子 —— 不能直接删,必须用替换法:用 N 的左子树最大结点,或右子树最小结点来"顶替" N 的值,再转而去删那个替身。

情况①本质上就是"没有孩子"——父节点把孩子指针置空就完事了。而"没有孩子"可以当成情况②的一个退化:它的"唯一孩子"就是空指针,把空指针挂给父节点,效果一模一样。所以情况①完全可以合并进情况②,代码上也更优雅。这也是在很多教材里,删除只讲"单孩子/双孩子"两派的原因。

情况②:单孩子的"顶替"

当 N 只有一个孩子时,思路是:绕过 N,让父节点直接连到 N 的唯一孩子上。打个比方,就像办公室里中间那个人离职了,他的直属领导直接对接他的唯一下属,中间人从汇报链上消失。

这里有一个必须处理的"边界死角":如果 N 恰好就是根结点(此时 parent 为空),我们特判一下,直接让 _root 指向 N 的唯一孩子即可。否则,就得判断 N 是父节点的左孩子还是右孩子,再让对应的指针指向 N 的孩子:

bool Erase(const K& key)
{
    Node* parent = nullptr;      // 记录当前结点的父节点
    Node* cur = _root;           // 从根开始找要删的结点
    while (cur)
    {
        if (cur->_key < key)     // 目标更大,向右找,并同步更新 parent
        {
            parent = cur;
            cur = cur->_right;
        }
        else if (cur->_key > key) // 目标更小,向左找
        {
            parent = cur;
            cur = cur->_left;
        }
        else                      // 找到了,进入删除逻辑
        {
            // ====== 情况①②:N 最多一个孩子,采用"顶替法" ======
            if (cur->_left == nullptr)      // 左孩子为空:用右孩子顶上来
            {
                if (parent == nullptr)      // N 是根:直接把根换成右孩子
                {
                    _root = cur->_right;
                }
                else if (parent->_left == cur)   // N 是父节点的左孩子
                {
                    parent->_left = cur->_right;
                }
                else                            // N 是父节点的右孩子
                {
                    parent->_right = cur->_right;
                }
                delete cur;                 // 释放 N 的内存
                return true;
            }
            else if (cur->_right == nullptr) // 右孩子为空:用左孩子顶上来
            {
                if (parent == nullptr)      // N 是根:直接把根换成左孩子
                {
                    _root = cur->_left;
                }
                else if (parent->_left == cur)
                {
                    parent->_left = cur->_left;
                }
                else
                {
                    parent->_right = cur->_left;
                }
                delete cur;                 // 释放 N 的内存
                return true;
            }
            else
            {
                // ====== 情况③:N 有两个孩子,采用"替换法"删除 ======
                // 思路:找右子树最小结点(或左子树最大结点)顶替 N,再删掉替身
                Node* rightMinP = cur;                 // 替身的父节点,必须先初始化为 cur
                Node* rightMin = cur->_right;          // 从右孩子的根开始找右子树最小
                while (rightMin->_left)                // 一路向左走到底,即右子树最小值
                {
                    rightMinP = rightMin;
                    rightMin = rightMin->_left;
                }
 
                cur->_key = rightMin->_key;            // 第一步:把替身的值赋给 N
 
                // 第二步:删除替身(它最多只有一个右孩子,属于前面两种情况)
                if (rightMinP->_left == rightMin)      // 替身是父节点的左孩子
                {
                    rightMinP->_left = rightMin->_right;
                }
                else                                   // 替身是父节点的右孩子(右子树根即最小)
                {
                    rightMinP->_right = rightMin->_right;
                }
                delete rightMin;                       // 释放替身的内存
                return true;
            }
        }
    }
    return false;                // 树里没有这个 key,删除失败
}

这里有个新手必踩的坑必须点破:为什么删除也必须维护 parent? 因为删除的本质是"修改父节点指向孩子的那根指针"。如果你只拿到了 cur(要删的结点),你根本不知道应该去改谁的 left/right。忘记维护 parent,是删除 bug 的第一大来源——你删掉结点却改不了父节点的线,树立刻分成两截。这和插入里"必须用 parent 挂接"是同一个道理的两个版本,请你把"改树结构必先拿到父指针"当成一句警句背下来。

情况③:双孩子的"替换法"

当 N 两个孩子都健在时,"顶替法"失灵了——因为 N 的空位一旦让其中一个孩子顶上来,另一个孩子就无处安放了。怎么办?核心思想是替换法(replacement):

找 N 右子树中最小的结点(顺着右子树一直往左走到底),或者找 N 左子树中最大的结点(顺着左子树一直往右走到底)。这两个结点中任意一个,放到 N 的位置,整棵树都依然满足二叉搜索树的性质。

为什么偏偏是这两个"端点值"?因为右子树中最小结点,是右子树里所有值的最小下界,它仍然大于左子树的所有值、小于等于右子树其余值——天然符合"左小右大"。左子树最大结点同理。它们两个是"填补 N 之后不会破坏有序性"的最优人选。你可以理解为:只有这两个"边界哨兵",能保证顶替后整棵树的"左小右大"规则不破功。 换个角度想:中序遍历里,N 的前驱(左子树最大)和后继(右子树最小)恰好是唯二能"替位而不乱序"的接盘侠——这正是二叉搜索树的中序升序性质在删除里的又一次兑现。

替换的具体操作不是"把整棵子树搬过去",而是一个精巧的两步:

  1. 交换值:把 N 的值改成替身的值(这里只改 key,不动指针)。
  2. 删除替身:既然替身已经"退休"到 N 的位置,那个原位置就空了。而因为替身是"右子树最左"(或"左子树最右"),它最多只有一个右孩子(或左孩子)——也就是说,替身只会落入情况①②,可以直接用上节的"顶替法"删掉它。

于是"双孩子"这个最难的删除,被降维成了"单孩子"这个最简单的情况。这里的妙处在于"值替换、指针不动":对用户来说,树的形态里删掉的是那个难缠的双孩子,实际物理上删除的却是个好对付的叶子或单枝。这是经典的"用逻辑复杂度换取结构简单度"的智慧。

这里还有一个著名到值得单列出来的坑:当右子树的最小结点恰好就是 N 的右孩子时——也就是 cur->_right 本身没有左孩子。此时做"顺左走到底"的循环一趟就走完,rightMin 就是 cur->_right,而 rightMinP 必须初始化成 cur 而不是 nullptr。因为替身从父节点的右边"冒"出来(父节点就是 N 自己),删除替身时挂接逻辑走的是"父节点的右孩子"分支。如果 rightMinP 一开始是 nullptr,这一句 rightMinP->_right = rightMin->_right; 就会对空指针解引用,直接崩给你看。这是课件里特别强调"一定要把 cur 给 rightMinP"的原因所在。

else     // 情况③:N 有两个孩子,替换法删除
{
    Node* rightMinP = cur;   // 替身的父节点,这里必须先初始化为 cur
    Node* rightMin = cur->_right;  // 从右孩子的根开始,去找右子树的最小结点
    while (rightMin->_left)          // 一路向左走到底,就是右子树最小结点
    {
        rightMinP = rightMin;
        rightMin = rightMin->_left;
    }
 
    cur->_key = rightMin->_key;      // 第一步:把最小结点的值赋给 N(替换)
 
    // 第二步:删除最小结点(它最多只有一个右孩子,属于情况①②)
    if (rightMinP->_left == rightMin)   // 最小结点是父节点的左孩子
    {
        rightMinP->_left = rightMin->_right;   // 用它的右孩子顶上
    }
    else                                  // 最小结点是父节点的右孩子(即它就是右孩子本身)
    {
        rightMinP->_right = rightMin->_right;
    }
    delete rightMin;                // 释放最小结点的内存
    return true;
}

请仔细看最后那个 if/else:为什么判断 rightMinP->_left == rightMin 而不是放心大胆地用 rightMinP->_left?因为正常走循环时,最小结点一定来自父节点的左链,是左孩子;但有一种例外——右子树本身就没有左孩子时,最小结点就是根,它位于 cur(此时 cur 就是 rightMinP)的右孩子位置上。两种来源不同,挂接的方向就不同,所以必须分别判断。这个细节,是"删除要看父指针指向哪一侧"的又一次现身。

对称的另一半:左子树最大替换法

我们上面一直用"右子树最小"当替身,这是对称的、完全可以代换的。想看另一面,只需把方向全部反转:找到 cur->_left 一路向右走到头,得到左子树最大结点 leftMax,把它的值赋给 cur,然后删除 leftMax。删除 leftMax 时同样要关心 "左子树根即最大" 的边界:

else     // 情况③的对称实现:用左子树最大结点替换(与右最小版原理完全相同)
{
    Node* leftMaxP = cur;             // 替身父节点,先记为 cur
    Node* leftMax = cur->_left;       // 从左孩子的根开始,向右找左子树最大
    while (leftMax->_right)           // 一路向右走到底,就是左子树最大结点
    {
        leftMaxP = leftMax;
        leftMax = leftMax->_right;
    }
 
    cur->_key = leftMax->_key;        // 值替换
 
    // 删除替身:最大结点最多只有一个左孩子
    if (leftMaxP->_right == leftMax)
        leftMaxP->_right = leftMax->_left;   // 正常情况下它来自右链
    else
        leftMaxP->_left = leftMax->_left;    // 左子树根本身就是最大,走左分支
    delete leftMax;
    return true;
}

两个版本在文献和题库里都会被考到,你只要吃透一个,另一个转个方向就能秒懂。选择哪个主要看习惯——只要保证"永不改 key、永远删替身、永远在删除时更新父指针指向替身的孩子"这条纲领不错,哪个都对。

用一次完整实验验证删除

光讲不练等于白讲。我们删掉 {8,3,1,10,6,4,7,14,13} 里最有代表性的几个结点,看中序遍历是否始终保持有序。这里依然沿用上一节那份完整的 BSTree 类,main 聚焦删除:

#include <iostream>
 
// 假设上面那颗完整 BSTree<int> 类定义在此(含 Insert/Find/Erase/InOrder/析构/拷贝)
int main()
{
    BSTree<int> tree;
    int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
    for (int x : a)
    {
        tree.Insert(x);
    }
 
    tree.InOrder();               // 初始:1 3 4 6 7 8 10 13 14
 
    tree.Erase(1);                // 叶子结点,直接删
    tree.InOrder();               // 3 4 6 7 8 10 13 14
 
    tree.Erase(3);                // 单孩子结点(它的右孩子顶上来)
    tree.InOrder();               // 4 6 7 8 10 13 14
 
    tree.Erase(8);                // 双孩子结点,用右子树最小结点替换
    tree.InOrder();               // 观察:8 被 10 替换后,结果仍有序
 
    std::cout << (tree.Find(8) ? "8 存在" : "8 已删除") << std::endl;
 
    return 0;
}

Erase(8) 时,8 有两个孩子,右子树 {10,14,13} 里的最小结点是 10,于是用 10 替换 8,再删掉原来的 10——最终中序输出依旧是从小到大。这就是替换法的全部意义:用一次"必落入最简单情况"的间接删除,换来全盘有序性的不破。 请你重点观察并模拟"Erase(8)"这一个分支里,rightMin 与 rightMinP 各自指到哪里,把"右最小就是右孩子本身"的边界在心里过一次。

中序遍历即有序:查找你还能拿这棵树干什么

到这儿,BST 的四板斧——查、插、删、遍历——你已经全部到手。现在是时候把线索收拢,看看这一小段有序性,能顺势换成哪三样实打实的东西。

第一,排序。 把一堆数插进 BST,中序遍历一次,出来就是有序的。这本质上是种"树排序"。但注意,它的时间并不总是 O(N log N),如果数据已经几乎有序,插入会退化成一条链,反而退化到 O(N²)——这是它不比快排/归并普适的原因。打个比方,树排序像是"边放边让孩子自己排队",但如果来的队伍本来已经排好,它反而会歪成一根线,排队的效率就崩了。

第二,去重。 我们的 Insert 遇到相等的 key 直接返回 false 不插入,天然就把重复值挡在外面。往树里喂一堆含重复的数据,最后中序出来就是"去重且有序"的集合。这几乎是 set 容器的教科书原理。

第三,查找的"在不在"判断。 无论是车牌号在白名单里吗、单词拼写对吗、ID 出现过吗——一切的"存在性查询",都可以抽象成一口气 Find。你只需要维护一个"合法集合",然后每来一个目标就 Find 一下。

这三个能力,正好引出 BST 最重要的两类现实分工:key 场景和 key/value 场景。我们展开聊聊,因为它们直接对应到 STL 的一整组容器。

应用场景(一):key 搜索——"它到底存不存在"

只关心"某个 key 在不在",这是最简单的搜索模型,叫 key 搜索场景。树里只需要存 key 本身,一套 Find 就能判断存在性。这类树支持增/删/查,但不支持修改——为什么?因为一旦改了某个结点的 key,搜索树"左小右大"的结构可能就塌了,等于把一个有序书架上的书悄悄换了位置。这是原则红线,不是疏漏。

现实里这两个例子刚好就能对号入座:

  • 无人值守车库的"白名单":物业把买了车位的业主车牌录进系统。车开到门口,摄像头扫牌,拿着车牌去树里 Find——在,就抬杆放行;不在,提示"非本小区车辆"。它只关心"在不在",典型的 key 场景。
  • 英文拼写检查器:把整本词库的单词都插进树。扫描文章时,每读到一个单词就 Find 一次,找不到就画上波浪线标红,提示"拼写可能错了"。同样只关心"在不在"。

顺手把上一节的中间件补全成一个"能编译、能跑"的最小验证,把这些接口串成完整的一段。这份代码假设你手里有我们那颗"内存安全"的完整 BSTree 类,main 里演示 key 场景的用法:

#include <iostream>
 
// 假设上面那颗完整 BSTree<int> 类定义在此(含 Insert/Find/Erase/InOrder/析构/拷贝)
int main()
{
    BSTree<int> tree;
    for (int x : { 9, 5, 12, 3, 7, 11, 15 })
    {
        tree.Insert(x);            // 乱序插入
    }
    tree.InOrder();                // 输出:3 5 7 9 11 12 15,天然有序
 
    std::cout << (tree.Find(7) ? "7 在" : "7 不在") << std::endl;     // 在
    std::cout << (tree.Find(8) ? "8 在" : "8 不在") << std::endl;     // 不在
 
    tree.Erase(7);                 // 删除单孩子结点
    tree.Erase(9);                 // 删除双孩子结点(右子树最小 11 替换)
    tree.InOrder();                // 输出:3 5 11 12 15
    return 0;
}

这套 key 版的 BSTree 接口(Insert/Find/Erase/InOrder),就是你理解 STL 里 set 的那把钥匙——set 底层正是这样一棵"只允许唯一 key"的二叉搜索树。顺带一提,set 的 count(key) 方法名暗示的正是这种"在不在"的语义,几乎就是从这种树直接长出来的。

应用场景(二):key/value 搜索——字典、计时、计数

key 场合只够回答"在不在"。可真实世界的查询经常要"查到了还得拿点啥":查英文想要中文翻译、查车牌要算停了多少分钟、查一个词想知道它出现了几次。这就是 key/value 搜索场景——每个 key 都挂着一个 value,value 可以是任意类型。

结点的定义升级为"key + value + 左右指针":

template<class K, class V>
struct BSTNode
{
    K _key;                     // 关键码(用于比较、决定树结构)
    V _value;                   // 与 key 绑定的值(可以任意类型)
    BSTNode<K, V>* _left;       // 左孩子
    BSTNode<K, V>* _right;      // 右孩子
 
    BSTNode(const K& key, const V& value)
        : _key(key)
        , _value(value)
        , _left(nullptr)
        , _right(nullptr)
    {
    }
};

请注意一个思想,它就是标题里"键值分离"所指的东西:结构由 key 决定,value 只是"乘客"。 树的选择、比较、增删查,仍然只看 key(按 key 走左小右大的规则);value 只是"搭着 key 的车"一起被查找、被取回。你可以把 key 看成"工号"、value 看成"工牌上写的姓名职务"——排队的顺序、留在队伍里的位置,都是按工号定的,工牌内容随便填。它允许修改 value(改翻译、改次数都不会动树结构),但依然不能改 key——理由和前面一样,改 key 会破坏排序性质。这一点课件里讲得非常明确:增删查改以 key 为准,能改 value 不能改 key。

请看三个经典场景如何落地:

  • 中英互译字典:树里存 (英文, 中文),输入 left 就查到 左边。Find 返回结点,就能拿到 value。
  • 商场计时车库:入口扫牌记下 (车牌, 入场时间),出口再扫一次 Find 出入场时间,用"当前时间 - 入场时间"算出停车时长,再按费率算钱。value 在两次查询之间被反复读取。
  • 词频统计:读一个单词,先 Find 一下——第一次出现就插入 (词, 1),已经出现过就把查到的结点 value 加一。反复几次下来,value 里就是每个词的出现次数。

把字典和词频统计写成能立即运行的完整代码。这里的 BSTreeKV 是 key/value 版,我给它补上析构函数(后序释放),让整段既覆盖两大场景、又不会内存泄漏:

#include <iostream>
#include <string>
using namespace std;
 
class BSTreeKV   // key/value 版搜索树
{
    struct Node
    {
        string _key;    // 关键码:这里用 string
        int _value;     // 与 key 绑定的值:这里用 int
        Node* _left;
        Node* _right;
        Node(const string& key, int value)
            : _key(key), _value(value), _left(nullptr), _right(nullptr) {}
    };
    Node* _root = nullptr;
 
public:
    ~BSTreeKV() { _Destroy(_root); }     // 析构:后序释放整棵树,避免内存泄漏
 
    bool Insert(const string& key, int value)
    {
        if (_root == nullptr)              // 空树直接建根
        {
            _root = new Node(key, value);
            return true;
        }
        Node* parent = nullptr;
        Node* cur = _root;
        while (cur)
        {
            if (cur->_key < key)           // 目标更大,往右
            {
                parent = cur;
                cur = cur->_right;
            }
            else if (cur->_key > key)      // 目标更小,往左
            {
                parent = cur;
                cur = cur->_left;
            }
            else                           // 重复 key 不允许插入
            {
                return false;
            }
        }
        cur = new Node(key, value);        // 找到空位后挂接
        if (parent->_key < key)
        {
            parent->_right = cur;
        }
        else
        {
            parent->_left = cur;
        }
        return true;
    }
 
    Node* Find(const string& key)          // 修改:返回结点而非 bool,好用 value
    {
        Node* cur = _root;
        while (cur)
        {
            if (cur->_key < key)
            {
                cur = cur->_right;
            }
            else if (cur->_key > key)
            {
                cur = cur->_left;
            }
            else
            {
                return cur;
            }
        }
        return nullptr;
    }
 
    void InOrder() { _InOrder(_root); cout << endl; }
 
private:
    void _InOrder(Node* root)
    {
        if (root == nullptr) return;
        _InOrder(root->_left);
        cout << root->_key << ":" << root->_value << endl;  // 打印 key 与 value
        _InOrder(root->_right);
    }
 
    void _Destroy(Node* root)              // 后序释放整棵树
    {
        if (root == nullptr) return;
        _Destroy(root->_left);
        _Destroy(root->_right);
        delete root;
    }
};
 
int main()
{
    // ---- 场景1:词频统计 ----
    BSTreeKV countTree;
    string words[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
    for (const string& w : words)
    {
        auto ret = countTree.Find(w);       // 先查这个单词是否出现过
        if (ret == nullptr)                 // 第一次出现:插入(词, 1)
        {
            countTree.Insert(w, 1);
        }
        else                                // 出现过:次数加一
        {
            ret->_value++;
        }
    }
    cout << "单词出现次数:" << endl;
    countTree.InOrder();                    // 每个词连同次数有序输出
 
    cout << endl;
    // ---- 场景2:简易英中词典 ----
    BSTreeKV dict;
    dict.Insert("left", 0);
    dict.Insert("right", 0);
    string word = "left";
    auto it = dict.Find(word);
    if (it != nullptr)
    {
        std::cout << "查出 left 并取其 value=" << it->_value << endl;
    }
    return 0;
}

看到这里你应该已经彻底明白"键值分离"的涵义了:key 负责"你是谁、排在哪",value 负责"你身上带了什么";树的结构只认 key。" key 场景,树只回答"Yes/No";key/value 场景,树不仅回答"在不在",还把你绑在 key 身上的数据原样递给你。而这一切的底层,都是同一棵二叉搜索树,只是结点的"行囊"变大了而已。而这"一个只存 key、一个存 key+value 两棵不同结点的树",正是 STL 里 set 与 map 的结构分离源头:set 的结点就是想上面那棵 key 树,map 的结点则是 key/value 树。顺带一提,在真正的 STL map 里,结点存的是一个 pair<const K, V>(key 带上 const 防止被改动)——这又一次呼应了"不能改 key"的设计。

复杂度分析:平衡与退化

前面每个操作,我们都在反复强调一个词——树的高度。现在是时候把账算清楚了:二叉搜索树的增删查,复杂度到底是多少?

答案看起来有点反直觉:平均 O(log N),最坏 O(N)。为什么不是稳定的 O(log N)?这取决于树长得是否"匀称"。

  • 最优情况:如果插入的数据比较"居中",树会长成(或接近)一棵完全二叉树,高度约等于 log₂N。此时查找一次最多走 log₂N 步,比线性查找快得多。
  • 最坏情况:如果插入的数据本来就有序(比如按 1,2,3,4... 依次插入),那么每次新结点都挂在当前最右侧,树直接退化成一条单支树(形态上更像链表),高度变成 N。此时查找和线性查找一样慢,之前的全部优化一笔勾销。

检索下你的基本功:高度是"根到最远叶子"的路径长度。满二叉时每高一档,结点数近似翻倍,所以结点数 N 和高度 h 之间是 N ≈ 2^h,反解出 h ≈ log₂N;退化成链时,结点排成一串,h = N。正因为最坏能到 O(N),所以综合而言,普通二叉搜索树增删查的时间复杂度只能保守地写 O(N)——注意,这是"最坏情况"的记法,并不代表平均表现也这么差。

这里我把"log 直觉"再喂一口:log₂N 的意思是"N 每翻一倍,步数才加 1"。N=1024 时 ≈10 步;N=100 万时 ≈20 步。这就是为什么"平衡的 BST 查找几乎感觉不到慢",也是为什么"退化到 O(N)"那么恐怖——同样的 100 万条数据,平衡树 20 步搞定,退化之链要一百万步。请允许我再敲一遍这个数字对比,因为它是本课的情感主线:平衡与退化之间,隔着 20 与 1,000,000 的天堑。

顺带区分容易混淆的两兄弟概念:满二叉树(full) 指每个结点要么是叶子、要么两个孩子都齐全;完全二叉树(complete) 指除最后一层外都填满、最后一层从左往右连续填充。两者都是"接近 log₂N 高度"的一种绑定条件。课件讲的"最优情况下接近完全二叉树"——意思是只要树长得够"满/匀",高度就走不到那个 log₂N 附近。你只要抓住"高与结点数成 log 关系"这个核心,进阶再较真满/完全的定义也不迟。

这还不是全貌。课件还专门点名了一个对比对象:二分查找(binary search)。二分查找在有序数组里也能做到 O(log N),那它为什么赢不了 BST?因为它有两大致命缺陷:

  1. 必须存在支持下标随机访问且有序的结构(典型就是数组)。数组已经够苛刻了。
  2. 插入和删除代价极高。为了维持"有序",在数组中间插一个数,得把后面所有元素整体后移,删除同理——一次操作平均 O(N),这和 BST 的灵活相比是降维打击。

也就是说:BST 用"指针 + 递归"换来了"增删查三合一都灵活",而二分查找只有"查"这一个优点,插还要付出挪动数据的惨痛代价。这一对比,立刻显出了"带指针的平衡树"的宝贵价值——这是课件反复强调的核心论断。

那它为什么还不是终点:退化成链的深渊

现在,把最坏情况拉近仔细看。上一节说的"按有序数据插入导致退化"不是理论上的天方夜谭——它太容易触发了。考个试、写个系统,用户数据稍微有序,你的 BST 就悄然变成了一条链表。来看这个触目惊心的实验:同一组数据,按两种顺序插入,树的形态天差地别:

#include <iostream>
 
// 假设上面那颗完整 BSTree<int> 类定义在此(含 Insert/Find/Erase/InOrder/析构/拷贝)
int main()
{
    BSTree<int> balanced;
    int mid[] = { 50, 25, 75, 12, 37, 62, 88 };   // 尽量居中的插入顺序
    for (int x : mid)
    {
        balanced.Insert(x);
    }
 
    BSTree<int> degenerate;
    int ordered[] = { 12, 25, 37, 50, 62, 75, 88 }; // 有序插入
    for (int x : ordered)
    {
        degenerate.Insert(x);
    }
 
    std::cout << "有序插入后中序遍历(仍是升序,但树早已畸形成链):" << std::endl;
    degenerate.InOrder();
    return 0;
}

两组树,中序打印出来都是那片熟悉的升序序列——但这是假象。中序结果一样,只是因为"二叉搜索树天然有序";而有序插入的那棵,内部早就是一条单调的右链了。查找 88,平衡树只需 log₂N≈3 步,退化的链却要走 7 步。当数据足够多(比如一百万个),平衡与退化的差距,是从"约 20 步"到"一百万步"的天壤之别。这正是"退化的深渊"四个字的沉重所在。

这也解释了一个新手最常问的问题:"BST 都这样了,凭什么 STL 的 map/set 还那么快?"——因为 STL 里的 map/set,实现时根本不会直接用一棵普通裸二叉搜索树,而是动用了"会自我纠偏"的平衡二叉搜索树,在每次插删后自动把高度约束回 O(log N) 附近。裸 BST 只是它们的理论原型。

为何需要平衡树:推开 AVL 与红黑树的门

我们已然知道:BST 的潜力巨大、现实却残酷——一旦退化,一切归零。于是所有问题的症结归结为一句:能不能让树在增删之后,一直保持(近乎)平衡,高度始终是 O(log N)? 能。这正是平衡二叉搜索树存在的全部理由,也是二叉树从"好懂"走向"高级"的那一道门槛。

平衡树领域最广为人知的两员大将,正是下一课的主角:

  • AVL 树:以发明者 Adelson-Velsky 和 Landis 命名。它给每个结点配了一个"平衡因子"(左右子树高度之差),严格约束平衡因子只能取 -1、0、+1,一旦越界就通过**旋转(rotation)**把它扳回平衡。它的树高被锁在 O(log N),但为了这份严格,每次插入删除都可能触发链式旋转调整,代价偏高。
  • 红黑树:给每个结点涂上红或黑色,通过几条染色规则来"近似平衡"——它不要求左右子树严格等高,只保证没有任何一条路径会比其他路径长出两倍以上,因此同样是 O(log N) 的高度,但旋转次数和调整开销明显少于 AVL。正是这种"够用的平衡 + 更低的代价",让红黑树成了 STL 里 map/multimap/set/multiset 的最终实现根基。

你可以这样一锤定音地总结它们的定位:AVL 是"追求极致的平衡,宁可多付旋转代价"的严苛派;红黑树是"只要够用的平衡,省下每一次多余旋转"的务实派。实际的标准库几乎都选了务实派。而 C++ 里更直白的理由是——你学的这份 BSTree,只要再套上平衡因子与旋转这两个补丁,就是 AVL;换个角度套上红黑染色规则,就是红黑树。裸 BST 是它们共同的地基,你的理解深度,决定了未来能否无缝接到那两棵更复杂的大树上去。这门"乘胜追击"的进化,正好就是我们下一节课程的起点。

把这一课收进心里

我们这一路,从给普通二叉树加一条"左小右大"的规则,见证了一棵商品的二叉搜索树诞生:用递归道破它的性质,用循环与递归两套招式拿捏查找,小心翼翼维护 parent 指针完成插入,再层层拆解删除的三种情况——叶子直接删、单孩子顶上去、双孩子用左子树最大或右子树最小结点来替换——最后点破中序遍历即有序的天机,用它落定排序、去重、存在性查询三大应用,并把 key 与 key/value 两类场景和 STL 的 set/map 家族一一对上号。我们还顺手把树的"善终"问题处理干净:析构后序释放、深拷贝构造、拷贝交换式赋值,让 BST 从"会漏内存的新手玩具"升级成"内存安全的 C++ 类"。

然后我们认真算了一笔账:平均 O(log N) 很美好,可一旦数据有序、树退化成长链,一切优化瞬间归零,最坏跌回 O(N)。这深渊的警示,也正是下一群主角登场的理由——AVL 树与红黑树,用旋转与染色,把高度永远钉在 O(log N)。所以现在手头这棵会撒娇、会退化、还惦记着平衡的老朋友,本质上就是你要走向更复杂数据结构的一张老地图——请把它收好。

在合上这一课之前,把下面几道小问题在心里过一遍,能不打磕绊说出答案,说明你已经真正拿到了这棵树的缰绳:

  1. 为什么 BST 的性质必须说"左子树所有结点都小于根",而不能只说"左孩子小于根"?
  2. 插入时如果不维护 parent,直接 cur = new Node(key) 会发生什么?能画出错误的效果吗?
  3. 删除一个双孩子结点时,为什么可以用"右子树最小"或"左子树最大"来替换?替换后为什么转而去删"替身"就简单了?
  4. rightMinP 为什么在删除双孩子时必须先初始化为 cur 而不是 nullptr?
  5. 用同一组数据、按"居中顺序"和"有序顺序"分别插入,为什么中序结果一样、查找性能却天差地别?
  6. key 与 key/value 两种场景,分别对应 STL 的哪组容器?为什么 key 场景不能改 key、key/value 场景只能改 value?

参考答案与详解

第 1 题:为什么必须说"左子树所有结点都小于根",而不能只说"左孩子小于根"?

因为 BST 的查找走的是"比较一次、扔掉一整侧候选"的二分思路,它要求从根一路走下去,只要在某一步往左拐了,就保证这棵左子树里不可能再藏着"该往右走"的元素。如果只保证"左孩子小于根",那就拦不住"左子树的孙结点大于根"这类钻空子的数据。举个例子:根是 8,左孩子是 3,假设 3 的右孩子是 9。只看相邻的两层,3 < 8 满足"左孩子小于根";但 9 落在 8 的左子树里却又大于 8,于是你去查 9 时,先跟 8 比完就一头扎进左子树,再也碰不到 9 了——它成了一棵"永远查不到的孤儿",整棵树就是一株假 BST。所以"所有"二字不是措辞较真,而是 BST 高效查找能成立的命根子(正文里 isBST 的范围封锁法正是为了严格守这条规则)。

第 2 题:插入时不维护 parent、直接 cur = new Node(key) 会发生什么?

会发生两件事:树没变,内存却漏了。原因在指针的"值拷贝"本质——Node* cur 只是一个"写着某对象地址的小纸条",你 cur = new Node(key) 只是在这张纸条副本上换了个新地址写,可它原本是从 parent->_right(或 _left)那里抄来的;你改的是局部变量副本,树里真正那根指针依然指着原来的空位。于是新结点从刚 new 出来就再也没有任何指针指向它,成为一块"游离"在堆上、谁也管不到的孤儿内存——它既不会出现在树的任何遍历结果里(树结构没变),又永远无法被释放(内存泄漏)。正确做法必须两手抓:先用 parent 记住"挂在谁的槽位上",等 cur 走空后,用 parent->_left(或 _right)去真正挂接。

第 3 题:删除双孩子结点时为什么可以用"右子树最小"或"左子树最大"来替换?替换后为什么转而去删"替身"就简单了?

因为这两个"端点值"恰好是中序遍历里 N 的后继与前驱,把它们放到 N 的位置,整棵树依然满足"左小右大",不破坏有序性:

  • 右子树最小结点 R ≥ 左子树所有结点的值,且 ≤ 右子树其余结点的值——它比左子树全体都大、又比右子树其余都小,占住 N 的位置后规则不破;
  • 左子树最大结点同理。

而替换后之所以"简单",是因为替身 R 是"子树的最端点":右子树最小结点必然没有左孩子(最多有一个右孩子),左子树最大结点必然没有右孩子(最多有一个左孩子)。也就是说替身只会落入删除的"叶子/单孩子"这两种最简单的情况,可以直接用上节的顶替法删掉。于是最难的"双孩子删除"被降维成了"单孩子删除",这就是"值替换、再删替身"的价值。

第 4 题:删除双孩子时 rightMinP 为什么必须先初始化为 cur,而不是 nullptr?

因为存在一个边界情况:当 cur->_right 本身没有左孩子时,右子树最小结点就是 cur 的右孩子自己。此时"顺左走到底"的循环一趟都不走,rightMinP 若是 nullptr 就不会被更新。可后面删除替身时,要判断替身是父节点的左孩子还是右孩子并改写对应指针——这时替身的父节点其实是 N(cur)自己,替身位于父节点的右孩子位置上,必须走 rightMinP->_right = rightMin->_right 这句。若 rightMinP 还是 nullptr,这行就是对空指针解引用,直接崩溃。所以初始化 rightMinP = cur 就是为了兜住"右子树根本就是最小结点"这个分支,确保删除挂接时总能拿到合法的父节点。

第 5 题:同一组数据,按"居中顺序"和"有序顺序"分别插入,为什么中序结果一样、查找性能却天差地别?

中序结果一样是必然的:任何 BST 的中序遍历都输出同一个升序序列,这个结论只由"树里存了哪些值"决定,与树的具体形态无关——二叉搜索树的性质天然保证了"左 < 根 < 右"层层成立。但树的"身材"天差地别:居中顺序插入会长成接近完全二叉树的矮胖结构,高 ≈ log₂N;有序插入则让每个新结点都挂到最外侧,树退化成一条单调长链,高 = N。查找的步数是"最多走树高那么多步",所以前者要 log 步就能找到目标,后者可能要从头走到黑。两者都满足 BST 定义,中序也都能输出升序,但一个快如闪电、一个慢如蜗牛——这正是"不能只看中序结果,还要看树高"的提醒。

第 6 题:key 与 key/value 两种场景,分别对应 STL 的哪组容器?为什么 key 场景不能改 key、key/value 场景只能改 value?

  • key 场景对应 set(允许重复则对应 multiset)——树里只存 key,只管"在不在";
  • key/value 场景对应 map(允许重复则对应 multimap)——树里存 pair<const K, V>,查到这个 key 就能取回它绑定的 value。

两者共同的红线都是不能改 key,因为 key 决定了结点在"左小右大"排序体系里的位置。一旦改了 key,这个结点可能已经不再位于它该在的位置上,整棵树的查找/增删路线全部错乱(甚至直接查不到它),BST 的性质就被破坏了。而 value 只是 key 上的"乘客",不参与任何比较、不影响树的结构,所以 key/value 场景可以随意修改 value(改翻译、改计数、改停车时长都不动树的形状)——这也是为什么 STL 的 map 结点里 key 用 const 修饰、value 却用普通成员存放的原因。

想清楚这些,你就可以放心地推开 AVL 与红黑树那扇门了——那里,正等着你用今天打下的地基,去见二叉搜索树长大后最优秀的两个样子。