java集合框架体系
数据结构
算法复杂度分析
- 时间复杂度分析:对代码运行时间所消耗时间多少进行分析
- 空间复杂度分析:对代码运行所占用的内存的大小进行分析
时间复杂度
时间复杂度分析:来评估代码的执行耗时
-
假如执行每行代码的执行耗时一样:1ms
-
分析这段代码总执行多少行?
3n + 3
-
代码耗时总时间:T(n)=(3n + 3) * 1ms
-
大O表示法:不具体表示代码真正的执行时间,而表示代码执行时间随数据规模增长的变化趋势
-
T(n)与代码的执行次数成正比(代码的行数越多,执行时间越长)
T(n) = O(3n + 3) —》 T(n)= O(n)
-
当n很大时,公式中的低阶,常量,系数三部分并不左右其增长的趋势,因此可以忽略,我们只需要记录一个最大的量级就可以了
常见的复杂度表现形式
一般高于n的二次方或三次方就说明复杂度是相对较高的了,需要优化
总结:只要代码的执行时间不随着n的增大而增大,这样的复杂度就是O(1)
第一个因为int类型默认是占用4个字节
因此不管怎么运算都是4个字节,所以复杂度为O(1)
第二个因为int[] a 的长度需要n来决定,所以时间复杂度为O(n)
我们常见的空间复杂度就是O(1),O(n),O(n^2),其他的像对数阶复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单的多
什么是算法时间复杂度?
时间复杂度表示了算法的执行时间与数据规模之间的增长关系
常见的时间复杂度有哪些
O(1),O(n),O(n^2),O(log n)
速记口诀:常对幂指阶
什么是算法的空间复杂度?
表示算法占用的额外存储空间和数据规模之间的增长关系
常见的空间复杂度:O(1),O(n),O(n ^2)
List集合
List集合是基于数据去实现的想要了解List集合,就应该先了解数组
数组
数组(Array)是一种连续的内存空间存储相同的数据类型数据和线性数据结构。
数组是如何获取其他元素的值的呢?
采用寻址公式:
- baseAddress:数组的首地址
- dataTypeSize:代表数组中元素类型的大小,int类型,dataTypeSize=4个字节
为什么数组索引从0开始呢?假如从1开始不行吗?
如果从1开始的话根据寻址公式那么索引i就必须进行减1的操作,因此索引从0开始效率更高
-
在根据数组索引获取元素的时候,会用索引和寻址公式来计算内存中所对应的元素数据,寻址公式是:数组的首地址+索引乘以存储数据的类型的大小
-
如果数组的索引从1开始,寻址公式中,就需要增加一次减法操作,对于CPU来说就多了一次指令,性能不高。
操作数组的时间复杂度(查询)
- 随机查询(根据索引查询)
数组元素的访问是通过索引来访问的,计算机通过对数组的首地址和寻址公式能够很快速的找到想要访问的元素
2. 未知索引查询
情况二:查找排序后数组内的元素,查找55号数据
不排序的情况下:复杂度O(n)
如果进行排序之后使用二分查找法,将数据分成两部分进行比较查询:
时间复杂度O(log n)
操作数组的时间复杂度(插入和删除)
数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变得很低。
因为在插入一个元素的时候,后面的元素需要整体向后移动
而在删除一个元素的时候,后面的元素需要整体前向移动
最好的情况下是O(1),最坏的情况下O(n),平均情况下的时间复杂度是O(n)
-
数组(Array)是一种用连续的内存空间存储相同的数据类型数据的线性数据结构。
-
数组的下标为什么从0开始
寻址公式:baseAddress+i*dataTypeSize,计算下标的存储地址效率较高
-
查询的时间复杂度
- 随机(通过下标)查询的时间复杂度是O(1)
- 查找元素(未知下标)的时间复杂度是O(n)
- 查找元素(未知下标但排序)通过二分查找法的时间复杂度是O(log n)
-
插入和删除时间复杂度
插入和删除的时候,为了保证数组的内存连续性,需要挪动数组的元素,平均时间复杂度是O(n)
ArrayList源码分析
源码分析:
List<Integer> list = new ArrayList<Integer>();
list.add(1);
主要从第三个方面进行分析:
分析对象:jdk1.8
成员变量:
构造方法
将collection对象转化为数组,然后将数组的地址赋值给elementData。
第一次添加数据
因为没有超过默认的数组长度,因此默认不扩容
第十一次添加
ArrayList底层原理
- ArrayList底层是用动态的数组实现的
- ArrayList初始化容量为0,当第一次添加数据的时候才会初始化容量为10
- ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
- ArrayList在添加数据的时候
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
- 返回添加成功的布尔值
ArrayList list = new ArrayList(10)中的list扩容几次
参考回答:
该语句只是声明和实例了一个ArrayList,指定了容量为10,未扩容
如何实现数组和List之间的转化
如何实现数组和List之间的转化呢?
// 数组转集合
public static void testArrayToList() {
String[] strs = {"aaa", "bbb", "ccc"};
List<String> list = Arrays.asList(strs);
for (String s : list) {
System.out.println(s);
}
}
// 集合转数组
public static void testListToArray() {
List<String> list = new ArrayList<>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for (String s : array) {
System.out.println(s);
}
}
参考回答:
- 数组转List,使用JDK中java.util.Arrays工具类的asList方法
- List转数组,使用List的toArray方法,无参toArray方法返回Object数组,传入初始化长度的数组对象,返回该对象数组
面试官再问:
- 用Arrays.asList() 转list后,如果修改了数组内容,list受影响吗?
- List用toArray()转数组后,如果修改了List内容,数组受影响吗?
// 数组转集合
public static void testArrayToList2() {
String[] strs = {"aaa", "bbb", "ccc"};
List<String> list = Arrays.asList(strs);
for (String s : list) {
System.out.println(s);
}
strs[1] = "ddd";
System.out.println("===========================");
for (String s : list) {
System.out.println(s);
}
}
// 集合转数组
public static void testListToArray2() {
List<String> list = new ArrayList<>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for (String s : array) {
System.out.println(s);
}
list.add("ddd");
System.out.println("===========================");
for (String s : array) {
System.out.println(s);
}
}
用Arrays.asList() 转list后,如果修改了数组内容,list受影响吗?受影响
因为asList中没有去创建数组,因此最后改变该是基于原来的数组,因此会受到影响
List用toArray()转数组后,如果修改了List内容,数组受影响吗?不受影响
toArray方法会重新创建一个新的数组,所以不受影响
再答:
- Arrays.asList转化list之后,如果修改了数组的内容,list会受影响,因为他的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
- list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层它是进行了数组的拷贝,跟原来的元素没啥关系了,所以即时list修改了之后,数组也不会受影响
LinkedList数据结构-链表
单项链表
- 链表中的每个元素称之为节点(Node)
- 物理存储单元上,非连续,非顺序的存储结构
- 单项链表:每个节点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个节点的指针。记录下一个节点地址的指针叫做后继指针next
链表中的某个节点为B,B的下一个节点为C
表示B.next == C
查询操作
- 只有在查询头结点的时候不需要遍历链表,时间复杂度为O(1)
- 查询其他节点需要遍历链表,时间复杂度为O(n)
插入或删除操作
* 只有在添加和删除头结点的时候不需要遍历链表,时间复杂度是O(1)
- 添加或删除其他节点需要遍历链表找到对应的节点后,才能完成新增和删除节点,时间复杂度是O(n)
双向链表
而双向链表,顾名思义,它支持两个方向
-
每个结点不止有一个后继指针next指向后面的节点
-
有一个前驱指针prev指向前面的结点
对比单项链表: -
双向链表需要额外的两个空间来缓存后继结点和前驱结点的地址
-
支持双向遍历,这样也带来了双向链表操作的灵活性
查询操作
查询头尾结点的时间复杂度是O(1)
平均查询时间复杂度是O(n)
给定节点找前驱结点的时间复杂度为O(1)
添加和删除操作
头尾结点新增的时间复杂度为O(1)
其他部分节点增删的时间复杂度是O(n)
给定节点增删的时间复杂度为O(1)
-
单项链表和双向链表的区别是什么?
- 单向链表只有一个方向,结点只有一个后继指针next
- 双向链表它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点
-
链表操作数据的时间复杂度是多少?
链表 查询 新增删除 单项链表 头O(1),其他O(n) 头O(1),其他O(n) 双线链表 头尾O(1),其他O(n),给定其他节点O(1) 头尾O(1),其他O(n),给定其他节点O(1)
ArrayList和LinkedList的区别
底层数据结构
- ArrayList 是动态数组的数据结构实现的
- LinkedList 是双向链表的数据结构实现的
-
操作数据效率
- ArrayList按照下标查询的时间复杂度是O(1)【内存是连续的,根据寻址公式】,LinkedList不支持下标查询
- 查找(未知索引):ArrayList需要遍历,链表也需要索引,时间复杂度都是O(n)
- 新增和删除
- ArrayList尾部插入和删除,时间复杂度是O(1);其他部分新增需要挪动数组,时间复杂度是O(n)
- LinkedList头尾结点新增时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
-
内存空间
- ArrayList底层是数组,内存连续,节省内存
- LinkedList是双向链表需要存储数据,和两个指针,更加占用内存
线程安全
- ArrayList和LinkedList都不是线程安全的
- 如果需要保证线程的安全,有两种解决方案:
- 在方法内使用,局部变量则是线程安全的
- 使用线程安全的ArrayList和LinkedList
当时相对应的性能也会下降
ArrayList和LinkedList的区别是什么?
- 底层数据结构
- 效率
- 空间
- 线程是否安全
Map集合
- 数据结构
- 二叉树
- 红黑树
- 散列表
- 面试问题
- HashMap实现原理
- HashMap的put方法的具体流程
- hashMap的寻址算法
- 讲一讲HashMap的扩容机制
- 为何HashMap的数组长度一定是2的次幂
- hashMap在1.7情况下的多线程死循环的问题
- HashSet与HashMap的区别
- HashTable与HashMap的区别
二叉树
二叉树:
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子结点和右子节点,不过,二叉树并不要求每个节点都有两个子节点,有的节点只有一个左子结点,有的节点只有一个右子节点。
二叉树的每个节点的左子结点和右子节点也分别满足二叉树的定义
java中有两种方式实现二叉树:数组存储,链式存储。
基本链式存储的数节点可定义如下:
二叉树分类
在二叉树中,比较常见的二叉树有:
- 满二叉树
- 完全二叉树
- 二叉搜索树
- 红黑树
二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
二叉搜索树-时间复杂度分析
实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我画了几棵树,我们来看一下插入,查找,删除的时间复杂度
插入,查找和删除的时间复杂度是O(log n)
但是二叉树可能会退化成链表,那么时间复杂度就会变成O(n)
- 什么是二叉树
- 每个节点最多有两个"叉",分别是左子结点和右子节点
- 不要求每个节点都有两个子节点,有的节点只有左子结点,有的节点只有右子节点
- 二叉树每个节点的左子树和右子树也分别满足二叉树的定义
- 什么是二叉搜索树
- 二叉搜索树(Binary Search Tree,BST)又名二叉查找树。有序二叉树
- 树中的任一节点,其左子树的每个节点的值,都要小于这个节点的值而右子树的值都大于这个节点的值
- 没有键值相等的节点
- 通常情况下二叉树搜索的时间复杂度是O(log n)
红黑树
**红黑树(Red Black Tree):**也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)
红黑树的性质
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同树木的黑色节点
在添加和删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质
红黑树的一大原则就是保证平衡
红黑树的复杂度
- 查找:
- 空黑树也是一种BST(二叉搜索树)树,查找操作的时间复杂度是:O(log n)
- 添加:
- 添加先要从根节点开始找到元素添加的位置,时间复杂度是O(log n)
- 添加完成后涉及到复杂度为O(1)的旋转调整操作
- 故整体的复杂度为:O(log n)
- 删除:
- 首先从根节点开始找到被删除的元素,时间复杂度是O(log n)
- 删除完整后涉及到复杂度为O(1)的旋转调整操作
- 故而整体的复杂度为O(log n)
什么是红黑树?
- 红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST)
- 所有的红黑树规则都是希望红黑树能够保证平衡
- 红黑树的时间复杂度:查找,添加,删除都是O(log n)
散列表
散列表(Hash Table)
散列表(Hash Table)又名哈希表/Hash表,是根据键(key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性
下面举一个例子:
现在换成Hash的方式进行存储
使用hash方法对key进行Hash然后将结果存入到链表中
将键(key)映射为数组下标的函数叫做散列寒素,可以表示为:hashValue = hash(key)
散列表的基本要求:
- 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
- 如果key1 == key2,那么经过hash后得到的哈希值也未必相同:hash(key1) == hash(key2)
- 如果key1 != key2,那么经过hash后得到的哈希值也必须不相同:hash(key1) != hash(key2)
散列冲突
实际情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这种情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
上图表示当两个key分别进行hash运算之后它计算出来的值是相同的,那么就会出现冲突的问题,简称:哈希冲突或者哈希碰撞
散列冲突解决方式-链表法(拉链法)
在散列表中,数组的每一个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放在相同的槽位对应的链表中。
如果两个key分别hash之后得到桶给一个值,那么就在这个值下标所对应的数组中使用链表的方式去存储数据,这样就解决了哈希冲突的问题。
散列冲突-链表法(拉链)-时间复杂度
- 插入操作,通过散列函数计算处对应的散列槽位,将其插入对应的链表中即可,插入的时间复杂度是O(1)
当查找和删除一个元素的时候,我们同样通过散列函数计算处对应的槽,然后遍历链表查找或者删除
-
平均情况下基于链表解决冲突时查询的时间复杂度是O(1)
-
但散列链表中链接的数据过长的时候,退化成链表的时候,查询的时间复杂度就从O(1)退化成O(n)
- 针对这种情况呢,我们可以对链表进行改造成其他的高效的动态数据结构,比如红黑树,查询的时间复杂度就是O(log n)
将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
DDos攻击:
分布式拒绝服务攻击(英文意思:Distributed Denial of Service,简称DDos)
指处于不用位置的多个攻击同时向一个或数个目标发动的攻击,或者一个攻击控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击,由于攻击的发出点分布在不同的地方,这类攻击成为分布式拒绝服务攻击,其中的攻击者可以是多个。
什么是散列表?
- 散列表(Hash Table)又名哈希表/Hash表
- 根据(key)直接访问在内存存储位置值(Value)的数据结构
- 由数组演化而来的,利用了数组支持按照下标进行随机访问数据
散列冲突
- 散列冲突又称哈希冲突,哈希碰撞
- 指多个key映射到同一个数组下标位置
散列冲突-链表法(拉链)
- 数组的每个下标位置称为桶(bucket)或者槽(slot)
- hash冲突后的元素都放在相同槽位对应的链表或红黑树中
HashMap的实现原理
HashMap的数据结构:底层是使用了hash表数据结构,即数组和链表或红黑树
- 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
- 存储时,如果出现hash值相同的key,此时有两种情况,
- 如果key相同,则覆盖原始的值
- 如果key不同(出现了冲突),则将当前的key-value放入到链表或红黑树中
- 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应的值。
追问:jdk1.7和jdk1.8有什么区别?
HashMap的jdk1.7和1.8有什么区别
- JDK1.8之前采用的是拉链法。拉链法:将链表和数组相组合,也就是创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将哈希冲突的值加到链表中即可。
- JDK1.8在解决哈希冲突时有了较大的变化,当链表的长度大于阈值(默认8)时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容resize()时,红黑树拆分成的节点数小于等于临界值6个,则退化成链表。
说一下HashMap的实现原理
- 底层使用hash表数据结构,即数组+(链表|红黑树)
- 添加数据时,计算key的值确定元素在数组中的下标
- key相同则替换
- 不同则存入链表或红黑树中
- 获取数据通过key的hash计算数组下标获取元素
HashMap的jdk1.7和jdk1.8的区别
- jdk1.8之前采用的拉链法,数组+链表
- jdk1.8之后采用的数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树
HashMap的put方法
HashMap源码分析-常见的属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 2; // aka 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient HashMap.Node<K,V>[] table;
transient int size;
- DEFAULT_INITIAL_CAPACITY 默认的初始容量
- DEFAULT_LOAD_FACTOR 默认负载因子
扩容阈值 == 数组容量 * 加载因子
Node实体类构成:
HashMap的源码分析
- HashMap是懒加载,在创建对象时并没有初始化数组
- 在无参构造函数中,设置了默认的加载因子是0.75
HashMap的put方法的具体流程
- 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
- 根据键值key计算hash值得到数组索引
- 判断table[i] == null,条件成立,直接新建节点添加
- 如果table[i] == null,不成立
- 判断table[i]的首个元素是否和key一样,相同就直接覆盖value
- 判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对
- 遍历table[i],在链表的尾部插入数据,然后判断链表的长度是否大于8,大于8的话把链表转化成红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在直接覆盖value
HashMap扩容
讲一讲HashMap的扩容流程
- 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次扩容都是达到了扩容的阈值(数组长度 * 0.75)
- 每次扩容的时候,都是扩容之前容量的2倍
- 扩容之后,会创建一个新的数组,需要把老数组中的数据源挪动到新的数组中
- 没有hash冲突的节点,则直接使用e.hash & (newCap - 1) 计算新数组的索引位置
- 如果有红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
HashMap的寻址算法
进行二次hash的原因:
因为一次hash之后很多值为存在固定的下标之上,会造成hash存储不均匀,因此使用二次hash的方式,对原来的hash值进行位移的运算,使得数组中的数据存储的更加的均匀
这个也被称为扰动算法,使得hash值更加均匀,减少hash冲突
(n-1)&hash:得到数组中的索引,代替取模,性能更好,
数组的长度必须是2的n次幂
为何HashMap的数组长度一定是2的次幂?
- 计算索引时效率更高:如果是2的n次幂可以使用位于运算代替取模
- 扩容时重新计算索引效率更高:hash & oldCap == 0 的元素留在原来的位置,否则新的位置 = 旧位置 + oldCap
HashMap的寻址算法
- 计算对象的hashCode()
- 再进行调用hash()方法进行二次哈希,hashcode值右移16位再异或运算,让哈希分布更加均匀
- 最后(capacity - 1)& hash 得到索引
为何HashMap的数组长度一定是2的次幂?
- 计算索引时效率更高:如果是2的n次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高:hash & oldCap == 0 的元素留在原来的位置,否则新的位置 = 旧位置 + oldCap
HashMap在jdk1.7情况下多线程死循环问题
jdk1.7的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
-
变量e指向的是需要迁移的对象
-
变量next指向的是下一个需要迁移的对象
-
jdk1.7中的链表采用的头插法
-
在数据迁移的过程中并没有新的对象产生,只是改变了对象的引用
死循环问题的复现:
线程2扩容后,由于头插法,链表顺序颠倒,但是线程1的临时变量e和next还引用了这两个节点
因为线程2使用的是头插法,索引在迁移之后原来的A B 会变成B A
由于线程2迁移的时候已经把B的next指向了A
随后线程1 进行操作,然后嫌迁移A 迁移A之后将next中的B赋值给e
这是就会产生一个问题 A指向B,B也指向了A
总结:
HashMap在jdk1.7情况下多线程死循环问题
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能会导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashMap数据,数据中的一个链表,在准备扩容时,线程二介入
线程二:也读取了hashmap,直接进行扩容,因为是头插法,链表的顺序会颠倒过来,比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束
线程一:继续执行的时候就会出现死循环问题。
线程一:现将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成了循环。
当然在jdk 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中的死循环的问题。
笔记是对黑马课程中的知识进行的个人总结,图片借鉴了课程视频中的资料,感谢黑马程序员的开源精神,哈哈,如有问题联系我删除!