了解二叉树
二叉树是一个树形结构,每个节点最多有两个叉,及最多有两个子节点分别是左子节点和右子节点。左右两个子节点也分别有其对应的左右子节点。
一些相关术语
- 节点:包含一个数据元素及若干指向子树分支的信息。
- 节点的度:一个节点含有的字节点的个数,二叉树的节点的度最大为2。
- 根节点:第一个节点。根节点不存在父节点。
- 叶子节点:也称为终端节点,没有子树的节点或者度为零的节点。
- 分支节点:也称为非终端节点,度不为零的节点称为非终端节点。
js模拟二叉树的简单实现
kotlin
class TreeNode {
val: any;
left: TreeNode | null;
right: TreeNode | null;
constructor(val?: any, left?: TreeNode | null, right?: TreeNode | null) {
this.val = val;
this.left = left === undefined ? null : left;
this.right = right === undefined ? null : right;
}
}
满二叉树和完全二叉树
每层节点个数都达到了最大的二叉树就是满二叉树
最后一层叶子节点都靠左排列,且除了最后一层,其他层的节点个数都达到了最大的二叉树是完全二叉树。
💡 找规律:满二叉树一定是是完全二叉树,完全二叉树不一定是满二叉树
二叉树的三种遍历方式
二叉树中一个很重要的操作就是如何遍历树中所有的节点,按照深度优先原则,二叉树有三种经典的遍历方法:前序遍历、中序遍历和后序遍历。
前序遍历指的是:对于树中的任意节点,先遍历当前节点、再遍历当前节点的左子树,最后遍历当前节点的右子树,按照这个顺序直到遍历完整棵树。上面这个二叉树遍历完成之后的顺序:[A,B,D,E,C,F,G]。
中序遍历指的是:对于树中的任意节点,先遍历当前节点的左子树、再遍历当前节点,最后遍历当前节点的右子树,按照这个顺序直到遍历完整棵树。上面这个二叉树遍历完成之后的顺序:[D,B,E,A,F,C,G]。
后序遍历指的是:对于树中的任意节点,先遍历当前节点的左子树、再遍历当前节点的右子树,最后遍历当前节点,按照这个顺序直到遍历完整棵树。上面这个二叉树遍历完成之后的顺序:[D,E,B,F,G,C,A]。
因为整个遍历过程天然具有递归的性质,我们一般直接用递归函数来模拟这一过程,遇到空节点后终止递归。时间复杂度为O(n)。
scss
function preorderTraversal(root: TreeNode | null): number[] {
const res: number[] = [];
// preOrderTraversal(root, res);
// middleOrderTraversal(root, res);
// postOrderTraversal(root, res);
return res;
};
// 二叉树的前序遍历
const preOrderTraversal = (node: TreeNode | null, res: number[]) => {
if (!node) return;
// 按照中、左、右的顺序循序遍历
res.push(node.val);
traversal(node.left, res);
traversal(node.right, res);
}
// 二叉树的中序遍历
const middleOrderTraversal = (node: TreeNode | null, res: number[]) => {
if (!node) return;
// 按照左、中、右的顺序循序遍历
traversal(node.left, res);
res.push(node.val);
traversal(node.right, res);
}
// 二叉树的后序遍历
const postOrderraversal = (node: TreeNode | null, res: number[]) => {
if (!node) return;
// 按照左、右、中的顺序循序遍历
traversal(node.left, res);
traversal(node.right, res);
res.push(node.val);
}
二叉搜索树(Binary Search Tree)
二叉搜索树是二叉树中最常见的一种类型,二叉搜索树是为了快速查询而生的,它要求在树中的任意一个节点,若其左子树不为空,则其左子树的每一个节点的节点值,都要小于当前节点的节点值。若其右子树不为空,则其右子树的每一个节点的节点值,都要大于当前节点的节点值。所以当对二叉搜索树进行中序遍历后,可以输出有序的数据序列。由于它的有序性,二叉搜索树可以在O(logn)的时间复杂度内进行查找操作。
实现二叉搜索树查询、插入、删除
二叉搜索树的查询
类似于数组中的二分查找,先取根节点,比较要查询值和根节点值。当查询值小于根节点值时,那就在左子树中递归查找,当查询值大于根节点值时,那就在右子树中递归查找,直至相等。时间复杂度一般为O(logn)。
💡 二叉搜索树虽然是一个天然的二分结构,能很好的利用二分查找快速定位数据,但是它存在极端场景比如根节点是最小值,之后每次插入的元素都是树内最大元素,就会导致整个二叉树就全都只有右子节点,从二叉搜索树退化成一个链表,此时时间复杂度为O(n)
kotlin
function searchBST(root: TreeNode | null, val: number): TreeNode | null {
if (!root || root.val === val) return root;
if (val < root.val) {
return searchBST(root.left, val);
} else {
return searchBST(root.right, val);
}
}
二叉搜索树的插入
新插入的数据一般都在叶子节点上,基于二叉树的查询,我们只需要从根节点开始,依次比较要插入的数据和节点值的大小关系。
- 如果要插入的数据比当前节点值大且当前节点的右子树为空,就将新数据插入到当前节点的右子节点。
- 如果要插入的数据比当前节点值小且当前节点的左子树为空,就将新数据插入到当前节点的左子节点。
时间复杂度为O(logn)
scss
function insertIntoBST(root: TreeNode | null, val: number): TreeNode | null {
if (!root) return new TreeNode(val);
if (val > root.val) {
if (root.right) {
insertIntoBST(root.right, val);
} else {
root.right = new TreeNode(val);
}
}
if (val < root.val) {
if (root.left) {
insertIntoBST(root.left, val);
} else {
root.left = new TreeNode(val);
}
}
return root;
}
二叉搜索树的删除
不同于二叉搜索树的查询和插入,删除操作需要考虑要删除节点的子节点个数,所以相对比较复杂,需要分三种情况处理
- 要删除节点不存在子节点:直接将父节点中指向要删除节点的指针指向null。
- 要删除节点只存在一个子节点(左或右):将要删除节点的父节点指向要删除节点的子节点。
- 要删除节点的两个子节点都存在:找到要删除节点的右子树中最小节点,把它替换到要删除节点的位置上。
ini
function deleteNode(root: TreeNode | null, key: number): TreeNode | null {
if (!root) return null;
if (key < root.val) {
root.left = deleteNode(root.left, key);
} else if (key > root.val) {
root.right = deleteNode(root.right, key);
} else {
if (root.left) {
let node = root.left;
while (node.right) {
node = node.right;
}
node.right = root.right;
return root.left;
} else {
return root.right;
}
}
return root;
}
简单拓展一下-数据库索引
在关系型数据库系统中,索引用于加快查询操作。二叉搜索树可以用作索引结构,其中每个节点包含一个索引键和指向对应数据行的指针。利用二叉搜索树的有序性,在树中进行查找操作,可以在O(logn)的时间复杂度内找到满足查询条件的数据行,而不需要遍历整个数据库表。 不过显然,只是简单的二叉搜索树还不够。还需要解决几个问题:二叉树退化问题和查询效率问题。同时还需要考虑范围查询的问题,毕竟查询一个范围的数据这个场景还是很常见的。
自平衡二叉树
为了解决二叉搜索树退化成链表的问题,就出现了自平衡二叉树。自平衡树在二叉搜索树上增加了一些约束,每个节点的左子树和右子树高度差不能超过1,这样,我的查询操作的时间复杂度才能维持在O(logn); 我们需要在数据插入或删除的时候进行特定平衡操作,当插入/删除操作导致树不平衡时通过旋转来调整节点的位置,使树重新平衡。
B树
二叉搜索树虽然可以保持查询操作的时间复杂度,但因为他每个节点只有两个子节点,那么当节点个数越多的时候,树的层数就越多,这样就会增加磁盘的I/O次数,从而影响数据查询的效率,为了减少树的层数,B树就出现了,B树允许每个节点存在多个子节点从而降低树的层数。B树的每个节点可以有M个子节点,M称为B树的阶。 假设M = 3,那就是一个3阶的B树,特点就是每个节点最多有2个(M-1个)数据和最多有3(M)个子节点。在插入的时候,超过要求的话,就会分裂节点。
💡 关于M阶B树最多有(M-1)个数据和M个子节点:就像一条绳子,切一刀会分成两段,切两刀,会分成三段。(M-1)个数据,可以把数据范围分成M个。
💡 磁盘I/O(Input/Output)是指计算机系统与磁盘存储设备之间进行的数据读取和写入操作。磁盘I/O是计算机系统中常见的一种I/O操作类型,用于从磁盘读取数据到内存或将数据从内存写入磁盘。磁盘I/O是一种相对较慢的操作,而磁盘 I/O 次数越多,所消耗的时间也就越大。所以一般索引的数据结构能狗在尽可能少的磁盘的 I/O 操作中完成查询工作。
B+数
而B+ 树是基于 B 树的一个升级,他将所有的数据都放到了叶子结点上,并将叶子结点连接起来,组成了一个有序链表。这种设计对范围查找非常有帮助,比如说我们想知道 12 月 1 日和 12 月 12 日之间的订单,这个时候可以先查找到 12 月 1 日所在的叶子节点,然后利用链表向右遍历,直到找到 12 月12 日的节点,这样就不需要从根节点查询了,进一步节省查询需要的时间。
总结
二叉搜索树是一个天然的二分结构,可以很好的利用二分查找快速查询,但在某些极端场景下,二叉树会退化成链表,为了解决这个问题,就有了自平衡二叉树,他会在插入/删除之后进行自旋转操作以维持树的平衡。
而树的高度决定了磁盘I/O的次数,为了降低磁盘I/O和提高查询效率,B树增加了叉数,以降低树的高度,而B+树则是将数据都存放到叶子节点,并将叶子节点连接成链表,以便于范围查询。感兴趣的可以深入了解一下。