线性表是一种最为常用的数据结构,包括了一个数据的集合以及集合中各个数据之间的顺序关系。线性表从数据结构的分类上来说是一种顺序结构。在Python中的tuple,list等类型都属于线性表的一种。
从抽象数据类型的线性表来看,一个线性表应该具有以下这些操作(以伪代码的形式写出):
ADT List: List(self) #表的构造操作,创建一个新表 is_empty(self) #判断一个表是不是空表 len(self) #返回表的长度 prepend(self,elem) #在表的开头加入一个元素elem作为新表头 append(self,elem) #在表的末尾加上一个elem insert(self,elem,i) #在指定位置i加上一个元素elem del_first(self) #删除表头元素 del_last(self) #删除表尾元素 del(self,i) #删除位置i的元素 search(self,elem) #查找元素elem在表中出现的位置,如果没找到返回-1 forall(self,option) #提供一个遍历接口,对表中每一个元素实行操作option
另外还可以考虑一些如sort,reserve等等的操作。同时,以上所有操作都是对表本身做出变化,也可以设计一些非变化的操作,即改变并返回的是表的一个副本而原表只作为一个数据源,本身保持不变。
考虑到计算机内存本身的特点和线性表的各种操作的效率,主要可以考虑线性表的两种基本模型或者说实现方式:
1. 顺序表,将表元素连续地存放在一片计算机内存中,元素之间的顺序由它们的储存顺序自然表现出来。
2. 链接表,通过每个元素的储存单元中额外指出下一个元素的方法来实现元素在逻辑上的连续排列。
下面将按照这两种实现的方式分别说明
顺序表
■ 顺序表的基本实现方式
顺序表的大小通常在创建之时就确定下来(但是不一定一直都是这个大小不能改变,见后动态顺序表的实现方式)。
顺序表的特点是计算表中任何元素位置的过程都非常简单,是O(1)操作。如果每个元素所需要的储存单元大小都相同为c,那么知道一个元素的下标之后想要计算它的物理地址访问它只需要L = L0+c*i,这是一步简单的单元操作,花费常量时间。如果表中每个元素所需要的储存单元大小不一样的话没有办法这么简单地算出物理地址,但是也不难处理。这牵扯到顺序表的布局方式,一共有两种布局:第一种“基本布局”是就如我们上面所说的,每个储存单元之间都互相相邻挨在一起,此时每个储存单元里都直接储存着元素。第二种布局称为“元素外置布局”,有点像linux中文件系统的架构,在顺序表中储存的都是储存元素单元的物理地址,因为地址大小都是一样的所以顺序表本身可以做到和第一类布局一样通过简单计算得到某个下标的元素,然后再进行一步O(1)的“通过物理地址取内容”的操作来获取到元素。虽然中间隔了一层,但是记录元素物理地址的 这个顺序表在时间上和空间上的开销都不大,所以可以认为这种实现形式也是实现了一般元素组成的顺序表。
顺序表的基本实现形式确定了之后还要根据表需要的操作来进行优化和改造。比如,作为线性表的一个特点,顺序表必须要支持加入/删除元素的操作。因为顺序表的大小是确定的,这就带来一个问题,我该如何确定我加入新元素不会超出顺序表规定的空间。为了有效地解决这个问题和辅助其他很多的操作,在线性表里再加上两个储存单元分别用于存储表的长度和当前表内有多少元素非常有意义。所以,一个顺序表在内存中申请得到的一片区域进一步被分成了三部分,分别是记录表容量个数,记录当前元素个数以及数据储存块。
进一步思考,内存中这个表一旦创建出来,其大小就确定了,其前后的内存空间也可能被其他数据占据导致我没有办法对当前的顺序表进行扩容或精简。这对于一开始就声明好表大小不再改变的数据结构比如Python中tuple这样的而言可能还好一点,因为它的数据储存部分不用改变,而对于list这样的就很麻烦了。一种很容易想到的解决方案就是在创建之时把表创建的大一点,可能初始化的时候不把表填满,留出一部分空间待以后有可能填进来的元素填充,当元素把这部分空间填满了之后我就申请一块更大的空间,把原来的数据复制过去然后继续填充。这样就可以让更多元素加入进表中。但是重新申请一块空间创建一个新表会使得原来旧表的地址失效,很多引用了旧表的操作也就失效了。这给人感觉是治标不治本的
为了解决上面这个问题,另一种顺序表的结构被提出,即分离式结构。相对的我们之前说的,脑补的顺序表都是一体式结构。一体式结构中表信息(表长度和当前元素个数)与元素储存区紧邻在一起,这样虽然方便管理,但是当我需要换个更大的空间时会有问题。而分离式结构的顺序表将元素储存区的地址存在表信息后面(和上面元素外置布局方式很有一个意思的感觉),使得元素储存区成为一个可以替换的模块。当当前元素储存区存满,我就可以新申请一片更大的区域,把当前内容复制过去,然后直接把顺序表中指向原元素储存区的指针指向新元素储存区。这样就可以做到在不改变表本身地址的情况下使顺序表增容了。这实际上也是python中list的实现方式。这种储存区满了就换新的而不影响顺序表本身的表是动态顺序表。
动态顺序表也需要思考策略,比如说每次扩大储存区的容量设置为多少比较合适。一种方案是每次扩大都是多扩大固定个元素,从复杂度的角度来看,每需要换一个储存区复制原有数据需要花时间O(m),m为当时满的储存区中元素个数,计算可以得出对于最终长度为n的一个线性表,复制的总次数大概要在kn**2这个量级,k是个常量参数由每次扩大储存区的大小决定。这看起来比较费时间的原因是更换储存区太过频繁了。有人提出了另一种策略,每次需要扩大储存区时扩大数量为上一次扩大的两倍,这样可以计算证明其从0个元素增加至n个元素所花的时间是O(1)的。后一个策略在操作复杂度上带来优化,但是同时它的空闲单元数最多的时候会占全部的一半,带来了空间上的浪费。这是一个以空间换时间的案例。
■ 顺序表的基本操作
● 创建空表
向内存申请一块空间,在开头记录下表的容量max并将元素个数的计数num设置为0。
● 简单判断操作
当num=max是判断表满,当num=0时判断表空
● 访问给定下标的元素
访问下标为i的元素时,先检查i是否在[0,num)中,如果不是的话表明访问越界。如果i合法的话,就计算出相关元素的地址(这个过程可能根据布局方式和结构的不同而不同)。这个操作显然跟表一共有多少个元素没多大关系,所以是个O(1)的操作。
● 遍历操作
按顺序访问表中元素,另外维护一个当前访问元素的下标值,每访问一次该值+1。因为每一次访问都是通过计算地址得到目标元素的,所以每一次访问都是O(1)的,一共n次访问所以最终,遍历操作是O(n)的。
● 查找给定元素的位置
在没有其他信息的情况下,检索一个元素只能通过遍历表来检索。这称为线性检索。遍历每一个元素,判断元素的值是否和给出的值相同,否则继续遍历。
● 加入新元素
加入元素分成好几种情况。每成功加入一个新元素之后,表头维护的num信息应该+1
1,在尾部加入新元素,先判断当前num是否等于max,如果是的话那就表明我们需要更换数据储存区了。否则就在尾部加上相关元素。这个操作显然是O(1)的,跟表当前有多少元素在没有关系。
2,把新数据存进储存区的第i个单元,此时在判断完表是否满了之后还要考虑,把新数据写入相关位置之后,原本在那个位置如果有老数据该怎么办。如果不要求插入后的表保持原来的顺序(不保序)的话,那么可以直接把原先那个位置的元素给放到整个表的最后面,这样操作仍然是O(1)。如果要求保序,那么在插入相关位置之后,从 此位置的原元素开始,之后每个元素都要向后移动一位,这么一来使得操作和原表中一共有多少元素就挂钩了。事实上,不论是平均还是最坏情况,保序的插入操作是O(n)的。
● 删除元素
删除其实和插入是类似的,分成尾端删除和定位删除两种情况。和插入也类似的,尾端删除是一个O(1)操作,而定位删除因为要考虑保序和不保序两种情况,也分成了O(1)和O(n)两种情形。
除此之外,删除还有一种比较特殊的情况,条件删除。即看准某个符合条件的元素进行删除。一般来说进行条件删除也是先要遍历表的,在遍历中加上一个条件判断,总体而言条件删除仍然是一个O(n)的操作。
总的来说,用顺序表操作有优点也有缺点。一般而言其优点在于它直接按位置访问元素,元素在表中储存得十分紧凑,除了一些占O(1)的辅助信息以外其他的都是有效的元素信息。但是顺序表的缺点也很明显,为了能够放下足够多的元素,通常需要进行储存区更换,而在更换之后又会有大量的空闲单元的出现。
■ Python中的List类型
前面已经说过了,python中的tuple和list都是顺序表的实现。此外list还是个采用了分离式结构的动态顺序表,保证了在不断加入元素的过程中,表对象的标识(id)不会改变。在python的官方实现中,为了让list能够更有效率地更换储存区,采用了下面这样的储存区更换策略:建立空表或者很小的表的时候,系统默认给出一块可以容纳8个元素的储存区,在元素增加的过程中,如果区满了就换一块4倍大的储存区,以此类推直到表的大小达到5万个元素左右的时候,当区再满的时候就换一块两倍大的储存区。之所以要在5万这个节点上做出这种变化是为了避免出现过多的空闲储存单元。
对于list的一些主要的操作而言,有下面这几点可以提一下:
len函数的操作是一个O(1)的操作。因为它只是取了表头信息中的num这个参数做了一些加工而已。
元素访问和赋值,尾端加入以及尾端删除(包括尾端切片删除)都是O(1)操作
指定位置的元素加入,切片替换,切片删除,表拼接(extend)都是O(n)操作
reverse()操作是O(n),而sort()操作,python封装的是最好的排序算法,其平均和最坏的时间复杂度都是O(nlogn)。
另外,值得指出的一点是,python没有提供接口让程序员可以管理list中每个储存单元的大小,虽然这样设计的初衷是为了减轻编程负担,避免人为的操作引起的错误,但这也无疑在一定程度上限制了使用表的自由。
由于篇幅过长,链接表的内容放在了另一篇笔记中。