C++ 二叉搜索树详解:从一个结点到完整 BST 实现

头像

🔥 星恒随风: 个人主页
❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 《C++从入门到入土》
✨ 数据即知识,压缩即智能

文章目录

前言

在学习普通二叉树时,我们主要关注的是树的结构,以及前序、中序、后序、层序等遍历方式。

但普通二叉树有一个问题:结点之间没有统一的大小关系。

假设现在要在一棵普通二叉树中查找数字 13,除了把整棵树遍历一遍,我们通常没有更好的办法。因为站在某个结点上时,并不知道目标应该在左子树还是右子树。

二叉搜索树在普通二叉树的基础上增加了一条有序规则:

较小的数据放在左边
较大的数据放在右边

正是这条看起来很简单的规则,让树具备了定向查找、插入和删除的能力。


一、先从一个二叉树结点开始

1.1 一个结点需要保存什么?

最基础的二叉树结点通常包含三部分:

当前结点的数据
指向左孩子的指针
指向右孩子的指针

使用 C++ 模板可以写成:

template<class K>
struct BSTNode
{
    K _key;
    BSTNode<K>* _left;
    BSTNode<K>* _right;

    explicit BSTNode(const K& key)
        : _key(key)
        , _left(nullptr)
        , _right(nullptr)
    {
    }
};

其中:

_key   :当前结点保存的关键字
_left  :指向左子树
_right :指向右子树

刚创建结点时,它还没有孩子,所以:

_left = nullptr;
_right = nullptr;

一个结点可以简单画成:

          ┌─────────┐
          │   key   │
          └─────────┘
            /     \
         left     right

目前这个结构还只是普通二叉树结点。

想让它成为二叉搜索树,还需要增加一条关于数据大小的规则。


二、从局部大小关系构成二叉搜索树

2.1 二叉搜索树的基本规则

二叉搜索树也叫二叉排序树,英文是:

Binary Search Tree
BST

一棵二叉搜索树需要满足:

1. 左子树中的所有关键字都小于当前结点
2. 右子树中的所有关键字都大于当前结点
3. 左子树和右子树本身也分别是二叉搜索树

例如:

              8
           /     \
          3       10
        /  \        \
       1    6        14
           / \       /
          4   7     13

观察根结点 8

左子树:1、3、4、6、7,都小于 8
右子树:10、13、14,都大于 8

再观察结点 3

左边的 1 小于 3
右边的 4、6、7 大于 3

这条规则并不是只对根结点成立,而是对树中的每一个结点都成立。
在这里插入图片描述

2.2 不只是比较直接孩子

一个常见误区是把二叉搜索树理解成:

左孩子小于根
右孩子大于根

这还不够。

正确规则要求:

整棵左子树的所有结点都小于根
整棵右子树的所有结点都大于根

例如下面这棵树不是二叉搜索树:

        8
       /
      3
       \
        10

虽然 3 < 8,但 10 位于 8 的左子树中,而:

10 > 8

所以它破坏了二叉搜索树的整体性质。

2.3 是否允许重复关键字?

二叉搜索树可以设计成允许重复,也可以设计成不允许重复。

不允许重复时:

左子树 < 根 < 右子树

允许重复时,可以规定:

左子树 < 根 <= 右子树

或者规定:

左子树 <= 根 < 右子树

关键不在于重复值必须放哪一边,而在于规则必须保持一致。

不能出现:

这一次相等往左走
下一次相等又往右走

否则查找、删除和遍历时很难确定重复元素的位置。

本文实现的是不允许重复关键字的二叉搜索树:

key 小于当前结点:向左
key 大于当前结点:向右
key 等于当前结点:插入失败

这类似于唯一键集合的语义。


三、为什么中序遍历会得到有序序列?

3.1 中序遍历的访问顺序

中序遍历的顺序是:

左子树
根结点
右子树

代码形式是:

void InOrder(Node* root)
{
    if (root == nullptr)
    {
        return;
    }

    InOrder(root->_left);
    cout << root->_key << " ";
    InOrder(root->_right);
}

对于下面的二叉搜索树:

              8
           /     \
          3       10
        /  \        \
       1    6        14
           / \       /
          4   7     13

中序遍历结果为:

1 3 4 6 7 8 10 13 14

这是一个递增序列。

3.2 为什么一定有序?

站在任意一个结点的位置看:

左子树所有值 < 当前结点 < 右子树所有值

中序遍历先访问左子树,所以较小的值先输出。

然后输出当前结点。

最后访问右子树,所以较大的值最后输出。

而左右子树本身又是二叉搜索树,所以它们内部的中序遍历也是有序的。

因此:

左子树的有序序列
+ 当前结点
+ 右子树的有序序列

拼接起来仍然是有序序列。

这不是遍历结束后再排序,而是树本身的结构已经维护了顺序。
在这里插入图片描述

3.3 中序遍历可以用来检查 BST

如果一棵不包含重复关键字的二叉树是二叉搜索树,那么它的中序遍历结果应当严格递增。

例如:

1 3 4 6 7 8 10 13 14

如果遍历结果出现:

1 3 10 8 14

就说明树中的大小关系可能已经被破坏。

不过要注意:

中序有序是判断 BST 的重要依据,但实际验证时还要注意重复值策略以及比较器的定义。


四、查找:每比较一次,就排除一半方向

4.1 查找的核心思路

假设要查找 13

              8
           /     \
          3       10
        /  \        \
       1    6        14
           / \       /
          4   7     13

第一步比较:

13 > 8

所以不用再看 8 的左子树,直接向右走。

第二步:

13 > 10

继续向右。

第三步:

13 < 14

转向左子树。

第四步找到:

13 == 13

完整路径是:

8 → 10 → 14 → 13

每次比较后,只有一个方向可能包含目标。

4.2 查找失败的情况

假设查找 12

12 > 8  → 向右
12 > 10 → 向右
12 < 14 → 向左
12 < 13 → 向左

最后走到:

nullptr

说明目标不存在。

因此查找只有两个结束条件:

找到相等结点:查找成功
走到空指针:查找失败

4.3 迭代查找代码

bool Find(const K& key) const
{
    Node* cur = _root;

    while (cur != nullptr)
    {
        if (key < cur->_key)
        {
            cur = cur->_left;
        }
        else if (key > cur->_key)
        {
            cur = cur->_right;
        }
        else
        {
            return true;
        }
    }

    return false;
}

主循环中只有三种情况:

key < 当前值:向左走
key > 当前值:向右走
key == 当前值:返回成功

4.4 查找次数取决于什么?

查找过程只会沿着树中的一条路径向下。

因此查找次数与树高 h 成正比:

时间复杂度:O(h)

如果树接近平衡:

h ≈ log₂N

查找效率接近:

O(log N)

如果树退化成单支树:

h ≈ N

查找效率就变成:

O(N)

所以二叉搜索树真正影响效率的不是结点总数本身,而是树高。
在这里插入图片描述


五、插入:查找失败的位置就是新结点的位置

5.1 插入的本质

在二叉搜索树中插入一个值,实际上就是先执行一次查找。

区别是:

普通查找走到空位置后结束
插入操作走到空位置后创建新结点

例如依次插入:

8, 3, 1, 10, 6, 4, 7, 14, 13

插入 6 时:

6 < 8 → 向左
6 > 3 → 向右
当前位置为空 → 插入 6

插入结果:

        8
       /
      3
       \
        6

5.2 为什么需要 parent 指针?

查找插入位置时,cur 最终会走到空:

cur == nullptr

但空指针本身无法告诉我们新结点应该连接到谁。

所以需要额外保存一个 parent

Node* parent = nullptr;
Node* cur = _root;

每次移动之前,先记录当前结点:

parent = cur;

最后:

cur    指向空位置
parent 指向空位置的父结点

再根据大小关系决定连接到父结点左边还是右边。

5.3 插入代码

bool Insert(const K& key)
{
    if (_root == nullptr)
    {
        _root = new Node(key);
        return true;
    }

    Node* parent = nullptr;
    Node* cur = _root;

    while (cur != nullptr)
    {
        if (key < cur->_key)
        {
            parent = cur;
            cur = cur->_left;
        }
        else if (key > cur->_key)
        {
            parent = cur;
            cur = cur->_right;
        }
        else
        {
            return false;
        }
    }

    Node* newNode = new Node(key);

    if (key < parent->_key)
    {
        parent->_left = newNode;
    }
    else
    {
        parent->_right = newNode;
    }

    return true;
}

5.4 根结点需要单独处理

如果树为空:

_root == nullptr

此时没有父结点,新结点本身就是根:

_root = new Node(key);

这是插入操作中的第一个边界情况。

5.5 插入顺序会影响树的形状

插入顺序:

8 3 1 10 6 4 7 14 13

可以得到一棵相对均匀的树:

              8
           /     \
          3       10
        /  \        \
       1    6        14
           / \       /
          4   7     13

如果按递增顺序插入:

1 3 4 6 7 8 10 13 14

树会退化成:

1
 \
  3
   \
    4
     \
      6
       \
        7
         \
          8
           \
            10
              \
               13
                 \
                  14

这棵树在结构上已经很像单链表。

因此:

二叉搜索树的形状不仅由数据决定,也由插入顺序决定。

在这里插入图片描述


六、删除:二叉搜索树中最复杂的操作

查找和插入都只需要沿着一条路径向下走。

删除不仅要找到目标,还要在删除后保持二叉搜索树的结构,因此情况更多。

假设要删除的结点为 N,可以根据孩子数量进行分类。


6.1 情况一:删除叶子结点

叶子结点没有左右孩子:

      parent
        |
        N
       / \
   nullptr nullptr

删除时,只需要让父结点对应的孩子指针指向空:

parent 的孩子指针 = nullptr
delete N

例如删除 1

删除前:

      3
     /
    1

删除后:

      3

叶子结点也可以统一看成“只有零个有效孩子”的单孩子情况。


6.2 情况二:只有右孩子

例如删除 10

    10
      \
       14

删除后,应当让 10 的父结点直接连接 14

parent
   \
    14

也就是:

parent 的孩子指针 = cur->_right;
delete cur;

6.3 情况三:只有左孩子

例如:

       14
      /
     13

删除 14 后,让它的父结点直接连接 13

parent 的孩子指针 = cur->_left;
delete cur;

情况一、二、三可以合并为:

删除拥有零个或一个孩子的结点

统一处理方式是:

Node* child = cur->_left != nullptr
            ? cur->_left
            : cur->_right;

然后让父结点连接 child

如果两个孩子都为空,child 自然就是 nullptr


6.4 删除的是根结点怎么办?

如果:

parent == nullptr

说明当前删除的结点就是根结点。

此时不能修改父结点,因为根本没有父结点。

应当直接修改:

_root

例如根只有右孩子:

_root = cur->_right;

根只有左孩子:

_root = cur->_left;

删除代码中,这是非常容易遗漏的边界情况。
在这里插入图片描述


6.5 情况四:左右孩子都存在

假设要删除 8

              8
           /     \
          3       10
        /  \        \
       1    6        14
           / \       /
          4   7     13

不能直接把 8 删除,因为:

左子树和右子树都失去了连接位置

这时通常采用替换法。

可以选择:

左子树中的最大结点:前驱

或者:

右子树中的最小结点:后继

本文选择右子树中的最小结点。

8 的右子树中:

      10
        \
         14
        /
       13

最小结点是:

10

因为右子树最小结点就是不断向左走到尽头的位置。

10 的值复制到原来的 8

原结点 8 改成 10

然后转而删除右子树中原来的 10
在这里插入图片描述

6.6 为什么后继结点容易删除?

右子树中的最小结点不可能有左孩子。

如果它有左孩子,那么左孩子会比它更小,它就不是最小结点了。

所以后继结点只可能是:

叶子结点
或者只有右孩子

它最终会退化成前面已经解决的零孩子或单孩子删除。

这就是替换法的核心:

把复杂的双孩子删除,转化为简单的零孩子或单孩子删除。


七、删除代码逐步实现

7.1 先找到目标结点和父结点

Node* parent = nullptr;
Node* cur = _root;

while (cur != nullptr)
{
    if (key < cur->_key)
    {
        parent = cur;
        cur = cur->_left;
    }
    else if (key > cur->_key)
    {
        parent = cur;
        cur = cur->_right;
    }
    else
    {
        break;
    }
}

如果循环结束后:

cur == nullptr

说明目标不存在:

return false;

7.2 处理零个或一个孩子

if (cur->_left == nullptr || cur->_right == nullptr)
{
    Node* child = cur->_left != nullptr
                ? cur->_left
                : cur->_right;

    if (parent == nullptr)
    {
        _root = child;
    }
    else if (parent->_left == cur)
    {
        parent->_left = child;
    }
    else
    {
        parent->_right = child;
    }

    delete cur;
    return true;
}

这一段同时覆盖:

叶子结点
只有左孩子
只有右孩子
删除根结点且根至多只有一个孩子

7.3 处理两个孩子

Node* successorParent = cur;
Node* successor = cur->_right;

while (successor->_left != nullptr)
{
    successorParent = successor;
    successor = successor->_left;
}

此时:

successor 指向右子树最小结点
successorParent 指向它的父结点

用后继值覆盖待删除结点:

cur->_key = successor->_key;

再删除后继结点:

if (successorParent->_left == successor)
{
    successorParent->_left = successor->_right;
}
else
{
    successorParent->_right = successor->_right;
}

delete successor;
return true;

为什么连接的是:

successor->_right

因为后继结点不可能有左孩子,但可能有右孩子。

7.4 右孩子本身就是后继的特殊情况

例如删除:

    8
     \
      10

8 的右子树最小值就是右孩子 10 本身。

初始化时必须写:

Node* successorParent = cur;
Node* successor = cur->_right;

这样没有进入 while 循环时:

successorParent 仍然是待删除结点 cur
successor 是 cur 的右孩子

后面才能正确执行:

successorParent->_right = successor->_right;

如果错误地把:

successorParent

初始化为空,或者初始化成不正确的位置,这种边界情况就很容易出错。


八、一个完整的二叉搜索树实现

下面实现一个不允许重复关键字的二叉搜索树。

除了插入、查找、删除和中序遍历,还补充:

析构函数
深拷贝
拷贝赋值
移动构造
自定义比较器

完整代码如下:

#include <functional>
#include <iostream>
#include <utility>

template<class K, class Compare = std::less<K>>
class BSTree
{
private:
    struct Node
    {
        K _key;
        Node* _left;
        Node* _right;

        explicit Node(const K& key)
            : _key(key)
            , _left(nullptr)
            , _right(nullptr)
        {
        }
    };

public:
    BSTree() = default;

    BSTree(const BSTree& other)
        : _root(Copy(other._root))
        , _compare(other._compare)
    {
    }

    BSTree(BSTree&& other) noexcept
        : _root(other._root)
        , _compare(std::move(other._compare))
    {
        other._root = nullptr;
    }

    BSTree& operator=(BSTree other)
    {
        Swap(other);
        return *this;
    }

    ~BSTree()
    {
        Destroy(_root);
        _root = nullptr;
    }

    void Swap(BSTree& other)
    {
        using std::swap;

        swap(_root, other._root);
        swap(_compare, other._compare);
    }

    bool Insert(const K& key)
    {
        if (_root == nullptr)
        {
            _root = new Node(key);
            return true;
        }

        Node* parent = nullptr;
        Node* cur = _root;

        while (cur != nullptr)
        {
            if (_compare(key, cur->_key))
            {
                parent = cur;
                cur = cur->_left;
            }
            else if (_compare(cur->_key, key))
            {
                parent = cur;
                cur = cur->_right;
            }
            else
            {
                return false;
            }
        }

        Node* newNode = new Node(key);

        if (_compare(key, parent->_key))
        {
            parent->_left = newNode;
        }
        else
        {
            parent->_right = newNode;
        }

        return true;
    }

    bool Contains(const K& key) const
    {
        const Node* cur = _root;

        while (cur != nullptr)
        {
            if (_compare(key, cur->_key))
            {
                cur = cur->_left;
            }
            else if (_compare(cur->_key, key))
            {
                cur = cur->_right;
            }
            else
            {
                return true;
            }
        }

        return false;
    }

    bool Erase(const K& key)
    {
        Node* parent = nullptr;
        Node* cur = _root;

        while (cur != nullptr)
        {
            if (_compare(key, cur->_key))
            {
                parent = cur;
                cur = cur->_left;
            }
            else if (_compare(cur->_key, key))
            {
                parent = cur;
                cur = cur->_right;
            }
            else
            {
                break;
            }
        }

        if (cur == nullptr)
        {
            return false;
        }

        // 零个或一个孩子
        if (cur->_left == nullptr || cur->_right == nullptr)
        {
            Node* child = cur->_left != nullptr
                        ? cur->_left
                        : cur->_right;

            if (parent == nullptr)
            {
                _root = child;
            }
            else if (parent->_left == cur)
            {
                parent->_left = child;
            }
            else
            {
                parent->_right = child;
            }

            delete cur;
            return true;
        }

        // 两个孩子:寻找右子树最小结点
        Node* successorParent = cur;
        Node* successor = cur->_right;

        while (successor->_left != nullptr)
        {
            successorParent = successor;
            successor = successor->_left;
        }

        cur->_key = successor->_key;

        if (successorParent->_left == successor)
        {
            successorParent->_left = successor->_right;
        }
        else
        {
            successorParent->_right = successor->_right;
        }

        delete successor;
        return true;
    }

    void InOrder(std::ostream& out = std::cout) const
    {
        InOrder(_root, out);
        out << '\n';
    }

    bool Empty() const
    {
        return _root == nullptr;
    }

private:
    static void InOrder(const Node* root, std::ostream& out)
    {
        if (root == nullptr)
        {
            return;
        }

        InOrder(root->_left, out);
        out << root->_key << ' ';
        InOrder(root->_right, out);
    }

    static void Destroy(Node* root)
    {
        if (root == nullptr)
        {
            return;
        }

        Destroy(root->_left);
        Destroy(root->_right);

        delete root;
    }

    static Node* Copy(const Node* root)
    {
        if (root == nullptr)
        {
            return nullptr;
        }

        Node* newRoot = new Node(root->_key);

        try
        {
            newRoot->_left = Copy(root->_left);
            newRoot->_right = Copy(root->_right);
        }
        catch (...)
        {
            Destroy(newRoot);
            throw;
        }

        return newRoot;
    }

private:
    Node* _root = nullptr;
    Compare _compare;
};

九、为什么析构要使用后序遍历?

9.1 错误的释放顺序

如果先删除根结点:

delete root;

然后再访问:

root->_left
root->_right

这时 root 已经成为悬空指针,继续访问会产生未定义行为。

9.2 正确释放顺序

应该先释放左右子树,再释放根:

左子树
右子树
根结点

这正好是后序遍历。

static void Destroy(Node* root)
{
    if (root == nullptr)
    {
        return;
    }

    Destroy(root->_left);
    Destroy(root->_right);

    delete root;
}

可以把它理解为拆房子:

先拆上层结构
再拆最下面的支撑点

如果先把根结点删除,就无法再通过它找到左右子树。


十、为什么二叉搜索树需要深拷贝?

10.1 默认拷贝的问题

BSTree 中保存了一个指针:

Node* _root;

如果直接使用编译器生成的默认拷贝:

BSTree<int> tree2 = tree1;

默认行为只会复制 _root 指针的地址。

结果是:

tree1._root ─┐
             ├── 指向同一棵树
tree2._root ─┘

这属于浅拷贝。

后果包括:

修改一棵树可能影响另一棵树
一棵树析构后,另一棵树留下悬空指针
两个对象析构时重复释放同一批结点

10.2 深拷贝的思路

深拷贝要重新创建所有结点:

static Node* Copy(const Node* root)
{
    if (root == nullptr)
    {
        return nullptr;
    }

    Node* newRoot = new Node(root->_key);

    newRoot->_left = Copy(root->_left);
    newRoot->_right = Copy(root->_right);

    return newRoot;
}

它的递归结构是:

复制当前根
复制左子树
复制右子树

最终两棵树的结构和数据相同,但内存完全独立。

tree1 → 原树
tree2 → 新树

10.3 copy-and-swap 赋值

赋值运算符写成:

BSTree& operator=(BSTree other)
{
    Swap(other);
    return *this;
}

参数 other 是传值对象,进入函数前已经完成拷贝。

函数中交换两棵树的资源:

Swap(other);

函数结束时,other 析构,自动释放当前对象原来的旧资源。

这种写法将:

拷贝
旧资源释放
自赋值处理

统一交给构造函数、交换函数和析构函数完成,代码比较简洁。
在这里插入图片描述


十一、为什么使用 Compare,而不是直接写小于号?

前面的基础版本可以直接写:

key < cur->_key

但模板类型不一定使用默认升序,也不一定直接支持 <

所以完整实现中使用比较器:

Compare _compare;

默认类型是:

std::less<K>

判断 key 是否更小:

_compare(key, cur->_key)

判断当前结点是否更小:

_compare(cur->_key, key)

如果两次比较都为假:

!_compare(key, cur->_key)
&&
!_compare(cur->_key, key)

说明两个关键字在当前比较规则下等价。

这种写法也对应有序关联容器常见的比较方式。

11.1 自定义降序比较器

struct Greater
{
    bool operator()(int left, int right) const
    {
        return left > right;
    }
};

使用:

BSTree<int, Greater> tree;

这时树的方向会与默认升序规则相反,但只要所有操作都使用同一个比较器,结构仍然是自洽的。


十二、测试完整代码

int main()
{
    BSTree<int> tree;

    int values[] = {
        8, 3, 1, 10, 6, 4, 7, 14, 13
    };

    for (int value : values)
    {
        tree.Insert(value);
    }

    cout << "中序遍历:";
    tree.InOrder();

    cout << boolalpha;
    cout << "查找 7:" << tree.Contains(7) << '\n';
    cout << "查找 12:" << tree.Contains(12) << '\n';

    tree.Erase(1);   // 删除叶子
    tree.Erase(14);  // 删除只有一个孩子的结点
    tree.Erase(3);   // 删除有两个孩子的结点
    tree.Erase(8);   // 删除根结点

    cout << "删除后:";
    tree.InOrder();

    BSTree<int> copy = tree;

    cout << "拷贝结果:";
    copy.InOrder();

    return 0;
}

可能的输出:

中序遍历:1 3 4 6 7 8 10 13 14
查找 7:true
查找 12:false
删除后:4 6 7 10 13
拷贝结果:4 6 7 10 13

在 Linux 环境中,可以使用:

g++ -std=c++17 -Wall -Wextra -pedantic bst.cpp -o bst
./bst

建议同时打开 AddressSanitizer 检查内存问题:

g++ -std=c++17 \
    -Wall -Wextra -pedantic \
    -fsanitize=address,undefined \
    -g bst.cpp -o bst

./bst

它可以帮助发现:

重复释放
越界访问
释放后继续使用
部分未定义行为

十三、key 模型:只关心一个值是否存在

13.1 什么是 key 模型?

有些场景只需要判断某个关键字在不在集合中。

结点只需要保存:

K _key;

例如:

车牌号是否属于本小区
单词是否在词典中
用户 ID 是否已经注册
黑名单中是否存在某个账号

查找结果通常只是:

存在
不存在

这种结构对应的是 key 搜索场景。

13.2 为什么不能直接修改 key?

假设原来有:

        8
       /
      3

如果直接把 3 修改成 10

        8
       /
      10

此时 10 位于 8 的左边,破坏了二叉搜索树规则。

所以关键字通常不能直接修改。

如果确实要修改,应当执行:

删除旧 key
插入新 key

而不是在原位置直接赋值。


十四、key/value 模型:通过 key 查找对应信息

14.1 从集合升级为映射

有些场景不仅要知道 key 是否存在,还需要找到 key 对应的数据。

例如中英词典:

left   → 左边
right  → 右边
insert → 插入
string → 字符串

此时一个结点需要保存:

key
value
left
right

可以定义为:

template<class K, class V>
struct BSTMapNode
{
    K _key;
    V _value;

    BSTMapNode<K, V>* _left;
    BSTMapNode<K, V>* _right;

    BSTMapNode(const K& key, const V& value)
        : _key(key)
        , _value(value)
        , _left(nullptr)
        , _right(nullptr)
    {
    }
};

14.2 树的顺序由谁决定?

即使结点中同时有 keyvalue,树的结构仍然只由 key 决定。

比较时使用:

cur->_key

而不是:

cur->_value

因此:

key 决定结点放在树中的位置
value 只是与 key 关联的数据

14.3 key 不能改,value 可以改

修改 key 可能破坏树的顺序。

修改 value 不会改变结点位置。

例如:

apple → 苹果

可以修改为:

apple → 苹果;苹果公司

结点仍然按照 apple 这个 key 存放,所以树结构不会受到影响。
在这里插入图片描述


十五、key/value 的查找接口应该返回什么?

对于 key 模型,查找只需要返回:

bool

对于 key/value 模型,只返回真假往往不够,因为调用者还需要访问对应的 value

可以让查找函数返回 value 的指针:

V* Find(const K& key)
{
    Node* cur = _root;

    while (cur != nullptr)
    {
        if (key < cur->_key)
        {
            cur = cur->_left;
        }
        else if (key > cur->_key)
        {
            cur = cur->_right;
        }
        else
        {
            return &cur->_value;
        }
    }

    return nullptr;
}

常量版本:

const V* Find(const K& key) const
{
    const Node* cur = _root;

    while (cur != nullptr)
    {
        if (key < cur->_key)
        {
            cur = cur->_left;
        }
        else if (key > cur->_key)
        {
            cur = cur->_right;
        }
        else
        {
            return &cur->_value;
        }
    }

    return nullptr;
}

这样:

返回 nullptr:key 不存在
返回有效指针:可以读取或修改对应 value

十六、应用一:简单中英词典

BSTreeMap<string, string> dictionary;

dictionary.Insert("left", "左边");
dictionary.Insert("right", "右边");
dictionary.Insert("insert", "插入");
dictionary.Insert("string", "字符串");

查询:

string word;

while (cin >> word)
{
    const string* translation = dictionary.Find(word);

    if (translation != nullptr)
    {
        cout << word << " -> "
             << *translation << '\n';
    }
    else
    {
        cout << "未找到该单词\n";
    }
}

这里的搜索过程是:

输入英文单词
→ 按 key 在树中查找
→ 找到结点
→ 读取对应中文 value

十七、应用二:统计单词出现次数

假设有一组水果名称:

string words[] = {
    "苹果", "西瓜", "苹果",
    "西瓜", "苹果", "香蕉",
    "苹果", "香蕉"
};

用:

水果名称作为 key
出现次数作为 value

处理逻辑:

第一次出现:
插入 <水果, 1>

已经存在:
对应次数加 1

示例:

for (const string& word : words)
{
    int* count = countTree.Find(word);

    if (count == nullptr)
    {
        countTree.Insert(word, 1);
    }
    else
    {
        ++(*count);
    }
}

最终可能得到:

苹果:4
西瓜:2
香蕉:2

这个过程也是有序关联容器中常见的词频统计模型。


十八、二叉搜索树的性能分析

18.1 所有核心操作都依赖树高

二叉搜索树中的:

查找
插入
删除
查找最小值
查找最大值

本质上都是从某个结点沿着一条路径向下移动。

所以它们的时间复杂度都可以统一写成:

O(h)

其中 h 是树高。

18.2 接近平衡时

如果每一层的结点数量比较均匀:

        ●
      /   \
     ●     ●
    / \   / \
   ●  ●  ●  ●

树高大约为:

log₂N

操作效率为:

O(log N)

18.3 退化时

如果按有序顺序插入:

1 2 3 4 5

可能形成:

1
 \
  2
   \
    3
     \
      4
       \
        5

树高变成:

N

操作效率退化为:

O(N)

18.4 不能笼统说 BST 一定是 O(log N)

普通二叉搜索树不保证平衡。

所以更准确的说法是:

查找、插入、删除:O(h)

接近平衡:O(log N)
最坏退化:O(N)

直接说:
在这里插入图片描述

二叉搜索树查找一定是 O(log N)

是不严谨的。


十九、二分查找和二叉搜索树有什么区别?

二分查找和二叉搜索树都利用了有序性,但使用场景不同。

19.1 二分查找

二分查找通常工作在支持随机访问的有序结构中,例如:

vector<int>
array<int, N>

查找复杂度为:

O(log N)

但在数组中间插入或删除元素时,通常需要搬移后面的数据:

插入和删除:O(N)

19.2 二叉搜索树

二叉搜索树中的结点通过指针连接。

找到目标位置后,插入或删除主要通过修改指针完成。

操作复杂度为:

O(h)

平衡时接近:

O(log N)

退化时是:

O(N)

19.3 简单对比

对比项有序数组加二分查找普通二叉搜索树
查找O(log N)O(h)
中间插入O(N)O(h)
删除O(N)O(h)
是否连续存储
是否需要随机访问
最坏查找O(log N)O(N)
内存局部性较好通常较弱

如果数据几乎不修改,只进行大量查询,有序数组可能很合适。

如果数据需要动态插入和删除,平衡搜索树更有优势。
在这里插入图片描述


二十、为什么还要学习 AVL 树和红黑树?

普通二叉搜索树最大的缺点是:

无法保证树高

一旦树退化成单支结构,性能就从:

O(log N)

下降到:

O(N)

平衡搜索树会在插入和删除后,通过旋转、颜色或高度规则控制树高。

常见结构包括:

AVL 树
红黑树
伸展树
B 树和 B+ 树

其中:

AVL 树:对高度平衡要求更严格
红黑树:通过颜色规则维持近似平衡

这些结构的共同目标是:

避免二叉搜索树退化,尽量把树高控制在对数级别。

二叉搜索树是这些平衡树的基础。

如果连普通 BST 的插入、查找和删除都没有理解,后面学习旋转和平衡调整时会比较吃力。


二十三、把所有知识点串起来

现在从最底层重新看一遍二叉搜索树。

23.1 从结点开始

每个结点保存:

key
left
right

指针把结点组织成二叉树。

23.2 加入有序规则

规定:

左边小
右边大

并且左右子树继续遵循相同规则。

普通二叉树由此变成二叉搜索树。

23.3 有序规则形成定向查找

查找一个 key 时:

比当前值小 → 向左
比当前值大 → 向右
相等 → 找到

每次比较只保留一个可能方向。

23.4 查找失败的位置成为插入位置

插入其实就是一次没有找到目标的查找。

走到空位置后,创建新结点并连接到父结点。

23.5 删除必须维护原有规则

零个或一个孩子时,父结点可以直接跨过被删除结点连接它的孩子。

两个孩子时,不能直接删除,需要使用前驱或后继替换。

23.6 有序结构带来中序有序

因为:

左子树 < 根 < 右子树

所以按照:

左 → 根 → 右

访问时,会自然得到有序序列。

23.7 所有操作都受树高控制

查找、插入和删除都只沿一条路径进行:

复杂度为 O(h)

所以:

树越矮,操作越快
树越高,操作越慢

23.8 普通 BST 引出平衡树

普通 BST 不会自动控制高度。

因此需要 AVL 树、红黑树等结构,在保留搜索树有序规则的同时,尽量维持较低高度。

最终完整知识链是:

二叉树结点
    ↓
左右孩子指针
    ↓
左小右大的局部规则
    ↓
递归形成全局有序结构
    ↓
定向查找
    ↓
在查找失败处插入
    ↓
通过指针调整完成删除
    ↓
中序遍历得到有序序列
    ↓
操作复杂度取决于树高
    ↓
普通 BST 可能退化
    ↓
AVL 树与红黑树维持平衡
    ↓
支撑更稳定的有序关联结构

这就是从一个结点出发,逐步构建出的二叉搜索树知识体系。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐