线性表是某类元素的一个集合,还记录着元素之间的一种顺序关系。
线性表是最基本的数据结构之一。python的内置类型list、tuple都可看作线性表的实现。
3.1.1 表的概念和性质
假设E是一个(有穷或无穷的)基本元素集合,E中的元素可能都是某个类型的数据对象。一个线性表就是E中的一组有穷个元素排成的序列L = (e1, e2, …, en-1)。
表元素之间存在一个基本关系,称为下一个关系。对于表L,其下一个关系是二元组的集合{<e0, e1>, <e1, e2>, …, <en-2, en-1>}。下一个关系是一种顺序关系,即线性关系,线性表是一种线性结构。
3.1.2 表抽象数据类型
3.1.3 线性表的实现:基本考虑
主要考虑两方面情况:
1) 计算机内存的特点,以及保存元素和元素顺序信息的需要。
2) 各种重要操作的效率。
基于各方面考虑,人们提出了两种基本的实现模型:
1) 将表中元素顺序地存放在一大块连续的存储区里,这样实现的表被称为顺序表(或连续表)。在这种实现中,元素间的顺序关系由它们的存储顺序自然表示。
2) 将表元素放在通过链接构造起来的一系列存储块里,这样实现的表被称为链接表,简称为链表。
3.2 顺序表的实现
表中元素顺序存放在一片足够大的连续存储区里,首元素存入存储区的开始位置,其余元素依次存放。元素之间的逻辑顺序关系通过元素在存储区里的物理位置表示(隐式表示)。
3.2.1 基本实现方式
最常见情况是一个表里保存的元素类型相同,因此存储每个表元素所需的存储量相同,可以在表里等距安排同样大小的存储位置。这种安排可以直接映射到计算机内存单元,因此存取操作可以在O(1)时间内完成。
顺序表元素存储区的基本表示方式如图3.1a。
如果表元素大小不统一,可以采用另一种布局。将实际数据元素另行存储,在顺序表各单元位置只保存对相应元素的引用。由于每个链接所需的存储量相同,通过上述公式,可以计算出元素链接的存储位置,然后再做一次间接访问,就能得到实际元素的数据了。如图3.1b。
在图3.1b中,顺序表保存的不是实际数据,而是找到实际数据的线索,这种顺序表被称为对实际数据的索引,这是最简单的索引结构。
线性表的一个重要性质是可以插入/删除元素,即在一个表的存续期内,其元素个数可能发生变化。这就带来一个问题:在创建一个表时,应该分配多大的一块存储区?表元素存储块需要安排在计算机内存里,一旦分配就占据了内存中的一块区域,有了固定的大小(并由此确定的表的容量)。而且该块存储区的前后可能都被其他有用对象占据,存储块的大小不能随便变化,尤其是无法扩充。
在创建顺序表时,一种可能是按创建时确定的元素个数分配存储,这种做法适合创建不变的顺序表,例如tuple。如果是变动的表,就必须区分表中元素个数和元素存储区的容量。一个合理的方法是分配一块足以容纳当前需要记录的元素的存储块,另外保留一些空位,以满足增加元素的需要。
元素存储区的大小决定了表的容量,这是分配存储块时确定的。元素个数的记录要与表中实际元素个数保持一致,在表元素发生变化时,都需要更新这一记录。
3.2.2 顺序表基本操作的实现
假设表容量为max,表中元素个数为num。
创建和访问操作
1) 创建空表:需要分配一块元素存储,记录表的容量并将元素计数值设为0。注意创建新表的存储区后,应立即将max和num设置好,保证这个表处于合法状态。
2) 简单判断操作:判断表空和表满。表空:当且仅当num=0,表满:当且仅当num==max。这两个都是O(1)操作。
3) 访问给定下标i 的元素:访问表中第 i 个元素时,需要检查 i 的值是否在表的合法元素范围内,即0 <= i <= num-1。超出范围的访问是非法访问。位置合法时需要算出元素的实际位置,然后由给定位置得到元素的值。该操作不依赖表中元素个数,因此是O(1)操作。
4) 遍历操作:顺序访问表中元素,只需在遍历过程中用一个整数变量记录遍历到达的位置。每次访问表元素时,通过存储区开始位置和上述变量的值在O(1)时间内可算出相应元素的位置,完成元素访问。遍历中要保证下标加一减一后的访问不超过合法范围。
5) 查找给定元素d(第一次出现)的位置:即检索。在没有其他信息的情况下,只能通过d与表中元素逐个比较的方式实现检索,称为线性检索。
6)查找给定元素d在位置k之后第一次出现的位置:与上面操作的实现方式类似,只需从k+1位置的元素开始比较。
7) 另一种需求与最后两个操作类似:给定一个条件,要求找到第一个满足该条件的元素,或者从某位置之后满足条件的下一个元素。
5~7这几个操作都需要检查表元素的内容,属于基于内容的检索。
变动操作:加入元素
1) 尾端加入:即把新数据项存入下标为num的位置,如果这时num==max,表满了,操作就会失败。如果表不满就直接存入元素,并更新元素个数。该操作为O(1)时间。如图3.4a。
2) 新数据存入元素存储区的第 i 个单元:首先需要检查下标 i 是否为插入元素的合法位置。通常情况下位置 i 已经有数据(除非 i == num),存入新数据时又不能简单地抛弃原有数据,就必须把该项数据移走。移动数据的方式需要根据操作的要求确定。此外,操作前也要检查表是否已满,操作结束后的状态还应保持有数据的单元在存储区前段连续,以及num的正确更新。
如果操作不要求维持原有元素的相对位置(即不要求保序),可以把原来位置 i 上的元素移到位置num,腾出来的位置 i 放入新元素,最后元素个数加1。这一操作为O(1)时间。如图3.4b。
如果要求保序,就必须把插入位置 i 之后的元素逐一下移,最后把新数据项存入位置 i 。这种 操作的开销与移动元素的个数成正比,最坏和平均情况都是O(n)。如图3.4c。
变动操作:删除元素
1) 尾端删除数据:只需将元素计数值num减一。这样表尾元素不再位于合法范围内,相当于删除了。如图3.5a。
2) 删除位置 i 的数据:首先要检查下标 i 是否为当前表中的合法元素位置,合法位置为0 <= i <= num-1。与插入元素情况类似,也分为两种情况:如果不要求保序,就把当时位置num-1上的数据拷贝到位置i ,覆盖原来的元素,然后修改元素计数值。如图3.5b。如果要求保序,就将位置i之后的元素逐个上移,然后修改元素计数值。如图3.5c。
3)基于条件的删除:给定需要删除的数据项d本身,或者给出一个条件要求删除满足这个条件的(一个、几个或所有)元素。这种操作与检索有关。时间复杂度一般为O(n)。
顺序表及其操作的性质
一般情况的定位插入和删除,
这里只考虑保序操作,那么定位插入和定位删除这两个操作的平均时间复杂度和最坏情况的时间复杂度都是O(n)。
各种访问操作,如果其执行中不需要扫描表内容的全部或一部分,其时间复杂度都是O(1),需要扫描表内容操作时间复杂度都是O(n)。
顺序表总结:
优点:O(1)时间的按位置访问元素;元素在表里存储紧凑,除表元素存储区之外只需要O(1)空间存放少量辅助信息。
缺点:需要连续的存储区存放表中元素,如果表很大,就需要分配很大片的连续内存空间。一旦确定了存储块大小,可容纳元素个数并不随着插入/删除操作而变化。如果在很大的存储区里只保存了少量数据,就会有大量闲置内存单元。定位插入和定位删除通常需要移动大量元素,效率低。创建表时需要多大存储区很难预估。
3.2.3 顺序表的结构
一个顺序表的完整信息包括两部分:一部分是表中的元素集合,另一部分是为实现正确操作而记录的信息(即关于表整体情况的信息,比如max和num)。一个具体数据结构应该具有一个对象的整体形态。如何把这两部分信息组织为一个顺序表的实际表示,是实现顺序表时需要解决的第一个问题。
两种基本实现方式
表的全局信息只需要常量存储,对于不同的表,这部分信息的规模相同。根据计算机内存的特点,至少有两种可行的表示方式。
1) 一体式结构:实现比较紧凑,有关信息集中在一起,整体性强,易于管理。由于是连续存放,根据下标访问元素,仍然可以使用之前的公式,只需加一个常量:
Loc(ei) = Loc(e0) + C + c * i # 其中C为数据成分max和num的存储量。
这种方式也有缺点。比如,元素存储区是表对象的一部分,因此不同的表对象大小不一(因为存储区大小不同)。另外,创建后存储区就固定了。
2) 分离式结构:表对象里只保存与整个表有关的信息,实际元素存放在另一个独立的存储区里,通过链接与表对象关联。这样表对象大小统一,但不同表对象可以关联不同大小的存储区,创建和管理复杂一些。基于下标的元素访问,仍然可以在O(1)时间内完成。但是操作要分两步:首先从表对象找到存储区,然后使用公式计算存储位置。显然比一体式代价略高。
替换元素存储区
分离式实现的最大优点是带来了一种新的可能:在标识不变的情况下,为表对象换一块元素存储区。也就是说,表还是原来的表,其内容可以不变,但容量改变了。操作过程如下:
(1) 另外申请一块更大的元素存储区。
(2) 把表中已有的元素复制到新存储区。
(3) 用新的元素存储区替换原来的元素存储区(改变表对象中的链接)
(4) 实际加入新元素。
人们把采用分离式技术实现的顺序表称为动态顺序表,因为其容量可以在使用中动态变化。
尾端插入和存储区扩充
如果创建表的时候不能确定表的最终大小,又要保证操作的正常进行,采用动态顺序表就是最合理的选择。现在考虑这种表的增长过程的时间复杂度问题。
假设随着操作的进行,动态顺序表的大小从0逐渐扩大到n(也就是假设每次都不够)。
如果采用首端插入或一般定位插入,每次操作的间开销都与表长度有关(O(n)),总时间开销应该与n的平方成正比,也就是说,整个增长过程的时间复杂度为O(n²)。
如果采用尾端插入,一次插入操作本身是O(1)。但更换存储区时需要复制表中所有元素,复制操作需要O(n)时间。
那么应该怎样选择新存储区的大小呢?这就涉及空闲存储单元的数量和替换存储的频度问题。
考虑一种简单策略:每次替换存储时增加10个元素存储位置,这种策略被称为线性策略。假设表长从0增加到1000,每加入10个元素就要换一次存储,那么总的元素复制次数为10+20+30+...+990=49500。对一般的n,总的元素复制次数大约为1/20*n2。也就是说虽然每次做尾端插入的代价是O(1),加上元素复制之后,执行一次插入操作的平均代价还是达到了O(n)。每次增加多少个存储位置影响的仅仅是`n²`前的系数。
应该有这样一种策略:其中随着元素数量的增加,替换存储区的频率不断降低。假设表中元素从0增加到1024,存储区大小依次为1,2,4,8,...,1024,表增长过程中复制元素的次数是1+2+4+...+512=1023,对于一般的n,在整个表的增长过程中,元素复制次数也是O(n)。也就是说,采用存储增长的加倍策略和尾端插入操作,将一个表从0增加到n,插入操作的平均时间复杂度为O(1)。
python中的list采用了一种更快的策略,下面有讲。
对于线性策略,无论n取什么值,元素存储区的最大空闲单元数为9,而加倍策略空闲单元可以达到n/2,为了获得时间上的优势,在空间上付出了代价。
还有一个问题值得注意:动态顺序表尾端插入的代价不统一。大多数可以在O(1)时间内完成,但也会因为替换存储区而出现高代价操作。当然,高代价操作的出现很偶然,并将随着表的增大而变得越来越稀疏。另一方面,不间断插入元素是这里的最坏情况,一般情况下插入和删除操作交替出现,替换存储区的情况会更稀少。但无论如何,高代价操作是可能出现的,应该注意这一情况,特别是开发时间要求特别高的应用时。
缓解上述问题的一种可能是增加一个设定容量的操作。这样,如果程序员知道下面一段操作的时间要求必须得到保证,就可以在这个操作前根据情况把表设定到一个足够大的容量,保证在关键计算片段中不会出现存储器替换。
3.2.4 python的list
list和tuple都采用了顺序表的实现技术,具有顺序表的所有性质。tuple是不变的表,因此不支持改变其内部状态的任何操作。在其他方面,它与list的性质类似。
list的基本实现技术
list是一种元素个数可变的线性表,可以加入和删除元素,在各种操作中维持已有元素的顺序。其重要的实现约束还有:
1) 基于下标的高效访问和更新,时间复杂度应为O(1)。
2) 允许任意加入元素,而且在不断加入元素的过程中,表对象的标识(即函数id得到的值)不变。
这些基本约束决定了list的可能解决方案:
1) 由于要求O(1)时间访问元素,并在各种操作中保序,这种表只能采用顺序表技术,表中元素保存在一块连续存储区里。
2) 要求能容纳任意多的元素,就必须可以更换存储区,而且保证标识不变,只能采用分离式实现技术。
所以list是一种采用分离式技术实现的动态顺序表,其性质都源于这种实现方式。
list的元素存储区调整策略:在建立空表(或很小的表)时,系统分配一块能容纳8个元素的存储区;在执行插入操作时,如果元素区满就换一块4倍大的存储区。但如果当时的表已经很大(这里的“很大”是个确定的参数,目前的值为5000),系统将改变策略,换存储区时容量加倍,这样可避免出现过多空闲的存储位置。
一些主要操作的性质
list结构的其他特点也由其顺序表实现方式决定。例如,由于其中一般位置的插入和删除操作都是保序的,要移动一些表元素,因此需要O(n)时间。
所有序列(无论是否变动序列)都有的共性操作,复杂度由操作中需要考察的元素个数确定。
python的一个问题是,没有提供查询list对象当前存储块容量的操作(并不是指元素个数,而是存储单元个数),也没有设置容量的操作,一切与容量有关的处理都由python解释器自动完成。这样做的优点是降低编程负担,避免人为操作可能引起的错误。但这种设计也限制了表的使用方式,前面提到的存储区调整策略在这里也难以使用了。
几个操作
lst.clear() 清除表中所有元素,这应该是个O(1)操作。但具体实现的情况未见说明。有两种明显可行的做法:
1) 简单地将lst的元素计数值设为0,这样元素存储区里的所有元素都看不见了,自然应该看作空表。
2) 另行分配一个空表用的存储区,原存储区直接丢弃。python解释器的存储管理器将自动回收这个存储块。
第一种实现最简单,操作效率高,但不能真正释放元素存储区占用的存储。如果在执行clear之前这个表很长,执行操作后表里已经没有元素了,但仍占用原有的大块存储。第二种实现是再次从空表开始,如果这个表再增长到很大,过程中又要一次次更换存储区。
lst.reverse() 修改lst自身,将元素顺序倒置。时间复杂度为O(n)。
#####################
插播一组重要内容,倒置
1. 列表倒置,因为列表是可变类型,所以比较简单,
1 name = ['y', 'a', 'n', 'g', 'x', 'i', 'a', 'o', 'l', 'i', 'n', 'g'] 2 name.reverse()
2. 字符串倒置
reversed()虽然也能进行倒置,但不是我们想要的结果。
1 name = 'yangxiaoling' 2 name2 = reversed(name) 3 print(name2) 4 print(name2.__next__()) 5 print(name2.__next__()) 6 print(name2.__next__()) 7 # 结果: 8 # <reversed object at 0x000002537401C668> 9 # g 10 # n 11 # i
因为字符串是不可变类型,所以转置之前需要先转为可变类型,比如列表
1 name = 'yangxiaoling' 2 name = list(name) # 把字符串打散最简便的方法 3 # print(name) # ['y', 'a', 'n', 'g', 'x', 'i', 'a', 'o', 'l', 'i', 'n', 'g'] 4 5 i, j = 0, len(name)-1 6 while i < j: # 这样处理只要迭代一半的数据就能解决问题,比遍历要好,但时间复杂度仍为O(n)。 7 name[i], name[j] = name[j], name[i] 8 i, j = i+1, j-1 9 10 print(''.join(name)) # gniloaixgnay
#####################
lst.sort() 排序,这是个变动操作。最好的排序算法,平均和最坏情况的时间复杂度都为O(n*logn)。python的排序算法就是这样。
3.2.5 顺序表的简单总结
顺序结构是组织一组元素的最重要方式。
顺序表的优点和缺点都在于其元素存储的集中方式和连续性。
从缺点看,这样的表结构不够灵活,不容易调整和变化。如果在一个表的使用中需要经常修改结构,反复操作的代价可能很高。如果程序里需要巨大的线性表,采用顺序表就需要巨大块的连续存储空间,这可能造成存储管理方面的困难。
链接表在这两方面都有优势。
3.3 链接表
线性表的另一种实现技术。
3.3.1 线性表的基本需要和链接表
线性表的定义,一些元素的序列,维持着元素之间的一种线性关系。
实现线性表的基本需要:
1) 能够找到表中的首元素(无论直接还是间接)。
2) 从表里的任一元素出发,都可以找到它之后的下一个元素。
把元素保存在连续的存储区里,自然能满足这两个需求,其中元素间的顺序关联是隐含的。但要满足线性表的这两种需求,并不一定需要连续存储元素,对象之间的链接也可看作一种顺序关联,基于它也可以实现线性表。用链接关系表示顺序关联是显式的。基于链接技术实现的线性表称为链接表或链表。
下面只考虑在每个结点里只存储一个表元素的情况。
3.3.2 单链表/单向链接表
单链表的结点是一个二元组,其表元素域elem保存着数据项,链接域next保存着下一结点的标识。
要想掌握一个单链表,只需用一个变量(图中为p)保存首结点标识,这种变量称为表头变量或表头指针。
为了表示一个链表的结束,只需给表的最后结点的链接域设置一个不会作为结点对象标识的值(称为空链接),python中用`None`表示。
在实现链表上的算法时,并不需要关心在某个具体的表里各结点的具体链接值是什么(虽然保存在表结构里的值都是具体的),只需关心链表的逻辑结构。对链表的操作也只需根据链表的逻辑结构考虑和实现。
为方便下面的讨论,现在定义一个简单的表结点类:
1 class LNode: 2 def __init__(self, elem, next_=None): 3 self.elem = elem # 元素域 4 self.next = next_ # 链接域
基本链表操作
1) 创建空链表:只需把相应的表头变量设置为空链接,在python中设置为None。
2) 删除链表:丢弃这个链表中的所有结点。在python中只需将表指针赋值为None,就抛弃了链表原有的所有结点。python解释器的存储管理系统会自动回收不用的存储。
3) 判断表是否为空:将表头变量的值与空链接比较。在python中就是检查相应变量的值是否为None。
4) 判断是否已满:一般而言链表不会满,除非程序用完了所有可用的存储空间。
加入元素:在链表里插入新元素不需要移动已有数据,只需为新元素安排一个新结点,然后根据操作要求,把新结点连在表中的正确位置。
首端插入:O(1)
需要三步完成:
1) 创建一个新结点并存入数据。
2) 把原链表首结点的链接存入新首结点的链接域next。
3) 修改表头变量,使之指向新结点。
实现代码:
1 q = LNode(13) 2 q.next = head 3 head = q
一般情况的元素插入:要想在单链表的某个位置插入一个新结点,必须先找到该位置之前的那个结点,修改其链接域。如何找到前一结点稍后讨论,先看已经找到前一结点之后怎么插入元素。假设变量pre已指向前一结点,操作分三步:
1) 创建一个新结点并存入数据。
2) 把pre链接域的值存入新结点的链接域。
3) 把新结点的链接存入pre的链接域。
代码实现:
1 q = LNode(13) 2 q.next = pre.next 3 pre.next = q
删除元素:
1) 删除表首元素:只需修改表头指针,令其指向表中第二个结点。丢弃不用的结点将被python解释器自动回收。
实现代码:
1 head = head.next
2) 一般情况的元素删除:必须先找到要删除结点的前一结点,用变量pre指向,然后修改pre的链接域,使之指向被删结点的下一结点。
实现代码:
1 pre.next = pre.next.next
一般情况下的插入和删除元素,都要求找到当前结点的前一结点。
扫描、定位和遍历
链表的扫描,对表内容的一切检查都只能从表头变量开始,沿着表中链接逐步进行。
1 p = head 2 while p and 还需继续的其他条件: 3 对p所知结点里的数据进行操作 4 p = p.next
循环中使用的辅助变量p称为扫描指针。每个扫描循环必须使用一个扫描指针作为控制变量,每步迭代必须检查其值是否为None,保证随后操作的合法性。这与连续表的越界检查类似。
常用操作的实现
1) 按下标定位:确定第 i 个元素所在的结点。python中,下标都是从0开始的,链表也不例外。
1 p = head 2 while p and i > 0: # 第0个元素是表头 3 i -= 1 4 p = p.next
if p:
找到了
else:
没找到
循环结束时可能出现两种情况:或者扫描完表中所有结点还没找到第 i 个结点(即i大于链表长度),或者p所指结点就是所需。通过检查p值是否为None可以区分这两种情况。这就和前面在一般情况下插入或删除元素要先找到前一结点接上了。当需要删除第k个结点时,可以先设置i为k-1,循环后检查i == 0且p.next != None就可以执行删除了。
按元素定位:假设需要在链表里找到满足谓词pred的元素。同样可以参考上面的表扫描模式。
实现代码:
1 p = head 2 while p and not pred(p.elem): # pred(p.elem)表示符合某条件,不符合条件就继续往下找 3 p = p.next
循环结束时或者p是None;或者pred(p.elem)是True,找到了所需元素。
完整的扫描称为遍历。
1 p = head 2 while p:
print(p.elem) 3 p = p.next
链表操作的复杂度
创建空表:O(1)。
删除表(指的是删除整个链表,而不是删除链表中的元素):python中是O(1)
判断空表:O(1)
加入元素:
首端加入元素:O(1)。
尾端加入元素:O(n),因为要找到最后结点,修改其链接域。
定位加入元素:O(n),平均情况和最坏情况。(最坏情况就是尾端加入)
删除元素:
首端删除元素:O(1)
尾端删除元素:O(n),因为需要找到表的倒数第二个结点。
定位删除元素:O(n),平均情况和最坏情况。(平均情况相当于删除中间结点,最坏情况就是尾端删除)
扫描、定位和遍历操作都需要检查一批表结点,其时间复杂度受表结点个数约束,都是O(n)操作。
求表的长度
1 def length(head): 2 p, n = head, 0 3 while p: 4 n += 1 5 p = p.next 6 return n
需要遍历表中所有结点,所以需要O(n)时间。
实现方式的变化
以求表的长度为例,如果程序经常需要调用上面的函数,O(n)复杂度就可能成为效率问题。如果表很长,执行该函数就可能造成可察觉的停顿。解决这个问题的一个方法是改造单链表的实现结构,增加一个表长度记录。这个记录不属于任何表元素,是有关表的整体信息。表示这件事的恰当方法是定义一种链表对象,把表的长度和表中的结点链表都作为这个表对象的数据成分。如下图:
图中变量p指向表对象,表对象的一个数据域记录表中元素个数,另一个域记录链表的引用。这样求表长度就能实现O(1)操作,但另一方面,这种表的每个变动操作都需要维护计数值。这种调整消除了一个线性时间操作,可能在应用中很有意义。
3.3.3 单链表类的实现
1 class LNode: 2 def __init__(self, elem, next_=None): 3 self.elem = elem # 元素域 4 self.next = next_ # 链接域 5 6 llist1 = LNode(1) 7 p = llist1 # p是扫描指针 8 for i in range(2, 11): # 总共创建了10个结点 9 p.next = LNode(i) 10 p = p.next 11 12 p = llist1 # 扫描指针移到首端 13 while p: # 遍历链表 14 print(p.elem) 15 p = p.next
自定义异常
1 class LinkedListUnderflow(ValueError): 2 pass
python提供了一套预定义的标准异常,每个异常都是一个类,按继承关系形成了一个层次结构。按python的规定,程序里所有自定义的异常类必须是标准异常类的派生类,更确切地说,它们都应该继承异常类Exception或它的直接或间接派生类。如果在程序里需要定义异常,就应该从标准异常类中选一个最接近所需的异常,在定义自己的异常时继承这个异常类。
LList类的定义,初始化函数和简单操作
基于这种结构:
现在基于结点类LNode定义一个单链表对象的类,在这种表对象里只有一个引用链表结点的_head域,初始化为None表示建立的是一个空表。
1 class LinkedListUnderflow(ValueError): 2 pass 3 4 class LNode: 5 def __init__(self, elem, next_=None): 6 self.elem = elem # 表元素域 7 self.next = next_ # 链接域 8 9 class LList: 10 def __init__(self): # 创建一个空链表 11 self._head = None 12 13 def is_empty(self): 14 return self._head == None 15 16 # 在表头插入数据 17 def prepend(self, elem): 18 # q = LNode(elem) 19 # q.next = self._head 20 # self._head = q 21 # 可以用一行实现 22 self._head = LNode(elem, self._head) 23 24 # 删除表头结点并返回这个结点里的数据 25 def pop(self): 26 if not self._head: 27 raise LinkedListUnderflow('in pop') 28 e = self._head.elem 29 self._head = self._head.next 30 return e
这里把LList对象的_head域作为对象的内部表示,不希望外部使用。python程序中属于这种情况的域按照习惯用单个下划线开头的名字命名。
尾端操作
尾端插入:
1 def append(self, elem): 2 # 这里需要区分两种情况:如果原表为空,引用新结点的就应该是表对象的_head域,否则就是已有的最后结点的next域。两种情况下需要修改的数据与不同。 3 # 很多链表的变动操作都会遇到这个问题,只有表首端插入/删除可以统一处理。 4 if not self._head: 5 self._head = LNode(elem) 6 return 7 8 # 先扫描链表,确定最后一个结点 9 p = self._head # 需要提前考虑空链表的情况 10 while p.next: 11 p = p.next 12 p.next = LNode(elem)
尾端删除:
在尾端删除的操作里,扫描循环应该找到表中倒数第二个结点,也就是找到p.next.next为None的p。
在开始一般性扫描之前,需要处理两个特殊情况:如果表空就引发异常。表中只有一个元素时需要特殊处理,因为这时需要修改表头指针。
1 # 尾端删除元素并返回这个结点里的数据 2 def pop_last(self): 3 if not self._head: 4 raise LinkedListUnderflow('in pop') 5 6 p = self._head 7 if not p.next: # 表中只有一个元素 8 e = p.elem 9 self._head = None 10 return e 11 while p.next.next: # 执行完while,p就是倒数第二个结点 12 p = p.next 13 e = p.next.elem 14 p.next = None 15 return e
应该记住,尾端插入要找最后一个结点,尾端删除要找倒数第二个结点。
其他操作
找到满足给定条件的第一个表元素。
1 def find(self, pred): 2 p = self._head 4 while p and not pred(p.elem): 5 p = p.next 6 if p: 7 return p.elem
打印链表中的元素
1 def printall(self): 2 p = self._head 3 while p: 4 print(p.elem, end='') # print默认以` `结尾,这里可以自定制 5 if p.next: 6 print(', ', end='') 7 p = p.next 8 print('')
使用,
1 mlist1 = LList() 2 for i in range(10): 3 mlist1.prepend(i) 4 for i in range(11, 20): 5 mlist1.append(i) 6 mlist1.printall()
表的遍历
1 # 遍历,对每个元素做各种操作 2 def for_each(self, proc): 3 p = self._head 4 while p: 5 proc(p.elem) 6 p = p.next
比如打印每一项 `mlist1.for_each(print)`
python为内部汇集类型(list、tuple)提供的遍历机制是迭代器。LList也应该有类似的操作方式,以使之能用在标准的操作框架里。
python中定义迭代器,最简单的方式是定义生成器函数。在类中也可以具有这种性质的方法。
1 def elements(self): 2 p = self._head 3 while p: 4 yield p.elem 5 p = p.next 6 7 # 迭代 8 for x in mlist1.elements(): 9 print(x)
筛选生成器(过滤器)
1 def filter(self, pred): 2 p = self._head 3 while p: 4 if pred(p.elem): 5 yield p.elem 6 p = p.next
除了这些,还可以根据需要添加其他操作,如定位插入和定位删除等,具体参照list。
完整示例,
1 #!coding:utf8 2 3 class LNode: 4 def __init__(self, elem, next_=None): 5 self.elem = elem 6 self.next = next_ 7 8 class LinkedListUnderflow(ValueError): 9 pass 10 11 class LList: 12 def __init__(self): 13 self._head = None 14 15 def is_empty(self): 16 return self._head == None 17 18 def prepend(self, elem): 19 self._head = LNode(elem, next_=self._head) 20 21 def pop(self): 22 if self.is_empty(): 23 raise LinkedListUnderflow('error') 24 e = self._head.elem 25 self._head = self._head.next 26 return e 27 28 def append(self, elem): 29 if self.is_empty(): 30 self._head = LNode(elem) 31 return 32 p = self._head 33 while p.next: 34 p = p.next 35 p.next = LNode(elem) 36 37 def pop_last(self): 38 if self.is_empty(): 39 raise LinkedListUnderflow('error') 40 p = self._head 41 if not p.next: 42 e = p.elem 43 self._head = None 44 return e 45 while p.next.next: 46 p = p.next 47 e = p.next.elem 48 p.next = None 49 return e 50 51 def find(self, pred): 52 p = self._head 53 while p and not pred(p.elem): 54 p = p.next 55 if p: 56 return p.elem 57 58 def printall(self): 59 p = self._head 60 while p: 61 print(p.elem, end='') 62 if p.next: 63 print(', ', end='') 64 p = p.next 65 66 def for_each(self, proc): 67 p = self._head 68 while p: 69 proc(p.elem) 70 p = p.next 71 72 def elements(self): 73 p = self._head 74 while p: 75 yield p.elem 76 p = p.next 77 78 def findall(self, pred): 79 p = self._head 80 while p: 81 if pred(p.elem): 82 yield p.elem 83 p = p.next 84 85 llist1 = LList() 86 for i in range(5): 87 llist1.prepend(i) 88 for i in range(11, 16): 89 llist1.append(i) 90 91 for i in llist1.elements(): 92 print(i)
3.4 链表的变形和操作
3.4.1 单链表的简单变形
现在从简单单链表的一个缺点出发,讨论一种修改后的设计。前面单链表实现的一个缺点是,尾端加入元素的操作效率低,因为这时只能从表头开始查找,直至找到表的最后一个结点,而后才能链接新结点。而实际中,需要频繁地从表的两端加入元素的情况也很常见。图3.12给出了一种可行设计:
给表对象增加一个表尾结点引用域。这样只需常量时间就能找到尾结点,在表尾加入新结点的操作就可能做到O(1)。
链表的这一新设计与前面单链表的结构类似,这种结构变化不应该影响非变动操作的实现,只影响表的变动操作。
通过继承和扩充定义新链表类
以上面定义的链表类为基类,新链表类为派生类。新类的初始化函数里需要增加一个尾结点引用域,变动操作都可能修改这个域,需要重新定义。
1 class LList1(LList): 2 pass
初始化和变动操作
初始化方法:在派生类的初始化函数中,应该首先调用基类的初始化函数。
1 def __init__(self): 2 super(LList1, self).__init__() 3 self._rear = None
首端插入操作:
1 def prepend(self, elem): 6 # 但是同一个类里的不同方法在处理同一事项上应该保持一致。 7 if self.is_empty(): 8 self._head = LNode(elem) 9 self._rear = self._head 10 else: 11 self._head = LNode(elem, self._head)
尾端插入操作:
1 # 尾端插入元素 2 def append(self, elem): 3 # 这里需要区分两种情况:如果原表为空,引用新结点的就应该是表对象的_head域,否则就是已有的最后结点的next域。两种情况下需要修改的数据与不同。 4 # 很多链表的变动操作都会遇到这个问题,只有表首端插入/删除可以统一处理。 5 if not self._head: 6 self._head = LNode(elem) 7 self._rear = self._head 8 # 当增加了尾结点后,就不需要从开头开始找了 9 # 先扫描链表,确定最后一个结点 10 # p = self._head # 需要提前考虑空链表的情况 11 # while p.next: # 如果下一个为空,说明当前就是最后一个结点。 12 # p = p.next 13 # p.next = self._rear = LNode(elem) 14 else: 15 self._rear.next = LNode(elem) 16 self._rear = self._rear.next
首端删除操作:(不需重新定义,下面只是为了说明不需给_rear赋值)
1 # 首端删除元素
2 def pop(self):
3 if not self._head:
4 raise LinkedListUnderflow('pop error')
5
6 if not self._head.next:
7 e = self._head.elem
8 self._head = self._head.next
9 # self._rear = None # 由于确定了统一使用_head的值判断表空,因此不需要给_rear赋值None
10 else:
11 e = self._head.elem
12 self._head = self._head.next
13 return e
尾端删除元素:
1 # 尾端元素删除 2 def pop_last(self): 3 if not self._head: 4 raise LinkedListUnderflow('in pop') 5 6 p = self._head 7 if not p.next: 8 e = p.elem 9 self._head = None 10 # self._rear = None # 由于确定了统一使用_head的值判断表空,因此不需要给_rear赋值None 11 return e 12 while p.next.next: 13 p = p.next 14 e = p.next.elem 15 p.next = None 16 self._rear = p 17 return e
类的使用:
1 mlist1 = LList1() 2 mlist1.prepend(99) 3 for i in range(11, 20): 4 mlist1.append(i) 5 6 for x in mlist1.filter(lambda y: y % 2 == 0): 7 print(x)
增加尾结点引用的类,变化的只是尾端插入操作的效率,其他不变。
完整实例:
1 #!coding:utf8 2 3 class LinkedListUnderflow(ValueError): 4 pass 5 6 class LNode: 7 def __init__(self, elem, next_=None): 8 self.elem = elem # 表元素域 9 self.next = next_ # 链接域 10 11 class LList: 12 def __init__(self): # 创建一个空链表 13 self._head = None 14 15 def is_empty(self): 16 return self._head == None 17 18 # 首端插入元素 19 def prepend(self, elem): 20 self._head = LNode(elem, self._head) 21 22 # 需要考虑只有一个元素时的情况 23 # 尾端插入元素 24 def append(self, elem): 25 # 这里需要区分两种情况:如果原表为空,引用新结点的就应该是表对象的_head域,否则就是已有的最后结点的next域。两种情况下需要修改的数据与不同。 26 # 很多链表的变动操作都会遇到这个问题,只有表首端插入/删除可以统一处理。 27 if not self._head: 28 self._head = LNode(elem) 29 return 30 31 # 先扫描链表,确定最后一个结点 32 p = self._head # 需要提前考虑空链表的情况 33 while p.next: # 如果下一个为空,说明当前就是最后一个结点。 34 p = p.next 35 p.next = LNode(elem) 36 37 # 首端删除元素并返回这个结点里的数据 38 def pop(self): 39 # 需要考虑空链表的情况 40 if not self._head: 41 raise LinkedListUnderflow('in pop') 42 43 e = self._head.elem 44 self._head = self._head.next 45 return e 46 47 # 需要考虑只有一个元素时的情况 48 # 尾端删除元素并返回这个结点里的数据 49 def pop_last(self): 50 if not self._head: 51 raise LinkedListUnderflow('in pop') 52 53 # it's too low! 54 # p, n = self._head, 0 55 # while p: 56 # n += 1 57 # p = p.next 58 # 59 # p = self._head 60 # while p and n > 1: 61 # n -= 1 62 # p = p.next 63 # e = p.next.elem 64 # p.next = None 65 # return e 66 67 p = self._head 68 if not p.next: # 表中只有一个元素 69 e = p.elem 70 self._head = None 71 return e 72 while p.next.next: # 执行完while,p就是倒数第二个结点(其下一个的下一个为空) 73 p = p.next 74 e = p.next.elem 75 p.next = None 76 return e 77 78 # 查找符合条件的第一个元素 79 def find(self, pred): 80 p = self._head 81 82 while p and not pred(p.elem): 83 p = p.next 84 if p: 85 return p.elem 86 87 def printall(self): 88 p = self._head 89 while p: 90 print(p.elem, end='') # print默认以` `结尾,这里可以自定制 91 if p.next: 92 print(', ', end='') 93 p = p.next 94 print('') 95 96 # 遍历,对每个元素做各种操作 97 def for_each(self, proc): 98 p = self._head 99 while p: 100 proc(p.elem) 101 p = p.next 102 103 # 迭代器 104 def elements(self): 105 p = self._head 106 while p: 107 yield p.elem 108 p = p.next 109 110 # 过滤器 111 def filter(self, pred): 112 p = self._head 113 while p: 114 if pred(p.elem): 115 yield p.elem 116 p = p.next 117 118 # 使用 119 # mlist1 = LList() 120 # for i in range(10): 121 # mlist1.prepend(i) 122 # for i in range(11, 20): 123 # mlist1.append(i) 124 # # mlist1.for_each(print) 125 # for x in mlist1.elements(): 126 # print(x) 127 128 # 类中方法,只需要重写改变链表的方法。 129 class LList1(LList): 130 def __init__(self): 131 super(LList1, self).__init__() 132 self._rear = None 133 134 # 首端插入操作 135 def prepend(self, elem): 136 # self._head = LNode(elem, self._head) 137 # if not self._rear: # 是空链表 138 # self._rear = self._head 139 140 # 上面的代码可以实现功能。但是同一个类里的不同方法在处理同一事项上应该保持一致。 141 if not self._head: 142 self._head = LNode(elem, self._head) 143 self._rear = self._head 144 else: 145 self._head = LNode(elem, self._head) 146 147 # 尾端插入元素 148 def append(self, elem): 149 # 这里需要区分两种情况:如果原表为空,引用新结点的就应该是表对象的_head域,否则就是已有的最后结点的next域。两种情况下需要修改的数据与不同。 150 # 很多链表的变动操作都会遇到这个问题,只有表首端插入/删除可以统一处理。 151 if not self._head: 152 self._head = LNode(elem) 153 self._rear = self._head 154 return 155 156 # 当增加了尾结点后,就不需要从开头开始找了 157 # 先扫描链表,确定最后一个结点 158 # p = self._head # 需要提前考虑空链表的情况 159 # while p.next: # 如果下一个为空,说明当前就是最后一个结点。 160 # p = p.next 161 # p.next = self._rear = LNode(elem) 162 else: 163 self._rear.next = LNode(elem) 164 self._rear = self._rear.next 165 166 # 首端删除不需要修改 167 168 # 尾端元素删除 169 def pop_last(self): 170 if not self._head: 171 raise LinkedListUnderflow('in pop') 172 173 p = self._head 174 if not p.next: 175 e = p.elem 176 self._head = None 177 # self._rear = None # 由于确定了统一使用_head的值判断表空,因此不需要给_rear赋值None 178 return e 179 while p.next.next: 180 p = p.next 181 e = p.next.elem 182 p.next = None 183 self._rear = p 184 return e 185 186 # mlist1 = LList1() 187 # mlist1.prepend(99) 188 # for i in range(11, 20): 189 # mlist1.append(i) 190 # 191 # for x in mlist1.filter(lambda y: y % 2 == 0): 192 # print(x)
3.4.2 循环单链表
循环单链表是指最后一个结点的next域不为None,而是指向第一个结点。
在链表对象里,记录表尾结点更合适,这样可以同时支持O(1)时间的表头/表尾插入和O(1)时间的表头删除(仅仅多一个表尾删除)。当然,由于循环链表里的结点连成一个圈,哪个结点算是表头或表尾,主要是概念问题,从表的内部形态上无法区分。
循环单链表操作与普通单链表的差异在于扫描循环的结束控制。一些不变操作的实现也要修改。
这种表对象只需一个数据域_rear,它在逻辑上始终引用着表的尾结点。
首端插入结点,就是在尾结点和首结点之间加入新的首结点,尾结点引用不变。尾端插入结点也是在原尾结点和首结点之间加入新结点,只是插入后把它作为新的尾结点。这两个操作都要考虑空表插入的特殊情况。
对于输出表元素的操作,关键在于循环结束的控制。下面实现中比较扫描指针与表头结点的标识,到达了表头结点就结束。
首端弹出操作。
尾端弹出操作需要通过一个扫描循环确定位置。
循环单链表类:
1 #!coding:utf8 2 3 class LinkedListUnderflow(ValueError): 4 pass 5 6 class LNode: 7 def __init__(self, elem, next_=None): 8 self.elem = elem # 表元素域 9 self.next = next_ # 链接域 10 11 class LCList: 12 def __init__(self): # 创建一个空链表 13 self._rear = None 14 15 def is_empty(self): 16 return self._rear == None 17 18 # 首端插入元素 19 def prepend(self, elem): 20 if self.is_empty(): 21 self._rear = LNode(elem) 22 self._rear.next = self._rear # 当只有一个结点时,下一个结点是它本身 23 else: 24 self._rear.next = LNode(elem, self._rear.next) 25 26 # 尾端插入元素 27 def append(self, elem): 28 self.prepend(elem) 29 self._rear = self._rear.next 30 31 # 首端删除元素并返回这个结点里的数据 32 def pop(self): 33 # 需要考虑空链表的情况 34 if not self._rear: 35 raise LinkedListUnderflow('in pop') 36 37 # 只有一个结点时 38 p = self._rear.next 39 if self._rear is p: 40 # e = self._rear.elem # self._rear.next = self._rear 41 self._rear = None 42 else: 43 self._rear.next = self._rear.next.next 44 return p.elem 45 46 def printall(self): 47 if self.is_empty(): 48 return 49 p = self._rear.next # 表头结点 50 while True: 51 print(p.elem) 52 if self._rear == p: 53 break 54 p = p.next 55 # 或者 56 while self._rear is not p: # 当循环到表尾时退出了,没有打印最后一次。 57 print(p.elem) 58 p = p.next 59 print(p.elem)
3.4.3 双链表
单链表中即使增加了尾结点引用,也只能支持O(1)时间的首端插入/删除和尾端插入。如果希望两端插入和删除操作都能高效完成。就必须加入另一方向的链接。这样就得到了双向链接表,简称双链表。有了结点之间的双向链接,不仅能支持两端的高效操作,一般结点操作也会更加方便,但也需要付出代价:每个结点都需要增加一个链接域,增加的空间开销与结点数成正比,O(n)。如果每个表结点里的数据规模比较大,新增加的开销可能就显得没那么重要了。
为了支持首尾两端的高效操作,双链表应该采用图3.14所示的结构,包含一个尾结点引用域。从双链表中任一结点出发,可以直接找到前后的相邻结点(都是O(1)操作)。下面假定结点的下一结点引用域为next,前一结点引用域为prev。
结点操作
结点删除:
示例代码:假定当前要删除的结点为p,
p.prev.next = p.next p.next.prev = p.prev # 一不小心就搞错
如果要考虑前后可能无结点的情况,只需增加适当的条件判断。
结点增加:假定加入位置为结点p之后,
1 # 不是书中内容 2 q.next = p.next 3 p.next.prev = q 4 p.next = q 5 q.prev = p
双链表类
可以继承LNode类:
1 class LNode: 2 def __init__(self, elem, next_=None): 3 self.elem = elem # 表元素域 4 self.next = next_ # 链接域 5 6 class DLNode(LNode): 7 def __init__(self, elem, next_=None, prev_=None): 8 super(DLNode, self).__init__(elem, next_) 9 self.prev = prev_
定义一个双链表类,从带首尾结点引用的单链表类LList1派生,空表判断和find、filter、printall方法都可继承,它们执行中只使用next方向的引用,用在双链表上也完全正确。
类中的几个变动操作需要重新定义,因为需要设置前一结点引用prev。首端和尾端的插入/删除都是O(1)操作。
插入操作需要判断是否为空链表,删除操作需要考虑只有一个结点的情况。
class DLList(LList1): def prepend(self, elem): p = DLNode(elem, None, self._head) # 首结点没有前一个结点 if not self._head: self._rear = p else: p.next.prev = p self._head = p # 是从两种情况中提取的 # 对应prepend def append(self, elem): p = DLNode(elem, self._rear, None) if not self._head: self._head = p else: p.prev.next = p self._rear = p def pop(self): if not self._head: raise LinkedListUnderflow('in pop') e = self._head.elem self._head = self._head.next if self._head: self._head.prev = None # 修改表头元素,并把新表头元素的前一个关系删除,不需要关心原表头文件的下一个关系(已经没有引用了,会自动回收)。 return e # 对应pop def pop_last(self): if not self._head: raise LinkedListUnderflow('in pop') e = self._rear.elem self._rear = self._rear.prev if self._rear: self._rear.next = None else: self._head = None # 设置_head保证is_empty正常工作 return e
循环双链表
3.4.4 两个链表操作
从这两个操作的实现中可以看到链表操作的更多特点。
链表反转
首先考虑表元素的反转,也就是list中reverse操作的工作。
顺序表上的实现是:用两个下标,逐对交换元素位置并把下标向中间移动,直到两个下标碰头时操作完成。同样的操作模式可以用在双链表上,因为双链表中结点有next和prev两个引用,同时支持两个方向的扫描操作。
单链表不支持从后向前查找结点,要找前一结点,只能从头开始,需要时间为O(n²)。
对于顺序表,改变其中元素的顺序只能在表中搬动元素,而对于链表,有两种方法:一是在结点之间搬动元素,二是修改结点的链接关系。
通过搬动元素的方式实现单链表中元素反转很不方便,而且效率很低。下面考虑基于修改链接的方法。
对于单链表,在首端插入/删除元素只需O(1)时间,所以最好能在首端进行。如果不断向一个表的首端插入结点,最早放进去的结点将在表尾,而从表首取出结点,最后取出的结点将是最早存入的。也就是说,从一个表的首端不断取出结点,将其加入另一个表的首端,就完成了一个链表的反转过程。取出和加入都是O(1)操作,总时间开销是O(n),所以这个过程就是一个高效的反转算法。
在LLIst类中定义这个方法,最后把反转后的结点链赋给表对象的_head域。LList1继承LList时,需要重写这个方法,因为完成反转后还要设置_rear。
链表排序
这里只考虑插入排序。
先看顺序表的插入排序算法示例,
1 def insert_sort(lst): 2 for i in range(1, len(lst)): 3 x = lst[i] 4 j = i 5 while j > 0 and lst[j-1] > x: 6 lst[j] = lst[j-1] 7 j -= 1 8 lst[j] = x 9 return lst
单链表的插入排序存在两种可能做法:一是移动表中元素,二是调整结点间的链接关系。
首先考虑基于移动元素的方法。
在简单单链表中的实现,
1 # 通过移动表中元素实现。 2 def insert_sort(self): 3 if self.is_empty(): 4 return 5 crt = self._head.next 6 while crt: 7 x = crt.elem 8 p = self._head 9 while p is not crt and p.elem <= x: # 跳过小元素; 红色条件可以防止极端情况出现,p不能超过crt,否则就不是排序序列了,极端情况看下图。 10 p = p.next 11 while p is not crt: # 倒换大元素,完成元素插入的工作,这个是插入算法的关键。 12 p.elem, x = x, p.elem 13 p = p.next 14 crt.elem = x # 回填最后一个元素,修改了x并不表示修改了crt.elem。 15 crt = crt.next
现在考虑通过调整链接的方式实现插入排序。
1 # 自己写的,思路是删除crt结点,然后插入到指定位置 2 def insert_sort2(self): 3 if self.is_empty(): 4 return 5 pre_crt = self._head 6 crt = self._head.next 7 while crt: 8 p = self._head 9 q = None 10 while p is not crt and p.elem <= crt.elem: 11 q = p 12 p = p.next 13 if p is not crt: 14 pre_crt.next = crt.next # 删除crt结点 15 if not q: # 表示crt比排序序列的首元素都小 16 self._head = crt 17 else: 18 q.next = crt 19 crt.next = p 20 21 pre_crt = crt 22 crt = crt.next 23 24 # 书上的,思路是把序列分成两部分,前一部分是已排序序列,后一部分是要排序部分。找到插入位置后修改链接关系。 25 def insert_sort2(self): 26 p = self._head 27 if p is None or p.next is None: 28 return 29 rem = p.next 30 p.next = None # 初始时,self.head引用的只有一个结点。 31 while rem is not None: 32 p = self._head 33 q = None 34 while p is not None and p.elem <= rem.elem: # `p is not None`是判断排序序列。 35 q = p 36 p = p.next 37 if q is None: 38 self._head = rem # rem与前面的排序序列没有关联。 39 else: 40 q.next = rem 41 q = rem 42 rem = rem.next 43 q.next = p # 删除了q与rem的链接。
3.4.5 不同链表的简单总结
3.5 表的应用
3.5.1 Josephus问题和基于“数组”概念的解法
。。。