设为“置顶或星标”,第一时间送达干货。
![图解:什么是二叉排序树? 图解:什么是二叉排序树?](https://cxyxiaowu-1257126549.cos.ap-guangzhou.myqcloud.com/2020/05/1590745305-0184d76e78e0716.jpeg)
转自景禹
景禹的写作风格还是一如既往的细腻:),欢迎关注他。
以下为原文。
今天我们谈一谈 二叉排序树 ,一种你会爱上的数据结构,当然人有优缺,二叉排序是也是如此,我们一起开动脑筋征服她吧。
二叉排序树
二叉排序树(Binary Sort Tree)或者是一颗空树;或者是具有如下性质的二叉树:
(1) 若它的左子树不空,则 左子树 上所有结点的值 均小于 它的根结点的值;
(2) 若它的右子树不空,则 右子树 上所有结点的值 均大于 它的根结点的值;
(3) 它的 左、右子树又分别为二叉排序树 。
显然二叉排序树的定义是一个递归形式的定义,所以后面景禹要讲的插入、查找和删除都是基于递归的形式。
下图中的这颗二叉树就是一颗典型的二叉排序树:
二叉排序树既然是名字当中带有 排序 二字,这就是它相对于普通二叉树的优势所在。此时可能还不清楚,没关系,我们一步一步的建立一颗上面的二叉树就会很清晰。
假设我们初始时有如下 无序序列:
第一步:插入 8 作为根结点。
第二步:插入 3 ,与根结点 8 进行比较,发现比8小,且根结点没有左孩子,则将 3 插入到 8 的左孩子。
第三步:插入10,首先与根结点比较,发现比 8 大,则要将 10 插入根结点的右子树;根结点 8 的右子树为空,则将 10 作为 8 的右孩子。
第四步:插入 1,首先与根结点比较,比根结点小,则应插入根结点的左子树。再与根结点的左孩子 3 比较,发现比 3 还小,则应插入 3 的左孩子。
第五步:插入 6,先与根结点8比较,小于 8,向左走;再与 3 比较,大于 3,向有走,没有结点,则将6 作为3的右孩子。
第六步:插入14,先与8比较,比 8 大,向右走;再与8的右孩子10比较,比10大,向右走,没有结点,则将14作为10的右孩子。
第七步:插入4,先与8比较,发现比8小,向左走,再与3比较,比3大向右走,再与6比较,向左走且没有左孩子,将4作为6的左孩子。
第八步:插入7,先与8比较,发现比8小,向左走,再与3比较,向右走,在与6比较,继续向右走,发现6没有右孩子,则将7作为 6的右孩子插入。
第九步:插入13,先与8比较(大于)向右,再与10比较(大于)向右,再与14比较(小于)向左,发现14的左孩子为空,则将13插入到14的左孩子位置。
上面构造出的二叉排序树的中序遍历结果(关于二叉树的遍历不熟悉的,可以参看 一文横扫二叉树的所有遍历方法 ):
二叉排序树的查找操作
二分查找是在一个有序数组上进行的,就像前面我们通过对二叉排序树进行中序遍历得到的结果一样,初始时,我们将整个有序数组当做二分查找的搜索空间,然后计算搜索空间的中间元素,并与查找元素进行比较;然后将整个搜索空间缩减一半;重复上面的步骤,直到找到待查找元素或者返回查找失败的信息。关于二分查找的详细介绍可以参看 二分查找就该这样学 这篇文章。
二叉排序树的查找操作与二分查找非常相似,我们一起试着查找值为13的结点。
第一步:访问根结点 8 .
第二步:根据二叉排序树的左子树均比根结点小,右子树均比根结点大的性质, 13 > 8 ,因此值为13的结点可能在根结点 8 的右子树当中,我们查看根结点的右子节点 10 :
第三步:与第二步相似, 13 > 10 ,因此查看结点 10 的右孩子 14 :
第四步:根据二叉排序树的左子树均比根结点小,右子树均比根结点大的性质, 13 < 14 ,因此查看 14 的左孩子 13 ,发现刚好和要查找的值相等:
二叉排序树查找操作的实现:
struct node* search(struct node* root, int key)
{
// 如果root为空或者root的值和待查找的key相同,则返回root
if (root == NULL || root->key == key)
return root;
// 当待查找的关键字大于根结点的值,则递归查找根结点的右子树
if (root->key < key)
return search(root->right, key);
// 当待查找的关键字小于根结点的值,则递归查找根结点的左子树
return search(root->left, key);
}
二叉排序树的插入操作
对于任意一个待插入的元素 x 都是插入在二叉排序树的叶子结点,问题的关键就是确定插入的位置,原理当然和上面的查找操作一样,从根结点开始进行判断,直到到达叶子结点,则将待插入的元素作为一个叶子结点插入即可,多说无益,直接看图。
假设我们现在要插入值为 9 的结点,该怎么做呢?
第一步:访问根结点 8
第二步:根据二叉排序树的左子树均比根结点小,右子树均比根结点大的性质, 9 > 8 ,因此值为9的结点应该插入到根结点 8 的右子树当中,我们查看根结点的右子节点 10 :
第三步:根据二叉排序树的左子树均比根结点小,右子树均比根结点大的性质, 9 < 10 ,因此值为9的结点应该插入到结点 10 的左子树当中,访问结点10的左孩子,发现为空,则将 9 作为 10号结点的左孩子插入。
struct node* insert(struct node* node, int key)
{
/*如果树为空,则返回一个新的结点,相当于上面例子中10的左子树为空,则将9插入到10的左子树 */
if (node == NULL) return newNode(key);
/*当插入的值key小于当前结点的key,则递归判断左子树*/
if (key < node->key)
node->left = insert(node->left, key);
else if (key > node->key) /*当插入的值key大于当前结点的key,则递归判断右子树子树*/
node->right = insert(node->right, key);
/* 返回结点指针*/
return node;
}
二叉排序树的删除操作
删除操作与查找和插入操作不同,我们要分以下三种情况进行处理:
一、被删除的结点D是叶子结点:直接从二叉排序树当中移除即可,也不会影响树的结构
二、被删除的结点D仅有一个孩子:如果只有左孩子,没有右孩子,那么只需要把要删除结点的左孩子链接到要删除结点的父亲节点,然后删除D结点就好了;如果只有右孩子,没有左孩子,那么只要将要删除结点D的右孩子重接到要删除结点D的父亲结点。
假设我们要删除值为 14 的结点,其只有一个左孩子结点 13 ,没有右孩子 。
第一步:保存要删除结点 14 的 左孩子 结点 13 到临时变量 temp,并删除结点 14;
第二步:将删除结点 14 的父结点 10 的 右孩子 设置为 temp,即结点 13。
我们再以删除结点 10 为例,再看一下没有左孩子,只有一个右孩子的情况。
第一步:保存要删除结点 10 的 右孩子 结点 14 到临时变量 temp,并删除结点 10;
第二步:将删除结点 10 的父结点 8 的 右孩子 设置为 temp,即结点 14。
三、被删除结点的左右孩子都存在
对于这种情况就复杂一些了,但是我相信只要小禹禹耐心看下去,一定会有收获,这一次我们将图变得复杂一点儿,给原来结点 10 增加了一个左孩子结点 9 。
对于上面的二叉排序树的中序遍历结果如下所示:
现在我们先不考虑二叉排序上的删除操作,而仅在得到的中序遍历结果上进行删除操作。我们以删除中序遍历结果当中的顶点8为例进行说明:
当删除中序遍历结果中的 8 之后,哪一种方式不会改变中序遍历结果的有序性呢?
我们下面就来看删除左右孩子都存在的结点是如何实现的,依旧以删除根结点 8 为例。首先我们一起看用根结点的左子树当中值最大的结点 7 来替换根结点的情况。
第一步:获得待删除结点 8 的左子树当中值最大的结点 7 ,并保存在临时指针变量 temp 当中(这一步可以通过从删除结点的左孩子 3 开始,一个劲地访问右子结点,直到叶子结点为止获得):
第二步:将删除结点 8 的值替换为 7 ;
第三步:删除根结点左子树当中值最大的结点(这一步可能左子树中值最大的结点存在左子结点,而没有右子结点的情况,那么删除就退化成了第二种情况,递归调用即可):
我们再来一起看一下使用删除结点的 右子树 当中值最小的结点替换删除结点的情况:
第一步:查找删除结点 8 的右子树当中值最小的结点,即 9 (先访问删除结点的右子结点 10,然后一直向左走,直到左子结点为空,则得到右子树当中值最小的结点)。
第二步:将删除结点 8 的值替换为 9 ;
第三步:删除根结点右子树当中值最小的结点。
以上就是删除二叉排序树的三种情况的分析。
二叉排序树的删除操作代码实现:
struct node* deleteNode(struct node* root, int key)
{
// 如为空,直接返回
if (root == NULL) return root;
// 如果删除的值小于 root 的值,则递归的遍历rootde左子树
// 即删除结点位于左子树
if (key < root->key)
root->left = deleteNode(root->left, key);
// 如果删除的值大于 root 的值,则递归的遍历root的右子树
// 即删除结点位于右子树
else if (key > root->key)
root->right = deleteNode(root->right, key);
// 如果值相等,则删除结点
else
{
/*第一种情况可以包含在第二种当中,所以不做处理
这里处理第二种情况*/
//删除结点的左孩子为空
if (root->left == NULL)
{
struct node *temp = root->right;
if(root->key == 30){
printf("%dn",temp->key);
}
free(root);
return temp;
}
else if (root->right == NULL)//删除结点的右孩子为空
{
struct node *temp = root->left;
free(root);
return temp;
}
// 左右孩子均不为空,获取删除结点中序遍历的直接后继结点
struct node* temp = minValueNode(root->right);
// 将删除结点的值替换为直接后继结点的值
root->key = temp->key;
// 删除直接后继结点
root->right = deleteNode(root->right, temp->key);
/* 或者获取删除结点中序遍历的直接前驱结点。*/
//struct node* temp = maxValueNode(root->left);
// root->key = temp->key;
//root->left = deleteNode(root->left, temp->key);
}
return root;
}
时间复杂度分析
二叉排序树的插入和查找、删除操作的最坏时间复杂度为 ,其中 h 是二叉排序树的高度。最极端的情况下,我们可能必须从根结点访问到最深的叶子结点,斜树的高度可能变成 n,插入和删除操作的时间复杂度将可能变为 。下图就是两颗斜树(就相当于单链表)。这也是二叉排序树在进行多次插入操作后可能发生的不平衡问题,也是二叉排序树的缺陷所在,但这依旧不妨碍其作为一个伟大的数据结构。
LeetCode题解
题目来源于 LeetCode 98 验证二叉搜索树 Validate Binary Search Tree
题目描述
给定一个二叉树,判断其是否是一个有效的二叉搜索树。
假设一个二叉搜索树具有如下特征:
节点的左子树只包含小于当前节点的数。节点的右子树只包含大于当前节点的数。所有左子树和右子树自身必须也是二叉搜索树。
输入输出示例
输入:[2,1,3]
输出: true 示例 2:
输入:[5,1,4,null,null,3,6]。
输出: false
解释: 输入为: [5,1,4,null,null,3,6]。根节点的值为 5 ,但是其右子节点值为 4 。
题目解析
我们在来温习一下二叉排序树的定义。二叉排序树(Binary Sort Tree)或者是一颗空树;或者是具有如下性质的二叉树:
(1) 若它的左子树不空,则 左子树 上所有结点的值 均小于 它的根结点的值;
(2) 若它的右子树不空,则 右子树 上所有结点的值 均大于 它的根结点的值;
(3) 它的 左、右子树又分别为二叉排序树 。
方法一(简单但错误的方法)
最简单的方式就是对每一个顶点,判断其左孩子是不是比它小,左孩子是否比它大。
bool isValidBST(struct TreeNode* root){
if( root == NULL ) return true;
if( root->left != NULL && root->left->val > root->val )
return false;
if( root->right != NULL && root->right->val < root->val )
return false;
if( !isValidBST(root->left) || !isValidBST(root->right) )
return false;
return true;
}
上图中的根结点是 6 ,而其左子树中的结点 7 大于 6 ,显然不符合二叉排序树的定义;右子树当中的结点 1 小于根结点 6,同样是不合理的;但是你发现上面程序会返回 true,因为上面的程序仅检查了一个结点的左孩子和右孩子,我们自然想到检查一个的结点左子树当中的最大顶点是否比结点小,右子树当中值最小的顶点是否比结点大不就好了,比如根结点 6 的左子树当中最大的顶点为 7 大于 6 ,所以不是一颗二叉排序树,返回false;
方法二(正确但并不高效)
对于每一个结点,检查结点左子树中值最大的结点的值是否小于结点,右子树中值最小的结点是否大于结点。
int minValueNode(struct TreeNode* node)
{
struct TreeNode* current = node;
/*从删除结点的右孩子开始一直向左走,找到最小值*/
while (current && current->left != NULL)
current = current->left;
return current->val;
}
/* 返回删除结点左子树当中的值最大的结点指针 */
int maxValueNode(struct TreeNode* node)
{
struct TreeNode* current = node;
/*从删除结点的右孩子开始一直向左走,找到最小值*/
while (current && current->right != NULL)
current = current->right;
return current->val;
}
bool isValidBST(struct TreeNode* root){
if(root == NULL) return true;
if(root->left != NULL && maxValueNode(root->left) >= root->val) return false;
if(root->right != NULL && minValueNode(root->right) <= root->val) return false;
if(!isValidBST(root->left) || !isValidBST(root->right)) return false;
return true;
}
方法三(正确且高效)
方法二低效在哪儿呢?对于每一个结点都查找其左子树中值最大的结点,查找其右子树中值最小的结点,这样不可避免地会重复查找一棵树的子树,效率自然低下。更好的解决方案就是只对树中的每个结点检查一次。诀窍就是写一个辅助函数 isBSTUtil(struct TreeNode* root, long long min ,long long max)
来遍历树,不断更新每一个遍历结点的最大与最小值的取值,初始时将最小值设置为 LONG_MIN
,将最大值设置为 LONG_MAX
;当递归调用左子树是,最大值则被更新为结点的值为 max = root->val
( 左子树结点的最大值一定小于root->val ),即调用 isBSTUtil(root->left,min, root-val)
;当递归调用右子树时,右子树中所有结点的下界也比 root
的值大,故更新 min = root-val
(右子树结点的最小值一定大于root->val),即调用 isBSTUtil(root->right, root->val, max)
.
int isBSTUtil(struct TreeNode* node, long long min, long long max)
{
/* 是一颗空树 */
if (node==NULL)
return 1;
/* 结点的值小于等于最小值,或者大于等于最大值都是不合理的,返回false */
if (node->val <= min || node->val >= max)
return 0;
/*否则递归地判断结点的左子树和右子树*/
return
isBSTUtil(node->left, min, node->val) &&
isBSTUtil(node->right, node->val, max);
}
bool isValidBST(struct TreeNode* root){
return isBSTUtil(root,LONG_MIN, LONG_MAX);
}
方法四(简化方法三)
我们可以使用空指针代替 LONG_MIN
和 LONG_MAX
的值。(道理和方法三一样,一个结点的左子树中结点的最大值不会超过结点的值,一个结点的右子树中结点的最小值也会大于结点的值)
class Solution {
public:
bool isValidBST(TreeNode* root,TreeNode* l=NULL, TreeNode* r=NULL) {
if(root == NULL) return true;
if(l != NULL && root->val <= l->val) return false;
if(r != NULL && root->val >= r->val) return false;
return isValidBST(root->left, l, root) and
isValidBST(root->right, root, r);
}
};
方法五(巧妙简单高效)
前面我们讨论过二叉排序树的中序遍历结果是一个有序数组,且有序数组的前一个元素一定小于后一个元素。
在进行中序遍历时,我们可以通过使用在递归调用函数中传入一个用于保存当前访问结点前一个结点值的整型变量 pre
,如果当前访问结点的值小于或者等于他的前驱结点的值,则该树不是二叉排序树。
class Solution {
bool isBSTUtil(struct TreeNode* root, long long& prev)
{
if (root) {
if (!isBSTUtil(root->left, prev))
return false;
// 当前结点小于等于它的直接前驱顶点,返回false
if (root->val <= prev)
return false;
//初始化pre 为当前结点
prev = root->val;
return isBSTUtil(root->right, prev);
}
return true;
}
public:
bool isValidBST(TreeNode* root) {
long long prev = LONG_MIN;
return isBSTUtil(root, prev);
}
};
感兴趣的可以去LeetCode上提交一下,最后一种实现方式的时间复杂度为 ,空间复杂度 。
推荐阅读
• C++是如何从代码到游戏的?• 告诉你一个学习编程的诀窍(建议收藏)• 自学编程的八大误区!克服它!• 新手如何有效的刷算法题(LeetCode)• 10款VS Code插件神器,第7款超级实用!• 在拼多多上班,是一种什么样的体验?我tm心态崩了呀!• 写给小白,从零开始拥有一个酷炫上线的网站!
欢迎关注我的公众号“五分钟学算法”,如果喜欢,麻烦点一下“在看”~
![图解:什么是二叉排序树? 图解:什么是二叉排序树?](https://cxyxiaowu-1257126549.cos.ap-guangzhou.myqcloud.com/2020/05/1590745328-8ccd97e48f6c82f.jpeg)
原文始发于微信公众号(五分钟学算法):图解:什么是二叉排序树?