C++中的vector类【详细分析及模拟实现】

vector类

image-20230320121558193

一、vector的介绍及使用

1、vector的文档介绍

vector是表示可变大小数组的序列容器

②就像数组一样, vector也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素进行访问,和数组一样高效。但是又不像数组,它的大小是可以动态改变的,而且它的大小会被容器自 动处理。

③本质讲, vector使用动态分配数组来存储它的元素。当新元素插入时候,这个数组需要被重新分配大小,为了增加存储空间。其做法是,分配一个新的数组,然后将全部元素移到这个数组。就时间而言,这是一个相对代价高的任务,因为每当一个新的元素加入到容器的时候, vector并不会每次都重新分配大小。

④vector分配空间策略: vector会分配一些额外的空间以适应可能的增长,因为存储空间比实际需要的存储空间更大。不同的库采用不同的策略权衡空间的使用和重新分配。但是无论如何,重新分配都应该是对数增长的间隔大小,以至于在末尾插入一个元素的时候是在常数时间的复杂度完成的。

⑤因此, vector占用了更多的存储空间,为了获得管理存储空间的能力,并且以一种有效的方式动态增长

⑥与其它动态序列容器相比(deque, list and forward_list), vector在访问元素的时候更加高效,在末 尾添加和删除元素相对高效。对于其它不在末尾的删除和插入操作,效率更低。比起list和forward_list 统一的迭代器和引用更好。

2、vector的构造

构造函数声明 接口说明
vector() (重点) 无参构造
vector (size_type n, const value_type& val = value_type()) 构造并初始化n个val
vector (const vector& x); (重点) 拷贝构造
vector (InputIterator first, InputIterator last); 使用迭代器进行初始化构造

由于模板的存在,因此在构造时需要显示实例化,例如:

vector<int> v1;

由于vector类与string类的大多数借口名称及用法极其相似,因此我们可以很快的上手vector类:

void test_vector1()
{
    
    
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);

	//for循环变流
	for (size_t i = 0; i < v.size(); i++)
	{
    
    
		cout << v[i] << " ";
	}
	cout << endl;

	//迭代器
	vector<int>::iterator it = v.begin();
	while (it != v.end())
	{
    
    
		cout << *it << " ";
		it++;
	}
	cout << endl;

	//范围for
	for (auto e : v)
	{
    
    
		cout << e << " ";
	}
	cout << endl;
}

image-20230320123432645


因此我们主要学习vector与string类不同的、独有的接口性质

3、关于扩容

一下是一个能直观看到容量变化的小程序:

void TestVectorExpand()
{
    
    
	size_t sz;
	vector<int> v;
	sz = v.capacity();
	cout << "making v grow:\n";
	for (int i = 0; i < 100; ++i)
	{
    
    
		v.push_back(i);
        //判断容量是否改变
		if (sz != v.capacity())
		{
    
    
			sz = v.capacity();
			cout << "capacity changed: " << sz << '\n';
		}
	}
}

image-20230320140222326

因此在windows系统下,默认大约以1.5倍的速度扩容;而在Linux环境下,扩容速度一般为2倍

一般每次扩容2倍比较合适:

每次扩容少了,会导致频繁扩容

每次扩容多了,用不完,存在浪费

同样的,由于多次扩容对资源的开销很大,因此我们可以用reserve预留空间:

image-20230320141342490

4、接口是否提供const

结论:

1、只读接口函数,提供const size();

2、只些接口函数,提供非const push_back();

3、可读可写的接口函数,提供const+非const operator[]

例如对于只读功能的**size()已经可读可写的operator[]**其对应的函数接口如下:

image-20230320153309070

image-20230320153339908


和**operator[]一样,at也具有与之相同的功能和两种(const、非const)接口,不同的是:at是利用函数实现的,以及operator[]**有越界的断言检查;

vector<int> v;
v.reserve(10);
v[0]=1;
v.at(0)=1;

5、assign

image-20230320162138606

①赋值:

//测试用例:
void test_vector()
{
    
    
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);

	for (auto e : v)
	{
    
    
		cout << e << " ";
	}
	cout << endl;
	//将它赋值为10个1
	v.assign(10, 1);

	for (auto e : v)
	{
    
    
		cout << e << " ";
	}
	cout << endl;
}

作用是将n个位置赋值为val:

image-20230320162518906

②支持迭代器且提供了模板

image-20230320162541666

//测试用例:
void test_vector()
{
    
    
	//迭代器
	vector<int> v;
	v.push_back(10);
	v.push_back(20);
	v.push_back(30);

	v.assign(v.begin(), v.end());
	for (auto e : v)
	{
    
    
		cout << e << " ";
	}
	cout << endl;

	string str("hello world");
    //模板的作用
	v.assign(str.begin(), str.end());
    //整形提升:将char型转换为int型(因此这里输出的是字符的ASCII码)
	for (auto e : v)
	{
    
    
		cout << e << " ";
	}
	cout << endl;
}

image-20230320163316537

需要注意的,迭代区间end()是最后一个元素的下一个位置,是一个左闭右开的区间

6、insert

string类的insert接口主要以下标的方式实现,而vector类向我们提供的是迭代器的方式

image-20230320163521251

void test_vector()
{
    
    
	vector<int> v;
	v.push_back(1);
	v.push_back(1);
	v.push_back(1);

	//头插
	v.insert(v.begin(), 2);
	v.insert(v.begin(), 3);

	//尾插
	v.insert(v.end(), 20);
	v.insert(v.end(), 30);

	//中间插入(将其理解为指针的用法)
	v.insert(v.begin() + 2, 10);

	for (auto e : v)
	{
    
    
		cout << e << " ";
	}
	cout << endl;
}

image-20230320193127925

对于insert而言,由string类的学习经验我们可以使用find+insert来实现在指定位置插入,然后vector类并没向我们提供find接口,其本质是由于find存在于类模板算法——复用,让所有的容器都可以使用:

image-20230320193401844

举例如下:

vector<int>::iterator it = find(v.begin(), v.end(), 1);
if (it != v.end())
{
    
    
	v.insert(it, 30);
}

string类为何不直接使用算法库中的find?

string类不仅仅向其它容器一样只寻找一个对象,string类还需要提供找子串的接口,且返回方式是下标而不是迭代器的方式,因此string类为我们单独提供了find接口

7、shrink_to_fit

image-20230320194436313

顾名思义,该函数的功能是缩小容量

对于reserve而言,不会缩容,而resize也仅仅改变size不该变capacity(设计理念:不动空间不去缩容);从此可以看出对于内存管理而言,不允许分段释放,只能整体释放

因此缩容的方式仅能通过异地拷贝过去,进行异地缩容

因此对于shrink_to_fit而言,我们是一般不会使用的(异地缩容消耗资源很大)

作用:Requests the container to reduce its capacity to fit its size

v.shrink_to_fit();
cout << v.size() << endl;
cout << v.capacity() << endl;

image-20230320200739762

二、vector深度剖析及模拟实现

我们取vector类的源码,可以得到以下声明:

template<class T>
class vector
{
    
    
public:
	typedef T value_type;
	typedef value_type* iterator;
	typedef const value_type* const_iterator;
	//...

protected:
	iterator start;
	iterator finish;
	iterator end_of_storage;
};

而通过我们对string类模拟实现的学习,我们也许会通过以下声明方式实现vector类的模拟实现:

T* a;
size_t size;
size_t capacity;

下图可以对应两者关系,方便我们理解其含义:

image-20230320202414526

对于string类我们,首先我们要搭建string类的基本框架:

//模板,代表各种类型
template<class T>
class vector
{
    
    
public:
	typedef T* iterator;
	typedef const T* const_iterator;

	//迭代器(const和非const)
	iterator begin();
    
	iterator end();
		
	const_iterator begin(); const
		
	const_iterator end(); const
		
	//operator[](const和非const)
	T& operator[](size_t pos);

	const T& operator[](size_t pos); const

	//构造函数
	vector();
    
	//迭代器区间构造
	template <class InputIterator>
	vector(InputIterator first, InputIterator last);

	//拷贝构造v2(v1)
	vector(const vector<T>& v);
			
	//用n个val值对其进行构造
	vector(size_t n, const T& val = T());

	//operator=赋值   v1=v2(v1是v2的拷贝)
	vector<T>& operator=(vector<T> v);
		
	//析构函数
	~vector();
		
	//容量调整
	void reserve(size_t n);

	//元素个数调整resize
	void resize(size_t n, T val = T());        

	//获取元素个数size
	size_t size(); const
		
	//获取容量大小capacity
	size_t capacity(); const
		
    //判断是否为空
	bool empty();
		
	//尾插
	void push_back(const T& x);

	//尾删
	void pop_back();
    
	//删除指定位置
	iterator erase(iterator pos);

	//交换
	void swap(vector<T>& v);
		
private:
	iterator _start;
	iterator _finish;
	iterator _endofstorage;
};

对于该模拟实现类,其成员变量具体含义如下:(可参考上图理解其含义)

iterator _start;:指向 vector 的第一个元素位置的迭代器

iterator _finish;:指向 vector 的最后一个元素之后的位置的迭代器

iterator _endofstorage;:指向 vector 可以最多容纳的元素之后的位置的迭代器

在上述代码中,使用了两个 typedef 声明:

typedef T* iterator;
typedef const T* const_iterator;

这两个声明的作用是为 vector 类型定义了两个迭代器类型:iterator 和 const_iterator。其中,iterator 类型可以用来修改 vector 中元素的值,而 const_iterator 类型只能用来访问 vector 中元素的值,但不能修改它们。因此,const_iterator 常用于遍历 vector,而 iterator 则常用于修改 vector 中的元素

需要注意的是,由于 iterator 和 const_iterator 均为指针类型,因此它们可以用指针运算来访问 vector 中的元素。例如,通过 ++iterator 可以将 iterator 移动到 vector 中的下一个元素,++const_iterator 也是类似的。这些迭代器操作使得 vector 的使用变得非常方便,而且与其他容器类的迭代器操作也保持一致,进一步提高了代码的可读性和可维护性

1、迭代器

由于vector类的底层仍然是基于数组实现的,因此vector类的迭代器类型均为指针类型,我们为const对象和非const对象分别提供begin()和end()接口即可:

//迭代器(const和非const)
iterator begin()
{
    
    
	return _start;
}
iterator end()
{
    
    
	return _finish;
}
const_iterator begin() const
{
    
    
	return _start;
}
const_iterator end() const
{
    
    
	return _finish;
}

2、[]重载

我们可以很轻松的为vector类提供如数组的[]操作符使得它可以通过此读写,对于const对象,我们需要其返回值也是const即可:

T& operator[](size_t pos)
{
    
    
	assert(pos < size());
	//_start代表数组名,可直接访问元素
	return _start[pos];
}

const T& operator[](size_t pos) const
{
    
    
	assert(pos < size());
	//_start代表数组名,可直接访问元素
	return _start[pos];
}

3、构造函数

3.1 默认构造

默认构造函数只用初始化列表即可

vector()
	:_start(nullptr)
    ,_finish(nullptr)
    ,_endofstorage(nullptr)
    {
    
    }

3.2 拷贝构造

所谓拷贝构造,我们仍指的是深拷贝,因此离不开开空间的过程:

vector(const vector<T>& v)
    :_start(nullptr)
    ,_finish(nullptr)
    ,_endofstorage(nullptr)
    {
    
    
        reserve(v.capacity());
        //将被拷贝对象v的元素插入开辟的空间中
        for(const auto& e:v)
        {
    
    
            push_back(e);
        }
    }

注:这里用到了reserve(v.capacity());以及push_back(e);的意义很明确,但是我们在模拟实现中还没用介绍到,这两个接口会在后续提到,为方便书写提前拿来用了

3.3 迭代器区间构造

在官方库中vector类的构造还涉及到迭代器区间构造,如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g48ejtgV-1679812103496)(E:\Typora图片\image-20230326003111641.png)]

所谓迭代器区间构造,即是vector类的构造函数可以接受一个迭代器区间作为参数,这里用到了模板参数:

template <class InputIterator>

原因是它希望可以接受任意类型的迭代器作为参数,从而实现对不同类型容器的支持。具体来说,这个构造函数会接受一对迭代器,它们指向一个范围内的元素。在C++中,迭代器是一种用于访问容器中元素的类型,不同类型的容器(如vector、list、set等)可能使用不同类型的迭代器。使用模板参数可以使得这个构造函数适用于不同类型的容器,只要它们提供了迭代器的定义即可,具体如下:

emplate <class InputIterator>
vector(InputIterator first, InputIterator last)
	:_start(nullptr)
	, _finish(nullptr)
	, _endofstorage(nullptr)
{
    
    
	while (first != last)
	{
    
    
		push_back(*first);
		first++;
	}
}

有了迭代器区间构造,我们即可使用它来构造一个tmp临时对象实现拷贝构造的现代写法,就像我们在完成string类的模拟实现时用了它的直接构造来创建tmp临时变量用于拷贝构造:

同理,我们依旧需要提供swap函数来实现我们对成员变量的交换:

//交换
void swap(vector<T>& v)
{
    
    
	std::swap(_start, v._start);
	std::swap(_finish, v._finish);
	std::swap(_endofstorage, v._endofstorage);
}
//拷贝构造:现代写法
vector(const vector<T>& v)
	:_start(nullptr)
    ,_finish(nullptr)
    ,_endofstorage(nullptr)
    {
    
    
        //构造一个对象然后交换
		//这里我们用到了迭代器构造区间构造
        vector<T> tmp(v.begin(),v.end());
        swap(tmp);
    }

3.4 val值构造

在官方库中vector类的构造还涉及到用n个val值对其进行构造,如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QRKiSUFv-1679812103496)(E:\Typora图片\image-20230326004633828.png)]

vector (size_type n, const value_type& val = value_type());

size_type n表示构造的 vector 的大小,也就是其中元素的数量;const value_type& val = value_type()表示构造的 vector 的每个元素的初值。默认值是 value_type(),也就是类型 T 的默认构造函数生成的值,而我们在书写时,可以提供匿名对象作为其缺省参数值

vector(size_t n, const T& val = T())
	:_start(nullptr)
	, _finish(nullptr)
	, _endofstorage(nullptr)
{
    
    
	reserve(n);
	for (size_t i = 0; i < n; i++)
	{
    
    
		push_back(val);
	}
}

3.5 析构函数

~vector()
{
    
    
	delete[] _start;
	_start = _finish = _endofstorage = nullptr;
}

4、赋值重载


有了string类模拟实现经验,我们知道这里可以用拷贝构造出一个tmp临时变量然后与被赋值对象作交换,同时我们在此基础上提供了更优化了解法:传参拷贝,这样我们甚至连tmp对象都不用开辟了:

vector<T>& operator=(vector<T> v)
{
    
    
	//这里用到了拷贝:仍然是构造拷贝了一个对象然后交换
	swap(v);
	return *this;
}

5、容量调整

5.1 reserve

reserve只扩容不缩容,而扩容只能采用异地扩容的方式,因此离不开新空间的开辟

void reserve(size_t n)
{
    
    
    if(n>capacity())
    {
    
    
        size_t oldSize=size();
        T* tmp=new T[n];
        if(_start!=nullptr)
        {
    
    
            for(int i=0;i<size();i++)
            {
    
    
                tmp[i]=_start[i];
            }
            delete[] _start;
        }
        _start=tmp;
        _finish=tmp+oldSize;
        _endofstorage=_start+n;
    }
}

在这段程序中,oldSize的定义是为了在重新分配内存之前记录当前vector的大小(size);当需要将vector的容量(capacity)增加到n时,程序会创建一个新的大小为n的数组(tmp),然后将vector中已有的元素复制到新的数组中。在复制过程中,将size()的值存储在oldSize变量中。

这样做的原因是,之后程序需要使用这个值来更新_finish指针,确保vector中的元素数量不会受到新容量的影响

5.2 resize

resize分为三种情况,在string类中的模拟实现中详细介绍过。我们只用跟随这个思路实现即可:

void resize(size_t n,T val=T())
{
    
    
    //扩容+添加数据
    if(n>capacity())
    {
    
    
        reserve(n);
    }
    //添加数据
    if(n>size())
    {
    
    
        while(_finish<_start+n)
        {
    
    
            *_finish=val;
            _finish++;
        }
    }
    //删除数据
    else
    {
    
    
        _finish=_start+n;
    }
}

在函数resize中,形参val是一个类型为T的参数,用于指定新元素的默认值。该参数的默认值为T(),即使用T的默认构造函数来初始化新元素。如果没有指定val参数,则默认使用该默认构造函数来初始化新元素

为什么要使用匿名对象?

①保证默认值的正确性

使用匿名对象作为函数形参的好处之一是可以确保参数的默认值正确。在这个例子中,使用T()来初始化val参数可以确保val的默认值与T的默认构造函数所产生的默认值相同。如果在函数调用时没有提供val参数,则将使用匿名对象T()作为默认参数,从而确保新元素被正确地初始化。

②避免不必要的构造函数调用

另一个好处是,使用匿名对象作为函数形参可以避免不必要的构造函数调用。在这个例子中,如果使用一个普通的参数来表示新元素的默认值,那么在函数调用时必须构造一个新的对象来作为该参数的值。但是,由于我们只需要一个默认值,而不是一个实际的对象,因此使用匿名对象可以避免创建不必要的对象和调用相关的构造函数。

总之,使用匿名对象作为函数形参既可以确保参数的默认值正确,也可以避免不必要的构造函数调用

6、size和capacity

由于该模拟实现类的成员变量是以start和finish给出的,因此我们需要一个恰当的方式为我们提供size与capacity:

//获取元素个数size
size_t size() const
{
    
    
	return _finish - _start;
}
//获取容量大小capacity
size_t capacity() const
{
    
    
	return _endofstorage - _start;
}

7、empty和clear

//判断是否为空
bool empty()
{
    
    
	return _finish == _start;
}
//清除
void clear()
{
    
    
	//不用更改空间
	//这里不能定为空指针:内存泄漏(找不到这片空间了)
	_finish = _start;
}

8、增删查改

8.1 尾插

插入时检查扩容问题即可:

void push_back(const T& x)
{
    
    
    if(_finish==_endofstorage)
    {
    
    
       	size_t newCapacity=capacity()==0?4:capacity()*2;
        reserve(newCapacity);
    }
    *_finish=x;
    _finish++;
}

8.2 尾删

检查是否为空即可:

void pop_back()
{
    
    
    assert(!empty());
    _finish--;
}

8.3 指定位置插入

指定位置插入我们即可理解为如何时间在中间插入,①首先需要检查插入位置的合法性,②其次需要考虑扩容问题,③最后我们需要挪动数据,插入

需要注意的是,由于扩容采取的是异地扩容,而我们是以迭代器(指针)的形式给出的pos位置,因此异地扩容后pos指向的不是原来的位置,需要更新pos的位置

以上行为即为本类重点问题:迭代器失效问题,在后文会详细介绍

void insert(iterators pos,const T& val)
{
    
    
    //检查插入位置的合法性
    assert(pos<_finish);
    assert(pos>=_start);
    
    //检查空间大小
    if(_finish==_endofstorage)
    {
    
    
        size_t len=pos-_start;
        size_t newCapacity=capacity()==0?4:capacity()*2;
        reserve(newCapacity);
        //更新pos位置
        pos=_start+len;
    }
    
    //挪动数据
    iterator end=_finish-1;
    while(end>=pos)
    {
    
    
        *(end+1)=*end;
        end--;
    }
    *pos=val;
    _finish++;
}

8.4 删除指定位置

同理,首先我们要检查删除位置的合法性,再挪动删除即可

需要注意的是,erase函数实现时也需要注意迭代器失效的问题,当删除一个元素时,其他元素的位置可能会发生变化,这意味着之前指向元素的迭代器pos会失效;为了解决这个问题,vector的erase函数通常会返回一个指向被删除元素的后续元素的迭代器。可以使用这个迭代器来更新你的迭代器并保持它们的有效性

//迭代器更新过程:
//更新之前指向下标为i的元素的迭代器
it = v.begin() + i;

//使用返回的迭代器来更新之前的迭代器
it = v.erase(it);

//现在it指向被删除元素的后续元素
iterator erase(iterator pos)
{
    
    
	assert(pos >= _start);
	assert(pos < _finish);
	//挪动删除
	iterator begin = pos;
	while (begin < _finish - 1)
	{
    
    
		*(begin) = *(begin + 1);
		begin++;
	}
	_finish--;
	return pos;
}

删除第i个(下标为i)元素:

iterator it = v.begin() + i;
v.erase(it);

三、迭代器失效问题

在vector中,当对其进行添加、删除操作时,迭代器可能会失效,因为vector会动态地重新分配存储空间,导致原有元素在内存中的位置发生变化

以下操作可能会导致迭代器失效:

  1. 在vector中插入元素:如果插入元素时,vector的容量已满,需要重新分配内存空间,将原有元素复制到新的内存空间中,此时迭代器可能会失效。
  2. 在vector中删除元素:当删除元素时,vector会移动删除元素之后的所有元素,填补删除元素留下的空洞,此时迭代器可能会失效。

在处理vector中的迭代器失效问题时,可以使用vector提供的方法或技巧来避免,比如:

  1. 使用下标操作代替迭代器操作:下标操作不会导致迭代器失效。
  2. 在需要改变vector大小的情况下,可以先使用reserve()函数预留足够的空间,避免vector在添加元素时频繁地重新分配内存空间。
  3. 使用erase()函数返回的迭代器来更新原有迭代器,保证迭代器的有效性。

总之,当涉及到vector的添加、删除操作时,需要注意迭代器可能会失效的问题,合理使用vector提供的方法或技巧来避免迭代器失效

猜你喜欢

转载自blog.csdn.net/kevvviinn/article/details/129779141
今日推荐