向量的空间管理
内部数组所占物理空间的容量,若在向量的生命期内不允许调整,则称作静态空间管理策略。该策略的空间效率难以保证。一方面,容量固定可能在此后的某一时刻,无法加入更多的新元素,即上溢$(overflow)$。另一方面如果为降低风险而预留部分空间,也很难明确界定一个合理的预留量。
向量实际规模与其内部数组容量的比值$($_$size/$_$capacity)$,亦称作装填因子$(loadfactor)$,它是衡量空间利用率的重要指标。从这一角度,静态空间管理的困境可归纳为:如何才能保证向量的装填因子既不致于超过 1,也不致于太接近于 0?
针对静态空间管理的困境可以改用动态空间管理策略,比较简单的方法如下代码所示:
template <typename T> void Vector<T>::expand() { //向量空间不足时扩容 if (_size < _capacity) return; //尚未满员时,不必扩容 if (_capacity < DEFAULT_CAPACITY) _capacity = DEFAULT_CAPACITY; //不低于最小容量 T* oldElem = _elem; _elem = new T[_capacity <<= 1]; //容量加倍 for (int i = 0; i < _size; i++) _elem[i] = oldElem[i]; //复制原向量内容(T 为基本类型,或已重载赋值操作符'=') delete [] oldElem; //释放原空间 }
此策略在每次调用$insert()$接口插入新元素之前,都要先进行调用检查内部数组的可用容量。一旦当前数据区已满(_$size == $_$capacity$),则将原数组替换为一个更大的数组。
与常规数组实现相比,可扩充向量更加灵活:只要系统尚有可用空间,其规模将不再受限于初始容量。每一次由$n$到$2n$的扩容,都需要花费$O(2n) = O(n)$插入操作所需时间。表面看来,这一扩容策略似乎效率很低,但实际上随着向量规模的不断扩大,需要进行扩容的概率也将迅速降低。
假定向量的初始容量为某一常数$N$且初始规模也为$N$,此时不断向向量中插入新元素,向量容量以$2$为比例按指数速度增长。假设通过$n$次插入后向量容量达到$capacity(n)$,此前共做过$\varTheta (log_2n)$次扩容,每次扩容所需时间线性正比于当时的容量(或规模),且同样以$2$为比例按指数速度增长。因此,消耗于扩容的时间累计不过:
$$
T\left( n \right) =2N+4N+\cdot \cdot \cdot capacity\left( n \right) <2\cdot capacity\left( n \right) ~\varTheta \left( n \right)
$$
将其分摊到其间的连续$n$次操作,单次操作所需的分摊运行时间应为$O(1)$。
导致低效率的另一情况是,向量的实际规模可能远远小于内部数组的容量。装填因子极有可能远远小于 100%,甚至非常接近于 0。当装填因子低于某一阈值时,称向量发生了下溢($underflow$)。尽管其并非必须解决的问题,但在某些场景,发生下溢时也有必要适当缩减内部数组容量。以下是一个缩容算法的代码:
template <typename T> void Vector<T>::shrink() { //装填因子过小时压缩向量所占空间 if (_capacity < DEFAULT_CAPACITY << 1) return; //不致收缩到 DEFAULT_CAPACITY 以下 if (_size << 2 > _capacity) return; //以 25%为界 T* oldElem = _elem; _elem = new T[_capacity >>= 1]; //容量减半 for (int i = 0; i < _size; i++) _elem[i] = oldElem[i]; //复制原向量内容 delete [] oldElem; //释放原空间 }
与$expand()$操作类似,尽管单次$shrink()$操作需要线性量级的时间,但其分摊复杂度亦为$O(1)$。实际上$shrink()$过程等效于$expand()$的逆过程,这两个算法相互配合,在不致实质地增加接口操作复杂度的前提下,保证了向量内部空间的高效利用。