有序表介绍
有序表和哈希表的本质区别就是:哈希表的Key是通过Hash函数散列组织的,而有序表的Key是顺序组织的。
有序表除了支持哈希表的所有操作之外,还提供了一些由于Key的有序性可以实现的其他操作。例如:找到最大或最小的Key对应的Value、给定一个Key找到比它小且最近的Key对应的Value是多少......
有序表所有操作的时间复杂度都是O(logN),非常高效。
很多结构都可以实现有序表。例如:红黑树(Red Black tree)、AVL树、SB树(Size Balanced Tree)、跳表(Skip List)...... 这些数据结构实现的有序表的性能指标都一样,都是O(logN)。由于它们各自实现有序表的原理不同,因此即使时间复杂度有区别也只是常数时间的差距,而且常数时间的差距也比较小。
因此,只要掌握一种数据结构对于有序表的实现就可以了。
在比赛和一般做题中一般会使用SB树来实现有序表,并不是因为SB树比其他数据结构实现有序表性能强多少,而是因为SB树在改写难度方便要大大小于其他几个数据结构。
逻辑关系
不同数据结构实现有序表分成两大系列:
- 平衡搜索二叉树系列:
- 红黑树
- AVL树
- SB树
- 跳表系列:跳表
搜索二叉树
平衡搜索二叉树,首先是一棵搜索二叉树,其次才涉及到平衡性的问题。
首先想一个问题:如果不考虑平衡性,如何在一棵搜索二叉树中实现数据的增删改查?
1. 搜索流程
搜索二叉树有固定的搜索流程,增删改查所有操作都需要基于搜索流程来完成具体的实现。
搜索流程是:目标Key先和根的Key进行比较,如果比根的Key小,则进入根的左子树继续搜索;如果比根的Key大,则进入根的右子树继续搜索,周而复始。
由于AVL树、SB树和红黑树也是搜索二叉树,因此也遵循该搜索流程完成增删改查的操作,只是它们各自在完成操作后如果平衡性被破坏会自动进行调整罢了。
2. 查询数据
目标Key进入搜索流程,如果查询到有该节点,则返回;如果没有,则返回统一错误。
3. 增加数据
如果添加一个数据,直接通过搜索流程找到对应的添加位置进行添加。
如果要增加一组数据,就先将需要组织的数据排列好,依次通过搜索流程找到对应的添加位置,然后加入。
一般默认一棵搜索二叉树上是没有重复节点的(Key),那么这样搜索二叉树的功能性会不会有损失?
其实不会,因为可以在搜索二叉树的每一个节点上增加数据项作为伴随数据(数据压缩),无论需要组织的数据多么复杂,都可以使用该方式解决。这样既能保证数据不会丢失,也能保证整个搜索二叉树没有重复节点。
4. 更新数据
目标Key如果能通过搜索流程在树中找到对应节点,就对该节点数据进行更新;如果找不到对应节点,则新建一个节点添加到搜索二叉树的对应位置。
5. 删除数据
删除数据就不是很容易了,为什么呢?
因为删除数据时需要分情况讨论:
-
要删除的节点既没有左孩子也没有右孩子,直接删除即可。
-
要删除的节点没有左孩子或者没有右孩子,让其唯一的那个孩子替代它(位置和环境)即可。
-
要删除的节点既有左孩子也有右孩子,让谁来代替它可以保持环境的稳定?
- 可以让左子树中最右的节点代替它。如果最右的节点有左孩子,让左孩子继承它的原位置。
- 可以让右子树中最左的节点代替它。如果最左的节点有右孩子,让右孩子继承它的原位置。
平衡搜索二叉树
搜索二叉树有一个问题,它没有平衡性,没有平衡性就会让搜索二叉树的操作的代价无法维持在O(logN)的水平。
搜索二叉树的操作的时间复杂度取决于数据状况,假设现在需要组织 [1,2,3,4,5,6] 这些数据成一棵搜索二叉树,那么在这棵二叉搜索树上进行增删改查的代价就会比O(logN)大,达到O(N)的代价,因为此时的搜索二叉树相当于一个单链表。
因此,就有了这样一个问题:搜索二叉树如何兼顾平衡性?
1. 平衡性
平衡性分为狭义平衡性和广义平衡性。
- 狭义平衡性:AVL树,红黑树,SB树等一些平衡树自己单独定义的平衡性。例如AVL树那样非常严苛的平衡性,任何一个节点的左子树和右子树高度差的绝对值不超过1。
- 广义平衡性:任何一个节点的左子树规模和右子树体量不会相差的太悬殊。这种体量,可以是树的深度,也可以是节点个数。
狭义平衡性是广义平衡性的一个具体实现。
只要搜索二叉树能够兼顾广义平衡性,那么就能将操作代价维持在O(logN)的水平,更不用说狭义平衡性了。
2. 左旋和右旋
定义:
施加在搜索二叉树上的两个动作,搜索二叉树依靠使用左旋和右旋这两个动作来实现自身平衡性的调整。
左旋:
A节点做一个左旋的动作,意味着A节点往左边倒,或者说A节点要倒向左边。
A节点的右孩子C节点代替A节点的原位置,C节点的左子树作为A节点的右子树(没有则不用管),A节点的左子树和C节点的右子树保持不变。
右旋:
A节点做一个右旋的动作,意味着A节点往右边倒,或者说A节点要倒向右边。
A节点的左孩子B节点代替A节点的原位置,B节点的右子树作为A节点的左子树(没有则不用管),A节点的右子树和B节点的左子树保持不变。
例子:
上述两个案例为了描述左右旋的全过程,没有体现出左右旋转对于调整平衡性显而易见的效果。
我们将上图中A节点右旋后的树按照B节点左旋,如下图:
3. 平衡搜索二叉树
平衡搜索二叉树一般也被称为带有自平衡操作的搜索二叉树。
平衡搜索二叉树只是一个核心思想层面的模型,它只定义了一棵树是否是平衡搜索二叉树的基本标准。
该标准是:首先该树是一棵搜索二叉树,其次该搜索二叉树具有广义平衡性,且包含了左旋和右旋这两个动作。但是具体如何使用左旋和右旋这两个动作来调整平衡性,并没有规定,只是定义了这两个调整平衡性的动作而已。
AVL树、红黑树和SB树都是对平衡搜索二叉树的具体实现,它们每一个对于自身平衡性的定义都不一样,对于自身如何使用左旋和右旋达到平衡的方式也都不一样。可以说它们通过对左旋和右旋的具体使用方式的不同,来维持自身定义的不同的平衡性。
AVL树
1. 平衡性
AVL树定义的平衡性是:任何一个节点的左子树和右子树高度差的绝对值不超过1。
2. 四种不平衡性情况
AVL树中四种平衡性被破坏的情况:
-
LL型:当前节点的左孩子的左子树深度较深从而导致该节点平衡性被破坏,对当前节点做一次右旋即可。
-
RR型:当前节点的右孩子的右子树深度较深从而导致该节点平衡性被破坏,对当前节点做一次左旋即可。
-
LR型:当前节点的左孩子的右子树深度较深从而导致该节点平衡性被破坏。
此时我们调整的大方向是:将当前节点的左孩子的右孩子调整到当前节点的位置,只有这样才能保证整体平衡性。
具体方式是:先对当前节点的左孩子做一次左旋,然后对当前节点做一次右旋即可。
-
RL型:当前节点的右孩子的左子树深度较深从而导致该节点平衡性被破坏,对还节点做一次左旋即可。
此时我们调整的大方向是:将当前节点的右孩子的左孩子调整到当前节点的位置,只有这样才能保证整体的平衡性。
具体方式是:先对当前节点的右孩子做一次右旋,然后对当前节点做一次左旋即可。
这四种情况的调整代价都是O(1)常数级别的。因此,哪怕从一个位置往上到根的这条路径沿途的所有节点都需要调整,整体代价也才O(logN)。
3. 平衡性检查机制
平衡搜索二叉树只有在添加节点和删除节点时才有可能出现不平衡的状态,先不讨论如何平衡,首先要解决的一个问题是如何建立一套检查是否自平衡的机制。
AVL树有自己的一套平衡性检查机制。
整体检查流程:
- 在AVL树执行增加操作后,会从新增的节点开始往上检查每一个节点的平衡性,如果不平衡则执行左旋或右旋调整,直到根节点平衡为止。
- 在AVL树执行删除操作后
- 如果该删除节点没有孩子或者只有一个孩子,那么从继承原删除节点的位置的节点开始向上检查每一个节点的平衡性。
- 如果该删除节点有两个孩子,那么从继承原删除节点的位置的节点的父节点开始向上检查每一个节点的平衡性。
具体到每一个节点,如果该节点不平衡,那么我们怎么确定是四种不平衡情况的哪一种?
节点检查流程:
计算当前节点左右子树深度差(左子树深度 - 右子树深度)。
-
如果深度差为2,判断是LL型还是LR型:
-
如果当前节点的左孩子的左孩子不是空,那么是LL型,进行LL型调整。
-
如果当前节点的左孩子的左孩子是空,那么是LR型,进行LR型调整。
-
-
如果深度差为-2,判断是RR型还是RL型:
-
如果当前节点的右孩子的右孩子不是空,那么是RR型,进行RR型调整。
-
如果当前节点的右孩子的右孩子是空,那么是RL型,进行RL型调整。
-
SB树
1. 平衡性
SB树定义的平衡性是:任何一棵树的节点数都不小于其兄弟节点的子树的节点数。
如上图所示,已知T1和T2两个子树各10个节点,如果想要以B节点为根的子树不违反SB树的平衡性,那么T3和T4两个子树都不能超过22个节点。最差情况T3和T4都是22个节点,那么以节点A为根的树的左右两个子树的规模最多也就差一倍。
从人的自然智慧来看,即使不用证明,也能感觉出来如果不破坏SB树的平衡性,那么左子树和右子树的规模必然相差不多,最差情况下左右子树规模差一倍。
2. 四种不平衡情况
SB树中也有四种平衡性被破坏的情况:
-
LL型:当前节点的左孩子的左子树的节点数量大于当前节点的右子树的节点数量,从而导致该节点的平衡性被破坏。
此时我们调整的大方向是:先让当前节点的左孩子上去,然后让孩子节点数变化的节点调用一个包含递归的检查操作M(Node)。
调整方式是:先对当前节点做一次右旋,再调用M(当前节点),最后调用M(当前节点原左孩子)。
-
RR型:当前节点的右孩子的右子树的节点数量大于当前节点的左子树的节点数量,从而导致该节点的平衡性被破坏。
此时我们调整的大方向是:先让当前节点的右孩子上去,然后让孩子节点数变化的节点调用一个包含递归的检查操作M(Node)。
调整方式是:先对当前节点做一次左旋,再调用M(当前节点),最后调用M(当前节点原右孩子)。
-
LR型:当前节点的左孩子的右子树的节点数量大于当前节点的右子树的节点数量,从而导致该节点的平衡性被破坏。
此时我们调整的大方向是:和AVL树一样,将当前节点的左孩子的右孩子调整到当前节点的位置,然后让孩子节点数变化的节点调用一个包含递归的检查操作M(Node)。
具体方式是:先对当前节点的左孩子做一次左旋,再对当前节点做一次右旋,最后调用M(当前节点原左孩子)、M(当前节点)和M(当前节点原左孩子的右孩子)。
-
RL型:当前节点的右孩子的左子树的节点数量大于当前节点的左子树的节点数量,从而导致该节点的平衡性被破坏。
此时我们调整的大方向是:和AVL树一样,将当前节点的右孩子的左孩子调整到当前节点的位置,然后让孩子节点数变化的节点调用一个包含递归的检查操作M(Node)。
具体方式是:先对当前节点的右孩子做一次右旋,再对当前节点做一次左旋,最后调用M(当前节点原右孩子)、M(当前节点)和M(当前节点原右孩子的左孩子)。
SB树的调整虽然没有AVL树那么直白,但是代价也非常低,都是O(logN)的水平,证明很复杂,就不证明了。
3. 平衡性检查机制
SB树也有自己的一套平衡性检查机制。
SB树的整体检查流程和AVL树一样,但是节点检查流程和AVL树不一样。
节点检查流程:
- 当前节点的左孩子、当前节点的左孩子的左孩子和当前节点的右孩子都不为空,且当前节点的左孩子的左子树的节点数大于当前节点的右子树,那么是LL型,进行LL型调整。
- 当前节点的左孩子、当前节点的左孩子的右孩子和当前节点的右孩子都不为空,且当前节点的左孩子的右子树的节点数大于当前节点的右子树,那么是LR型,进行LR型调整。
- 当前节点的右孩子、当前节点的右孩子的右孩子和当前节点的左孩子都不为空,且当前节点的右孩子的右子树的节点数大于当前节点的左子树,那么是RR型,进行RR型调整。
- 当前节点的右孩子、当前节点的右孩子的左孩子和当前节点的左孩子都不为空,且当前节点的右孩子的左子树的节点数大于当前节点的左子树,那么是RL型,进行RL型调整。
红黑树
1. 规则
-
节点不是红色就是黑色。
-
根节点和叶子必须是黑色。
红黑树中叶子节点并不是没有左孩子也没有右孩子的节点,而是最底层为null的节点。
-
任何两个红色的节点不能相邻。
-
对任何一个子树来说,从该子树的根节点出发到叶子节点每一条路径中黑色节点的数量必须一致。
设置这四点的规则综合起来有什么表现呢?
让红黑树的所有路径构成只会出现两种情况:
- 所有节点全是黑色(短路径)。
- 一个红色节点和一个黑色节点交替(长路径)。
2. 平衡性
由于红黑树的构成只会有两种情况,一种是全黑,一种是红黑交替。由规则4要求每一条路的黑色节点一样多,那么那么最坏情况是根的左右子树一个是由全黑节点构成,一个是红黑节点交替构成。即使这样,左右子树的规模也就相差一倍。
3. 平衡性调整
平衡性调整在这就不再阐述了,因为太复杂了且性能也很局限,纯属一个智力的盛宴。因此红黑树在现在的使用场景就比较少了,将来一定会被别的结构所代替,比如说SB树。
但是要知道的是,红黑树和AVL树和SB树都是大差不差的,只是到每个节点的时候检查的标准不一样,依然是只通过左旋和右旋来调整。
跳表
1. 节点结构
public class SkipListNode<K extends Comparable<K>, V> {
// key
public K key;
// 伴随数据
public V value;
// 下级指针链表
public ArrayList<SkipListNode<K, V>> nextNodes;
...
}
既然是让跳表来实现有序表,那么跳表节点的Key必须是可以进行比较操作的。
2. 搜索流程
从跳表的最高层依次往下找Key小于等于待操作节点的Key的最右节点,找到后判断待操作节点是否能够达到当前高度:
- 如果没达到,跳到下一层。
- 如果达到,对该节点进行操作。
在搜索流程中有一个加速过程,是因为在找到了当前层Key小于等于待操作节点的Key的最右节点后,如果带操作节点没达到当前搜索高度,则需要跳下一层,而跳到下一层后直接开始从上一层找到的最右节点开始判断待操作节点是否达到当前搜索高度。这样就避免了每层都从头开始查当前层的最右节点,从而跳过了那些一定不是最右节点的节点,节省了多次遍历的时间。
2. 添加数据
初始化时有一个默认节点,该节点有一条指向下级节点的指针,指向为null。该默认节点的Key有着排序上的最小意义,意思是所有需要组织的数据构成的节点的Key都比该默认节点的Key要大。
添加流程:
-
roll骰子决定待添加节点高度。
将待组织的数据加入到跳表中去,加入前,需要roll骰子来决定这个该数据构成的节点的高度,也就是说采用roll骰子的方式来决定由该数据构成的节点指向下级节点的指针有多少个。每一个节点最少的高度为 1,也就是说每一个节点在初始化时就有一条指向下级节点的指针,该指针指向null。
roll骰子会有50%的概率roll到0,有50%的概率roll到1。如果roll到 1,那么该节点高度增加 1,还可以继续roll,直到roll到 0 结束。
-
待添加节点高度和默认节点高度比较,如果默认节点高度小,则默认节点需要扩充高度。
只有默认节点才能扩充高度,一个普通节点不存在有扩充高度的情况,当一个普通节点在roll骰子结束后,它的高度就已经确定了。
默认节点的高度永远和跳表中高度最高的普通节点保持一致
-
根据Key排序找到节点插入的位置。
-
进入搜索流程:
- 如果达到当前搜索高度,则将最右节点在该层的下级指针指向待添加节点,然后将待添加节点在该层的下级指针指向该层中Key大于待添加节点的Key的最左节点。
- 如果没达到当前搜索高度,无动作,跳到当前层的下一层,继续查找该层中Key小于等于待添加节点的Key的最右节点。
例子:
假设要需要组织的数据为:[ 3,5,10,20 ],这些数据roll到的高度为:2,3,1,4。
如下图:
3. 查询数据
进入搜索流程:
- 如果没达到当前搜索高度,跳到下一层,如果到最后一层还是没有,则查询失败。
- 如果达到当前搜索高度,当前层找到了,直接返回。
4. 删除数据
进入搜索流程:
- 如果没达到当前搜索高度,跳到下一层,如果到最后一层还是没有,则删除失败。
- 如果达到当前搜索高度,将本层中Key小于等于待删除节点的Key最右节点的下级指针指向本层中Key大于待删除节点的Key的最左节点。
5. 时间复杂度
已知50%概率roll出 1,50%概率roll出 0。连续roll出 x 次 1 的概率是 (1 / 2) ^ x。
如果需要组织 N 个数据,第 0 层上有 N 个节点,第 1 层上有差不多 N / 2 个节点,第 2 层上有差不多 N / 4 个节点 ......
如果 N 足够大,那么跳表第 0 层的索引数量一直到最高层的索引数量会和一棵的完全二叉树的节点规模差不多,因此跳表的所有操作都是O(logN)的水平。
6. 先进思想
为什么说跳表的设计思想特别先进,因为它不再使用硬规则去设计出一个复杂度为O(logN)的结构了,它依靠概率来完成复杂度O(logN)。
数据的顺序随意,但是每个数据构成节点的高度完全随机,而跳表的效率之和高层和底层的节点分布有关。因此跳表相当于利用了随机概率,让数据顺序和操作代价完全解耦。
这就是为什么说跳表比老的某一种平衡搜索二叉树在思想层面上要先进很多。