This the multi-page printable view of this section. Click here to print.
数据结构
- 1: CH01-数组/链表
- 2: CH02-哈希表
- 3: CH03-树/二叉树
- 4: CH04-排序二叉树
- 5: CH05-平衡二叉树
- 6: CH06-红黑树
- 7: CH07-哈夫曼树
- 8: CH08-前缀树
- 9: CH09-图概念
- 10: CH10-图遍历
- 11: CH11-最小生成树
- 12: CH12-图最短路径
- 13: CH13-图拓扑排序
- 14: CH14-图-AOE-关键路径
- 15: CH15-Iterable
- 16: CH16-Collection
- 17: CH17-List
- 18: CH18-ArrayList
- 19: CH19-Queue
- 20: CH20-Deque
- 21: CH21-ArrayQueue
- 22: CH22-PriorityQueue
- 23: CH23-LinkedList
- 24: CH24-Vector
- 25: CH25-Stack
- 26: CH26-Map
- 27: CH27-SortedMap
- 28: CH28-NavigableMap
- 29: CH29-TreeMap
- 30: CH30-HashMap
- 31: CH31-LinkedHashMap
- 32: CH32-EnumMap
- 33: CH33-HashTable
- 34: CH34-Set
- 35: CH35-EnumSet
- 36: CH36-HashSet
- 37: CH37-LinkedHashSet
- 38: CH38-TreeSet
- 39: CH39-FailFast
1 - CH01-数组/链表
数组和链表是数据结构中最基本的部分,也是其余众多数据结构的基础。即使在 Java 中,这两种结构使用的也很普遍。这里我们会先对它们进行简要分析。
数组
在java中,数组定义为一种基本类型,其可以通过下标获取到对应位置的数据。其在内存中的存放结构如下:
正如上图所示,数组在内存中是一段连续的存储单元,每个数据依次放在每个单元中。分析这种结构,我们可以得出以下几个结论:
- 创建一个数组,必须声明其长度,以在内存中寻找合适的一段连续存储单元。这也意味着数组的大小是固定的,我们无法动态调整其大小。
- 想要获取数组中第i个元素,其时间复杂度是 O(1),因为可以根据其地址直接找到它。同理修改也是。
- 数组对查询表现一般,要想查找一个元素,需要遍历,时间复杂度为O(n)。
- 因为地址连续,想要在数组中插入一个元素是复杂的,因为从插入位置起,后边的所有元素都需要向后移动一位。同理删除也是,只是移动方向为向前。并且,当数组存满时,就无法继续插入了。
- 因为数组要占据一整块内存,有可能产生许多的碎片,也可能因为找不到合适的内存块,而导致存储失败。
链表
链表是一种离散存储结构,其在内存中存储不是连续的,每个数据元素都通过一个指针指向其下一个元素的地址。根据指针域的不同,链表又分为单链表、双向链表、循环链表等,这里我们只分析单链表。示意图如下所示:
分析这种结构,我们可以得出以下几个结论:
- 声明一个链表时,不需要知道其长度,也不需要连续的内存块,所以其大小可以动态调整。
- 链表的每个元素都分为数据域和指针域,前者是实际存储的数据,后者则指向下一个元素的地址。和数组相比,每个元素需要占用的内存更大了。
- 要获取链表的第 i 个元素变得复杂,因为其地址存放在它上一个元素的指针域里,所以只能从第一个元素起,进行 i 次操作。同理修改也是。
- 链表对查询表现也一般,需要遍历,时间复杂度为O(n)。
- 增加与删除一个元素更方便了,因为没有对内存地址的限制,我们只需要在对应节点合理处理下指针域的值,就可以把一个元素插入链表或者从链表删除。
- 链表对内存的要求很小,只要能够存储下一个数据元素的内存块都可以使用,因此不会造成碎片化。
总结起来就是:大小可以动态调整,增删迅速,查找较慢,数据元素所占内存略多,不需要整块内存块,不会造成碎片化。
如何选择
通过以上分析,数组和链表对我们影响最大的几点区别在于:
- 数组按位置查找迅速,链表增删方便。
- 数组是固定大小,链表可以随时扩充与缩减。
- 链表每个元素占据内存略多于数组。
- 数组和链表在查询方面表现都比较一般,耗时较长。
在数据量很小,内容基本固定时,我们选择何种数据结构的影响并不大。但当数据量较大时,如果我们需要对数据进行频繁的插入删除,我们应该选择链表,如果我们需要频繁的获取某个位置的元素,我们应该选择数组。数组与链表并没有明确的优劣之分,根据不同的使用场景进行不同的选择,才是这两种结构使用的最佳方式。
2 - CH02-哈希表
无论是数组还是链表,其对数据的查询表现都比较无力,要想知道一个元素是否在数组或链表中,只能从前向后挨个对比。出现这个问题的根源在于,我们没有办法直接根据一个元素找到它存储的位置,那有没有办法消除这个对比的过程呢?
哈希表就是解决查询问题的一种方案。在后续将会分析的二叉排序树中,还会将数据排序以进行二分查找,将时间复杂度从 O(n) 降低到 O(lg n)。
哈希表与 hash 函数
通俗来讲,哈希表就是通过关键字来获取数据的一种数据结构,它通过把关键字映射为表中的位置来获取元素,这种映射主要是使用 Hash 函数。
Hash 函数,实际上是建立起 key 值与 int 值映射关系的函数。这就好比我们每个人都有一个身份证号一样,无论是男是女,出生在何处,都可以通过身份证号来分辨,这就是把人的信息映射成一串数字的典型做法。Hash 函数和此类似,不过是把任意的 Java 对象,映射成一个 int 数值,供哈希表使用。
而哈希表,就是一个数组,只是其元素不是按照数组的规则排列的。任何一个元素要放进哈希表中,都必须先通过 Hash 函数获取到一个 int 数值,这个数值经过处理后将作为它的存放位置,然后这个元素才能放进哈希表中。
可以发现,数组与哈希表的操作不同之处主要在于,前者是直接插入,后者需要通过 Hash 函数计算后再插入。可以通过下图对比来理解:
哈希表完全继承了数组的优点,又显著的提高了查询的速度,通过 Hash 函数使得查询速度达到了 O(1)。既然有了哈希表,它这么优秀,为何还需要数组的存在呢?那是因为 Hash 表是有缺陷的,这个缺陷就是哈希碰撞。
哈希碰撞
Hash 函数所做的事,就是无论什么对象,都根据一个规则映射为一个 int 值。被转换的对象有无数种可能,但是 int 的值是有限的,它只有 2^32 个,这样一来,必然会有不同的对象,映射得到相同的 int 值,这就是所谓的哈希碰撞。发生碰撞之后,就要把不同的元素插入到相同的位置,这时候单纯的使用一维数组已经无法满足需求了。
解决哈希碰撞
要解决哈希碰撞,我们可以想到多种解决方案。例如使用二维数组,将碰撞的元素按顺序存储起来,类似下图:
这样的方式有一个很大的诟病,因为数组大小是固定的,所以第二维的数组长度都是一样的,但是哈希碰撞一定是比较少发生的情况,也就是我们声明了一个很大的数组,但是其中大部分都是闲置的,这就浪费了大量的内存。
还有一些方案是考虑了哈希表的散列化,将元素插入到空闲的位置去。因为哈希表基本不会像数组一样每个位置都有元素,这样就可以将碰撞的元素插入到这些空闲的位置中区,这种方案称为定址法。但是这个方法在扩展性上表现不佳,我们这里就不再浪费篇幅来解释它了。
目前比较通用的方法,就是使用数组+链表组合的方式。当出现哈希碰撞时,在该位置的数据就通过链表的方式链接起来,如下图所示:
这是当前比较理想的方法,既继承了数组的优点,又在碰撞时继承了链表的优点,这也是哈希表强大的地方之一。
在 JDK1.7 及之前的版本中,HashMap 的存储结构和上图是一致的,在 JDK1.8 之后还加入了红黑树以进一步优化,在后续文章中我们会对其进行详尽的分析。
哈希表的优缺点
哈希表是一种优化存储的思想,具体存储元素的依然是其他的数据结构。设计良好的哈希表,能同时兼备数组和链表的优点,它能在插入和查找时都具备良好的性能。然而设计不好的哈希表,有可能会出现较多的哈希碰撞,导致链表过长,从而哈希表会更像一个链表。还有当数据量很大时,为防止链表过长,就需要对数组进行扩容,这时就涉及到了数组的拷贝,其对性能的影响也很严重,所以需要提前对可能的情况有良好的预测,才能真正发挥哈希表的优势。
3 - CH03-树/二叉树
数组和链表都是用来解决一对一问题的,而一对多问题则需要树来解决。这里,我们重点关注二叉排序树,所以只会介绍一些必需了解的概念,关于树的更多知识,大家可以查看相关书籍进行系统的学习。
树的定义
树(Tree)是 n(n≥0) 个结点的有限集。n=0 时称为空树。在任意一棵非空树中:
- 有且仅有一个特定的称为根(Root)的结点;
- 当 n>1 时,其余结点可分为 m(m>0) 个互不相交的有限集 T1 、T2、……、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
如下图:
与现实中的树不同,数据结构里的树的根在最上方,并且只有一个根,就像一棵倒置的树。树的每个结点往下都是一棵子树,且这些子树不能相交,如下所示就不是一棵正确的树:
相关概念
节点分类
树的结点包含一个数据元素及若干指向其子树的分支。结点拥有的子树数称为结点的度(Degree) 。度为 0 的结点称为叶结点(Leaf) 或终端结点;度不为 0 的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。
如下图所示,A 结点为根节点,G、H、I、J、F 为叶节点,其余节点则为内部节点,此树的度为 3。
节点间的关系
结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。同一个双亲的孩子之间互称兄弟(Sibling)。结点的祖先是从根到该结点所经分支上的所有结点。反之,以某结点为根的子树中的任一结点都称为该结点的子孙。
深度
结点的层次(LeveI)从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第 L 层,则其子树的根就在第 L+1 层。其双亲在同一层的结点互为堂兄弟。树中结点的最大层次称为树的深度(Depth)或高度。
有序树,无序树
如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
二叉树
二叉树(Binary Tree)是n(n ≥ 0) 个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
下图就是一个二叉树,二叉树就是每个结点的度≤2的树。
二叉树遍历
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次旦仅被访问一次。
前序遍历
规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树, 再前序遍历右子树。
如下图所示,遍历结果为:ABDGHCEIF。
中序遍历
规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点) ,中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。
如下图所示,遍历结果为:GDHBAEICF。
后序遍历
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。
如下图所示,遍历结果为:GHDBIEFCA。
4 - CH04-排序二叉树
解决查询速度慢的方案除了哈希表外,还可以使用二叉排序树。我们知道,查询慢主要是因为不知道元素的位置,使用 hash 函数映射虽然解决了问题,但其并不稳定,当出现大量的哈希碰撞后其表现更像一个链表,查询速度大大降低。
二叉排序树的方案则是使元素有序,这样便可以使用二分法进行查找了,虽然效率相比 hash 函数低一些,但可以通过 AVL 树、红黑树等增加稳定性。
HashMap 在 JDK1.8 的实现中,就结合了哈希表的高效和红黑树的稳定,我们之后会详细分析其实现。
定义
二叉排序树(Binary Sort Tree),又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
- 它的左、右子树也分别为二叉排序树。
如下就是一棵简单的二叉排序树:
当对这棵树进行中序遍历时,其结果将按照从小到大排序。
查询操作
二叉排序树的查找时间复杂度为 O(lg n),查找使用二分法。要在上图中找到元素 37,只需要四次操作即可。
首先,找到根元素22,37比22大,所以淘汰左子树,再找到35,淘汰左子树,再找到41,进入左子树,得到37。可以看到其速度比挨个对比高了很多。
插入操作
二叉排序树的插入操作和查询类似,也需要通过二分法进行查找,找到合适的位置再插入元素,所以其插入速度相比链表较慢。
删除操作
从二叉排序树中删除一个元素主要分为三种情况。
例如要从下面这个二叉排序树中删除一个元素:
- 删除的元素是叶结点,这时可以直接删除它。比如要删除值为 1 的元素,删除它对树没有任何影响。
- 删除的元素仅有左孩子或者仅有右孩子时,直接让其孩子顶替它即可。比如要删除元素 35,只需要用 41 顶替它即可。
- 删除的元素既有左孩子又有右孩子,这时删除它相对复杂。一种好的方式是找到它的前驱或者后继来代替它。比如要删除元素 9,就用 6 或者 13 代替它即可。
问题
一棵普通的二叉排序树也会出现不平衡问题,如果插入的数据都在树的一侧,就会使得树的深度迅速增大,每次二分查找可以排除的数据很少,从而查询速度严重下降,比如下方这棵树:
要查找值为 2 的元素,使用二分法和使用链表速度差不多。为了解决这种问题,就需要在元素插入时即进行修正,后续介绍的AVL树和红黑树就是两种不同的解决方案。
5 - CH05-平衡二叉树
二叉排序树很好的平衡了插入与查找的效率,但不平衡的二叉排序树效率大打折扣。今天介绍的 AVL 树就是一种解决此问题的方案。
定义
平衡二叉树(Self-Balancing Binary Search Tree 或Height-Balanced Binary Search Tree),是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于 1 。它是一种高度平衡的二叉排序树。意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1 。我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子 BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是 -1 、0 和 1。
如下图就不是一棵AVL树,因为结点18的左子树高度为2,右子树高度为0,高度差大于1。
但通过一定的步骤调整之后,可以将其转为一棵平衡二叉树,如下图:
实现原理
平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。最小不平衡子树是指距离插入结点最近的,且平衡因子的绝对值大于 1 的结点为根的子树。
下面通过一个实例,了解平衡二叉树的构建过程。
假如我们要将数组 int[] a = {3, 2, 1, 4, 5, 6, 7, 10, 9, 8}
构建成一棵二叉排序树,如果直接按照二叉排序树的定义,会得到下面的结果:
这样的结果对查找是十分不利的,树的高度达到了 8,而且大多数只有一个孩子。所以我们需要一些操作,将它变成一棵 AVL 树。
首先,插入元素3和2时,没有什么影响,此时3的平衡因子为1,2的平衡因子为0,结果如下:
现在,要把1插入树中,这时结果如下所示:
此时3的平衡因子为2了,不再符合平衡二叉树的规则。此时,整棵树就是最小不平衡子树,我们将其右旋:
再插入4,也不会影响平衡,结果如下:
此时,插入元素5,以3为根结点的子树成为了最小不平衡子树,如下所示:
现在要对其进行左旋:
现在继续插入元素6,此时以2为根结点的右子树为最小不平衡子树,结果如下:
这时再次需要对其进行左旋,这次旋转后要将4的左孩子变为2的右孩子,以满足二叉排序树的定义,如下所示:
再插入7时,情况和之前有些类似了,结果如下:
左旋后结果如下:
现在,继续插入10,此时无需调整,结果如下:
下一步,插入元素9,此时结果如下:
按照之前的经验,这时我们应该进行左旋了,但是左旋之后9将变为10的右孩子,这会不符合二叉排序树的定义。和之前不同的是,7和10的平衡因子符号相反,这是造成这一结果的原因。这种情况下,要先以10为根节点右旋,再进行左旋,结果如下所示:
最后插入元素8,如下所示:
此时情况和上述类似,6是最小不平衡子树的根结点,9和6的平衡因子符号相反,所以先以9为根结点右旋:
然后再以6左旋:
可以看到,此树的高度仅为4,与之前的8相差很多,性能自然也好很多。
平衡二叉树的删除操作与插入类似,这里将不再介绍。大家可以自己思考如何最高效地删除元素,可以分叶结点、仅有一个子结点和有两个子结点三种情况考虑,这里还用到了递归的思想。
接下来我们将介绍另一种实现方式,红黑树。
6 - CH06-红黑树
红黑树和AVL树的思想是类似的,都是在插入过程中对二叉排序树进行调整,从而提升性能,它的增删改查均可以在 O(lg n) 内完成。
定义
红黑树是一棵二叉排序树。且满足以下特点:
- 每个节点或者是黑色,或者是红色。
- 根节点是黑色。
- 每个叶子节点(NIL)是黑色。(注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!)
- 如果一个节点是红色的,则它的两个儿子都是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
示例中每个结点最后都是一个 NIL 结点,它是黑色的,不过我们画图时通常会省略它。所以下文以及后续文章中绘制时都会省略NIL结点,大家记得还有它就可以。
实现原理
红黑树的插入与删除和AVL树类似,也是每插入一个结点,都检查是否破坏了树的结构,然后进行调整。红黑树每个结点插入时默认都为红色,这样做可以降低黑高,也可以减少调整的次数。
插入元素
红黑树的概念理解起来较为复杂,我们以一个简单的示例,看看如何构造一棵红黑树。
现有数组 int[] a = {1, 10, 9, 2, 3, 8, 7, 4, 5, 6};
我们要将其变为一棵红黑树。
首先插入1,此时树是空的,1就是根结点,根结点是黑色的:
然后插入元素10,此时依然符合规则,结果如下:
当插入元素9时,这时是需要调整的第一种情况,结果如下:
红黑树规则4中强调不能有两个相邻的红色结点,所以此时我们需要对其进行调整。调整的原则有多个相关因素,这里的情况是,父结点10是其祖父结点1(父结点的父结点)的右孩子,当前结点9是其父结点10的左孩子,且没有叔叔结点(父结点的兄弟结点),此时需要进行两次旋转,第一次,以父结点10右旋:
然后将父结点(此时是9)染为黑色,祖父结点1染为红色,如下所示:
然后以祖父结点1左旋:
下一步,插入元素2,结果如下:
此时情况与上一步类似,区别在于父结点1是祖父结点9的左孩子,当前结点2是父结点的右孩子,且叔叔结点10是红色的。这时需要先将叔叔结点10染为黑色,再进行下一步操作,具体做法是将父结点1和叔叔结点10染为黑色,祖父结点9染为红色,如下所示:
由于结点9是根节点,必须为黑色,将它染为黑色即可:
下一步,插入元素3,如下所示:
这和我们之前插入元素10的情况一模一样,需要将父结点2染为黑色,祖父结点1染为红色,如下所示:
然后左旋:
下一步,插入元素8,结果如下:
此时和插入元素2有些类似,区别在于父结点3是右孩子,当前结点8也是右孩子,这时也需要先将叔叔结点1染为黑色,具体操作是先将1和3染为黑色,再将祖父结点2染为红色,如下所示:
此时树已经平衡了,不需要再进行其他操作了,现在插入元素7,如下所示:
这时和之前插入元素9时一模一样了,先将7和8右旋,如下所示:
然后将7染为黑色,3染为红色,再进行左旋,结果如下:
下一步要插入的元素是4,结果如下:
这里和插入元素2是类似的,先将3和8染为黑色,7染为红色,如下所示:
但此时2和7相邻且颜色均为红色,我们需要对它们继续进行调整。这时情况变为了父结点2为红色,叔叔结点10为黑色,且2为左孩子,7为右孩子,这时需要以2左旋。这时左旋与之前不同的地方在于结点7旋转完成后将有三个孩子,结果类似于下图:
这种情况处理起来也很简单,只需要把7原来的左孩子3,变成2的右孩子即可,结果如下:
然后再把2的父结点7染为黑色,祖父结点9染为红色。结果如下所示:
此时又需要右旋了,我们要以9右旋,右旋完成后7又有三个孩子,这种情况和上述是对称的,我们把7原有的右孩子8,变成9的左孩子即可,如下所示:
下一个要插入的元素是5,插入后如下所示:
有了上述一些操作,处理5变得十分简单,将3染为红色,4染为黑色,然后左旋,结果如下所示:
最后插入元素6,如下所示:
又是叔叔结点3为红色的情况,这种情况我们处理过多次了,首先将3和5染为黑色,4染为红色,结果如下:
此时问题向上传递到了元素4,我们看2、4、7、9的颜色和位置关系,这种情况我们也处理过,先将2和9染为黑色,7染为红色,结果如下:
最后7是根结点,染为黑色即可,最终结果如下所示:
插入总结
可以看到,在插入元素时,叔叔结点是主要影响因素,待插入结点与父结点的关系决定了是否需要多次旋转。可以总结为以下几种情况:
- 如果父结点是黑色,插入即可,无需调整。
- 如果叔叔结点是红色,就把父结点和叔叔结点都转为黑色,祖父结点转为红色,将不平衡向上传递。
- 如果叔叔结点是黑色或者没有叔叔结点,就看父结点和待插入结点的关系。如果待插入结点和父结点的关系,与父结点与祖父结点的关系一致,比如待插入结点是父结点的左孩子,父结点也是祖父结点的左孩子,就无需多次旋转。否则就先通过相应的旋转将其关系变为一致。
删除元素
要从一棵红黑树中删除一个元素,主要分为三种情况。
情况-1:待删除元素没有孩子
没有孩子指的是没有值不为NIL的孩子。这种情况下,如果删除的元素是红色的,可以直接删除,如果删除的元素是黑色的,就需要进行调整了。
例如我们从下图中删除元素1:
删除元素1后,2的左孩子为NIL,这条支路上的黑色结点数就比其他支路少了,所以需要进行调整。
这时,我们的关注点从叔叔结点转到兄弟结点,也就是结点4,此时4是红色的,就把它染为黑色,把父结点2染为红色,如下所示:
然后以2左旋,结果如下:
此时兄弟结点为3,且它没有红色的孩子,这时只需要把它染为红色,父结点2染为黑色即可。结果如下所示:
情况-2:待删除元素有一个孩子
这应该是删除操作中最简单的一种情况了,根据红黑树的定义,我们可以推测,如果一个元素仅有一个孩子,那么这个元素一定是黑色的,而且其孩子是红色的。
假设我们有一个红色节点,它是树中的某一个节点,且仅有一个孩子,那么根据红色节点不能相邻的条件,它的孩子一定是黑色的,如下所示:
但这个子树的黑高却不再平衡了(注意每个节点的叶节点都是一个NIL节点),因此红色节点不可能只有一个孩子。
而若是一个黑色节点仅有一个孩子,如果其孩子是黑色的,同样会打破黑高的平衡,所以其孩子只能是红色的,如下所示:
只有这一种情况符合红黑树的定义,这时要删除这个元素,只需要使用其孩子代替它,仅代替值而不代替颜色即可,上图的情况删除完后变为:
可以看到,树的黑高并没有发生变化,因此也不需要进行调整。
情况-3:待删除元素有两个孩子
我们在讨论二叉排序树时说过,如果删除一个有两个孩子的元素,可以使用它的前驱或者后继结点代替它。因为它的前驱或者后继结点最多只会有一个孩子,所以这种情况可以转为情况1或情况2处理。
删除总结
删除元素最复杂的是情况1,这主要由其兄弟结点以及兄弟结点的孩子颜色共同决定。这里简要做下总结。
我们以N代表当前待删除节点,以P代表父结点,以S代表兄弟结点,以SL代表兄弟结点的左孩子,SR代表兄弟结点的右孩子,如下所示:
根据红黑树定义,这种情况下S要么有红色的子结点,要么只有NIL结点,以下对S有黑色结点的情况均表示NIL。
主要有以下几种:
- S是红色,P一定是黑色,S也不会有红色的孩子,如下:
此时把P和S颜色变换,再左旋,如下:
这样变换后,N支路上的黑色结点并没有增加,所以依然少一个,
- P,S以及S的全部孩子都是黑色。
无论S有几个孩子,或者没有孩子,只要不是红色都是这种情况,此时情况如下:
我们把S染为红色,这样一来,N和S两个支路都少了一个黑色结点,所以可以把问题向父结点转移,通过递归解决。染色后如下:
- P为红(S一定为黑),S的孩子都为黑。
这种情况最为简单,只需要把P和S颜色交换即可。这样N支路多了一个黑色元素,而S支路没有减少,所以达到了平衡。
- P任意色,S为黑,N是P的左孩子,S的右孩子SR为红,S的左孩子任意。
如下所示:
此时将S改为P的颜色,SR和P改为黑色,然后左旋,结果如下:
可以发现,此时N支路多了一个黑色结点,而其余支路均没有收到影响,所以调整完毕。
- P任意色,S为黑,N是P的左孩子,S的左孩子SL为红,S的右孩子SR为黑,如下所示:
此时变换S和SL的颜色,然后右旋,结果如下:
这时,所有分支的黑色结点数均没有改变,但情况5转为了情况4,再进行一次操作即可。
还有一些情况与上述是对称的,我们进行相应的转换即可。
总结
红黑树的操作比较复杂,插入元素可能需要多次变色与旋转,删除也是。这些操作的目的都是为了保证红黑树的结构不被破坏。这些复杂的插入与删除操作希望大家可以亲手尝试一下,以加深理解。
红黑树是 JDK 中 TreeMap、TreeSet 的底层数据结构,在 JDK1.8 中HashMap也用到了红黑树,所以掌握它对我们后续的分析十分重要。
- 关于红黑树与AVL树的区别?
- 为何选用红黑树?
7 - CH07-哈夫曼树
相关名词
先看一棵哈夫曼树: (哈夫曼树推理是通过叶子节点,所以理解的时候需要忽略非叶子节点,很多文章在这点上有误导):
- 路径与路径长度:从树中一个节点到另一个节点之间的分支构成了两个节点之间的路径,路径上的分支数目称作路径长度。若规定根节点位于第一层,则根节点到第H层的节点的路径长度为H-1。如到40 的路径长度为1;30的路径长度为2;20的路径长度为3。
- 节点的权:将树中的节点赋予一个某种含义的数值作为该节点的权值,该值称为节点的权;
- 带权路径长度:从根节点到某个节点之间的路径长度与该节点的权的乘积。例如上图节点10的路径长度为3,它的带权路径长度为 10 * 3 = 30。
- 树的带权路径长度:树的带权路径长度为所有叶子节点的带权路径长度之和,称为WPL。上图的WPL = 1x40+2x30+3x10+3x20 = 190,而哈夫曼树就是树的带权路径最小的二叉树。
哈夫曼树的构建
假设 n 个权值,则构造出的哈夫曼树有 n 个叶子节点。n 个权值分别设为 w1、w2、…、wn,哈夫曼树的构造规则为:
- 将w1、w2、…,wn看成是有 n 棵树的森林(每棵树仅有一个结点);
- 在森林中选出根结点的权值最小的两棵树进行合并,作为一棵新树的左、右子树,且新树的根结点权值为其左、右子树根结点权值之和;
- 从森林中删除选取的两棵树,并将新树加入森林;
- 重复上面两步,直到森林中只剩一棵树为止,该树即为所求得的哈夫曼树。
上图中,它的叶子节点为 10、20、30、40,以这四个权值来构建哈夫曼树的过程为:
哈夫曼编码
为{10,20,30,40}这四个权值构建了哈夫曼编码后,我们可以由如下规则获得它们的哈夫曼编码:
从根节点到每一个叶子节点的路径上,左分支记为0,右分支记为1,将这些0与1连起来即为叶子节点的哈夫曼编码。如下图:
(字母)权值 | 编码 |
---|---|
10 | 100 |
20 | 101 |
30 | 11 |
40 | 0 |
由此可见,出现频率越高的字母(也即权值越大),其编码越短。这便使编码之后的字符串的平均长度、期望值降低,从而达到无损压缩数据的目的。
具体流程如下:
哈夫曼树的实现
哈夫曼树的重点是如何构造哈夫曼树。本文构造哈夫曼时,用到了"(二叉堆)最小堆"。下面对哈夫曼树进行讲解。
节点
public class HuffmanNode implements Comparable, Cloneable {
protected int key; // 权值
protected HuffmanNode left; // 左孩子
protected HuffmanNode right; // 右孩子
protected HuffmanNode parent; // 父结点
protected HuffmanNode(int key, HuffmanNode left, HuffmanNode right, HuffmanNode parent) {
this.key = key;
this.left = left;
this.right = right;
this.parent = parent;
}
@Override
public Object clone() {
Object obj=null;
try {
obj = (HuffmanNode)super.clone();//Object 中的clone()识别出你要复制的是哪一个对象。
} catch(CloneNotSupportedException e) {
System.out.println(e.toString());
}
return obj;
}
@Override
public int compareTo(Object obj) {
return this.key - ((HuffmanNode)obj).key;
}
}
树
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
public class Huffman {
private HuffmanNode mRoot; // 根结点
/*
* 创建Huffman树
*
* @param 权值数组
*/
public Huffman(int a[]) {
HuffmanNode parent = null;
MinHeap heap;
// 建立数组a对应的最小堆
heap = new MinHeap(a);
for(int i=0; i<a.length-1; i++) {
HuffmanNode left = heap.dumpFromMinimum(); // 最小节点是左孩子
HuffmanNode right = heap.dumpFromMinimum(); // 其次才是右孩子
// 新建parent节点,左右孩子分别是left/right;
// parent的大小是左右孩子之和
parent = new HuffmanNode(left.key+right.key, left, right, null);
left.parent = parent;
right.parent = parent;
// 将parent节点数据拷贝到"最小堆"中
heap.insert(parent);
}
mRoot = parent;
// 销毁最小堆
heap.destroy();
}
/*
* 前序遍历"Huffman树"
*/
private void preOrder(HuffmanNode tree) {
if(tree != null) {
System.out.print(tree.key+" ");
preOrder(tree.left);
preOrder(tree.right);
}
}
public void preOrder() {
preOrder(mRoot);
}
/*
* 中序遍历"Huffman树"
*/
private void inOrder(HuffmanNode tree) {
if(tree != null) {
inOrder(tree.left);
System.out.print(tree.key+" ");
inOrder(tree.right);
}
}
public void inOrder() {
inOrder(mRoot);
}
/*
* 后序遍历"Huffman树"
*/
private void postOrder(HuffmanNode tree) {
if(tree != null)
{
postOrder(tree.left);
postOrder(tree.right);
System.out.print(tree.key+" ");
}
}
public void postOrder() {
postOrder(mRoot);
}
/*
* 销毁Huffman树
*/
private void destroy(HuffmanNode tree) {
if (tree==null)
return ;
if (tree.left != null)
destroy(tree.left);
if (tree.right != null)
destroy(tree.right);
tree=null;
}
public void destroy() {
destroy(mRoot);
mRoot = null;
}
/*
* 打印"Huffman树"
*
* key -- 节点的键值
* direction -- 0,表示该节点是根节点;
* -1,表示该节点是它的父结点的左孩子;
* 1,表示该节点是它的父结点的右孩子。
*/
private void print(HuffmanNode tree, int key, int direction) {
if(tree != null) {
if(direction==0) // tree是根节点
System.out.printf("%2d is root\n", tree.key);
else // tree是分支节点
System.out.printf("%2d is %2d's %6s child\n", tree.key, key, direction==1?"right" : "left");
print(tree.left, tree.key, -1);
print(tree.right,tree.key, 1);
}
}
public void print() {
if (mRoot != null)
print(mRoot, mRoot.key, 0);
}
}
最小堆
import java.util.ArrayList;
import java.util.List;
public class MinHeap {
private List<HuffmanNode> mHeap; // 存放堆的数组
/*
* 创建最小堆
*
* 参数说明:
* a -- 数据所在的数组
*/
protected MinHeap(int a[]) {
mHeap = new ArrayList<HuffmanNode>();
// 初始化数组
for(int i=0; i<a.length; i++) {
HuffmanNode node = new HuffmanNode(a[i], null, null, null);
mHeap.add(node);
}
// 从(size/2-1) --> 0逐次遍历。遍历之后,得到的数组实际上是一个最小堆。
for (int i = a.length / 2 - 1; i >= 0; i--)
filterdown(i, a.length-1);
}
/*
* 最小堆的向下调整算法
*
* 注:数组实现的堆中,第N个节点的左孩子的索引值是(2N+1),右孩子的索引是(2N+2)。
*
* 参数说明:
* start -- 被下调节点的起始位置(一般为0,表示从第1个开始)
* end -- 截至范围(一般为数组中最后一个元素的索引)
*/
protected void filterdown(int start, int end) {
int c = start; // 当前(current)节点的位置
int l = 2*c + 1; // 左(left)孩子的位置
HuffmanNode tmp = mHeap.get(c); // 当前(current)节点
while(l <= end) {
// "l"是左孩子,"l+1"是右孩子
if(l < end && (mHeap.get(l).compareTo(mHeap.get(l+1))>0))
l++; // 左右两孩子中选择较小者,即mHeap[l+1]
int cmp = tmp.compareTo(mHeap.get(l));
if(cmp <= 0)
break; //调整结束
else {
mHeap.set(c, mHeap.get(l));
c = l;
l = 2*l + 1;
}
}
mHeap.set(c, tmp);
}
/*
* 最小堆的向上调整算法(从start开始向上直到0,调整堆)
*
* 注:数组实现的堆中,第N个节点的左孩子的索引值是(2N+1),右孩子的索引是(2N+2)。
*
* 参数说明:
* start -- 被上调节点的起始位置(一般为数组中最后一个元素的索引)
*/
protected void filterup(int start) {
int c = start; // 当前节点(current)的位置
int p = (c-1)/2; // 父(parent)结点的位置
HuffmanNode tmp = mHeap.get(c); // 当前(current)节点
while(c > 0) {
int cmp = mHeap.get(p).compareTo(tmp);
if(cmp <= 0)
break;
else {
mHeap.set(c, mHeap.get(p));
c = p;
p = (p-1)/2;
}
}
mHeap.set(c, tmp);
}
/*
* 将node插入到二叉堆中
*/
protected void insert(HuffmanNode node) {
int size = mHeap.size();
mHeap.add(node); // 将"数组"插在表尾
filterup(size); // 向上调整堆
}
/*
* 交换两个HuffmanNode节点的全部数据
*/
private void swapNode(int i, int j) {
HuffmanNode tmp = mHeap.get(i);
mHeap.set(i, mHeap.get(j));
mHeap.set(j, tmp);
}
/*
* 新建一个节点,并将最小堆中最小节点的数据复制给该节点。
* 然后除最小节点之外的数据重新构造成最小堆。
*
* 返回值:
* 失败返回null。
*/
protected HuffmanNode dumpFromMinimum() {
int size = mHeap.size();
// 如果"堆"已空,则返回
if(size == 0)
return null;
// 将"最小节点"克隆一份,将克隆得到的对象赋值给node
HuffmanNode node = (HuffmanNode)mHeap.get(0).clone();
// 交换"最小节点"和"最后一个节点"
mHeap.set(0, mHeap.get(size-1));
// 删除最后的元素
mHeap.remove(size-1);
if (mHeap.size() > 1)
filterdown(0, mHeap.size()-1);
return node;
}
// 销毁最小堆
protected void destroy() {
mHeap.clear();
mHeap = null;
}
}
测试
public class HuffmanTest {
private static final int a[]= {5,6,8,7,15};
public static void main(String[] args) {
int i;
Huffman tree;
System.out.print("== 添加数组: ");
for(i=0; i<a.length; i++)
System.out.print(a[i]+" ");
// 创建数组a对应的Huffman树
tree = new Huffman(a);
System.out.print("\n== 前序遍历: ");
tree.preOrder();
System.out.print("\n== 中序遍历: ");
tree.inOrder();
System.out.print("\n== 后序遍历: ");
tree.postOrder();
System.out.println();
System.out.println("== 树的详细信息: ");
tree.print();
// 销毁二叉树
tree.destroy();
}
}
8 - CH08-前缀树
Trie,又称字典树、单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
什么是前缀树
在计算机科学中,trie,又称前缀树或字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。一般情况下,不是所有的节点都有对应的值,只有叶子节点和部分内部节点所对应的键才有相关的值。
Trie 这个术语来自于 retrieval。根据词源学,trie 的发明者 Edward Fredkin 把它读作/ˈtriː/ “tree”。但是,其他作者把它读作/ˈtraɪ/ “try”。trie 中的键通常是字符串,但也可以是其它的结构。trie 的算法可以很容易地修改为处理其它结构的有序序列,比如一串数字或者形状的排列。比如,bitwise trie 中的键是一串位元,可以用于表示整数或者内存地址。trie 树常用于搜索提示。如当输入一个网址,可以自动搜索出可能的选择。当没有完全匹配的搜索结果,可以返回前缀最相似的可能。
上图是一棵Trie树,表示了关键字集合{“a”, “to”, “tea”, “ted”, “ten”, “i”, “in”, “inn”} 。从上图可以归纳出Trie树的基本性质:
- 根节点不包含字符,除根节点外的每一个子节点都包含一个字符。
- 从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应的字符串。
- 每个节点的所有子节点包含的字符互不相同。
- 从第一字符开始有连续重复的字符只占用一个节点,比如上面的to,和ten,中重复的单词t只占用了一个节点。
前缀树的实现
节点
public class Trie {
//根节点
private Node root;
//Trie单词个数
private int size;
public Trie(){
root = new Node();
size = 0;
}
// 获得Trie中存储的单词数量
public int getSize(){
return size;
}
private class Node{
public boolean isWord; // 是否是某个单词的结束
public TreeMap<Character, Node> next; //到下一个节点的映射
public Node(boolean isWord){
this.isWord = isWord;
//初始化字典树
next = new TreeMap<>();
}
public Node(){
this(false);
}
}
}
插入方法:非递归
向Trie中添加一个新的单词word: 将单词拆分成一个个字符c,然后从根节点开始往下添加:
public void add(String word){
Node cur = root;
//循环判断新的cur节点是否包含下一个字符到下一个节点的映射
for(int i = 0 ; i < word.length() ; i ++){
//将c当成一个节点插入Trie中
char c = word.charAt(i);
//判断cur.next是不是已经指向我们要找的c字符相应的节点
if(cur.next.get(c) == null){
//新建节点
cur.next.put(c, new Node());
}
//否则,就直接走到该节点位置即可
cur = cur.next.get(c);
}
//判断该单词并不表示任何一个单词的结尾
if(!cur.isWord){
//确定cur是新的单词
cur.isWord = true;
size ++;
}
}
插入方法:递归
/**
* 向Trie中添加一个新的单词word(递归写法接口)
*
* @param word
*/
public void recursionAdd(String word) {
Node cur = root;
add(root, word, 0);
}
/**
* 递归写法调用方法实现递归添加
*
* @param node 传入要进行添加的节点
* @param word 传入要进行添加的单词
*/
public void add(Node node, String word, int index) {
// 确定终止条件,这个终止条件在没加index这个参数时,很难确定
// 此时一个单词已经遍历完成了,如果这个结束节点没有标记为单词,就标记为单词
if (!node.isWord && index == word.length()) {
node.isWord = true;
size++;
}
if (word.length() > index) {
char addLetter = word.charAt(index);
// 判断trie的下个节点组中是否有查询的字符,如果没有,就添加
if (node.next.get(addLetter) == null) {
node.next.put(addLetter, new Node());
}
// 基于已经存在的字符进行下个字符的递归查询
add(node.next.get(addLetter), word, index + 1);
}
}
查找单词:非递归
/**
* 查询单词word是否在Trie中(非递归写法)
*
* @param word
* @return
*/
public boolean contains(String word) {
Node cur = root;
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
if (cur.next.get(c) == null) {
return false;
} else {
cur = cur.next.get(c);
}
}
return cur.isWord;
}
查找单词:递归
/**
* 查询单词word中是否在Trie中接口(递归写法)
*
* @param word
* @return
*/
public boolean recursionContains(String word) {
Node cur = root;
return contains(root, word, 0);
}
/**
* 查询word中是否在Trie中递归写法
*
* @param node
* @param word
* @param index
* @return
*/
private boolean contains(Node node, String word, int index) {
if (index == word.length()) {
return node.isWord;
}
char c = word.charAt(index);
if (node.next.get(c) == null) {
return false;
} else {
return contains(node.next.get(c), word, index + 1);
}
}
查询前缀:非递归
/**
* 查询是否在Trie中有单词一prefix为前缀
*
* @param prefix
* @return
*/
public boolean isPrefix(String prefix) {
Node cur = root;
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
if (cur.next.get(c) == null) {
return false;
}
cur = cur.next.get(c);
}
return true;
}
查询前缀:递归
/**
* 查询是否在Trie中有单词一prefix为前缀(递归调用)
*
* @param prefix
* @return
*/
public boolean recursionIsPrefix(String prefix) {
Node node = root;
return recursionIsPrefix(root, prefix, 0);
}
/**
* 查询是否在Trie中有单词一prefix为前缀(递归实现)
*
* @return
*/
public boolean recursionIsPrefix(Node root, String prefix, int index) {
if (prefix.length() == index) {
return true;
}
char c = prefix.charAt(index);
if (root.next.get(c) == null) {
return false;
} else {
return recursionIsPrefix(root.next.get(c), prefix, ++index);
}
}
拓展理解
前缀树的复杂度
设平均查询的query词长n, 白名单m条记录,平均长度k,
简单单词查询:一个query,需要遍历每一个白名单,调用query是否contains方法,contains方法遍历前词,找到头元素一致,再遍历判断尾序列,contains的复杂度是O(n),整体复杂度是O(mn)
前缀树查询: 一个query,将这个query从头到尾遍历,每个元素在前缀树中判断,操作都是取下一个节点和判断是否是end,时间复杂度是O(1),整体时间复杂度是O(n)
前缀树应用场景
- 前缀匹配、URL 匹配
- 字符串检索, 比如 敏感词过滤,黑白名单等
- 词频统计
- 字符串排序
前缀树压缩:基数树
在计算机科学中,基数树,或称压缩前缀树,是一种更节省空间的 Trie(前缀树)。对于基数树的每个节点,如果该节点是确定的子树的话,就和父节点合并。基数树可用来构建关联数组。 用于 IP 路由。 信息检索中用于文本文档的倒排索引。
基数树可看做是以二进制位串为关键字的 trie 树,是一种多叉树形结构,同时又类似多层索引表,每个中间节点包含指向多个子节点的指针数组,叶子节点包含指向实际的对象的指针(由于对象不具备树节点结构,因此将其父节点看做叶节点)。基数树也被设计成多道树,以提高磁盘交互性能。同时,基数树也是按照字典序来组织叶节点的,这种特点使之适合持久化改造,加上它的多道特点,灵活性较强,适合作为区块链的基础数据结构,构建持久性区块时较好地映射各类数据集合上。基数树支持插入、删除、查找操作。查找包括完全匹配、前缀匹配、前驱查找、后继查找。所有这些操作都是 O(k)复杂度,其中 k 是所有字符串中最大的长度。
双数组 Trie 树:DoubleArrayTrie
双数组Trie树(DoubleArrayTrie)是一种空间复杂度低的Trie树,应用于字符区间大的语言(如中文、日文等)分词领域。
双数组Trie (Double-Array Trie)结构由日本人JUN-ICHI AOE于1989年提出的,是Trie结构的压缩形式,仅用两个线性数组来表示Trie树,该结构有效结合了数字搜索树(Digital Search Tree)检索时间高效的特点和链式表示的Trie空间结构紧凑的特点。双数组Trie的本质是一个确定有限状态自动机(DFA),每个节点代表自动机的一个状态,根据变量不同,进行状态转移,当到达结束状态或无法转移时,完成一次查询操作。在双数组所有键中包含的字符之间的联系都是通过简单的数学加法运算表示,不仅提高了检索速度,而且省去了链式结构中使用的大量指针,节省了存储空间。——《基于双数组Trie树算法的字典改进和实现》
9 - CH09-图概念
图(Graph)是由顶点和连接顶点的边构成的离散结构。在计算机科学中,图是最灵活的数据结构之一,很多问题都可以使用图模型进行建模求解。例如: 生态环境中不同物种的相互竞争、人与人之间的社交与关系网络、化学上用图区分结构不同但分子式相同的同分异构体、分析计算机网络的拓扑结构确定两台计算机是否可以通信、找到两个城市之间的最短路径等等。
基本概念
定义
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为: G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
和线性表,树的差异:
- 线性表中我们把数据元素叫元素,树中将数据元素叫结点,在图中数据元素,我们则称之为顶点(Vertex)。
- 线性表可以没有元素,称为空表;树中可以没有节点,称为空树;但是,在图中不允许没有顶点(有穷非空性)。
- 线性表中的各元素是线性关系,树中的各元素是层次关系,而图中各顶点的关系是用边来表示(边集可以为空)。
术语
- 顶点的度
- 顶点Vi的度(Degree)是指在图中与Vi相关联的边的条数。对于有向图来说,有入度(In-degree)和出度(Out-degree)之分,有向图顶点的度等于该顶点的入度和出度之和。
- 邻接
- 若无向图中的两个顶点V1和V2存在一条边(V1,V2),则称顶点V1和V2邻接(Adjacent);
- 若有向图中存在一条边<V3,V2>,则称顶点V3与顶点V2邻接,且是V3邻接到V2或V2邻接直V3;
- 路径
- 在无向图中,若从顶点Vi出发有一组边可到达顶点Vj,则称顶点Vi到顶点Vj的顶点序列为从顶点Vi到顶点Vj的路径(Path)。
- 连通
- 若从Vi到Vj有路径可通,则称顶点Vi和顶点Vj是连通(Connected)的。
- 权(Weight)
- 有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的数叫做权(Weight)。
类型
无向图
如果图中任意两个顶点之间的边都是无向边(简而言之就是没有方向的边),则称该图为无向图(Undirected graphs)。
无向图中的边使用小括号“()”表示; 比如
(V1,V2)
;
有向图
如果图中任意两个顶点之间的边都是有向边(简而言之就是有方向的边),则称该图为有向图(Directed graphs)。
有向图中的边使用尖括号“<>”表示; 比如
/<V1,V2>
完全图
- 无向完全图: 在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。(含有n个顶点的无向完全图有(n×(n-1))/2条边)
- 有向完全图: 在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。(含有n个顶点的有向完全图有n×(n-1)条边)
存储结构
邻接矩阵表示法
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
无向图
有向图
我们再来看一个有向图样例,如下图所示的左边。顶点数组为 vertex [4]={v0,v1,v2,v3}
,弧数组 arc[4][4]
为下图右边这样的一个矩阵。主对角线上数值依然为0。但因为是有向图,所以此矩阵并不对称,比如由v1到v0有弧,得到arc[1][0]=1
,而v到v没有弧,因此 arc[0][1]=0
。
不足: 由于存在n个顶点的图需要n*n个数组元素进行存储,当图为稀疏图时,使用邻接矩阵存储方法将会出现大量0元素,这会造成极大的空间浪费。这时,可以考虑使用邻接表表示法来存储图中的数据
邻接表示法
首先,回忆我们在线性表时谈到,顺序存储结构就存在预先分配内存可能造成存储空间浪费的问题,于是引出了链式存储的结构。同样的,我们也可以考虑对边或弧使用链式存储的方式来避免空间浪费的问题。
邻接表由表头节点和表节点两部分组成,图中每个顶点均对应一个存储在数组中的表头节点。如果这个表头节点所对应的顶点存在邻接节点,则把邻接节点依次存放于表头节点所指向的单向链表中。
无向图
从上图中我们知道,顶点表的各个结点由data和firstedge两个域表示,data是数据域,存储顶点的信息,firstedge是指针域,指向边表的第一个结点,即此顶点的第一个邻接点。边表结点由adjvex和next两个域组成。adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一个结点的指针。例如: v1顶点与v0、v2互为邻接点,则在v1的边表中,adjvex分别为v0的0和v2的2。
对于无向图来说,使用邻接表进行存储也会出现数据冗余的现象。例如上图中,顶点V0所指向的链表中存在一个指向顶点V3的同事,顶点V3所指向的链表中也会存在一个指向V0的顶点。
有向图
若是有向图,邻接表结构是类似的,但要注意的是有向图由于有方向的。因此,有向图的邻接表分为出边表和入边表(又称逆邻接表),出边表的表节点存放的是从表头节点出发的有向边所指的尾节点;入边表的表节点存放的则是指向表头节点的某个顶点,如下图所示。
带权图
对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可,如下图所示。
10 - CH10-图遍历
深度优先搜索
假设初始状态是图中所有顶点均未被访问,则从某个顶点 V 出发,首先访问该顶点,然后依次从它的各个未被访问的邻接点出发深度优先搜索来遍历整个图,直至图中所有和 V 有路径相通的顶点都被访问到。这里的关键是访问到邻接点时,接着去访问该邻接点的所有邻接点,然后再去访问第一层邻接点中的下一个邻接点的所有邻接点。
若此时尚有其他顶点未被访问到,则另选一个未被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
显然,深度优先搜索是一个递归的过程。
深度优先搜索:无向图
对上面的图G1进行深度优先遍历,从顶点A开始。
- 访问 A。
- 访问(A的邻接点)C。 在第1步访问A之后,接下来应该访问的是A的邻接点,即"C,D,F"中的一个。但在本文的实现中,顶点ABCDEFG是按照顺序存储,C在"D和F"的前面,因此,先访问C。
- 访问(C的邻接点)B。 在第2步访问C之后,接下来应该访问C的邻接点,即"B和D"中一个(A已经被访问过,就不算在内)。而由于B在D之前,先访问B。
- 访问(C的邻接点)D。 在第3步访问了C的邻接点B之后,B没有未被访问的邻接点;因此,返回到访问C的另一个邻接点D。
- 访问(A的邻接点)F。 前面已经访问了A,并且访问完了"A的邻接点B的所有邻接点(包括递归的邻接点在内)";因此,此时返回到访问A的另一个邻接点F。
- 访问(F的邻接点)G。
- 访问(G的邻接点)E。
因此访问顺序是: A -> C -> B -> D -> F -> G -> E
深度优先搜索:有向图
下面以"有向图"为例,来对深度优先搜索进行演示。
对上面的图G2进行深度优先遍历,从顶点A开始。
- 访问A。
- 访问B。 在访问了A之后,接下来应该访问的是A的出边的另一个顶点,即顶点B。
- 访问C。 在访问了B之后,接下来应该访问的是B的出边的另一个顶点,即顶点C,E,F。在本文实现的图中,顶点ABCDEFG按照顺序存储,因此先访问C。
- 访问E。 接下来访问C的出边的另一个顶点,即顶点E。
- 访问D。 接下来访问E的出边的另一个顶点,即顶点B,D。顶点B已经被访问过,因此访问顶点D。
- 访问F。 接下应该回溯"访问A的出边的另一个顶点F"。
- 访问G。
因此访问顺序是: A -> B -> C -> E -> D -> F -> G
广度优先搜索
广度优先搜索算法(Breadth First Search),又称为"宽度优先搜索"或"横向优先搜索",简称BFS。
它的思想是: 从图中某顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使得“先被访问的顶点的邻接点先于后被访问的顶点的邻接点被访问,直至图中所有已被访问的顶点的邻接点都被访问到。如果此时图中尚有顶点未被访问,则需要另选一个未曾被访问过的顶点作为新的起始点,重复上述过程,直至图中所有顶点都被访问到为止。
换句话说,广度优先搜索遍历图的过程是以v为起点,由近至远,依次访问和v有路径相通且路径长度为1,2…的顶点。
广度优先搜索:无向图
下面以"无向图"为例,来对广度优先搜索进行演示。还是以上面的图G1为例进行说明。
- 访问 A。
- 依次访问C,D,F。 在访问了A之后,接下来访问A的邻接点。前面已经说过,在本文实现中,顶点ABCDEFG按照顺序存储的,C在"D和F"的前面,因此,先访问C。再访问完C之后,再依次访问D,F。
- 依次访问B,G。 在第2步访问完C,D,F之后,再依次访问它们的邻接点。首先访问C的邻接点B,再访问F的邻接点G。
- 访问E。 在第3步访问完B,G之后,再依次访问它们的邻接点。只有G有邻接点E,因此访问G的邻接点E。
因此访问顺序是: A -> C -> D -> F -> B -> G -> E
广度优先搜索:有向图
下面以"有向图"为例,来对广度优先搜索进行演示。还是以上面的图G2为例进行说明。
- 访问A。
- 访问B。
- 依次访问C,E,F。 在访问了B之后,接下来访问B的出边的另一个顶点,即C,E,F。前面已经说过,在本文实现中,顶点ABCDEFG按照顺序存储的,因此会先访问C,再依次访问E,F。
- 依次访问D,G。 在访问完C,E,F之后,再依次访问它们的出边的另一个顶点。还是按照C,E,F的顺序访问,C的已经全部访问过了,那么就只剩下E,F;先访问E的邻接点D,再访问F的邻接点G。
因此访问顺序是: A -> B -> C -> E -> F -> D -> G
11 - CH11-最小生成树
概念
- 连通图:在无向图中,若任意两个顶点vivi与vjvj都有路径相通,则称该无向图为连通图。
- 强连通图:在有向图中,若任意两个顶点vivi与vjvj都有路径相通,则称该有向图为强连通图。
- 连通网:在连通图中,若图的边具有一定的意义,每一条边都对应着一个数,称为权;权代表着连接连个顶点的代价,称这种连通图叫做连通网。
- 生成树:一个连通图的生成树是指一个连通子图,它含有图中全部n个顶点,但只有足以构成一棵树的n-1条边。一颗有n个顶点的生成树有且仅有n-1条边,如果生成树中再添加一条边,则必定成环。
- 最小生成树:在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树。
最小生成树算法
Kruskal 算法
此算法可以称为“加边法”,初始最小生成树边数为0,每迭代一次就选择一条满足条件的最小代价边,加入到最小生成树的边集合里。
- 把图中的所有边按代价从小到大排序;
- 把图中的n个顶点看成独立的n棵树组成的森林;
- 按权值从小到大选择边,所选的边连接的两个顶点ui,viui,vi,应属于两颗不同的树,则成为最小生成树的一条边,并将这两颗树合并作为一颗树。
- 重复(3),直到所有顶点都在一颗树内或者有n-1条边为止。
Prim 算法
此算法可以称为“加点法”,每次迭代选择代价最小的边对应的点,加入到最小生成树中。算法从某一个顶点s开始,逐渐长大覆盖整个连通网的所有顶点。
- 图的所有顶点集合为VV;初始令集合u={s},v=V−uu={s},v=V−u;
- 在两个集合u,vu,v能够组成的边中,选择一条代价最小的边(u0,v0)(u0,v0),加入到最小生成树中,并把v0v0并入到集合u中。
- 重复上述步骤,直到最小生成树有n-1条边或者n个顶点为止。
由于不断向集合u中加点,所以最小代价边必须同步更新;需要建立一个辅助数组closedge,用来维护集合v中每个顶点与集合u中最小代价边信息:
总结
因为Kruskal涉及大量对边的操作,所以它适用于稀疏图;
普通的prim算法适用于稠密图,但堆优化的prim算法更适用于稀疏图,因为其时间复杂度是由边的数量决定的。
12 - CH12-图最短路径
介绍
最短路径问题是图论研究中的一个经典算法问题,旨在寻找图(由结点和路径组成的)中两结点之间的最短路径。最短路径不一定是经过边最少的路径,但在这些最短路径中,长度最短的那一条路径上只有一条边,且它的权值在从源点出发的所有边的权值最小。
从图中某一顶点(称为源点)到达另一顶点(称为终点)的路径可能不止一条,如何找到一条路径使得沿此路径上各边上的权值总和达到最小,例: 公交查询系统。
路径长度最短的最短路径的特点:
- 在这条路径上,必定只含一条弧,并且这条弧的权值最小。
- 下一条路径长度次短的最短路径的特点:
- 它只可能有两种情况: 或者是直接从源点到该点(只含一条弧); 或者是从源点经过顶点v1,再到达该顶点(由两条弧组成)。
问题解法:
- 求从某个源点到其余各点的最短路径 — Dijkstra算法
- 每一对顶点之间的最短路径 — Floyd算法
最短路径算法
Dijkstra 算法
定义概述
Dijkstra(迪杰斯特拉)算法是典型的单源最短路径算法,用于计算一个节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。Dijkstra算法是很有代表性的最短路径算法,在很多专业课程中都作为基本内容有详细的介绍,如数据结构,图论,运筹学等等。注意该算法要求图中不存在负权边。
问题描述: 在无向图 G=(V,E)
中,假设每条边 E[i]
的长度为 w[i]
,找到由顶点 V0 到其余各点的最短路径。(单源最短路径)
算法描述
算法思想: 设G=(V,E)是一个带权有向图,把图中顶点集合V分成两组,第一组为已求出最短路径的顶点集合(用S表示,初始时S中只有一个源点,以后每求得一条最短路径 , 就将加入到集合S中,直到全部顶点都加入到S中,算法就结束了),第二组为其余未确定最短路径的顶点集合(用U表示),按最短路径长度的递增次序依次把第二组的顶点加入S中。在加入的过程中,总保持从源点v到S中各顶点的最短路径长度不大于从源点v到U中任何顶点的最短路径长度。此外,每个顶点对应一个距离,S中的顶点的距离就是从v到此顶点的最短路径长度,U中的顶点的距离,是从v到此顶点只包括S中的顶点为中间顶点的当前最短路径长度。
算法步骤
a.初始时,S只包含源点,即S={v},v的距离为0。U包含除v外的其他顶点,即:U={其余顶点},若v与U中顶点u有边,则<u,v>正常有权值,若u不是v的出边邻接点,则<u,v>权值为∞。
b.从U中选取一个距离v最小的顶点k,把k,加入S中(该选定的距离就是v到k的最短路径长度)。
c.以k为新考虑的中间点,修改U中各顶点的距离;若从源点v到顶点u的距离(经过顶点k)比原来距离(不经过顶点k)短,则修改顶点u的距离值,修改后的距离值的顶点k的距离加上边上的权。
d.重复步骤b和c直到所有顶点都包含在S中。
Floyd 算法
13 - CH13-图拓扑排序
概念
对于任何有向图而言,其拓扑排序为其所有结点的一个线性排序(对于同一个有向图而言可能存在多个这样的结点排序)。该排序满足这样的条件——对于图中的任意两个结点u和v,若存在一条有向边从u指向v,则在拓扑排序中u一定出现在v前面。
例如一个有向无环图如下:
- 结点1必须在结点2、3之前
- 结点2必须在结点3、4之前
- 结点3必须在结点4、5之前
- 结点4必须在结点5之前
则一个满足条件的拓扑排序为 [1, 2, 3, 4, 5]
。
前提
当且仅当一个有向图为有向无环图(directed acyclic graph,或称DAG)时,才能得到对应于该图的拓扑排序。这里有两点要注意:
- 对于有环图,必然会造成循环依赖(circular dependency),不符合拓扑排序定义;
- 对于每一个有向无环图都至少存在一种拓扑排序;
不唯一的情况
:
上图中若我们删 4、5结点之前的有向边,上图变为如下所示:
则我们可得到两个不同的拓扑排序结果: [1, 2, 3, 4, 5]
和[1, 2, 3, 5, 4]
。
算法
为了说明如何得到一个有向无环图的拓扑排序,我们首先需要了解有向图结点的入度(indegree)和出度(outdegree)的概念。
假设有向图中不存在起点和终点为同一结点的有向边。
入度
: 设有向图中有一结点v,其入度即为当前所有从其他结点出发,终点为v的的边的数目。也就是所有指向v的有向边的数目。
出度
: 设有向图中有一结点v,其出度即为当前所有起点为v,指向其他结点的边的数目。也就是所有由v发出的边的数目。
在了解了入度和出度的概念之后,再根据拓扑排序的定义,我们自然就能够得出结论: 要想完成拓扑排序,我们每次都应当从入度为0的结点开始遍历。因为只有入度为0的结点才能够成为拓扑排序的起点。否则根据拓扑排序的定义,只要一个结点v的入度不为0,则至少有一条边起始于其他结点而指向v,那么这条边的起点在拓扑排序的顺序中应当位于v之前,则v不能成为当前遍历的起点。
由此我们可以进一步得出一个改进的深度优先遍历或广度优先遍历算法来完成拓扑排序。以广度优先遍历为例,这一改进后的算法与普通的广度优先遍历唯一的区别在于我们应当保存每一个结点对应的入度,并在遍历的每一层选取入度为0的结点开始遍历(而普通的广度优先遍历则无此限制,可以从该吃呢个任意一个结点开始遍历)。这个算法描述如下:
- 初始化一个int[] inDegree保存每一个结点的入度。
- 对于图中的每一个结点的子结点,将其子结点的入度加1。
- 选取入度为0的结点开始遍历,并将该节点加入输出。
- 对于遍历过的每个结点,更新其子结点的入度: 将子结点的入度减1。
- 重复步骤3,直到遍历完所有的结点。
- 如果无法遍历完所有的结点,则意味着当前的图不是有向无环图。不存在拓扑排序。
实现
广度优先遍历拓扑排序的Java代码如下:
public class TopologicalSort {
/**
* Get topological ordering of the input directed graph
* @param n number of nodes in the graph
* @param adjacencyList adjacency list representation of the input directed graph
* @return topological ordering of the graph stored in an List<Integer>.
*/
public List<Integer> topologicalSort(int n, int[][] adjacencyList) {
List<Integer> topoRes = new ArrayList<>();
int[] inDegree = new int[n];
for (int[] parent : adjacencyList) {
for (int child : parent) {
inDegree[child]++;
}
}
Deque<Integer> deque = new ArrayDeque<>();
// start from nodes whose indegree are 0
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) deque.offer(i);
}
while (!deque.isEmpty()) {
int curr = deque.poll();
topoRes.add(curr);
for (int child : adjacencyList[curr]) {
inDegree[child]--;
if (inDegree[child] == 0) {
deque.offer(child);
}
}
}
return topoRes.size() == n ? topoRes : new ArrayList<>();
}
}
复杂度
时间复杂度: O(n + e),其中n为图中的结点数目,e为图中的边的数目
空间复杂度: O(n)
14 - CH14-图-AOE-关键路径
概念
相关术语:
AOV网络(Activity On Vertex Network)
: 有向图,用顶点表示活动,用弧表示活动的先后顺序AOE网络(Activity On Edge)
: 有向图,用顶点表示事件,用弧表示活动,用权值表示活动消耗时间(带权的有向无环图)活动
: 业务逻辑中的行为,用边表示事件
: 活动的结果或者触发条件关键路径
: 具有最大路径长度(权重)的路径,可能不止一条活动的两个属性
: e(i)最早开始时间,l(i)最晚开始时间事件的两个属性
: ve(j)最早开始时间,vl(j)最晚开始时间
AOV和AOE的对比: 虽然都是用来对工程建模,但是还是有很大不同。主要体现在:
- AOV网是顶点表示活动的网,他只描述活动之间的制约更新,
- AOE网是用边表示活动的网,边上的权值表示活动持续的时间
实现
4个关键概念
事件最早发生时间
事件最早发生时间etv(earliest time of vertex),即顶点Vk的最早发生时间。
事件最晚发生时间
事件最晚发生时间ltv(lastest time of vertex),即顶点Vk的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的事件,超出此事件将会延误整个工期。
活动的最早开工时间
活动的最早开工时间ete(earliest time of edge),即弧ak的最早发生时间。
活动的最晚开工时间
活动的最晚开工时间lte(lastest time if edge),即弧的最晚发生时间,也就是不推迟工期的最晚开工时间。
4个时间的关系
我们可以由事件的最早发生时间和事件的最晚发生时间求出活动的最早和最晚开工时间。 由1,2可以求得3,4,然后在根据ete[k]
是否与lte[k]
相等来判断ak是否是关键活动。
算法实现
- 推演图
- etv 从左到右推导
- ltv 从右向左推导
- ete:活动最早开工时间需要和 etv 事件最早发生时间结合
- lte:活动最晚开工时间需要和 ltv 事件最晚发生时间结合(都是倒序获得):
15 - CH15-Iterable
当我们想要遍历集合时,Java 为我们提供了多种选择,通常有以下三种写法:
for 循环
for (int i = 0, len = strings.size(); i < len; i++) { System.out.println(strings.get(i)); }
foreach 循环
for (String var : strings) { System.out.println(var); }
while iterator
Iterator iterator = strings.iterator(); while (iterator.hasNext()) { System.out.println(iterator.next()); }
for 循环我们很熟悉了,就是根据下标来获取元素,这个特性与数组十分吻合,不熟悉的朋友可以阅读前面讲解数组的文章。
foreach 则主要对类似链表的结构提供遍历支持,链表没有下标,所以使用 for 循环遍历会大大降低性能。
Iterator 就是我们今天要讲述的主角,它实际上就是 foreach。
Iterable
Iterable 是迭代器的意思,作用是为集合类提供 for-each 循环的支持。由于使用 for 循环需要通过位置获取元素,而这种获取方式仅有数组支持,其他许多数据结构,比如链表,只能通过查询获取数据,这会大大的降低效率。Iterable 就可以让不同的集合类自己提供遍历的最佳方式。
Iterable 的文档声明仅有一句:
Implementing this interface allows an object to be the target of the “for-each loop” statement.
它的作用就是为 Java 对象提供 foreach 循环,其主要方法是返回一个 Iterator 对象:
Iterator<T> iterator();
也就是说,如果想让一个 Java 对象支持 foreach,只要实现 Iterable 接口,然后就可以像集合那样,通过 Iterator iterator = strings.iterator()
方式,或者使用 foreach,进行遍历了。
Iterator
Iterator 是 foreach 遍历的主体,它的代码实现如下:
// 判断一个对象集合是否还有下一个元素
boolean hasNext();
// 获取下一个元素
E next();
// 删除最后一个元素。
// 默认是不支持的,因为在很多情况下其结果不可预测,比如数据集合在此时被修改
default void remove(){...}
// 主要将每个元素作为参数发给 action 来执行特定操作
default void forEachRemaining(Consumer<? super E> action){...}
Iterator 还有一个子接口,是为需要双向遍历数据时准备的,在后续分析 ArrayList 和 LinkedList 时都会看到它。它主要增加了以下几个方法:
// 是否有前一个元素
boolean hasPrevious();
// 获取前一个元素
E previous();
// 获取下一个元素的位置
int nextIndex();
// 获取前一个元素的位置
int previousIndex();
// 添加一个元素
void add(E e);
// 替换当前元素值
void set(E e);
总结
在 Java 中有许多特性都是通过接口来实现的,foreach 循环也是。foreach 主要是解决 for 循环依赖下标的问题,为高效遍历更多的数据结构提供了支持。如果你清楚数组和链表的区别,应该就可以回答以下问题了:for 与 foreach 有何区别,哪个更高效?
16 - CH16-Collection
Collection 是 List、Queue 和 Set 的超集,它直接继承于 Iterable,也就是所有的 Collection 集合类都支持 for-each 循环。除此之外,Collection 也是面向接口编程的典范,通过它可以在多种实现类间转换,这也是面向对象编程的魅力之一。
方法定义
在阅读源码前,我们可以先自行想象一下,如果我们想封装下数组或链表以方便操作,我们需要封装哪些功能呢?比如:统计大小、插入或删除数据、清空、是否包含某条数据,等等。而 Collection 就是对这些常用操作进行提取,只是其很全面、很通用。下面我们看看它都提供了哪些方法。
//返回集合的长度,如果长度大于 Integer.MAX_VALUE,返回Integer.MAX_VALUE
int size();
//如果集合元素总数为0,返回true
boolean isEmpty();
//判断集合中是否包含指定的元素,其依据是equals()方法
boolean contains(Object o);
//返回一个包含集合中所有元素的数组
Object[] toArray();
//与上个类似,只是增加了类型的转换
<T> T[] toArray(T[] a);
//向集合中加入一个元素,如果成功加入则返回true,如果加入失败,或者因集合本身已经包含同个元素而不再加入时,返回false
boolean add(E e);
//从集合中删除指定元素的单个实例
boolean remove(Object o);
//如果集合包含指定集合中的所有元素,返回true
boolean containsAll(Collection<?> c);
//把指定集合中的所有元素添加到集合中,但在此期间,如果指定的集合发生了改变,可能出现意想不到的事情
boolean addAll(Collection<? extends E> c);
//从集合中删除所有包含在指定集合中的元素
boolean removeAll(Collection<?> c);
//仅保留集合中包含在指定集合中的元素
boolean retainAll(Collection<?> c);
//清空集合
void clear();
//将此方法抽象,是保证所有子类都覆写此方法,以保证equals的正确行为
boolean equals(Object o);
//同上
int hashCode();
//这个方法在JDK1.8中提供了默认的实现,会使用Iterator的形式删除符合条件的元素
default boolean removeIf(Predicate<? super E> filter){
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
超级实现类——AbstractCollection
在 Collection 中定义的许多方法,根据现有的定义以及继承的 Iterable,都可以在抽象类中实现,这样可以减少实现类需要实现的方法,这个抽象类就是 AbstractCollection。
首先我们关注下其文档,里面有两句说明可能会影响我们的继承:
To implement an unmodifiable collection, the programmer needs only to extend this class and provide implementations for the iterator and size methods. (The iterator returned by the iterator method must implement hasNext and next.)
To implement a modifiable collection, the programmer must additionally override this class’s add method (which otherwise throws an UnsupportedOperationException), and the iterator returned by the iterator method must additionally implement its remove method.
大体意思是说,如果要实现一个不可修改的集合,只需要重写 iterator 和 size 接口就可以,并且返回的 Iterator 需要实现 hasNext 和 next。而要实现一个可以修改的集合,还必须重写 add 方法(默认会抛出异常),返回的 Iterator 还需要实现 remove 方法。
方法定义
//这个毫无疑问,是可以直接获取的
public boolean isEmpty() {
return size() == 0;
}
//这个方法因为Iterator的存在,可以进行一致性封装,
//这里需要注意的是对象的比较是通过equals方法
//因为调用到了it.next()与it.hasNext(),这也是为什么文档注释会写实现集合类需要重写Iterator的这两个方法。
public boolean contains(Object o) {
Iterator<E> it = iterator();
if (o==null) {
while (it.hasNext())
if (it.next()==null)
return true;
} else {
while (it.hasNext())
if (o.equals(it.next()))
return true;
}
return false;
}
//和contains类似,也是通过Iterator实现的,但其会调用it.remove()方法,这也是为什么文档注释会写实现可以修改的集合类时需要重写Iterator的remove方法。
public boolean remove(Object o) {
//...省略,这里调用了it.remove()方法
}
类似的方法还有 containsAll(Collection<?> c)
、addAll(Collection<? extends E> c)
、removeAll(Collection<?> c)
、retainAll(Collection<?> c)
和 clear()
等,都需要利用到Iterator的特性,这里就不再一一赘述了。
另外还有一个toArray()的方法实现略微不同,可以看看其具体实现。
//这个实现相对复杂一些,可以看到扩容最主要的手段是Arrays.copyOf()方法,
//也就是需要将原数组通过复制到新的数组中来实现的。
//注意这里返回的顺序和Iterator顺序一致
//在这里实现是为了方便不同具体实现类互相转换,我们在后续会多次见到此方法
public Object[] toArray() {
//先根据当前集合大小声明一个数组
Object[] r = new Object[size()];
Iterator<E> it = iterator();
for (int i = 0; i < r.length; i++) {
//集合元素没那么多,说明不需要那么大的数组
if (! it.hasNext())
return Arrays.copyOf(r, i); //仅返回赋完值的部分
r[i] = it.next();
}
//元素比从size()中获取的更多,就需要进一步调整数组大小
return it.hasNext() ? finishToArray(r, it) : r;
}
private static <T> T[] finishToArray(T[] r, Iterator<?> it) {
//记录当前大小
int i = r.length;
while (it.hasNext()) {
int cap = r.length;
//r的长度不够,继续分配
if (i == cap) {
//扩充方式为cap+cap/2+1,也就是1.5倍扩容
int newCap = cap + (cap >> 1) + 1;
// 超过了最大容量,MAX_ARRAY_SIZE=Integer.MAX_VALUE-8
if (newCap - MAX_ARRAY_SIZE > 0)
//重新设置cap的值
newCap = hugeCapacity(cap + 1);
//对r进行扩容
r = Arrays.copyOf(r, newCap);
}
//赋值,进入下一轮循环
r[i++] = (T)it.next();
}
// 由于之前扩容是1.5倍进行的,最后再将其设置到和r实际需要的相同
return (i == r.length) ? r : Arrays.copyOf(r, i);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // 超过了最大正整数,也就是负数
throw new OutOfMemoryError
("Required array size too large");
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
//和toArray()方法类似,就不再赘述,具体可以查看源码
public <T> T[] toArray(T[] a) {
//...
}
除了以上这些方法,AbstractCollection还实现了toString方法,其是通过StringBuilder拼接了每个元素的toString完成的,也并不复杂。这里可以看下其源码:
public String toString() {
Iterator<E> it = iterator();
if (! it.hasNext())
return "[]";
StringBuilder sb = new StringBuilder();
sb.append('[');
for (;;) {
E e = it.next();
sb.append(e == this ? "(this Collection)" : e);
if (! it.hasNext())
return sb.append(']').toString();
sb.append(',').append(' ');
}
}
17 - CH17-List
List 是 Collection 三大直接子接口之一,其中的数据可以通过位置检索,用户可以在指定位置插入数据。List 的数据可以为空,可以重复。以下是其文档注释,只看前两段:
An ordered collection (also known as a sequence). The user of this interface has precise control over where in the list each element is inserted. The user can access elements by their integer index (position in the list), and search for elements in the list.
Unlike sets, lists typically allow duplicate elements. More formally, lists typically allow pairs of elements e1 and e2 such that e1.equals(e2), and they typically allow multiple null elements if they allow null elements at all. It is not inconceivable that someone might wish to implement a list that prohibits duplicates, by throwing runtime exceptions when the user attempts to insert them, but we expect this usage to be rare.
List 特有方法
我们关注其不同于 Collection 的方法,主要有以下这些:
//在指定位置,将指定的集合插入到当前的集合中
boolean addAll(int index, Collection<? extends E> c);
//这是一个默认实现的方法,会通过Iterator的方式对每个元素进行指定的操作
default void replaceAll(UnaryOperator<E> operator) {
Objects.requireNonNull(operator);
final ListIterator<E> li = this.listIterator();
while (li.hasNext()) {
li.set(operator.apply(li.next()));
}
}
//排序,依据指定的规则对当前集合进行排序,可以看到,排序是通过Arrays这个工具类完成的。
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
//获取指定位置的元素
E get(int index);
//修改指定位置元素的值
E set(int index, E element);
//将指定元素添加到指定的位置
void add(int index, E element);
//将指定位置的元素移除
E remove(int index);
//返回一个元素在集合中首次出现的位置
int indexOf(Object o);
//返回一个元素在集合中最后一次出现的位置
int lastIndexOf(Object o);
//ListIterator继承于Iterator,主要增加了向前遍历的功能
ListIterator<E> listIterator();
//从指定位置开始,返回一个ListIterator
ListIterator<E> listIterator(int index);
//返回一个子集合[fromIndex, toIndex),非结构性的修改返回值会反映到原表,反之亦然。
//如果原表进行了结构修改,则返回的子列表可能发生不可预料的事情
List<E> subList(int fromIndex, int toIndex);
通过以上对接口的分析可以发现,Collection 主要提供一些通用的方法,而List 则针对线性表的结构,提供了对位置以及子表的操作。
超级实现类——AbstractList
有了分析 AbstractCollection 的经验,我们分析 AbstractList 就更容易了。首先也看下其文档中强调的部分:
To implement an unmodifiable list, the programmer needs only to extend this class and provide implementations for the get(int) and size() methods.
To implement a modifiable list, the programmer must additionally override the set(int, E) method (which otherwise throws an UnsupportedOperationException). If the list is variable-size the programmer must additionally override the add(int, E) and remove(int) methods.
大致意思是说,要实现一个不可修改的集合,只需要复写 get 和 size 就可以了。要实现一个可以修改的集合,还需要复写 set 方法,如果要动态调整大小,就必须再实现 add 和 remove 方法。
然后看下其源码实现了哪些功能吧:
//在AbstractCollection中,add方法默认会抛出异常,
//而在这里是调用了add(int index, E e)方法,但这个方法也是没有实现的。
//这里默认会把元素添加到末尾。
public boolean add(E e) {
add(size(), e);
return true;
}
//同上,这个只需要进行一次遍历即可
public boolean addAll(int index, Collection<? extends E> c) {
//...
}
接下来,还有几个方法和 Iterator 与 ListIterator 息息相关,在AbstractList 中有具体的实现,我们先看看它是如何把集合转变成 Iterator 对象并支持 foreach 循环的吧。
我们追踪源码发现,在 iterator() 方法中直接返回了一个 Itr 对象:
public Iterator<E> iterator() {
return new Itr();
}
这样我们就明白了,它是实现了一个内部类,这个内部类实现了 Iterator 接口,合理的处理 hasNext、next、remove 方法。这个源码就不粘贴啦,其中仅仅在 remove 时考虑了一下多线程问题,有兴趣的可以自己去看看。
另外一个就是 ListIterator:
public ListIterator<E> listIterator() {
return listIterator(0);
}
可以看到,listIterator 方法依赖于 listIterator(int index)
方法。有了上边的经验,我们可以推测,它也是通过一个内部类完成的。
public ListIterator<E> listIterator(final int index) {
rangeCheckForAdd(index);
return new ListItr(index);
}
事实证明,和我们想的一样,AbstractList 内部还定义了一个 ListItr,实现了 ListIterator 接口,其实现也很简单,就不粘贴源码啦。
接下来我们看看,利用这两个实现类,AbstractList 都做了哪些事情。
//寻找一个元素首次出现的位置,只需要从前往后遍历,找到那个元素并返回其位置即可。
public int indexOf(Object o) {
ListIterator<E> it = listIterator();
if (o==null) {
while (it.hasNext())
if (it.next()==null)
return it.previousIndex();
} else {
while (it.hasNext())
if (o.equals(it.next()))
return it.previousIndex();
}
return -1;
}
//同理,寻找一个元素最后一次出现的位置,只需要从列表最后一位向前遍历即可。
//看到listIterator(int index)方法是可以传递参数的,这个我想我们都可以照着写出来了。
public int lastIndexOf(Object o) {
//...
}
//这个方法是把从fromIndex到toIndex之间的元素从集合中删除。
//clear()方法也是调用这个实现的(我认为clear实现意义并不大,因为在其上级AbstractCollection中已经有了具体实现)。
protected void removeRange(int fromIndex, int toIndex) {
ListIterator<E> it = listIterator(fromIndex);
for (int i=0, n=toIndex-fromIndex; i<n; i++) {
it.next();
it.remove();
}
}
接下来还有两块内容比较重要,一个是关于 SubList 的,一个是关于 equals 和 hashcode 的。
我们先看看 SubList 相关的内容。SubList 并不是新建了一个集合,只是持有了当前集合的引用,然后控制一下用户可以操作的范围,所以在接口定义时就说明了其更改会直接反应到原集合中。SubList 定义在 AbstractList 内部,并且是 AbstractList 的子类。在 AbstractList 的基础上增加了对可选范围的控制。
equals 和 hashcode 的实现,也关乎我们的使用。在 AbstractList 中,这两个方法不仅与其实例有关,也和其内部包含的元素有关,所以在定义数据元素时,也应该复写这两个方法,以保证程序的正确运行。这里看下其源码加深一下印象吧。
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof List))
return false;
ListIterator<E> e1 = listIterator();
ListIterator<?> e2 = ((List<?>) o).listIterator();
while (e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
//这里用到了数据元素的equals方法
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
return !(e1.hasNext() || e2.hasNext());
}
public int hashCode() {
int hashCode = 1;
for (E e : this)
//这里用到了数据元素的hashCode方法
hashCode = 31*hashCode + (e==null ? 0 : e.hashCode());
return hashCode;
}
18 - CH18-ArrayList
做了这么多准备,终于到了 ArrayList 了,ArrayList 是我们使用最为频繁的集合类了,我们先看看文档是如何介绍它的:
Resizable-array implementation of the List interface. Implements all optional list operations, and permits all elements, including null. In addition to implementing the List interface, this class provides methods to manipulate the size of the array that is used internally to store the list. (This class is roughly equivalent to Vector, except that it is unsynchronized.)
可见,ArrayList 是 Vector 的翻版,只是去除了线程安全。Vector 因为种种原因不推荐使用了,这里我们就不对其进行分析了。ArrayList 是一个可以动态调整大小的 List 实现,其数据的顺序与插入顺序始终一致,其余特性与 List 中定义的一致。
继承结构
可以看到,ArrayList 是 AbstractList 的子类,同时实现了 List 接口。除此之外,它还实现了三个标识型接口,这几个接口都没有任何方法,仅作为标识表示实现类具备某项功能。RandomAccess 表示实现类支持快速随机访问,Cloneable 表示实现类支持克隆,具体表现为重写了 clone 方法,java.io.Serializable 则表示支持序列化,如果需要对此过程自定义,可以重写 writeObject 与 readObject 方法。
一般面试问到与 ArrayList 相关的问题时,可能会问 ArrayList 的初始大小是多少?很多人在初始化 ArrayList 时,可能都是直接调用无参构造函数,从未关注过此问题。例如,这样获取一个对象:
ArrayList<String> strings = new ArrayList<>();
我们都知道,ArrayList 是基于数组的,而数组是定长的。那 ArrayList 为何不需要指定长度,就能使我们既可以插入一条数据,也可以插入一万条数据?回想刚刚文档的第一句话:
Resizable-array implementation of the List interface.
ArrayList 可以动态调整大小,所以我们才可以无感知的插入多条数据,这也说明其必然有一个默认的大小。而要想扩充数组的大小,只能通过复制。这样一来,默认大小以及如何动态调整大小会对使用性能产生非常大的影响。我们举个例子来说明此情形:
比如默认大小为 5,我们向 ArrayList 中插入 5 条数据,并不会涉及到扩容。如果想插入 100 条数据,就需要将 ArrayList 大小调整到 100 再进行插入,这就涉及一次数组的复制。如果此时,还想再插入 50 条数据呢?那就得把大小再调整到 150,把原有的 100 条数据复制过来,再插入新的 50 条数据。自此之后,我们每向其中插入一条数据,都要涉及一次数据拷贝,且数据量越大,需要拷贝的数据越多,性能也会迅速下降。
其实,ArrayList 仅仅是对数组操作的封装,里面采取了一定的措施来避免以上的问题,如果我们不利用这些措施,就和直接使用数组没有太大的区别。那我们就看看 ArrayList 用了哪些措施,并且如何使用它们吧。我们先从初始化说起。
构造方法与初始化
ArrayList 一共有三个构造方法,用到了两个成员变量。
//这是一个用来标记存储容量的数组,也是存放实际数据的数组。
//当 ArrayList 扩容时,其 capacity 就是这个数组应有的长度。
//默认时为空,添加进第一个元素后,就会直接扩展到 DEFAULT_CAPACITY,也就是 10
//这里和size区别在于,ArrayList扩容并不是需要多少就扩展多少的
transient Object[] elementData;
//这里就是实际存储的数据个数了
private int size;
除了以上两个成员变量,我们还需要掌握一个变量,它是:
protected transient int modCount = 0;
这个变量主要作用是防止在进行一些操作时,改变了ArrayList的大小,那将使得结果不可预测。
下面我们看看构造函数:
//默认构造方法。文档说明其默认大小为10,但正如elementData定义所言,
//只有插入一条数据后才会扩展为10,而实际上默认是空的
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//带初始大小的构造方法,一旦指定了大小,elementData就不再是原来的机制了。
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
//从一个其他的Collection中构造一个具有初始化数据的ArrayList。
//这里可以看到size是表示存储数据的数量
//这也展示了Collection这种抽象的魅力,可以在不同的结构间转换
public ArrayList(Collection<? extends E> c) {
//转换最主要的是toArray(),这在Collection中就定义了
elementData = c.toArray();
if ((size = elementData.length) != 0) {
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
重要方法
ArrayList 已经是一个具体的实现类了,所以在 List 接口中定义的所有方法在此都做了实现。其中有些在 AbstractList 中实现过的方法,在这里再次被重写,我们稍后就可以看到它们的区别。
先看一些简单的方法:
//还记得在AbstractList中的实现吗?那是基于Iterator完成的。
//在这里完全没必要先转成Iterator再进行操作
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
//和indexOf是相同的道理
public int lastIndexOf(Object o) {
//...
}
//一样的道理,已经有了所有元素,不需要再利用Iterator来获取元素了
//注意这里返回时把elementData截断为size大小
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}
//带类型的转换,看到这里a[size] = null;这个用处真不大,除非你确定所有元素都不为空,
//才可以通过null来判断获取了多少有用数据。
public <T> T[] toArray(T[] a) {
if (a.length < size)
// 给定的数据长度不够,复制出一个新的并返回
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
数据操作最重要的就是增删改查,改查都不涉及长度的变化,而增删就涉及到动态调整大小的问题,我们先看看改和查是如何实现的:
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
//只要获取的数据位置在0-size之间即可
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
//改变下对应位置的值
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
增和删是ArrayList最重要的部分,这部分代码需要我们细细研究,我们看看它是如何处理我们例子中的问题的:
//在最后添加一个元素
public boolean add(E e) {
//先确保elementData数组的长度足够
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
//先确保elementData数组的长度足够
ensureCapacityInternal(size + 1); // Increments modCount!!
//将数据向后移动一位,空出位置之后再插入
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
以上两种添加数据的方式都调用到了 ensureCapacityInternal 这个方法,我们看看它是如何完成工作的:
//在定义elementData时就提过,插入第一个数据就直接将其扩充至10
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
//这里把工作又交了出去
ensureExplicitCapacity(minCapacity);
}
//如果elementData的长度不能满足需求,就需要扩充了
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//扩充
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
//可以看到这里是1.5倍扩充的
int newCapacity = oldCapacity + (oldCapacity >> 1);
//扩充完之后,还是没满足,这时候就直接扩充到minCapacity
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//防止溢出
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
至此,我们彻底明白了 ArrayList 的扩容机制了。首先创建一个空数组 elementData,第一次插入数据时直接扩充至 10,然后如果 elementData 的长度不足,就扩充 1.5 倍,如果扩充完还不够,就使用需要的长度作为 elementData 的长度。
这样的方式显然比我们例子中好一些,但是在遇到大量数据时还是会频繁的拷贝数据。那么如何缓解这种问题呢,ArrayList为我们提供了两种可行的方案:
- 使用
ArrayList(int initialCapacity)
这个有参构造,在创建时就声明一个较大的大小,这样解决了频繁拷贝问题,但是需要我们提前预知数据的数量级,也会一直占有较大的内存。 - 除了添加数据时可以自动扩容外,我们还可以在插入前先进行一次扩容。只要提前预知数据的数量级,就可以在需要时直接一次扩充到位,与
ArrayList(int initialCapacity)
相比的好处在于不必一直占有较大内存,同时数据拷贝的次数也大大减少了。这个方法就是ensureCapacity(int minCapacity)
,其内部就是调用了ensureCapacityInternal(int minCapacity)
。
其他还有一些比较重要的函数,其实现的原理也大同小异,这里我们不一一分析了,但还是把它们列举出来,以便使用。
//将elementData的大小设置为和size一样大,释放所有无用内存
public void trimToSize() {
//...
}
//删除指定位置的元素
public E remove(int index) {
//...
}
//根据元素本身删除
public boolean remove(Object o) {
//...
}
//在末尾添加一些元素
public boolean addAll(Collection<? extends E> c) {
//...
}
//从指定位置起,添加一些元素
public boolean addAll(int index, Collection<? extends E> c){
//...
}
//删除指定范围内的元素
protected void removeRange(int fromIndex, int toIndex){
//...
}
//删除所有包含在c中的元素
public boolean removeAll(Collection<?> c) {
//...
}
//仅保留所有包含在c中的元素
public boolean retainAll(Collection<?> c) {
//...
}
ArrayList 还对父级实现的 ListIterator 以及 SubList 进行了优化,主要是使用位置访问元素,我们就不再研究了。
其他实现方法
ArrayList 不仅实现了 List 中定义的所有功能,还实现了 equals、hashCode、clone、writeObject 与 readObject 等方法。这些方法都需要与存储的数据配合,否则结果将是错误的或者克隆得到的数据只是浅拷贝,或者数据本身不支持序列化等,这些我们定义数据时注意到即可。我们主要看下其在序列化时自定义了哪些东西。
//这里就能解开我们的迷惑了,elementData被transient修饰,也就是不会参与序列化
//这里我们看到数据是一个个写入的,并且将size也写入了进去
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
//modCount的作用在此体现,如果序列化时进行了修改操作,就会抛出异常
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
readObject是一个相反的过程,就是把数据正确的恢复回来,并将elementData设置好即可,感兴趣可以自行阅读源码。
总结
总体而言,ArrayList 还是和数组一样,更适合于数据随机访问,而不太适合于大量的插入与删除,如果一定要进行插入操作,要使用以下三种方式:
- 使用
ArrayList(int initialCapacity)
这个有参构造,在创建时就声明一个较大的大小。 - 使用
ensureCapacity(int minCapacity)
,在插入前先扩容。 - 使用 LinkedList,我们很快就会介绍这个适合于增删的集合类。
19 - CH19-Queue
今天要介绍的 Queue 就不同了,它是一个严格的排队系统。就像许多火车站排队窗口在两侧加了护栏一样,大家只能从队尾进来,从队首离开,我们称之为 FIFO(first in first out),也就是先进来的人先离开。Queue 就严格遵循了这个原则,使插队和提早离开变得不可能。
当然 Queue 也有很多变种,FIFO 并不是其可以遵循的唯一规则。比如Stack(栈),就遵循 LIFO(last in first out),这就好比我们叠碗一样,后来者居上。还有我们之后要分析的 Deque,其允许元素从两端插入或删除,比如排队进站时总有人说,“我能不能插个队,我赶时间?”。
超级接口——Queue
队列在软件开发中担任着重要的职责,Java 函数的调用用到了栈的技术,在处理并发问题时,BlockingQueue 很好的解决了数据传输的问题。接下来我们看看 Java 是如何定义队列的吧。
首先,Queue 也继承自 Collection,说明它是集合家族的一员。Queue 接口主要提供了以下方法:
//将元素插入队列
boolean add(E e);
//将元素插入队列,与add相比,在容量受限时应该使用这个
boolean offer(E e);
//将队首的元素删除,队列为空则抛出异常
E remove();
//将队首的元素删除,队列为空则返回null
E poll();
//获取队首元素,但不移除,队列为空则抛出异常
E element();
//获取队首元素,但不移除,队列为空则返回null
E peek();
超级实现类——AbstractQueue
Queue 的定义很简单,所以其实现类也很简单,用简单的代码做复杂的事情,值得我们学习。
AbstractQueue 仅实现了 add、remove 和 element 三个方法,并且分别调用了另外一个仅细微区别的方法,我们这里只看其一:
//这里我们就明白,对于有容量限制的,直接调用offer肯定会更快
public boolean add(E e) {
if (offer(e))
return true;
else
throw new IllegalStateException("Queue full");
}
此外,它还实现了 clear 与 addAll 方法,重写这些方法可以使其更符合当前场景。
public void clear() {
while (poll() != null)
;
}
public boolean addAll(Collection<? extends E> c) {
if (c == null)
throw new NullPointerException();
if (c == this)
throw new IllegalArgumentException();
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
20 - CH20-Deque
Deque 全称为 double ended queue,即双向队列,它允许在两侧插入或删除元素,同时也建议我们不要向其中插入 null 值。除此之外,其余特性则和父级 Queue 类似。Deque 大多数情况下不会限制元素的数量,但这不是必须的。
Deque 中定义的方法主要分为四部分,第一部分就如 Deque 定义所言,提供两侧插入或删除的方法。第二部分是继承自 Queue 的实现。第三部分表示如果要基于此实现一个 Stack,需要实现的方法。最后一部分是继承自 Collection 的方法。
双端操作
这里方法和Queue定义方式一致,但却是针对两侧插入删除的。
//在队首添加元素
void addFirst(E e);
//在队首添加元素
boolean offerFirst(E e);
//在队尾添加元素
void addLast(E e);
boolean offerLast(E e);
//删除队首元素
E removeFirst();
E pollFirst();
//删除队尾元素
E removeLast();
E pollLast();
//获取队首元素
E getFirst();
E peekFirst();
//获取队尾元素
E getLast();
E peekLast();
//删除第一个事件,大多数指的是删除第一个和 o equals的元素
boolean removeFirstOccurrence(Object o);
//删除最后一个事件,大多数指的是删除最后一个和 o equals的元素
boolean removeLastOccurrence(Object o);
与 Queue 对应的方法
因为Queue遵循FIFO,所以其方法在Deque中对应关系有所改变,结合Deque的定义,我们很容易就想到它们的对应关系:
//与addLast(E e)等价
boolean add(E e);
//与offerLast(E e)等价
boolean offer(E e);
//与removeFirst()等价
E remove();
//与pollFirst()等价
E poll();
//与getFirst()等价
E element();
//与peekFirst()等价
E peek();
实现 Stack
Stack仅在一侧支持插入删除操作等操作,遵循LIFO原则。
//与addFirst()等价
void push(E e);
//与removeFirst()等价
E pop();
继承于 Collection 的方法
//顺序是从队首到队尾
Iterator<E> iterator();
//顺序是从队尾到队首
Iterator<E> descendingIterator();
21 - CH21-ArrayQueue
在介绍了 Queue 与 Deque 概念之后,这是要进行分析的第一个实现类。ArrayDeque 可能大家用的都比较少,但其实现里有许多亮点还是值得我们关注的。
Deque 的定义为 double ended queue,也就是允许在两侧进行插入和删除等操作的队列。这个定义看起来很简单,那么我们怎么实现它呢?我们最容易想到的就是使用双向链表。我们在前文介绍过单链表,其每个数据单元都包含一个数据元素和一个指向下一个元素位置的指针 next,这样的链表只能从前向后遍历。如果我们要把它变成双向的,只需要添加一个可以指向上一个元素位置的指针 previous,同时记录下其尾节点即可。LinkedList 的实现就是采用了这一实现方案。
那 ArrayDeque 又是什么,它的结构又是怎样的呢?我们先看下其文档吧:
Resizable-array implementation of the Deque interface. Array deques have no capacity restrictions; they grow as necessary to support usage. They are not thread-safe; in the absence of external synchronization, they do not support concurrent access by multiple threads. Null elements are prohibited. This class is likely to be faster than Stack when used as a stack, and faster than LinkedList when used as a queue.
文档中并没有过多的介绍实现细节,但说它是Resizable-array implementation of the Deque interface,也就是用可动态调整大小的数组来实现了Deque,听起来是不是像ArrayList?但ArrayDeque对数组的操作方式和ArrayList有较大的差别。下面我们就深入其源码看看它是如何巧妙的使用数组的,以及为何说 “faster than Stack when used as a stack, and faster than LinkedList when used as a queue”。
构造函数与重要成员变量
ArrayDeque共有四个成员变量,其中两个我们在分析ArrayList时已经见过了,还有两个我们需要认真研究一下:
//存放元素,长度和capacity一致,并且总是2的次幂
//这一点,我们放在后面解释
transient Object[] elements;
//capacity最小值,也是2的次幂
private static final int MIN_INITIAL_CAPACITY = 8;
//标记队首元素所在的位置
transient int head;
//标记队尾元素所在的位置
transient int tail;
其构造函数共有三个:
//默认构造函数,将elements长度设为16,相当于最小capacity的两倍
public ArrayDeque() {
elements = new Object[16];
}
//带初始大小的构造
public ArrayDeque(int numElements) {
allocateElements(numElements);
}
//从其他集合类导入初始数据
public ArrayDeque(Collection<? extends E> c) {
allocateElements(c.size());
addAll(c);
}
这里看到有两个构造函数都用到了 allocateElements
方法,这是一个非常经典的方法,我们接下来就先重点研究它。
寻找最近的 2 次幂
在定义 elements 变量时说,其长度总是2的次幂,但用户传入的参数并不一定符合规则,所以就需要根据用户的输入,找到比它大的最近的2次幂。比如用户输入13,就把它调整为16,输入31,就调整为32,等等。考虑下,我们有什么方法可以实现呢?
来看下ArrayDeque是怎么做的吧:
private void allocateElements(int numElements) {
int initialCapacity = MIN_INITIAL_CAPACITY;
// Find the best power of two to hold elements.
// Tests "<=" because arrays aren't kept full.
if (numElements >= initialCapacity) {
initialCapacity = numElements;
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;
if (initialCapacity < 0) // Too many elements, must back off
initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
}
elements = new Object[initialCapacity];
}
看到这段迷之代码了吗?在HashMap中也有一段类似的实现。但要读懂它,我们需要先掌握以下几个概念:
- 在java中,int的长度是32位,有符号int可以表示的值范围是 (-2)31 到 231-1,其中最高位是符号位,0表示正数,1表示负数。
>>>
:无符号右移,忽略符号位,空位都以0补齐。|
:位或运算,按位进行或操作,逢1为1。
我们知道,计算机存储任何数据都是采用二进制形式,所以一个int值为80的数在内存中可能是这样的:
0000 0000 0000 0000 0000 0000 0101 0000
比80大的最近的2次幂是128,其值是这样的:
0000 0000 0000 0000 0000 0000 1000 0000
我们多找几组数据就可以发现规律:
- 每个2的次幂用二进制表示时,只有一位为 1,其余位均为 0(不包含符合位)。
- 要找到比一个数大的2的次幂(在正数范围内),只需要将其最高位左移一位(从左往右第一个 1 出现的位置),其余位置 0 即可。
但从实践上讲,没有可行的方法能够进行以上操作,即使通过&操作符可以将某一位置 0 或置 1,也无法确认最高位出现的位置,也就是基于最高位进行操作不可行。
但还有一个很整齐的数字可以被我们利用,那就是 2n-1,我们看下 128-1=127 的表示形式:
0000 0000 0000 0000 0000 0000 0111 1111
把它和80对比一下:
0000 0000 0000 0000 0000 0000 0101 0000 //80
0000 0000 0000 0000 0000 0000 0111 1111 //127
可以发现,我们只要把80从最高位起每一位全置为1,就可以得到离它最近且比它大的 2n-1,最后再执行一次+1操作即可。具体操作步骤为(为了演示,这里使用了很大的数字):
首先是原值:
0011 0000 0000 0000 0000 0000 0000 0010
- 无符号右移1位
0001 1000 0000 0000 0000 0000 0000 0001
- 与原值
|
操作:
0011 1000 0000 0000 0000 0000 0000 0011
可以看到最高2位都是1了,也仅能保证前两位为1,这时就可以直接移动两位. 3. 无符号右移2位
0000 1110 0000 0000 0000 0000 0000 0000
- 与原值
|
操作:
0011 1110 0000 0000 0000 0000 0000 0011
此时就可以保证前4位为1了,下一步移动4位. 5. 无符号右移4位:
0000 0011 1110 0000 0000 0000 0000 0000
- 与原值
|
操作:
0011 1111 1110 0000 0000 0000 0000 0011
此时就可以保证前8位为1了,下一步移动8位 7. 无符号右移8位:
0000 0000 0011 1111 1110 0000 0000 0000
- 与原值
|
操作:
0011 1111 1111 1111 1110 0000 0000 0011
此时前16位都是1,只需要再移位操作一次,即可把32位都置为1了。 9. 无符号右移16位:
0000 0000 0000 0000 0011 1111 1111 1111
- 与原值
|
操作:
0011 1111 1111 1111 1111 1111 1111 1111
- 进行 +1 操作:
0100 0000 0000 0000 0000 0000 0000 0000
如此经过11步操作后,我们终于找到了合适的2次幂。写成代码就是:
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;
不过为了防止溢出,导致出现负值(如果把符号位置为1,就为负值了)还需要一次校验:
if (initialCapacity < 0) // Too many elements, must back off
initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
至此,初始化的过程就完毕了。
重要操作方法
add
Deque主要定义了一些关于First和Last的操作,如add、remove、get等。我们看看它是如何实现的吧。
//在队首添加一个元素,非空
public void addFirst(E e) {
if (e == null)
throw new NullPointerException();
elements[head = (head - 1) & (elements.length - 1)] = e;
if (head == tail)
doubleCapacity();
}
//在队尾添加一个元素,非空
public void addLast(E e) {
if (e == null)
throw new NullPointerException();
elements[tail] = e;
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}
这里,又有一段迷之代码需要我们认真研究了,这也是ArrayDeque值得我们研究的地方之一,通过位运算提升效率。
elements[head = (head - 1) & (elements.length - 1)] = e;
很明显这是一个赋值操作,而且应该是给head之前的位置赋值,所以head = (head - 1)是合理的操作,那这个& (elements.length - 1)又表示什么呢?
在之前的定义与初始化中,elements.length 要求为2的次幂,也就是 2n 形式,那这个 & (elements.length - 1)
也就是 2n-1 了,在内存中用二进制表示就是从最高位起每一位都是1。我们还以之前的127为例:
0000 0000 0000 0000 0000 0000 0111 1111
&
就是按位与,全1才为1。那么任意一个正数和127进行按位与操作后,都只有最右侧7位被保留了下来,其他位全部置0(除符号位),而对一个负数而言,则会把它的符号位置为0,&操作后会变成正数。比如-1的值是1111 … 1111(32个1),和127按位操作后结果就变成了127 。所以,对于正数它就是取模,对于负数,它就是把元素插入了数组的结尾。所以,这个数组并不是向前添加元素就向前扩展,向后添加就向后扩展,它是循环的,类似这样:
初始时,head 与 tail 都指向 a[0]
,这时候数组是空的。当执行 addFirst()
方法时,head 指针移动一位,指向 a[elements.length-1]
,并赋值,也就是给 a[elements.length-1]
赋值。当执行 addLast()
操作时,先给 a[0]
赋值,再将 tail指针移动一位,指向 a[1]
。所以执行完之后head指针位置是有值的,而 tail 位置是没有值的。
随着添加操作执行,数组总会占满,那么怎么判断它满了然后扩容呢?首先,如果 head==tail,则说明数组是空的,所以在添加元素时必须保证 head 与 tail 不相等。假如现在只有一个位置可以添加元素了,类似下图:
此时,tail 指向了 a[8]
,head已经填充到 a[9]
了,只有 a[8]
是空闲的。很显然,不管是 addFirst 还是 addLast,再添加一个元素后都会导致 head==tail。这时候就不得不扩容了,因为 head==tail 是判断是否为空的条件。扩容就比较简单了,直接翻倍,我们看代码:
private void doubleCapacity() {
//只有head==tail时才可以扩容
assert head == tail;
int p = head;
int n = elements.length;
//在head之后,还有多少元素
int r = n - p; // number of elements to the right of p
//直接翻倍,因为capacity初始化时就已经是2的倍数了,这里无需再考虑
int newCapacity = n << 1;
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
//左侧数据拷贝
System.arraycopy(elements, p, a, 0, r);
//右侧数据拷贝
System.arraycopy(elements, 0, a, r, p);
elements = a;
head = 0;
tail = n;
}
分析完add,那么get以及remove等都大同小异,感兴趣可以查看源码。我们还要看看在Deque中定义的removeFirstOccurrence和removeLastOccurrence方法的具体实现。
Occurrence 相关
removeFirstOccurrence 和 removeLastOccurrence 分别用于找到元素在队首或队尾第一次出现的位置并删除。其实现原理是一致的,我们分析一个即可:
public boolean removeFirstOccurrence(Object o) {
if (o == null)
return false;
int mask = elements.length - 1;
int i = head;
Object x;
while ( (x = elements[i]) != null) {
if (o.equals(x)) {
delete(i);
return true;
}
i = (i + 1) & mask;
}
return false;
}
这里就是遍历所有元素,然后通过delete方法删除,我们看看delete实现:
private boolean delete(int i) {
//检查
checkInvariants();
final Object[] elements = this.elements;
final int mask = elements.length - 1;
final int h = head;
final int t = tail;
//待删除元素前面的元素个数
final int front = (i - h) & mask;
//待删除元素后面的元素个数
final int back = (t - i) & mask;
// Invariant: head <= i < tail mod circularity
//确认 i 在head和tail之间
if (front >= ((t - h) & mask))
throw new ConcurrentModificationException();
// Optimize for least element motion
//尽量最少操作数据
//前面数据比较少
if (front < back) {
if (h <= i) {
//这时 h 和 i 之间最近距离没有跨过位置0
System.arraycopy(elements, h, elements, h + 1, front);
} else { // Wrap around
System.arraycopy(elements, 0, elements, 1, i);
elements[0] = elements[mask];
System.arraycopy(elements, h, elements, h + 1, mask - h);
}
elements[h] = null;
head = (h + 1) & mask;
return false;
} else {
if (i < t) { // Copy the null tail as well
//这时 t 和 i 之间最近距离没有跨过位置0
System.arraycopy(elements, i + 1, elements, i, back);
tail = t - 1;
} else { // Wrap around
System.arraycopy(elements, i + 1, elements, i, mask - i);
elements[mask] = elements[0];
System.arraycopy(elements, 1, elements, 0, t);
tail = (t - 1) & mask;
}
return true;
}
}
总结
ArrayDeque 通过循环数组的方式实现的循环队列,并通过位运算来提高效率,容量大小始终是2的次幂。当数据充满数组时,它的容量将翻倍。作为Stack,因为其非线程安全所以效率高于 java.util.Stack,而作为队列,因为其不需要结点支持所以更快(LinkedList使用Node存储数据,这个对象频繁的new与clean,使得其效率略低于ArrayDeque)。但队列更多的用来处理多线程问题,所以我们更多的使用 BlockingQueue,关于多线程的问题,以后再认真研究。
22 - CH22-PriorityQueue
PriorityQueue(优先级队列)是一种在队列的基础上支持优先级的,PriorityQueue的优先级表现为一个PriorityQueue会关联一个Comparator,Comparator的结果体现了优先级的大小。PriorityQueue内部使用了数组来保存元素,支持动态扩容,整个数组会当做一个堆,每次加入删除元素的时候会调整堆。
PriorityQueue构造器
public PriorityQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
}
public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
}
public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
}
public PriorityQueue(int initialCapacity,
Comparator<? super E> comparator) {
// Note: This restriction of at least one is not actually needed,
// but continues for 1.5 compatibility
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}
默认的初始容量为11,如果没有提供自己的Comparator,那么会默认认为PriorityQueue的泛型类实现了Comparable接口. 初始容量为11? 也不知道怎么想的。
PriorityQueue的增加,删除,获取元素
public boolean add(E e) {
return offer(e);
}
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length)
grow(i + 1);
size = i + 1;
if (i == 0)
queue[0] = e;
else
siftUp(i, e);
return true;
}
PriorityQueue的动态增长策略是:
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
// overflow-conscious code
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}
原长度小于64,就每次只加2,大于64之后,就在原来的基础上增加1.5倍。what the hell… PriorityQueue底层的数组,每次在插入,移除某个元素时都需要重新调整堆:
public E poll() {
if (size == 0)
return null;
int s = --size;
modCount++;
E result = (E) queue[0];
E x = (E) queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);
return result;
}
private void siftDown(int k, E x) {
if (comparator != null)
siftDownUsingComparator(k, x);
else
siftDownComparable(k, x);
}
@SuppressWarnings("unchecked")
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>)x;
int half = size >>> 1; // loop while a non-leaf
while (k < half) {
int child = (k << 1) + 1; // assume left child is least
Object c = queue[child];
int right = child + 1;
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right];
if (key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
这种堆在优先级上来说,是最大堆,数组的0位置元素是最优先的,这个最优先的元素在Comparator比较小,具有最小的值,这种越小的值越优先的策略可以理解为对弱者的一种重视.
Others
PriorityQueue这种基于堆的结构,在插入和删除是都非常高效,O(logn)。PriorityQueue也支持序列化。提供了readObject和writeObject方法,这两个方法在ObjectInputStream和ObjectOutputStream时都会调用.
23 - CH23-LinkedList
分析完了 List 与 Queue 之后,终于可以看看 LinkedList 的实现了。LinkedList 弥补了 ArrayList 增删较慢的问题,但在查找方面又逊色于 ArrayList,所以在使用时需要根据场景灵活选择。对于这两个频繁使用的集合类,掌握它们的源码并正确使用,可以让我们的代码更高效。
LinkedList 既实现了 List,又实现了 Deque,前者使它能够像使用 ArrayList 一样使用,后者又使它能够承担队列的职责。LinkedList 内部结构是一个双向链表,我们在分析 ArrayDeque 时提到过这个概念,就是扩充单链表的指针域,增加一个指向前一个元素的指针 previous。
AbstractSequentialList
AbstractSequentialList 是 LinkedList 的父级,它继承自 AbstractList,并且是一个抽象类,它主要为顺序表的链式实现提供一个骨架:
This class provides a skeletal implementation of the List interface to minimize the effort required to implement this interface backed by a “sequential access” data store (such as a linked list). For random access data (such as an array), AbstractList should be used in preference to this class.
意思是它的主要作用是提供一个实现 List 接口的骨架,来减少我们实现基于链式存储的实现类时所需的工作量。AbstractSequentialList 并没有做很多特殊的事情,其中最主要的是提供一个方法的默认实现,并将以下方法抽象,以期有更符合场景的实现:
public abstract ListIterator<E> listIterator(int index);
其他一些方法的实现都利用了这个 listIterator 方法,我们不再一一查看了。下面我们分析 LinkedList 的实现。
LinkedList 的结构
可以看到,LinkedList 也实现了 Cloneable、java.io.Serializable 等方法,借鉴于 ArrayList 的经验,我们可以想到它的 Clone 也是浅克隆,在序列化方法也采用了同样的方式,我们就不再赘述了。
构造方法与成员变量
数据单元 Node
在介绍链表结构时提到过,其数据单元分为数据域和指针域,分别存储数据和指向下一个元素的位置,在 Java 中只要定义一个实体类就可以解决了。
private static class Node<E> {
E item; //数据
Node<E> next; //下一个元素
Node<E> prev; //上一个元素
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
成员变量
LinkedList 成员变量主要有三个,而且其意义清晰可见。
// 记录当前链表的长度
transient int size = 0;
// 第一个节点
transient Node<E> first;
// 最后一个节点
transient Node<E> last;
构造函数
因为链表没有长度方面的问题,所以也不会涉及到扩容等问题,其构造函数也十分简洁了。
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
一个默认的构造函数,什么都没有做,一个是用其他集合初始化,调用了一下 addAll 方法。addAll 方法我们就不再分析了,它应该是和添加一个元素的方法是一致的。
重要方法
LinkedList 既继承了 List,又继承了 Deque,那它必然有一堆 add、remove、addFirst、addLast 等方法。这些方法的含义也相差不大,实现也是类似的,因此 LinkedList 又提取了新的方法,来简化这些问题。我们看看这些不对外的方法,以及它们是如何与上述函数对应的。
//将一个元素链接到首位
private void linkFirst(E e) {
//先将原链表存起来
final Node<E> f = first;
//定义一个新节点,其next指向原来的first
final Node<E> newNode = new Node<>(null, e, f);
//将first指向新建的节点
first = newNode;
//原链表为空表
if (f == null)
//把last也指向新建的节点,现在first与last都指向了它
last = newNode;
else
//把原链表挂载在新建节点,也就是现在的first之后
f.prev = newNode;
size++;
modCount++;
}
//与linkFirst类似
void linkLast(E e) {
//...
}
//在某个非空节点之前添加元素
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
//先把succ节点的前置节点存起来
final Node<E> pred = succ.prev;
//新节点插在pred与succ之间
final Node<E> newNode = new Node<>(pred, e, succ);
//succ的prev指针移到新节点
succ.prev = newNode;
//前置节点为空
if (pred == null)
//说明插入到了首位
first = newNode;
else
//把前置节点的next指针也指向新建的节点
pred.next = newNode;
size++;
modCount++;
}
//删除首位的元素,元素必须非空
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
final E element = f.item;
final Node<E> next = f.next;
f.item = null;
f.next = null; // help GC
first = next;
if (next == null)
last = null;
else
next.prev = null;
size--;
modCount++;
return element;
}
private E unlinkLast(Node<E> l) {
//...
}
//删除一个指定的节点
E unlink(Node<E> x) {
//...
}
可以看到,LinkedList 提供了一系列方法用来插入和删除,但是却没有再实现一个方法来进行查询,因为对链表的查询是比较慢的,所以它是通过另外的方法来实现的,我们看一下:
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
//可以说尽力了
Node<E> node(int index) {
// assert isElementIndex(index);
//size>>1就是取一半的意思
//折半,将遍历次数减少一半
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
最后,我们看下它如何对应那些继承来的方法:
//引用了node方法,需要遍历
public E set(int index, E element) {
checkElementIndex(index);
Node<E> x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}
//也可能需要遍历
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
//也要遍历
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
public E peek() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
public E element() {
return getFirst();
}
public E poll() {
final Node<E> f = first;
return (f == null) ? null : unlinkFirst(f);
}
public E remove() {
return removeFirst();
}
public boolean offer(E e) {
return add(e);
}
public boolean offerFirst(E e) {
addFirst(e);
return true;
}
//...
总结
LinkedList 非常适合大量数据的插入与删除,但其对处于中间位置的元素,无论是增删还是改查都需要折半遍历,这在数据量大时会十分影响性能。在使用时,尽量不要涉及查询与在中间插入数据,另外如果要遍历,也最好使用 foreach,也就是 Iterator 提供的方式。
24 - CH24-Vector
Vector 和 ArrayList 非常相似,底层都是基于数组的实现,支持动态扩容。支持通过索引访问,删除,添加元素。Vector 也继承自 AbstractList,也实现了 Collections 接口。与 ArrayList 不同的是,Vector 是线程安全的容器。他的添加删除等方法都是 synchronized。但是 Vector 也支持 fail-fast。
构造器
public Vector(int initialCapacity, int capacityIncrement) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
this.capacityIncrement = capacityIncrement;
}
/**
* Constructs an empty vector with the specified initial capacity and
* with its capacity increment equal to zero.
*
* @param initialCapacity the initial capacity of the vector
* @throws IllegalArgumentException if the specified initial capacity
* is negative
*/
public Vector(int initialCapacity) {
this(initialCapacity, 0);
}
/**
* Constructs an empty vector so that its internal data array
* has size {@code 10} and its standard capacity increment is
* zero.
*/
public Vector() {
this(10);
}
/**
* Constructs a vector containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection whose elements are to be placed into this
* vector
* @throws NullPointerException if the specified collection is null
* @since 1.2
*/
public Vector(Collection<? extends E> c) {
elementData = c.toArray();
elementCount = elementData.length;
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
}
与ArrayList类似,Vector支持初始化时指定大小,默认初始化的情况下,是一个可以容纳10个元素的数组。(这一点与ArrayList不同,ArrayList默认初始化时是空的数组).
Vector添加,删除,访问元素
Vector支持向尾部添加元素,向指定位置添加元素,在向指定位置添加元素的时候需要移动。支持访问某个位置的元素,访问头部,尾部元素,支持删除某个位置的元素,删除指定元素(通过equals比较).
public synchronized E firstElement() {
if (elementCount == 0) {
throw new NoSuchElementException();
}
return elementData(0);
}
/**
* Returns the last component of the vector.
*
* @return the last component of the vector, i.e., the component at index
* <code>size() - 1</code>.
* @throws NoSuchElementException if this vector is empty
*/
public synchronized E lastElement() {
if (elementCount == 0) {
throw new NoSuchElementException();
}
return elementData(elementCount - 1);
}
public synchronized E elementAt(int index) {
if (index >= elementCount) {
throw new ArrayIndexOutOfBoundsException(index + " >= " + elementCount);
}
return elementData(index);
}
public synchronized E elementAt(int index) {
if (index >= elementCount) {
throw new ArrayIndexOutOfBoundsException(index + " >= " + elementCount);
}
return elementData(index);
}
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized void insertElementAt(E obj, int index) {
modCount++;
if (index > elementCount) {
throw new ArrayIndexOutOfBoundsException(index
+ " > " + elementCount);
}
ensureCapacityHelper(elementCount + 1);
System.arraycopy(elementData, index, elementData, index + 1, elementCount - index);
elementData[index] = obj;
elementCount++;
}
public synchronized void removeElementAt(int index) {
modCount++;
if (index >= elementCount) {
throw new ArrayIndexOutOfBoundsException(index + " >= " +
elementCount);
}
else if (index < 0) {
throw new ArrayIndexOutOfBoundsException(index);
}
int j = elementCount - index - 1;
if (j > 0) {
System.arraycopy(elementData, index + 1, elementData, index, j);
}
elementCount--;
elementData[elementCount] = null; /* to let gc do its work */
}
Vector的扩容策略与ArrayList不同,ArrayList总是以1.5倍的大小来扩容,而Vector会支持用户在构造Vector时指定一个整形的incrementCapacity变量,每次在需要增加的时候,都会尽量增加指定的incrementCapacity的大小。
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
如果增加incrementCapacity大小还不能保证放得下所有的元素,那么就增加可以放得下所有元素的大小。如果在创建Vector的时候,没有指定initialCapacity大小,initialCapacity是0,这就会导致每次增加元素都会扩容。这是相当低效的。jdk文档里已不推荐使用Vector。大概这是原因之一。
Vector的序列化
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
final java.io.ObjectOutputStream.PutField fields = s.putFields();
final Object[] data;
synchronized (this) {
fields.put("capacityIncrement", capacityIncrement);
fields.put("elementCount", elementCount);
data = elementData.clone();
}
fields.put("elementData", data);
s.writeFields();
}
Vector的序列化似乎只提供了writeObject方法,奇怪的是,这里方法在序列化的时候首先clone了所有的元素,尽管这样做可以保证同步,但是实在不确定这种方法的效率.
Vector优化
尽管Vector的设计有一点缺点,但是Vector也尽量保证了删除元素的时候设置应用为null,来尽量保证GC。
public synchronized void removeAllElements() {
modCount++;
// Let gc do its work
for (int i = 0; i < elementCount; i++)
elementData[i] = null;
elementCount = 0;
}
在clean元素的时候,设置数组的所有元素为null.
Vector 的线程安全性
如我们之前贴的方法所示,add , remove , get等都有synchronized关键字来保证线程安全。而且,Vector的遍历也通过modCount保证了fail-fast. 这种简单加锁的机制,在高并发的情况下,肯定会带来效率的影响。因此,在高并发时,还是需要使用效率更好的容器。
25 - CH25-Stack
Stack和Vector一样,从jdk1.0就存在,Stack的代码相当简单,通过继承Vector类,实现了简单的Stack:
/**
* Creates an empty Stack.
*/
public Stack() {
}
/**
* Pushes an item onto the top of this stack. This has exactly
* the same effect as:
* <blockquote><pre>
* addElement(item)</pre></blockquote>
*
* @param item the item to be pushed onto this stack.
* @return the <code>item</code> argument.
* @see java.util.Vector#addElement
*/
public E push(E item) {
addElement(item);
return item;
}
/**
* Removes the object at the top of this stack and returns that
* object as the value of this function.
*
* @return The object at the top of this stack (the last item
* of the <tt>Vector</tt> object).
* @throws EmptyStackException if this stack is empty.
*/
public synchronized E pop() {
E obj;
int len = size();
obj = peek();
removeElementAt(len - 1);
return obj;
}
/**
* Looks at the object at the top of this stack without removing it
* from the stack.
*
* @return the object at the top of this stack (the last item
* of the <tt>Vector</tt> object).
* @throws EmptyStackException if this stack is empty.
*/
public synchronized E peek() {
int len = size();
if (len == 0)
throw new EmptyStackException();
return elementAt(len - 1);
}
/**
* Tests if this stack is empty.
*
* @return <code>true</code> if and only if this stack contains
* no items; <code>false</code> otherwise.
*/
public boolean empty() {
return size() == 0;
}
/**
* Returns the 1-based position where an object is on this stack.
* If the object <tt>o</tt> occurs as an item in this stack, this
* method returns the distance from the top of the stack of the
* occurrence nearest the top of the stack; the topmost item on the
* stack is considered to be at distance <tt>1</tt>. The <tt>equals</tt>
* method is used to compare <tt>o</tt> to the
* items in this stack.
*
* @param o the desired object.
* @return the 1-based position from the top of the stack where
* the object is located; the return value <code>-1</code>
* indicates that the object is not on the stack.
*/
public synchronized int search(Object o) {
int i = lastIndexOf(o);
if (i >= 0) {
return size() - i;
}
return -1;
}
Stack也是线程安全的,但是问题是它具有Vector的缺点,由于每次push一个元素都会扩容,这导致效率比较低下。正因为如此,Stack也不推荐使用,使用LinkedList效率会更高。
26 - CH26-Map
数组与链表在处理数据时各有优缺点,数组查询速度很快而插入很慢,链表在插入时表现优秀但查询无力。哈希表则整合了数组与链表的优点,能在插入和查找等方面都有不错的速度。我们之后要分析的 HashMap 就是基于哈希表实现的,不过在 JDK1.8 中还引入了红黑树,其性能进一步提升了。本文主要分析 JDK 中关于 Map 的定义。
Map 接口
An object that maps keys to values. A map cannot contain duplicate keys; each key can map to at most one value.
也就是基于 key-value 的数据格式,并且 key 值不可以重复,每个 key 对应的 value 唯一。Map 的 key 也可以为 null,也不可重。
在分析其定义的方法前,我们要先了解一下 Map.Entry 这个接口。
Map.Entry 接口
存储在 Map 中的数据需要实现此接口,主要提供对 key 和 value 的操作,也是我们使用最多的操作。我们先分析它:
// 获取对应的key
K getKey();
// 获取对应的value
V getValue();
// 替换原有的value
V setValue(V value);
// 希望我们实现equals和hashCode
boolean equals(Object o);
int hashCode();
// 从1.8起,还提供了比较的方法,类似的方法共四个
public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getKey().compareTo(c2.getKey());
}
重要方法
// 返回当前数据个数
int size();
// 是否为空
boolean isEmpty();
// 判断是否包含key,这里用到了key的equals方法,所以key必须实现它
boolean containsKey(Object key);
// 判断是否有key保存的值是value,这也基于equals方法
boolean containsValue(Object value);
// 通过key获取对应的value值
V get(Object key);
// 存入key-value
V put(K key, V value);
// 移除一个key-value对
V remove(Object key);
// 从其他Map添加
void putAll(Map<? extends K, ? extends V> m);
// 清空
void clear();
// 返回所有的key至Set集合中,因为key是不可重的,Set也是不可重的
Set<K> keySet();
// 返回所有的values
Collection<V> values();
// 返回key-value对到Set中
Set<Map.Entry<K, V>> entrySet();
// 希望我们实现equals和hashCode
boolean equals(Object o);
int hashCode();
此外,还有一些 Java8 相关的 default 方法,就不一一展示了。
default V getOrDefault(Object key, V defaultValue) {
V v;
return (((v = get(key)) != null) || containsKey(key))
? v
: defaultValue;
}
超级实现类——AbstractMap
对应于 AbstractCollection,AbstractMap 的作用也是类似的,主要是提供一些方法的实现,可以方便继承。下面我们看看它都实现了哪些方法:
// 返回大小,这里大小基于entrySet的大小
public int size() {
return entrySet().size();
}
public boolean isEmpty() {
return size() == 0;
}
//基于entrySet操作
public boolean containsKey(Object key) {
Iterator<Map.Entry<K,V>> i = entrySet().iterator();
if (key==null) {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getKey()==null)
return true;
}
} else {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (key.equals(e.getKey()))
return true;
}
}
return false;
}
public boolean containsValue(Object value) {
//...
}
public V get(Object key) {
//...
}
public V remove(Object key) {
//...
}
public void clear() {
entrySet().clear();
}
除此以外,还定义了两个变量:
transient Set<K> keySet;
transient Collection<V> values;
还提供了默认的实现方法,我们只看其中一个吧:
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new AbstractSet<K>() {
public Iterator<K> iterator() {
return new Iterator<K>() {
private Iterator<Entry<K,V>> i = entrySet().iterator();
public boolean hasNext() {
return i.hasNext();
}
public K next() {
return i.next().getKey();
}
public void remove() {
i.remove();
}
};
}
public int size() {
return AbstractMap.this.size();
}
public boolean isEmpty() {
return AbstractMap.this.isEmpty();
}
public void clear() {
AbstractMap.this.clear();
}
public boolean contains(Object k) {
return AbstractMap.this.containsKey(k);
}
};
keySet = ks;
}
return ks;
}
除了以上相关方法以外,AbstractMap 还实现了 equals、hashCode、toString、clone 等方法,这样在具体实现时可以省去很多工作。
27 - CH27-SortedMap
由于乱序的数据对查找不利,例如无法使用二分法等降低算法的时间复杂度,如果数据在插入时就排好顺序,查找的性能就会提升很多。SortedMap 接口就是为这种有序数据服务的。
SortedMap 接口需要数据的 key 支持 Comparable,或者可以被指定的 Comparator 接受。SortedMap 主要提供了以下方法:
// 返回排序数据所用的Comparator
Comparator<? super K> comparator();
// 返回在[fromKey, toKey)之间的数据
SortedMap<K,V> subMap(K fromKey, K toKey);
// 返回从第一个元素到toKey之间的数据
SortedMap<K,V> headMap(K toKey);
// 返回从fromKey到末尾之间的数据
SortedMap<K,V> tailMap(K fromKey);
//返回第一个数据的key
K firstKey();
//返回最后一个数据的key
K lastKey();
SortedMap 主要提供了获取子集,以及获取最大值(最后一个值)和最小值(第一个值)的方法。但这仅仅是排序数据能提供的便利的一小部分,在之后分析的 NavigableMap 中,我们还会看到更多的功能。
28 - CH28-NavigableMap
SortedMap 提供了获取最大值与最小值的方法,但对于一个已经排序的数据集,除了最大值与最小值之外,我们可以对任何一个元素,找到比它小的值和比它大的值,还可以按照按照原有的顺序倒序排序等。NavigableMap 就为我们提供了这些功能。
NavigableMap 主要有以下方法:
// 找到第一个比指定的key小的值
Map.Entry<K,V> lowerEntry(K key);
// 找到第一个比指定的key小的key
K lowerKey(K key);
// 找到第一个小于或等于指定key的值
Map.Entry<K,V> floorEntry(K key);
// 找到第一个小于或等于指定key的key
K floorKey(K key);
// 找到第一个大于或等于指定key的值
Map.Entry<K,V> ceilingEntry(K key);
K ceilingKey(K key);
// 找到第一个大于指定key的值
Map.Entry<K,V> higherEntry(K key);
K higherKey(K key);
// 获取最小值
Map.Entry<K,V> firstEntry();
// 获取最大值
Map.Entry<K,V> lastEntry();
// 删除最小的元素
Map.Entry<K,V> pollFirstEntry();
// 删除最大的元素
Map.Entry<K,V> pollLastEntry();
//返回一个倒序的Map
NavigableMap<K,V> descendingMap();
// 返回一个Navigable的key的集合,NavigableSet和NavigableMap类似
NavigableSet<K> navigableKeySet();
// 对上述集合倒序
NavigableSet<K> descendingKeySet();
29 - CH29-TreeMap
TreeMap 是红黑树的 Java 实现,对红黑树不太了解的可以查阅前面关于红黑树的介绍。红黑树能保证增、删、查等基本操作的时间复杂度为 O(lgN)。本文将对 TreeMap 的源码进行分析。
Entry 定义
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
// ... 省略其他方法
}
构造函数与成员变量
成员变量
// 比较器
private final Comparator<? super K> comparator;
// 根节点
private transient Entry<K,V> root;
// 大小
private transient int size = 0;
构造函数
// 默认构造,比较器采用key的自然比较顺序
public TreeMap() {
comparator = null;
}
// 指定比较器
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
// 从Map集合导入初始数据
public TreeMap(Map<? extends K, ? extends V> m) {
comparator = null;
putAll(m);
}
// 从SortedMap导入初始数据
public TreeMap(SortedMap<K, ? extends V> m) {
comparator = m.comparator();
try {
buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
} catch (java.io.IOException cannotHappen) {
} catch (ClassNotFoundException cannotHappen) {
}
}
这里用到的 putAll 和 buildFromSorted 方法,在分析完增删查等重要方法之后再进行分析。
重要方法
增加一个元素
红黑树最复杂的地方就在于增删了,我们就从增加一个元素开始分析:
public V put(K key, V value) {
// 暂存根节点
Entry<K,V> t = root;
// 根节点空,就是还没有元素
if (t == null) {
compare(key, key); // type (and possibly null) check
// 新建一个元素,默认颜色黑色
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
// 根节点不为空,有元素时的情况
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
// 初始化时指定了comparator比较器
if (cpr != null) {
do {
// 把t暂存到parent中
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
// 比较小,往左侧插入
t = t.left;
else if (cmp > 0)
// 比较大,往右侧插入
t = t.right;
else
// 一样大,所以就是更新当前值
return t.setValue(value);
} while (t != null);
}
else {
// 使用key的比较器,while循环原理和上述一致
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
// 不断的比较,找到了没有相应儿子的节点
//(cmp<0就是没有左儿子,cmp>0就是没有右儿子)
Entry<K,V> e = new Entry<>(key, value, parent);
// 把数据插入
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 新插入的元素破坏了红黑树规则,需要调整
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
fixAfterInsertion 是实现的重难点,我们先看看 Java 是如何实现的,稍后会对其中出现的几种情况做对应的图示分析。
private void fixAfterInsertion(Entry<K,V> x) {
// 先把x节点染成红色,这样可以不增加黑高,简化调整问题
x.color = RED;
// 条件是父节点是红色的,且x不是root节点,
// 因为到root节点后就走到另外的分支了,而那个分支是正确的
while (x != null && x != root && x.parent.color == RED) {
//x的父节点是其祖父节点的左儿子
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
// y是x的叔叔,也就是祖父节点的右儿子
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
//叔叔是红色的
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
// 调整完毕,继续向上循环
x = parentOf(parentOf(x));
} else {
// 叔叔是黑色的
if (x == rightOf(parentOf(x))) {
// x是右节点,以其父节点左旋
x = parentOf(x);
rotateLeft(x);
}
// 右旋
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else {
//x的父节点是其祖父节点的右儿子
// y是其叔叔
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
//叔叔是红色的
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
// 调整完毕,继续向上循环
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
// x是左节点,以其父节点右旋
x = parentOf(x);
rotateRight(x);
}
//左旋
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
//root节点颜色为黑色
root.color = BLACK;
}
左旋和右旋代码如下:
// 右旋与左旋思路一致,只分析其一
// 结果相当于把p和p的儿子调换了
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
// 取出p的右儿子
Entry<K,V> r = p.right;
// 然后将p的右儿子的左儿子,也就是p的左孙子变成p的右儿子
p.right = r.left;
if (r.left != null)
// p的左孙子的父亲现在是p
r.left.parent = p;
// 然后把p的父亲,设置为p右儿子的父亲
r.parent = p.parent;
// 这说明p原来是root节点
if (p.parent == null)
root = r;
else if (p.parent.left == p)
p.parent.left = r;
else
p.parent.right = r;
r.left = p;
p.parent = r;
}
}
//和左旋类似
private void rotateRight(Entry<K,V> p) {
// ...
}
增加元素图示
在分析红黑树的文章中,我们已经演示过如何进行插入元素,这里结合代码再演示一次。首先再看下红黑树的定义:
- 每个节点是红色或黑色。
- 根节点是黑色。
- 每个叶子节点(NIL)是黑色。(这里叶子节点,是指为空(NIL或NULL)的叶子节点)
- 如果一个节点是红色的,则它的两个儿子都是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
现有一棵简单的红黑树:
然后我们希望把一个值为7的元素插入进去。按照put方法,先把7和根节点14比较,发现7<14,就向左遍历。到6时,发现7>6,于是再和8比较,发现8是一个叶节点,所以把7插入到8的左儿子处,如下图所示:
为了不增加黑高,这里把7设置为红色。现在,这棵树已经不再是红黑树了,因为其违反了规则如果一个节点是红色的,则它的两个儿子都是黑色的。我们按照 fixAfterInsertion 的方式对其进行调整,fixAfterInsertion 中的参数x就是这里的7。
首先,进入循环后发现7的父亲是右节点,进入 else 判断,7的叔叔4是红色的,于是把4和8染为黑色,6染为红色,把x参数指向6,并进入下一次循环。如下所示:
此时x是6,其父亲10是左儿子,其叔叔18是黑色的,此时代码就会走到这里:
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
此时,就需要把10和14的颜色更换,如下图所示:
然后以14为基础右旋,涉及到的元素有10、12和14,如下所示:
具体操作为,把10的右儿子12,变为14的左儿子,然后把14变为10的右儿子,结果如下:
此时循环条件不再满足,也就是调整完毕,可以看到,依然是一棵正确的红黑树。
这只是需要调整的一种情况,再举一个复杂一些的例子,此时把11插入了红黑树中:
此时其父亲10是红色,没有叔叔,所以需要先左旋,再右旋。具体操作如下:
- 以10为基础左旋,涉及元素为10和11。
情况就和之前插入7类似了,更改11和12的颜色,然后x指向12:
这时又和刚插入11时类似,以8为基础左旋:
这里是不是就很熟悉了呢?最后的结果如下所示:
代码的做法和我们之前的分析如出一辙,这里再次演示的原因是加深对理论方法与实际代码间关系的理解。
获取元素
TreeMap 中的元素是有序的,当使用中序遍历时就可以得到一个有序的 Set 集合,所以获取元素可以采用二分法:
final Entry<K,V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
除了获取某个元素外,还可以获取它的前一个元素与后一个元素:
// 获取前一个元素
static <K,V> Entry<K,V> predecessor(Entry<K,V> t) {
if (t == null)
return null;
else if (t.left != null) {
// t有左孩子,所以t的前一个元素是它左孩子所在的子树的最右侧叶子结点
Entry<K,V> p = t.left;
while (p.right != null)
p = p.right;
return p;
} else {
// t没有左孩子,所以t的前一个元素有两种情况
// 1. t是右孩子,那它的前一个元素就是它的父结点
// 2. t是左孩子,它的前一个元素需要向上递归,直到递归到下一个是右孩子的节点,转为情况1
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
while (p != null && ch == p.left) {
ch = p;
p = p.parent;
}
return p;
}
}
// 获取后一个元素
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
//...
}
删除一个元素
从红黑树中删除一个元素,和增加一个元素一样复杂。我们看看 Java 的实现:
public V remove(Object key) {
// 先用二分法获取这个元素,如果为null,不需要继续了
Entry<K,V> p = getEntry(key);
if (p == null)
return null;
V oldValue = p.value;
deleteEntry(p);
return oldValue;
}
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
// If strictly internal, copy successor's element to p and then make p
// point to successor.
//如果p有两个儿子,就把p指向它的后继者,也就是它后边的元素
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
} // p has 2 children
// Start fixup at replacement node, if it exists.
// p有一个儿子,或者没有儿子,获取到之后放在replacement中
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
// p有儿子
if (replacement != null) {
// Link replacement to parent
// 把p的子孙接在p的父级
replacement.parent = p.parent;
//p是根节点
if (p.parent == null)
root = replacement;
//p是左儿子
else if (p == p.parent.left)
p.parent.left = replacement;
// p是右儿子
else
p.parent.right = replacement;
//把p的链接都删掉
// Null out links so they are OK to use by fixAfterDeletion.
p.left = p.right = p.parent = null;
// Fix replacement
if (p.color == BLACK)
//修正
fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.
root = null;
} else {
//p没有儿子
// No children. Use self as phantom replacement and unlink.
if (p.color == BLACK)
fixAfterDeletion(p);
// 把其父节点链接到p的都去掉
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}
修正的方法如下所示:
private void fixAfterDeletion(Entry<K,V> x) {
while (x != root && colorOf(x) == BLACK) {
// x是左儿子
if (x == leftOf(parentOf(x))) {
// sib是x的兄弟
Entry<K,V> sib = rightOf(parentOf(x));
// 兄弟是红色的
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateLeft(parentOf(x));
sib = rightOf(parentOf(x));
}
// 兄弟没有孩子或者孩子是黑色的
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
// 兄弟的右孩子是黑色的
if (colorOf(rightOf(sib)) == BLACK) {
setColor(leftOf(sib), BLACK);
setColor(sib, RED);
rotateRight(sib);
sib = rightOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root;
}
} else { // symmetric
Entry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateRight(parentOf(x));
sib = leftOf(parentOf(x));
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK);
setColor(sib, RED);
rotateLeft(sib);
sib = leftOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(leftOf(sib), BLACK);
rotateRight(parentOf(x));
x = root;
}
}
}
setColor(x, BLACK);
}
删除元素的过程相对简单些,在分析红黑树的文章里已经做了示例,这里就不再画图展示了。
遗留问题
在前面分析构造函数时,有两个函数 putAll 和 buildFromSorted 当时忽略了,现在我们来看看它们的实现。
public void putAll(Map<? extends K, ? extends V> map) {
int mapSize = map.size();
if (size==0 && mapSize!=0 && map instanceof SortedMap) {
//...
buildFromSorted(
mapSize,map.entrySet().iterator(),null, null);
//...
return;
}
super.putAll(map);
}
putAll 当 Map 是一个 SortedMap 实例时,依赖于 buildFromSorted,其他情况则是由 AbstractMap 实现的。所以这里重点看下 buildFromSorted 的实现。
buildFromSorted 有两个,一个是供 putAll 等调用的,另外一个则是具体的实现。
// 这个方法主要是被调用,关注它只为了看下computeRedLevel这个方法
private void buildFromSorted(int size, Iterator<?> it,
java.io.ObjectInputStream str,
V defaultVal)
throws java.io.IOException, ClassNotFoundException {
this.size = size;
root = buildFromSorted(0, 0, size - 1, computeRedLevel(size),
it, str, defaultVal);
}
这里调用了一个 computeRedLevel 的方法,是这里的关键。
这个方法和染色为红色有关,其实现和二分法看似有一定联系,其文档说明它是:
Find the level down to which to assign all nodes BLACK. This is the last ‘full’ level of the complete binary tree produced by buildTree. The remaining nodes are colored RED. (This makes a `nice’ set of color assignments wrt future insertions.) This level number is computed by finding the number of splits needed to reach the zeroeth node. (The answer is ~lg(N), but in any case must be computed by same quick O(lg(N)) loop.)
大概意思是讲通过这种方式可以构建一个优秀的红黑树,能够为以后插入更多数据提供便利。
最后我们看下 buildFromSorted 的实现:
private final Entry<K,V> buildFromSorted(int level, int lo, int hi,
int redLevel,
Iterator<?> it,
java.io.ObjectInputStream str,
V defaultVal)
throws java.io.IOException, ClassNotFoundException {
if (hi < lo) return null;
// 获取中间位置
int mid = (lo + hi) >>> 1;
Entry<K,V> left = null;
if (lo < mid)
// 递归左子树,和压栈类似,直到lo>=mid才能返回结果
left = buildFromSorted(level+1, lo, mid - 1, redLevel,
it, str, defaultVal);
// extract key and/or value from iterator or stream
K key;
V value;
if (it != null) {
// 给key和value赋值
if (defaultVal==null) {
Map.Entry<?,?> entry = (Map.Entry<?,?>)it.next();
key = (K)entry.getKey();
value = (V)entry.getValue();
} else {
key = (K)it.next();
value = defaultVal;
}
} else { // use stream
// 从序列化中恢复
key = (K) str.readObject();
value = (defaultVal != null ? defaultVal : (V) str.readObject());
}
Entry<K,V> middle = new Entry<>(key, value, null);
// color nodes in non-full bottommost level red
//
if (level == redLevel)
middle.color = RED;
if (left != null) {
middle.left = left;
left.parent = middle;
}
if (mid < hi) {
Entry<K,V> right = buildFromSorted(level+1, mid+1, hi, redLevel,
it, str, defaultVal);
middle.right = right;
right.parent = middle;
}
return middle;
}
根据以上方式,我们测试向其中插入10条数据,其结果类似下图:
可见,redLevel 控制的是红色节点出现的层级,使插入的数据更整齐,方便后续操作。
30 - CH30-HashMap
HashMap 可能是我们使用最多的键值对型的集合类了,它的底层基于哈希表,采用数组存储数据,使用链表来解决哈希碰撞。在 JDK1.8 中还引入了红黑树来解决链表长度过长导致的查询速度下降问题。以下是文档对它的介绍中我们重点关注的部分:
Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.
An instance of HashMap has two parameters that affect its performance: initial capacity and load factor. The capacity is the number of buckets in the hash table, and the initial capacity is simply the capacity at the time the hash table is created. The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs.
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use. And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used.
构造函数与成员变量
在看构造函数和成员变量前,我们要先看下其数据单元,因为HashMap有普通的元素,还有红黑树的元素,所以其数据单元定义有两个:
// 普通节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ...
}
// 树节点,继承自LinkedHashMap.Entry
// 这是因为LinkedHashMap是HashMap的子类,也需要支持树化
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
// ...
}
// LinkedHashMap.Entry的实现
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
TreeNode 定义了一些相关操作的方法,我们会在使用时进行分析。
成员变量
// capacity初始值,为16,必须为2的次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// capacity的最大值,为2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// load factor,是指当容量被占满0.75时就需要rehash扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表长度到8,就转为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 树大小为6,就转回链表
static final int UNTREEIFY_THRESHOLD = 6;
// 至少容量到64后,才可以转为树
static final int MIN_TREEIFY_CAPACITY = 64;
// 保存所有元素的table表
transient Node<K,V>[] table;
// 通过entrySet变量,提供遍历的功能
transient Set<Map.Entry<K,V>> entrySet;
// 下一次扩容值
int threshold;
// load factor
final float loadFactor;
构造函数
HashMap 有多个构造函数,主要支持配置容量 capacity 和 load factor,以及从其他 Map 集合获取初始化数据。
public HashMap(int initialCapacity, float loadFactor) {
// ... 参数校验
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
这些构造函数都很简单,putMapEntries 也是依次插入元素的,我们后续分析 put 方法时就能理解其操作了,这里我们还要看下 tableSizeFor 这个方法:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
如果你是跟随我文章的顺序读到这里,有没有感觉十分熟悉?这就是找到距离 cap 参数最近的 2 的次幂,类似于 ArrayQueue 中的操作。
重要方法
无论是 List 还是 Map,最重要的操作都是增删改查部分,我们还从增加一个元素开始分析。
增加一个元素
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
这里我们先关注下 hash 函数,在 HashMap 中其实现如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里用到的方法很简单,就是把 key 与其高 16 位异或。文档中有如下说明:
There is a tradeoff between speed, utility, and quality of bit-spreading.
因为没有完美的哈希算法可以彻底避免碰撞,所以只能尽可能减少碰撞,在各方面权衡之后得到一个折中方案,这里我们就不再追究了。
put 方法的具体实现在 putVal 中,我们看下其实现:
// 参数onlyIfAbsent表示是否替换原值
// 参数evict我们可以忽略它,它主要用来区别通过put添加还是创建时初始化数据的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 空表,需要初始化
if ((tab = table) == null || (n = tab.length) == 0)
// resize()不仅用来调整大小,还用来进行初始化配置
n = (tab = resize()).length;
// (n - 1) & hash这种方式也熟悉了吧?都在分析ArrayDeque中有体现
//这里就是看下在hash位置有没有元素,实际位置是hash % (length-1)
if ((p = tab[i = (n - 1) & hash]) == null)
// 将元素直接插进去
tab[i] = newNode(hash, key, value, null);
else {
//这时就需要链表或红黑树了
// e是用来查看是不是待插入的元素已经有了,有就替换
Node<K,V> e; K k;
// p是存储在当前位置的元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //要插入的元素就是p,这说明目的是修改值
// p是一个树节点
else if (p instanceof TreeNode)
// 把节点添加到树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 这时候就是链表结构了,要把待插入元素挂在链尾
for (int binCount = 0; ; ++binCount) {
//向后循环
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表比较长,需要树化,
// 由于初始即为p.next,所以当插入第9个元素才会树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 找到了对应元素,就可以停止了
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 继续向后
p = e;
}
}
// e就是被替换出来的元素,这时候就是修改元素值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 默认为空实现,允许我们修改完成后做一些操作
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// size太大,达到了capacity的0.75,需要扩容
if (++size > threshold)
resize();
// 默认也是空实现,允许我们插入完成后做一些操作
afterNodeInsertion(evict);
return null;
}
以上方法和我们开头看到的文档描述一致,在插入时可能会从链表变成红黑树。里面用到了 TreeNode.putTreeVal 方法向红黑树中插入元素,关于 TreeNode 的方法我们最后分析。除此之外,还有一个树化的方法是 treeifyBin,我们现在看下其原理:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果表是空表,或者长度还不到树化的最小值,就需要重新调整表了
// 这样做是为了防止最初就进行树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
// while循环的目的是把链表的每个节点转为TreeNode
do {
// 根据当前元素,生成一个对应的TreeNode节点
TreeNode<K,V> p = replacementTreeNode(e, null);
//挂在红黑树的尾部,顺序和链表一致
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
// 这里也用到了TreeNode的方法,我们在最后一起分析
// 通过头节点调节TreeNode
// 链表数据的顺序是不符合红黑树的,所以需要调整
hd.treeify(tab);
}
}
无论是在 put 还是 treeify 时,都依赖于 resize,它的重要性不言而喻。它不仅可以调整大小,还能调整树化和反树化(从树变为链表)所带来的影响。我们看看它具体做了哪些工作:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 大小超过了2^30
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 扩容,扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 原来的threshold设置了
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 全部设为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 扩容完成,现在需要进行数据拷贝,从原表复制到新表
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 这是只有一个值的情况
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 重新规划树,如果树的size很小,默认为6,就退化为链表
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 处理链表的数据
// loXXX指的是在原表中出现的位置
Node<K,V> loHead = null, loTail = null;
// hiXXX指的是在原表中不包含的位置
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//这里把hash值与oldCap按位与。
//oldCap是2的次幂,所以除了最高位为1以外其他位都是0
// 和它按位与的结果为0,说明hash比它小,原表有这个位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 挂在原表相应位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 挂在后边
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
删除一个元素
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
和插入一样,其实际的操作在 removeNode 方法中完成,我们看下其实现:
// matchValue是说只有value值相等时候才可以删除,我们是按照key删除的,所以可以忽略它。
// movable是指是否允许移动其他元素,这里是和TreeNode相关的
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 不同情况下获取待删除的node节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
// TreeNode删除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 在队首,直接删除
tab[index] = node.next;
else
// 链表中删除
p.next = node.next;
++modCount;
--size;
// 默认空实现,允许我们删除节点后做些处理
afterNodeRemoval(node);
return node;
}
}
return null;
}
获取一个元素
除了增删之外,重要的就是查询操作了。查询的 get 方法也是通过调用 getNode 方法完成的,我们看下其实现:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
这里逻辑和我们分析的增删很类似,再读起来就很简单了。
TreeNode 方法介绍
在前面分析增删时,可以发现与红黑树相关的操作都是通过 TreeNode 来实现的,下面我们就来看看 TreeNode 的具体实现。
TreeNode 算上其继承来的成员变量,共有11个:
final int hash;
final K key;
V value;
Node<K,V> next;
Entry<K,V> before, after;
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
这么多的变量,说明其功能十分强大。这主要是因为它需要在树和链表之间来回转换。下面按照本文中出现的方法顺序对其函数进行分析。
首先是在添加元素时使用到了 TreeNode.putTreeVal:
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
// 获取到root节点
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {
// dir表示查询方向
int dir, ph; K pk;
// 要插入的位置在树的左侧
// 树化会依据key的hash值
if ((ph = p.hash) > h)
dir = -1;
// 要插入的位置在树的右侧
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p; //找到了,替换即可
// comparableClassFor是如果key实现了Comparable就返回具体类型,否则返回null
// compareComparables是比较传入的key和当前遍历元素的key
// 只有当前hash值与传入的hash值一致才会走到这里
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
TreeNode<K,V> q, ch;
//左右都查过了
searched = true;
// 通过hash和Comparable都找不到,只能从根节点开始遍历
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
// 元素的hashCode一致,且没有实现Comparable,在树里也没有
// tieBreakOrder则是调用System.identityHashCode(Object o)来进行比较,
//它的意思是说不管有没有覆写hashCode,都强制使用Object类的hashCode
// 这样做,是为了保持一致性的插入
dir = tieBreakOrder(k, pk);
}
// 代码执行到这,说明没有找到元素,也就是需要新建并插入了
TreeNode<K,V> xp = p;
// 经历过上述for循环,p已经到某个叶节点了
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// moveRootToFront目的很明确也是必须的。
// 因为这个红黑树需要挂在数组的某个位置,所以其首个元素必须是root
// balanceInsertion是因为插入元素后可能不符合红黑树定义了
// 这部分知识在分析TreeMap中有详细介绍
// 需要了解的话可以查看文末链接
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
除了 putTreeVal 之外,我们还调用过 treeify 以及 removeTreeNode 等方法。这些方法的过程都和 putTreeVal 类似,大家感兴趣可以自己去分析,这里就不再介绍了。
增删图示
上面这些增删的代码都很抽象,即使加了大量的注释,也很难以理解,这里做一个简单的示意图,方便我们理解为何要这么做。这里需要一些红黑树调整的知识,大家可以参考文末关于 TreeMap 的文章链接。
删除和增加类似,我们以增加为例。
起初,我们有一张 table 表,其中插入了一些数据。由于 HashMap 优秀的设计,想要构造出一个需要红黑树的表很难。我们假设插入的数据的 key 在 table 表相同位置的 hash 值都一致,且实现了 Comparable 接口。Comparable 按照 key 的自然顺序比较,图中的数字都表示 key 值。这里数据都是不准确的甚至可能会重复,我们只要理解目的即可。
图中左侧是 hash 算法完成后的 hash 值,中间是插入的内容,有的位置还没有数据,有的位置已经插入了一些数据并变为了链表,并且我们假设 capacity 已经大于 64(64是可以树化的阈值)。如下图所示:
为了完整的演示,现在我们向表中插入一个 hash=6 的值。由于6的位置现在是空的,所以元素会直接放在此处:
我们继续插入一个hash=6的值,此时,6的位置已经存在一个元素,所以新的元素会通过链表的方式链接在18的后边,如下所示:
现在,我们再插入几个hash=6的值,直到达到链表变为红黑树的阈值(默认是8个):
此时,在6的位置上有了8个元素。这时,我们要向其中加入一个9,就需要进行树化,用红黑树代替链表以提升查询性能。
树化时,先获取第一个元素18,将其转为 TreeNode 节点,并设置为 head。然后把后续节点依次转为 TreeNode,并依次挂在 head 之后,他们的 prev 指向前一个元素,next 指向后一个元素。挂完之后类似下图:
转为树节点之后,需要通过head,也就是这里的18,来进一步调整。首先,18就是root节点,颜色设置为黑色。然后比较18与20,它们的 hash 都一样,所以会采用 Comparable 比较。这时20应该在18的右边。然后按照 balanceInsertion 方法此时不需要调整,所以18依然是 root,且依然在 table 表的首位,结果如下:
然后再调整31,31在18的右侧,结果如下:
这时候就破坏了红黑树了,按照在TreeMap中介绍的方法,需要进行调整,这里不再展示过程,而直接展示结果了:
如果仅是一棵红黑树,到此调整就完毕了,但是这棵红黑树需要在 table 表中,所以其根节点必须在首位。我们看到,加入31以后,根节点由18变为了20,所以就需要按照 moveRootToFront 方法将 root 节点提前。这一操作并不会改变树的结构,仅仅是把新的 root 和原来的 root 在 table 表中的位置交换了一下,如下所示:
然后按照这样的规则继续调整剩下的元素,这些步骤和上述类似,最终调整结果如下:
总结
HashMap 是目前分析的最复杂的集合类了。合理的使用它能够在增删改查等方面都有很好的表现。在使用时我们需要注意以下几点:
- 设计的 key 对象一定要实现 hashCode 方法,并尽可能保证均匀少重复。
- 由于树化过程会依次通过 hash 值、比较值和对象的 hash 值进行排序,所以 key 还可以实现 Comparable,以方便树化时进行比较。
- 如果可以预先估计数量级,可以指定 initial capacity,以减少 rehash 的过程。
- 虽然 HashMap 引入了红黑树,但它的使用是很少的,如果大量出现红黑树,说明数据本身设计的不合理,我们应该从数据源寻找优化方案。
31 - CH31-LinkedHashMap
LinkedHashMap 是 HashMap 的子类,所以也具备 HashMap 的诸多特性。不同的是,LinkedHashMap 还维护了一个双向链表,以保证通过 Iterator 遍历时顺序与插入顺序一致。除此之外,它还支持 Access Order,即按照元素被访问的顺序来排序,我们熟知的 LRUCache 底层就依赖于此。以下是文档中需要我们注意的点:
Hash table and linked list implementation of the Map interface, with predictable iteration order. This implementation differs from HashMap in that it maintains a doubly-linked list running through all of its entries. This linked list defines the iteration ordering, which is normally the order in which keys were inserted into the map (insertion-order). Note that insertion order is not affected if a key is re-inserted into the map.
A special LinkedHashMap(int,float,boolean) constructor is provided to create a linked hash map whose order of iteration is the order in which its entries were last accessed, from least-recently accessed to most-recently (access-order). This kind of map is well-suited to building LRU caches.
The removeEldestEntry(Map.Entry) method may be overridden to impose a policy for removing stale mappings automatically when new mappings are added to the map.
Note, however, that the penalty for choosing an excessively high value for initial capacity is less severe for this class than for HashMap, as iteration times for this class are unaffected by capacity.
构造函数与成员变量
成员变量
在分析成员变量前,我们先看下其存储元素的结构。
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
这个 Entry 在 HashMap 中被引用过,主要是为了能让 LinkedHashMap 也支持树化。在这里则是用来存储元素。
// 双向链表的头,用作AccessOrder时也是最老的元素
transient LinkedHashMap.Entry<K,V> head;
// 双向链表的尾,用作AccessOrder时也是最新的元素
transient LinkedHashMap.Entry<K,V> tail;
// true则为访问顺序,false则为插入顺序
final boolean accessOrder;
构造函数
关于 LinkedHashMap 的构造函数我们只关注一个,其他的都和 HashMap 类似,只是把 accessOrder 设置为了 false。在上边的文档说过,initialCapacity 并没有在 HashMap 中那般重要,因为链表不需要像数组那样必须先声明足够的空间。下面这个构造函数是支持访问顺序的。
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
重要方法
LinkedHashMap 并没有再实现一整套增删改查的方法,而是通过复写 HashMap 在此过程中定义的几个方法来实现的。对此不熟悉的可以查看文末关于 HashMap 分析的文章,或者对照 HashMap 的源码来看。
插入一个元素
HashMap 在插入时,调用了 newNode 来新建一个节点,或者是通过 replacementNode 来替换值。在树化时也有两个对应的方法,分别是 newTreeNode 和 replacementTreeNode。完成之后,还调用了 afterNodeInsertion 方法,这个方法允许我们在插入完成后做些事情,默认是空实现。
为了方便分析,我们会对比 HashMap 中的实现与 LinkedHashMap 的实现,来摸清它是如何做的。
// HashMap中的实现
Node<K, V> newNode(int hash, K key, V value, Node<K, V> next) {
return new Node<>(hash, key, value, next);
}
// LinkedHashMap中的实现
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
// HashMap中的实现
Node<K, V> replacementNode(Node<K, V> p, Node<K, V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}
// LinkedHashMap中的实现
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
LinkedHashMap.Entry<K,V> q = (LinkedHashMap.Entry<K,V>)p;
LinkedHashMap.Entry<K,V> t =
new LinkedHashMap.Entry<K,V>(q.hash, q.key, q.value, next);
transferLinks(q, t);
return t;
}
// newTreeNode和replacementTreeNode和此类似
通过以上对比,可以发现,LinkedHashMap 在新增时,调用了 linkNodeLast,再替换时调用了 transferLinks。以下是这两个方法的实现。
// 就是将元素挂在链尾
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
// 用dst替换src
private void transferLinks(LinkedHashMap.Entry<K,V> src,
LinkedHashMap.Entry<K,V> dst) {
LinkedHashMap.Entry<K,V> b = dst.before = src.before;
LinkedHashMap.Entry<K,V> a = dst.after = src.after;
if (b == null)
head = dst;
else
b.after = dst;
if (a == null)
tail = dst;
else
a.before = dst;
}
最后我们看下 afterNodeInsertion 做了哪些事情吧:
// evict在HashMap中说过,为false表示是创建阶段
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 不是创建阶段
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
// 自动删除最老的元素,也就是head元素
removeNode(hash(key), key, null, false, true);
}
}
removeEldestEntry 是当想要在插入元素时自动删除最老的元素时需要复写的方法。其默认实现如下:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
查询
因为要支持访问顺序,所以获取元素的方法和HashMap也有所不同。下面我们看下其实现:
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
// 数据被访问,需要将其移动到末尾
afterNodeAccess(e);
return e.value;
}
getNode 方法是在 HashMap 中实现的,所以这是包装了一下 HashMap 的方法,并添加了一个 afterNodeAccess,其实现如下:
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
// e元素不在末尾
if (accessOrder && (last = tail) != e) {
// p是e,b是前一个元素,a是后一个元素
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// e要放在末尾,所以没有after
p.after = null;
// 把e去掉,把b和a接起来
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
//把e接在末尾
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
关于 LinkedHashMap 的分析就到这里了,其他关于 Iterator 的内容都和 Collection 是大同小异的,感兴趣的可以去查看相关源码。
32 - CH32-EnumMap
EnumMap用于持有key为Enum类型的map,由于枚举类型的特殊性,EnumMap内部直接使用了简单的数组来支持,这使得EnumMap的操作相当高效.
- EnumMap不是线程安全的,并发修改需要用户自己实现线程安全.
- EnumMap没有实现Fail-Fast
EnumMap构造器
EnumMap能够通过指定key类型来初始化一个EnumMap。内部构建了一个数组,用于持有所有的枚举元素:
public EnumMap(Class<K> keyType) {
this.keyType = keyType;
keyUniverse = getKeyUniverse(keyType);
vals = new Object[keyUniverse.length];
}
/**
* Creates an enum map with the same key type as the specified enum
* map, initially containing the same mappings (if any).
*
* @param m the enum map from which to initialize this enum map
* @throws NullPointerException if <tt>m</tt> is null
*/
public EnumMap(EnumMap<K, ? extends V> m) {
keyType = m.keyType;
keyUniverse = m.keyUniverse;
vals = m.vals.clone();
size = m.size;
}
这里的getUniverse()方法可以返回所有的枚举类型值。并且构建数组表示最多可以容纳枚举元素个数那么多的key-value对.
插入,获取,删除修改元素
public V put(K key, V value) {
typeCheck(key);
int index = key.ordinal();
Object oldValue = vals[index];
vals[index] = maskNull(value);
if (oldValue == null)
size++;
return unmaskNull(oldValue);
}
EnumMap的插入,删除都可以i实现的非常高效,直接使用枚举元素的字面值,然后设置对应数组上的值即可.
public V remove(Object key) {
if (!isValidKey(key))
return null;
int index = ((Enum<?>)key).ordinal();
Object oldValue = vals[index];
vals[index] = null;
if (oldValue != null)
size--;
return unmaskNull(oldValue);
}
public V get(Object key) {
return (isValidKey(key) ?
unmaskNull(vals[((Enum<?>)key).ordinal()]) : null);
}
由于底层直接使用数组,因此,插入,删除,获取的时间都是O(1)的.
33 - CH33-HashTable
HashTable是类似于HashMap的容器,但是:
- HashTable是线程安全的,几乎所有的方法都是synchronized,因此,它也是阻塞的,在高并发的情况下应该使用效率更好的容器.
- HashTable与Stack,Vector一样,也支持Fail-Fast.
- HashTable底层类似于hashMap,但没有在冲突过大导致链较长的时候转化为红黑树,因此在元素较多,冲突较大时,效率上没有HashMap高.
- HashTable也不是有序的,既不保证插入的顺序也不能够使用Comparator来保证顺序.
HashTable构造器
与HashMap类似,HashTable也支持指定初始容量,loadFactor,扩容策略基本相同.
public Hashtable(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal Load: "+loadFactor);
if (initialCapacity==0)
initialCapacity = 1;
this.loadFactor = loadFactor;
table = new Entry<?,?>[initialCapacity];
threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}
/**
* Constructs a new, empty hashtable with the specified initial capacity
* and default load factor (0.75).
*
* @param initialCapacity the initial capacity of the hashtable.
* @exception IllegalArgumentException if the initial capacity is less
* than zero.
*/
public Hashtable(int initialCapacity) {
this(initialCapacity, 0.75f);
}
/**
* Constructs a new, empty hashtable with a default initial capacity (11)
* and load factor (0.75).
*/
public Hashtable() {
this(11, 0.75f);
}
HashTable每次在添加元素以后,会判断其目前已经添加的元素数量是否达到了一个阈值threshold,是的话就会扩容,然后rehash:
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
// overflow-conscious code
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
// Keep running with MAX_ARRAY_SIZE buckets
return;
newCapacity = MAX_ARRAY_SIZE;
}
Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
modCount++;
threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
table = newMap;
for (int i = oldCapacity ; i-- > 0 ;) {
for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
Entry<K,V> e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (Entry<K,V>)newMap[index];
newMap[index] = e;
}
}
rehash是一个相当耗时的工作O(n),它涉及到重新分配数组,为原数组中所有元素计算hash值.
插入删除,获取元素
HashTable支持线程安全的插入,删除,获取元素,由于基于hashtable,在hash比较均匀的情况下,效率很高:
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
34 - CH34-Set
因为 Set 的结构及实现都和 Map 保持高度一致,这里将不再对其进行分析了,感兴趣的朋友可以自行查看源码。但我们还是需要知道什么是 Set,Set 是一个包含不可重元素的集合,也就是所有的元素都是唯一的。还是看下文档说明吧:
A collection that contains no duplicate elements. More formally, sets contain no pair of elements e1 and e2 such that e1.equals(e2), and at most one null element. As implied by its name, this interface models the mathematical set abstraction.
此外 Set 系列也有 SortedSet、NavigableSet 这种基于排序的接口,它们的作用在分析 Map 时都已经详细介绍过了。
35 - CH35-EnumSet
EnumSet用于持有Enum类型的值,由于底层使用位数组来支持,因此非常高效:
- EnumSet不是一个有序的集合,他的遍历也不是按照元素添加的循序.
- EnumSet不是线程安全的
- EnumSet也不支持Fail-Fast
EnumSet非常适用于需要持有很多枚举元素的容器,并且支持快速的添加,删除,判断操作。
EnumSet是一个抽象类,没有提供构造器,创建一个EnumSet类似于工厂方法可以这样调用:
public static <E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType) {
EnumSet<E> result = noneOf(elementType);
result.addAll();
return result;
}
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
Enum<?>[] universe = getUniverse(elementType);
if (universe == null)
throw new ClassCastException(elementType + " not an enum");
if (universe.length <= 64)
return new RegularEnumSet<>(elementType, universe);
else
return new JumboEnumSet<>(elementType, universe);
}
private static <E extends Enum<E>> E[] getUniverse(Class<E> elementType) {
return SharedSecrets.getJavaLangAccess()
.getEnumConstantsShared(elementType);
}
让我们首先感觉有点惊奇的是: allOf只是指明了类的类型,然后用这个类型获取了enum类型所有的值,底层使用的是sun.misc.SharedSecrets这个类,这个类提供了一些很有趣的方法。
RegularEnumSet和JumboEnumSet就是EnumSet这个类的具体实现。EnumSet也提供了从有限的元素中初始化:
public static <E extends Enum<E>> EnumSet<E> of(E first, E… rest) {
EnumSet<E> result = noneOf(first.getDeclaringClass());
result.add(first);
for (E e : rest)
result.add(e);
return result;
}
EnumSet也支持add等方法,添加枚举元素到集合中.
实现原理
EnumSet非常高效,内部使用了bit来存储Enum元素对应的整形值. java的枚举类型的每个元素除了一个关联的名字以外,通常还有一个整形值,对应这个元素在枚举类型的所有元素组成的数组的下标,从0开始.
EnumSet内部保存的不是对应的元素,而是枚举元素对应的下标值。
EnumSet内部初始化时,具体的实现分为RegularEnumSet和JumboEnumSet, RegularEnumSet适合于元素个数小于等于64的枚举类型,其内部使用了一个long来保存所有的元素.
RegularEnumSet
RegularEnumSet构造器常常以枚举类型和枚举类型的所有值来初始化:
RegularEnumSet(Class<E>elementType, Enum<?>[] universe) {
super(elementType, universe);
}
当add一个元素的时候:
public boolean add(E e) {
typeCheck(e);
long oldElements = elements;
elements |= (1L << ((Enum<?>)e).ordinal());
return elements != oldElements;
}
这里的elements是long类型,初始值为0. 用于保存所有已添加的元素对应的下标。这里通过一个简单的位操作,将元素下标,在elements对应的位置置1:
当清空所有元素的时候,直接设置elements为0即可
public void clear() {
elements = 0;
}
当判断是否存在某个元素的时候,可以直接判断对应的位置是否为1,即可判断是否存在:
public boolean remove(Object e) {
if (e == null)
return false;
Class<?> eClass = e.getClass();
if (eClass != elementType && eClass.getSuperclass() != elementType)
return false;
long oldElements = elements;
elements &= ~(1L << ((Enum<?>)e).ordinal());
return elements != oldElements;
}
也是只需要简单的位操作即可。
RegularEnumSet的遍历也相当简单:
public E next() {
if (unseen == 0)
throw new NoSuchElementException();
lastReturned = unseen & -unseen;
unseen -= lastReturned;
return (E) universe[Long.numberOfTrailingZeros(lastReturned)];
}
RegularEnumSet对应的Iterator只需要每次获得对应的1位,从而获得对应的元素.
JumboEnumSet
当枚举类型元素的数量大于64的时候,就会创建一个JumboEnumSet. JumboEnumSet内部使用了long的数组来保留所有的元素,(很难想象一个枚举类型元素个数超过64个的场景):
JumboEnumSet(Class<E>elementType, Enum<?>[] universe) {
super(elementType, universe);
elements = new long[(universe.length + 63) >>> 6];
}
long数组可以理解为一个桶,每个桶里可以放置64个元素。由于元素个数很大,就分别放在不同的桶里。桶的数量=(元素个数 + 63) / 64,加63是为了保证有足够的桶.
每当添加一个元素的时候,首先计算桶的位置,然后使用和RegularEnumSet相同的方法来存放:
public boolean add(E e) {
typeCheck(e);
int eOrdinal = e.ordinal();
int eWordNum = eOrdinal >>> 6;
long oldElements = elements[eWordNum];
elements[eWordNum] |= (1L << eOrdinal);
boolean result = (elements[eWordNum] != oldElements);
if (result)
size++;
return result;
}
36 - CH36-HashSet
HashSet底层依据HashMap来实现了基于Hash的集合。HashSet基本上是HashMap的一层封装.
- HashSet并不是线程安全的,HashSet支持集合的特性,每个元素会作为HashMap的键来保存,从而排斥重复。HashSet提供了集合基本的插入,删除,遍历等方法。
- HashSet也支持Fail-Fast.
HashSet由于基于HashMap,因此它的初始化和HashMap类似,支持指定初始容量和loadFactor。
HashSet初始化
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
/**
* Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
* the specified initial capacity and default load factor (0.75).
*
* @param initialCapacity the initial capacity of the hash table
* @throws IllegalArgumentException if the initial capacity is less
* than zero
*/
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
HashSet基于HashMap,因此,可以非常方便的实现,由于集合元素的特性,它得不可重复性在大多数情况下保证了key不会冲突。因此,你可以认为它得时间复杂度是O(1)的。
插入删除元素
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
37 - CH37-LinkedHashSet
LinkedHashSet继承了HashSet,奇怪的是HashSet暴露了一个包可见的构造器,基于LinkedHashMap构造了支持插入顺序的集合:
- LinkedHashSet不是线程安全的,它的并发修改会导致Fail-Fast.
- LinkedHashSet基于LinkedHashMap,而且由于集合元素的不可重复性,几乎可以认为它的插入操作,判断是否包含某一个元素和删除某一个元素的操作是O(1)的.
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
public LinkedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
}
/**
* Constructs a new, empty linked hash set with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity of the LinkedHashSet
* @throws IllegalArgumentException if the initial capacity is less
* than zero
*/
public LinkedHashSet(int initialCapacity) {
super(initialCapacity, .75f, true);
}
/**
* Constructs a new, empty linked hash set with the default initial
* capacity (16) and load factor (0.75).
*/
public LinkedHashSet() {
super(16, .75f, true);
}
}
HashSet奇怪的构造器:
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
由于LinkedHashMap在HashMap之上通过维护一个支持插入顺序的链表来实现了对插入顺序的支持,而且几乎没有性能损失. LinkedHashSet在LinkedHashMap之上也提供了支持集合的,支持顺序的set。它的效率也非常高,由于集合元素的不可重复性,一般情况下几乎可以认为它的操作都是O(1).
38 - CH38-TreeSet
TreeSet是一个有序的集合,它支持特定的排序方法。TreeSet底层基于TreeMap实现,在它支持集合基本的方法外,他还保证了插入的顺序,而由于这种顺序的特性,它甚至提供了一种类似于双端队列的接口方法,支持从头部尾部取出元素,返回某个返回的子集合.
- TreeSet不是线程安全的,它的并发修改会导致Fail-Fast
- TreeSet底层基于TreeMap,这种红黑树的结构保证了插入,删除,获取某个元素的复杂度都为0(log n).
TreeSet构造器
与TreeMap类似,TreeSet的构造器支持提供一个比较器Comparator,如果没有,相应的元素应该实现Comparator接口:
public TreeSet() {
this(new TreeMap<E,Object>());
}
/**
* Constructs a new, empty tree set, sorted according to the specified
* comparator. All elements inserted into the set must be <i>mutually
* comparable</i> by the specified comparator: {@code comparator.compare(e1,
* e2)} must not throw a {@code ClassCastException} for any elements
* {@code e1} and {@code e2} in the set. If the user attempts to add
* an element to the set that violates this constraint, the
* {@code add} call will throw a {@code ClassCastException}.
*
* @param comparator the comparator that will be used to order this set.
* If {@code null}, the {@linkplain Comparable natural
* ordering} of the elements will be used.
*/
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
TreeSet与TreeMap一样,在支持add等操作之外,还支持返回集合的某个子集,支持返回头部尾部元素,而这正是红黑树的好处:
查询方法
public E first() {
return m.firstKey();
}
/**
* @throws NoSuchElementException {@inheritDoc}
*/
public E last() {
return m.lastKey();
}
public E pollFirst() {
Map.Entry<E,?> e = m.pollFirstEntry();
return (e == null) ? null : e.getKey();
}
/**
* @since 1.6
*/
public E pollLast() {
Map.Entry<E,?> e = m.pollLastEntry();
return (e == null) ? null : e.getKey();
}
39 - CH39-FailFast
细心地朋友看Java容器源码时一定会发现在list()和listIterator()的注释中都有一句话:
The iterators returned by this class’s iterator and listIterator methods are fail-fast.
我看ArrayList源码没认真想fail-fast是什么意思,看Vector源码时又看到了这个词,而且在翻看Set实现类和Map实现类源码时也看到了这个词。fail-fast是什么?本篇文章以Vector为例来详细解说fail-fast。
什么是fail-fast
下面是Vector中源码的最上部的注释中关于fail-fast的介绍:
The iterators returned by this class's {@link #iterator() iterator} and
* {@link #listIterator(int) listIterator} methods are <em>fail-fast</em></a>:
* if the vector is structurally modified at any time after the iterator is
* created, in any way except through the iterator's own
* {@link ListIterator#remove() remove} or
* {@link ListIterator#add(Object) add} methods, the iterator will throw a
* {@link ConcurrentModificationException}. Thus, in the face of
* concurrent modification, the iterator fails quickly and cleanly, rather
* than risking arbitrary, non-deterministic behavior at an undetermined
* time in the future. The {@link Enumeration Enumerations} returned by
* the {@link #elements() elements} method are <em>not</em> fail-fast.
*
* <p>Note that the fail-fast behavior of an iterator cannot be guaranteed
* as it is, generally speaking, impossible to make any hard guarantees in the
* presence of unsynchronized concurrent modification. Fail-fast iterators
* throw {@code ConcurrentModificationException} on a best-effort basis.
* Therefore, it would be wrong to write a program that depended on this
* exception for its correctness: <i>the fail-fast behavior of iterators
* should be used only to detect bugs.</i>
由iterator()和listIterator()返回的迭代器是fail-fast的。在于程序在对list进行迭代时,某个线程对该collection在结构上对其做了修改,这时迭代器就会抛出ConcurrentModificationException异常信息。因此,面对并发的修改,迭代器快速而干净利落地失败,而不是在不确定的情况下冒险。由elements()返回的Enumerations不是fail-fast的。需要注意的是,迭代器的fail-fast并不能得到保证,它不能够保证一定出现该错误。一般来说,fail-fast会尽最大努力抛出ConcurrentModificationException异常。因此,为提高此类操作的正确性而编写一个依赖于此异常的程序是错误的做法,正确做法是:ConcurrentModificationException 应该仅用于检测 bug。
大意为在遍历一个集合时,当集合结构被修改,很有可能 会抛出Concurrent Modification Exception。为什么说是很有可能呢?从下文中我们可以知道,迭代器的remove操作(注意是迭代器的remove方法而不是集合的remove方法)修改集合结构就不会导致这个异常。
看到这里我们就明白了,fail-fast 机制是java容器(Collection和Map都存在fail-fast机制)中的一种错误机制。在遍历一个容器对象时,当容器结构被修改,很有可能会抛出ConcurrentModificationException,产生fail-fast。
什么时候会出现fail-fast?
在以下两种情况下会导致fail-fast,抛出ConcurrentModificationException
- 单线程环境:遍历一个集合过程中,集合结构被修改。注意,listIterator.remove()方法修改集合结构不会抛出这个异常。
- 多线程环境:当一个线程遍历集合过程中,而另一个线程对集合结构进行了修改。
单线程环境例子:
import java.util.ListIterator;
import java.util.Vector;
public class Test {
/**
* 单线程测试
*/
@org.junit.Test
public void test() {
try {
// 测试迭代器的remove方法修改集合结构会不会触发checkForComodification异常
ItrRemoveTest();
System.out.println("----分割线----");
// 测试集合的remove方法修改集合结构会不会触发checkForComodification异常
ListRemoveTest();
} catch (Exception e) {
e.printStackTrace();
}
}
// 测试迭代器的remove方法修改集合结构会不会触发checkForComodification异常
private void ItrRemoveTest() {
Vector list = new Vector<>();
list.add("1");
list.add("2");
list.add("3");
ListIterator itr = list.listIterator();
while (itr.hasNext()) {
System.out.println(itr.next());
//迭代器的remove方法修改集合结构
itr.remove();
}
}
// 测试集合的remove方法修改集合结构会不会触发checkForComodification异常
private void ListRemoveTest() {
Vector list = new Vector<>();
list.add("1");
list.add("2");
list.add("3");
ListIterator itr = list.listIterator();
while (itr.hasNext()) {
System.out.println(itr.next());
//集合的remove方法修改集合结构
list.remove("3");
}
}
}
从结果中可以看到迭代器itr的remove操作并没有出现ConcurrentModificationException异常。而集合的remove操作则产生了异常。
多线程环境例子:
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Vector;
public class Test {
private static List<String> list = new Vector<String>();
/**
* 多线程情况测试
*/
@org.junit.Test
public void test2() {
list.add("1");
list.add("2");
list.add("3");
// 同时启动两个线程对list进行操作!
new ErgodicThread().start();
new ModifyThread().start();
}
/**
* 遍历集合的线程
*/
private static class ErgodicThread extends Thread {
public void run() {
int i = 0;
while (i < 10) {
printAll();
i++;
}
}
}
/**
* 修改集合的线程
*/
private static class ModifyThread extends Thread {
public void run() {
list.add(String.valueOf("5"));
}
}
/**
* 遍历集合
*/
private static void printAll() {
Iterator iter = list.iterator();
while (iter.hasNext()) {
System.out.print((String) iter.next() + ", ");
}
System.out.println();
}
}
从结果中可以看出当一个线程遍历集合,而另一个线程对这个集合的结构进行了修改,确实有可能触发ConcurrentModificationException异常。
fail-fast实现原理
下面是Vector中迭代器Itr的部分源码:
/**
* An optimized version of AbstractList.Itr
*/
private class Itr implements Iterator<E> {
int expectedModCount = modCount;
//省略的部分代码
public void remove() {
if (lastRet == -1)
throw new IllegalStateException();
synchronized (Vector.this) {
checkForComodification();
Vector.this.remove(lastRet);
expectedModCount = modCount;
}
cursor = lastRet;
lastRet = -1;
}
@Override
public void forEachRemaining(Consumer<? super E> action) {
//省略的部分代码
checkForComodification();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
从代码中可以看到,每次初始化一个迭代器都会执行int expectedModCount = modCount;。modcount意为moderate count,即修改次数,对集合内容的修改都将增大这个值,如modCount++;。在迭代器初始化过程中会执行int expectedModCount = modCount;来记录迭会通过checkForComodification()方法判断modCount和expectedModCount 是否相等,如果不相等就表示已经有线程修改了集合结构。
使用迭代器的remove()方法修改集合结构不会触发ConcurrentModificationException,现在可以在源码中看出来是为什么。在remove()方法的最后会执行expectedModCount = modCount;,这样itr.remove操作后modCount和expectedModCount依然相等,就不会触发ConcurrentModificationException了。
如何避免fail-fast?
使用java.util.concurrent包下的类去取代java.util包下的类。所以,本例中只需要将Vector替换成java.util.concurrent包下对应的类即可。