目录
前言:
在上一篇文章中我们学习了数组,对于数组这种线性表的顺序存储结构,我们知道对于其插入、删除操作需要移动大量数据,因此是很耗性能的,时间复杂度达到O(n), 那有什么方法解决这个问题么?还有像数组这种顺序存储结构需要预先分配内存空间从而可能造成内存空间的浪费。针对这些问题,链表这类链式存储结构(非顺序存储的结构)就显示出其强大的功能了。
一、链表的底层原理
什么是链表:
链表是通过链式存储(随机存储)方式组织数据存储于内存中的一种线性表数据结构。
其与数组最大的区别是链表不需要在内存中开辟整块连续的内存空间进行存储,
链表通过指针的方式灵活地在内存中组织数据,这个特点也使得链表在无需复制数据的情况下支持动态扩容。
下图为链表与数组在内存中存储的简易图:
通过上图,我们很直观地看到链表是通过指针的方式将一组零散的内存块组织起来的。
也就是说,假如现在有一组数据占内存100M,通过数组来存储那么内存就必须有连续的100M内存块才可以存储得下这些数据。但是链表不一样,链表需要的内存不需要连续,但通过链表的方式存储数据需要的实际内存会比数组的大一点,因为链表中每个数据需要额外开辟空间存储其对应的指针。
二、常见的链表
常见的链表有:单链表、双链表、循环单链表、循环双链表。
链表的知识点:结点、头结点、尾结点。链表是通过指针的方式将内存中零散的内存块串联起来的。一般在链表中把这些内存块称为结点,链表中的第一个节点称为头结点,最后一个结点称为尾节点。为了将所有结点串联起来,因此每个结点除了存储数据data还需要存储链表上下个结点的地址next 后继指针,如果存储的上一个节点的地址 prev 前驱指针。
单链表
链表中每个结点只有一个后续指针,该指针指向下个数据,尾结点的后续指针指向空地址。
循环单链表
循环单链表其实只是将单链表的尾结点指针指向空地址修改成指向头结点了。如下图所示:
双链表
双链表与单链表相比,就是每个结点多了一个前驱指针。如下图所示:
循环双链表
其与双链表唯一的区别是其尾结点的next 指针由指向空地址转向头结点、头结点的前驱指针指向尾结点。如下图所示:
单链表与双链表的优缺点
单链表相对来说操作简单一点,占用内存空间小一点。
双链表由于需要存储两个指针故占用内存会相对大一点,但是操作性能方面会比单链表高一点。
三、链表的基本操作
- 读操作:对于单链表来说,其读操作只能从头到尾依次读取,故其时间复杂度为O(n)。对于有序的双链表,可事先判断数据在前面一半还是后一半,然后对元素进行双向遍历,故读取相对省时一些,但总的来说时间复杂度依然是O(n)。
- 插入操作:在对链表进行插入操作时,由于链表的无序性,可直接在链中任意位置进行插入,在插入时只需将对应位置的指针指向进行修改便可,因此其操作的时间复杂度为O(1)。
- 更新操作:与读操作一样,由于其随机存储的原因,在对数据进行修改的时候需要首先找到该元素再进行重新赋值。
- 删除操作:通常在很多地方都看到链表适合快速删除的操作,其删除操作的时候复杂度为O(1)。但真的是这样么?
事实上,对于删除操作,我们无非是分两种情况,一种是根据值等于指定结点进行数据的删除;
另一种是删除给定指针指向的结点。
对于第一种情况,无论是单链表还是双链表,其时间复杂度都为O(n)。
因为其都需要从头到尾对链表进行遍历找到对应的值,这一步类似于链表的读操作,然后再进行删除操作。
对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点q 需要知道其前驱结点,
而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,
直到p->next=q,说明p是q的前驱结点。
但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,
不需要像单链表那样遍历。
所以,针对第二种情况,单链表删除操作需要O(n)的时间复杂度,而双向链表只需要在O(1)的时间复杂度内就搞定了!
同理,如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。
双向链表可以在O(1)时间复杂度搞定,而单向链表需要O(n)的时间复杂度。
四、链表与数组性能对比
从上表可知:数组适合读操作多、写操作少的场景;链表适合读少、写多的场景。但需要注意,链表的所占的存储空间会比链表大不少。
五、链表的实现代码
引用小灰漫画一书的代码如下:
package chapter2.part2;
/**
* Created by weimengshu on 2018/8/24.
*/
public class MyLinkedList {
//头节点指针
private Node head;
//尾节点指针
private Node last;
//链表实际长度
private int size;
/**
* 链表插入元素
* @param data 插入元素
* @param index 插入位置
*/
public void insert(int data, int index) throws Exception {
if (index<0 || index>size) {
throw new IndexOutOfBoundsException("超出链表节点范围!");
}
Node insertedNode = new Node(data);
if(size == 0){
//空链表
head = insertedNode;
last = insertedNode;
} else if(index == 0){
//插入头部
insertedNode.next = head;
head = insertedNode;
}else if(size == index){
//插入尾部
last.next = insertedNode;
last = insertedNode;
}else {
//插入中间
Node prevNode = get(index-1);
insertedNode.next = prevNode.next;
prevNode.next = insertedNode;
}
size++;
}
/**
* 链表删除元素
* @param index 删除的位置
*/
public Node remove(int index) throws Exception {
if (index<0 || index>=size) {
throw new IndexOutOfBoundsException("超出链表节点范围!");
}
Node removedNode = null;
if(index == 0){
//删除头节点
removedNode = head;
head = head.next;
}else if(index == size-1){
//删除尾节点
Node prevNode = get(index-1);
removedNode = prevNode.next;
prevNode.next = null;
last = prevNode;
}else {
//删除中间节点
Node prevNode = get(index-1);
Node nextNode = prevNode.next.next;
removedNode = prevNode.next;
prevNode.next = nextNode;
}
size--;
return removedNode;
}
/**
* 链表查找元素
* @param index 查找的位置
*/
public Node get(int index) throws Exception {
if (index<0 || index>=size) {
throw new IndexOutOfBoundsException("超出链表节点范围!");
}
Node temp = head;
for(int i=0; i<index; i++){
temp = temp.next;
}
return temp;
}
/**
* 输出链表
*/
public void output(){
Node temp = head;
while (temp!=null) {
System.out.println(temp.data);
temp = temp.next;
}
}
/**
* 链表节点
*/
private static class Node {
int data;
Node next;
Node(int data) {
this.data = data;
}
}
public static void main(String[] args) throws Exception {
MyLinkedList myLinkedList = new MyLinkedList();
myLinkedList.insert(3,0);
myLinkedList.insert(4,0);
myLinkedList.insert(9,2);
myLinkedList.insert(5,3);
myLinkedList.insert(6,1);
myLinkedList.remove(0);
myLinkedList.output();
}
}
链表中比较常见的操作,代码还有待补充
- 单链表反转
- 链表中环的检测
- 两个有序的链表合并
- 删除链表倒数第n个结点
- 求链表的中间结点
常见的 Java 链表容器
HashMap、LinkedHashMap;像HashMap 是一种基哈希表的数据结构,其底层其实是数组与链表的方式来整合数据的。
其实可以说HashMap是基于数组存储的,hash算法将key转换成数组下标,value值根据下标存储入数组中,由于hash算法算出来的值可能会有冲突的情况出来(不同的key算出来的hash值相同,也就是说这时对应的数组同一个下标需要存储多个值),对于这类冲突问题,hashMap通过把该下标所有对应的值通过链表的形式存储起来。
在遍历的时候如同一个下标下出现多个对应的值,则依次遍历直到取到对应的值为止,当同一个下标对应的链表过长时,遍历链表的效率会比遍历红黑树的低,所以在JDK1.8后,当链过长长度超过8之后,链表就会转换成红黑树的数据结构进行存储了。
当HashMap长度达到 初始值 * 负载因子(0.75)时便像数组那样进行扩容(这一步是动态进行的),因此如何事先知道HashMap的长度,为了提升效率,可将其控制在自动扩容之前就将所有数据存储完成。从上述可知,数据结构只要你留心便是无处不存在了。
六、简述缓存机制
1)什么是缓存?
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。该实现主要是应用了以空间换取时间的思想。
2)为什么使用缓存?即缓存的特点
缓存的大小是有限的,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。
3)什么是缓存淘汰策略?
指的是当缓存被用满时清理数据的优先顺序。
4)有哪些缓存淘汰策略?
常见的3种包括先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frenquently Used)、最近最少使用策略LRU(Least Recently Used)。
5)CPU缓存简述
CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。
而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块并保存到CPU缓存中,下次访问内存数据的时候就会先从CPU缓存开始查找,找到就无需再从内存中取。这就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。
对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。
如何分别用链表和数组实现LRU缓冲淘汰策略?
1)链表实现LRU缓存淘汰策略
当访问的数据没有存储在缓存的链表中时,直接将数据插入链表表头,时间复杂度为O(1);
当访问的数据存在于存储的链表中时,将该数据对应的节点,插入到链表表头,时间复杂度为O(n)。如果缓存被占满,则从链表尾部的数据开始清理,时间复杂度为O(1)。
2)数组实现LRU缓存淘汰策略
方式一:首位置保存最新访问数据,末尾位置优先清理
当访问的数据未存在于缓存的数组中时,直接将数据插入数组第一个元素位置,此时数组所有元素需要向后移动1个位置,时间复杂度为O(n);当访问的数据存在于缓存的数组中时,查找到数据并将其插入数组的第一个位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉末尾的数据,时间复杂度为O(1)。
方式二:首位置优先清理,末尾位置保存最新访问数据
当访问的数据未存在于缓存的数组中时,直接将数据添加进数组作为当前最有一个元素时间复杂度为O(1);当访问的数据存在于缓存的数组中时,查找到数据并将其插入当前数组最后一个元素的位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉数组首位置的元素,且剩余数组元素需整体前移一位,时间复杂度为O(n)。(优化:清理的时候可以考虑一次性清理一定数量,从而降低清理次数,提高性能。)
3)通过散列表的方式(这个效率会更高)
详见如下链接
漫画:什么是LRU(Least Recently Used)算法?
如何通过单链表实现“判断某个字符串是否为水仙花字符串”?
(比如 上海自来水来自海上)
1)前提:字符串以单个字符的形式存储在单链表中。
2)遍历链表,判断字符个数是否为奇数,若为偶数,则不是。
3)将链表中的字符倒序存储一份在另一个链表中。
4)同步遍历2个链表,比较对应的字符是否相等,若相等,则是水仙花字串,否则,不是。
七、链表的实际应用场景
哈哈哈。。。。常见的有面试、考证
其实当了解了更多有关数据结构之后,会发现像栈、队列、散列表、树、图等比较复杂的逻辑数据结构基本要么基于数组实现,要么基于链表实现。因此对于链表来说是其重要可想而知。
注:该系列博文为笔者学习《数据结构与算法之美》的个人学习笔记小结
链表的常用操作:【数据结构】链表的原理及java实现