1. 二叉搜索树实现
2.二叉树搜索树应用分析
3. 二叉树进阶面试题
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
下面就是一颗二叉搜索树,我们下面的模拟实现都按照这棵树来实现。
int a[] = {8, 3, 1, 10, 6, 4, 7, 14, 13};
根据我们之前学习的二叉树章节,我们是用左孩子右兄弟来描述一个结点的,所以我们这里也先来描述一下结点的信息,之前我们使用的是struct,这里我们就是用class来描述结点信息。首先使用结构体+模板来创建结点,里面需要给出左子树,右子树,结点的值。只需要写一个构造函数对其值赋初值就行了。
// T - type K - 关键字
template
//struct BinarySearchTreeNode
struct BSTreeNode
{
typedef BSTreeNode Node;
Node* _left;
Node* _right;
K _key;
BSTreeNode(const K& key)
:_left(nullptr)
,_right(nullptr)
,_key(key)
{}
};
a、从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找。
b、最多查找高度次,走到到空,还没找到,这个值不存在。
对比我们之前的二分查找算法,我们来看看二分查找算法的缺陷
我们之前学习的查找就是二分查找,但是该查找的前提必须要求数组有序,所以每次插入一个数据,就可能要导致重新排序,并且在数组中还要挪动数据,非常难以维护,而对于搜索二叉树成本低,查找效率也快,最坏情况查找也只会走二叉搜索树的高度次,而且搜索二叉树的中序遍历就是有序的。
bool Find(const K& key)//查找key值
{
Node* _cur = _root;
while (_cur)
{
if (_cur->_key < key)
{
_cur = _cur->_right;
}
else if(_cur->_key > key)
{
_cur = _cur->_left;
}
else
{
//找到了key值
return true;
}
}
//此时cur以已经为空,说明找不到key值
return false;
}
注意:如果插入的key值已经存在二叉搜索树中了,此时我们就认为不能插入了。
a. 树为空,则直接新增节点,赋值给root指针
b. 树不空,按二叉搜索树性质查找插入位置,插入新节点
然后我们来看一下我们的代码有没有什么问题?
bool Insert(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 false;
}
}
//此时_cur为空
_cur = new Node(key);
return true;
}
看上去挺对,但是我们忽略了一个问题,我们申请的结点给到_cur指针了,而且它时一个局部变量,出了作用域不仅消耗了,而且还会出现内存泄漏,我们此时要解决问题,就要与父指针进行链接,所以此时我们要找到_cur的父节点。
bool Insert(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
{
//如果要插入的值和当前值相等,那就不能插入了
return false;
}
}
//此时_cur为空
_cur = new Node(key);
if (_parent->_key < key)//要插入的值比当前值大
{
_parent->_right = _cur;
}
else//要插入的值比当前值小
{
_parent->_left = _cur;
}
return true;
}
但是我们的代码还存在一个小bug,如果我们的树一开始一个结点都没有,此时为空树,那么while循环体就没有进入,那么此时的_parent就还是空指针,那么此时访问_parent结点里面的元素就会出现错误。
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;
}
}
//此时_cur为空
_cur = new Node(key);
if (_parent->_key < key)//要插入的值比当前值大
{
_parent->_right = cur;
}
else//要插入的值比当前值小
{
_parent->_left = cur;
}
return true;
}
根据二叉搜索树的中序是有序的,所以我们这里通过走一遍中序来测试我们的程序,所以我们这里先来实现一下中序。
void InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
InOrder(root->_left);
cout << root->_key << " ";
InOrder(root->_right);
}
但是我们这里的中序无法调用,因为我们的中序要求传入根结点,如果不传入根节点我们这里就无法递归了,我们这里有两种解决方法,第一个方法是写一个获取根节点的函数,因为我们的根节点是私有的,或者我们还可以套一层子函数
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
void InOrder()
{
_InOrder(_root);
cout << endl;
}
现在我们来测试一下此时输出的结果。
根据二叉搜索树的中序是有序的,所以我们上面的结果是正确的。对于我们的插入和查找都是比较简单的,而对于二叉搜索树的删除才是最棘手的。
我们首先来看第一种情况,当左为空,将右托付给父亲,此时是托付给父亲的右还是父亲的左呢?
所以此时我们还要进行判断,若要删除的结点左为空,就将要删除结点的右托付给父亲。
再来看一下第二种情况,当右为空,将左托付给父亲,此时是托付给父亲的右还是父亲的左呢?同样的要分情况讨论。
现在我们再来看一下我们的第三种情况,它也是最复杂的,我们下面采用第二种:右子树的最左结点,此时该节点一定是左为空,右不一定为空。
我们能不能直接将结点3和结点4进行交换,然后再删除结点3呢?这样是不行的,因为交换之后按照二叉搜索树的特点,我们就不能找到3结点了。然后我们再来看看我们上面的写法有没有上面问题。如果我们删除的是根节点8呢?
如果我们删除的是根节点8,此时右树的根就为最左结点,此时循环体就没有进入,由于rightMinParent赋值为空指针,后面访问就会出现崩溃。所以我们要想解决,就不能将rightMinParent赋值为空,而需要将其赋值为_cur,并且加一个判断,rightMin有可能为rightMinParent的左,有可能为右,然后单独链接rightMin的右,这样即可解决。
这样就解决了问题,我们来测试一下。
我们再来测试一下其他情况,
如果我们把所有的值都删除呢?
此时我们发现我们的程序崩溃了,为什么呢?
我们发现此时删除最后一个结点出现了问题,父结点是空的,因为我们此时没有进入循环,直接走的else语句,由于我们赋值parent是空指针,所以此时就出现了问题,这个问题就好比我们下面的场景。
第一种情况要删除的节点左为空,需要将右托付给父亲;第二种情况要删除的节点右为空,需要将左托付给父亲,但是由于此时是根节点,父指针为空,此时要解决就要单独处理。
此时我们再来测试一下结果
现在我们再来写一下递归的形式,但是要注意这里的递归都必须要套一层,因为我们这里的_root是私有的,外部不能使用。
bool _InsertR(Node*& root, const K& key)
{
//当走到空,就可以插入了
if (root == nullptr)
{
// 如何与父亲进行链接呢?可以在传root使用传引用
// 此时要进行链接的时候,root刚好是root->_left或者root->_right的别名
// 此时刚好就可以把要插入的结点与父节点相链接
root = new Node(key);
return true;
}
if (root->_key < key)
return _InsertR(root->_right, key);
else if (root->_key > key)
return _InsertR(root->_left, key);
else
return false;
}
图解:
然后我们再来看一下删除的递归写法:
情况1:删除的节点右为空
情况2:删除的节点左为空
情况3:删除的节点左右都不为空
假如我们要删除的是节点是3,此时节点3刚好是root,能不能使用引用的方法,让右树的最左节点4成为root的别名,然后再替换删除节点呢?
我们这里思路是依然找到右树的最小节点rightMin,然后将rightMin与root交换,然后复用删除的代码即可
bool _EraseR(Node*& root, const K& key)
{
//没找到
if (root == nullptr)
return false;
if (root->_key < key)
return _EraseR(root->_right, key);
else if (root->_key > key)
return _EraseR(root->_left, key);
else
{
Node* del = root;//保存要删除的节点
//找到了
if (root->_right == nullptr)
root = root->_left;
else if (root->_left == nullptr)
root = root->_right;
else
{
// 这里不能加引用
// 引用不能改变指向
// 找右树的最小节点
Node* rightMin = root->_right;
Node*& rightMin = root->_right;
while (rightMin->_left)
{
rightMin = rightMin->_left;
}
swap(root->_key, rightMin->_key);
return _EraseR(root->_right, key);
}
delete del;
return true;
}
}
此时测试一下我们的程序
现在我们再来写一下Destory函数
void Destory(Node* root)
{
if (root == nullptr)
return;
Destory(root->_left);
Destory(root->_right);
}
通过这个Destory函数来完成我们的析构函数。
~BSTree()
{
Destory(_root);
}
如果我们要对二叉搜索树进行拷贝呢?很明显,我们这里没有写拷贝构造函数,那就使用的是默认的拷贝构造函数,此时是浅拷贝,必然会出现问题,我们这里要使用深拷贝的写法。
BSTree(const BSTree& t)
{
_root = Copy(t._root);
}
Node* Copy(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;
}
我们来测试一下
对于赋值的话就比较简单了,直接用一个现代写法就足够了
BSTree& operator=(const const BSTree t)
{
swap(_root, t._root);
return *this;
}
1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到 的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
2. KV模型:每一个关键码key,都有与之对应的值Value,即的键值对。该种方 式在现实生活中非常常见:
我们上面的写的二叉搜索树的模型就是我们的K模型,现在我们来改造一下,让它是一个KV模型的数。修改我们对于插入的逻辑没有什么变化,但是在查找的时候我们的返回值需要改一下,因为我们还要找打到value,其他的变化就存储结点的结构体变化一下
// T - type K - 关键字
template
//struct BinarySearchTreeNode
struct BSTreeNode
{
typedef BSTreeNode Node;
Node* _left;
Node* _right;
K _key;
V _value;
BSTreeNode(const K& key, const V& value)
: _left(nullptr)
, _right(nullptr)
, _key(key)
, _value(value)
{}
};
我们来测试一下
void TestBSTree1()
{
// 输入单词,查找单词对应的中文翻译
keyvalue::BSTree dict;
dict.Insert("string", "字符串");
dict.Insert("tree", "树");
dict.Insert("left", "左边、剩余");
dict.Insert("right", "右边");
dict.Insert("sort", "排序");
// 插入词库中所有单词
string str;
while (cin >> str)
{
keyvalue::BSTreeNode* ret = dict.Find(str);
if (ret == nullptr)
{
cout << "单词拼写错误,词库中没有这个单词:" << str << endl;
}
else
{
cout << str << "中文翻译:" << ret->_value << endl;
}
}
}
运行结果:
同时我们还可以使用KV模型统计次数。
void TestBSTree4()
{
// 统计水果出现的次数
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };
keyvalue::BSTree countTree;
for (const auto& str : arr)
{
// 先查找水果在不在搜索树中
// 1、不在,说明水果第一次出现,则插入<水果, 1>
// 2、在,则查找到的节点中水果对应的次数++
//BSTreeNode* ret = countTree.Find(str);
auto ret = countTree.Find(str);
if (ret == NULL)
{
countTree.Insert(str, 1);
}
else
{
ret->_value++;
}
}
countTree.InOrder();
}
我们来看一下运行结果
为什么我们这里的数字无序呢?中序不是有序的嘛?这里要注意一点,中序有序值得是key有序,而不是value有序。
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二 叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树: