数据结构与算法之美——链表

数组需要一块连续的内存空间来存储,对内存要求比较高。如果我们申请一个一定大小的数组,当内存中没有连续足够大的空间时,即便内存剩余的总可用空间大于所申请的大小,仍然会申请失败。

链表恰恰不同,它不需要连续的内存空间,而是通过指针将一组零散的内存块串联起来使用。

链表有三种最常见的结构:单链表、双链表、循环链表。

单链表:

链表是通过指针将一组零散的内存块串联到一起。其中我们把每个内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址,我们把这个记录下一个节点地址的指针叫做后继指针next。有两个结点比较特殊,它们分别是第一个结点和最后一个结点,我们习惯性地把它们分别叫做头结点尾结点,其中,头结点用来记录链表的基地址,尾结点的指针不指向任何一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。

插入和删除:

与数组一样,链表也支持数据的查找、插入和删除操作。因为链表的存储空间本身就不是连续的,所以在链表中进行插入删除操作时非常快速的,我们只需要考虑相邻结点的指针变化,所以对应的时间复杂度是O(1)

查找:

链表的随机访问并没有数组那么高效。由于链表中的数据并非连续存储,所以无法通过首地址和下标,进行寻址公式的计算继而得到对应的内存地址,而需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。时间复杂度为O(n)

循环链表:

循环链表是一种特殊的单链表,它和单链表唯一的区别就在于:循环链表的尾结点指针是指向链表的头结点,它像一个环一样首尾相连,所以叫做循环链表。和单链表相比,循环链表的优点是从链尾到链头比较方便。当处理的数据具有环形结构特点时,就比较适合采用循环链表。

双链表:

双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针next指向后面一个结点,还有一个前驱指针prev指向前面一个结点。虽然两个指针比较浪费存储空间,但可以支持双向遍历,带来了双向链表操作上的灵活性。

插入和删除:

单链表和双链表都支持时间复杂度为O(1)的插入和删除操作,但是在实际操作中,双链表在时间上更具有优势。

以删除为例,在执行删除操作中,无非就是以下两个要求:

1.删除结点中“值等于n”的结点;

2.删除给定指针指向的结点。

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后将其删除。尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间对应的时间复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)。

对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!同理,如果我们插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。

除了插入、删除操作的优势外,在对于有序链表的按值查询的效率上,双向链表也比单链表要高,我们可以记录上次查找的位置p,在查询时,根据要查询的值与p的大小关系决定向前或向后查找,平均只需要查找一半的数据。

这是一种用空间换时间的设计思想,在内存空间充足时,我们可以选择空间复杂度较高、时间复杂度较低的算法或者数据结构!

双向循环链表:

循环链表和双链表的结合体。

链表VS数组:

数组简单易用,使用连续的内存空间,可以借助CPU的缓存机制预读数组中的数据,访问频率较高;链表对CPU缓存不友好,不能有效预读。与数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。数组大小固定,链表支持动态扩容。由于每个结点都需要存储一个指向下一个结点的指针,链表对内存的消耗会翻倍,同时,链表进行频繁的插入、删除操作会频繁的内存申请和释放,容易造成内存碎片。

写链表代码技巧:

一.理解指针或引用的含义

指针/引用:都是存储所指对象的内存地址。将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

二.警惕指针丢失和内存泄漏

在插入结点时,一定要注意操作顺序。

例:在a,b中间插入x

1.将结点x的next指针指向结点b;

2.将节点a的next指针指向结点x。

在删除结点时,也一点过要记得释放内存空间。

三.利用哨兵简化实现难度

针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。如果我们引入哨兵结点,head指针会一直指向这个哨兵结点,我们把这种带有哨兵结点的链表叫做带头链表。在带头链表中,插入第一个结点和删除最后一个结点都可以统一为相同的代码实现逻辑了。

四.重点留意边界条件处理

常用的检查链表代码是否正确的边界问题:

如果链表为空,代码是否能正常工作?

如果链表只包含一个/两个结点,代码是否能正常工作?

代码逻辑在处理头结点和尾结点的时候,代码是否能正常工作?

五.举例画图,辅助思考

对于稍微复杂的链表操作,使用举例法和画图法。

六.多写多练,多写多练

单链表反转

链表中环的检测

两个有序链表合并

删除倒数第n个结点

求中间结点

猜你喜欢

转载自www.cnblogs.com/westCastle/p/10494328.html