根据逻辑次序的复杂程度,大致可以将各种数据结构划分为线性结构、半线性结构与非线性结构三大类。
在线性结构中,各数据项按照一个线性次序构成一个整体。最为基本的线性结构统称为序列(sequence),根据其中数据项的逻辑次序与其物理存储地址的对应关系不同,又可进一步地将序列区分为向量(vector)和列表(list)。在向量中,所有数据项的物理存放位置与其逻辑次序完全吻合,此时的逻辑次序也称作秩(rank);而在列表中,逻辑上相邻的数据项在物理上未必相邻,而是采用间接定址的方式通过封装后的位置(position)相互引用。
- 向量结构的高效实现,包括其作为抽象数据类型的接口规范以及对应的算法,尤其是高效维护动态向量的技巧。
- 还将针对有序向量,系统介绍经典的查找与排序算法,并就其性能做一分析对比,这也是本章的重点与难点所在。
- 引入复杂度下界的概念,并通过建立比较树模型,针对基于比较式算法给出复杂度下界的统一界定方法。
一、数组:
若集合S由n个元素组成,且各元素之间具有一个线性次序,则可将它们存放于起始于地址A、物理位置连续的一段存储空间,并统称作数组(array),通常以A作为该数组的标识。对于任何0 < i < j < n,A[i]都是A[j]的前驱(predecessor),A[j]都是A[i]的后继(successor).,对于任何i >1,A[i - 1]称作A[i]的直接前驱(intermediate predecessor);对于任何i < n - 2,A[i + 1]称作A[i]的直接后继(intermediate successor)。任一元素的所有前驱构成其前缀(prefix),所有后继构成其后缀(suffix)。
若数组A[]存放空间的起始地址为A,且每个元素占用s个单位的空间,则元素A[i]对应的物理地址为:A + i * s因其中元素的物理地址与其下标之间满足这种线性关系,故亦称作线性数组(linear array).
二、向量
按照面向对象思想中的数据抽象原则,可对以上的数组结构做一般性推广,使得其以上特性更具普遍性。向量(vector)就是线性数组的一种抽象与泛化,它也是由具有线性次序的一组元素构成的集合V = { v0, v1, ..., vn-1 },其中的元素分别由秩相互区分。
各元素的秩(rank)互异,且均为[0, n)内的整数。具体地,若元素e的前驱元素共计r个,则其秩就是r。经如此抽象之后,我们不再限定同一向量中的各元素都属于同一基本类型,它们本身可以是来自于更具一般性的某一类的对象。另外,各元素也不见得同时具有某一数值属性,故而并不保证它们之间能够相互比较大小。
- 以下首先从向量最基本的接口出发,设计并实现与之对应的向量模板类。
- 然后在元素之间具有大小可比性的假设前提下,通过引入通用比较器或重载对应的操作符明确定义元素之间的大小判断依据,并强制要求它们按此次序排列,从而得到所谓有序向量。
- 介绍和分析此类向量的相关算法及其针对不同要求的各种实现版本。
2.1 ADT接口:
在引入秩的概念并将外部接口与内部实现分离之后,无论采用何种具体的方式,符合统一外部接口规范的任一实现均可直接地相互调用和集成。下表给出了一个整数向量从被创建开始,通过ADT接口依次实施一系列操作的过程。请留意观察,向量内部各元素秩的逐步变化过程。
typedef int Rank; //秩 #define DEFAULT_CAPACITY 3 //默讣癿刜始容量(实际应用中可讴置为更大) template <typename T> class Vector { //向量模板类 protected: Rank _size; int _capacity; T* _elem; //规模、容量、数据区 7 void copyFrom(T const* A, Rank lo, Rank hi); //复刢数组匙间A[lo, hi) 8 void expand(); //空间丌足时扩容 9 void shrink(); //装填因子过小时压缩 10 bool bubble(Rank lo, Rank hi); //扫描交换 11 void bubbleSort(Rank lo, Rank hi); //起泡排序算法 12 Rank max(Rank lo, Rank hi); //选叏最大元素 13 void selectionSort(Rank lo, Rank hi); //选择排序算法 14 void merge(Rank lo, Rank mi, Rank hi); //弻幵算法 15 void mergeSort(Rank lo, Rank hi); //弻幵排序算法 16 Rank partition(Rank lo, Rank hi); //轴点极造算法 17 void quickSort(Rank lo, Rank hi); //快速排序算法 18 void heapSort(Rank lo, Rank hi); //堆排序(秴后结合完全堆讱解) 19 public: //构造函数 Vector(int c = DEFAULT_CAPACITY, int s = 0, T v = 0) //容量为c、规模为s、所有元素初始为v { _elem = new T[_capacity = c]; for (_size = 0; _size < s; _elem[_size++] = v); } //s <= c 大小小于等于容量 Vector(T const* A, Rank lo, Rank hi) { copyFrom(A, lo, hi); } //数组区间复制 Vector(T const* A, Rank n) { copyFrom(A, 0, n); } //数组整体复制 Vector(Vector<T> const& V, Rank lo, Rank hi) { copyFrom(V._elem, lo, hi); } //向量区间复制 Vector(Vector<T> const& V) { copyFrom(V._elem, 0, V._size); } //向量整体复制 // 析构函数 ~Vector() { delete [] _elem; } //释放内部空间 // 只读访问接口 Rank size() const { return _size; } //规模 bool empty() const { return !_size; } //判空 int disordered() const; //判断向量是否已经排序 Rank find(T const& e) const { return find(e, 0, _size); } //无序向量整体查找 Rank find(T const& e, Rank lo, Rank hi) const; //无序向量区间查找 Rank search(T const& e) const //有序向量整体查找 { return (0 >= _size) ? -1 : search(e, 0, _size); } Rank search(T const& e, Rank lo, Rank hi) const; //有序向量区间查找 38 // 可写访问接口 39 T& operator[](Rank r) const; //重载下标操作符,可以类似亍数组形式引用各元素 40 Vector<T> & operator=(Vector<T> const&); //重载赋值操作符,以便直接克隆向量 41 T remove(Rank r); //初除秩为r的元素 42 int remove(Rank lo, Rank hi); //删除秩在区间[lo, hi)之内的元素 43 Rank insert(Rank r, T const& e); //插入元素 44 Rank insert(T const& e) { return insert(_size, e); } //默认作为末元素插入 45 void sort(Rank lo, Rank hi); //对[lo, hi)排序 46 void sort() { sort(0, _size); } //整体排序 47 void unsort(Rank lo, Rank hi); //对[lo, hi)置乱 48 void unsort() { unsort(0, _size); } //整体置乱 49 int deduplicate(); //无序去重 50 int uniquify(); //有序去重 51 // 遍历 52 void traverse(void (*)(T&)); //遍历(使用函数指针,只读或局部性修改) 53 template <typename VST> void traverse(VST&); //遍历(使用函数对象,可全尿性修改) 54 }; //Vector
这里通过模板参数T,指定向量元素的类型。于是,以Vector<int>或Vector<float>之类的形式,可便捷地引入存放整数或浮点数的向量;而以Vector<Vector<char>>之类的形式,则可直接定义存放字符的二维向量等。这一技巧有利于提高数据结构选用的灵活性和运行效率,并减少出错,因此在本书中频繁使用。
重载向量的赋值运算符如下:
template <typename T> Vector<T>& Vector<T>::operator=(Vector<T> const& V ) { //重载赋值操作符 if (_elem) delete [] _elem; //释放原有内容 copyFrom(V._elem, 0, V.size()); //整体复刢 return *this; //返回当前对象的引用,以便于链式赋值}
2.2 析构函数:
与所有对象一样,不再需要的向量,应借助析构函数(destructor)及时清理(cleanup),以释放其占用的系统资源。与构造函数不同,同一对象只能有一个析构函数,不得重载。向量对象的析构过程,如代码2.1中的方法~Vector()所示:只需释放用于存放元素的内部数组_elem[],将其占用的空间交还操作系统。_capacity和_size之类的内部变量无需做任何处理,它们将作为向量对象自身的一部分被系统回收,此后既无需也无法被引用。
若不计系统用于空间回收的时间,整个析构过程只需O(1)时间。
同样地,向量中的元素可能不是程序语言直接支持的基本类型。比如,可能是指向动态分配对象的指针或引用,故在向量析构之前应该提前释放对应的空间。出于简化的考虑,这里约定并遵照“谁申请谁释放”的原则。究竟应释放掉向量各元素所指的对象,还是需要保留这些对象,以便通过其它指针继续引用它们,应由上层调用者负责确定。
2.3 动态空间管理:
向量实际规模与其内部数组容量的比值(即_size/_capacity),亦称作装填因子(loadfactor),它是衡量空间利用率的重要指标。如何保证向量的装填因子既不至于超过1,也不至于太接近0.需要改用动态空间管理策略。其中一种有效的方法,即使用所谓的可扩充向量。
分摊时间:
假定数组的初始容量为某一常数N。既然是估计复杂度的上界,故不妨设向量的初始规模也为N——即将溢出。另外不难看出,除插入操作外,向量其余的接口操作既不会直接导致溢出,也不会增加此后溢出的可能性,因此不妨考查最坏的情况,假设在此后需要连续地进行n次insert()操作,n >> N。首先定义如下函数:
size(n) = 连续插入n个元素后向量的规模 capacity(n) = 连续插入n个元素后数组的容量 T(n) = 为连续插入n个元素而花费于扩容的时间
其中,向量规模从N开始随着操作的进程逐步递增,故有:size(n) = N + n。既然不致溢出,故装填因子绝不会超过100%。同时,这里的扩容采用了“懒惰”策略——只有在的确即将发生溢出时,才不得不将容量加倍——因此装填因子也始终不低于50%。