链表是一种常见的基础数据结构,是一种线性表,但是并不会按线性顺序存储数据,而是在每个节点里存储下一个节点的指针。
五花八门的链表结构
从底层的存储结构上看,数组需要一块连续的内存空间来存储,对内存要求较高。如果我们申请一个100MB大小的数组,当内存没有连续的、足够大的内存空间时,即便内存剩余总可用空间大于100MB,也会申请失败。
而链表相反,它并不需要一块连续的内存空间,它通过“指针”将一系列零散的内存块串联起来使用。
链表结构五花八门,比较常见的三种是:单链表、双向链表、循环链表。
单链表
链表通过指针将一组零散的内存块串联起来。其中,我们把内存块称为链表的“结点”。为了将所有结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址,把这个记录下一个结点地址的指针叫做后继指针next。
单链表为什么叫“单”链表,因为它是单向的。遍历链表只能从第一个结点开始,依次遍历,没有回头路。最后一个结点指向NULL,以此表示链表的结尾
与数组一样,链表也支持数据的查找、插入和删除操作。我们知道,数组插入、删除操作需要搬移大量数据元素,以保证内存数据的连续性,时间复杂度为O(n)。而在链表中插入或删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表空间本来就是不连续的。所以链表中插入和删除一个数据是非常快速的。
因为链表的插入和删除操作,只需要考虑相邻结点的指针改变,所以对应的时间复杂度为O(1)。
因为链表的存储方式,导致链表无法随机访问第k个元素。无法像数组一样根据首地址和下标直接计算出对应的内存地址。链表需要一个一个结点遍历的方式来访问第k个元素。因此链表随机访问的性能没有数组好,需要O(n)的时间复杂度。
循环链表
循环链表是特殊的单链表。针对单链表无法回头的问题,最后一个结点的指针不再是NULL,而是第一个结点的地址。如此就像一个环一样首尾相连,所以叫循环链表。
双向链表
不管单链表还是循环链表都是“单”向的。双向链表,顾名思义它是双向的。它支持两个方向,每个结点不止有后继指针next指向下一个结点,还有一个前驱指针prev指向前面的结点。
单链表和循环链表如果要找到某个结点的前趋结点需要从头开始遍历。而双向链表可用支持O(1)的时间复杂度找到前驱结点。正是因为如此,使双向链表在某些情况下的插入、删除操作都要比单链表简单、高效。
先看删除操作。
从链表中删除一个数据无外乎两种情况:
- 删除结点中“值等于某个给定值”的结点。
- 删除给定指针指向的结点。
对于第一种情况,无论单链表和双向链表都需要从头依次遍历比对值,直到找到值等于给定值的结点,将它删除。
尽管单纯的删除结点操作时间复杂度为O(1),但是查找过程是主要的耗时点,因此链表删除给定值的结点的操作的时间总复杂度是O(n)。
对于第二种情况,我们已经找到了要删除的结点,但是要删除一个结点,需要知道这个结点的前驱结点,因为我们要将前驱结点的指针指向被删除结点的下一个结点。,而单链表并不支持直接获取前驱结点,所以为了找到前驱结点,我们还要从头结点开始遍历链表。而双向链表则可以很方便的删除。
同理,如果我们希望在链表的某个指定结点前面插入一个结点,双向链表也比单链表有很大的优势。
对于有序链表,双向链表按值查询的效率也要比单链表高。因为,我们可以记录上一次查找的位置p,每次查询时,根据要查找的值和p的大小关系,决定是往前还是往后遍历查找,所以平均只需要查找一半的数据。
这里面蕴含着一个设计思想,那就是用空间换时间,双向链表占用的空间更多,但效率更高。
找链表的中间位置
使用快慢指针,可以找到链表的中间位置。
慢指针每次移动一步,快指针每次移动两步。
显然链表的长度(大于0)要么为偶数要么为奇数。
当为偶数时,如下图:
蓝色为慢指针,黄色为快指针。可以看到当为偶数时,快指针到空位置NULL。而慢指针到链表长度一半的位置的下一个结点。
当为奇数时,如下图:
这个方法在某些地方算法中很实用的。