算法复杂度分析
时间复杂度
时间复杂度:来评估代码的执行耗时,复杂度分析就是弄清楚代码的执行次数和数据规模n之间的关系。
大O表示法:不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。
T(n)与代码的执行次数成正比(代码行数越多,执行时间越长)
T(n)=O(3n+3) ----> T(n)=O(n)
当n很大时,公式中的低阶、常量、系数三部分并不左右增长趋势,因此可以忽略,只需要记录一个最大的量就行。
常见时间复杂度
O(1):
只要代码的执行时间不随着n的增大而增大,代码的复杂度永远是O(1)
学习进行复杂度分析,可以指导写出性能更好的代码和评判别人写的代码好坏。
空间复杂度
空间复杂度全称:渐进空间复杂度,表示算法占用的额外存储空间和数据规模之间的增长关系。
常用的空间复杂度就是O(1)、O(n)、O(n^2),一些其他的像对数阶的复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单很多。
数据结构
数组
数组是一种连续的内存空间存储相同的数据类型数据的线性数据结构。
数组如何获取其他元素的地址值:
为什么数组从0开始:
如果从1开始,对于cpu来说增加了一个减法指令,从0开始就是一种优化。
在根据数组索引获取元素的时候,会用索引和寻址公式来计算内存所对应的元素数据,寻址公式是:数组的首地址+索引乘以存储数据的类型大小。
操作数组的时间复杂度:
查找
根据索引查询:
未知索引查询:
新增、删除
数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变得很低
为了保证数组的连续性,需要挪动数组元素,平均时间复杂度为O(n)。
链表
单向链表
链表中的每一个元素称之为节点(Node)
物理存储单元上,非连续、非顺序的存储结构
单向链表:每个节点包括两部分:一个存储数据元素的数据域,另一个是存储下一个节点地址的指针域。记录下一个节点地址的指针叫做后继指针 next。
查询时间复杂度
只有在查询头结点的时候不需要遍历链表,时间复杂度为O(1),查询其他节点需要遍历,时间复杂度为O(n);
插入、删除时间复杂度
只有在添加和删除头结点的时候不需要遍历链表,时间复杂度为O(1);添加和删除其他节点需要使用到遍历找到对应节点后才能完成,时间复杂度为O(n);
双向链表
双向链表支持两个方向,每个节点不止有一个后继指针next指向后面的节点,还有一个前驱指针prev指向前面的节点。
查询时间复杂度
查询头尾节点的时间复杂度为O(1)
平均查询时间复杂度为O(n)
给定节点找前驱节点的时间复杂度为O(1)
插入、删除时间复杂度
增删头尾节点的时间复杂度为O(1)
其他部分节点增删时间复杂度为O(n)
给定节点增删的时间复杂度为O(1)
单向链表和双向链表的区别?
1.单向链表只有一个方向,节点只有一个后继指针 next;
2.双向链表支持两个方向,每个节点不止有一个后继节点 next 指向后面的节点,还有一个前驱指针 prev 指向前面的节点。
二叉树
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树每个节点的左子树和右子树也分别满足二叉树的定义。
java中实现二叉树的两种方式:数组存储、链式存储:
常见分类:
满二叉树、完全二叉树、二叉搜索树、红黑树
二叉搜索树
二叉搜索树又名二叉査找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
时间复杂度:
查找、插入、删除的时间复杂度为O(logn)
极端情况下:二叉树退化成链,时间复杂度为O(n).
红黑树
红黑树:也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树;
特性
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
在添加或者删除节点的时候,如果不符合这些特性会发生旋转,以达到所有的性质。
时间复杂度
查找:红黑树也是一棵二叉搜索树,查找操作的时间复杂度为:O(log n)
添加、删除:添加、删除先要从根节点开始找到元素添加的位置,时间复杂度O(log n);完成后涉及到复杂度为O(1)的旋转调整操作;
故整体复杂度为:O(log n)。
散列表
散列表又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。
将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求:
散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标;
如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1)== hash(key2);
如果key1!=key2,那么经过hash后得到的哈希值也必不相同即:hash(key1)!= hash(key2);
散列冲突
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置):
散列冲突-链表法(拉链法)
在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
链表法-时间复杂度
1.插入:通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度是 O(1);
2.当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除
平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
散列表可能会退化为链表,查询的时间复杂度就从 O(1)退化为 O(n)
将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)
List
ArrayList
源码分析
List<Integer> list = new ArrayList<Integer>();
list.add(1);
成员变量:
构造函数:
添加数据(第一次):
添加数据(不扩容):
添加数据(扩容):
底层实现原理
1.ArrayList底层是用动态的数组实现的
2.ArrayList初始容量为0,当第一次添加数据时才会初始化容量为10
3.ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
4.ArrayList在添加数据的时候
确保数组已使用的长度加一知乎足够存下下一个数据
计算数组容量,如果当前数组已使用长度加一后的大于当前数组长度,则调用grow方法扩容
确保新增的数据有地方存储,则将新元素添加到位于size的位置上
成功后返回成功布尔值
数组和list之间的转换
数组转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修改了以后,数组也不受影响
LinkedList
ArrayList和LinkedList的区别?
1.底层数据结构
ArrayList 是动态数组的数据结构实现;LinkedList 是双向链表的数据结构实现。
2.操作数据效率
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(加锁);
Map
HashMap
HashMap实现原理
HashMap的数据结构:底层使用了hash表数据结构,即数组和链表或红黑树
1.当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标。
2.存储时,如果出现hash值相同的key,有两种情况:第一种:如果key相同则覆盖原始值;第二种:如果key不同,则将当前的key-value放入链表或红黑树中。
3.获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
HashMap中jdk1.7和jdk1.8有什么区别
1.jdk1.8之前采用的拉链法,数组+链表
2.jdk1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树。
源码分析
常见属性
DEFAULT_INITIAL_CAPACITY:默认的初始容量(16)
DEFAULT_LOAD_FACTOR:默认加载因子(0.75)
扩容阈值 = 数组容量 * 加载因子
构造函数
HashMap是懒惰加载,在创建对象时并没有初始化数组
在无参构造函数中,设置了默认的加载因子是0.75
添加数据
流程图:
具体流程:
1.判断键值对数组table是否为空或为nul,否则执行resize0)进行扩容(初始化)
2.根据键值key计算hash值得到数组索引
3.判断table[i]==nul,条件成立,直接新建节点添加
4.如果table[i]==null ,不成立
4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
4.2 判断tabe[ 是否为treeNode,即table 是否是红黑树,如果是红黑树,则直接在树中插入键值对
4.3 遍历tablel,链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
5.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
扩容机制
流程图:
扩容机制:
1.在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度*0.75);
2.每次扩容的时候,都是扩容之前容量的2倍;
3.扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
没有hash冲突的节点,则直接使用 e.hash &(newCap-1)计算新数组的索引位置
如果是红黑树,走红黑树的添加
如果是链表,则需要遍历链表,可能需要拆分链表,判断(e,hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
寻址算法
1.计算对象的hashCode()
2.再进行调用hash()方法进行二次哈希,hashCode值右移16再异或运算,让哈希分布更加均匀
3.最后(capacity - 1) & hash 得到索引
为何HashMap的数组长度一定是2的次幂?
1.计算索引时效率更高:如果是2的 n次幂可以使用位与运算代替取模
2.扩容时重新计算索引效率更高: hash &oldCap ==0的元素留在原来位置,否则新位置= 旧位置+oldCap
hashMap在1.7情况下的多线程死循环问题
在jdk1.7的hashMap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环。
比如说,现在有两个线程
线程一:读取到当前的hashMap数据,数据中一个链表,在准备扩容时,线程二介入线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。当然,JDK8将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了idk7中死循环的问题。