从 Hash 算法到红黑树、 B+树

最近看到这样一个问题:

给你 N(1 < N < 10) 个自然数,每个数的范围为 0 ~ 99,用最快的速度判断给定的某个数是否在这 N 个数内。

假设 N 为 5,这 5 个数分别为 1,10,34,57,63。

我想到的做法是遍历,也只有遍历。那么还有没有其他的做法呢?我看到了这样一种解法:由于题目的要求是判断给定的数字存在与否,那么我们可以创建一个大小为 100 的 int 数组,然后分别将 N 个数字对应的数组下标的元素设置为 1,表示该下标对应的数字存在。之后判断给定的数字是否存在,只需要查看数字所对应的数组下标的元素是否为 1 即可。

这种做法是利用数组支持下标随机访问数据的特性,因此不需要进行遍历,性能会很高。但是某些情况下,该做法会存在一些问题。

例如,如果 N 个自然数的范围很大(0 ~ 99999999 ),那么我们就需要为数组开辟一块很大空间。显然在这种情况下,我们就不能再单纯的将数组直接映射为数组下标。

此时我们可以将 N 个自然数分别取模之后再映射为数组下标,这里我们将 N 个数字用 10 取模后映射为数组下标。这样一来,不论 N 个自然数的范围有多大,我们都一定可以将这些数字映射到大小为模数(10)的数组的某一个下标。之后判断给定数字是否存在,只需要查看数字取模后所对应的数组下标的元素是否为 1 即可。

相信细心的同学应该注意到一个问题:这里给出的 N 个自然数取模的结果恰好各不相同,那么如果 N 个自然数取模的结果出现重复怎么办?

好的,那么到此为止,通过上述场景的举例,Hash 算法和 Hash 冲突的基本概念就已经描述清楚了。

Hash 算法

要想弄清楚 Hash 算法,首先要明白 Hash 表。Hash 翻译成中文是散列,所以 Hash 表就是常说的散列表。

百度百科:哈希表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。

简单来说,Hash 表是一种表结构,可以直接根据给定的 key 值计算出目标位置。

Hash 表结构实现通常采用数组,利用的是数组支持下标随机访问数据的特性。所以 Hash 表本质上就是数组的一种扩展,由数组演化而来。可以说,如果没有数组就没有 Hash 表。

与普通的列表不同之处在于,普通列表仅能通过下标来获取目标位置的值,而 Hash 表可以根据给定的 key 值计算得到目标位置的值。

而将给定的 key 值计算得到目标位置就是 Hash 算法。概括的说,Hash 算法的公式就是 index = f(key)

前文所举的例子中,Hash 表大小为模数,Hash 算法是对 key 值直接取模,所得的值即为数组下标。

在例子最后,引出了 Hash 冲突的概念:不同的 key 值通过 Hash 算法计算后得到相同的目标位置,造成了碰撞。

一篇文章教你读懂哈希表-HashMap

散列表的基本原理与实现

Hash 冲突

首先我们要明确一点:Hash 冲突是一个无法避免的问题,即使是 MD5 等加密 Hash 算法也无法避免。

基本上冲突出现的原因可以概括为以下两点:

  • Hash 算法不够优秀,很容易将不同的 key 值求得相同的目标位置,如例子中的取模算法。
  • Hash 表的底层是数组,存放数据存在上限。

那么如果出现 Hash 冲突,我们应该如何解决呢?通常情况下,有以下两种方法应对 Hash 冲突:

  1. 开放定址法
  2. 链地址法(哈希桶)

开放定址法也叫再散列法,其核心思想是:如果使用 Hash 算法计算 key 值得到的目标位置出现冲突,那么使用该 key 值再生成一个目标位置;如果仍有冲突产生,则继续生成目标位置,直到找到一个没有冲突的目标位置。开放定址法有多种实现方式,其中最常见的是线性探测。

线性探测的做法是:如果使用 Hash 算法求得 key 值的目标位置已经被占用了,那么就从当前位置开始,依次往后查找是否有空闲位置,直到找到为止。因此使用开放定址法,目标位置上就不能再用 0 和 1 区分使用该位置上是否有数据,而应该存放目标的值。

假设此时 N 个数字为:1,10,33,51,63,那么 Hash 表中的数据存放应该如下所示:

开放定址法的缺点:

  1. 能存放的数据有限,容量不够时需要扩容。
  2. 删除数据需要特殊处理。在实际使用中,每个位置上会有一个标记位,表示该位置是 EMPTY、EXIST 还是 DELETE。
  3. 如果冲突较多,查找数据时可能会退化成遍历,失去数组的特性。

正是由于开放定址法存在上述的诸多缺点,实际使用中一般使用链地址法。

开放定址法的思想是出现了冲突就去寻找下一个空闲的散列地址,而链地址法的思想则是出现冲突就地解决。具体做法是在目标位置上使用链表,一旦出现冲突,就将新的数据添加到链表尾部。

但是我们知道链表的优点是增删快,查询慢。如果冲突较多,那么遍历链表将会严重影响 Hash 表的性能。因此,需要有一种更好的数据结构来代替链表。

在 Java 中,一种经典 Hash 表实现是 HashMap。JDK 1.8 版本中,HashMap 的数据结构是链表 + 红黑树(链表长度大于 8 时,才将链表替换为红黑树。链表长度小于 8,性能优于红黑树)。

至此,我们可以总结出好的 Hash 表设计应该包含以下几个方面:

  1. 性能:插入、删除、查找都必须要快;
  2. 空间:不能占用太多内存;
  3. Hash 函数:尽可能减少 Hash 碰撞;
  4. 扩容
  5. 并发

数据结构与算法:hash冲突解决

详解哈希表查找

处理哈希冲突的线性探测法

红黑树

二叉搜索树,又叫二叉查找树、二叉排序树,它具有以下特点:

  1. 如果它的左子树不为空,则左子树上节点的值都小于根节点;
  2. 如果它的右子树不为空,则右子树上节点的值都大于根节点;
  3. 子树也遵循以上两点。

基于以上特点,二叉搜索树可以保证中序遍历是有序的(之前一直再想先序遍历、后序遍历有什么用?后序遍历一般用于删除节点,因为可以保证删除节点之前,左右子节点已经遍历过了)。

在此基础上,二叉搜索树的时间复杂度会非常低:O(logn)。假设树上有 20 亿个点,最多只需要查找 32 次(2 ^ 32 = 21 亿)。但是,二叉搜索树存在一个弊端:在极端情况下,树结构会退化成链表结构。

为了解决这个问题,就出现了二叉树的改良版:AVL 树(平衡二叉树)。AVL树是带有平衡条件的二叉搜索树,用平衡因子差值判断是否平衡并通过旋转来实现平衡:左右子树树高不超过1。AVL树是强平衡二叉树,必须满足平衡条件。不管是执行插入还是删除操作,只要不满足平衡条件,就要通过旋转来保持平衡,而旋转是非常耗时的。

因此如果将 AVL 树作为 Hash 表的底层结构,在冲突较多时旋转将会非常耗时。这也就是为什么 JDK 1.8 版本中,HashMap 使用数组 + 红黑树的数据结构。

相较于 AVL 树追求极致的平衡,红黑树是弱平衡二叉树(确保没有一条路径会比其它路径长出两倍即可),牺牲了部分平衡性,以换取删除/插入操作时少量的旋转次数,整体上性能优于AVL树。

红黑树的性质:

  1. 每个节点不是红色就是黑色;
  2. 根节点永远是黑色的;
  3. 所有叶子节点一定是黑色的(叶子节点其实是上图中的 NIL 节点,NIL 节点是虚拟的叶子节点);
  4. 任何两个红色的节点不能相邻;
  5. 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。

红黑树的 3 种变换规则:

  1. 变色:当前节点的父亲节点是红色,且它的祖父节点的另一个子节点(叔叔节点)也是红色。变色过程如下:
    1. 将父亲节点设为黑色;
    2. 将叔叔节点也设为黑色;
    3. 将祖父节点(也就是父亲节点的父亲节点)设为红色;
    4. 将指针定位到祖父节点设为当前要操作的节点。
  2. 左旋:当前节点的父亲节点是红色,叔叔节点是黑色,且当前节点是右子树,以父亲节点作为参考左旋。
  3. 右旋:当前节点父亲节点是红色,叔叔节点是黑色,且当前节点是左子树,那么
    1. 将父亲节点变为黑色;
    2. 将祖父节点变为红色;
    3. 以祖父节点作为参考右旋。

左旋动图演示:

在这里插入图片描述

右旋动图演示:

在这里插入图片描述

另外需要注意的是,所有新插入点都默认是红色。下图是三种操作的演示:

史上最清晰的红黑树讲解(上)

红黑树和AVL树(平衡二叉树)区别

手撕红黑树RedBlackTree Right?带动画的哦

30张图带你彻底理解红黑树

B+ 树

通过上文的介绍,我们知道红黑树是一种非常优秀的数据结构,那么为什么数据库不使用该数据结构呢?以索引为例,数据库索引的底层一般使用 B 树(B- 树和 B 树是同一种数据结构,- 是连接符,属于翻译问题)或 B+ 树。

要弄清楚这个问题,首先我们要明确一点:操作数据库的数据其实是在进行磁盘 I/O,索引也是如此。因此操作索引的过程也就是磁盘 I/O 的过程。作为用于快速定位的数据结构,只有在大量数据的前提下索引才能发挥其作用。若使用红黑树作为索引的数据结构,由于红黑树的本质是二叉树,即使可以自平衡,在大量数据的作用下树也会很高。

树一旦变得很高,就会带来一个问题:大量的磁盘 I/O。为什么会得出这样的结论呢?这里要先明白磁盘的 I/O 机制:从磁盘读写数据时,每次只能操作一个逻辑块的数据 — — 一般是 4KB。而我们知道,二叉树的各个节点之间逻辑上连接但是物理上不连接,不同的节点可能位于不同的逻辑块上。那么从二叉树的一个节点到达另一个节点的过程,就是一个不断进行磁盘查找/读取数据的过程。

通过上面的分析我们可以得出结论:磁盘查找/读取的次数往往由树的高度所决定。因此,我们需要一种树高尽可能低的树结构,而这种树结构就是 B 树。

B 树属于多叉树,又名平衡多路查找树(查找路径不只两个)。它有一个重要概念:阶。一个树的阶,就是这个树中一个节点个数的最大值。例如,一个 B 树有的节点有 2 个子节点,有个节点有 4 个子节点,那么这个树的阶就是 4。下图为 4 阶 B 树:

一个 M 阶 B 树有以下基本性质:

  1. 每个节点至多有 M 个子节点,M-1 个关键字(即节点上存放的数据);
  2. 如果根节点不是叶子节点,那么根节点至少有 2 个子节点(即 B 树不仅只有根节点);
  3. 除根节点和叶子节点外,其他每个节点至少有 Ceil(M/2) 个子节点(Ceil 为向上取整);
  4. 每个关键字的左子树的值均小于当前关键字,右子树的值均大于当前关键字。

正如红黑树有 3 中变换,B 树也有一种重要的操作:分裂。当插入新的关键字后违反了 B 树的性质,称为 B 树的上溢,此时就需要做分裂。

例如,一个 4 阶 B 树的某个节点在插入关键字后,该节点上存在 4 个关键字,我们知道 B 树的每个节点至多能有 M-1 个关键字。也就是说在插入新的关键字后,该节点已经不满足 B 树性质了。那么此时就需要先找到该节点上的中位数:当阶数 M 为奇数时,就取排在中间的关键字;当阶数 M 为偶数时,就取中间位置的前一个或后一个关键字,然后以该关键字为中心进行分裂即可。

下面以一个 B 树的构建过程演示分裂:

从上图我们可以发现,红黑树的构建是自上而下的,而 B 树的构建则是从下往上的。

诚然使用 B 树定位数据很高效,但是由于 B 树的每种节点上都保存了数据(根节点、中间节点、叶子节点),因此在面对范围查询的时候,依然会出现较多的磁盘 I/O。例如执行下列查询的时候:

select * from user wherer id < 100;

为了解决这个问题,在 B 树的基础上做出了改进,这就是 B+ 树。

B+ 树和 B 树的区别:

  1. B+ 树中只有叶子节点会带有指向记录的指针,而 B 树则所有节点都带有,在内部节点出现的索引项不会再出现在叶子节点中。
  2. B+ 树中所有叶子节点都是通过指针连接在一起,而 B 树不会。

B+ 树的优点:

  1. 非叶子节点不会带上指针,这样一个块中可以容纳更多的索引项,一是可以降低树的高度。二是一个内部节点可以定位更多的叶子节点。
  2. 叶子节点之间通过指针来连接,范围扫描将十分简单,而对于 B 树来说,则需要在叶子节点和内部节点不停的往返移动。

B树的优点:对于在内部节点的数据,可直接得到,不必根据叶子节点来定位。

linux磁盘IO工作原理分析

数据库:为什么使用B+树而不使用红黑树

B树和B+树的区别

Mysql索引杂谈

B树和B+树

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值