链表基本理论知识与链表经典应用LRU缓存
链表是比数组稍微复杂的数据结构,下面从底层结构和应用场景总结一下概要。
一、链表底层结构
1.1 链表结构
官方名词:链表是一种在物理上非连续、非顺序的数据结构,由若干个节点node组成。
这个比较通俗的来说,用以下两张图来对一下数组和链表的结构在内存占用空间中的展示。
数组:数组是需要一块连续的内存空间组成,对内存的要求是比较高的,这会导致当数组需要的内存空间大于总内存的剩余空间或者接近(数组内存申请过大),会出现申请失败。
链表:链表与数组相反,不需要连续的内存空间,它是通过无数的指针 将内存中零散的内存块进行连接,然后使用。
链表的分类
常见的链表结构分三种,单链表、双向链表以及循环链表。
单链表结构
上一节说的单链表是由指针将一组零散的内存块进行连接使用,这些零散的节点被称之为“结点”(节点、结点都一样)
每一个结点上(内存块)又分为了两个部分,一部分存储着数据data,一部分存储着下一个结点的地址,这个记录下个节点的部分叫做指针,通常是叫后续指针next。
其中单链表有两个特殊的节点,一个叫头结点,一个叫尾结点。
头节点就是第一个节点,用于记录链表的基地址,获得了基地址,就可以通过头节点获得下一个节点的地址,从而遍历整条链表。
尾结点就是最后一个节点,尾结点的后续指针指向的是一个空值null。
单链表性能跟数组对比
链表跟数组同样支持查找、插入、删除操作
1.查找:数组查找某个元素是可以通过基地址和下标进行寻址公式的计算获得要查找元素的内存地址的,时间复杂度是O(1)。但是对于链表来说,它没有寻址计算,它只能一个一个通过遍历头节点、再下一个节点、下下一个节点这样遍历全链表获得想要查找的元素地址,所以时间复杂度是O(n)。
2.插入和删除:数组在删除或者插入元素(不在末尾)的时候,需要做大量的数据搬移,所以时间复杂度是O(n)。而链表内存块本身就不是连续的,只要把上一个内存块的后续指针指向新内存块,然后把新内存块的后续指针指向替换节点就能完成插入操作,所以时间复杂度是O(1)。
但是这里单链表的删除时间复杂度有点特殊,删除操作时间复杂度是O(1),但是链表删除前一般需要遍历一次,时间复杂度是O(n),所以总复杂度其实是O(1)+O(n)=O(n)。(回答链表删除操作复杂度时候要说明是否包含遍历复杂度)
一般链表删除包含两种情况:1.删除链表中某个值=xx,2.删除链表中指针指向的某个节点。
对于1.不管什么链表还是数组,删除某个值的情况下,都要遍历数组或者链表,找到所有等于定值的内存块,然后删除。其中链表复杂度是O(1)+O(n)
对于2.链表以及找到了要删除的节点,但是删除某个节点,还需要它上一个节点的地址,所以为了找到上一个节点的地址,又需要从头遍历一次链表,所以单链表需要O(1)+O(n).但是这种情况会在双链表有所改善。
循环链表
循环链表其实就是一种特殊的单链表,跟单链表的区别就是循环链表的尾结点是指向链表的 头节点,并非是普通单链表一样指向空地址。
循环链表主要是用于处理某些具有环形结构特点的数据,例如经典的约瑟夫问题。
双向链表
双向链表跟单链表最大的不同是,单链表是单向的,即指针只能从头节点指向下一个节点,再下下个节点,一个方向。而双向链表顾名思义就是能指向两项,每一个结点除了一个后续指针next和存储数据的data部分外,还拥有一个前驱节点prev指向前一个结点。
双向链表跟单向链表对比
- 双向链表需要占用的空间更多,因为它需要额外的一个空间去存储前驱节点的地址。
- 虽然占用的空间比较多,但是这是用空间换取了时间,双向链表对比于单链表在某些情况下的插入和删除会更高效。
例如删除操作:
在单链表的时候说过,一般删除操作分两种:
1.是删除链表中结点值=某个值,这种情况不管是单链表还是双链表都需要一个一个遍历,时间复杂度都是O(n)+O(1)=O(n)
2.是删除定向指针指定的结点,例如删除下标2的结点。对于单链表因为不支持获取上一个结点的地址,所以找到定向指针指定的结点后还需要再次遍历找到上一个结点的地址。但是对于双向链表,本身结点就有一个前驱节点存储着上一个结点的地址,所以双向链表在这种情况下的时间复杂度是O(1).
3.在按值查找方面,双向链表效率也比单向链表高,因为因为双向链表记录着上一次查找的位置,在寻找比当前值大还是小的情况下,双向链表可以决定往前查找还是往后查找,而单链表只能从头到尾遍历,双向链表时间复杂度为单链表的二分之一。
这对于插入操作也是一样的,插入操作单向链表也需要找到上一个结点的位置,时间复杂度是O(n)+O(1)=O(n),双向链表则是O(1)。
空间换取时间概念
在实际中,双向链表使用是比单链表频繁的,因为这涉及到了空间换时间的设计思想。在实际中,内存是可以通过升级硬件获取的,如果在追求高效率的代码执行速度的时候,就可以采用空间复杂度比较高但是时间复杂度比较低的数据结构。但是如果内存不足的情况下,就采取相反的数据结构。
链表性能对比数组
如果只是单纯从时间复杂度分析,不忽略在单链表说过的单链表查询再需要遍历一次的情况下,
一般:
数组:插入、删除复杂度是O(n),随机访问元素的时间复杂度是O(1)
链表:插入、删除复杂度是O(1),随机访问元素的时间复杂度是O(n)
但是!!!!!
在实际的应用中,数据使用的是连续的内存空间,因此可以借助cpu的缓存机制进行预读数组中的数据,所以实际中的数组访问效率是更高的。而链表在内存中不是连续的,无法进行预读。
数组缺点则是上面说过的,一经说明后,数组的内存大小是固定的,是要占用一块连续的内存空间。如果过大,容易导致出现空间不足的问题,即OOM(out of memory),而链表在这一方面是没有限制的,可以无限增加扩容。
链表缺点:每一个结点都需要额外的空间去存储前驱节点的地址和后续节点的地址,如果频繁的进行插入、删除操作,导致内存的频繁申请和释放,容易生成内存碎片,导致频繁的GC。
选择链表还是数组,要根据不同类型的项目,当前对数据结构的要求去进行选择。
链表经典场景-LRU缓存淘汰算法。
缓存是一种提高读取数据性能的技术,但是缓存大小有限,当缓存空间用满时,哪些数据该被清理,哪些数据应该保存,一般有三种策略,FIFO先进先出策略,LFU 最少使用策略,LRU最近最少使用策略。
LRU具体要怎么基于链表去实现呢,
首先
1.维护一个单链表,数据越早进入链表的,把数据放置链表尾端。
2,当有一个新数据进来的时候,需要从头便利链表。这时候分两种情况:
1).当前数据没有在缓存链表中,即是新数据,若此时缓存未满,则直接将该数据插入到链表头部,但若此时缓存链表已经满了,则将链表尾结点删除,将新的数据插入链表头部
2).当前数据已经在缓存表了,则遍历一次链表,将该数据之前所在的位置进行删除,再插入到头部中。
这样就能实现了一个LRU缓存淘汰算法了。
构造链表基本代码
package java_test;
public class Mylinked {
private Node head;
private Node last;
private int size;
public Node get(int index){
//查找某个元素的位置
if (index<0||index>=size){
throw new IndexOutOfBoundsException("超出链表范围");
}
Node tmp = head;
for (int i=0; i<index; i++){
tmp = tmp.next;
}
return tmp;
}
public void insert(int data, int index){
System.out.println("size:"+size+" index:"+index);
if (index<0||index>size){
throw new IndexOutOfBoundsException("超出链表范围");
}
Node insertNode = new Node(data);
if (size==0){
//空链表插入元素
head = insertNode;
last = insertNode;
}else if (index==0){
//头部插入元素
insertNode.next = head;
head = insertNode;
}else if (size==index){
//尾部插入元素
last.next = insertNode;
last = insertNode;
}else {
//中间插入元素
Node prevNode = get(index);
insertNode.next = prevNode.next;
prevNode.next = insertNode;
}
size++;
}
public Node remove(int index){
if (index<0||index>size){
throw new IndexOutOfBoundsException("超出链表范围");}
Node removeNode = null;
if (index==0){
//删除头节点
removeNode = head;
head = head.next;
}else if (index ==size-1){
//删除尾结点
Node prevNode = get(index-1);
removeNode = prevNode.next;
prevNode.next=null;
last = prevNode;
}else {
//删除中间节点
Node prevNode = get(index-1);
Node nextNode = prevNode.next.next;
removeNode = prevNode.next;
prevNode.next = nextNode;
}
size--;
return removeNode;
}
public void output(){
//展示
Node tmp = head;
while (tmp!=null){
System.out.println(tmp.data);
tmp = tmp.next;
}
}
public static void main(String[] args) {
Mylinked mylinked =new Mylinked();
mylinked.insert(100,0);
mylinked.insert(101,1);
mylinked.insert(102,2);
mylinked.insert(103,3);
System.out.println("-----------insert------");
mylinked.output();
System.out.println("-----------remove---------");
mylinked.remove(0);
mylinked.output();
}
}
class Node{
//自定类Node
int data;
Node next;
Node(int data){
this.data=data;
}
}