作者:阿润菜菜
专栏:数据结构
例如,如果我们要用二叉搜索树存储一些人的姓名和年龄,我们可以用key/value模型,把姓名作为键值,把年龄作为数据值。这样我们就可以根据姓名快速查找或插入一个人的信息。
在key/value模型中,我们需要定义一个内部类来表示节点,每个节点包含一个键值、一个数据值、一个左子链接和一个右子链接。我们还可以定义一个节点计数变量来记录以该节点为根的子树中的节点个数,这样可以方便地实现一些有序符号表的操作。
在key的模型中,我们只需要定义一个内部类来表示节点,每个节点包含一个键值、一个左子链接和一个右子链接。我们不需要定义一个数据值或一个节点计数变量。
本文以key模型进行讲解
在二叉搜索树中,我们可以实现以下基本操作:
我们可以使用递归或迭代的方法来实现删除。递归方法基于二叉搜索树的性质:如果要删除的键值等于当前节点的键值,就按照上述三种情况处理;如果要删除的键值小于当前节点的键值,就在左子树中继续删除,并将返回的新的左子树赋给当前节点的左子链接;如果要删除的键值大于当前节点的键值,就在右子树中继续删除,并将返回的新的右子树赋给当前节点的右子链接;如果遇到空链接,就说明查找失败。
迭代方法基于循环和栈来实现。首先,我们使用一个循环来查找要删除的节点,并记录它的父节点和左右方向。然后,我们使用一个栈来存储从根节点到要删除的节点的路径。接着,我们按照上述三种情况处理要删除的节点,并更新它的父节点和左右方向。最后,我们使用一个循环来更新从根节点到要删除的节点的路径上所有节点的计数变量。
这样,就可以保证删除一个节点后,二叉搜索树的性质仍然成立。
代码示例: 已加上注释
bool _EraseR(Node*& root, const K& key) {
if (root == nullptr) return false;
if (root->_key < key) {
return _EraseR(root->_left, key);
} else if (root->_key > key) {
return _EraseR(root->_right, key);
} else {
Node* del = root; //创建一个指针del指向要删除的节点
//开始准备删除
if (root->_right == nullptr) { //如果要删除的节点没有右子节点
root = root->_left; //就用它的左子节点替换它
} else if (root->_left == nullptr) { //如果要删除的节点没有左子节点
root = root->_right; //就用它的右子节点替换它
} else { //如果要删除的节点有两个子节点
Node* maxleft = root->_left; //创建一个指针maxleft指向它的左子树
while (maxleft->_right) { //找到左子树中最右边的节点,即中序后继
maxleft = maxleft->_right;
}
swap(root->_key, maxleft->_key); //交换要删除的节点和中序后继的键值
return _EraseR(root->_left, key); //递归地在左子树中删除该键值
}
delete del; //释放要删除的节点的内存空间
return true;
}
}
同时还有非递归方法: 直接看代码和注释
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 {
// 1、左为空
if (cur->_left == nullptr) { // 如果当前节点没有左子树
if (cur == _root) { // 如果当前节点是根节点
_root = cur->_right; // 将根节点改为当前节点的右子树
} else { // 如果当前节点不是根节点
if (parent->_left == cur) { // 如果当前节点是父节点的左孩子
parent->_left =
cur->_right; // 将父节点的左孩子改为当前节点的右子树
} else { // 如果当前节点是父节点的右孩子
parent->_right =
cur->_right; // 将父节点的右孩子改为当前节点的右子树
}
}
delete cur; // 释放当前节点的内存
}
// 2、右为空
else if (cur->_right == nullptr) { // 如果当前节点没有右子树
if (cur == _root) { // 如果当前节点是根节点
_root = cur->_left; // 将根节点改为当前节点的左子树
} else { // 如果当前节点不是根节点
if (parent->_left == cur) { // 如果当前节点是父节点的左孩子
parent->_left = cur->_left; // 将父节点的左孩子改为当前节点的左子树
} else { // 如果当前节点是父节点的右孩子
parent->_right =
cur->_left; // 将父节点的右孩子改为当前节点的左子树
}
}
delete cur; // 释放当前节点的内存
}
// 3、左右都不为空
else // 如果要删除的节点有两个子节点
{
// 找右树最小节点替代,也可以是左树最大节点替代
Node* pminRight =
cur; // 定义一个指针指向当前节点,用来记录后继节点的父节点
Node* minRight =
cur->_right; // 定义一个指针指向当前节点的右子树,用来寻找后继节点
while (
minRight->_left) // 循环找到右子树中最小的节点,也就是最左边的节点
{
pminRight = minRight; // 更新后继节点的父节点
minRight = minRight->_left; // 更新后继节点
}
cur->_key = minRight->_key; // 将后继节点的值复制到当前节点
if (pminRight->_left == minRight) // 如果后继节点是它父节点的左孩子
{
pminRight->_left =
minRight
->_right; // 将后继节点的父节点的左孩子改为后继节点的右子树
} else // 如果后继节点是它父节点的右孩子
{
pminRight->_right =
minRight
->_right; // 将后继节点的父节点的右孩子改为后继节点的右子树
}
delete minRight; // 释放后继节点的内存
}
return true;
}
}
return false;
}
搜索:给定一个键值,在二叉搜索树中查找是否存在对应的节点。我们可以使用递归或迭代的方法来实现搜索。
搜索算法基于二叉搜索树的性质:
循环:
//查找
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
{
cur = cur->_left;
}
else if (cur->_key < key)
{
cur = cur->_right;
}
else
{
return true;
}
}
return false;
}
递归:
bool _FindR(Node* root, const K& key)
{
if (root == nullptr)
return false;
if (root->_key == key)
return true;
if (root->_key < key)
{
return FindR(root->_right);
}
else
{
return FindR(root->_left);
}
}
插入:给定一个键值和一个数据值,在二叉搜索树中插入一个新的节点。我们也可以使用递归或迭代的方法来实现插入。
插入算法基于二叉搜索树的性质:
循环:
// 插入一个键值为key的节点到二叉搜索树中
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;
}
// 如果当前节点的键值等于要插入的键值,说明已经存在,返回false
else {
return false;
}
}
// 创建一个新节点,键值为key
cur = new Node(key);
// 根据父节点的键值判断新节点是左孩子还是右孩子,并链接
if (parent->_key < key) {
parent->_right = cur;
} else {
parent->_left = cur;
}
// 返回true表示插入成功
return true;
}
递归方式:
bool _Insert(Node*& root, const K& key)
{
if (root = nullptr)
{
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;
}
}
这个复制函数是属于先复制后链接的方法。它的思想是先创建一个新的节点,然后再递归地复制它的左右子树,并将它们链接到新节点上。这样,每个节点都会被复制一次,并且保持了原始树的结构和顺序。你可以这样理解:
A
/ \
B C
/ \ / \
D E F G
A'
/ \
B' C'
/ \ / \
D' E'F' G'
代码:
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;
}
贴上码云仓库的连接:二叉搜索树K模型实现
另一个知识点:
例如查找给定的键是否存在于二叉搜索树中,调用私有的_FindR函数。这种方式是为了把_root作为参数传递给私有函数,这样私有函数就可以递归地操作二叉搜索树的节点。如果不这样做,私有函数就无法访问_root,因为它是一个私有的成员变量。
二叉搜索树的性能分析主要取决于树的高度,即从根节点到最深的叶子节点的层数。树的高度决定了每次操作需要访问的节点个数,因为每次操作都是沿着树的路径进行的。
二叉搜索树的高度又取决于树的形状,即节点在树中的分布。树的形状又取决于插入节点的顺序。如果插入节点的顺序是随机的,那么二叉搜索树会趋向于平衡,即左右子树的高度相差不大。如果插入节点的顺序是有序的,那么二叉搜索树会退化为链表,即只有一条单边路径。
我们可以用以下公式来估计二叉搜索树操作的平均时间复杂度:
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
可以看出,随机顺序插入节点可以使二叉搜索树保持较低的高度,从而提高操作效率。
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为: l o g 2 N log_2 N log2N
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为: N 2 \frac{N}{2} 2N
**问题:**如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插
入关键码,二叉搜索树的性能都能达到最优?后续介绍平衡二叉搜索树 — AVL树和红黑树。