算法复杂度分析
时间复杂度
大O表示法:不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势
空间复杂度
空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间于数据规模之间的增长关系
常见空间复杂度就是O(1),O(n),O(n^2)
List相关面试题
数组
数组是一种用连续的内存空间存储相同数据类型数据的现行数据结构
数组如何获取其它元素的地址值
寻址公式:a[i] = baseAddress + i * dataTypeSize
- baseAddress: 数组的首地址
- dataTypeSize: 代表数组中元素类型的大小,int型的数据,dataTypeSize=4个字节
为什么数组索引从0开始,从1开始不行吗?
根据数组索引获取元素时。会利用索引和寻址公式来计算内存所对应的元素数据,寻址公式是:数组的首地址+索引乘以存储数据的类型大小
如果数据的索引从1开始,寻址公式中就需要增加一次减法操作,对CPU来说就多了一次指令,性能下降。
查询数组的时间复杂度
- 随机查询(根据索引查询)
数据元素的访问是通过索引来访问的,计算机通过数组的首地址和寻址公式能很快速找到想要访问的元素 - 未知索引查询(查询某个值位置)
对于未排序需要O(n),已经排序的需要O(log n)
增删数据
数据是一组连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变的很低,平均O(n)
ArrayList
ArrayList底层的实现原理是什么
- ArrayList底层是用动态数组实现的
- ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
- ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容需要拷贝数组
- ArrayList在添加数据的时候
1. 确保数组已用长度(size)加一后足够存下下一个数据
2. 计算数组的容量,如果当前数组已使用长度+1后的大于当前数组长度,则调用grow方法扩容
3. 确保新增数据有地方存储后,将新元素添加到位于size的位置上
4. 返回添加成功的布尔值
ArrayList list = new ArrayList(10)中的list扩容几次
该语句只是声明和实例了一个ArrayList,指定了容量为10,没有进行扩容
如何实现数组和List之间的转换
- 数组转List,直接使用JDK中java.util.Arrays工具类的asList方法
- List转数组,使用List的toArray方法。无参toArray返回Object数组,传入初始化长度的数组对象,返回该数组对象
用Arrays.asList转List之后,如果修改了数组内容,list会受影响吗?
Arrays.asList转换list之后,如果修改了数组内容,list会受影响。因为它底层使用的Arrays类中的一个内部类ArraysList来构造的集合。在这个集合的构造器中,把我们转入的这个集合进行了包装,最终指向都是同一个内存地址。
List用toArray转数组后,如果修改了List内容,数组受影响吗?
List用了toArray转数组后,如果修改了list内容,数组不会影响。当调用了toArray以后,在底层对数组进行了拷贝,和原来的数组使用不同内存地址。所以即使list修改以后,内存也不受影响。
LinkedList
单向链表
- 链表中每个元素称为结点(Node)
- 物理存储单元上非连续、非顺序的存储结构
ArrayList与LinkedList的区别是什么?
- 底层数据结构
- ArrayList是动态数组的数据结构实现
- LinkedList是双向链表的数据结构实现
- 操作数据效率
- ArrayList按照下标查询的时间复杂度是O(1),LinkedList不支持下标查询
- 查找(位置索引):ArrayList和LinkedList都需要遍历,时间复杂度都是O(n)
- 新增和删除:
ArrayList尾部插入和删除,时间复杂度是O(1);其它部分需要挪动数组,时间复杂度是O(n)
LinkedList头尾节点删除时间复杂度是O(1),其它都要删除链表,时间复杂度是O(n)
- 内存空间占用
- ArrayList底层是数组,内存连续,节省内存
- LinkedList是双向链表,需要存储数据,和两个指针,更占用内存
- 线程安全
- ArrayList和LinkedList都不是线程安全的
- 如果需要保证线程安全,有两种解决方法:
在方法内使用,局部变量则是线程安全的
使用线程安全的ArrayList和LinkedList
List<Object> syncArrayList = Collections.synchronizedList(new ArrayList<>());
List<Object> syncArrayList = Collections.synchronizedList(new LinkedList<>());
HashMap相关面试题
二叉树
注意,没有键相等的节点
二叉搜索树
查找、添加、删除时间复杂度为O(logn),极端情况下会退化为O(n)
红黑树
自平衡的二叉搜索树
性质:
- 节点要么是红色,要么是黑色
- 根节点是黑色
- 叶子节点都是黑色空节点
- 红黑树红色节点的子节点都是黑色
- 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
在添加或删除节点时,如果不符合这些性质会发送旋转,以达到所有的性质
红黑树的复杂度
- 查找:O(log n)
- 添加: 首先要从根节点找到元素添加的位置O(n),添加完成后进行时间复杂度为O(1)的旋转调整操作,故整体时间复杂度为O(log n)
- 删除: 同添加类似,整体时间复杂度为O(n)
散列表
散列表又名哈希表/Hash表,是根据键(key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来,利用了数组支持按照下标进行随机访问数据的特性。
将key映射到下标的函数叫做散列函数,可以表示为:hashValue=hash(key)
散列冲突
多个key映射到同一个数组下标位置
- 链表法: 数组的每个下标位置可以称为桶,每个桶对应一个链表,所有散列值相同的元素都放到相同桶对应的链表中。时间复杂度:插入O(1), 查找和删除:当查找、删除一个元素时,计算出对应的桶,然后遍历链表查找或删除,平均为O(1),数据量过多情况下会退化为O(n)。可以将链表改成红黑树。
HashMap
HashMap的实现原理
- 底层使用hash表数据结构,即数组和链表或红黑树
- 添加数据时,计算key的值确定元素在数组的下标,可以形同则替换,不同则存入链表或红黑树中;获取数据时通过key的hash计算数组下标获取元素
HashMap的jdk1.7和jdk1.8有什么区别
- JDK1.8之前采用拉链法,数组+链表
- JDK1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树。
HashMap put方法流程
- 判断键值对数组table是否为空或null,否则执行resize()进行扩容(初始化)
- 根据键值key计算hash得到数值索引
- 判断table[i]==null,条件成立直接新建节点添加
- 如果table[i]==null, 不成立
- 4.1. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
- 4.2. 判断table[i]是否为treeNode,即table[i]是否为红黑树,如果是红黑树,则直接在树中插入键值对
- 4.3. 遍历table[i],在链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话就把链表转化为红黑树,在红黑树中执行插入操作,遍历过程中若发此案key已经存在则直接覆盖value
- 插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold(数组长度*0.75),如果超过进行扩容
HashMap的扩容机制
- 在添加元素或者初始化的时候需要调用resize进行扩容,第一次添加数据初始化数组长度为16,以后每次扩容都是达到了扩容阈值(数组长度*0.75)
- 每次扩容的时候,都是扩容之前容量的两倍;
- 扩容之后,会创建一个新的数组,需要把老数组中的数据挪动到新的数组中
- 如果没有hash冲突的节点,直接使用
e.hash&(newCap-1)
计算新数组的索引位置 - 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,并进行拆分。判断
(e.hash & oldCap)
是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上。
HashMap的寻址算法
- 计算对象的 hashCode()
- 再进行调用 hash() 方法进行二次哈希,hashcode值右移16位再异或运算,让哈希分布更均匀
- 最后
(capacity-1)&hash
得到索引
为何HashMap的数组长度一定是2的次幂
- 计算索引时效率更高:如果2的n次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置,否则新位置 = 旧位置 + oldCap
HashMap在jdk1.7中数组扩容时死循环问题
在jdk1.7的HashMap进行数组扩容时,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说现在有两个线程
线程一: 读取到当前hashmap数据,数据中只有一个链表,在准备扩容时,线程二介入
线程二:同样读取hashmap,直接进行扩容。因为是头插法,链表的顺序会发生颠倒,比如原来顺序是AB,现在会变为BA
线程一:继续执行时,依然是将AB倒装,但是由于另一个线程的原因,A和B的next都指向了对方,形成了死循环
ConcurrentHashMap
为何称ConcurrentHashMap线程安全
- 底层数据结构:JDK 1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK 1.8 采用的数据结构和HashMap一样,数组+链表/红黑二叉树。 - 实现线程安全的方式:
- 在JDK1.7中,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
- 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。
ConcurrentHashMap扩容过程
- 扩容面临问题:同一时间有多个线程插入元素,可能同时引起对整个哈希桶的扩容
- 扩容过程
- 基于sizeCtl共享变量,通知各线程当前哈希桶的状态
s i z e c t l = { − 2 当前处于扩容状态 − 1 所有线程完成扩容 > 0 当前的扩容阈值 sizectl=\begin{cases} -2 & 当前处于扩容状态 \\ -1 & 所有线程完成扩容 \\ \gt 0 & 当前的扩容阈值 \end{cases} sizectl=⎩ ⎨ ⎧−2−1>0当前处于扩容状态所有线程完成扩容当前的扩容阈值 - 基于
transferIndex
共享变量,重新划分区间,保证每一个子区间最多只有一个线程进行扩容(每个线程在扩容时,都从transferIndex开始向前一段上进行)。 - 基于双table+标记节点,保证扩容过程中get操作不受扩容影响(get操作访问到被标记节点时,会在新表上寻找对应节点,put操作访问到被标记节点时,会先进行扩容)
- 共享变量用volatile装饰,保证线程之间的可见性
- sizeCtl、transferIndex采用自旋+CAS进行修改,保证原子性
- 节点的迁移和标记采用synchronized关键字加锁,保证原子性
- 基于sizeCtl共享变量,通知各线程当前哈希桶的状态
线程安全
synchronized关键字的底层原理
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程持有【对象锁】
- 底层由monitor实现,monitor是jvm级别的对象(C++实现),线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、entrylist、waitlist
- 其中owner是关联的获得对象锁的线程,且只能关联一个线程;entrylist关联的是阻塞状态的线程;waitset关联的是处于Waiting状态的线程