在线性结构中,各数据项按照一个线性次序构成一个整体。最基本的的线性结构统称为序列(sequence),根据其中数据项的逻辑次序与其物理存储地址对应关系的不同,又可以进一步地将序列区分为向量(vector)和列表(list)。在向量中,所有数据项的物理存放在位置与逻辑次序完全吻合,此时的逻辑次序也称作秩(rank);而在列表中,逻辑上相邻的数据项在物理上未必是相邻,而是采用间接地址的方式通过封装后的位置(position)相互作用。
在这里,我的目的肯定不是为了讲述vector作为一个内置类型的ADT接口用法,而是对其数据结构做一定的分析和记录。
构造与析构
1.默认构造方法
与所有的对象一样,向量在使用之前也需要首先被系统创建–借助构造函数做初始化。其中默认的构造方法是,首先根据创建者指定的初始化容量,向系统申请空间,以创建内部私有数组_elem[]:若容量未明确指定,则使用默认值DEFAULT_CAPACITY。接下来,鉴于初生的向量尚不包含任何元素,故将指示规模的变量_size初始化为0。
这整个过程没有任何迭代,故若忽略用于分配数组空间的时间,共只需常数时间。
2.析构方法
向量对象的析构对象:只需要释放用于存放元素的内部数组_elem[],将其占用的空间交还操作系统。_capacity和_size之类的内部变量无需做任何处理,它们作为向量对象自身的一部分被系统回收,此后既无需也无法被引用。
扩容
1.扩容原理
我在看北邮人论坛的时候,看到了一个帖子,面试官问了他vector扩容的机制是什么?所以想要对扩容有一个更深刻印象,是我写这篇文章的初衷。内部数组所占物理空间的容量,若在向量的生命期内不允许调整,则称静态空间策略。向量的实际规模与内部数组容量的比值(_size/_capacity),也称为装填因子(load factor),它是衡量空间利用率的重要指标。我们要做的便是保证向量的装填因子既不超过1,也不太接近于0,我们需要寻找一个balance。所以我们要改用动态空间的策略,使用可扩充向量是一个好方法。
T *oldElem=_elem; //_elem转换成指向数组首元素的指针
_elem=new T[_capacity<<=1];
for(int i=0; i<_size;++i)
_elem[i]=oldElem[i];//复制原向量内容
delete []oldElem;
实际上,在调用insert()接口插入之前新元素之前,都要先调用该算法,检查内部数组的可用容量,一旦当前数据区已满(_size==_capacity),则将原来数组替换为一个更大的数组。
这里值得注意的一点是,新数组的地址由操作系统分配,与原数据区没有直接关系。在这种情况下,若直接引用数组(就是直接改变数组的大小),比如说:
T &temp=_elem;
temp=new T[_capacity<<=1];
这种情况往往会导致共同指向原数组的其他指针失效,称为野指针。我也不知道理解是否准确,但这不是关键,关键是新数组的容量总是取作原数组的两倍。
2.时间复杂度
准确地,每一次由n到2n的扩容,都需要花费O(2n)=O(n)时间,这也是最坏情况下,单次插入操作所需要的时间。似乎,效率不高。但是实际上,随着向量规模的不断扩大,在执行插入操作之前需要进行扩容的概率,也迅速降低。
缩容
动态缩容shrik()算法:
if(_capacity<DEFAULT_CAPACITY<<1) return;//不至于收缩到DEFAULT_CAPACITY以下
if(_size<<2>_capacity) return;//以%25为界
T *oldElem=_elem; //_elem转换成指向数组首元素的指针
_elem=new T[_capacity>>=1];
for(int i=0; i<_size;++i)
_elem[i]=oldElem[i];//复制原向量内容
delete []oldElem;
可见,每次删除操作之后,一旦空间利用率已降至某一阈值以下,该算法随即申请一个容量减半的新数组,将原数组中的元素逐一搬迁至其中,最后将原数组所占空间交还给操作系统。但在实际应用中,为避免出现频繁交替扩容和缩容的情况,可以选用更低的阈值,甚至取做0,相当于禁止缩容。
插入
根据向量ADT定义,插入操作insert(r,e)负责将任意给定的元素e插到任意指定的秩为r的单元:
expand(); //若有必要,扩容
for(int i=_size;i>r;i--){
_elem[i]=_elem[i-1];
elem[i]=e;
_size++;
return r;
}
我们可以看到,执行插入操作的时间主要消耗于后继元素的后移,线性正比于后缀元素。可见,新插入元素越靠后(前)所需的时间越短。
删除
我们在这里考虑区间删除:remove(lo,hi)
if(lo==hi) return 0; //出于效率考虑,我们要考虑退化情况,比如remove(0,0)
while(hi<_size) _elem[lo++]=_elem[hi++];
_size=lo;
shrink();//如有必要,还应该要缩容
remove(lo,hi)的计算成本,只要消耗于后续元素的前移,线性正比于后缀的长度,总体不过O(m+1)=O(_size-hi+1)。这与我们的期望完全吻合:区间操作所需的时间,应该取决于后继元素的数目,而与被删除区间本身的宽度无关。一般来说,本删除元素在向量中的位置越靠后(前)所需时间越短(长),最好为O(1),最坏为O(n)=O(_size)。
唯一化
在很多应用中,在进一步处理之前都要求数据元素互异。下面看看针对无序向量的唯一化算法:
int oldsize=_size;
Rank i=1;
while(i<_size)
find(_elem[i],0,i)<0 ?i++ :remove(i);
随着循环的不断进行,当前元素的后续持续地严格减少,因此,经过n-2步迭代之后该算法必然终止。
这里所需的时间,主要消耗于find()和remove()两个接口,因此每步迭代所需时间为O(n),总体复杂度应为O(
n2
)。
下面来看看一道关于唯一化的面试题目,已知道数组中不重复且按升序排序,使用二分法查找法的递归或者非递归方式,查询数字m是否在数组a中,如果是则返回m在数组a中的位置。
递归解法:
void find(int b[],int target,int lo, int hi, int& res){
if(lo==hi&&b[lo]==target) {res=lo;return;}
if(lo==hi) return;
int mi=(lo+hi)>>1;
find(b,7,lo,mi,res);
find(b,7,mi+1,hi,res);
}
int main() {
int a[8]={0,1,2,3,4,5,6,7};
int res=10;
find(a,7,0,7,res);
cout<<res<<endl;
}
复杂度分析:在这里,递归实例一共用2n-1个,故这个算法的运算时间为O(2n-1)=O(n),二分递归版本的时间复杂度和线性版本的时间复杂度是一样的。其实这个算法还可以做一些优化,比如说当找到这个target的时候,可以直接返回到main函数。所以算法还需要改进。
非递归解法:
关于非递归解法,我觉得应该先给数组排序,这无疑增加了空间复杂度,我觉得还是使用递归版本的算法比较好。