文章目录
- 引言:对"效率"的极致追求
- 一、"左小右大":二叉搜索树的铁律
- 二、二叉搜索树递归实现
-
- 1.结构设计与创建
- 2.插入:按"规矩"找到自己的位置
- [3. 遍历:天然的"排序器"](#3. 遍历:天然的“排序器”)
- [4. 查找:高效的"二分"](#4. 查找:高效的“二分”)
- 5.树高:递归丈量"深度"
- 6.删除:棘手的"重建"艺术
- 7.销毁:"斩草除根"
- 三、迭代实现:告别"栈溢出"的循环
- 四、总结:秩序的代价
引言:对"效率"的极致追求
在之前的探索中,我们已经掌握了多种数据结构。我们学习了数组,它能通过索引实现 O(1) 的随机访问,但前提是你知道索引;如果我们想在其中查找 某个特定值,只能从头到尾遍历,效率是 O(n)。
后来我们学习了二分查找 ,它能将查找效率一举提升 O(log n)。但它有一个苛刻的前提:数据必须存储在有序的数组中 。这个前提,使得插入和删除操作变成了 O(n) ,因为你必须移动大量元素来维持秩序。
那么有没有一种"两全其美"的结构?它既能像二分查找那样高效(O(log n)),又能像链表那样灵活地插入和删除(O(log n))?
于是,我们利用二分查找的思想,与"树"的结构巧妙结合,创建了另一类至关重要的二叉树结构------二叉搜索树(BST) 。它通过一条规则------左子树所有节点小于根,右子树所有节点大于根,使得二叉树变得有序,查找、插入、删除等这些操作在平均情况下都达到对数级的时间复杂度。二叉搜索树是后续平衡二叉搜索树等更复杂结构的基础。
一、"左小右大":二叉搜索树的铁律
二叉搜索树,也常被称为二叉排序树。它之所以能实现高效查找,只因它在二叉树的基础上,坚定不移地执行着一条"铁律":
- 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值
- 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值
- 它的左右树也分别为二叉搜索树
这个递归的定义,赋予了整棵树一种全局的有序性。这种有序性,让我们在查找、插入、删除时,拥有了和二分查找一样的"决策能力"------在任何一个节点,我们都能通过一次比较,立刻知道下一步该向左走,还是向右走。
| 结构 | 查找元素 | 插入元素 | 删除元素 |
|---|---|---|---|
| 普通数组 | O(n) |
O(n) |
O(n) |
| 顺序数组 | O(logn) |
O(n) |
O(n) |
| 二叉搜索树 | O(logn) |
O(logn) |
O(logn) |
二、二叉搜索树递归实现
二叉搜索树的递归定义,天然适合用递归函数来操作。
1.结构设计与创建
首先,我们定义节点(BSNode)和树的"管家"(BSTree)。这个"管家"结构体(树头)不存储实际数据,而是负责维护 root 指针和 count 等元信息,这与我们之前实现链表的思想(头节点)一致。
c
typedef int Element;
// 定义二叉搜索树的节点结构
typedef struct _bs_node {
Element data;
struct _bs_node *left;
struct _bs_node *right;
} BSNode;
// 定义二叉搜索树的头节点(树头)
typedef struct {
BSNode *root;
int count;
const char *name;
} BSTree;
BSTree* createBSTree(const char* name) {
BSTree* tree = malloc(sizeof(BSTree));
if (tree == NULL) {
return NULL;
}
tree->name = name;
tree->root = NULL;
tree->count = 0;
return tree;
}
2.插入:按"规矩"找到自己的位置
插入操作是BST递归思想的完美体现。我们提供一个对外接口 insertBSTree,它内部调用一个 static (私有) 的辅助函数 insertBSNode。BST插入必须通过值比较来确保结构的有序性。
c
// 辅助函数:创建新节点
static BSNode *createBSNode(Element e) {
BSNode *node = malloc(sizeof(BSNode));
if (node == NULL) {
return NULL;
}
node->data = e;
node->left = node->right = NULL;
return node;
}
// 辅助函数:递归插入的"引擎"
static BSNode* insertBSNode(BSTree *tree, BSNode *node, Element e) {
// 1. 递归终止条件:找到了插入的空位
if (node == NULL) {
tree->count++;
return createBSNode(e); // 创建新节点并返回
}
// 2. 递归查找:根据"左小右大"规则,决定向哪边走
if (e < node->data) {
// 递归插入到左子树,并更新左子树的链接
node->left = insertBSNode(tree, node->left, e);
} else if (e > node->data) {
// 递归插入到右子树,并更新右子树的链接
node->right = insertBSNode(tree, node->right, e);
}
// (如果 e == node->data,我们默认不做操作,保持树的唯一性)
// 3. 返回当前节点(保持链接不变)
return node;
}
// 对外接口:插入操作
void insertBSTree(BSTree* tree, Element e) {
// 递归的"启动"
tree->root = insertBSNode(tree, tree->root, e);
}
3. 遍历:天然的"排序器"
由于"左小右大"的铁律,当我们对二叉搜索树进行中序遍历 (左 -> 根 -> 右)时,会得到一个严格递增的有序序列。这不仅是验证BST正确性的绝佳手段,也是它"排序树"别名的由来。
c
void visitBSNode(const BSNode* node) {
printf("\t%d", node->data); // 打印节点值
}
static void inOrderBSNode(const BSNode *node) {
if (node) {
inOrderBSNode(node->left); // 左
visit(node); // 根
inOrderBSNode(node->right); // 右
}
}
void inOrderBSTree(const BSTree *tree) {
printf("[%s]Tree:", tree->name);
inOrderBSNode(tree->root);
printf("\n");
}
4. 查找:高效的"二分"
查找操作是非递归的,它完美地再现了"二分查找"的过程。
c
BSNode* searchBSTree(const BSTree* tree, Element e) {
BSNode *node = tree->root;
while (node != NULL) {
if (e < node->data) {
node = node->left; // 目标更小,去左边找
} else if (e > node->data) {
node = node->right; // 目标更大,去右边找
} else {
return node; // 找到了!
}
}
return NULL; // 遍历到底也没找到
}
5.树高:递归丈量"深度"
树的高度,是从根节点到最远叶节点的最长路径上的边数(或节点数,此处按节点数定义)。在二叉搜索树中,高度反映了树的"纵深"程度,也间接影响着查找、插入等操作的时间复杂度------理想情况下,树越"矮胖",效率越高;越"瘦高",越接近链表,性能退化。
计算树高天然适合递归:当前节点的高度,等于其左右子树高度的最大值加一。空节点高度为0,这是递归的边界条件。
c
static int heightBSNode(const BSNode* node) {
if (node == NULL) {
return 0; // 空节点,高度为0
}
int leftHeight = heightBSNode(node->left); // 递归求左子树高度
int rightHeight = heightBSNode(node->right); // 递归求右子树高度
if (leftHeight > rightHeight) {
return ++leftHeight;
}
return ++rightHeight;
}
int heightBSTree(const BSTree* tree) {
return heightBSNode(tree->root);
}
6.删除:棘手的"重建"艺术
删除是BST中最复杂的操作,因为它必须在移除一个节点后,依然保持"左小右大"的铁律。我们同样使用递归实现,根据被删除节点的"度的数量"分三种情况讨论:
- 度为 0 (叶子节点) :可以直接
free,并让其父节点指向NULL。 - 度为 1 (只有一个孩子):也比较简单,让其父节点"跳过"自己,直接指向自己的那个唯一孩子。
- 度为 2 (左右孩子俱全) :最棘手。
- 策略 :为了不破坏结构,我们不能直接删除它。我们从它的左子树 中,找到那个值最大 的节点(即它的"前驱"),或者从右子树 中找到值最小的节点(即它的"后继")。
- 替换 :将这个"前驱"(或"后继")节点的值,复制到当前要删除的节点上。
- 转化:转而去删除那个"前驱"(或"后继")节点。由于这个"前驱"(或"后继")节点一定是其子树中最边缘的,它自己的度必然只可能是0或1,这就把一个复杂问题转化成了我们能解决的情况1或情况2。
c
// 辅助函数:在 node 的子树中找到值最大的节点
static BSNode* maxValueBSNode(BSNode *node) {
while (node && node->right) {
node = node->right;
}
return node;
}
static BSNode* deleteBSNode(BSTree* tree, BSNode *node, Element e) {
if (node == NULL) {
return NULL;
}
// 1. 递归查找
if (e < node->data) {
node->left = deleteBSNode(tree, node->left, e);
} else if (e > node->data) {
node->right = deleteBSNode(tree, node->right, e);
} else {
// 2. 找到了, e == node->data,开始执行删除
BSNode *tmp;
if (node->left == NULL) { // 度为0或1
tmp = node->right;
free(node);
tree->count--;
return tmp; // 返回右孩子(或NULL)给上一层
}
if (node->right == NULL) { // 度为1
tmp = node->left;
free(node);
tree->count--;
return tmp; // 返回左孩子给上一层
}
// 此时说明待删除节点的度为2,替换当前节点值(后继或前驱)
// 找这个节点的左节点的最大值
tmp = maxValueBSNode(node->left);
// 复制前驱的值到当前节点
node->data = tmp->data;
// 递归地从左子树中删除那个前驱节点
// (注意:此时删除的 e 变成了 node->data,也就是 tmp->data)
node->left = deleteBSNode(tree, node->left, node->data);
}
return node;
}
// 对外接口:删除操作
void deleteBSTree(BSTree* tree, Element e) {
if (tree) {
tree->root = deleteBSNode(tree, tree->root, e);
}
}
7.销毁:"斩草除根"
对于二叉搜索树的销毁,我们需要先把左右子树删除,最后删除根节点。可以用后续遍历的方法。
c
static void freeBSNode(BSTree *tree, BSNode *node) {
if (node) {
freeBSNode(tree, node->left); // 左
freeBSNode(tree, node->right);// 右
free(node); // 根
tree->count--;
}
}
void releaseBSTree(BSTree *tree) {
if (tree) {
freeBSNode(tree, tree->root);
printf("There are %d nodes.\n", tree->count);
free(tree);
}
}
三、迭代实现:告别"栈溢出"的循环
递归虽然优雅,但在树极深的情况下可能导致栈溢出。使用循环(迭代)是更稳健的选择。
1.非递归插入
非递归插入的思路,就是用一个 pre 指针始终"尾随" cur 指针,以便在 cur 找到空位时,pre 能执行链接操作。
c
void deleteBSTree(BSTree* tree, Element e) {
BSNode *cur = tree->root;
BSNode *pre = NULL;
while (cur) {
pre = cur;
if (e < cur->data) {
cur = cur->left;
} else if (e > cur->data) {
cur = cur->right;
} else {
return;
}
}
// 可能插入的是第一个根元素,可能插入的是一个普通的位置
BSNode *node = createBSNode(e);
if (pre) {
if (e < pre->data) {
pre->left = node;
} else if (e > pre->data) {
pre->right = node;
}
} else {
tree->root = node;
}
tree->count++;
2.非递归删除
非递归删除是所有操作中最复杂的,因为它需要处理的指针关系和边界情况最多。我们将递归中的三种情况(度为0、1、2)在迭代中实现。
核心思路:
- 查找:首先,我们必须通过循环找到要删除的节点
cur,并且始终要保留其父节点pre的指针。 - 处理:根据
cur的度(0, 1, 2)执行不同的逻辑。
c
// 辅助函数:度为2时进行的操作
// 策略:找到 cur 的后继节点(右子树中最小的节点),用其值覆盖 cur,然后转化为删除那个后继节点(后继节点度必为0或1)
static void deleteMiniNode(BSNode *node) {
// 1. 查找后继节点及其父节点
BSNode *mini = node->right;
BSNode *pre = node;
while (mini->left) {
pre = mini;
mini = mini->left;
}
// 2.转化为删除后继节点
if (pre->data == mini->data) {
pre->right = mini->right;
} else {
pre->left = mini->right;
}
// 3.将后继节点的值 复制 到当前节点
node->data = mini->data;
free(mini);
}
void deleteBSTree(BSTree* tree, Element e) {
// 查找要删除的节点(cur)及其父节点(pre)
BSNode *node = tree->root;
BSNode *pre = NULL;
while (node) {
pre = node;
if (e < node->data) {
node = node->left;
} else if (e > node->data) {
node = node->right;
} else {
break;
}
}
if (node == NULL) {
printf("No %d element!\n", e);
return;
}
BSNode *tmp = NULL;
if (node->left == NULL) {
tmp = node->right;
} else if (node->right == NULL) {
tmp = node->left;
} else {
deleteMiniNode(node->right);
--tree->count;
return;
}
if (pre) {
if (node->data < pre->data) {
pre->left = tmp;
} else {
pre->right = tmp;
}
} else {
tree->root = tmp;
}
free(node);
--tree->count;
}
四、总结:秩序的代价
今天,我们掌握了二叉搜索树------这个为了兼顾"查找"与"增删"效率 而生的精妙结构。它依靠"左小右大"的铁律,在平均情况下为我们提供了 O(log n) 的全能表现。
然而,这种秩序是有"代价"的。
请思考一个问题:如果我们按顺序 (1, 2, 3, 4, 5)向一棵空的BST中插入数据,会发生什么? 1 成为根,2 成为 1 的右孩子,3 成为 2 的右孩子... 这棵树会"退化"成一条链表!
在这种最坏情况下,BST的所有操作都将退化回 O(n) 的复杂度。我们费尽心机构建的"秩序",反而成了我们的枷锁。
如何让这棵树在插入时保持"平衡",永不退化?这就是"自我平衡"的艺术,也是我们下一篇文章的主题:AVL树。