java基础知识总结——基本数据结构

数据结构是指数据的组织、管理和存储格式,其使用目的是为了高效地访问和修改数据。常见的数据结构有数组、链表、栈、队列、散列表(哈希表)、树等。其中数组和链表可以看成是实实在在的物理结构,其他数据结构都可以看成是逻辑结构,他们的物理实现既可以利用数组也可以利用链表来实现。

1、数组

数组是最为简单常见的数据结构,数组中的每个元素都有自己的下标,元素在内存中是顺序存储的,且内存是由一个个连续的内存单元组成的。

数组的优势在于读取和更新元素,可以直接根据下标读取和更新元素,这种方式只花费常数时间的复杂度。

在不改变元素相对位置的前提下,数组增加和删除元素的操作都涉及到大量元素的被迫移动,其时间复杂度都是线性时间复杂度。

总之,数组适合读操作多、写操作少的场景。

2、链表

链表是一种在物理上非连续、非顺序的数据结构,由若干节点所组成,单向链表由存放数据的data部分和存放指向下一个节点的指针next部分所组成。而双向链表由两个指针,一个指向上一个节点,另一个指向下一个节点。

链表的优势在于能够灵活地进行插入和删除操作。在查找元素时,链表不像数组那样可以通过下标快速进行定位,只能从头节点开始向后一个一个节点逐一查找,其时间复杂度为线性时间复杂度。更新元素前,需要先找到该元素,如果不考虑查找的过程,则链表更新元素的操作和数组一样。

插入和删除元素时,若不考虑之前查找元素的过程,只考虑纯粹的插入和删除操作,则时间复杂度都是常数级别的。

3、栈和队列

3.1栈

栈是一种线性数据结构,即可以利用数组实现,也可以利用链表实现。栈中的元素只能先进后出,其基本操作为入栈(push)和出栈(pop),入栈和出栈只会影响到最后一个元素,所以无论栈是以数组还是以链表实现,入栈和出栈的时间复杂度都是常数级别的。

3.2队列

队列也是一种线性数据结构,不同于栈的先进后出,队列中的元素只能先进先出,队列的出口端叫对头,入口端叫队尾。队列既可以利用数组实现,也可以利用链表实现。

队列的基本操作为入队和出队,对于链表实现方式,队列的入队和出队操作和栈是大同小异的,而对于数组实现方式来说,采用了循环队列的方式来维持队列容量的恒定。

什么是循环队列呢?

假设一个队列经过反复的入队和出队操作,还剩下2个元素,在“物理”上分布于数组的末尾位置。这时又有一个新元素将要入队。

image.png

在数组不做扩容的前提下,如何让新元素入队并确定新的队尾位置呢?我们可以利用已出队元素留下的空间,让队尾指针重新指回数组的首位。

image.png

这样一来,整个队列的元素就“循环”起来了。在物理存储上,队尾的位置也可以在队头之前。当再有元素入队时,将其放入数组的首位,队尾指针继续后移即可。

image.png

一直到(队尾下标+1)%数组长度 = 队头下标时,代表此队列真的已经满了。需要注意的是,队尾指针指向的位置永远空出1位,所以队列最大容量比数组长度小1。

image.png

4、散列表(哈希表)

散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value)的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。可以说散列表是数组与链表的结合,它通过哈希函数实现Key和数组下标的转换,通过开放寻址法和链表法来解决哈希冲突。

4.1哈希函数

数组可以根据下标进行元素的随机访问,是一种查询效率最高的数据结构。散列表在本质上也是一个数组,可是数组只能根据下标,像a[0]、a[1]、a[2]、a[3]、a[4]这样来访问,而散列表的Key则是以字符串类型为主的。例如以学生的学号作为Key,输入002123,查询到李四;或者以单词为Key,输入by,查询到数字46……

所以我们需要一个“中转站”,通过某种方式,把Key和数组下标进行转换。这个中转站就叫作哈希函数

image.png

在不同的语言中,哈希函数的实现方式是不一样的。这里以Java的常用集合HashMap为例,来看一看哈希函数在Java中的实现。

在Java及大多数面向对象的语言中,每一个对象都有属于自己的hashcode,这个hashcode是区分不同对象的重要标识。无论对象自身的类型是什么,它们的hashcode都是一个整型变量。

既然都是整型变量,想要转化成数组的下标也就不难实现了。最简单的转化方式是什么呢?是按照数组长度进行取模运算。

index = HashCode (Key) % Array.length

实际上,JDK(Java Development Kit,Java语言的软件开发工具包)中的哈希函数并没有直接采用取模运算,而是利用了位运算的方式来优化性能。

通过哈希函数,我们可以把字符串或其他类型的Key,转化成数组的下标index。

如给出一个长度为8的数组,则当

key=001121时,

index = HashCode ("001121") % Array.length = 1420036703 % 8 = 7

而当key=this时,

index = HashCode ("this") % Array.length = 3559070 % 8 = 6

4.2写操作的过程

写操作就是在散列表中插入新的键值对(在JDK中叫作Entry)。

如调用hashMap.put("002931", "王五"),意思是插入一组Key为002931、Value为王五的键值对。

具体该怎么做呢?

第1步,通过哈希函数,把Key转化成数组下标5。

第2步,如果数组下标5对应的位置没有元素,就把这个Entry填充到数组下标5的位置。

image.png

但是,由于数组的长度是有限的,当插入的Entry越来越多时,不同的Key通过哈希函数获得的下标有可能是相同的。例如002936这个Key对应的数组下标是2;002947这个Key对应的数组下标也是2。

image.png

这种情况,就叫作哈希冲突

解决哈希冲突的方法主要有两种,一种是开放寻址法,一种是链表法。

开放寻址法的原理很简单,当一个Key通过哈希函数获得对应的数组下标已被占用时,我们可以“另谋高就”,寻找下一个空档位置。

以上面的情况为例,Entry6通过哈希函数得到下标2,该下标在数组中已经有了其他元素,那么就向后移动1位,看看数组下标3的位置是否有空。

image.png

很不巧,下标3也已经被占用,那么就再向后移动1位,看看数组下标4的位置是否有空。

image.png

幸运的是,数组下标4的位置还没有被占用,因此把Entry6存入数组下标4的位置。

image.png

这就是开放寻址法的基本思路。当然,在遇到哈希冲突时,寻址方式有很多种,并不一定只是简单地寻找当前元素的后一个元素,这里只是举一个简单的示例而已。

在Java中,ThreadLocal所使用的就是开放寻址法。

接下来,重点讲一下解决哈希冲突的另一种方法——链表法。这种方法被应用在了Java的集合类HashMap当中。

HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。

image.png
4.3读操作的过程

读操作就是通过给定的Key,在散列表中查找对应的Value。

例如调用 hashMap.get("002936"),意思是查找Key为002936的Entry在散列表中所对应的值。

具体该怎么做呢?下面以链表法为例来讲一下。

第1步,通过哈希函数,把Key转化成数组下标2。

第2步,找到数组下标2所对应的元素,如果这个元素的Key是002936,那么就找到了;如果这个Key不是002936也没关系,由于数组的每个元素都与一个链表对应,我们可以顺着链表慢慢往下找,看看能否找到与Key相匹配的节点。

image.png

在上图中,首先查到的节点Entry6的Key是002947,和待查找的Key 002936不符。接着定位到链表下一个节点Entry1,发现Entry1的Key 002936正是我们要寻找的,所以返回Entry1的Value即可。

4.3散列表的扩容

既然散列表是基于数组实现的,那么散列表也要涉及扩容的问题。

首先,什么时候需要进行扩容呢?

当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。

image.png

这时,散列表就需要扩展它的长度,也就是进行扩容

对于JDK中的散列表实现类HashMap来说,影响其扩容的因素有两个。

  • Capacity,即HashMap的当前长度

  • LoadFactor,即HashMap的负载因子,默认值为0.75f

衡量HashMap需要进行扩容的条件如下。

HashMap.Size >= Capacity×LoadFactor

扩容不是简单地把散列表的长度扩大,而是经历了下面两个步骤。

1.扩容,创建一个新的Entry空数组,长度是原数组的2倍。

2.重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。

经过扩容,原本拥挤的散列表重新变得稀疏,原有的Entry也重新得到了尽可能均匀的分配。

扩容前的HashMap:

image.png

扩容后的HashMap:

image.png

需要注意的是,关于HashMap的实现,JDK 8和以前的版本有着很大的不同。当多个Entry被Hash到同一个数组下标位置时,为了提升插入和查找的效率,HashMap会把Entry的链表转化为红黑树这种数据结构。

5、树

在实际场景中,有许多逻辑关系并不是简单的线性关系,常常存在着一对多,甚至是多对多的情况。其中树和图就是典型的非线性数据结构。下面主要介绍一种典型的树——二叉树。二叉树(binary tree)是树的一种特殊形式。二叉,顾名思义,这种树的每个节点最多有2个孩子节点。注意,这里是最多有2个,也可能只有1个,或者没有孩子节点。

二叉树的结构如图所示。

image.png
5.1满二叉树与完全二叉树

此外,二叉树还有两种特殊形式,一个叫作满二叉树,另一个叫作完全二叉树。

什么是满二叉树呢?

一个二叉树的所有非叶子节点都存在左右孩子,并且所有叶子节点都在同一层级上,那么这个树就是满二叉树。

image.png

简单点说,满二叉树的每一个分支都是满的。

什么又是完全二叉树呢?

完全二叉树的定义很有意思。对一个有n个节点的二叉树,按层级顺序编号,则所有节点的编号为从1到n。如果这个树所有节点和同样深度的满二叉树的编号为从1到n的节点位置相同,则这个二叉树为完全二叉树。

这个定义还真绕,看看下图就很容易理解了。

image.png

在上图中,二叉树编号从1到12的12个节点,和前面满二叉树编号从1到12的节点位置完全对应。因此这个树是完全二叉树。

完全二叉树的条件没有满二叉树那么苛刻:满二叉树要求所有分支都是满的;而完全二叉树只需保证最后一个节点之前的节点都齐全即可。

5.2二叉树的存储方式

二叉树属于逻辑结构,它可以通过多种物理结构来表达。

链式存储是二叉树最直观的存储方式。二叉树的每一个节点包含3部分。

  • 存储数据的data变量

  • 指向左孩子的left指针

  • 指向右孩子的right指针

使用数组存储时,会按照层级顺序把二叉树的节点放到数组中对应的位置上。如果某一个节点的左孩子或右孩子空缺,则数组的相应位置也空出来。这样可以更方便地在数组中定位二叉树的孩子节点和父节点。

假设一个父节点的下标是parent,那么它的左孩子节点下标就是2×parent + 1;右孩子节点下标就是2×parent + 2

显然,对于一个稀疏的二叉树来说,用数组表示法是非常浪费空间的。

什么样的二叉树最适合用数组表示呢?二叉堆这种特殊的完全二叉树,就是用数组来存储的。

5.3二叉查找树

二叉树包含许多特殊的形式,每一种形式都有自己的作用,但是其最主要的应用还在于进行查找操作和维持相对顺序这两个方面。

二叉查找树在二叉树的基础上增加了以下几个条件。

  • 如果左子树不为空,则左子树上所有节点的值均小于根节点的值

  • 如果右子树不为空,则右子树上所有节点的值均大于根节点的值

  • 左、右子树也都是二叉查找树

下图就是一个标准的二叉查找树。

image.png

二叉查找树的这些条件有什么用呢?当然是为了查找方便。

对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是n,那么搜索节点的时间复杂度就是O(logn),和树的深度是一样的。这种依靠比较大小来逐步查找的方式,和二分查找算法非常相似。

二叉查找树要求左子树小于父节点,右子树大于父节点,正是这样保证了二叉树的有序性。

因此二叉查找树还有另一个名字——二叉排序树(binary sort tree)

新插入的节点,同样要遵循二叉排序树的原则。

5.3.1二叉查找树的自平衡问题:

在插入新元素的过程中会遇到问题,比如在二叉排序树中依次插入9、8、7、6、5、4,可能会出现下面的结果。

image.png

这样不只是外观看起来变得怪异了,查询节点的时间复杂度也退化成了O(n)。

解决这个问题就涉及到二叉树的自平衡。二叉树自平衡的方式有多种,如红黑树、AVL树、树堆等。

5.4二叉堆

二叉堆本质上是一种完全二叉树,它分为最大堆和最小堆。二叉堆虽然是一个完全二叉树,但它的存储方式并不是链式存储,而是顺序存储。换句话说,二叉堆的所有节点都存储在数组中

什么是最大堆呢?最大堆的任何一个父节点的值,都大于或等于它左、右孩子节点的值。

image.png

什么是最小堆呢?最小堆的任何一个父节点的值,都小于或等于它左、右孩子节点的值。

image.png

二叉堆的根节点叫作堆顶

最大堆和最小堆的特点决定了:最大堆的堆顶是整个堆中的最大元素;最小堆的堆顶是整个堆中的最小元素

对于二叉堆,有如下几种操作。其中堆的插入和删除操作的时间复杂度是O(logn)。但构建堆的时间复杂度却并不是O(nlogn),而是O(n)。

  1. 插入节点。

  2. 删除节点。

  3. 构建二叉堆。

这几种操作都基于堆的自我调整。所谓堆的自我调整,就是把一个不符合堆性质的完全二叉树,调整成一个堆。下面以最小堆为例,看看二叉堆是如何进行自我调整的。

5.4.1插入节点

当二叉堆插入节点时,插入位置是完全二叉树的最后一个位置。例如插入一个新节点,值是 0。

image.png

这时,新节点的父节点5比0大,显然不符合最小堆的性质。于是让新节点“上浮”,和父节点交换位置。

image.png

继续用节点0和父节点3做比较,因为0小于3,则让新节点继续“上浮”。

image.png

继续比较,最终新节点0“上浮”到了堆顶位置。

image.png
5.4.2删除节点

二叉堆删除节点的过程和插入节点的过程正好相反,所删除的是处于堆顶的节点。例如删除最小堆的堆顶节点1。

image.png

这时,为了继续维持完全二叉树的结构,我们把堆的最后一个节点10临时补到原本堆顶的位置。

image.png

接下来,让暂处堆顶位置的节点10和它的左、右孩子进行比较,如果左、右孩子节点中最小的一个(显然是节点2)比节点10小,那么让节点10“下沉”。

image.png

继续让节点10和它的左、右孩子做比较,左、右孩子中最小的是节点7,由于10大于7,让节点10继续“下沉”。

image.png

这样一来,二叉堆重新得到了调整。

5.4.3构建二叉堆

构建二叉堆,也就是把一个无序的完全二叉树调整为二叉堆,本质就是让所有非叶子节点依次“下沉”

下面举一个无序完全二叉树的例子,如下图所示。

image.png

首先,从最后一个非叶子节点开始,也就是从节点10开始。如果节点10大于它左、右孩子节点中最小的一个,则节点10“下沉”。

image.png

接下来轮到节点3,如果节点3大于它左、右孩子节点中最小的一个,则节点3“下沉”。

image.png

然后轮到节点1,如果节点1大于它左、右孩子节点中最小的一个,则节点1“下沉”。事实上节点1小于它的左、右孩子,所以不用改变。

接下来轮到节点7,如果节点7大于它左、右孩子节点中最小的一个,则节点7”下沉“。

image.png

节点7继续比较,继续“下沉”。

image.png

经过上述几轮比较和“下沉”操作,最终每一节点都小于它的左、右孩子节点,一个无序的完全二叉树就被构建成了一个最小堆。

5.5优先队列

优先队列不再遵循先入先出的原则,而是分为两种情况。

  • 最大优先队列,无论入队顺序如何,都是当前最大的元素优先出队

  • 最小优先队列,无论入队顺序如何,都是当前最小的元素优先出队

要实现以上需求,利用线性数据结构并非不能实现,但是时间复杂度较高。一般采用二叉堆来实现优化队列,可以用最大堆来实现最大优先队列,用最小堆 来实现最小优化队列,这样的话,每一次入队操作就是堆的插入操作,每一次出队操作就是删除堆顶节点。二叉堆节点“上浮”和“下沉”的时间复杂度都是O(logn),所以优先队列入队和出队的时间复杂度也是O(logn)。

参考资料:《漫画算法:小灰的算法之旅》

你可能感兴趣的:(java基础知识总结——基本数据结构)