【数据结构】 线性表的链接表
链接表
链接表简称链表,它的基本想法建立在下面三点之上
1. 把表中的元素分别存储在一批独立的储存块中(称之为链表的节点)。
2. 保证从组成表结构的任意一个节点出发可以找到与其相关的下一节点。
3. 在前一个节点里用链接的方式显式地记录与下一个节点之间的关联。
一般而言,链表的每个节点的储存单元里只储存一个元素,当然也可以储存多个元素,下面按照一个元素说明。其次每个节点的链接单元可以链接向多个方向。下面按照链接单元性质上的不同来说明不同的链表类型
■ 单链表
单链表指每个节点的链接域只储存下一个节点地址信息的链表,单链表是最简单的一种链表形式。每个节点从逻辑上来说是一个二元组(elem,next)。elem是元素域,用于储存这个节点的数据信息(或者在数据的关联信息)。next是链接域,保存着同一个链表中下一个节点的标识。
在常见的单链表中,引用首节点的元素p可以找到首节点所在的位置,从而通过链条找到整个链表的所有内容。也就是说,想要掌握一个单链表,只要掌握住这个链表的首节点的引用就可以了。这个首节点的引用也可以被称为表头变量或者表头指针。
理所当然,链表的尾节点上的链接域是空的,没有内容。所以通过判断一个节点的链接域是不是空值就可以知道链表有没有结束。严格来说,当表头指针就是空的时候也表示了链表结束,只不过此时链表是个空表,还没开始就已结束。
在实现单链表的算法上,并不需要关心某个具体的表里各个节点链接域中具体是什么值,而只需要关心节点之间的逻辑关系。对链表的操作也只需要根据链表的逻辑结构考虑和实现。
下面一个是通过python实现的一个单链表类,目前只有一个初始化方法:
class LNode():
def __init__(self, elem, next_=None):
self.elem = elem
self.next = next_
■ 基本链表操作
创建空链表:因为链表在创建的时候不需要空间大小,元素个数等参数,非常方便。只需要创建一个链表节点作为表头节点,然后把表头指针设置成None即可。
删除链表:指丢弃这个链表中的所有节点,在其他一些语言中可能需要一个一个节点地回收链表占据的空间十分麻烦,在python中,由于存在自动的资源回收系统,只需要把表头指针设置成None就可以了,系统会自动回收首节点后面的所有链表节点,因为他们是没人引用的资源了。
判断空链表:将表头指针和None作比较,如果表头指针是None那么就是一个空链表。一般而言链表是不限制元素个数的所以不会满,除非程序用光了可用的所有内存。
● 加入元素
同顺序表一样,加入元素到链表分情况考虑,插入在表头,插入在特定位置和插入在表尾。
插入在表头的情况,通过三步完成插入:1.创建一个新节点存入数据,用一个变量q来引用这个节点。 2.把当前表头指针的head赋值给q.next。3.把q赋值给head使得q成为表头指针。用代码来表示的话就是:
q = LNode(13)
q.next = head
head = q
这样13变成了这个链表的首元素。注意弄清首元素和表头指针的区别。表头指针是指用来引用首节点的那个变量,而首元素是指首节点的元素域中的内容。
如果插入是在指定的任意位置的话,那么按照以下步骤操作:1.创建一个新节点并且存入数据,用一个变量q来引用这个节点。2.把前一节点pre的next域赋值给q的next域。3.把q赋值给pre的next域。用代码来表示就是:(从这个过程中我们可以看到,在任意位置插入新节点时,不必关心插入后后一个节点的信息,而只要关注其前一个节点即可)
q = LNode(13)
q.next = pre.next
pre.next = q
至于在表尾插入新元素,从代码上说用上面这三行代码也是可以的。只不过在表尾的场合中,pre.next原本就是None。
● 删除元素
删除节点分成两种情况,分别是表头删除和表中删除。
表头删除的话很简单,只要把表头指针指向当前表中第二个节点即可。第一个节点由于失去了所有引用,python自动就把它回收处理了。
当在表中删除某个节点时,需要知道被删除节点的前一个节点的信息。而从代码上来说,只需要:
pre.next = pre.next.next
再一次,python用它的垃圾回收机制帮我们自动回收了这个被删除的节点(因为在此之前只有它的前一个节点的next域引用了它,当这个next域变成后一个节点的时候,这个节点就没有任何引用了)
● 定位,扫描和遍历
因为单链表的话只有一个方向的链接属性,开始情况下只掌握表头指针。想要深入表中,只有从表头节点开始一个个找下去,逐步进行。这种过程被称之为扫描。扫描的基本模式用代码来表示就是:
p = head #p代表了当前扫描的节点指针
while p is not None and 其他一些条件:
#对p中的元素数据做出一些处理
p = p.next #将p指向下一节点
这段代码中的while循环结束的条件可以由具体场景来定,当如果是还没有扫描完全部就可以跳出循环的情况那就是一个定位操作了。常见的定位有比如按下标定位:
p = head
while p is not None and i > 0:
i -= 1
p = p.next
另外还可以按元素内容定位,比如我们设计了一个谓语函数pred来判断元素的值是否是我们需要的:
p = head
while p is not None and not pred(p.elem):
p = p.next
从头到尾完整的一次扫描就是遍历了,遍历过程中往往是需要对整个链表中的所有元素内容都做出一定动作的时候:
p = head
while p is not None:
print p.elem
p = p.next
● 求表的长度
表长度可以在遍历之前维护一个变量来记录表长度,遍历一个节点这个变量+1:
p = head
count = 0
while p is not None:
count += 1
p = p.next
print count
因为遍历是一个O(n)的操作,当链表比较长的时候,且要较频繁地统计链表的长度的时候,每次都这么遍历一下是不明智的。一个解决的办法是学顺序表,在链表当中添加一个节点,让这个节点来维护链表长度的信息。(我看不出有什么必要一定要以一个链表节点的形式来维护链表长度的信息,我认为完全可以在链表外部维护一个变量,比如添加一个LNode类的类变量。。)
● 基本链表操作的复杂性
总结下链表操作的时间复杂度如下:
创建空表:O(1)
删除表:O(1)(在Python的使用层面上来说是这样,但是实际上python解释器还在内部进行了内存管理的操作,这部分可能并不是O(1)这么简单的)
判断空表:O(1)
加入元素:
表头加入:O(1)
表尾或定位加入:O(n)(因为需要先找到插入位置的前一个节点,而即使是通过下标找,因为是链表不是顺序表所以必将扫描整个表导致时间花费上升)
删除元素:
表头删除:O(1)
表尾或定位删除:O(n)
扫描,定位和遍历因为需要检查一批的表节点,所以其复杂度必然是O(n)的,也因此其他所有用到扫描,定位,遍历的操作的复杂度也不会低于O(n)
■ 单链表类的实现
刚才基本已经说完链表基本操作的所有规程,然后也给出了一个链表节点类的定义。结合两者应该就可以给出一个实现了链表的类了。下面是链表类LList的代码:(在这段代码之前就已经写了LNode类的定义了)
class LList():
def __init__(self):
self._head = None # 虽然上面提到了很多head的变动,但是这些变动应该都在类内进行。不让类的使用者在类外自由使用表头指针是有利的。
def is_empty(self):
return self._head is None
def prepend(self, elem):
q = LNode(elem)
q.next = self._head
self._head = q
def pop(self):
if self._head is None:
raise ValueError("empty link list")
e = self._head.elem
self._head = self._head.next
return e
def append(self, elem):
if self._head is None: # 空表时下面的循环进都不会进去,所以必须作为特殊情况单独拿出来考虑
self._head = LNode(elem)
return
p = self._head
while p.next is not None:
p = p.next # 不像顺序表那么方便,需要从头开始逐个遍历到表尾
p.next = LNode(elem)
def pop_last(self):
if self._head is None:
raise ValueError("empty link list")
p = self._head
while p.next.next is not None: # 这个比较聪明,当p.next.next是None的时候就意味着当前元素是倒数第二个了
p = p.next
e = p.next
p.next = None
return e
def printall(self):
p = self._head
while p is not None:
print p.elem,
if p.next is not None:
print ',',
p = p.next
def __str__(self):
self.printall()
return ""
然后是对这个链表类的使用:
mlist = LList()
for i in range(10):
mlist.prepend(i)
for i in range(11, 20):
mlist.append(i)
print mlist
#结果
#9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 , 11 , 12 , 13 , 14 , 15 , 16 , 17 , 18 , 19
■ 对上述实现的进一步改进
● 增加迭代功能
除了直接print出这个链表中所有内容,更多时候我们期待的是能够遍历列表中的内容然后做出一些操作。虽然遍历链表在类里面做了很多次了,但是目前还没有提供给外部的接口可以来遍历。所以有必要把这链表中的内容们做成可以迭代的形式。说到可迭代的话大概有两种思路。第一是额外添加一个生成器方法在类中,这样外部可以调用这个方法来获得一个链表内容的生成器。第二则是在类中实现__iter__和next方法让它本身变成一个迭代器。
关于第一种生成器的法子:
def elements(self):
p = self._head
while p is not None:
yield p.elem
p = p.next
for item in mlist.elements():
print item
甚至可以更加进阶一点,给生成器加个过滤函数:
def filter(self,pred):
p = self._head
while p is not None:
if pred(p.elem):
yield p.elem
p = p.next
#此时生成器生产数据之前还会先通过pred这个过滤函数判断一下是否要生成
关于第二种把类本身改造成迭代器的代码:(这段是我自己写的不是书上的,质量难免有下降。。)
def __iter__(self):
return self
def next(self):
if self._head is None:
raise StopIteration
if not hasattr(self,"count"):
self.count = 0
return self._head.elem
else:
self.count += 1
p = self._head
i = 0
while p is not None and i < self.count:
i += 1
p = p.next
if p is None:
raise StopIteration
else:
return p.elem
这样在LList的实例mlist就可以直接用for item in mlist这样的形式来迭代了。
■ 更多其他形式的链表
上面讲的主要都是单链表这一种链表,除此之外还有一些单链表的变形和双链表存在。
所谓单链表的变形就是指这种数据结构本质上还是单链表,但是通过一些结构上的小改进让这个单链表能更好地适应实际需要。
● 带有尾节点引用的单链表
以上提到的单链表,在尾部添加节点的过程中并不是很好。每次添加都要从头开始逐个向后推知道最后一个节点。为了提高后端插入的效率,可以在表头指针上再加一个引用至表尾节点的域,通过维护这个域,在想要进行表尾插入(或任何表尾操作)的时候,就可以只用O(1)的复杂度了。
如何实现这个带尾节点引用的链表?因为除了尾节点的操作之外,其他操作基本都没有变化,所以应该考虑通过继承和扩充之前定义的链表类来实现。基于这种想法,我们给出新类LList1。在其初始化方法中除了需要有一个self._head变量用来当做表头指针外还需要再加上一个self._rear用来表示尾节点指针。此外应该仔细考虑上面的LList类中已经给出的所有操作,对于有些操作比如判空,定位插入删除等可以不用管self._rear,而对于尾部插入删除,表头插入等等就需要考虑新加入self._rear之后这些操作应该做哪些变化使得我们可以顺利地维护好self._rear这个变量。
class LList1(LList): def __init__(self): LList.__init__(self) self._rear = None def prepend(self,elem): self._head = LNode(elem,self._head) #其实不用像LList中的prepend这么复杂,因为LNode类的初始化方法中已经给出了指定next的接口 if self._rear is None: #判断是否空表,如果是_rear还是None表明prepend前还是空表,就把_rear设置成等同于_head(表头即表尾) self._rear = self._head def append(self,elem): if self.is_empty(): #这里换了一种判空方式 self._rear = LNode(elem) self._head = self._rear else: self._rear.next = LNode(elem) self._rear = self._rear.next def pop_last(self): if self.is_empty(): raise ValueError("empty link list") p = self._head if p.next is None: e = p.elem self._head = None self._rear = None return e while p.next.next is not None: p = p.next e = p.next.elem p.next = None self._rear = p return e
结合上面的例子,简单地讨论一下类设计中的一个重要原则,就是要做到逻辑上的自洽。比如在加入尾节点指针之后,我们可能要改变对于空表的定义。原先空表是指self._head是None的表,但是现在是维持原定义不变还是要求_head和_rear同时为None才能算是空表呢。这一点上面我自己写的代码就不是很好。在很多地方很明显的表明我想的其实是想让_head和_rear同时为None才算空表,但是在判空的时候又用了is_empty()方法,这个方法只判断了_head的情况。所以最好的还是在LList1这个类中再重写一下is_empty方法来统一类的设计。
● 循环单链表
单链表的尾节点的next域通常是None,如果把它改成链表中某个节点的指针那么这个链表就变成了一个循环链表,如果这个节点是表头节点的话那么整个链表就变成了环状链表。
环状链表的表头和表尾相互衔接在一起,这就带来一个问题,我该用哪个指针来指代这个表。稍加思考可以看出,用表尾节点的指针来作为指代整个链表的对象比较合理。用表尾指针的话可以同时把表头操作和表尾操作都变成O(1)操作。当然在循环链表中的表头和表尾大多意义上只是个概念问题,从表的内部形态上来看没有明显的表头表尾。任何一个节点都可以做表头也可以做表尾。
实现循环链表需要注意几点:
1. 和带尾节点指针的链表一样,在初始化时应该考虑增加一个_rear变量,逻辑上始终指向尾节点,从而为其他操作提供一个参考
2. 进行循环遍历表时应该要改变循环判断条件,原先是判断一个节点的链接域是不是None,而现在应该判断某个节点是不是尾节点
3. prepend和append方法操作的其实是同一个对象了。所以在定义时可以定义一个方法然后在相对应的另一个中直接调用之前的那个方法。
具体实现就不多说了,书上的话没有继承LList类而是重新开了一个LClist类
● 双链表
单链表只能做一个方向的搜索和遍历,即使是增加了尾节点引用,也只是支持了O(1)的尾部操作。如果希望两端插入和删除操作都能高效完成,就需要改变节点的基本结构。比如改造链表为双链表,每个节点除了next链接域之外还应该有一个prev链接域指向本节点的前一个节点。下面简单说明一下双链表的实现。
首先是双链表节点的实现:继承单链表节点的LNode类
class DLNode(LNode): def __init__(self,elem,prev=None,next_=None): LNode.__init__(self,elem,next_) self.prev = prev
关于双链表类,可以选择继承一下单链表类,其中的判空操作,检索,print等等操作是可以保持不变的,因为这些操作即使是在双链表中也只用了next域控制。对于要变动节点的操作,应该都是要有锁改变的。另外,为了直接改进双链表为带有尾节点引用的双链表,我们让它直接继承LList1类。
class DLList(LList1): def __init__(self): LList1.__init__(self) def prepend(self,elem): p = DLNode(elem,None,self._head) if self._head is None: #如果是空表就设置一下尾节点引用 self._rear = p else: p.next.prev = p #如果不是空表,使得原表头几点的prev指向新建出来的节点 self._head = p def append(self,elem): p = DLNode(elem,self._rear,None) if self._rear is None: self._head = p else: p.prev.next = p self._rear = p def pop(self): if self._head is None: raise ValueError("empty double link list") e = self._head.elem self._head = self._head.next if self._head is not None: self._head.prev = None return e
■ 一些更高端的链表操作
以上是一些链表的变形。之前说到的链表操作大多都是基于链表结构的单参数操作,此外链表还有一些更加复杂一些的操作值得一提。
● reverse
链表进行反转并不是节点进行反转,因为节点的具体地址和形式对于用户而言是不知道的,所以我们只要把链表中的元素内容进行反转就可以让使用者以为链表整个已经反转过来了。一个进行反转操作的算法是在表头和表尾设置两个扫描指针,分别从前到后从后到前扫描链表,每扫描一对元素互换他们的位置直到两个指针相遇。但是这种算法要求链表可以进行双向扫描,双链表的话ともかく,对于单链表行不通。即使可以做到效率也是很差。
对于最简单的单链表,一个事实就是在表头部分进行操作是最高效的。表头操作时,不论是删除还是插入都是O(1)。基于这一点,为了改进单链表的反转算法,我们可以考虑逐个把一个表的节点pop出来然后prepend到一个新的表中去,因为插入新表也是从表头开始,所有得到的新表就变成了原来旧表的反转了。这个方法看起来似乎在空间上消耗有点大其实不然,因为链表不是顺序表,每个节点被删除后空间就被释放,而新建一个节点所占用的空间也仅仅只有这个节点的大小。由于旧表中pop和新表中prepend两个操作都是O(1)的,整体操作就变成了O(n)操作。比如像LList类中添加一个反转本身的方法,代码如下:
def reverse(self): p = None while self._head is not None: q = self._head self._head = q.next #摘下原来的表头节点 q.next = p p = q #将摘下的节点加入到p引用的新表中,并且把p移到新表的表头 self._head = p #最终改变本实例的表头引用到新表
● sort
排序操作比反转更加复杂些,书上为了说明链表的排序采用的是较为简单的排序算法,插入排序。插入排序的要义是三点:
1. 在操作过程中维护一个排列好的序列,初始情况下这个序列只有一个元素。这个序列在整个排序过程中一直保持正确的排序
2. 每次从尚未处理的乱序序列中取出一个元素,将其插入有序序列中,保证有序序列仍然有序
3. 所有元素都插入有序序列后排序结束
再具体一点,一般来说可以让有序序列和无序序列共同占有整个需要排序的序列的空间,初始有序序列元素为首元素。此外取出乱序序列中的元素后从哪个方向插入也是一个问题。一般顺序表可以考虑从后往前扫描,只要是大于当前元素的话就把有序序列中的元素后移。但是由于单链表只能从前往后扫描,所以这步操作要改成从前往后,只要是小于当前元素就保持不动,直到找到该插入的位置之后使得该位置之后的所有有序序列中的元素向后推移一格。所以我们可以得到的方法是这样的:
def sort1(self): if self._head is None: return crt = self._head.next #crt是指向乱序序列第一个节点的指针,初始情况下指向第二个节点 while crt is not None: x = crt.elem p = self._head while p is not crt and p.elem <= x: #从头开始扫描有序序列并判断当前节点元素内容是不是小于x,小于就不做处理 p = p.next while p is not crt: y = p.elem p.elem = x x = y p = p.next p.elem = x #循环跳出时有序序列的最大值还没有被赋给crt的位置,还要再操作一下 crt = crt.next
以上这种排序实现是基于元素值的交换。而在链表的场合中,其实还可以进行链接关系的转换。下面是转换链接关系的代码,我没有细看具体说明就不多讲了。
def sort(self): p = self._head if p is None or p.next is None: return rem = p.next p.next = None while rem is not None: p = self._head q = None while p is not None and p.elem <= rem.elem: q = p p = p.next if q is None: self._head = rem else: q.next = rem q = rem rem = rem.next q.next = p #最后三句赋值语句的顺序不能出错,否则将导致丢失正在处理的节点
■ 链表总结
链表相比于顺序表有自身的缺点和优点。分析如下:
优点:
表结构是通过一些链接形成的,所以表结构很容易修改和调整
修改表结构和数据排列方式不必修改或移动每一个数据本身,而可以通过修改节点之间的链接关系来实现
整个表由一些小储存块组成,在空间上来说很优化,比较容易安排
缺点:
定位访问需要线性的时间,相比于顺序表时间复杂度高
为了解决很多线性时间的问题可以对链表做出双链表、尾指针优化,但是这增大了结构复杂度和空间上的消耗