数据结构入门 (十):“左小右大”的秩序 —— 深入二叉搜索树
文章目录
- 引言:对“效率”的极致追求
- 一、“左小右大”:二叉搜索树的铁律
- 二、二叉搜索树递归实现
- 1.结构设计与创建
- 2.插入:按“规矩”找到自己的位置
- 3. 遍历:天然的“排序器”
- 4. 查找:高效的“二分”
- 5.树高:递归丈量“深度”
- 6.删除:棘手的“重建”艺术
- 7.销毁:“斩草除根”
- 三、迭代实现:告别“栈溢出”的循环
- 1.非递归插入
- 2.非递归删除
- 四、总结:秩序的代价
引言:对“效率”的极致追求
在之前的探索中,我们已经掌握了多种数据结构。我们学习了数组,它能通过索引实现 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 等元信息,这与我们之前实现链表的思想(头节点)一致。
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插入必须通过值比较来确保结构的有序性。
// 辅助函数:创建新节点
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正确性的绝佳手段,也是它“排序树”别名的由来。
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. 查找:高效的“二分”
查找操作是非递归的,它完美地再现了“二分查找”的过程。
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,这是递归的边界条件。
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。
// 辅助函数:在 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或1tmp = node->right;free(node);tree->count--;return tmp; // 返回右孩子(或NULL)给上一层}if (node->right == NULL) { // 度为1tmp = 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.销毁:“斩草除根”
对于二叉搜索树的销毁,我们需要先把左右子树删除,最后删除根节点。可以用后续遍历的方法。
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 能执行链接操作。
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)执行不同的逻辑。
// 辅助函数:度为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树。
