二叉搜索树 - C++ 实现

概述 Overview

二叉查找树(英语:Binary Search Tree, 后文中简称 BST), 也称为二叉搜索树有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree), 是在 20 世纪 60 年代为解决标记数据的高效存储问题而设计的, 由 Conway Berners-Lee 和 David Wheeler 发明.

具体指的是一棵空树或者具有下列性质的二叉树:

  1. 若任意节点的左子树不空, 则左子树上所有节点的值均小于它的根节点的值;
  2. 若任意节点的右子树不空, 则右子树上所有节点的值均大于它的根节点的值;
  3. 任意节点的左、右子树也分别为二叉查找树;

简单来说, 二叉搜索树中的每一个节点都满足: 左子树中的所有元素均小于该节点元素; 右子树中的所有元素均大于该节点元素. \(左子树元素 \le 本节点元素 \le 右子树元素\). 左小右大.

这种结构上的设计使得 BST 可以以二分查找思路实现 \(\Omega(\log n)\) (不是大o, 而是下限在logn)级别的快速增, 删, 查等操作, 因为在树中的每一步操作都能排除一半的元素. 完成一颗二叉搜索树的建立之后, 我们还可以以中序遍历的方式得到排序后的序列, 这也是二叉搜索树被称为排序二叉树的原因.

需要注意的是, 二叉搜索树的效率与在建立时输入的元素顺序有很大的关系. 在最坏情况下, 二叉搜索树会退化成链表. 树层数大大增加使得查找等操作需要消耗更多的时间)此时, 需要对树进行额外的优化 - 平衡, 来保证高效的运行效率. 在本文中我们不作讨论, 本文仅介绍朴素的二叉搜索树.

如果看完之后还是不太理解的话, 可以看看这个美国旧金山大学CS做的一个算法可视化. Binary Search Tree Visualization (usfca.edu) 在这个网站上详细地看到 BST 每一步的操作. 做的很好, 不妨去玩玩!


基本操作 Operations

和其他的数据结构类似, 二叉搜索树也有着这样的几个基本操作.

搜索 Search

️ 根据元素值直接搜索节点

假设要搜素的节点的元素值为 item.

那么搜索就是要从数的根节点 root 开始, 逐节点遍历该树.

若当前扫描到的节点的元素值小于 item, 则往右走; 若元素值大于 item 则左走. 直到扫描到目标节点或遇到 nil 节点时(未找到该元素)停止搜索, 返回结果.

具体的代码实现如下.

void insert(const int item)
{
TreeNode *scan = root;
TreeNode *prev = root;
while (scan != nullptr)
{
prev = scan;
scan = item < scan->data ? scan->left : scan->right;
} TreeNode *newNode = new TreeNode(item);
if (root)
{
(item < prev->data ? prev->left : prev->right) = newNode;
newNode->parent = prev;
}
else
{
root = newNode;
} return;
}

️ 找节点的前驱/后继

前驱节点指的是树在中序遍历的序列中, 目标结点的前一个结点(类似链表中前驱的定义).(当目标结点为第一个结点是返回nil)

e.g. 如一颗树的中序遍历序列为: {1, 2, 3, 4, 5}, 那么元素值为 2 的节点的前驱就是元素值为 1 的节点; 元素值为 1 的节点的前驱就是 nil.

在BST中, 因为BST 的定义, 其中序遍历序列就是树中所有元素按元素值大小排序而输出的序列.

因此, 前驱节点也可以被定义成所有小于目标元素的所有元素中的最大值 \(\max( \{item\ |\ item \in tree \and item < target\})\).

根据此定义我们来完成搜索前驱节点的算法.

由 BST 的定义我们可以得到一个结论,

设有一个节点 node, 在中序遍历的序列中:

  1. 如果 node 是父节点的左节点

    左子树中的所有节点 < node < 父节点

  2. 如果 node 是父节点的右节点

    父节点 < node < 左子树中的所有节点

(光这样子讲可能有点抽象, 想象一下把一颗 BST 拍扁, 得到的序列就是中序遍历的序列, 容易得到以上结论)

基于上面的结论, 请考虑以下这两种 case.

  • case 01/ 有左子树. 所有有可能的 node 的均在左子树

    那就意味着, 我们只需要找到 node 左子树中最大的那个节点即可.

  • case 02/ 无左子树. 需要一直向祖先追溯. 直到找到第一个比 node 小的节点.

    第一个找到的节点就是在中序遍历中最接近 node 节点的节点. 也就是 node 的前驱结点.

    若找不到, 就说明 node 就是中序遍历中最小的元素, 返回 nil 节点.

具体代码实现如下.

TreeNode *p_pred(int item)
{
TreeNode *itemNode = p_find(item);
if (!itemNode)
return nullptr; if (itemNode->left)
{
return p_max(itemNode->left);
}
else
{
TreeNode *scan = itemNode->parent;
while (scan && scan->left == itemNode)
{
scan = scan->parent;
itemNode = itemNode->parent;
}
return scan;
}
}

while 退出时, 变量满足: scan == nullptr.(无前驱) 或 node != prev.left ( 等价于 node->data < item) (有前驱)

对应上文提到的两种 case.

查找后继同理, 代码也大差不差. 这里不再赘述. 直接给出代码.

TreeNode *p_succ(int item)
{
TreeNode *itemNode = p_find(item);
if (!itemNode)
return nullptr; if (itemNode->right)
{
return p_min(itemNode->right);
}
else
{
TreeNode *scan = itemNode->parent;
while (scan && scan->right == itemNode)
{
scan = scan->parent;
itemNode = itemNode->parent;
}
return scan;
}
}

️ 插入 Insertion

本文给出的是迭代的实现方法, 递归的方法应该很好想, 依据 BST 的定义来写就好了, 这里就省略不写了(偷懒 :p)

依据二叉搜索树的定义查找.

待插入的元素比当前扫描到的元素小就继续扫描左子树, 反之则继续扫描右子树, 直到扫描到nil节点就插入待插入的元素.

给出代码如下.

注意树为空的时候需要特殊处理哈.

void insert(const int item)
{
TreeNode *scan = root;
TreeNode *prev = root;
while (scan != nullptr)
{
prev = scan;
scan = item < scan->data ? scan->left : scan->right;
} TreeNode *newNode = new TreeNode(item);
if (root)
{
(item < prev->data ? prev->left : prev->right) = newNode;
newNode->parent = prev;
}
else
{
root = newNode;
} return;
}

删除 Deletion / Removal

删除的情况就相对比较多比较复杂了. BST 的删除需要考虑以下三种情况:

  1. 左右子树皆空.

    删除节点是叶节点, 将对应的节点置为 nil.

  2. 左右子树中只有其中一个非空.

    将节点的非空子树重新链接到节点的父节点即可.

  3. 左右子树均非空.

    这种情况是最棘手的情况. 我们做详细讨论.

情况三很麻烦.

如果也像前两种情况直接删除掉节点, 会出现两个无父节点的子树, 和原节点对应的父节点. 如果这个父节点原本还有两个子树的话, 那就意味着我们要面对三个子树, 和两个待链接的指针, 就必须要合并其中两个子树, 这会使问题会变得相当困难.

我们能不能把第三种情况也转换成前两种情况呢?

我们再仔细揣摩一下前驱和后继的定义.

也许你已经发现了这个性质, BST 中某个节点的前驱和后继一定是满足 case1 或 case2 的. (可以用反证法和前驱后继定义证明. 如果是case3 那么他就不是前驱或后继, 因为还有比该节点更大/小的节点, 不符合定义中的"最")

要是不可以直接删除, 可以做到用替换节点做到等效的删除吗? 怎样替换才可以保持 BST 的性质呢?

保持 BST 结构上的性质 也可以说是 使树的中序遍历序列结果不变.

我们想到可以用这个节点的前驱或后继来替换掉这个待删除节点. (在后文代码中使用后继, 两个方案都可以实现, 留给读者自行思考~)

因为待删除节点是不重要的, 替换之后中序遍历序列是不变的! 此后, 只需要替换之后将多出来的一个前驱/后继删除掉即可.

也就是说, 如此操作之后, 我们就将删除 case3 (待删除节点)的情况转换为删除 case1/2 (删除多余前驱/后继)的情况了!

具体地说, 用上之前找后继的代码. 用后继节点元素值替换待删除节点的元素值. 然后删掉多余的节点.

代码见下.

这个是直接使用指针的版本, 会啰嗦一点, 因为还需要根据 delNode 节点反推这个节点的指针(二级指针). 如果用上引用的话会代码简洁不少.

public:
void remove(const int item)
{
TreeNode *delNode = p_find(item);
if (delNode)
p_remove(delNode);
} private:
void p_remove(TreeNode *delNode)
{
if (delNode->isLeaf()) // case1
{
if (delNode->parent)
{
// judge which pointer of node to modify
if (delNode == delNode->parent->left)
{
delNode->parent->left = nullptr;
}
else
{
delNode->parent->right = nullptr;
}
}
else // when root == delNode;
{
root = nullptr;
}
delete delNode;
}
else if (delNode->left == nullptr) // case 2
{
// basically same as above
if (delNode->parent)
{
if (delNode == delNode->parent->left)
{
delNode->parent->left = delNode->right;
}
else
{
delNode->parent->right = delNode->right;
}
}
else // root == delNode;
{
root = delNode->right;
root->parent = nullptr;
}
delete delNode;
}
else if (delNode->right == nullptr)
{
// same as above
if (delNode->parent)
{
if (delNode == delNode->parent->left)
{
delNode->parent->left = delNode->left;
}
else
{
delNode->parent->right = delNode->left;
}
}
else // root == delNode;
{
root = delNode->left;
root->parent = nullptr;
}
delete delNode;
}
else
{
// case3
TreeNode *succNode = p_succ(delNode);
delNode->data = succNode->data;
p_remove(succNode);
} }

这个是引用的版本.

public:
void remove_ref(const int item)
{
TreeNode *&delNode = p_find_ref(item);
if (delNode)
p_remove_ref(delNode);
} private:
void p_remove_ref(TreeNode *&delNodeRef)
{
TreeNode *delPtr = delNodeRef;
if (delNodeRef->isLeaf())
{
delNodeRef = nullptr;
}
else if (!delNodeRef->left || !delNodeRef->right)
{
TreeNode *subTree = max(delNodeRef->right, delNodeRef->left);
if (root == delNodeRef)
subTree->parent = nullptr; delNodeRef = subTree;
}
else
{
TreeNode *succNode = p_succ(delNodeRef);
delNodeRef->data = succNode->data;
p_remove(succNode);
} delete delPtr;
}

如果还是不太理解的话, 可以看看这个美国旧金山大学 CS 做的一个算法可视化. Binary Search Tree Visualization (usfca.edu) 在这个网站上详细地看到 BST 中每一步的操作. 做的很好, 不妨去玩玩!


代码实现 Implement

下面的是 BST 类的完整实现.

class TreeNode
{
public:
TreeNode(int m_data) : data(m_data) {}
int data; TreeNode *left = nullptr;
TreeNode *right = nullptr;
TreeNode *parent = nullptr; bool isLeaf()
{
return left == nullptr && right == nullptr;
}
}; class BinarySearchTree
{
public:
void insert(const int item)
{
TreeNode *scan = root;
TreeNode *prev = root;
while (scan != nullptr)
{
prev = scan;
scan = item < scan->data ? scan->left : scan->right;
} TreeNode *newNode = new TreeNode(item);
if (root)
{
(item < prev->data ? prev->left : prev->right) = newNode;
newNode->parent = prev;
}
else
{
root = newNode;
} return;
} void remove(const int item)
{
TreeNode *delNode = p_find(item);
if (delNode)
p_remove(delNode);
} void remove_ref(const int item)
{
TreeNode *&delNode = p_find_ref(item);
if (delNode)
p_remove_ref(delNode);
} int find(const int item)
{
TreeNode *node = p_find(item); return node ? node->data : -1;
} int pred(const int item)
{
TreeNode *node = p_pred(item); return node ? node->data : -1;
} int succ(const int item)
{
TreeNode *node = p_succ(item); return node ? node->data : -1;
} void print()
{
p_printAll(root);
putchar('\n');
} private:
TreeNode *root = nullptr;
const TreeNode *empty = nullptr;
void p_printAll(TreeNode *node)
{
if (!node)
return; p_printAll(node->left);
printf("%d ", node->data);
p_printAll(node->right);
}
void p_remove(TreeNode *delNode)
{
if (delNode->isLeaf()) // case1
{
if (delNode->parent)
{
// judge which pointer of node to modify
if (delNode == delNode->parent->left)
{
delNode->parent->left = nullptr;
}
else
{
delNode->parent->right = nullptr;
}
}
else // when root == delNode;
{
root = nullptr;
}
delete delNode;
}
else if (delNode->left == nullptr) // case 2
{
// basically same as above
if (delNode->parent)
{
if (delNode == delNode->parent->left)
{
delNode->parent->left = delNode->right;
}
else
{
delNode->parent->right = delNode->right;
}
}
else // root == delNode;
{
root = delNode->right;
root->parent = nullptr;
}
delete delNode;
}
else if (delNode->right == nullptr)
{
// same as above
if (delNode->parent)
{
if (delNode == delNode->parent->left)
{
delNode->parent->left = delNode->left;
}
else
{
delNode->parent->right = delNode->left;
}
}
else // root == delNode;
{
root = delNode->left;
root->parent = nullptr;
}
delete delNode;
}
else
{
// case3
TreeNode *succNode = p_succ(delNode);
delNode->data = succNode->data;
p_remove(succNode);
} } void p_remove_ref(TreeNode *&delNodeRef)
{
TreeNode *delPtr = delNodeRef;
if (delNodeRef->isLeaf())
{
delNodeRef = nullptr;
}
else if (!delNodeRef->left || !delNodeRef->right)
{
TreeNode *subTree = max(delNodeRef->right, delNodeRef->left);
if (root == delNodeRef)
subTree->parent = nullptr; delNodeRef = subTree;
}
else
{
TreeNode *succNode = p_succ(delNodeRef);
delNodeRef->data = succNode->data;
p_remove(succNode);
} delete delPtr;
} TreeNode *p_find(const int item)
{
TreeNode *scan = root;
while (scan != nullptr && scan->data != item)
{
scan = item < scan->data ? scan->left : scan->right;
} return scan;
} TreeNode *&p_find_ref(const int item)
{
TreeNode *scan = root;
TreeNode *prev = nullptr;
while (scan != nullptr && scan->data != item)
{
prev = scan;
scan = item < scan->data ? scan->left : scan->right;
} if (root->data == item) return root;
if (!scan) return (TreeNode *&) empty;
return prev->left == scan ? (*prev).left : (*prev).right;
}
TreeNode *p_pred(int item)
{
TreeNode *itemNode = p_find(item);
if (!itemNode)
return nullptr; if (itemNode->left)
{
return p_max(itemNode->left);
}
else
{
TreeNode *scan = itemNode->parent;
while (scan && scan->left == itemNode)
{
scan = scan->parent;
itemNode = itemNode->parent;
}
return scan;
}
} TreeNode *p_succ(int item)
{
TreeNode *itemNode = p_find(item);
if (!itemNode)
return nullptr; if (itemNode->right)
{
return p_min(itemNode->right);
}
else
{
TreeNode *scan = itemNode->parent;
while (scan && scan->right == itemNode)
{
scan = scan->parent;
itemNode = itemNode->parent;
}
return scan;
}
} TreeNode *p_succ(TreeNode *itemNode)
{
if (itemNode->right)
{
return p_min(itemNode->right);
}
else
{
TreeNode *scan = itemNode->parent;
while (scan && scan->right == itemNode)
{
scan = scan->parent;
itemNode = itemNode->parent;
}
return scan;
}
} TreeNode *p_max(TreeNode *node)
{
if (!node)
return nullptr;
while (node->right)
{
node = node->right;
} return node;
}
TreeNode *p_min(TreeNode *node)
{
if (!node)
return nullptr;
while (node->left)
{
node = node->left;
} return node;
}
};

结构分析 Analysis

总结上文提到的所有操作, 对 BST 的每一步操作都是向树的深处再进一层, 而每一次进入一颗子树都意味着筛选掉了当前树中的一半节点, 对剩下来的一半节点继续操作.(二分思想)

由此, 我们可以容易的的得到一个结论: BST 中的最大查找次数取决于 BST 的最大深度, 即树高. 在最好情况下, 一个有着 n 个节点的树, 他的深度最小为 \(\log_2 n\). 此时, BST 可以达到最高效率.

但如果不是最好的情况呢? 很多时候 BST 生成的树不会是理想的, 常常会有一些不满的子树, 产生额外的深度. 此时, BST 就会退化成更低级的数据结构. 也就是说, 这个数据结构的时间复杂度下限为 \(\Omega(\log n)\), 而其上限仍然为 O(n), 与数组和链表差不多. (甚至插入删除还不如链表的 O(1).)

具体来说, 实际上, BST 的结构与插入元素的顺序有着很大的关系. 举个例子, 同一组数列的不同排列可以形成不同的树. 虽然他们序列中元素都是相同的, 但是生成的树的结构却不尽相同. 当插入元素的顺序已然有序的时候. 根据在基本操作中插入操作的实现. 此时, 在形成的树中, 元素只会在树的一侧堆积. 也就是说, 这个时候 BST 会退化成一个单链表. 使得操作效率大大降低.

那么怎么才能让 BST 的深度尽量的小呢? 具体地说, 是否有解决方案可以让 BST 保证每次的插入都可以调整成最优的结构, 不再退化成链表?

是有的! 平衡树的提出解决了 BST 的退化问题. 通过保证树中每一个节点的左右子树高度尽可能相同. 可以使整颗树的深度近似等于前文提到的最小深度 \(\log_2 n\).

本文中不再作详细讨论, 感兴趣可以自行搜索相关信息.


️ 参考资料 Reference

Wikipedia - 二叉搜索树 - 维基百科, 自由的百科全书 (wikipedia.org)

Wikipedia - Binary search tree - Wikipedia

OI-Wiki - AVL 树 - OI Wiki (oi-wiki.org)

二叉搜索树 - C++ 实现的更多相关文章

  1. [数据结构]——二叉树(Binary Tree)、二叉搜索树(Binary Search Tree)及其衍生算法

    二叉树(Binary Tree)是最简单的树形数据结构,然而却十分精妙.其衍生出各种算法,以致于占据了数据结构的半壁江山.STL中大名顶顶的关联容器--集合(set).映射(map)便是使用二叉树实现 ...

  2. [LeetCode] Serialize and Deserialize BST 二叉搜索树的序列化和去序列化

    Serialization is the process of converting a data structure or object into a sequence of bits so tha ...

  3. [LeetCode] Verify Preorder Sequence in Binary Search Tree 验证二叉搜索树的先序序列

    Given an array of numbers, verify whether it is the correct preorder traversal sequence of a binary ...

  4. [LeetCode] Lowest Common Ancestor of a Binary Search Tree 二叉搜索树的最小共同父节点

    Given a binary search tree (BST), find the lowest common ancestor (LCA) of two given nodes in the BS ...

  5. [LeetCode] Binary Search Tree Iterator 二叉搜索树迭代器

    Implement an iterator over a binary search tree (BST). Your iterator will be initialized with the ro ...

  6. [LeetCode] Convert Sorted List to Binary Search Tree 将有序链表转为二叉搜索树

    Given a singly linked list where elements are sorted in ascending order, convert it to a height bala ...

  7. [LeetCode] Convert Sorted Array to Binary Search Tree 将有序数组转为二叉搜索树

    Given an array where elements are sorted in ascending order, convert it to a height balanced BST. 这道 ...

  8. [LeetCode] Recover Binary Search Tree 复原二叉搜索树

    Two elements of a binary search tree (BST) are swapped by mistake. Recover the tree without changing ...

  9. [LeetCode] Validate Binary Search Tree 验证二叉搜索树

    Given a binary tree, determine if it is a valid binary search tree (BST). Assume a BST is defined as ...

  10. [LeetCode] Unique Binary Search Trees 独一无二的二叉搜索树

    Given n, how many structurally unique BST's (binary search trees) that store values 1...n? For examp ...

随机推荐

  1. 给定字符串定义char *a = “I love China!”,读入整数n,输出在进行了a = a + n这个赋值操作以后字符指针a对应的字符串

    include<stdio.h> include<string.h> int main() { const char *a="I love China!"; ...

  2. KingbaseES批量数据加载的实践技巧

    有时,KingbaseES数据库需要在单个或最少的步骤中导入大量数据,这通常称为批量数据导入.其中数据源通常是一个或多个大文件,这个过程有时可能非常慢. 造成性能不佳的原因有很多:索引.触发器.外键. ...

  3. 【读书笔记】C#高级编程 第十章 集合

    (一)概述 数组的大小是固定的.如果元素个数是动态的,就应使用集合类. List<T>是与数组相当的集合类.还有其它类型的集合:队列.栈.链表.字典和集. (二)列表 1.创建列表 调用默 ...

  4. Python实践项目——LSB隐写术

    此为北京理工大学某专业某学期某课程的某次作业 一.项目背景 1.隐写术 隐写术是一门关于信息隐藏的技巧与科学,所谓信息隐藏指的是不让除预期的接收者之外的任何人知晓信息的传递事件或者信息的内容. 2.L ...

  5. Flink SQL 子图复用逻辑分析

    子图复用优化是为了找到SQL执行计划中重复的节点,将其复用,避免这部分重复计算的逻辑.先回顾SQL执行的主要流程 parser -> validate -> logical optimiz ...

  6. Elasticsearch Painless script编程

    我们之前看见了在Elasticsearch里的ingest node里,我们可以通过以下processor的处理帮我们处理我们的一些数据.它们的功能是非常具体而明确的.那么在Elasticsearch ...

  7. Query String Query和Sumple Query String

  8. 一文搞定 Spring事务

    Spring 事务 上文 使用SpringJDBC 1.JDBC事务控制 ​ 不管你现在使用的是那一种ORM开发框架,只要你的核心是JDBC,那么所有的事务处理都是围绕着JDBC开展的,而JDBC之中 ...

  9. jq修改多个css样式

    $("#xxx").css({"属性名称": "属性值", "属性名称": "属性值" });

  10. 记Windows的一个存在了十多年的bug

    bug Windows有一个bug,持续了十多年,从Windows Visita开始(2007年),一直存在,直到Windows11(2021年)才修复(其实也不叫修复,后面我再具体说),而Windo ...