二叉树与二叉搜索树(BST):从基础到应用
一、树的基本概念:从"一对多"到层次结构
什么是树?
树是一种非线性数据结构,它像自然界的树一样有"根"和"分支"。简单说,树是由n个节点组成的集合,满足:
- 有且仅有一个根节点(没有"上级"节点)
- 除根节点外,每个节点有且仅有一个"上级"(直接前驱)
- 每个节点可以有0个或多个"下级"(直接后继)
这种"一对多"的关系,让树天然适合表达层次结构。
生活中的树结构
树状结构在生活中无处不在,比如:
- 公司架构:CEO(根)→ 部门经理(中层)→ 员工(叶子)
- 文件夹系统:根目录→子文件夹→文件
- 家谱:祖先(根)→ 父母→子女→孙辈
- 足球世界杯:决赛(顶层)→ 半决赛→四分之一决赛→小组赛
树的核心术语(附通俗解释)
为了统一描述,我们需要明确这些术语:
术语 | 通俗解释 | 例子 |
---|---|---|
根节点(Root) | 树的"起点",没有上级 | 公司的CEO |
双亲节点(Parent) | 某节点的直接上级 | 你的直属领导 |
孩子节点(Child) | 某节点的直接下级 | 你管理的团队成员 |
层次(Level) | 节点所在的"深度",根为第1层 | CEO是1层,经理是2层,员工是3层 |
度(Degree) | 一个节点有多少个直接下级 | 经理带3个员工,度就是3 |
叶子节点(Leaf) | 没有下级的节点(度为0) | 基层员工、最终文件 |
树的高度(Height) | 树的最大层次数 | 从CEO到基层员工共3层,高度就是3 |
二、二叉树:每个节点最多"分两叉"
什么是二叉树?
二叉树是一种特殊的树,它的每个节点最多有2个孩子(左孩子和右孩子),且左右孩子的顺序不能颠倒(有序树)。
可以理解为:每个节点最多"分两叉",左边一个分支,右边一个分支。
二叉树的3个重要规律
这些规律能帮你快速计算二叉树的节点数量:
- 第i层最多有
2^(i-1)
个节点(比如第3层最多8个节点) - 高度为k的二叉树,最多有
2^k - 1
个节点(比如高度3的树最多7个节点) - 叶子节点数(n₀)= 度为2的节点数(n₂)+ 1(数学可证,记住即可)
常见的二叉树类型
根据结构特点,二叉树可分为:
- 满二叉树:每一层都长满节点(符合"最多节点"规律),比如高度3的树有7个节点
- 完全二叉树:除最后一层外都长满,最后一层节点从左到右连续排列(像"填格子")
- 平衡二叉树:任意节点的左右子树高度差≤1(不会"一边倒")
- 退化二叉树:所有节点都只有左孩子或只有右孩子,变成了"链表"(性能最差)
二叉树的两种存储方式
存储二叉树主要有两种方式,各有优劣:
1. 顺序存储(用数组)
把节点按层次顺序存入数组,通过下标计算节点关系:
- 根节点存在下标1的位置(方便计算)
- 下标为n的节点,父节点在下标
n/2
处 - 下标为n的节点,左孩子在
2n
处,右孩子在2n+1
处
优点:访问快,不用存指针
缺点:非完全二叉树会浪费空间(比如退化树)
2. 链式存储(用指针)
每个节点包含3部分:
// 二叉树节点结构
typedef struct Node {int data; // 存储的数据struct Node* left; // 左孩子指针(指向左边分支)struct Node* right;// 右孩子指针(指向右边分支)
} Node;
优点:灵活,不浪费空间
缺点:需要额外空间存指针
三、二叉树的遍历:如何"走遍"所有节点?
遍历是指按一定规则访问树中所有节点,且每个节点只访问一次。二叉树有4种核心遍历方式,区别在于访问根节点的时机。
1. 前序遍历(根→左→右)
规则:先访问根节点,再遍历左子树,最后遍历右子树。
口诀:“先根,再左,后右”
// 前序遍历函数
void preOrder(Node* root) {if (root == NULL) return; // 空节点,直接返回printf("%d ", root->data); // 1. 先访问根节点preOrder(root->left); // 2. 遍历左子树preOrder(root->right); // 3. 遍历右子树
}
2. 中序遍历(左→根→右)
规则:先遍历左子树,再访问根节点,最后遍历右子树。
口诀:“先左,再根,后右”
// 中序遍历函数
void inOrder(Node* root) {if (root == NULL) return; // 空节点,直接返回inOrder(root->left); // 1. 遍历左子树printf("%d ", root->data); // 2. 访问根节点inOrder(root->right); // 3. 遍历右子树
}
3. 后序遍历(左→右→根)
规则:先遍历左子树,再遍历右子树,最后访问根节点。
口诀:“先左,再右,后根”
// 后序遍历函数
void postOrder(Node* root) {if (root == NULL) return; // 空节点,直接返回postOrder(root->left); // 1. 遍历左子树postOrder(root->right); // 2. 遍历右子树printf("%d ", root->data); // 3. 访问根节点
}
4. 按层遍历(广度优先)
规则:从上到下、从左到右,一层一层访问节点(像"扫楼梯")。
实现:通常用队列辅助,先入队根节点,然后出队时依次入队其左右孩子。
遍历示例(直观理解)
假设有一棵简单二叉树:
1/ \2 3/ \
4 5
- 前序遍历:1 → 2 → 4 → 5 → 3
- 中序遍历:4 → 2 → 5 → 1 → 3
- 后序遍历:4 → 5 → 2 → 3 → 1
- 按层遍历:1 → 2 → 3 → 4 → 5
四、二叉搜索树(BST):会"自动排序"的树
什么是BST?
二叉搜索树(Binary Search Tree)是一种特殊的二叉树,它的节点值满足左小右大原则:
- 任意节点的左子树中,所有节点值都小于该节点值
- 任意节点的右子树中,所有节点值都大于该节点值
这个特性让BST的查找效率极高(类似二分查找)。
BST的核心操作(附代码实现)
1. 准备工作:创建节点
首先需要一个函数创建新节点:
// 创建新节点(初始化数据和指针)
Node* createNode(int value) {Node* newNode = (Node*)malloc(sizeof(Node));newNode->data = value; // 存储数据newNode->left = NULL; // 左孩子初始为空newNode->right = NULL; // 右孩子初始为空return newNode;
}
2. 插入节点(核心)
插入时必须保持"左小右大"原则,步骤:
- 若当前树为空,新节点就是根
- 若新值 < 当前节点值,插入左子树
- 若新值 > 当前节点值,插入右子树
- 若值相等(重复),通常不插入(避免混乱)
// 插入节点到BST
Node* insert(Node* root, int value) {// 情况1:当前位置为空,直接放入新节点if (root == NULL) {return createNode(value);}// 情况2:根据值的大小决定插入左/右子树if (value < root->data) {// 插入左子树,并用返回的新左子树更新当前左指针root->left = insert(root->left, value);} else if (value > root->data) {// 插入右子树,并用返回的新右子树更新当前右指针root->right = insert(root->right, value);} else {// 情况3:值相等,不插入(BST通常不允许重复值)printf("值 %d 已存在,不重复插入\n", value);}// 返回当前节点(保持树结构)return root;
}
3. 查找节点
利用"左小右大"特性,每次可排除一半节点:
// 在BST中查找值为value的节点
Node* search(Node* root, int value) {// 情况1:树空或找到目标节点if (root == NULL || root->data == value) {return root;}// 情况2:目标值小于当前节点,去左子树找if (value < root->data) {return search(root->left, value);}// 情况3:目标值大于当前节点,去右子树找else {return search(root->right, value);}
}
4. 删除节点(难点)
删除后必须保持BST特性,分3种情况:
情况 | 处理方式 |
---|---|
删除叶子节点(无孩子) | 直接释放该节点 |
删除只有一个孩子的节点 | 用孩子节点"替代"它的位置 |
删除有两个孩子的节点 | 用"左子树最大节点"或"右子树最小节点"替代,再删除被替代的节点 |
// 找到右子树中最小的节点(用于删除操作)
Node* findMin(Node* node) {Node* current = node;// 一直向左找(左子树最小)while (current && current->left != NULL) {current = current->left;}return current;
}// 从BST中删除值为value的节点
Node* deleteNode(Node* root, int value) {// 情况1:树空,直接返回if (root == NULL) return root;// 步骤1:找到要删除的节点if (value < root->data) {root->left = deleteNode(root->left, value); // 去左子树删} else if (value > root->data) {root->right = deleteNode(root->right, value); // 去右子树删} else {// 步骤2:找到节点,执行删除// 子情况A:只有一个孩子或没有孩子if (root->left == NULL) {Node* temp = root->right; // 用右孩子替代free(root);return temp;} else if (root->right == NULL) {Node* temp = root->left; // 用左孩子替代free(root);return temp;}// 子情况B:有两个孩子Node* temp = findMin(root->right); // 找右子树最小节点root->data = temp->data; // 用最小值替代当前节点值root->right = deleteNode(root->right, temp->data); // 删除那个最小节点}return root;
}
5. 销毁BST(避免内存泄漏)
用后序遍历销毁(先删孩子,再删根):
// 销毁整个BST
void destroyBST(Node* root) {if (root == NULL) return;destroyBST(root->left); // 先销毁左子树destroyBST(root->right); // 再销毁右子树free(root); // 最后销毁根节点
}
BST的性能特点
- 优点:查找、插入、删除的平均时间复杂度为
O(log n)
(和二分查找一样快) - 缺点:如果插入顺序有序(如1,2,3,4),会退化为链表,性能降为
O(n)
- 解决办法:使用平衡二叉树(如AVL树、红黑树),自动保持树的平衡
五、BST的实际应用场景
BST的"有序性"和"高效查找"让它在很多场景中发挥作用:
- 数据库索引:数据库用BST的变种(B树、B+树)加速查询,比如查找某条记录
- 自动补全:搜索框的"输入提示"功能,用BST快速匹配前缀
- 排序:中序遍历BST可直接得到有序序列(类似快速排序的思想)
- 范围查询:比如查找"成绩在80-90分之间的学生",BST可高效定位范围
六、学习指南:如何掌握树结构?
初学者常见误区
- 分不清"高度"和"深度":高度是从下往上数(根的高度是树的总高度),深度是从上往下数(根的深度是1)
- 递归理解困难:树的操作天然适合递归,可先手动模拟小案例(如3层树的遍历)
- 忽略内存管理:忘记释放节点会导致内存泄漏,销毁树时一定要用后序遍历
练习建议(从易到难)
- 手动模拟3种遍历方式(用前面的示例树)
- 实现计算二叉树高度的函数
- 验证一棵二叉树是否为BST(检查是否满足左小右大)
- 用迭代法实现前/中/后序遍历(不用递归,用栈)
- 尝试实现平衡二叉树(AVL树)
七、总结
树和二叉搜索树是数据结构中的"基石",它们的核心价值在于:
- 表达层次关系(树的本质)
- 高效的查找与排序(BST的优势)
掌握它们,不仅能解决很多实际问题,还能为学习更复杂的数据结构(如堆、B树、线段树)打下基础。记住:多画图、多模拟、多编码,树结构其实并不难!