目录
什么是链表
我们前面说了“数组”,它是一种占用连续内存空间的数据结构。但有时候我们需要存储大量的数据,内存中却没有像样大小的连续空间,这可怎么办呢?我们有一个需要大量增删操作的数据集,而改查操作用的比较少,选择数组进行存储的话,操作起来不构效率怎么办呢?链表应运而生,它并不需要⼀块连续的内存空间,它通过“指针”将⼀组零散的内存块串联起来使⽤。
链表的特点
为什么链表应运而生呢?因为链表这种结构,刚好和数组互补,本着“优劣同源”的思想,既然数组快改查,慢增删,互补的链表应该就是快增删,慢改查了吧。欸,还真是这样的哈哈哈。
数组 | 链表 | |
增删 | O(n) | O(1) |
改查 | O(1) | O(n) |
所以频繁查改的数据集,用数组存最美滋滋;频繁增删的数据集,用链表存就美滋滋啦,完美~
【这里要注意增删有特殊的步骤,单纯的删除操作链表是O(1)滴,但是要找到待增删元素的位置,链表确是O(n)滴~】
有读者大人问啦:那增删改查都多,咋整啊,总不能搞个数组链表出来吧。牛皮!还真是搞个数组链表来解决这种情况,这个咱们下回再说。
说了半天,还没总结一下链表的特点呢,其实定义里也有讲到:
1、链表不需要⼀块连续的内存空间。 有人问了,凭什么就不需要啊?怎么实现的啊? 好问题!我们来想想,数据存在存储空间里,咱们要用的时候,去访问数据在的地址就行,数组之所以需要连续,就是因为想要知道第一个元素的地址就能推断出后面的数据的地址。那咱们如果不想让数据连续存放的话,我们只要知道所有数据的地址就可以了。但这样也太复杂了,于是我们在每个数据的空间里,都记录一下下一个存储空间的内存地址,用指针指向它。这样,离散的数据就被串联起来啦。
2、增删快,改查慢。 和数组互补,我们得不厌其烦的捋捋这个逻辑,数组因为连续,所以增删需要移动元素,这是遍历是O(n)级别的操作,改查直接算出地址一步到位了,这是O(1)的操作。 链表不连续呀,增删咱们直接改变指针指向的地址就可以了,多快呀,这是O(1)的操作,但改查不一样了,因为我们只知道下一个元素的地址,所以我们只能从头到尾慢慢查,这是遍历,是O(n)的操作。
3、扩展性好,不用提前指定大小。 又一个和数组互补的特点来了,数组不灵活,需要提前指定大小。
4、内存利用率低于数组。 链表里的结点,不仅需要存储数据,还需要存储下一个数据的地址,存储相同的数据,所需要的内存肯定就相比不需要存储地址的数组要大些。
怎么用链表
链表分为很多种,咱们先从最简单最基础的单链表开始说起。
链表的基础知识
事实上,文章的第一幅图还有细化的空间,咱们给他细化一下。
比起数组,链表多了一个next结构,用来存储下一个结点的位置。 对于链表而言,一个内存块即是一个结点。
在链表中,较为特殊的就是头结点和尾结点,头结点一般存放链表的基地址,而尾结点的next值向null。
public class ListNode { //链表的一个结点
int val;
ListNode next;
ListNode(int x) { val = x; }
}
单链表
文章的第一张图就是单链表结构,这里的“单”指的是单方向~
链表的基础操作是增删改查。我们来看看怎么实现
增(插):由于链表的结点都是散乱的内存块,所以只需要元素A指向需要新增的元素C的地址,而C再指向A原先指向的地址即可。
插入后:
C.next=A.next;
A.next=C;
删:元素A直接指向元素B指向的元素C即可。
A.next=A.next.next;
改、查
改查的情况比较特殊,由于链表的内存是离散的,所以我们并不能像数组一样“随机访问”,结点的位置,只有指向它的其他结点知道,所以我们想要改查一个结点,必须老老实实的从链表的头开始找起,这是个遍历操作,需要O(n)的时间复杂度。
上图表示想要找到data=3的寻找步骤。
循环链表
循环链表是特殊的单链表,具体特殊在:他的尾节点的next指针,指向了头结点,长成了这样。
和单链表相⽐,循环链表的优点是从链尾到链头⽐较⽅便。当要处理的数据具有环型结构特点时,就特别适合采⽤循环链表。
基础的增删改查实现了,那么迎来了神奇的需求:删除给定结点?
不就是上面说到的删除结点么,O(1)的复杂度,链表的优势所在。
no no no,上面讲的都是理论层面的东西,咱们仔细捋捋删除操作的具体细节。
删除无非是两种情况:
1、删除值等于给定值的结点
2、删除给定指针指向的结点
对于第一种情况来说,删除需要有个查找的过程,单纯的删除操作的确是O(1),但这个查找的过程却是O(n)的。
对于第二种情况来说,我们已经知道了要删除的结点,但要删除这个结点,需要他的前驱结点,在单链表结构中,我们又要憨憨的去遍历寻找前驱结点了。
啊~听起来好麻烦啊,单这类需求又非常常见。于是我们想想能不能对单链表的结构进行修改呢?既然删除结点需要前驱结点,那我们多加一个指针指向前驱结点就好啦。
于是双向链表应运而生。
双向链表
public class ListNode { //链表的一个结点
int val;
ListNode next;
ListNode pre;
ListNode(int x) { val = x; }
}
双向链表相比于单链表来说,他可以⽀持O(1)时间复杂度的情况下找到前驱结点。
所以,针对第⼆种删除情况,单链表删除操作需要O(n)的时间复杂度,⽽双向链表只需要在O(1)的时间复杂度内就搞定了!
同理,如果我们希望在链表的某个指定结点前⾯插⼊⼀个结点,双向链表⽐单链表有很⼤的优势。双向链表可以在O(1)时间复 杂度搞定,⽽单向链表需要O(n)的时间复杂度。
对于⼀个有序链表,双向链表的按值查询的效率也要⽐单链表⾼⼀些。因为,我们可以记录上次查找的位置p,每次查询时,根据要查找的值与p的⼤⼩关系,决定是往前还是往后查找,所以平均只需要查找⼀半的数据。
等等,我们发现,双向链表和循环链表其实并不矛盾,这就意味着,可以搞出一个双向循环链表来。
在实际的软件开发中,不能仅仅利⽤复杂度分析就决定使⽤哪个 数据结构来存储数据。
要对数组和链表的 各种性能进⾏对⽐,综合来选择使⽤两者中的哪⼀个。