“默认情况下,C++标准库提供了合理的性能”。如果你对“合理的”一词暗含的意思有过好奇,请接着读下去……
引言
假设我们希望从一个文件中将一串类型为double的值读进一个数据结构中,从而允许我们高效地访问这些值,通常的方法如下:
vector<double> values;
double x;
while (cin >> x)
values.push_back(x);
当循环结束时,values会容纳有所有的值,我们将可以通过values[i]高效地访问任何值。
在直觉上,标准库vector类就像一个内建数组:我们可以认为它在单块连续的内存中容纳其元素。实际上,尽管C++标准没有明确要求vector的元素要占用连续的内存,然而标准委员会在2000年10月份的会议上裁定此项要求的遗漏归因于工作上的疏忽,并且投票表决将其作为技术勘误的一部分而包含进来。这个迟到的要求谈不上是多大的痛苦,因为每一个现有的vector实现本来就是以这种方式工作的。
如果一个vector的元素位于连续的内存中,我们就很容易明白它是如何高效地访问个体元素的 — 只要使用与内建数组相同的机制就可以了。不过,要弄明白一个vector实现是如何处理高效增长的问题就不是这么简单了,因为这种增长将不可避免地涉及到将元素从一块内存区域拷贝到另外一块内存区域。尽管现代处理器通常特别擅长于将一块连续的数据从内存的一个地方拷贝到另一个地方,然而这样的拷贝并非是免费的午餐。因此,思考一个标准库实现可能是如何处理vector的增长而又不消耗过量的时间或空间,很有意义。
本文的余下部分将讨论一个用于管理vector增长的简单而高效的策略。
尺寸和容量
要想搞清楚vector类的工作机制,首先要清楚它并不仅仅是一块内存。相反,每一个vector都关联有两个“尺寸”:一个称为尺寸(size),表示vector容纳的元素的数量;另一个称为容量(capacity),表示可被用来存储元素的内存总量。在vector尾部留有额外的内存的用意在于,当使用push_back向vector追加元素时无需分配更多的内存。如果邻接于vector尾部的内存当时恰好未被占用,那么vector的增长只要将那块内存合并过来即可。然而这样的好运气极其罕见,大多数情况下需要分配新的内存,然后将vector现有的元素拷贝到那块内存中,然后销毁原来的元素,最后归还元素先前占用的内存。在vector中留有额外的内存的好处在于,这样的重新分配(代价可能很昂贵)不会每当试图向vector追加一个元素时都发生。
重新分配内存的代价有多高昂?它涉及如下四个步骤:
•为需要的新容量分配足够的内存;
•将元素从原来的内存拷贝到新内存中;
•销毁原来的内存中的元素;
•归还原来的内存。
如果元素的数目为n,那么我们知道步骤(2)和(3)都要占用O(n)的时间,除非分配或归还内存的代价的增长超过O(n),否则这两步将在全部运行时间中占居支配地位。因此我们可以得出结论:无论用于重新分配的容量(capacity)是多少,重新分配一个尺寸(size)为n的vector需要占用O(n)的时间。
这个结论暗示了一种折衷权衡。假如在重新分配时请求大量的额外内存,那么在相当长的时间内将无需再次进行重新分配,因此总体重新分配操作消耗的时间相对较少,这种策略的代价在于将会浪费大量的空间。另一方面,我们可以只请求一点点额外的内存,这么做将会节约空间,但后继的重新分配操作将会耗费时间。换句话说,我们面临一个经典的抉择:拿时间换空间,或者相反。
重新分配策略
作为一个极端的例子,假定每当填充vector一次我们就将其容量增加1个单位,这种策略耗费尽可能少的内存空间,但每当追加一个元素时都要重新分配整个vector。我们说过,重新分配一个具有n个元素的vector占用O(n)的时间,因此,如果我们从一个空vector开始并将其增长到k个元素,那么占用的总时间将会是O(1+2+...+k)或者O(k2),这太可怕了!有没有更好的办法呢?
比方说,假如不是以1个步幅增长vector的容量,而是以一个常量C的步幅来增长它将会如何?很明显这个策略将会减少重新分配的次数(基于因子C),所以这当然是一种改进,但这个改进到底有多大呢?
理解这个改进的方式之一是要认识到此一新策略将针对每C个元素块进行一次重新分配。假设我们为总量为KxC个元素分配K块内存,那么,第一次重新分配将会拷贝C个元素,第二次将会拷贝2xC个元素,等等。Big-O表示法不考虑常量因子,因此我们可以将所有的C因子分摊开来而获得O(1+2+...+K)或者O(K2)的总时间。换句话说,时间仍然是元素个数的二次方程,不过是带有一个小得多的因子罢了。
撇开较小的因子不谈,“二次行为”仍然太糟糕,即使有一个快速的处理器也是如此。实际上,对于快速的处理器来说尤其糟糕,因为快速的处理器通常伴有大量的内存,而访问具有大量内存的快速处理器的程序员常常试图用尽那些内存(这是迟早的事)。这些程序员往往会发现,如果在运行一个二次算法的话,处理器的速度于事无补。
我们刚刚证实,一个希望能以小于“二次时间”而分配大型vector的实现是不能使用“每次填充时以常量步幅增长vector容量”的策略的,相反,被分配的附加内存的数量必须随着vector的增长而增长。这个事实暗示存在一种简单的策略:vector从单个元素开始而后每当重新分配时倍增其容量,如何?事实证明这种策略允许我们以O(n)的时间构建一个有着n个元素的vector。
为了理解是如何获得这样的效率的,考虑当我们已经完全填满它并打算对其重新分配时的vector的状态:
自最近一次重新分配内存以来被追加到vector中的元素有一半从未被拷贝过,而对于那些被拷贝的元素而言,其中一半只被拷贝了一次,其余的一半被拷贝了两次,以此类推。
换句话说,有n/2的元素被拷贝了一次或多次,有n/4的元素被拷贝了两次或多次,等等。因此,拷贝元素的总数目为n/2 + n/4 +...,结果可以近似为n(随着n的增大,这个近似值越发精确)。撇开拷贝动作不谈,有n个元素被追加到了vector中,但操作占用的时间总量仍然是O(n)而不是O(n2)。
讨论
C++标准并没有规定vector类必须以某种特定的方式管理其内存,它只是要求通过重复调用push_back而创建一个具有n个元素的vector耗费的时间不得超过O(n),我们刚才讨论的策略可能是满足此项要求的最直截了当的一种。
因为对于这样的操作来说vector具有优秀的时间性能,所以没有什么理由避免使用如下循环:
vector<double> values;
double x;
while (cin >> x)
values.push_back(x);
是的,当其增长时,实现将会重新分配vector的元素,但是,如果我们事先能够预测vector最终尺寸的话,这个重新分配耗费的时间将不会超过“一个常量因子”可能会占用的时间。
练习
1.设想我们通过以如下方式编写代码而努力使我们那个小型循环速度更快:
while (cin >> x)
{
if (values.size() == values.capacity())
values.reserve(values.size() + 1000);
values.push_back(x);
}
效果将会如何?成员函数reserve进行一次重新分配,从而改变vector的capacity,使其大于或等于其参数。
2.设想不是每次倍增vector的尺寸,而是增大三倍,在性能上将会产生什么样的影响?特别是,创建一个具有n个元素的vector的运行时间仍然为O(n)吗?
3.设想你知道你的vector最终将拥有多少元素,在这种情况下,在填充元素之前你可以调用reserve来预先分配数量合适的内存。试一试你手边的vector实现,看看调用reserve与否对你的程序的运行时间有多大的影响。
附STL源码:
template <class T, class Alloc = alloc>
class vector{
public:
typedef T value_type;
typedef value_type* iterator;
protected:
iterator start; // 指向头结点
iterator finish; // 尾结点
iterator end_of_storage; //容量
public:
// 获取容量函数
size_type capacity() const
{
return size_type(end_of_storage - begin());
}
// 默认构造函数,可见都被初始化为0
vector() : start(0), finish(0), end_of_storage(0) {}
// 带容量的构造函数
explicit vector(size_type n) { fill_initialize(n, T()); }
// fill_initialize实现如下
void fill_initialize(size_type n, const T& value)
{
start = allocate_and_fill(n, value);
finish = start + n;
end_of_storage = finish; // 可见容量被设置为n的值
}
// push_back()调用insert搜索_aux函数
void insert_aux(iterator position, const T& x)
{
if (finish != end_of_storage) //
else
{
const size_type old_size = size();
const size_type len = old_size != 0 ? 2 * old_size : 1; // 可见,初始为0,添加一个后容量变1,以后空间不够的话,新容量为原先2倍
}
}
};