interview4-集合篇

interview4-集合篇_第1张图片一、算法复杂度分析

为什么要进行复杂度分析?因为可以指导你编写出性能更优的代码和评判别人写的代码的好坏。

(1)时间复杂度分析

时间复杂度是用来评估代码的执行耗时的。

interview4-集合篇_第2张图片

1.假如每行代码的执行耗时一样:1ms

2.分析这段代码总执行多少行?3n + 3

3.代码耗时总时间: T(n) = (3n + 3) * 1ms

interview4-集合篇_第3张图片

  • 大O表示法:不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势

  • T(n)与代码的执行次数成正比(代码行数越多,执行时间越长)

  • 当n很大时,公式中的低阶,常量,系数三部分并不左右其增长趋势,因此可以忽略,我们只需要记录一个最大的量级就可以了

    interview4-集合篇_第4张图片

    例:

interview4-集合篇_第5张图片

上图时间复杂度:只要代码的执行时间不随着参数n的增大而增大,这样的代码复杂度都是O(1)

interview4-集合篇_第6张图片

interview4-集合篇_第7张图片

interview4-集合篇_第8张图片

(2)空间复杂度分析

空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间数据规模之间的增长关系。

interview4-集合篇_第9张图片

二、List分析

(1)数组底层

数组(Array)是一种用连续的内存空间存储相同数据类型数据的线性数据结构。

interview4-集合篇_第10张图片

为什么数组索引从0开始呢?假如从1开始不行吗?

interview4-集合篇_第11张图片

  • 在根据数组索引获取元素的时候,会用索引和寻址公式来计算内存所对应的元素数据,寻址公式是:数组的首地址+索引*存储数据的类型大小

  • 如果数组的索引从1开始,寻址公式中,就需要增加一次减法操作,对于CPU来说就多了一次指令,性能不高。

查找数组的时间复杂度:

1. 随机查询(根据索引查询)数组元素的访问是通过索引来访问的,计算机通过数组的首地址寻址公式能够很快速的找到想要访问的元素

interview4-集合篇_第12张图片

2. 未知索引查询

查找排序后数组内的元素,查找55号数据

删除和插入数组的时间复杂度:

数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变的很低。

interview4-集合篇_第13张图片

最好情况下是O(1)的,最坏情况下是O(n)的,平均情况下的时间复杂度是O(n)。

(2)ArrayList剖析

源码如何分析?要从成员变量构造函数关键方法来分析。

1. 底层数据结构

ArrayList底层是用动态的数组实现的

2. 初始容量

ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10

3. 扩容逻辑

ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组

4. 添加逻辑

  • 确保数组已使用长度(size)加1之后足够存下下一个数据

  • 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)

  • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。

  • 返回添加成功布尔值。

(3)数组与ArrayList相关问题

ArrayList list=new ArrayList(10)中的list扩容几次?

interview4-集合篇_第14张图片

参考回答:该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容

如何实现数组和List之间的转换?

interview4-集合篇_第15张图片

  • 数组转List ,使用JDK中java.util.Arrays工具类的asList方法

  • List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组

用Arrays.asList转List后,如果修改了数组内容,list受影响吗?

Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址

List用toArray转数组后,如果修改了List内容,数组受影响吗?

list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响

(4)单向链表与双向链表

单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next

链表中的每一个元素称之为结点(Node),物理存储单元上,非连续、非顺序的存储结构。

interview4-集合篇_第16张图片

双向链表:顾名思义,它支持两个方向每个结点不止有一个后继指针 next 指向后面的结点有一个前驱指针 prev 指向前面的结点

interview4-集合篇_第17张图片

interview4-集合篇_第18张图片

对比单链表:

  • 双向链表需要额外的两个空间来存储后继结点和前驱结点的地址

  • 支持双向遍历,这样也带来了双向链表操作的灵活性

单向链表只有一个方向,结点只有一个后继指针 next。双向链表它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。

链表操作数据的时间复杂度:

查询、新增、删除
单向链表 头O(1),其他O(n)
双向链表 头尾O(1),其他O(n),给定节点O(1)

(5)ArrayList和LinkedList的区别

1、底层数据结构

  • ArrayList 是动态数组的数据结构实现

  • LinkedList 是双向链表的数据结构实现

2、操作数据效率

interview4-集合篇_第19张图片

  • ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式:数组的首地址+索引*存储数据的类型大小】, LinkedList不支持下标查询

  • 查找(未知索引): ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)

  • 新增和删除:

    ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n);

    LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)

3、内存空间占用

  • ArrayList底层是数组,内存连续,节省内存

  • LinkedList 是双向链表需要存储数据,和两个指针,更占用内存

4、线程安全

ArrayList和LinkedList都不是线程安全的。如果需要保证线程安全,有两种方案:

  1. 在方法内使用,局部变量则是线程安全的

  2. 使用线程安全的ArrayList和LinkedList

List syncArrayList = Collections.synchronizedList(new ArrayList<>());
List syncLinkedList = Collections.synchronizedList(new LinkedList<>());

三、数据结构

(1)二叉树

二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。二叉树每个节点的左子树和右子树也分别满足二叉树的定义。

interview4-集合篇_第20张图片

Java中有两个方式实现二叉树:数组存储,链式存储。基于链式存储的树的节点可定义如下:

interview4-集合篇_第21张图片

在二叉树中,比较常见的二叉树有:满二叉树、完全二叉树、二叉搜索、树红黑树

二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值(左小右大)。

interview4-集合篇_第22张图片

实际上由于二叉查找树的形态各异,时间复杂度也不尽相同, 看一下插入,查找,删除的时间复杂度:

interview4-集合篇_第23张图片

interview4-集合篇_第24张图片

(2)红黑树

红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)

interview4-集合篇_第25张图片

红黑树的特征:

  1. 节点要么是红色,要么是黑色

  2. 根节点是黑色

  3. 叶子节点都是黑色的空节点

  4. 红黑树中红色节点的子节点都是黑色

  5. 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

保证平衡: 在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质。

红黑树的时间复杂度:查找、添加、删除都是O(logn)

(3)散列表

散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。

interview4-集合篇_第26张图片

将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)

散列函数的基本要求:

  • 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。

  • 如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)

  • 如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)

散列冲突:

interview4-集合篇_第27张图片

实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)

在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

interview4-集合篇_第28张图片

散列冲突-链表法(拉链)

  • 数组的每个下标位置称之为桶(bucket)或者槽(slot)

  • 每个桶(槽)会对应一条链表

  • hash冲突后的元素都放到相同槽位对应的链表中或红黑树中

四、HashMap分析

(1)HashMap的实现原理

HashMap的数据结构: 底层使用Hash表数据结构,即数组和链表或红黑树

  1. 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标

  2. 存储时,如果出现hash值相同的key,此时有两种情况。

    a. 如果key相同,则覆盖原始值;

    b. 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中

  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。

interview4-集合篇_第29张图片

HashMap的jdk1.7和jdk1.8有什么区别?

  1. jdk1.8之前采用的拉链法,数组+链表

  2. jdk1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树

(2)HashMap的put方法的具体流程

interview4-集合篇_第30张图片

interview4-集合篇_第31张图片

  1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)

  2. 根据键值key计算hash值得到数组索引

  3. 判断table[i]==null,条件成立,直接新建节点添加

  4. 如果table[i]==null ,不成立

    4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value

    4.2 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对

    4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value

  5. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。

(3)HashMap的寻址算法

interview4-集合篇_第32张图片

为何HashMap的数组长度一定是2的次幂?

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模

  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

HashMap在1.7情况下的多线程死循环问题

jdk7的的数据结构是:数组+链表。在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环

interview4-集合篇_第33张图片

比如说,现在有两个线程

线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入

线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。

线程一:继续执行的时候就会出现死循环的问题。

线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。

(4)HashMap扩容机制

扩容流程:

interview4-集合篇_第34张图片

1.在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)

2.每次扩容的时候,都是扩容之前容量的2倍

3.扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中

  • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置

  • 如果是红黑树,走红黑树的添加

  • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

(5)HashMap与HashSet、HashTable的区别

HashMap、HashSet和HashTable都是Java集合框架中的一部分,它们主要在实现的接口、线程安全性、执行效率、对空值的处理方式以及添加元素的方式上存在区别。

  1. 实现的接口:HashMap和HashTable是Map接口的实现类,而HashSet是Set接口的实现类。

  2. 线程安全性:HashTable中的方法加了同步锁(synchronized),因此它是线程安全的;而HashMap是异步的,所以存放的对象并不是线程安全的。HashSet的底层是用HashMap实现的,因此它也不是线程安全的。

  3. 执行效率:因为HashTable是同步的,而HashMap是异步的,所以HashMap的执行效率比HashTable要高。三者的执行效率由快到慢为:HashMap>HashSet>HashTable。

  4. 对空值的处理:HashMap的key、value是可以为null,而HashTable的key、value则不能存放Null。HashSet只能存放value,其底层使用了hashmap,因此它也是可以放Null的。

  5. 添加元素的方式:HashMap是通过put方法添加元素的,而HashSet是通过add方法来添加元素的。

总的来说,HashMap、HashSet和HashTable各有其特点和使用场景,选择使用哪个集合类主要取决于具体的业务需求。

你可能感兴趣的:(数据结构)