常见集合篇(三)二叉树
是什么
二叉树是一种树状数据结构,其中每个节点最多有两个子节点,分别称为左子节点和右子节点 。在普通的二叉树中,节点间没有特定的排序规则,节点的子节点可以具有任何值,兄弟节点之间也没有关系。
为什么要有
二叉树适用于数据自然形成层次结构的应用场景。比如文件系统,文件和文件夹可以很自然地以二叉树的形式组织起来,根节点可以是根目录,子节点可以是子文件夹或文件 ;在编译器的语法树构建中,也常利用二叉树来表示语句结构,方便进行语法分析和代码生成。
怎么实现
一般通过节点类来实现,每个节点包含数据域、左孩子指针、右孩子指针。以 Java 代码为例:
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
在构建二叉树时,可以通过递归或迭代的方式添加节点。例如,递归构建一棵简单的二叉树:
public class BinaryTree {
public TreeNode buildTree() {
TreeNode root = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
TreeNode node3 = new TreeNode(3);
root.left = node2;
root.right = node3;
return root;
}
public static void main(String[] args) {
BinaryTree binaryTree = new BinaryTree();
TreeNode root = binaryTree.buildTree();
}
}
底层原理
从存储角度来看,二叉树通常有链式存储和数组存储两种方式。链式存储就是上述通过节点类和指针链接的方式,这种方式对于节点的插入和删除操作比较灵活,不需要移动大量数据,只需要修改指针指向。 数组存储则是按照层序遍历的顺序将节点存储在数组中,对于完全二叉树来说,这种存储方式比较节省空间,并且可以方便地通过数组下标计算节点的父子关系。例如,对于数组中索引为 i
的节点,其左孩子节点的索引为 2i + 1
,右孩子节点的索引为 2i + 2
。
二叉树的遍历操作是其重要的底层操作,包括前序遍历(根左右)、中序遍历(左根右)、后序遍历(左右根)和层序遍历。前序遍历先访问根节点,再递归访问左子树和右子树;中序遍历先递归访问左子树,再访问根节点,最后递归访问右子树;后序遍历先递归访问左子树和右子树,最后访问根节点;层序遍历则是按照层次,从根节点开始,一层一层地访问节点,通常借助队列来实现。以中序遍历为例,递归实现代码如下:
void inOrder(TreeNode root) {
if (root != null) {
inOrder(root.left);
System.out.print(root.val + " ");
inOrder(root.right);
}
}
二叉搜索树
是什么
二叉搜索树(Binary Search Tree,简称 BST)是一种特殊的二叉树,它要么为空,要么满足以下属性:节点的左子树只包含关键码小于该节点关键码的节点;节点的右子树只包含关键码大于该节点关键码的节点;左、右子树也必须是二叉搜索树。
为什么要有
二叉搜索树使得查找、添加和删除操作在理想情况下可以在 O(logn) 的时间复杂度内完成(n 为节点个数) 。常用于需要频繁搜索和有序遍历数据的应用,如数据库中索引的实现,通过二叉搜索树可以快速定位数据记录;关联数组的底层实现也常利用二叉搜索树,方便根据键快速查找对应的值。
怎么实现
在实现上,基于二叉树的节点结构,添加节点时,根据节点值与当前节点值的大小关系,递归地决定向左子树还是右子树插入。例如,用 Java 实现插入操作:
TreeNode insert(TreeNode root, int val) {
if (root == null) {
return new TreeNode(val);
}
if (val < root.val) {
root.left = insert(root.left, val);
} else {
root.right = insert(root.right, val);
}
return root;
}
搜索操作也是类似的递归过程,从根节点开始,根据搜索值与当前节点值的比较结果,决定向左还是向右继续搜索:
TreeNode search(TreeNode root, int val) {
if (root == null || root.val == val) {
return root;
}
if (val < root.val) {
return search(root.left, val);
} else {
return search(root.right, val);
}
}
底层原理
二叉搜索树的底层原理基于其有序性。在插入节点时,总是能找到合适的位置插入,从而保持树的有序性。例如,插入一个新节点 x
,从根节点开始比较,如果 x
小于当前节点值,就往当前节点的左子树走,否则往当前节点的右子树走,直到找到合适的空位置插入。
删除节点相对复杂一些,分为几种情况。如果要删除的节点没有子节点,直接删除即可;如果只有一个子节点,那么用子节点代替要删除的节点;如果有两个子节点,通常的做法是找到该节点右子树中的最小节点(或者左子树中的最大节点),将其值赋给要删除的节点,然后删除这个最小节点(因为这个最小节点最多只有一个子节点,删除相对容易)。
二叉搜索树的查找操作时间复杂度在树平衡的情况下为 O(logn) ,因为每次比较都能排除掉一半的搜索空间,类似于二分查找。但如果二叉搜索树退化为链表(例如,节点依次单调插入),那么查找、插入和删除操作的时间复杂度都会退化为 O(n) 。
红黑树
是什么
红黑树是一种自平衡的二叉搜索树,它在二叉搜索树的基础上,为每个节点增加了一个表示颜色(红色或黑色)的存储位,并通过对节点颜色的约束,确保树的平衡。红黑树满足以下性质:
- 节点是红色或黑色。
- 根节点是黑色。
- 每个叶节点(NIL 节点,空节点)是黑色的。
- 每个红色节点的两个子节点都是黑色(即从叶子到根的所有路径上不存在两个连续的红色结点 )。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
为什么要有
普通的二叉搜索树在极端情况下可能会退化为链表,导致查找、插入和删除操作的时间复杂度恶化到 O(n) 。红黑树通过这些颜色性质的约束,保证了在插入和删除操作后,树能大致保持平衡,使得查找、插入和删除操作在最坏情况下的时间复杂度都为 O(logn) 。在很多对性能要求较高且数据动态变化频繁的场景中都有应用,如 Java 集合框架中的 TreeMap
、TreeSet
等底层都是用红黑树实现,用于高效地存储和检索键值对;在 Linux 内核的虚拟内存管理、任务调度等模块中,也利用红黑树来优化数据的组织和操作效率。
怎么实现
红黑树的实现涉及节点的插入、删除以及维持树平衡的旋转和变色操作。
插入操作:
插入过程首先是根据一般二叉查找树的插入步骤,把新结点插入到某个叶结点的位置上,然后将新节点着为红色。这是因为如果新插入的是黑色结点,那么它所在的路径上就会多出一个黑色的结点,破坏了红黑树从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点这一性质。但新插入节点为红色可能会导致出现连续的红色节点,违反红黑树性质,所以需要进行调整。调整过程通过旋转(左旋和右旋)和变色操作来维持红黑树的性质。以 Java 代码为例(简化示意,非完整代码):
void insertFixup(TreeNode z) {
while (z.parent != null && z.parent.color == RED) {
if (z.parent == z.parent.parent.left) {
TreeNode y = z.parent.parent.right;
if (y != null && y.color == RED) {
// 叔叔节点是红色,进行变色操作
z.parent.color = BLACK;
y.color = BLACK;
z.parent.parent.color = RED;
z = z.parent.parent;
} else {
// 叔叔节点是黑色或不存在,进行旋转和变色操作
if (z == z.parent.right) {
z = z.parent;
leftRotate(z);
}
z.parent.color = BLACK;
z.parent.parent.color = RED;
rightRotate(z.parent.parent);
}
} else {
// 对称情况处理(与左子树逻辑镜像)
}
}
root.color = BLACK;
}
删除操作:
删除操作首先按照二叉搜索树的删除步骤删除节点,如果删除的是红色节点,不会破坏红黑树的性质,无需额外操作;如果删除的是黑色节点,会导致其所在路径上黑色节点数量减少,破坏红黑树性质,需要通过删除修复操作来恢复。删除修复同样是通过旋转和变色操作,引入一些指针来处理不同的情况。删除算法如下(简化示意,非完整代码):
void deleteFixup(TreeNode x) {
while (x != root && (x == null || x.color == BLACK)) {
if (x == x.parent.left) {
TreeNode w = x.parent.right;
if (w.color == RED) {
// 情况处理,通过旋转和变色恢复平衡
w.color = BLACK;
x.parent.color = RED;
leftRotate(x.parent);
w = x.parent.right;
}
// 后续更多情况处理逻辑
} else {
// 对称情况处理(与左子树逻辑镜像)
}
}
if (x != null) x.color = BLACK;
}
底层原理
红黑树通过对节点颜色的约束以及旋转、变色操作来维持树的平衡。从平衡原理角度看,性质 4(每个红色节点的两个子节点都是黑色)和性质 5(从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点)保证了从根到叶子的最长路径最多不会超过最短路径的两倍 。因为最短路径全部由黑色结点构成,而最长结点则由红黑结点交错构成(始终按照一红一黑的顺序组织)。
旋转操作(左旋和右旋)是红黑树维持平衡的重要手段。左旋是将一个节点的右子节点提升为父节点,同时调整相关节点的指针;右旋则相反,是将一个节点的左子节点提升为父节点并调整指针。例如,左旋操作的代码实现(以 C++ 为例,简化示意):
void leftRotate(Node* x) {
Node* y = x->right;
x->right = y->left;
if (y->left != nullptr) {
y->left->parent = x;
}
y->parent = x->parent;
if (x->parent == nullptr) {
root = y;
} else if (x == x->parent->left) {
x->parent->left = y;
} else {
x->parent->right = y;
}
y->left = x;
x->parent = y;
}
在插入或删除节点后,通过旋转和变色操作,不断调整树的结构和节点颜色,确保红黑树的性质始终满足,从而保证树的高度维持在 O(logn) ,进而保证查找、插入和删除操作的高效性。