多路平衡查找树-数据结构(C语言)

多路平衡查找树

学习了一些支持动态查找的平衡二叉查找树(BBST)结构。但在数据量较大的工程应用(如数据库)中,由于树中的结点信息往往保存在磁盘而非内存里,维护一棵平衡二叉查找树会导致大量的磁盘寻址和读写操作。而磁盘存取的时间要比 CPU 运算的时间更长。为了解决这个问题,可以通过每次存取连续多个数据项,来减少磁盘读写的时间开销。
我们都知道,平衡树是检索效率非常高的一类数据结构,但平衡树每个结点只能保存一个关键字。为了便于一次从磁盘中读入连续的多个数据,科学家们提出了一类非常巧妙的数据结构——多路查找树。
通过将二叉查找改为多路查找,可以在降低查找深度的同时,在同一个结点内维护更多的信息,从而通过上面提到的、每次存取连续多个数据项的方法,降低磁盘寻址和读写的时间开销,优化在磁盘上检索数据的性能。
在这一章里,我们将要学习这类数据结构。
二叉查找树(binary search tree) 类似, 多路查找树(Multi-way search tree) 是指树中结点最多有 M 个孩子。因此,二叉查找树实际上是多路查找树在 M=2 时的一个特例。
类似 平衡树(平衡二叉查找树) ,多路查找树也有对应的平衡版本—— 多路平衡查找树 。在多路平衡查找树中,查找的时间效率依然可以保证为 m a t h c a l O ( l o g N ) mathcal{O}(logN) mathcalO(logN) 复杂度,并且树的深度更小。
要如何扩展呢?

有一个非常直观的想法:在二叉树中,每个结点最多只能保存一个关键字和两个孩子结点。如果我们将这个限制同时放大 1,也就是说在这棵树里,每个结点要么存储 1 个关键字和 2 个孩子、要么存储 2 个关键字和 3 个孩子。这就是 2-3 树(2-3 tree) 的思想。
2-3 树诞生于 1970 年,由 約翰·霍普克洛夫特(John Edward Hopcroft,1986 年图灵奖获得者) 提出。
在 2-3 树中,有两种结点:2-node 和 3-node,表示每个结点有 2 个还是 3 个孩子。树中的 所有叶子结点都在同一层 ,叶子结点可以包含一个或两个关键字。
2-3 树中的 2-node 和平衡树中的结点非常像,有一个关键字,该关键字大于左子树内所有元素,且小于右子树内所有元素。
3-node 的三个子树分别为左子树、中子树和右子树,有两个关键字:

  • 第一个关键字大于左子树内所有元素,且小于中子树内所有元素; * 第二个关键字大于中子树内所有元素,且小于右子树内所有元素。 注意,和我们之前学到的二叉树不同的是,2-node 一定 有两个孩子和一个关键字;3-node 一定 有三个孩子和两个关键字。
    在 2-3 树中查找元素 x 的算法如下:

  • 如果当前结点是叶子结点,则判断元素值是否和 x 相等,如果相等则找到元素 x,否则树中不存在 x 元素,结束查找。
    如果当前结点是 2-node,关键字为 d,那么有三种情况:
    如果 x 和 d 相等,则找到元素 x;
    如果 x 小于 d,则查找左子树;
    如果 x 大于 d,则查找右子树。
    如果当前结点是 3-node,关键字为 a,b,那么有四种情况:
    如果 x 和 a 或 b 相等,则找到元素 x;
    如果 x 小于 a,则查找左子树;
    如果 x 大于 b,则查找右子树;
    否则,查找中子树。
    基于 2-3 树,演化出很多种平衡多路查找树,如 2-3-4 树、B 树等,其中最著名的要数 B 树了。在本章接下来的课程里,我们将重点学习 B 树及其变种形式的思想和原理。

B树

首先,我们根据前面学到的 2-3 树,对多路查找树给出一个定义。一棵 m 路查找树满足如下的性质:

根结点最多有 m 棵子树,有 x ( 0 ≤ x ≤ m ) x(0 \leq x \leq m) x(0xm) 棵子树 P i ( 1 ≤ i ≤ m ) P_i(1 \leq i \leq m) Pi(1im) 和 m−1 个关键字 K i ( 1 ≤ i < m ) K_i(1 \leq i < m) Ki(1i<m)
对于一个结点内的所有关键字 K i ( 1 ≤ i < m − 1 ) K_i(1 \leq i < m-1) Ki(1i<m1) ,满足 K i < K i + 1 K_i < K_{i+1} Ki<Ki+1 ;子树 P i P_i Pi中的所有关键字都大于 K i − 1 K_{i-1} Ki1 ,都小于 K i K_i Ki
子树 P m P_m Pm 中的所有关键字都大于 K m − 1 K_{m-1} Km1
子树 P 1 P_1 P1 中的所有关键字都小于 K 1 K_1 K1
所有子树 P i P_i Pi都是 m 路查找树。
我们前面学的 2-3 树,和马上要学习的 B 树,都是多路查找树。

在开始学习 B 树之前,首先澄清一点,很多教科书上的“B-树”念作“B 树”,而不是很多老师念的“B 减树”。-只是一个连字符,而不是减号。
B 树(B Tree) 是一种应用广泛的多路平衡查找树,它的查找、插入和删除操作的时间复杂度都是 O ( l o g n ) \mathcal{O}(logn) O(logn)
一棵最小度数为 t ( t ≥ 2 ) t(t \geq 2) t(t2)的 B 树除了满足多路查找树的基本性质以外,还满足如下的性质:

根结点至少有一个关键字,以及两个孩子结点;
所有内部结点至少有 t 个孩子结点,至多有 2t 个孩子结点;
所有内部结点至少有 t−1 个关键字,至多有 2t−1 个关键字;
每个叶子结点没有孩子。 下图就是一棵最小度数为 2 的 B 树,根结点有两个孩子结点,结点 5 和结点11 各有两个孩子结点。
在这里插入图片描述

查找

在 B 树中查找元素和在二叉搜索树中查找非常相似,查找的伪代码如下:

search(node, key)
    i = 0
    while i < node->count and key > node->keys[i]
        i = i + 1
    if i < node->count and key == node->keys[i]
        return (node, i)
    else if node->is_leaf
        return NIL
    else return search(node->childs[i], key)

算法的过程为:首先顺序找到当前结点第一个大于等于key的关键字,如果这个关键字和key相等则查找成功直接返回;否则,如果当前结点是叶结点,说明查找失败,结束查找;否则,去这个关键字左边的子树中继续查找。

遍历

和查找操作类似,你可以像遍历普通的树结构一样遍历整棵 B 树。如下是顺序遍历的伪代码,会将所有关键字从小到大遍历:

traverse(node)
    i = 0
    while i < node->count
        traverse(node->childs[i])
插入

B 树的插入操作相比于二叉查找树而言要复杂得多。我们不能简单地将一个关键字作为叶子结点直接插入到已有的 B 树中,因为这会破坏 B 树的形态,导致插入后的 B 树不合法。应该怎么插入呢?

分裂

由于在 B 树中,每个叶结点都保存了 t−1 到 2t−1 个关键字,因此我们需要将待插入的关键字插入到正确的叶子结点中的正确位置。但是,过程没有这么简单,有一个非常麻烦的情况需要处理——要插入的叶子结点已经满了(有 2t−1 个关键字)的情况。这时,我们需要将这个叶子结点 分裂(spilt) ,将中间的关键字提升至父结点的正确位置中。
在这里插入图片描述
在这里插入图片描述
上面的图演示了在最小度数为 2 的 B 树中,将 11,13,14 结点分裂的过程。当待插入的位置已经达到关键字数量的上限时,将 11,13,14 结点分裂, 13 提升至父结点,并将 11 和 14 作为父结点的两个新的子结点。
分裂过程的伪代码如下:

split_child(father, loc)
    rchild = new node
    lchild = father->childs[loc]
    rchild->is_leaf = lchild->is_leaf
    rchild->count = t - 1
    for j = 0 to t - 2
        rchild->keys[j] = lchild->keys[j + t]
    if not lchild->is_leaf
        for j = 0 to t - 1
            rchild->childs[j] = lchild->childs[j + t]
    lchild->n = t - 1
    for lchild = father->count downto loc + 1
        father->childs[lchild + 1] = father->childs[j]
    father->childs[loc + 1] = rchild
    for j = father->count - 1 downto loc
        father->keys[j + 1] = father->keys[j]
    father->keys[i] = lchild->keys[t]
    father->count += 1
插入过程

借助分裂操作,我们可以确保待插入的位置一定不是一个满结点。插入过程的伪代码如下:

insert_key_to_tree(int key)
    if (root->count == 2 * t - 1)
        new_root = new node
        new_root->is_leaf = false
        new_root->count = 0
        new_root->childs[0] = root
        split_child(new_root, 0)
        insert_nonfull(new_root, key)
    else
        insert_nonfull(new_root, key)

如果根结点已满,则将根结点分裂,并从新的根出发插入,否则直接从根出发插入。插入的具体过程如下:

insert_nonfull(node, key)
    i = node->count - 1
    if node->is_leaf
        while i >= 0 and key < node->keys[i]
            node->keys[i + 1] = node->keys[i]
            i -= 1
        node->keys[i + 1] = key
        node->count += 1
    else
        while i >= 0 and key < node->keys[i]
            i -= 1
        i += 1
        if node->childs[i]->count == 2 * t - 1
            tree_split_child(node, i)
            if (key > node->keys[i])
                i += 1
        insert_nonfull(node->childs[i], key)

如果当前待插入的结点为叶子结点,因为我们已经确保当前结点未满,所以直接插入到正确的位置就可以了。如果不是叶子结点,则首先找到应该插入的子树位置,如果对应的子结点已满,则将对应的子结点分裂,之后继续递归插入到子树中。

插入过程举例

1)要在一个已生成的最小度数为 2 的 B 树上插入一个元素 5。B 树的初始状态如下:
在这里插入图片描述
2)发现根结点已满,对根结点进行分裂:
在这里插入图片描述
3)继续从根结点开始插入,发现应该插入到 10 结点所在子树中:
在这里插入图片描述
4)继续执行插入操作,发现应该插入到 2,3,4 所在结点中,但该结点已满:
在这里插入图片描述
5)将 2,3,4 结点分裂:
在这里插入图片描述
6)将要插入的元素 5 插入到正确的位置上:
在这里插入图片描述

B 树的删除算法

B 树的删除操作复杂之处在于,待删除的关键字可能存在于树中的任意一个结点上。当所在结点并非叶子结点时,需要同时维护这个内部结点的孩子指针,不仅要确保删除后仍然是一棵树,还要满足 B 树对于结点的关键字数量的上下界限制。和插入操作类似,在删除一个元素时,如果发现所在结点的关键字个数为下限(最小度数减一)时,需要向上回溯,以确保删除之后不会导致 B 树不合法。
和插入操作类似,B 树的删除操作需要在递归过程中确保所在结点的关键字个数 不小于 最小度数 t,也就是说,要比最少的关键字个数大。这样,我们就可以在自上而下的删除过程中确保 不需要向上回溯 。
接下来,我们对在子树 x 中删除关键字 k 的各种情况逐个分析。
1)如果关键字 k 在结点 x 中,并且 x 是叶结点,则从 x 中删除 k。下图显示了从 B 树中删除关键字 14 的过程。
在这里插入图片描述
在这里插入图片描述
2)如果关键字 k 在结点 x 中,并且 x 是内部节点,则进行如下操作:

  • 2a)如果结点 x 中 k 的左侧子结点至少包含 t 个关键字,则首先找到左侧子结点所在子树中的最大关键字 k ′ k' k ,并将它和 k 进行交换,之后在左侧子树中继续递归地删除 k ′ k' k。下图显示了从 B 树中删除关键字 8 的过程。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  • 2b)和 a 情况对称地,如果结点   x \ x  x   k \ k  k 的右侧子结点至少包含   t \ t  t 个关键字,则首先找到右侧子结点所在子树的最小关键字   k ′ \ k'  k ,并将它和   k \ k  k 进行交换,之后在右侧子树中继续递归地删除   k ′ \ k'  k 。下图显示了从 B 树中删除关键字 11 的过程。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  • 2c)否则,如果左右两侧的子结点都只包含   t − 1 \ t-1  t1 个关键字,则将左右两侧的子结点加上   k \ k  k 合并为一个新的子结点,并递归地在新的子结点中递归地删除   k \ k  k。下图显示了从 B 树中删除关键字 11 的过程。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    3)如果关键字   k \ k  k 不在当前的结点   x \ x  x 中,那么可以确定   k \ k  k 可能会出现在   x \ x  x 的其中一个子结点   c i \ c_i  ci 所在的子树里。如果   c i \ c_i  ci 的关键字个数不小于   t \ t  t ,那么递归地到   c i \ c_i  ci 所在子树中删除   k \ k  k ,否则,需要通过步骤 3a 或 3b 来确保接下来继续到一个关键字个数不小于   t \ t  t 的子结点中递归删除。
    3a)如果   c i \ c_i  ci 的一个相邻的兄弟结点包含至少   t \ t  t 个关键字,那么将   x \ x  x中一个关键字降至 c i c_i ci 中,并将相邻兄弟中的一个关键字升至 x。这样,   c i \ c_i  ci 就有   t \ t  t 个关键字了,可以继续到结点   t \ t  t 中递归删除 k k k。下图显示了从 B 树中删除关键字   2 \ 2  2 的 3a 过程。
    在这里插入图片描述
    在这里插入图片描述
    3b)如果   c i \ c_i  ci 及其所有相邻的兄弟结点都只包含   t − 1 \ t-1  t1 个关键字,那么将   c i \ c_i  ci 和它的一个兄弟合并,并继续在   c i \ c_i  ci 中递归删除 k k k 。下图显示了从 B 树中删除关键字 10 的 3b 过程。
    在这里插入图片描述
    在这里插入图片描述
B 树的变种

在 B 树的基础之上,衍生出很多种经典实用的数据结构,其中最为人熟知的两个就是 B+ 树和 B* 树。
B+ 树于 1974 年由 Wedekind 提出,它被广泛应用于文件和数据库索引中。B+ 树和 B 树的不同之处在于:

  • 所有关键字都存放在叶结点中,非叶结点的关键字表示的是子树中所有关键字的最小值,这被称为 最小复写码 。当然,也可以统一存储子树所有关键字的最大值,是完全等价的。
  • 所有叶结点包含了全部的关键字信息,并且叶结点之间按从小到大的顺序链接。
    在这里插入图片描述
    上面这张图展示了一个 B+ 树的例子。每个非叶子结点保存的是对应子树内所有关键字的最小值,而所有叶结点之间像链表一样,通过指针从小到大串联起来。 B+ 树的优点如下:
  • 由于每次查询都必须从根结点出发直到叶子结点,因此在 B+ 树中的每次查询时间比较稳定。
  • 由于非叶子结点内并不需要真正保存关键字的具体信息,因此整体的磁盘读写开销会更小。
  • 遍历叶子结点就可以从小到大遍历相邻的元素。

尤其由于第三点,导致现有的数据库索引大多采用 B+ 树作为索引数据结构。

在 B+ 树的基础之上,有一个更为复杂的变种:B 树。在 B 树中,内部结点(非根、非叶子)也增加了一个指向兄弟的指针。并且每个结点的关键字个数至少为关键字个数上限的 2 3 \frac{2}{3} 32
由于 B+ 树没有对结点内关键字个数的上下限做调整,因此 B+ 树的分裂和 B 树一致。而由于 B* 内关键字个数的下限有所调整,因此,当一个 B* 树的结点已经满了时,如果下一个兄弟结点未满,那么讲一部分数据转移到兄弟结点中,再将关键字插入原来的结点;如果兄弟结点也满了,则在原结点与兄弟结点之间增加新结点,原结点和兄弟结点各复制 1 / 3 1/3 1/3 的数据到新的结点,最后在父结点增加到新结点的指针。
也正因为 B 调整了关键字数量的下限,使得 B 树的空间使用率相比 B 树和 B+ 树更高。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
平衡二叉是一种特殊的二叉,它的左右子的高度差不超过1。AVL是一种自平衡的二叉搜索,它的高度始终保持在O(log n)。 下面是C语言实现平衡二叉(AVL)的代码: ``` #include <stdio.h> #include <stdlib.h> /* 定义平衡二叉节点结构体 */ struct AVLNode { int data; // 存储的数据 int height; // 节点高度 struct AVLNode *leftChild; // 左子 struct AVLNode *rightChild; // 右子 }; /* 获取节点高度 */ int getHeight(struct AVLNode *node) { if (node == NULL) { return -1; } else { return node->height; } } /* 获取节点平衡因子 */ int getBalanceFactor(struct AVLNode *node) { if (node == NULL) { return 0; } else { return getHeight(node->leftChild) - getHeight(node->rightChild); } } /* 更新节点高度 */ void updateHeight(struct AVLNode *node) { node->height = 1 + (getHeight(node->leftChild) > getHeight(node->rightChild) ? getHeight(node->leftChild) : getHeight(node->rightChild)); } /* 右旋操作 */ struct AVLNode *rotateRight(struct AVLNode *node) { struct AVLNode *newRoot = node->leftChild; node->leftChild = newRoot->rightChild; newRoot->rightChild = node; updateHeight(node); updateHeight(newRoot); return newRoot; } /* 左旋操作 */ struct AVLNode *rotateLeft(struct AVLNode *node) { struct AVLNode *newRoot = node->rightChild; node->rightChild = newRoot->leftChild; newRoot->leftChild = node; updateHeight(node); updateHeight(newRoot); return newRoot; } /* 插入操作 */ struct AVLNode *insert(struct AVLNode *root, int data) { if (root == NULL) { root = (struct AVLNode *) malloc(sizeof(struct AVLNode)); root->data = data; root->height = 0; root->leftChild = NULL; root->rightChild = NULL; } else if (data < root->data) { root->leftChild = insert(root->leftChild, data); if (getHeight(root->leftChild) - getHeight(root->rightChild) == 2) { if (data < root->leftChild->data) { root = rotateRight(root); } else { root->leftChild = rotateLeft(root->leftChild); root = rotateRight(root); } } } else if (data > root->data) { root->rightChild = insert(root->rightChild, data); if (getHeight(root->rightChild) - getHeight(root->leftChild) == 2) { if (data > root->rightChild->data) { root = rotateLeft(root); } else { root->rightChild = rotateRight(root->rightChild); root = rotateLeft(root); } } } updateHeight(root); return root; } /* 中序遍历 */ void inOrderTraversal(struct AVLNode *root) { if (root != NULL) { inOrderTraversal(root->leftChild); printf("%d ", root->data); inOrderTraversal(root->rightChild); } } int main() { struct AVLNode *root = NULL; int data[] = {5, 2, 8, 1, 3, 6, 9}; int len = sizeof(data) / sizeof(data[0]); int i; for (i = 0; i < len; i++) { root = insert(root, data[i]); } inOrderTraversal(root); return 0; } ``` 以上代码实现了平衡二叉的插入和中序遍历操作。在插入操作中,根据插入节点的值和当前节点的值的大小关系,不断递归向左或向右子进行插入操作,并在递归返回时更新节点高度和进行平衡操作。在平衡操作中,根据节点的平衡因子进行旋转操作,使重新平衡。在中序遍历操作中,按照左子、根节点、右子的顺序遍历中的节点,输出节点的值。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值