数据结构与算法《三》散列表,哈希算法,树结构(待完善)

本文详细介绍了哈希表的概念、散列函数的设计要求和常见方法,包括直接寻址法、除留余数法、平方取中法和折叠法。同时,讨论了散列冲突及其解决策略,如开放寻址法(线性探测、二次探测、双重散列)和链表法。此外,还探讨了Java中HashMap的实现和哈希算法的基本原理,强调了哈希表在企业级应用中的特性,如快速查询、插入和删除,以及合理的内存占用。
摘要由CSDN通过智能技术生成

1.散列表

1.1.概念

散列表(Hash Table)又名哈希表/Hash表,是根据键(key)直接访问在内存存储位置的数据结构,它是由数组演化而来,利用了数组支持按照下标进行随机访问数据的特性。

1.2.散列函数

1.2.1.散列函数的要求和特点

散列函数就是一个函数(方法),能够将给定的 key 转换成特定的散列值,我们可以表示为:hashValue = hash(key)

散列函数需要满足以下几个基本要求:

  1. 散列函数计算得到的散列值,必须是大于等于0的正整数,因为 hash 值需要作为数组的下标;
  2. 如果 key1== key2,那么经过hash后得到的哈希值也必然相同,即 hash(key1) == hash(key2)
  3. 如果 key1 != key2,那么经过hash后得到的哈希值也必然不同,即 hash(key1)  !=  hash(key2)

好的散列函数应该满足以下几个特点:

  1. 散列函数不能太复杂,因为太复杂势必要消耗很多的时间在计算哈希值上,也会间接影响散列表性能
  2. 散列函数计算得出的哈希值尽可能的随机且均匀的分布,这样能够将散列冲突最小化

1.2.2.散列函数的设计方法

实际工作中,我们还需要综合考虑各种因素。这些因素有关键字的长度,特点,分布,还有散列表的大小等。散列函数各式各样的,我举几个常用的,简单的散列函数的设计方法。

  1. 直接寻址法
  2. 除留余数法
  3. 平方取中法
  4. 折叠法

1.直接寻址法

比如我们现在要对 0-100 岁的人口数字统计表,那么我们对年龄这个关键字 key 就可以直接用年龄的数字作为地址。此时 

hash(key) = key。这个时候,我们可以得出这么个哈希函数 hash(0) = 0,hash(1) = 1,....... ,hash(100) = 100。

比如我们现在要统计的是1980年后出生年份的人口数,那么我们对出生年份这个关键字可以用年份减去1980作为地址。此时

hash(key) = key - 1980。

也就是说,我们可以取关键字 key 的某个线性函数值为散列地址,即 hash(key) = a * key + b,其中a,b为常量。

这样的散列函数优点是简单,均匀,也不会产生冲突,但问题是这需要事先知道关键字key的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,直接寻址法虽然简单,但却不常用。

2.除留余数法

除留余数法是最常用的构造散列函数方法。

对于散列表长度为 m 的散列函数公式为:hash(key) = key mod p(p<= m);   注:mod 为求余运算符号

此方法的关键就在于选择合适的 p,如果 p 选择的不合适,就可能会容易产生哈希冲突,比如有12个关键字 key,现在我们针对他设计一个散列表。如果采用除留余数法,那么可以先尝试将散列函数设计为 hash(key) = key mod 12 的方法。比如 29 mod 12 = 5,所以它存储在下标为 5 的位置。

不过这也是存在冲突的可能的,因为 12 = 2 * 6 = 3 * 4。如果关键字中有像 18(3*6),30(5*6),42(7*6),他们的余数都是 6,这样就和 78 所对应的下标位置冲突了。此时如果我们不选择 p = 12,而且选用 p = 11,则结果如下:

使用除留余数法的一个经验是,若散列列表长度为 m,通常 p 为小于或等于表长(最好接近m)的最大质数或不包含小于20质因子的合数。总之实践证明:当P取小于哈希表长的最大质数时,产生的哈希函数较好。

3.平方取中法

这是一种常用的哈希函数构造方法。这个方法是先取关键字的平方,然后根据可使用空间的大小,选择平方数是中间几位为哈希地址。

hash(key) = key 平方的中间几位

这种方法的原理是通过取平方扩大差别,平方值的中间几位和这个数的每一位都相关,则对不同的关键字得到的哈希函数值不易产生冲突,由此产生的哈希地址也较为均匀。

4.折叠法

有时关键码所含的位数很多,采用平方取中法计算太复杂,则可将关键码分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为散列地址,这种方法称为折叠法,折叠法可分为两种:

移位折叠:将分割后的几部分低位对齐相加。

边界折叠:从一端沿分割界来回折叠,然后对齐相加。

比如关键字为:12320324111220,分成 5 段,123,203,241,112,20,两种方法如下:

当然了,散列函数的设计方法不仅仅只有这些方法,对于这些我们不需要全部掌握,只需要理解其设计原理即可。

1.2.3.散列冲突

两个不同的关键字(key),由于散列函数值相同,因而被映射到同一表位置上。该现象称为 散列冲突 或 哈希碰撞

散列冲突的解决方案:即使再好的散列函数可能也无法避免散列冲突,那么如果出现了散列冲突,我们该如何解决呢?

在本节我们来介绍两类方法解决散列冲突:开放寻址法,链表法

1.开放寻址法

开放寻址法的核心思想是:一旦出现了散列冲突,我们就重新去寻址一个空的散列地址。

(1)线性检测

我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

散列表的大小为 7 ,在元素 X 插入之前已经有 a b c d 四个元素插入到散列表中了,元素 X 经过 hash(X) 计算之后得到的哈希值为 4 ,但是 4 这个位置已经有数据了,所以产生了冲突,于是我们需要按照顺序依次向后查找,一直查找到数组的尾部都没有空闲位置了,所以再从头开始查找,直到找到空闲位置下标为 1 的位置,至此将元素 X 插入下标为 1 的位置。
我们刚刚所讲的是向散列表中插入数据,如果要从散列表中查找是否存在某个元素,这个过程跟插入类似,先根据散列函数求出要查找元素的 key 的散列值,然后比较数组中下标为其散列值的元素和要查找的元素,如果相等则表明该元素就是我们想要的元素,如果不等还要继续向后寻找遍历,如果遍历到数组中的空闲位置还没有找到则说明我们要找的元素并不在散列表中。
当然了散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。其中删除操作稍微有点特殊,删除操作不能简单的将要删除的位置设置为空,为什么呢?
上面刚讲到从散列表中查找是否存在某个元素一旦在对应 hash 值下标下的元素不是我们想要的就会继续在散列表中向后遍历,直到找到数组中的空闲位置,但如果这个空闲位置是我们刚刚删除的,那就会中断向后查找的过程,那这样的话查找的算法就会失效,本来应该认定为存在的元素会被认定为不存在,那删除的问题如何解决呢?我们可以将删除的元素特殊标记为 deleted ,当线性检测遇到标记 deleted 的时候并不停下来而是继续向后检测,如下图所示:

使用线性检测的方式存在很大的问题:那就是当散列表中的数据越来越多的时候,散列冲突发生的可能性就越来越大,空闲的位置越来越少,那线性检测的时间就会越来越长,在极端情况下我们可能需要遍历整个数组,所以最坏的情况下时间复杂度为 O(n) ,因此对于开放寻址解决冲突还有另外两种比较经典的的检测方式: 次检测,双重散列
(2)二次检测
所谓的二次检测跟线性检测的原理一样,只不过线性检测每次检测的步长是 1 ,每次检测的下标依次是: hash(key)+0 hash(key)+1 hash(key)+2 ,hash(key)+3.......,所谓的二次检测指的是每次检测的步长变为原来的二次方,即每次检测的下标为
(3)双重散列
所谓的双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key), hash2(key) hash3(key)…… 我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
装载因子:
总之不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲位置。我们用装载因子 (load factor) 来表示空位的多少。
散列表装载因子的计算公式为: 装载因子 = 散列表中元素的个数 / 散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。那 如果装 载因子过大了怎么办 ?装载因子过大不仅插入的过程中要多次寻址,查找的过程也会变得很慢。当装载因子过大时,进行动态扩容 ,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8 ,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4 。针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。
插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1) 。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n) 。但是这个动态扩容的过程在 n 次操作中会遇见一次,因此平均下来时间复杂度接近最好情况,就是 O(1)。
当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的阈值,甚至可以大于 1。
总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法 。这也是Java 中的 ThreadLocalMap 使用开放寻址法解决散列冲突的原因。
2.链表法
相比开放寻址法,它要简单很多。图中,在散列表中,数组的每个下标位置我们可以称之为“ 桶(bucket)”或者“ 槽(slot)”,每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
基于链表的散列冲突处理方法比较适合存储大对象,大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
综合本章节所学习的知识点,我们知道作为一个企业级的散列表,应该有如下特点:
  1. 支持快速的查询,插入,删除操作
  2. 内存占用合理,不能浪费过多的内存空间
  3. 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
我们要实现这一个散列表应该从如下几个方面来考虑设计思路:
  1. 设计一个合适的散列函数
  2. 定义装载因子阈值,并且设计动态扩容策略
  3. 选择合适的散列冲突解决方法

1.3.散列表的应用

HashMap 的数据结构图如下图所示:
jdk1.8 中关于 HashMap 的实现跟 jdk1.7 的几点差别:
1 :数据结构引入了红黑树,好处是可以提高查询效率 (jdk1.7 中极端情况下查询 O(n) ,如果引入红黑树在极端情况下的查询可以降低为 O(log n)) ,当散列表某 一桶内链表节点数 >=8 时链表树化成红黑树,红黑树太小时退化成链表,退化的 阈值为 6
2 :计算 key hash 值的方式不一样,但是思路和原理一样都是对 key hashCode 进行扰动让高位和低位一起参与运算计算出更加均匀的 hash 码,降低 hash 冲突的概率。
3 :插入数据时如果发送了 hash 冲突,优先判断该位置上是否是红黑树,如果是 则存入红黑树中,如果是链表则插入链表尾节点上 (jdk1.7 是插入到链表头节点 ) ,插入完成后还判断链表的节点数是否大于等于设定好的链表转红黑树的阈值, 如果满足则将链表转换为红黑树
4 :两个版本都会产生扩容操作,只不过 jdk1.8 中扩容涉及到对红黑树的操作以 及优化了在 hash 冲突时计算元素新下标的代码,使其非常简单高效!

2.哈希算法

2.1.概念

哈希算法又称为摘要算法,它可以将任意数据通过一个函数转换成长度固定的数据串,这个映射转换的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。 可见,摘要算法就是通过摘要函数 f() 任意长度的数据( data) 算出固定长度的摘要( digest) ,目的是为了发现原始数据 是否被人篡改过
摘要算法之所以能指出数据是否被篡改过,就是因为摘要函数是一个 单向函数 ,计算 f(data) 很容易,但通过 digest 反推 data 却非常困难。而且,对原始数据做一个 bit 的修改,都会导致计算出的摘要完全不同。
那有没有可能两个不同的数据通过某个摘要算法得到了相同的摘要呢?完全有可能!
因为任何摘要算法都是把无限多的数据集合映射到一个 有限的集合 中。这种情况就是我们说的碰撞

2.2.要求

我们要想设计出一个优秀的哈希算法并不是很容易,一个优秀的哈希算法一般要满足如下几点要求:
  1. 将任何一条不论长短的信息,计算出唯一的一摘要(哈希值)与它相对应,对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
  2. 摘要的长度必须固定,散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
  3. 摘要不可能再被反向破译。也就是说,我们只能把原始的信息转化为摘要,而不可能将摘要反推回去得到原始信息,即哈希算法是单向的
  4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希
这些要求都是比较理论的说法,我们那一种企业常用的哈希算法 MD5 来说明:现使用 MD5 对三段数据分别进行哈希求值:
1.MD5(' 数据结构和算法 ') = 31ea1cbbe72095c3ed783574d73d921e
2.MD5(' 数据结构和算法很好学 ')=0fba5153bc8b7bd51b1de100d5b66b0a
3.MD5(' 数据结构和算法不好学 ')=85161186abb0bb20f1ca90edf3843c72
从其结果我们可以看出: MD5 的摘要值 ( 哈希值 ) 是固定长度的,是 16 进制的 32 位即 128 Bit 位,无论要哈希的数据有多长,多短,哈希之后的数据长度是固定的,另外哈希值是随机的无规律的,无法根据哈希值反向推算文本信息,其次 2 3 表明尽管只有一字之差得到的结果也是千差万别,最后哈希的速度和效率是非常高的,这一点我们可能还体会不到,因为我们哈希的只是很短的一串数据,即便我们哈希的是整个这段文本,用 MD5 计算哈希值,速度也是非常的快,总之 MD5 基本满足了我们前面所讲解的这几个要求。
在本章节中我们学习了散列表数据结构,掌握了散列函数的特点及设计要求,明确了其中几种设计方案,知道了散列冲突的原理以及解决散列冲突的方案,对于散列表在企业中的应用我们重点分析了 HashMap HashTable 的源码,最后我们介绍了哈希算法,重点是阐述了哈希算法的应用场景。

3.树

在前面章节的中我们学习了线性表数据结构:数组,链表,栈,队列;在这章中我们来学习一种非线性表叫做:树。我们先来看树的定义及相关概念。

3.1.树的定义及相关概念

3.1.1.定义

树在维基百科中的定义为: (英语: Tree )是一种无向图( undirected graph),其中任意两个顶点间存在唯一一条路径。或者说,只要没有回路的连通图就是树。
在计算机科学中, (英语: tree )是一种抽象数据类型( ADT )或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。它是由 n n>0 )个有限节点组成一个具有层次关系的集合。把它叫做 是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:
  1. 每个节点都只有有限个子节点或无子节点;
  2. 没有父节点的节点称为根节点;
  3. 每一个非根节点有且只有一个父节点;
  4. 除了根节点外,每个子节点可以分为多个不相交的子树
  5. 树里面没有环路(cycle)
这个定义不是特别好懂,那我们借助于一幅图来理解就会非常的清晰
以上这些都是树,下面我们再看几个 不是树 的情况
这种数据结构真的很像我们现实生活中的 ,这里面每个元素我们叫作 节点” ;用来连线相邻节点之间的关系,我们叫作 父子关系 。 比如在下方这副图中:
其中:节点 B 是节点 C D E 父节点 C D E 就是 B 子节点 C D E 之间称为 弟节点 ,我们把没有父节点的 A 节点叫做 根节点 ,我们把没有子节点的节点称为 叶子节点, 如: F G H I K L 均是叶子节点。
理解了树的定义之后我们再来理解一些关于树的概念

3.1.2.高度,深度和层

理解了树的定义之后我们来学习几个跟树相关的概念: 高度 (Heigh) ,深度 (Depth) ,层 (Level) ,我们依次来看这几个概念:
  • 节点的高度:节点到叶子节点的最长路径(边数),所有叶子节点的高度为 0。某节点到叶子节点的距离。
  • 节点的深度:根节点到这个节点所经历的边的个数,根的深度为 0。某节点到根节点的距离。
  • 节点的层数:节点的深度+1。
  • 树的高度:根节点的高度。
我们用一幅图来继续说明如下:

3.2.二叉树

树这种数据结构形式结构是多种多样的,但是在实际企业开发中用的最多的还是二叉树,接下来我们学习二叉树。

3.2.1.定义

二叉树,顾名思义,每个节点最多有两个 ,也就是两个子节点,分别是 左子节 右子节点 。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点,如下图所示均是二叉树:
当然了在这三棵树中,有两棵比较特殊的二叉树,分别是 T2 T3
T2 :叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树
T3 :叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树
满二叉树我们特别容易理解,完全二叉树我们可能就不是特别能够分清楚,下面我画几棵树,你分析一下看哪些是完全二叉树?
通过对比这几棵树,你分析出来哪些是完全二叉树哪些不是吗?
答案是: T1 是完全二叉树, T2,T3,T4 均不是完全二叉树。
你可能会说,满二叉树的特征非常明显,但是完全二叉树的特征不怎么明显啊,单从长相上来看,完全二叉树并没有特别特殊的地方啊,那我们为什么还要特意把它拎出来讲呢?为什么偏偏把最后一层的叶子节点靠左排列的叫完全二叉树?如果靠右排列就不能叫完全二叉树了吗?这个定义的由来或者说目的在哪里?
要理解完全二叉树定义的由来,我们需要先了解,如何表示(或者存储)一棵二叉树?想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。
我们先来看比较简单、直观的链式存储法。从我们画的图中你应该可以很清楚地看到,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要找到根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用。 大部分二叉树代码都是通过这种结构来实现的

我们再来看,基于数组的顺序存储法。我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。如下图所示:
像我刚刚图中这棵树其实是一个完全二叉树,我们只是浪费了数组下标为 0 的位置,但如果对于如下这棵树,我们浪费的存储空间可就多了:
总结一下,如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。
所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。

3.2.2.二叉树的遍历

二叉树经典的三种遍历方式:前序遍历,中序遍历,后续遍历:

理解:前,中,后是以节点本身为参照的。
我们还是以图示的方式来表述整个过程:

3.2.3.二叉查找树

我们以一副图示表示一下该树的结构:

代码实现如下:
public class SimpleBinarySearchTree {

    // 二叉查找树,指向根节点
    private Node tree;

    /**
     * 根据指定的值查找对应的节点
     * @param value
     * @return
     */
    public Node find(int value){
        Node parent = tree;

        while(parent != null){
            if(parent.value > value){
                parent = parent.left;
            }else if(parent.value < value){
                parent = parent.right;
            }else {
                return parent;
            }
        }
        return parent;
    }

    /**
     * 节点类
     * */
    private static class Node{
        // 节点值
        private int value;
        // 左节点
        private Node left;
        // 右节点
        private Node right;

        protected Node(Node left,int value,Node right){
            this.left = left;
            this.value = value;
            this.right = right;
        }

        public void setValue(int value) {
            this.value = value;
        }

        public void setLeft(Node left) {
            this.left = left;
        }

        public void setRight(Node right) {
            this.right = right;
        }
    }
}

代码实现如下:
    private Node createNode(Node left,int value,Node right){
        return new Node(left,value,right);    
    }
    
    private Node createNode(int value){
        return createNode(null,value,null);
    }

    /**
     * 将value值存入容器
     * @param value
     * @return
     */
    public boolean put(int value) throws Exception {
        if(tree == null){
            tree = createNode(value);
            return true;
        }

        Node parent = tree;
        while (parent != null){
            if(parent.value > value){
                if(parent.left == null){
                    parent.left = createNode(value);
                    return true;
                }
                parent = parent.left;
            }else if(parent.value < value){
                if(parent.right == null){
                    parent.right = createNode(value);
                    return true;
                }
                parent = parent.right;
            }else {
                throw new Exception("查找二叉树不可插入相同值的节点");
            }
        }
        return false;
    }

测试代码:

     public static void main(String[] args) throws Exception{
        // 创建容器
        SimpleBinarySearchTree tree = new SimpleBinarySearchTree();
        // 向容器中添加值
        tree.put(2);
        tree.put(4);
        tree.put(6);
        tree.put(10);
        tree.put(15);
        tree.put(16);
        tree.put(17);
        tree.put(18);
        tree.put(5);
        tree.put(3);
        tree.put(9);
        tree.put(11);
        tree.put(12);

        // 从容器中取出节点值为12的节点
        SimpleBinarySearchTree.Node node = tree.find(12);
        System.out.printf("节点值为12的节点为:"+node);
    }

解释一下第三种情况,也就是删除的节点有两个子节点,需要删除的节点的值大于左节点及其以下的所有节点,小于右节点及其以下的所有节点,所以需要再删除节点的所有子节点中选出一个大于左节点及其以下所有节点,且小于右节点及其以下的所有节点,这种情况下,这个合适的节点只能是右节点下的最小值,即右节点下面的最底层的左节点这个节点。
按照规则删除之后的树结构是:
/**
     * 删除节点
     * @param value
     */
    public void remove(int value){
        // 记录要删除的节点
        Node p = tree;
        // 记录要删除节点的父节点
        Node p_parent = null;
        // 先找到要删除的元素及其父元素
        while (p!=null){
            if(p.value > value){
                p_parent = p;
                p = p.left;
            }else if(p.value < value){
                p_parent = p;
                p = p.right;
            }else {
                break;
            }
        }

        // 如果没有找到则返回
        if(p == null){
            return;
        }

        // 要删除的节点有两个子节点,这种情况要用于右子树中最小节点的值替换当前要删除元素的值,然后删除右侧最小节点
        if(p.left != null && p.right != null){
            // 找到该节点右子树的最小节点-》最左侧的叶子节点
            Node rightTree = p.right;
            Node rightTree_p = p; // rightTree 的父节点
            while (rightTree.left != null){
                rightTree_p = rightTree;
                rightTree = rightTree.left;
            }
            // 用右子树最小节点替换当前要删除的节点
            p.value = rightTree.value;
            // 删除右子树的最小节点,考虑到删除操作的其他两种情况,要删除元素是叶子节点以及要删除元素只有一个子节点都属于元素的删除,
            // 这里的思路和逻辑是一样的,为统一代码逻辑编写在此处不直接删除
            p = rightTree;
            p_parent = rightTree_p;
        }

        // 删除节点是叶子节点或者仅有一个子节点,都是要删除该节点,将父节点的指针指向当前节点的子节点
        Node child = null;
        // 计算当前节点的子节点
        if(p.right != null){
            child = p.right;
        }else if(p.left != null){
            child = p.left;
        }else {
            child = null;
        }

        // 执行删除
        if(p_parent == null){ // 要删除根节点
            tree = child;
        }else if(p_parent.left == p){  // 更新父节点的左指针
            p_parent.left = child;
        }else {
            p_parent.right = child;
        }
    }

   /**
     * 获取最小节点
     * @param value
     * @return
     */
    public Node getMin(int value){
        if(tree == null){
            return null;
        }

        Node p = tree;
        while (p.left != null){
            p = p.left;
        }
        return p;
    }

    /**
     * 获取最大节点
     * @param value
     * @return
     */
    public Node getMax(int value){
        if(tree == null){
            return null;
        }

        Node p = tree;
        while (p.right != null){
            p = p.right;
        }
        return p;
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值