gitbook/业务开发算法50讲/docs/466065.md
2022-09-03 22:05:03 +08:00

16 KiB
Raw Blame History

01动态数组按需分配的vector为什么要二倍扩容

你好,我是微扰君。今天我们进入第一章基础数据结构的学习。

计算机程序一直以来最根本的作用就是处理数据。即使在早期的计算机中,计算就已经不仅仅是几个数字之间的加减乘除那么简单了,经常需要处理大量线性存储的数据,一个很好的例子就是向量乘法。显然,我们需要找到一种合适的方式在计算机中存储这些信息,并能让我们可以快速地进行向量运算。

再举一个更工程化的例子。假设有个需求我们希望只借助内存实现一个简易的银行账户管理系统每个账号里包括两个基本信息账户ID及余额。用户首次开户的时候会被分配一个账户ID系统要支持用户通过ID快速查询余额也可以存款/取款改变自己的余额。

你可能会觉得这有什么难的用数组就可以解决。建立一个整型动态数组每来一个用户就给存到数组的某个位置用该位置的数组下标来当用户的ID就行。查询起来更快数组大小是动态的也不用考虑用户数量超过容量上限的问题。

但是,基于下标随机访问数组元素为什么这么高效?动态数组是怎么做到看起来可以有无限容量?扩容机制的时间复杂度是多少,是不是会带来额外的内存浪费呢?不知道你有没有思考过这些问题。

今天我们就带着这些问题一起学习第一种序列式容器vector也就是动态数组。相信你学完之后对这些问题的理解就深刻啦。

数组和内存

讲解动态数组的实现之前,首先要回顾一下数组是什么,不过为了和动态数组区分开来,我们常常也称之为静态数组。可以这样定义:静态数组是由相同类型的元素线性排列的数据结构,在计算机上会分配一段连续的内存,对元素进行顺序存储。

其中有三个关键词,相同类型、连续内存、顺序存储。之所以这样设计本质就是为了能做到基于下标对数组进行O(1)时间复杂度的快速随机访问。

存储数组时,会事先分配一段连续的内存空间,将数组元素依次存入内存。因为数组元素的类型都是一样的,所以每个元素占用的空间大小也是一样的,这样我们就很容易用“数组的开始地址+index*元素大小”的计算方式快速定位到指定索引位置的元素这也是数组基于下标随机访问的复杂度为O(1)的原因。

图片

为什么要事先分配一段内存呢?答案也很简单,因为内存空间并不是无限的。一段程序里可能有很多地方都需要分配内存,我们必然要为分配的连续内存寻找一个边界。

事先确定数组大小并分配相应的内存给数组就是告诉程序这块地方已经是某个数组的地盘了就不要再来使用了。同样访问该数组的时候下标也不应该超过地盘的范围在大部分语言里这样的非法操作都会引起越界的错误但在一些没有越界保护实现的语言比如C语言这就是一个很大的问题需要开发者非常谨慎。有时这甚至会成为软件被黑客攻击的漏洞。

静态数组的特性

当然在内存里这样的顺序存储也不是没有代价这直接导致了数组的插入和删除会低效很多平均的复杂度是O(N)。因为数组,和集合不同,元素在数组中的位置是我们关心的。

在长度为N的数组中要在下标为T的位置插入数据时原数组中下标为T到N-1的元素都需要向后顺移一位这需要遍历数组中共计N-T个元素当然如果希望插入到数组的末端只需要做插入而不需要做任何移动操作。但同样如果我们希望将新元素插入到数组最开始的位置就要将原数组所有元素都向后移动了这需要移动共计N个元素。

图片

所以平均而言数组的插入操作的时间复杂度为O(N)。删除操作基于类似的原因复杂度当然也是O(N)。

总的来说,静态数组的特性就是数组中元素的个数是事先确定的每个元素都有对应的索引第一个元素的索引为0。因为每个元素在内存占用的空间是一样的,我们可以基于首元素的地址和目标元素的下标,直接定位到目标元素的位置。

动态数组

很显然,使用静态数组的时候需要事先指定空间大小,这并不是很让人们满意。因为静态数组的使用者分配完内存之后,数组空间就不再能扩展(或收缩)了。比如在开头的简易银行系统中,确定固定的数组大小带来的风险就是:当用户数不断上涨直至超过数组容量范围时,我们的系统就不能继续工作了。

唯一的解决方案只能是重新申请一个更大的数组。这个过程,如果自己手动实现,有相当多琐碎的操作,至少包括配置一整块更大的连续内存空间、将元素逐一拷贝至新空间,以及释放原本的空间。

而动态数组的意义就在于将这些繁琐的细节封装起来,给用户良好使用体验的同时,也兼顾效率。这就是为什么我们在大部分业务开发场景下,更多地采用动态数组容器,而不是原生的静态数组。

STL的Vector就是这样一种经过严格测试和实战检验的动态数组容器我们下面来分析一下它的原理和实现。其他高级语言的动态数组容器的实现思路其实也是类似的比如Java中的ArrayList等后续我们也会用Java中的实现来讲解。你搞清楚一个其他的就很好理解了。

动态数组源码分析

当然STL的源码涉及了许多高深的C++技巧,我们并不会展开讨论,会对源码做一些简单的调整方便你理解原理。这里也给你一个看源码的小建议,不要死抠细节。我个人看源码比较喜欢自顶而下的方式,先从大的模块暴露的方法和接口看起,而不是上来就开始研究小模块的实现细节

比如学习STL源码中vector实现的时候你会经常发现allocator相关的方法如果你揪着它不放一路溯源会发现allocator的底层实现也非常复杂但是大多数时候我们不需要这么做只要理解清楚了allocator的哪些方法用来申请内存、哪些方法用于释放内存具体实现细节暂时当作黑箱把精力集中在当下要搞清楚的问题上就可以了。

首先来看vector在内存中的表示。它有两个指标大小和容量。

大小,表示现在存了多少数据。存数据的部分其实和静态数组是一样的,都是一段连续的内存空间顺序排列这若干类型相同、大小一致的元素,但不同的地方在于,数组的大小是可以动态调整的。

我们知道,向计算机申请空间连续的内存空间是一个成本比较高的操作,不只需要扫描出堆区内存的空闲内存块,可能还需要向操作系统申请更大的堆空间,并产生用户态-内核态的切换成本。

所以为了减少二次分配的次数初次配置空间的时候可以分配比vector目前所需空间更多一些后续的若干次插入就不再需要触发昂贵的扩容操作了。这样的可用空间我们称为vector的容量是vector在创建时需要的第二个可选参数。

图片

所以我们可以用三个指针来标记vector空间的使用情况分别是

  1. _start 指针指向vector第一个元素
  2. _finish 指针指向vector最后一个元素
  3. _end 指针指向vector预留容量的边界

当然,动态数组的两个核心指标就很容易计算出来了:

  1. 容量capacity = _end - _first表示目前的数组最多能存储多少个元素
  2. 大小size = _finish - _first表示数组当前已经存储的元素个数
    对应的代码如下:
template <class _Tp, class _Alloc = __STL_DEFAULT_ALLOCATOR(_Tp) >
class vector : protected _Vector_base<_Tp, _Alloc> 
{
       ...
protected:
  _Tp* _M_start; //表示目前使用空间的头
  _Tp* _M_finish; //表示目前使用空间的尾
  _Tp* _M_end_of_storage; //表示目前可用空间的尾  
    ...
};

你不用太关注模版相关的语法只需要知道这里protected的三个变量就是前面提到的3个指针。

有了这些我们就可以判断什么时候需要触发扩容操作以及扩容的方式。因为vector创建的时候会给一个容量但随着我们不断往数组中插入元素数组的大小终究会超过当前分配的容量于是需要重新分配更大的内存那具体分配多少是一个比较合理的值呢

STL的扩容方法

来看一下STL怎么做的。除了查询已有资料之外我个人比较推荐动手实验不仅能随时检验自己脑海里的想法通过动手实践对原理的理解和印象也更深刻一些。所以我们来编写一些测试方法观察Vector在测试过程中的行为再和官方文档及资料进行对比验证。

要做的实验也很简单就是往一个数组里不断的插入元素并观察size和capacity的变化。完整的代码可以在这里找到。

vector<int> v;

for (int i = 0; i < 20; i++) {
    cout << "size: " << v.size() << " capacity " << v.capacity() << endl;
    v.push_back(i);
}

图片

通过实验我们能发现一个很明显的规律如果每次只插入一个元素当vector的大小小于容量时容量不会发生变化数组大小不断递增。而当vector的大小即将超过容量的时候插入之后容量大小会翻番。

所以倍增就是vector的扩容方式这种类似倍增的策略也会出现在许多其他使用场景中。

这里很自然会有一个问题,为什么每次扩容时候都是以倍增的方式扩容,而不是增加固定大小的容量呢?

在回答这个问题之前我们先看一看STL扩容逻辑的实现。

  void push_back(const _Tp& __x) {//在最尾端插入元素
    if (_M_finish != _M_end_of_storage) {//若有可用的内存空间
      construct(_M_finish, __x);//构造对象
      ++_M_finish;
    }
    else//若没有可用的内存空间,调用以下函数把x插入到指定位置
      _M_insert_aux(end(), __x);
  }
  
template <class _Tp, class _Alloc>
	void 
	vector<_Tp, _Alloc>::_M_insert_aux(iterator __position, const _Tp& __x)
	{
	  if (_M_finish != _M_end_of_storage) {
	    construct(_M_finish, *(_M_finish - 1));
	    ++_M_finish;
	    _Tp __x_copy = __x;
	    copy_backward(__position, _M_finish - 2, _M_finish - 1);
	    *__position = __x_copy;
	  }
	  else {
	    const size_type __old_size = size();
	    const size_type __len = __old_size != 0 ? 2 * __old_size : 1;
	    iterator __new_start = _M_allocate(__len);
	    iterator __new_finish = __new_start;
	    __STL_TRY {
	      __new_finish = uninitialized_copy(_M_start, __position, __new_start);
	      construct(__new_finish, __x);
	      ++__new_finish;
	      __new_finish = uninitialized_copy(__position, _M_finish, __new_finish);
	    }
	    __STL_UNWIND((destroy(__new_start,__new_finish), 
	                  _M_deallocate(__new_start,__len)));
	    destroy(begin(), end());
	    _M_deallocate(_M_start, _M_end_of_storage - _M_start);
	    _M_start = __new_start;
	    _M_finish = __new_finish;
	    _M_end_of_storage = __new_start + __len;
	  }
	}

这段扩容操作在push_back和insert操作中都会触发我们以简单一点的push_back也就是往数组尾部插入元素的操作为例来解释扩容的逻辑。

可以看到push_back的时候会先做一个判断看当前的容量是不是不够用了。如果够用我们只要直接往后插入一个元素不够用才进行_M_insert_aux扩容并插入的操作插入后需要把finish指针往后移动。这里在容量够用的时候插入逻辑用的是construct函数是STL容器中通用的构造方法。

我们来重点分析扩容逻辑所在的_M_insert_aux方法

  • 13-20行实际上是因为还有其他函数会调用这个方法我们已经确定容量不足所以不会进入这段逻辑。
  • 22-25行主要做的事情就是读取原有的vector大小old_size再从内存里申请一段新的空间大小为2*old_size创建新的首尾指针并指向新的空间。
  • 26-31行将老空间里的数据逐一搬到新的空间里并在最后添加新的元素。这样就完成了扩容的主要目的这是一个O(n)复杂度的操作,因为你需要对原数组进行逐一的深拷贝。
  • 最后在32-38行我们需要做一些清理和收尾工作释放掉老的数组空间和指针将容器的首尾及容量指针都更新到对应的位置。

这样Vector就完成了对扩容操作的封装是不是其实并不复杂呢

现在清楚扩容的具体实现之后,来解答前面的问题:为什么扩容是采用倍增的方式,而不是每次扩展固定大小?这背后其实是有严密数学依据的,非常有趣,我们一起来探索一下。

用极端法来考虑这个问题。

先假设是不是可以不倍增而是每次只扩展一个元素呢直觉上这当然是不合理的这会导致每一次插入都会触发扩容而每次扩容都会进行所有元素的复制操作。所以如果我们要插入n个元素需要进行的拷贝次数

1+2+3+… +n=n^{2}

复杂度为O(n^2)均摊下来每次操作时间复杂度就是O(n)。

那如果我们不是每次只拓展一个元素而是每次扩展C的容量对复杂度的计算会产生多大的影响呢同样来计算一下每插入C次就需要进行一次扩展操作每次扩展仍然需要复制全部元素所以总的拷贝次数是

C + 2C + 3C + … + floor(n/C) = n^{2}

复杂度同样为O(n^2) 。均摊下来每次操作时间复杂度还是O(n)。 虽然次数少了C倍但仍然不令人满意。

更好的做法就是和STL一样采用倍增的思想每次都将容量扩展为当前的一倍它往往能让我们的时间复杂度下降很多。

算一下倍增这种策略下需要拷贝的次数假设一共还是插入N次那总拷贝次数就是从1加到2的X次其中x是logn向上取整这是因为容量每次都在翻番所以每次触发拷贝的时候容量分别是1、2、4、8 … 一直到logn向上取整。

1+2+4+8+… +2^{x}=2^{(x+1)}-1

这样插入N个元素的复杂度就一下减少为O(N)了。均摊到每次插入的扩容复杂度就为O(1),这当然是一个令人满意的结果啦。

总结

相信经过今天的学习你一定已经对开头的几个问题有答案了吧简单总结一下。数组是支持O(1)基于下标随机访问的数据结构,在内存中是连续存储的。基于下标高效访问元素的核心就在于“相同类型”和“连续存储”的特性,当然,也带来了高昂的插入和删除的时间复杂度。

动态数组之所以能看起来像是无限容量,也仅仅是因为它内置了倍增的扩容策略,每次数组大小超过容量的时候,就会触发数组的扩容机制,封装了繁琐的拷贝细节。

也正是因为上述特性,动态数组广泛应用在需要经常查询、变更,但是很少插入/删除的场景比如我们在实现一个简单的Web服务器的时候可以用vector来存储handler的线程达到线程复用的效果。

你应该感受到了今天的内容比上一讲的文本差分要简单一些,是的,接下来我们会先把数据结构实现的基础打好,了解清楚背后的实现原理,这样在日常开发中,不同的数据结构可能造成什么样的性能瓶颈,你都能烂熟于心。

课后作业

我们已经细致地分析了在vector中插入元素的方法如果要删除一个元素应该怎么实现呢在删除元素的时候需不需要缩容呢如果需要的话你会怎么做。

这是一个开放问题,欢迎你在留言区与我讨论。我们下节课见。