DSA-7:列表及其接口设计1
从向量到列表
向量采用“循秩访问”的方式,可以通过秩直接访问对应元素。列表与向量同属于序列结构的范畴,其中的元素也构成一个线性逻辑次序。但是与向量极为不同的是,列表中元素的物理地址可以是任意的。
为了保证对列表元素访问的可行性,逻辑上互为前驱和后继的元素之间,应当维护某种索引关系。这种索引索引关系,可以抽象地理解为被索引元素的位置。因此列表元素是“循位置访问”。索引还可以理解为通往被索引元素的链接,因此也可以被称作是“循链接访问”。
列表结构相比于向量,在解决某些问题时有其独有的优势,可以弥补向量结构在功能和性能方面的不足。
从静态到动态
数据结构支持静态和动态两种操作,静态操作仅从中获取信息,后者则会修改数据结构的局部甚至整体。
向量采用的就是静态的存储策略,可以在$\mathcal O(1)$时间内由秩确定向量元素的物理地址,但是在添加或者删除元素时,会伴随着$\mathcal O(n)$元素的移动。
列表结构要求元素在逻辑上具有线性次序,但是对其物理地址并未作任何限制——这就是采用了“动态存储”的策略。在生命周期内,动态存储的数据结构将随着内部数据的需要,相应地分配或回收局部的数据空间。为了实现这一功能,该类结构需要使用指针或者引用的方法,来确定各个元素实际的物理地址。
采用动态存储的策略可以大大降低动态操作的成本。
由秩到位置
相对应的,采用动态存储的策略时,静态操作的性能会有所下降。
以列表为例,为了访问秩为r
的元素,我们只能顺着相邻元素之间的指针,从某一端出发逐个扫描各个元素,经过r
步迭代之后才能确定该元素的物理存储位置。这意味着,原先只需要$\mathcal O(1)$时间的静态操作,此时的复杂度也将线性正比于被访问元素的秩,在最坏情况下等于元素总数n
。即使在各个元素被访问概率相等的情况下,平均而言也需要$\mathcal O(n)$时间。
对于采用动态存储策略的数据结构,应该通过位置而非秩来指代并访问其中的数据元素。列表中的位置是指代各数据元素的一个标识性指标,借助它可以便捷地得到元素的物理存储地址。各个元素的位置,通常可以表示和实现为联结与元素之间的指针和引用。
列表
与向量一样,列表也是由线性逻辑次序的一组元素构成的集合:
$L = { a_0, a_1, …, a_{n-1} }$
列表是链表结构的一般化推广,其中的元素称作节点,分别由特定的位置或者链接指代。与向量一样,在元素之间也可以定义前驱、直接前驱、后继、直接后继。相对于任意元素,也有定义对应的前缀、后缀等子集。
列表的基本接口
由上面的叙述可知,列表节点除了需要保存对应的数据项,还需要记录其前驱和后继的位置。因此这里首先设计列表节点对象,然后通过基本的节点去构建列表。
列表节点
列表节点对象应该支持下面的操作接口:
操作接口 | 功能 |
---|---|
data |
当前节点所存储的数据对象 |
pred |
当前节点前驱节点的位置 |
succ |
当前节点后继节点的位置 |
insertAsPred(e) |
插入前驱节点,存入被引用对象e ,返回新节点位置 |
insertAsSucc(e) |
插入后继节点,存入被引用对象e ,返回新节点位置 |
根据上表的内容,可以设计ListNode
模板类如下:
1 | using Rank = int; //秩 |
列表
列表对象应该支持下面的操作接口:
操作接口 | 功能 | 适用对象 |
---|---|---|
size() |
报告列表当前的规模(节点总数) | 列表 |
first() 、last() |
返回首、末节点的位置 | 列表 |
insertAsFirst(e) 、insertAsLast(e) |
将e 当作首、末节点插入 |
列表 |
insertA(p, e) 、insertB(p, e) |
将e 当作节点p 的直接后继、前驱插入 |
列表 |
remove(p) |
删除位置p 处的节点,返回其数值 |
列表 |
disordered() |
判断所有节点是否已按非降序排列 | 列表 |
sort() |
调整各节点的位置,使之按非降序排列 | 列表 |
find(e) |
查找目标元素e ,失败时返回NULL |
列表 |
search(e) |
查找目标元素e ,返回不大于e 且秩最大的节点 |
有序列表 |
deduplicate() |
剔除重复节点 | 列表 |
uniquify() |
剔除重复节点 | 有序列表 |
traverse() |
遍历并统一处理所有节点,处理方法由函数对象决定 | 列表 |
根据上表的内容,可以设计List
模板类如下:
1 |
|
列表的基本接口(其一)
头、尾节点
列表中存在有头节点和尾节点,它们是私有的,对外界不可见。如果存在对外部可见的数据节点,则其中的第一个和最后一个节点分别称作首节点和末节点。列表对象内部的组成及逻辑结构如下图所示:
就内部结构而言,头节点紧邻于首节点之前,尾节点紧邻于末节点之后。这类封装之后从外部不可见的节点,称作哨兵节点。此处的两个哨兵节点被等效地视作NULL
。
设置了哨兵节点之后,对于从外部可见的任一节点而言,其前驱和后继在列表内部都必然存在。这使得相关算法不必再对各种边界退化情况做专门的处理,从而避免了出错的可能,还带来了许多的便利。
默认构造方法
创建List
对象时,默认构造方法将调用下面的统一初始化过程init()
。该函数完成了以下工作:
- 在列表内部创建了头、尾哨兵节点
- 将头哨兵节点的前驱设为
NULL
,后继设为尾哨兵节点 - 将尾哨兵节点的后继设为
NULL
,前驱设为头哨兵节点 - 将列表规模置零
新创建出来的List
对象如下图所示,其对外的有效部分初始为空,哨兵节点对外不可见,此后插入的新节点都将在两哨兵节点之间。
init()
函数也会在列表的其他构造方法中被调用。该函数只需运行常数时间。
列表的析构函数由于需要调用类中其他的接口,所以将在下一篇博客中给出。
由秩到位置
列表偶尔可能需要使用秩来指定列表节点,因此这里通过重载操作符[]
的方式设计一个转换接口。
1 | template <typename T> //重载下标操作符,以通过秩直接访问列表节点(虽方便,效率低,需慎用) |
这段代码的功能非常明显:要确定秩为r
的元素的位置,只需要从首节点出发顺着后继指针向后移动r
步即可。由此,该算法的时间复杂度为$\mathcal O (\rm r)$。可以看出,相比于向量的同类接口,列表在使用秩访问指定元素时时效率十分低下。
当$\rm{r > n/2}$时,从尾哨兵节点出发沿前驱指针向前查找可以在一定程度上减少查找次数。但是就总的平均效率而言,这一改进并无实质性意义。
查找
列表中有两个查找接口,分别是find(e)
和find(e, n, p)
。前者为整体查找,后者为区间查找。前者作为后者的特例,可以直接调用后者,因此这里考虑后者的实现。代码如下:
1 | template <typename T> //在无序列表内节点p(可能是trailer)的n个(真)前驱中,找到等于e的最后者 |
该查找算法是顺序查找方法,时间复杂度应是$\mathcal O (\rm n)$,线性正比于查找区间的宽度。
DSA-7:列表及其接口设计1