数组在存储结构上的弊端:
数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个100MB大小的数组,当内存中没有连续的,足够大的存储空间时,即便内存的剩余总可用空间大于100MB,仍然会申请失败。
链表的内存结构?
链表不需要一块连续的内存空间,它通过”指针”将一组零散的内存块串联起来使用,所以如果我们申请的是100MB大小的链表,根本不会有问题
单链表
两个特殊结点:
头结点用来记录链表的基地址,有了它,我们就可以遍历得到整条链表。
尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点
插入和删除:
因为链表的存储空间本身就不是连续的,所以插入和删除操作不需要搬移节点,插入和删除操作的时间复杂度都是O(1)
随机访问:
因为链表的存储空间并不是连续的,所以只能根据指针一个结点一个结点地有一次遍历,直到找到相应的结点,时间复杂度为O(n)
循环链表
循环链表和单链表的区别就在尾节点,循环链表的尾节点指针指向链表的头结点
和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表
双向链表
首节点的前驱指针和尾节点的后继指针均指向空地址
双向链表相对于单链表的劣势:
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址,所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。
双向链表相对于单链表的优势:
删除对比:
链表删除数据有两种情况:
① 删除结点中”值等于某个给定值”的结点
单链表和双链表时间复杂度都为O(n),因为删除操作都为O(1),但遍历查找的时间复杂度为O(n),根据时间复杂度的加法法则,总时间复杂度为O(n)
② 删除给定指针指向的结点
单链表:需要从头结点开始遍历链表找到删除结点的前驱结点, 时间复杂度为O(n)
双向链表:要删除的结点保存了前驱结点,所以不需要遍历,双向链表的时间复杂度为O(1)
插入对比:
双向链表时间复杂度为O(1),单链表为O(n)
总结:
双向链表在某些情况下的插入和删除等操作都要比单链表简单,高效,但比较耗费内存。在实际软件开发中,双链表还是比单链表应用更加广泛,Java中的LinkedHashMap就用到了双向链表
双向循环链表
首节点的前驱指针指向尾节点,尾节点的后继指针指向首节点
链表VS数组性能大比拼
正是因为数组和链表内存存储的区别,他们插入,删除,随机访问操作的时间复杂度正好相反。在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据
选择数组还是链表?
1.插入、删除和随机访问的时间复杂度
数组:插入、删除的时间复杂度是O(n),随机访问的时间复杂度是O(1)。
链表:插入、删除的时间复杂度是O(1),随机访问的时间复杂端是O(n)。
2.数组缺点
1)数组的声明要占用整块连续内存空间,若申请内存空间很大,比如100M,但若内存空间没有100M的连续空间时,则会申请失败,尽管内存可用空间超过100M。
2)大小固定,若存储空间不足,需进行扩容,一旦扩容就要进行数据复制,而这时非常费时的,Java中的ArrayList容器就是用数组实现的
3.数组优点:
数组支持随机访问
4.链表缺点
1)内存空间消耗更大,因为需要额外的空间存储指针信息。
2)对链表进行频繁的插入和删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,还可能会造成频繁的GC(自动垃圾回收器)操作。
5.链表优点:
链表支持动态扩容,插入删除高效
6.数组与链表最大区别:
数组与链表最大的区别作者认为是,数组是大小固定,一经声明就要占用整块连续内存空间而链表本身没有大小的限制,天然地支持动态扩容。
7.如何选择?
数组简单易用,在实现上使用连续的内存空间,可以借助CPU的缓冲机制预读数组中的数据,所以访问效率更高,而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法预读。
如果代码对内存的使用非常苛刻,那数组就更适合。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入,删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,就有可能会导致频繁的GC,所以,在我们实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表
扩展:
CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(这个大小我不太确定。。)并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。
对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储
设计思想:
用空间换时间:
当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高,但时间复杂度相对很低的算法或者数据结构。
时间换空间:
如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路
思考题:
如何用链表来实现LRU缓存淘汰策略呢?
缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了
如何轻松写出正确的链表代码
-
理解指针或引用的含义
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针 -
警惕指针丢失和内存泄漏
插入和删除链表结点时,要记得手动释放结点对应的内存空间,否则,就会出现内存泄漏的问题 -
利用哨兵简化实现难度
针对链表的插入,删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理,虽然哨兵可能只是少做一次判断如果,我们引入哨兵结点,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫做带头链表。没有哨兵结点的链表叫做不带头链表
哨兵结点是不存储数据的,因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑
- 重点留意边界处理条件
经常用来检查链表是否正确的边界4个边界条件:
1.如果链表为空时,代码是否能正常工作?
2.如果链表只包含一个节点时,代码是否能正常工作?
3.如果链表只包含两个节点时,代码是否能正常工作?
4.代码逻辑在处理头尾节点时是否能正常工作?
5.举例画图,辅助思考
核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。
6.多写多练,没有捷径
五个常见的链表操作:
1.单链表反转
2.链表中环的检测
3.两个有序链表合并
4.删除链表倒数第n个节点
5.求链表的中间节点