二叉搜索树完全指南:从核心原理到增删查改全实现

目录

一、二叉搜索树的概念

[1.1 二叉搜索树的核心概念与性质](#1.1 二叉搜索树的核心概念与性质)

[1.2 关于相等值的处理](#1.2 关于相等值的处理)

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

[2.1 性能全看 "长相":结构决定一切](#2.1 性能全看 “长相”:结构决定一切)

[2.2 聊点实在的:二叉搜索树比二分查找强在哪?](#2.2 聊点实在的:二叉搜索树比二分查找强在哪?)

三、先搭个代码框架

[3.1 二叉搜索树节点模板类SBTnode](#3.1 二叉搜索树节点模板类SBTnode)

[3.2 二叉搜索树功能实现类 SBTree](#3.2 二叉搜索树功能实现类 SBTree)

四、二叉搜索树的查找

[4.1 基本思想与代码实现](#4.1 基本思想与代码实现)

[4.2 特别情况说明](#4.2 特别情况说明)

五、二叉搜索树的插入

[5.1 具体过程图文解释](#5.1 具体过程图文解释)

[5.2 代码实现详细解析](#5.2 代码实现详细解析)

[5.2.1 查找插入位置](#5.2.1 查找插入位置)

六、二叉搜索树的删除

[6.1 执行删除操作时共有多少种情况](#6.1 执行删除操作时共有多少种情况)

[6.2 所有情况的应对策略思想总结](#6.2 所有情况的应对策略思想总结)

[6.2.1 直接删除法](#6.2.1 直接删除法)

[6.2.2 托孤法](#6.2.2 托孤法)

[6.2.3 替换删除法](#6.2.3 替换删除法)

[6.3 代码实现详细解析](#6.3 代码实现详细解析)

[6.3.1 先给出完整代码](#6.3.1 先给出完整代码)

[6.3.2 查找待删除结点](#6.3.2 查找待删除结点)

[6.3.3 情况一、二、三:当前结点没有左/右孩子](#6.3.3 情况一、二、三:当前结点没有左/右孩子)

[6.3.4 情况四:左右子树都存在](#6.3.4 情况四:左右子树都存在)


今天我们来聊二叉搜索树。

说白了,它就是给普通二叉树套上了一套特定的规则约束,也正是这套约束,让它拥有了远超普通二叉树的高效搜索能力。

别看它增删查操作都挺快,性能看着很亮眼,但其实这份高效背后,藏着不小的潜在效率坑。

另外还要提一句,纯key和key-value这两种不同的应用场景,对应的二叉搜索树设计和实现思路,也会有不小的差别。

一、二叉搜索树的概念

1.1 二叉搜索树的核心概念与性质

先给大家明确下基本定义:二叉搜索树 ,英文缩写BST ,也常被叫做二叉排序树。它要么是一棵空树,要么必须满足下面这三条核心性质:

  • 首先是左子树的规则:如果左子树不为空,那么左子树上所有节点的值,都必须小于(部分实现允许等于)根节点的值。
  • 对应的右子树刚好反过来:如果右子树存在,那上面所有节点的值,都得大于(同样部分实现允许等于)根节点的值。
  • 最后也是最关键的一条递归性质:它的左子树和右子树本身,也必须各自是一棵二叉搜索树。

光讲概念肯定不好理解,我们画两个图看看:

看上面这张图,就是一个标准的二叉搜索树模型。根节点是8,它左边所有子树里的数都比8小,对应我们刚才说的左子树性质;右边所有子树里的数都比8大,对应右子树性质。

而且这个规则不是只在根节点生效,是层层递归往下的,比如以3为根的左子树、以6为根的左子树,每一棵子树都得严格遵守同样的规则,这就是递归性质的体现。

再看右边这张图,二叉搜索树其实不只有左边这种键值完全不重复的实现,也允许像右边这样存在重复键值的情况。关于重复键值具体怎么处理,我们后面会专门展开讲。

1.2 关于相等值的处理

二叉搜索树里能不能插入相等的值,其实没有统一标准,主要看具体的实现和使用场景,一般分两种情况:

  • 第一种是不允许重复:像C++STL里的set和map,就严格禁止插入和已有节点值相等的节点。
  • 另一种就宽松多了,允许重复插入:比如multiset和multimap这两个STL容器,就支持往里面塞相等的值,这些重复值一般会被统一放到同一棵子树里,通常是右子树。

当然,set、map、multiset和multimap这几个容器,我们后面会专门写文章详细聊。这里可以先透个底,它们的底层其实都是平衡二叉树里的红黑树。

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

2.1 性能全看 "长相":结构决定一切

先给大家说句大实话:二叉搜索树的性能好不好,完全看它长什么样,上下限差得特别大

最理想的情况,就是它长成一棵完全二叉树 ,或者说高度特别平衡的树。这时候树的高度大概是 log₂N,不管是查找、插入还是删除,时间复杂度都是O(logN),效率非常能打。

但它也有特别拉胯的时候,如果插入的数据本身就是有序的,那二叉搜索树会直接退化成一条链表。这时候树的高度就变成了N,所有操作的时间复杂度都会暴跌到O(N),跟从头遍历数组没什么区别了。

还是看图说话最清楚:左边这棵树长得就很标准,接近完全二叉树,增删查都是 logN 的效率;右边这棵就惨了,几乎就是一条链表,所有操作都得从头走到尾,效率直接拉胯。

可能有小伙伴会好奇,好好的树怎么就变成链表了?我举个例子大家就懂了:比如你按 [10,9,8,7,6,5,4,3,2,1] 这个倒序序列来构建二叉搜索树。

根节点先插10,然后9比10小,插在10左边;

8比9小,又插在9左边;

后面的数一个比一个小,只能一直往左边插。

最后整棵树就变成了一条歪歪扭扭的左链表,完全失去了二叉搜索树的优势。

综合来看,二叉搜索树的平均时间复杂度其实是O(logN),但它的性能波动实在太大了,一旦运气不好碰到最坏情况,直接跌到O(N),这种效率在实际生产环境里是完全不能接受的。

也正因为这个硬伤,才有了二叉搜索树的各种改进版本,也就是我们接下来要重点学习的平衡二叉搜索树。像经典的AVL树和红黑树,都属于这一类。它们通过一套严格的规则,强制让树的高度始终维持在O(logN)左右,从根本上解决了退化成链表的问题,保证了增删查改操作的稳定高效。

2.2 聊点实在的:二叉搜索树比二分查找强在哪?

要搞懂平衡二叉搜索树的好,我们拿它和二分查找对比一下就清楚了。虽说二分查找也能做到O (logN)的查找效率,但它有两个挺致命的缺陷:

首先是存储上的限制太死:二分查找必须依赖支持下标随机访问的结构,比如数组,而且数据还得是有序的,这要求就有点高了。

更难受的是更新效率太低:在数组这种结构里插入或删除元素,往往得挪动一大片数据,效率实在感人。

而平衡二叉搜索树刚好补上了这两个坑:它不需要连续的存储空间 ,还能高效地处理动态数据的插入、删除和查找,这就很实用了。

好了,铺垫了这么多,接下来我们正式进入正题:聊聊二叉搜索树的增删查操作到底是怎么实现的。

三、先搭个代码框架

讲了这么多原理,终于要动手写代码了。

cpp 复制代码
#pragma once
#include<iostream>
using namespace std;
namespace key{
template <class K>
class SBTnode{
public:
	K _key;
	SBTnode<K>* left;
	SBTnode<K>* right;
	SBTnode(const K& key)
		:_key(key)
		, left(nullptr)
		, right(nullptr)
	{}
};	
template <class K>
class SBTree{
using Node = SBTnode<K>;
public:
		///..................
protected:
	Node* _root = nullptr;
};
}

先把整体的架子搭出来,后面再一点点往里面填增删查的具体逻辑,这样思路不容易乱

咱们先准备两个模板类,一个是二叉搜索树的节点模板类,另一个是负责实现具体功能的二叉搜索树类。

3.1 二叉搜索树节点模板类SBTnode

这个节点类用了模板参数K,说白了就是让节点能存任意类型的数据,不管是 int、double 还是自定义类,都能往里塞。

节点内部有三个核心成员:

_key用来存节点的关键字(也就是实际数据),

left和right则是两个指针,分别指向当前节点的左孩子和右孩子。

构造函数也很简单,接收一个const K&类型的 key,把它赋给_key,同时把left和right都初始化为空指针,这样新创建的节点一开始是没有子节点的,干干净净。

3.2 二叉搜索树功能实现类 SBTree

在这个功能类里,先给节点类型起个短点的别名,方便后面写代码,可以用 typedef,也可以用 C++11 新增的using,两种方式都行,看你习惯。

接着,在类里组合进来一个根节点指针 _root,顺便给它个缺省值 nullptr,表示这棵树一开始是空的,连根节点都没有。

四、二叉搜索树的查找

4.1 基本思想与代码实现

查找的逻辑其实特别简单,我们就从根节点开始,拿着要找的值x一步步比:

说白了就是:x 比根节点大?

那咱就往右边子树找;

x 比根节点小?

那就往左边子树钻。

最多也就找树的高度那么多次,要是最后都遍历到空节点了还没见着x,那说明这树里根本就没这个值。

cpp 复制代码
Node* 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 cur;
	}
	return nullptr;
}
4.2 特别情况说明

这里得分两种情况聊:

要是咱们的实现不允许插入相等的值,那查找就很省心,找到第一个等于x的节点,直接返回就完事了。

但如果支持重复插入,那树里可能藏着好几个 x。这时候通常有个约定俗成的规矩:要找中序遍历下第一个出现的 x

举个例子,比如要找值为3的节点,得找到那种 "某个节点(比如值为 1 的节点)的右孩子刚好是 3" 的,返回这个首次出现的 3 才算数。

如下图,查找3,要找到1的右孩⼦的那个3返回。

不过得提一句,关于重复值的查找规则,C++ 标准其实没做硬性规定,属于未指定行为。像 multimap、multiset 这些容器在这方面的具体实现,完全取决于编译器的选择。

五、二叉搜索树的插入

5.1 具体过程图文解释

插入的逻辑其实和查找有点像,咱们一步步来:

如果树本身是空的,那最简单,直接新增一个节点,把它赋值给root指针就行。

要是树不为空,那就得按二叉搜索树的规矩来:

  1. 插入值比当前节点大,就往右子树走;
  2. 比当前节点小,就往左子树走,一直找到一个空位置,把新节点插进去。
  3. 如果支持插入相等的值,那碰到和当前节点相等的值时,可以统一往右走,也可以统一往左走,找到空位插进去就行。但这里有个关键点要注意:逻辑得保持一致,别一会插左边一会插右边,不然树的规则就乱了。

光说可能有点抽象,咱们用一个具体例子来演示下这个过程:
大家看上面这棵二叉搜索树,现在我们要往里面插一个数 16。

具体操作如下图:我们就按照二叉搜索树的性质,一步步往下找,找到那个合适的空位,直接把 16插进去就行。

如下图,如果咱们要插入一个重复的数字,可以提前定好规矩 ------ 要么统一插在已经存在的那个重复值节点的左子树,要么统一插在右子树。

接着还是按照二叉搜索树的老套路,一步步往下找空位,找到后把新节点插进去就完事了。

5.2 代码实现详细解析

代码这块稍微有点绕,咱们别着急,分块慢慢捋。整体可以把代码拆成三部分:

第一是找插入的位置,

第二是真刀真枪地插进去,

第三是处理一些特殊情况。

5.2.1 查找插入位置

先讲第一块:怎么找插入的位置。我们还是从根节点出发,拿着要插的 key,跟当前节点 cur 的_key 比大小。大了就往右走,小了就往左走,走的时候别忘了顺手记一下父节点 parent。等 cur 走到 nullptr 的时候,说明咱们找到该插的地方了;

但要是走着走着碰到了和 key 一样的节点,那就直接返回 false,告诉外面 "不允许重复插哈"。

cpp 复制代码
bool Insert(const K& key){
	Node* cur = _root;
	Node* parent = nullptr;
	while (cur){
		if (cur->_key < key){
			parent = cur;
			cur = cur->right;
		}
		else if (cur->_key > key){
			parent = cur;
			cur = cur->left;
		}
		else
			return false;
	}
}

等位置找好了,接下来就是真的把节点插进去了。

这时候咱们刚才记的parent就派上用场了,它刚好指向待插入位置的父节点

我们先创建一个新节点new Node(key),然后再拿 key 跟 parent 的_key 比一下大小:

  • 要是 key 比父节点小,就把新节点插成它的左孩子;
  • 要是比父节点大,就插成右孩子。

这么一弄,就能稳稳地保持住二叉搜索树 "左子树都比根小、右子树都比根大" 的有序结构了。

cpp 复制代码
cur = new Node(key);
	if (parent->_key > key)
		parent->left = cur;
	else if (parent->_key < key)
		parent->right = cur;
	return true;

最后提个特殊情况,如果整棵树本身就是空的,也就是 _root 还等于 nullptr,那说明我们现在插的是这棵树的第一个节点。

这时候就不用费劲儿去查位置了,直接创建一个新节点,让 _root 指向它就行。说白了,这一步就把二叉搜索树给初始化好了。

cpp 复制代码
if (_root == nullptr){
		_root = new Node(key, value);
		return true;
	}

六、二叉搜索树的删除

注意了,二叉搜索树最麻烦的部分这就来了,删除操作。

6.1 执行删除操作时共有多少种情况

首先得先找找看要删的元素在不在树里,要是压根不存在,直接返回 false就完事了。但如果找到了,那麻烦就来了,得根据要删的节点 N 的情况分四种处理:

  1. 第一种情况:要删的节点N左右孩子都空着,是个光杆司令。
  2. 第二种情况:节点N左孩子空着,但右孩子还在。
  3. 第三种情况:节点N右孩子空着,但左孩子还在。
  4. 第四种情况:最头疼的,节点N左右孩子都好好的,一个都不少。
6.2 所有情况的应对策略思想总结

针对这四种情况,我总结了三种应对策略:直接删除法、托孤法、替换删除法

6.2.1 直接删除法

直接删除法很简单:先把N节点的父亲对应的孩子指针改成空,然后直接删掉 N 节点就行。

顺便说一句,第一种情况(左右都空)其实可以当成第二种或第三种情况来处理,效果没差。

6.2.2 托孤法

接下来是托孤法,专门处理情况二和情况三,也就是要删的节点只有一个孩子的时候。

情况二是N只有右孩子,那简单,让N的父亲把对应孩子指针直接指向 N 的右孩子,然后删掉 N 就行;

情况三反过来,只有左孩子,就让父亲指向左孩子,再删 N

给大家看个图就懂了,比如现在要删的节点是14,它只有一个孩子13。按照托孤法的处理方式,直接让 14 的父节点 10 的右指针指向 13,然后把 14 删掉就完事了。

这么一弄,不仅把节点删了,还能保证二叉搜索树**"左小右大"**的规矩没被破坏 ------ 哪怕 13 下面还有子树,也会跟着 13 一起接回原来的位置,完全不用操心。

6.2.3 替换删除法

先给大家看张图,假设咱们现在要删的是像3或者8这种有两个子节点的节点。

这时候直接删 N 肯定不行,它两个孩子没地方放啊。想想看,要是还用托孤法,父亲只有一个空出来的指针,指了这个孩子,另一个怎么办?所以对付这种有两个孩子的节点,我们得换个思路。

不知道大家还记不记得,我们之前删堆顶元素的时候用过一个办法:从堆底拿个数和堆顶交换,然后删掉堆底那个,再把新堆顶往下调整调整就行

那我们能不能也用类似的思路,找个数和要删的数交换,来实现替换删除?不过二叉搜索树的规矩太严了,这个数可不能随便找。所以我们得定个规则,专门挑合适的替换数。

怎么找呢?

要么找 N 左子树里值最大的那个节点 R(也就是左子树最右边的那个),要么找 N 右子树里值最小的那个节点 R(右子树最左边的那个)。这两个节点随便拿一个放到 N 的位置,都能符合二叉搜索树的规矩。

这里说的 "替代",一开始我想的是把 N 和 R 的值交换一下,然后转头去删 R。毕竟 R 这时候肯定符合情况 2 或者 3,直接删就行。

不过这里得跟大家道个歉,我之前忽略了个问题,先直接说结论:我们只能让替代节点 R 去覆盖 N,不能让它们俩交换值,为什么呢?原理我接下来跟大家说:

如上图,假设我们要去删除3,并且找到了右子树的最小结点4,这个时候交换4和3:如下图:

如果在这个时候继续选择从头遍历去查找3并删除的话,我们是找不到的,因为3的位置不符合二叉搜索树的规则。就是这个细节我们下面的代码要注意一下。 ok来看看代码怎么写:

6.3 代码实现详细解析
6.3.1 先给出完整代码
cpp 复制代码
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 (cur == _root)
				{
					_root = cur->right;
				}
				else
				{
					if (parent->left == cur)
					{
						parent->left = cur->right;
					}
					else if (parent->right == cur)
					{
						parent->right = cur->right;
					}
				}
			}
			else if (cur->right == nullptr)
			{
				if (cur == _root)
				{
					_root = cur->left;
				}
				else
				{
					if (parent->left == cur)
					{
						parent->left = cur->left;
					}
					else if (parent->right == cur)
					{
						parent->right = cur->left;
					}
				}
			}
			else
			{
				Node* replaceparent = cur;
				Node* replace = cur->right;
				while (replace->left)
				{
					replaceparent = replace;
					replace = replace->left;
				}
				cur->_key = replace->_key;
				if (replaceparent->left == replace)
				{
					replaceparent->left = replace->right;
				}
				else if (replaceparent->right == replace)
				{
					replaceparent->right = replace->right;
				}
				cur = replace;
			}
			delete (cur);
			return true;
		}
	}
	return false;
}
6.3.2 查找待删除结点
cpp 复制代码
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;
    }

这段代码的作用很简单,就是在二叉搜索树里先把要删的那个节点给找出来。

这里面有两个关键指针得说一下:**cur 是我们当前遍历到的节点,parent 则专门跟着记 cur 的父节点。**毕竟后面真要动手删节点的时候,得改父节点的指针才行。

查找的逻辑完全就是二叉搜索树那老一套,所以代码就是不断地往下移动,同时顺手更新 parent 的值。等循环结束了,要是发现 cur 变成了 nullptr,那说明这树里根本就没咱们要找的这个元素,函数最后直接返回 false 就完事了。

6.3.3 情况一、二、三:当前结点没有左/右孩子
cpp 复制代码
if (cur->left == nullptr){
    if (cur == _root)
        _root = cur->right;
    else{
        if (parent->left == cur)
            parent->left = cur->right;
        else if (parent->right == cur)
            parent->right = cur->right;
    }
}

这部分代码对应的就是咱们前面原理里说的那三种情况:

情况1是要删的节点左右孩子都空;

情况 2 和 3 则是左空右不空,或者右空左不空。

有意思的是,这三种情况在代码里其实可以揉在一起统一处理。为什么呢?

因为如果一个节点没了左/右孩子 ,那直接**让父节点指向它剩下的那个右/左孩子就行。**说白了就是咱们之前说的托孤法,很省事。

不过这里得单独提一下删除根节点的情况,代码里专门做了处理,比如直接让 _root = cur->right。毕竟根节点没有父节点,没地方托孤,只能直接修改 _root 指针本身了。

6.3.4 情况四:左右子树都存在
cpp 复制代码
else
{
    Node* replaceparent = cur;
    Node* replace = cur->right;
    while (replace->left)
    {
        replaceparent = replace;
        replace = replace->left;
    }

等碰到待删除的节点左右子树都健在的时候,就不能直接删了。为啥?因为你要是直接把这个节点删了,它的左右两棵子树总不能同时接到父节点那儿去吧,所以这时候必须得用咱们之前说的替换删除法。

我这里采用的办法是,在当前节点的右子树里找那个最小的节点。怎么找呢?先一头扎进右子树,然后接着不断往左走,一直走到最左边的那个节点为止,这个节点,其实就是当前节点的中序后继。

cpp 复制代码
    cur->_key = replace->_key;

等找到这个替换节点 replace 之后,咱们就把它的值赋给当前要删的节点 cur。这么一弄,从逻辑上讲,就相当于用 replace 把 cur 的位置给顶掉了。

这时候真正需要动手删掉的节点,就不再是原来的 cur,而是变成这个 replace 了。

cpp 复制代码
    if (replaceparent->left == replace)
    {
        replaceparent->left = replace->right;
    }
    else if (replaceparent->right == replace)
    {
        replaceparent->right = replace->right;
    }

这时候删replace就简单多了。为啥呢?因为它是右子树里挑出来的最小节点,肯定没有左孩子,撑死了也就只有一个右孩子 ------ 这不刚好能用咱们之前说的托孤法嘛。

具体操作也很 straightforward,只需要让 replace 的父节点 replaceparent 指向 replace->right,就能顺顺利利把 replace 从树结构里给摘下来了。

cur = replace;

}

到这里,二叉搜索树最核心的增删查改逻辑我们就全部讲完了。从最基础的概念性质到性能分析,从搭建代码框架到逐行拆解每一个操作的细节,相信你已经对二叉搜索树的工作原理有了完整且深入的理解。

不过这还不是二叉搜索树的全部内容。在下一篇文章中,我们会继续完善它的所有常用接口,包括交换函数、中序遍历、默认构造、拷贝构造、赋值重载以及析构函数。

同时,我们还会结合实际开发需求,深入讲解二叉搜索树最核心的两个应用场景,纯key搜索场景和key-value键值对搜索场景,并给出对应的完整代码实现。内容同样干货满满,敬请期待!

相关推荐
shehuiyuelaiyuehao41 分钟前
关于进程和线程的关系
java·开发语言
‎ദ്ദിᵔ.˛.ᵔ₎1 小时前
C++哈希表
数据结构·c++·散列表
AaronCos1 小时前
弄懂java泛型中的extends和super
java·开发语言
毋语天1 小时前
Python 模块、包与异常处理:构建更稳健的程序
开发语言·python
IT观察者1 小时前
Qt单例的优雅实现
开发语言·qt
H_unique1 小时前
LangChain:消息
开发语言·langchain
求知也求真佳2 小时前
S07---S11 | 系统加固闭环总结:让你的 AI Agent 从 “能跑” 到 “稳跑、安全跑、长期跑”
开发语言·agent
JAVA学习通2 小时前
开云集致 Java开发 实习 一面
java·开发语言
小陈工2 小时前
Python异步编程进阶:asyncio高级模式与性能调优
开发语言·前端·数据库·人工智能·python·flask·numpy