算法复杂度分析
时间复杂度
假如执行每行代码的执行耗时一样: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集合,就应该先了解数组
数组
数组(Array)是一种连续的内存空间存储相同的数据类型数据和线性数据结构。
数组是如何获取其他元素的值的呢?
采用寻址公式:
如果从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(n)
源码分析:
List<Integer> list = new ArrayList<Integer>();
list.add(1);
成员变量:
构造方法
将collection对象转化为数组,然后将数组的地址赋值给elementData。
第十一次添加
该语句只是声明和实例了一个ArrayList,指定了容量为10,未扩容
如何实现数组和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);
}
}
参考回答:
面试官再问:
// 数组转集合
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方法会重新创建一个新的数组,所以不受影响
再答:
单项链表
表示B.next == C
插入或删除操作
* 只有在添加和删除头结点的时候不需要遍历链表,时间复杂度是O(1)
双向链表
而双向链表,顾名思义,它支持两个方向
每个结点不止有一个后继指针next指向后面的节点
双向链表需要额外的两个空间来缓存后继结点和前驱结点的地址
支持双向遍历,这样也带来了双向链表操作的灵活性
查询操作
查询头尾结点的时间复杂度是O(1)
平均查询时间复杂度是O(n)
给定节点找前驱结点的时间复杂度为O(1)
添加和删除操作
头尾结点新增的时间复杂度为O(1)
其他部分节点增删的时间复杂度是O(n)
给定节点增删的时间复杂度为O(1)
单项链表和双向链表的区别是什么?
链表操作数据的时间复杂度是多少?
链表 | 查询 | 新增删除 |
---|---|---|
单项链表 | 头O(1),其他O(n) | 头O(1),其他O(n) |
双线链表 | 头尾O(1),其他O(n),给定其他节点O(1) | 头尾O(1),其他O(n),给定其他节点O(1) |
底层数据结构
操作数据效率
内存空间
当时相对应的性能也会下降
ArrayList和LinkedList的区别是什么?
二叉树:
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子结点和右子节点,不过,二叉树并不要求每个节点都有两个子节点,有的节点只有一个左子结点,有的节点只有一个右子节点。
二叉树的每个节点的左子结点和右子节点也分别满足二叉树的定义
java中有两种方式实现二叉树:数组存储,链式存储。
在二叉树中,比较常见的二叉树有:
二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我画了几棵树,我们来看一下插入,查找,删除的时间复杂度
插入,查找和删除的时间复杂度是O(log n)
**红黑树(Red Black Tree):**也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)
红黑树的性质
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同树木的黑色节点
在添加和删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质
红黑树的一大原则就是保证平衡
红黑树的复杂度
什么是红黑树?
散列表(Hash Table)
散列表(Hash Table)又名哈希表/Hash表,是根据键(key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性
下面举一个例子:
使用hash方法对key进行Hash然后将结果存入到链表中
将键(key)映射为数组下标的函数叫做散列寒素,可以表示为:hashValue = hash(key)
散列表的基本要求:
散列冲突
实际情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这种情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
上图表示当两个key分别进行hash运算之后它计算出来的值是相同的,那么就会出现冲突的问题,简称:哈希冲突或者哈希碰撞
散列冲突解决方式-链表法(拉链法)
在散列表中,数组的每一个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放在相同的槽位对应的链表中。
如果两个key分别hash之后得到桶给一个值,那么就在这个值下标所对应的数组中使用链表的方式去存储数据,这样就解决了哈希冲突的问题。
散列冲突-链表法(拉链)-时间复杂度
当查找和删除一个元素的时候,我们同样通过散列函数计算处对应的槽,然后遍历链表查找或者删除
平均情况下基于链表解决冲突时查询的时间复杂度是O(1)
但散列链表中链接的数据过长的时候,退化成链表的时候,查询的时间复杂度就从O(1)退化成O(n)
将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
DDos攻击:
分布式拒绝服务攻击(英文意思:Distributed Denial of Service,简称DDos)
指处于不用位置的多个攻击同时向一个或数个目标发动的攻击,或者一个攻击控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击,由于攻击的发出点分布在不同的地方,这类攻击成为分布式拒绝服务攻击,其中的攻击者可以是多个。
什么是散列表?
散列冲突
散列冲突-链表法(拉链)
HashMap的数据结构:底层是使用了hash表数据结构,即数组和链表或红黑树
追问:jdk1.7和jdk1.8有什么区别?
HashMap的jdk1.7和1.8有什么区别
说一下HashMap的实现原理
HashMap的jdk1.7和jdk1.8的区别
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;
扩容阈值 == 数组容量 * 加载因子
Node实体类构成:
HashMap的源码分析
HashMap的put方法的具体流程
讲一讲HashMap的扩容流程
进行二次hash的原因:
因为一次hash之后很多值为存在固定的下标之上,会造成hash存储不均匀,因此使用二次hash的方式,对原来的hash值进行位移的运算,使得数组中的数据存储的更加的均匀
这个也被称为扰动算法,使得hash值更加均匀,减少hash冲突
(n-1)&hash:得到数组中的索引,代替取模,性能更好,
数组的长度必须是2的n次幂
为何HashMap的数组长度一定是2的次幂?
HashMap的寻址算法
为何HashMap的数组长度一定是2的次幂?
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中的死循环的问题。
笔记是对黑马课程中的知识进行的个人总结,图片借鉴了课程视频中的资料,感谢黑马程序员的开源精神,哈哈,如有问题联系我删除!