数据结构基础 & 手写ArrayList & 手写Hash Table

  • 在计算机科学中,数据结构是一种数据组织、管理和存储的格式,它可以帮助我们实现对数据高效的访问和修改,更准确的说,数据结构是数据值的集合,它可以体现数据值之间的关系,以及可以对数据进行应用的函数或操作!

线性表(Linear List)

  • 线性表是由同一类型的数据元素构成的有序序列的线性结构
  • 线性表中元素的个数就是线性表的长度,表的起始位置称为表头,表的结束位置称为表尾,当一个线性表中没有元素时,称为空表!
线性表一般需要包含以下功能:
  • 获取指定位置上的元素:直接获取线性表指定位置 i 上的元素。
  • 插入元素:在指定位置 i 上插入一个元素。
  • 删除元素:删除指定位置 i 上的一个元素。
  • 获取长度:返回线性表的长度。

实现线性表的结构一般有两种:一种是顺序存储实现,另一种是链式存储实现! 

1. 线性表:顺序表(ArrayList)- 底层采用数组实现

  • 基于数组进行强化,也就是说我们存放数据还是使用数组,但是我们可以为其编写一些额外的操作来强化为线性表,像这样底层依然采用顺序存储实现的线性表,我们称之为顺序表!
顺序表的插入和删除操作,其实就是: 

  • 插入元素时,需要将插入位置给腾出来,也就是要将后面的所有元素向右移动
  • 同理,如果要删除元素,那么也需要将所有的元素向左移动

顺序表是紧凑的,不能出现空位! 

手写ArrayList:

package com.gch.collection;

/**
 * 手写ArrayList
 * 线性表:顺序表 - ArrayList
 * @param <E> 泛型为E,因为表中要存放的具体数据类型是待定的
 */
public class ArrayList<E> {
    /** 当前已经存放的元素数量 */
    private int size = 0;
    /** 当前顺序表的容量 */
    private int capacity = 10;
    /** 底层存放数据的数组 */
    private Object[] array = new Object[capacity];

    /**
     * 插入方法:插入方法需要支持在指定下标位置进行插入
     * 注意:我们的插入操作并不是在任何位置都支持插入的,我们允许插入的位置只能是[0,size]这个闭区间范围内!
     * 由于size是0,所以一上来只能在第一个位置进行插入,也就是下标为0的位置!
     * 此外还要考虑万一插入元素时,我们的顺序表装满了怎么办?
     * 所以说,我们在插入元素之前,需要进行判断,如果已经装满了,那么我们需要先扩容之后才能继续插入新的元素!
     * @param element 要插入的元素
     * @param index 要插入指定位置的下标
     */
    public void add(E element, int index) {
        // 插入元素之前先判断插入位置是否合法
        if (index < 0 || index > size) {
            throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ " + size);
        }
        // 插入元素时,判断顺序表是否装满,如果装满,则需要扩容
        if (size == capacity) {
            // 扩容(规则)为原来容量的1.5倍,右移一位就是除以2
            int newCapacity = capacity + (capacity >> 1);
            // 创建一个新的数组来存放更多的元素
            Object[] newArray = new Object[newCapacity];
            // 使用arraycopy()快速拷贝原数组的内容到新的数组
            System.arraycopy(array,0,newArray,0,size);
            // 更换为新的数组
            array = newArray;
            // 更新容量为扩容之后的容量
            capacity = newCapacity;
        }
        // 插入元素时,判断插入元素的对应位置是否有元素
        if (array[index] == null) {

        } else {
            // 如果插入位置有元素,则后面的元素要进行右移(从后往前,即从右往左一个一个的进行右移,如果从往前往后进行右移,则会覆盖后面的元素)
            for (int i = size; i > index; i--) {
                // 右移
                array[i] = array[i-1];
            }
        }
        // 到这里说明插入位置没有元素,或者已经右移完成腾出位置了
        // 因此可以直接插入元素到对应位置上
        array[index] = element;
        // 插入完成后,让size自增
        size++;
    }

    /**
     * 删除对应位置上的元素
     * @param index 删除元素的下标
     * @return 返回被删除的元素
     */
    public E remove(int index) {
        // 需要对删除元素的下标范围进行判断
        if (index < 0 || index > size - 1) {
            throw new IndexOutOfBoundsException("删除位置非法,合法的插入位置为: 0 ~ " + (size - 1));
        }
        // 因为存放的是Object类型,因此这里需要强制类型转换为E
        E e = (E) array[index];
        // 因为要删除元素,因此后面的元素要进行左移(从左往右进行一个一个的左移)
        for (int i = index; i < size; i++) {
            array[i] = array[i + 1];
        }
        // 左移完成后也就意味着删除完成,删除完成后记得让size--
        size--;
        // 返回被删除的元素
        return e;
    }

    /**
     * 获取指定索引位置处的元素值
     * @param index 指定下标
     * @return 返回指定下标位置上的元素
     */
    public E get(int index) {
        // 判断指定的下标是否合法
        if (index < 0 || index > size - 1) {
            throw new IndexOutOfBoundsException("非法下标,合法的下标范围为: 0 ~ " + (size - 1));
        }
        // 说明下标合法,直接返回对应下标位置上的元素即可
        return (E) array[index];
    }

    /**
     * 修改某个索引位置处的元素值,返回修改前的元素值
     * @param index 要修改的索引下标
     * @param element 修改后的元素
     * @return 返回修改前的元素值
     */
    public E set(int index, E element) {
        // 判断要修改的索引位置是否合法
        if (index < 0 || index > size - 1) {
            System.out.println("非法下标,合法的下标范围为: o ~ " + (size - 1));
        }
        // 获取修改的索引位置对应的元素值
        E e = (E) array[index];
        // 修改对应索引位置的元素值
        array[index] = element;
        // 返回对应索引位置修改前的元素值
        return e;
    }

    /**
     * 获取当前存放的元素数量或个数(获取集合的大小)
     * @return 返回当前存放的元素数量
     */
    public int size() {
        return size;
    }

    /**
     * 判断当前集合是否为空
     * @return 如果为空,则返回true,否则返回false
     */
    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 重写toString()方法打印当前存放的元素
     *
     * @return
     */
    @Override
    public String toString() {
        System.out.println(size);
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < size; i++) {
            sb.append(array[i]).append(" ");
        }
        return sb.toString();
    }
}

2. 线性表:链表 - LinkedList 

链表是可以将物理地址上不连续的数据连接起来,通过指针来对物理地址进行操作,实现增删改查等功能。

链表的优点:

  • 插入删除速度快(因为有next指针指向下一个节点,通过改变指针的方向可以方便的增删或删除元素)
  • 大小不固定,拓展灵活
  • 内存利用率高,不会浪费内存,可以使用内存中细小的不连续空间,并且在需要空间的时候才创建空间。

链表的缺点:

  • 不能随机查找,必须从第一个开始遍历,查找效率低!

链表不同于顺序表:

  • 顺序表底层采用数组作为存储容器,需要分配一块儿连续且完整的内存空间进行使用;
  • 链表则不需要,它通过一个指针来连接各个分散的节点形成一个链状的结构每个节点存放一个元素,以及指向下一个节点的指针,通过这样一个一个相连,最后形成了链表,链表它不需要申请连续的内存空间,只需要按照顺序连接即可,虽然物理上可能不相邻,但是在逻辑上依然是每个元素相邻存放的,这样的结构叫做链表(单链表)。
链表分为带头节点的链表和不带头节点的链表:
  • 带头节点的链表就是会有一个头节点指向后续的整个链表,但是头节点不存放数据;

  • 而不带头节点的链表,第一个节点就是存放数据的节点。

一般设计链表都会采用带头节点的结构,因为操作更加方便!

  • Java里面没有指针,Java里面只有引用!(指针 => 引用)

链表的插入:

1. 修改新插入的节点的后续节点指向原本在这个位置的节点:

2. 接着将前驱节点的后续节点的指向修改为我们新插入的节点:

这样,我们就成功插入了一个新的节点!

  • 现在就已经将节点成功插入到了第二个位置上:
删除操作:
1. 直接将待删除节点的前驱节点的指向修改为待删除节点的下一个节点!

问题:什么情况下使用顺序表,什么情况下使用链表呢?

  • 链表在随机访问元素时,需要通过遍历来完成而顺序表则利用数组的特性直接访问得到,所以,当我们读取数据 多于 插入或者删除数据的情况下 => 读多写少,使用顺序表会更好!
  • 顺序表因为在插入元素时需要移动后续元素,因此整个移动操作会浪费时间;而链表则不需要,只需要修改节点指向即可完成插入,所以在频繁出现插入或者删除的情况下,使用链表会更好! 

虽然单链表使用起来也比较简单方便,但是我们如果想要操作某一个节点,比如删除或者插入,由于单链表的性质,我们只能先去找到它的前驱节点,才能进行,为了解决这种查找前驱节点非常麻烦的问题,我们可以让节点不仅保存指向后续节点的指针,同时也保存指向前驱节点的指针,这就是双链表或双向链表:

这样,我们无论在哪个节点,都能跟快速找到对应的前驱节点,这样就很方便了! 

3. 线性表:栈 - Stack

  • 栈(也叫堆栈,Stack)是一种特殊的线性表,它只能在表尾进行插入和删除操作:

也就是说,我们只能在一端进行插入和删除操作当我们依次插入1、2、3、4这四个元素后,连续进行四次删除操作,删除的顺序刚好相反:4、3、2、1,我们一般将其竖着看:

底部称为栈底,顶部称为栈顶,所有的操作只能在栈顶进行,也就是说,被压在下方的元素,只能等待其上方的元素出栈之后才能取出,就像我们往箱子里面放书一样,因为只有一个口取出里面的物品,所以被压在下面的书只能等上面的书被拿出来之后才能取出,这就是栈的思想,它是一种先进后出的数据结构(FILO:First In,Last Out)!

实现栈也是非常简单的,实际上使用链表实现栈会更加的方便,我们可以直接将头节点指向栈顶接节点,而栈顶结点连接后续的栈内节点:

  • pop - 出栈操作:从栈顶取出一个元素!出栈只需要将第一个元素移除即可!
  • push - 入栈操作:向栈中压入一个新的元素! 当有新的元素入栈,只需要在链表头部插入新的节点即可!

4. 线性表:队列 - Queue

  • 我们知道,栈中元素只能从栈顶出入,它是一种特殊的线性表,同样的,队列(Queue)也是一种特殊的线性表!

队列就像我们在超市、食堂需要排队一样,我们总是拍成一列,先到的人就排前面,后来的人就排在后面,越前面的人越先完成任务,这就是队列,队列有队头和队尾:


秉承先来后到的原则(可以看到数据结构都是有原则的,更何况我们人!),队列中的元素只能从队尾入队,只能从队首出队,也就是说队列的入队顺序和出队顺序是完全相同的,所以队列是一种先进先出(FIFO:First In,First Out)的数据结构! 

队列也可以使用链表和顺序表来实现,只不过使用链表的话就不需要关心容量之类的问题了,会更加灵活一些:

注意:我们需要同时保存队首和队尾两个指针,因为是单链表,所以队首需要存放头节点的指针

队尾则直接指向尾结点的指针! 

  • 当有新的元素入队时,只需要拼在队尾就行,同时队尾指针也要后移一位!

  • 出队时,只需要移除对首指向的下一个元素即可! 

5. 树 - Tree

  • 一个节点下面可能会连接多个节点,并不断延伸,就像树枝一样,从位于最上方的节点开始不断向下,而这种数据结构,我们就称之为树 - Tree,注意分支只能向后单独延伸,不能与其它分支上的节点相交~!
关于 树 - Tree的几个专业名词:
  • 我们一般称位于最上方的节点为树的根节点(Root),因为整颗树正是从这里开始延伸出去的。
  • 根节点没有任何父节点,其它节点有且只有一个父节点!
  • 每个节点可以有任意数量的子节点,没有子节点的节点称为叶节点或者叶子节点,具有子节点的节点称为内部节点!
  • 每个节点连接的子节点的数目(分支的数目),我们称为节点的度(Degree)。
  • 每个节点延伸下去的下一个节点都可以称为一颗子树(SubTree),比如节点B及其之后延伸的所有分支合在一起,就是一颗A的子树! 
  • 每个节点的层次(Level)按照从上往下的顺序,树的根节点为1,每向下一层 +1,整颗树中所有节点的最大层次,就是这颗树的深度(Depth)!
  • 如果两个节点的父节点是同一个,那么称这两个节点为兄弟节点!

5.1 二叉树 - Binary Tree

  • 二叉树它是一种特殊的树,它的度最大只能为2,所以我们称其为二叉树,一颗二叉树大概长这样:
二叉树 - Binary Tree
  • 并且二叉树任何节点的子树都是有左右之分的,不能颠倒顺序,根节点左边的子树,称为左子树,根节点右边的子树,称为右子树!

5.2 满二叉树(也叫真二叉树)

满二叉树
  • 在二叉树中,如果一颗二叉树的所有非叶子节点都有两个子节点,则该二叉树是一颗满二叉树,也叫做真二叉树!

  • 说白了就是没有出现任何度为1的节点的二叉树就是满二叉树,可以看到整棵树都是很饱满的!

5.3  完全二叉树

完全二叉树
  • 只有最后一层有空缺,并且所有的叶子节点是按照从左往右的顺序排列的,这样的二叉树我们一般称其为完全二叉树!
  • 在完全二叉树中,节点的缺失位置只允许在右侧,缺失节点的位置不能在中间或者左侧,同时任意两个节点之间没有间隙!

二叉树也可以使用链式存储形式,只不过现在一个节点需要存放一个指向左子树的引用和一个指向右子树的引用:

5.4 二叉树的遍历

  • 如果说我们想要遍历一颗二叉树,也就是说我们想要遍历二叉树的每一个节点,由于树形结构特殊,遍历顺序并不唯一,所以一共有四种访问方式:前序遍历、中序遍历、后序遍历、层序遍历不同的访问方式输出的节点顺序也不同! 

前序遍历:中左右 或者 根左右
  • 前序遍历是一种勇往直前的态度,走到哪儿就遍历到哪儿,先走左边再走右边,比如上面这个图,首先会从根节点开始:
前序遍历
  • 从 A - 根节点开始,先左后右,下一个节点就是B,然后继续走左边,是D, B的左边结束了,那么就要开始B的右边了,所以下一个节点是E,E结束之后,现在A的左子树已经全部遍历完成了,然后就是右边,接着就是C,C没有左子树,那么只能走右边,最后输出F,所以上面这个二叉树的前序遍历结果为:ABDECF~!
  1. 打印根节点
  2. 前序遍历左子树
  3. 前序遍历右子树 
中序遍历:左中右,左中右...  或者   左根右
  • 中序遍历在顺序上与前序遍历不同,前序遍历是走到哪儿就打印到哪儿;中序遍历需要先完成整颗左子树的遍历后再打印,然后再遍历其右子树!
中序遍历
  1. 中序遍历左子树
  2. 打印根节点
  3. 中序遍历右子树 

所以这颗二叉树的中序遍历结果为:DBFACF~! 

后序遍历:左右根
  • 后序遍历继续将打印的时机延后,需要等待左右子树全部遍历完成,才会去进行打印!
二叉树的三种遍历
层序遍历
  • 层序遍历,它是按照每一层在进行遍历,层序遍历实际上就是按照从上往下每一层,从左到右的顺序打印每个节点,比如下面的这颗二叉树,那么层序遍历的结果就是:ABCDEF梦想这样一层一层的从左到右的顺序挨个输出:
层序遍历

5.5 二叉查找树和平衡二叉树(AVL Tree)

回顾 - 二分搜索算法:
  • 对于一个有序的数组,可以一半一半的去找通过不断缩小查找范围,最终可以很高效率的找到有序数组中的目标位置!

而二叉查找树就是利用了二分搜索算法类似的思想来进行构建的! 

二叉查找树也叫二叉搜索树或者二叉排序树,它具有一定的规则:

  • 左子树中所有节点的值,均小于其根节点的值。
  • 右子树中所有节点的值,均大于其根节点的值。
  • 二叉查找树 / 二叉搜索树 / 二叉排序树的子树也是二叉查找树 / 二叉搜索树 / 二叉排序树。
一颗二叉搜索树长这样:
二叉查找树 / 二叉搜索树 / 二叉排序树
  • 实际上,我们在对普通二叉树进行搜索时,可能需要挨个进行查看比较,而有了二叉搜索树,查找效率就大大提升了,因为它就像二分搜索那样,一半儿一半儿的进行查找!
  • 利用二叉查找树,我们在搜索某个值的时候,效率会得到巨大提升,虽然看起来比较完美,但是如果我们插入的数值是一串单调递增或单调递减的数字,就会形成斜树,也就是最后就组成了这样的一颗只有一边的二叉树,这种情况,与其说它是一颗二叉树,不如说就是一个链表,如果这时我们想要查找某个节点,那么实际上查找的时间并没有得到任何优化,直接就退化成线性查找了(也就是挨个去查找)。
二叉查找树的斜树情况,退化成链表了

平衡二叉树或二叉平衡树的引入 

所以,二叉查找树只有在理想的情况下, 查找效率才是最高的,而像这种极端情况,就性能而言几乎没有任何提升因此,我们在进行节点插入时,需要尽可能地避免一边倒地情况,这里就需要引入平衡二叉树概念了。

平衡二叉树其实就是二叉查找树的进阶版,它在插入时会去动态的维护二叉树的平衡!

实际上我们发现,在插入时如果不去维护二叉树的平衡,某一边就可能会无限制地延伸下去,出现极度不平衡的情况,而我们理想中的二叉查找树左右尽可能是保持平衡的,平衡二叉树(AVL树)就是为了解决这样的问题而生的。

平衡二叉树
平衡二叉树它的性质如下:
  • 平衡二叉树一定是一颗二叉查找树!
  • 任意节点的左右子树也是一颗平衡二叉树!
  • 从根节点开始,左右子树的高度差不能超过1,否则视为不平衡!

可以看到,这些性质规定了平衡二叉树需要保持高度平衡这样我们的查找效率才不会因为数据的插入而出现降低的情况二叉树上节点的 左子树高度 减去 右子树高度 ,得到的结果称为该节点的平衡因子(Balance Factor),比如:

各节点的平衡因子

通过计算平衡因子,我们就可以快速得到是否出现失衡的情况,比如下面的这颗二叉树,正在执行插入操作:

可以看到,当插入之后,不再满足平衡二叉树的定义时,就出现了失衡的情况,我们可能会遇到以下几种情况导致失衡:

根据插入节点的不同偏向情况,分为LL型失衡、LR型失衡、RR型失衡、RL型失衡~!

而对于这种失衡情况,为了继续保持平衡状态,我们就需要进行处理了,我们需要依次进行调整,从而使得这颗二叉树能够继续保持平衡:

1. LL型调整(右旋)  

典型的LL型失衡

以上就是典型的LL型失衡为了能够保证二叉树的平衡,我们需要将其进行旋转来维持平衡去纠正最小不平衡子树即可对于LL型失衡,我们只需要进行右旋操作,首先我们要先找到最小不平衡子树:

  • 从哪里第一个开始不平衡的,它就是最小不平衡子树! 
  • 上图是从15这个节点开始不平衡的,因为从节点15开始是最小不平衡子树!

  • 可以看到根节点的平衡因子是2,是目前最小的出现不平衡的点所以说从根节点,也就是最小的出现不平衡的点,开始向左的三个节点需要进行右旋操作,右旋需要将这三个节点中间的节点作为新的根节点,其它的两个节点变成左右子树(左右节点):
LL型调整(右旋)

这样,我们就完成了右旋操作,可以看到右旋之后,所有的节点继续保持平衡,并且仍然是一颗二叉查找树!

2. RR型调整(左旋)

  • LL型是右旋,而RR型失衡是两个都往右边倒,因此需要左旋操作:
典型的RR型失衡
  • 首先找到最小的出现不平衡的点,开始向右的三个节点需要进行左旋操作,左旋需要将这三个节点中间的节点作为新的根节点,其它的两个节点变成左右子树或左右节点: 
RR型调整(左旋)

3.  RL型调整(先右旋,再左旋,需要旋转两次才行)

典型的RL型失衡:回旋镖形状,先右后左的状态
  • 形状是先向右再向左,这就是典型的RL型失衡~!

针对于RL型失衡,我们需要先进行右旋操作,注意这里的右旋操作针对的是后两个节点,首先找到最小的出现不平衡的点,然后对最小的出现不平衡的点的后两个节点进行右旋操作,然后再针对最小的出现不平衡的点的开始向右的三个节点进行左旋操作,左旋需要将这三个节点中间的节点作为新的根节点,其它的两个节点变成左右子树(左右节点):

RL型失衡调整(先右旋,再左旋)

完成两次旋转后,二叉树又重新变回了平衡状态~!

4. LR型调整(先左旋,再右旋)

典型的LR型失衡:回旋镖形状,先左后右的状态
  • 形状是先向左再向右,这就是典型的LR型失衡,我们同样需要对其进行两次旋转操作,从而使其二叉树变回平衡状态!

针对于LR型失衡,我们需要先进行左旋操作,注意这里的左旋操作针对的是后两个节点,首先找到最小的出现不平衡的点,然后针对最小的出现的不平衡的点的后两个节点进行左旋操作,然后再针对最小的出现不平衡的点开始向左的三个节点进行右旋操作,右旋需要将这三个节点中间的节点作为新的根节点,其它的两个节点变成左右子树(左右节点):

LR型失衡调整:先左旋,再右旋

完成两次旋转后,二叉树就又能继续保持平衡了!

总结:

  • 我们只需要在插入节点时注意维护整棵树的平衡因子,保证其处于稳定状态,这样就可以让整棵树一直处于高度平衡的状态,就不会出现极端情况使得整棵树退化成链表,从而使查找效率急剧降低了! 

5.6 树:红黑树(Red/Black Tree)

  • 红黑树是一颗自平衡的二叉查找树,因此红黑树也是二叉查找树的一种,红黑树的节点有红有黑!
红黑树它大概长这样: 
红黑树

红黑树它并不像平衡二叉树那样严格要求高度差不能超过1,红黑树不是通过高度平衡的,它的平衡是通过 "红黑规则" 进行实现的,只需要满足四个规则即可,它的规则如下(红黑规则):

  1. 每一个节点或是红色的,或者是黑色的,但根节点必须是黑色! 
  2. 如果某一个节点是红色,那么它的子节点必须是黑色,也就是红色节点的父节点和子节点不能为红色(不能出现两个红色节点相连的情况)!
  3. 如果一个节点没有子节点 或 没有父节点则该节点对应的指针属性值为NIL,这些NIL视为叶节点或者空节点,所有的空节点或者叶节点(NIL)都是黑色空节点视为NIL,红黑树中将空节点视为叶子节点)!
  4. 路径算法 - 每条路径均包含相同数目的黑色节点:每个节点到空节点(NIL)路径上出现的黑色节点的个数都相等,也就是说,对于每一个节点,从该节点到空节点或者叶节点的路径上,均包含相同数目的黑色节点!
那什么时候需要变色,什么时候需要旋转呢?

 

首先这颗红黑树只有一个根节点,因为根节点必须是黑色,所以说直接变成黑色,当我们插入一个新节点时,所有新插入的节点,默认情况下都是红色,所以新来的节点7根据排序规则直接放到11的左边就行了,再插入一个新节点15,根据排序规则节点15直接放到根节点11的右边:

现在我们继续插入一个节点4,根据排序规则放到7的左边:

但此时,我们发现违反了红黑树的规则,因为如果某一个节点是红色,那么它的子节点必须是黑色,也就是红色节点的父节点和子节点不能为红色,即不能出现两个红色节点相连的情况!

此时为了保持红黑树的性质,我们就需要进行颜色变换才可以,那怎么进行颜色变化呢?

  • 对于这种新插入的违反了红黑树规则的节点的父节点为红色,其父节点的兄弟节点也为红色的情况,我们才可以直接进行颜色变换!
  • 我们只需要将其新插入的违反了红黑树规则的节点的父节点和其父节点的兄弟节点修改为黑色即可! 

为啥父节点的兄弟节点也需要变成黑色?
  • 为了满足红黑树的性质或者红黑规则中的路径算法:每条路径均包含相同数目的黑色节点! 

接着我们继续插入节点1,根据排序规则节点1放到节点4的左边:

此时对于这种其违反了红黑树的节点的父节点为红色,父节点的兄弟节点为黑色(NIL视为黑色)的情况,不能直接进行颜色变换,因为如果变色的话,就会不符合路径算法,从而违反了红黑树的红黑规则,因此我们只能考虑旋转来解决该问题,需要先根据情况(LL型、RR型、LR型、RL型)来进行旋转,然后再变色,就可以解决问题!

由上图得知这实际上是一种LL型失衡,需要将违反红黑树规则的向左的三个节点通过进行右旋操作,右旋后再进行变色,将右旋后新的根节点改为黑色,之前的根节点改为红色:

同理,如果遇到了LR型失衡,跟前面一样,先左旋再右旋,然后进行变色即可: 

而RR型和RL型同理,这里就不一一演示了....

可以看到,红黑树实际上是通过颜色规则来进行旋转调整的! 

6. 哈希表或散列表(Hash Table)

  • 哈希表(Hash Table),也称为散列表,是一种基于哈希函数实现的数据结构。通过散列函数(哈希函数)将要参与检索的数据与散列值(哈希值)关联起来,生成一种便于搜索的数据结构,我们称其为散列表或哈希表!
  • 也就是说,现在我们需要将一堆数据保存起来,这些数据会通过哈希函数进行计算,得到与其对应的哈希值哈希值是通过哈希函数计算得到的,当我们下次需要查找这些数据时,只需要再次计算哈希值就能快速找到对应的元素了! 
  • 哈希表的实现依赖于哈希函数,哈希表的时间复杂度接近于O(1)!
哈希表或散列表

哈希函数也叫散列函数,哈希函数可以对一个目标计算出其对应的哈希值,并且,只要是同一个目标,无论计算多少次,得到的哈希值都是一样的结果,不同的目标计算出的结果几乎都不相同!

我们可以将元素保存到哈希表中,而保存的位置则与其对应的哈希值有关哈希值是通过哈希函数计算得到的,一般比较简单的哈希函数就是取模(取余)操作,哈希表的长度是多少,模就是多少!

元素插入的位置为计算出来的哈希值,注意哈希表中保存的数据是无序的,因为我们也不清楚计算完哈希值最后会放到哪个位置。

哈希表在查找时只需要进行依次哈希函数计算就能直接找到对应元素的存储位置,效率极高!

手写哈希表:

package com.gch.hash;

/**
 * 手写哈希表或散列表
 */
public class HashTable<E> {
    // 定义数组的长度
    private final int TABLE_SIZE = 10;
    // 定义数组
    private final Object[] hashTable = new Object[TABLE_SIZE];

    /**
     * 哈希函数或散列函数:计算出元素存放的位置
     * @param object 要查找的元素
     * @return 返回元素在数组中的位置
     */
    private int hash(Object object){
        // 每个对象都有独一无二的哈希值,可以通过hashCode()方法得到(只有极小的概率会出现相同的情况)
        // 注意:hashCode()方法为Native方法
        int hashCode = object.hashCode();
        // 取模操作:说白了就是用计算出来的元素的哈希值对数组长度进行取余
        return hashCode % TABLE_SIZE;
    }

    /**
     * 给哈希表中添加元素方法
     * @param element 要添加的元素
     */
    public void insert(E element) {
        // 利用哈希函数对该元素进行取模操作,从而获得该元素在数组中的下标
        int index = hash(element);
        // 直接将该元素存入到数组中的对应位置
        hashTable[index] = element;
    }

    /**
     * 判断该元素是否在哈希表中 / 判断哈希表中是否包含该元素
     * @param element 要查找的元素
     * @return 如果在,则返回true,否则false
     */
    public boolean contains(E element) {
        // 利用哈希函数计算该元素在数组中的下标
        int index = hash(element);
        // 判断该元素与该数组下标对应的元素是否相等
        return element == hashTable[index];
    }
}

我们知道通过哈希函数计算得到一个目标的哈希值,但是在某些情况下,哈希值可能会出现相同的情况,这种情况,我们称为哈希碰撞(哈希冲突):

哈希碰撞或哈希冲突

这种情况是不可避免的,我们只能通过使用更加高级的哈希函数来尽可能避免这种情况,但是无法完全避免,常见的哈希冲突解决方案是链地址法当发生哈希冲突时,我们依然将其保存在对应的位置上,我们可以将其连接为一个链表的形式:

当表中元素变多时,差不多就变成了这样,我们一般将其横过来看:

通过结合链表的形式,哈希冲突问题就可以得到解决了,但是同时也会出现一定的查找开销,因为现在有了链表,我们得挨个往后看才能找到,当链表变得很长时,查找效率也会变低! 

此时我们可以考虑结合其它得数据结构来提升效率,比如当链表长度达到8时,自动转换为一颗平衡二叉树或者是红黑树,这样就可以在一定程序上缓解查找的压力了。 

猜你喜欢

转载自blog.csdn.net/weixin_53622554/article/details/134125651