在之前介绍SkipList的文章当中,有一些同学反馈说由于对链表缺少认知以及了解,所以直接啃算法有些过于困难。加上之前的文章当中介绍过了栈,所以这次继续线性表这个话题,我们来一起讨论一下链表。
链表是很多数据结构的基础,它的最大特点是支持快速的删除和插入,因此在很多数据频繁变动的场景下使用广泛。而且链表的可拓展性较强,所以它的应用非常广泛,相关的拓展和改进版本也很多。今天我们和大家介绍的是双端链表,也称为双向链表,它是寻常单向链表的改进版本,也是会经常使用的链表。
单向链表
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。以上是维基百科当中的定义,我们可以明白两点,首先链表由多个节点组成的,每个节点存储了下一个节点的位置。其次,链表的各个节点不是顺序存储的。
我们看一下下图可以加深一下了解:
上图当中展示是单向链表,单向的意思是说每个节点只有一个指向后继节点的指针,也就是说链表只有一个遍历方向,因此称为是单向链表。
链表的增删
初学者在学习链表时可能会头疼它的使用,相比于数组的直接访问,链表需要通过移动指针来遍历节点修改节点的内容来完成增删,因此不如数组直观。我一直想要找到一个很好的例子来比喻链表运行的机制,直到有一次看谍战片,我发现间谍联络的系统就是以链表形式工作的。所以我决定以间谍系统来举例,介绍一下链表的工作原理。
假设你是民国时期军统的头目,你负责一个谍报链路。为了安全,你和你的手下们是单向联系的。也就是说只能你联系你的一个手下,由这个手下再去联络其他人,最终把暗号交到目标的手上。假设你的代号是A,你的手下是B,B的手下是C,C来执行任务。这个机制一直运转很好,直到有一天,B因为神秘原因转移了地点,导致A和B联络的时间变长,为了解决这个问题,你决定新增一个人手D专门负责A和B之间的联络,加快联络速度。你应该怎么办呢?
由于身份限制,以及安全原因,你是不知道B的具体信息的。你只能将消息给到A,让A去联络新人D,并且告诉他B的联络方法。还有一点需要注意,A必须等到D成功找到了B之后再切断和B的联系。否则一旦D出现什么意外,这整条链路就断了。
我们把整个图画出来如下:
先让D和B取得联系,之后断开A和B的联系。但是有一个问题,由于A的后继只有一个,A如果指向D,那么B的位置就会丢失。所以我们需要先用一个临时变量cur存储下来B的位置,然后再让D指向这个cur。不过在Python当中,我们可以不用这么麻烦,利用Python的多变量赋值的方法,我们可以一行代码搞定。
代码如下:
def add(pos, new_node):
new_node.next, pos.next = pos.next, new_node
假如过了一段时间,B又回到了原处,我们不需要D了,要删除这个节点该怎么办?很简单,直接让D将B的最新的联络方式给A就可以了。也就是说让A跨过D指向B即可。
来看图:
def delete(pos):
pos.next = pos.next.next
双向链表
理解了单向链表,双向链表也就很简单了。双向的意思也很明显,每个节点除了记录后继节点的位置之外,还会记录源头节点的位置。有了双向指针之后不仅是获取来源节点方便而已,并且也可以很方便地对整个链表进行倒叙遍历和头部插入。
还记得我们之前Python专题当中介绍过的deque这个库吗?通过deque我们可以实现一个双向增删元素的队列,结合双向链表的定义,很容易发现deque其实就是保留了一部分api的双向链表。换句话说deque是基于双向链表实现的,就和栈是基于list实现的一样。
和单向链表相比,由于我们多了一个指针,理解和实现起来会更加容易,因为之前需要通过顺序关系以及临时变量完成的内容现在可以通过前向指针很轻易地实现了。
下面附上双向链表增删的代码:
def add(pos, new_node):
pos.next, new_node.next = new_node, pos.next
new_node.pre, new_node.next.pre = pos, new_node
def delete(pos):
pos.next = pos.next.next
# 这个时候pos.next已经更新
pos.next.pre = pos
**总结 **
双向链表本身并不复杂,也没有太多变化的花样,和之前介绍的SkipList相比要简单许多。我相信即使是初学者,只要自己动手实现一遍,也足够掌握。在我初学数据结构的时候,我非常抗拒使用链表,除了觉得寻址很麻烦,需要遍历整个链表耗时很大之外。另一个根本的原因是在C++当中链表的编写很麻烦,而且很容易有内存泄漏以及野指针问题。所以我当时尽可能地使用数组作为替代,并且甚至一度认为随着内存价格的降低,总有一天我们可以抛弃链表这个结构。
直到后来我学习了操作系统之后,我找到了一个必须使用链表的理由。因为在操作系统当中,内存并不是连续的,大部分内存都是分散的。当我们创建一个数组的时候,我们其实是在想操作系统申请一块连续的内存。我们申请的数组越大,这块内存也就越大。显然越大的内存越难申请到,因为内存大多被切分成了许多碎片,在资源不够的情况下,操作系统需要做大量的工作才能将碎片搜集起来。
而链表因为通过指针寻址,所以可以避免这个问题,链表当中的元素分散在内存各处,分摊了内存消耗的压力。这也是在操作系统领域当中,链表大量使用的原因。
最后,由于篇幅限制,我们没有放上全部的代码,想要获取完整双向链表代码的同学,可以关注我的公众号,回复“双向链表”获取。