C++高阶-用 STL 中的容器管理数据

容器

容器,简单来讲,就是能够保存某种类型数据的类。按照组织数据的方式不同, STL 中的容器分为顺序容器( sequence container)和关联容器( associative container)两种。

顺序容器

顺序容器将数据组织成有限线性集合,所有数据都是同一类型的,就象一根绳子上拴着的多只蚂蚱。 STL 中包括三种基本顺序容器:向量( vector)、线性表( list)和双向队列( deque)。基于这三种基本顺序容器,又可以构造出一些专门的容器,用于表达一些比较特殊的数据结构,包括堆( heap)、栈( stack)、队列( queue)及优先队列( priority queue)

关联容器

关联容器所容纳的数据是由{键,值}对组成的,它提供了基于键/值的数据快速检索能力。在概念上,关联容器就像是一本字典,我们总是根据不同的健而将对应的值放到不同的位置,同时也是根据键的不同而访问与之对应的值,这就像查字典一样,使得在关联容器中检索数据的效率非常高。 STL中有 8 种关联容器。当一个键对应一个值时,可以使用集合( set)和映射( map)存放这种一一对应的数据。若同一个键对应有多个值时,则可以使用多集合( multiset)和多映射(multimap)存放这种一对多的数据。同时,集合和映射又可以根据内部实现机制的不同,分为基于红黑树实现的 set、multiset、 map 和 multimap ,以及基 于哈希表 实现的 unordered_set、 unordered_multiset 、unordered_map 和 unordered_multimap

unordered 是什么意思?

无序(‚unordered‛) 实际上代表着 STL 中两类关联容器之间一个最本质的差别——容器中的数据元素是否经过排序。 以 map 和 unordered_map 为例: 当我们将数据添加进入 map 容器时, map 容器会通过小于操作(默认情况下使用‚ <‛操作符,所以 map 容器要求数据元素可以使用‚ <‛操作符进行比较)对新加入的数据排序, 所以 map 容器中的所有数据都是已经排序完成的,也就ordered; 但是 unordered_map容器并没有对其中的数据元素进行排序, 而是使用数据元素的哈希值排列数据,所以它是无序的,也就是unordered。因此,它也并不要求数据元素具有小于操作符。

如果容器中的数据元素比较少(比如只有几十个元素),很难说哪一种容器更快; 但是对于大量数据(比如数万个元素)而言, unordered_map 容器的查找速度要比 map 容器快很多。基本上,我们可以把 unordered_map 容器当作一个优化之后的 map 容器来使用。在具体使用的时候,我们可以根据需要灵活地进行选择。

STL 中的容器,实际上就是一些数据结构的模板类。当使用这些容器时,需要根据它们容纳的数据类型对其进行实例化,产生相应的容器模板类。利用这些实例化之后的模板类,才能创建自己的容器对象实例,进而用它来保存和管理相应类型的数据。例如,创建一个可以容纳 Employee 类型数据的的 vector 容器:

// 可以容纳 Employee 类型数据的 vector 容器
vector<Employee> vecEmp;

操作容器中的数据元素

在 STL 中,各个容器都提供了相应的函数来完成对容器中数据的常用操作,比如将数据元素添加到容器中,或者删除容器中的数据元素等。例如,可以使用 vector 容器的 push_back()函数将数据元素添加到 vector 容器中:

// 定义一个可以保存 int 类型数据的 vector 容器
vector<int> vecSalary;
// 接收用户输入并将数据保存到容器中
int nInput = 0;
do
{
	cin>>nInput; // 输入数据
	if ( 0 == nInput ) // 判断输入数据是否有效
		break;
	// 通过 push_back()函数将数据装入容器中
	vecSalary.push_back( nInput );
} while( true );

除了数据的装入操作之外,大多数容器都还提供了其他常用操作函数,比如元素的删除、插入、交换和清空等。

// 向 vector 容器的开始位置插入一个数据
// insert()函数负责插入数据, begin()负责获得容器的开始位置
vecSalary.insert(vecSalary.begin(), 4999 );
// 删除 vector 容器中的前三个数据
vecSalary.erase( vecSalary.begin(),
vecSalary.begin() + 3 );
// 清空 vector 容器中的所有数据
vecSalary.clear();

使用迭代器访问容器中的数据元素

迭代器提供了一种对容器中数据元素进行访问的方法:我们先将迭代器指向容器中的某个位置,然后通过这个迭代器访问这个位置上的数据元素。从表现上来看,迭代器如同一个指针,它指向容器中的各个数据元素,并且可以通过它访问所指向的数据元素

// 定义一个 vector<int>容器的迭代器
vector<int>::iterator it;
// 将迭代器 it 指向 vector 容器的起始位置,这时 it 指向的是 vector 容器中的第一个元素
// vector 容器的 begin()函数返回的是指向其起始位置的游标( iterator)
it = vecSalary.begin();
// 为了简化代码,我们也可以用 auto 作为 it 的数据类型,
// 让编译器自己根据其定义时的初始值推断其真实数据类型
// auto it = vecSalary.begin();
// 通过迭代器访问容器中的数据元素
// 跟指针类似,在迭代器前使用“*”运算符就可以得到它所指向的数据元素
// 如果工资小于 2000 元,则增加为原来的 120%
if( *it < 2000 )
{
	// 通过迭代器读/写它所指向的数据元素
	*it = (*it) * 1.2;
}

除了可以使用迭代器访问容器中的单个数据元素之外,还可以使用两个迭代器定义容器中某个范围内的多个数据元素。例如,可以使用一对迭代器指定一个容器中的前 4 个元素这样一个范围。例如:

// 定义一个 vector<int>容器的迭代器,表示起始位置
vector<int>::iterator itfrom;
// 将迭代器指向 vector 容器的起始位置
itfrom = vecSalary.begin();
// 定义一个 vector<int>容器的迭代器,表示终止位置
vector<int>::iterator itto;
// 将表示终止位置的迭代器指向 vector 容器中的第 4 个数据元素
itto = vecSalary.begin() + 3;

通常,我们把迭代器看成是可以访问容器中元素的一种对象,把它作为一个对象来创建。但是在使用上,迭代器更像一个指针,跟指针相似,可以在迭代器对象前加上“*”操作符来获取这个迭代器所指向的数据; 可以对迭代器进行加减运算,使其指向发生偏移,从而访问其他位置的数据。比如,可以使用自增操作符“++”或者自减操作符“- -”来将迭代器向前或者向后移动一个位置

// 统计容器中所保存工资的总和
int nTotal = 0;
// 使用迭代器循环遍历容器中的数据
for(vector<int>::iterator it = vecSalary.begin(); // 将迭代器指向容器的起始位置
it != vecSalary.end(); // 判断是否到达容器的最后位置
++it ) // 通过自增运算符将迭代器指向容器中的下一个元素
{
	// 通过迭代器访问它所指向的数据元素
	nTotal += (*it);
}

end()函数得到的是容器中最后一个元素的下一个位置,当迭代器 it 的值与之不相等时,则意味着迭代器尚未到达最后一个元素,循环可以继续 。 所以,用迭代器 it 与 end()函数的值是否相等来作为循环的终止条件。这里并没有使用通常意义上的“<”操作符来判断当前迭代器是否小于 vector 容器的结束位置,这是因为“<”操作符在某些容器的迭代器中没有定义,为了保持代码的一致性, 使用所有容器迭代器都定义的“!=”操作符来判断迭代器是否到达容器的结束位置

迭代器类别

  1. 前向迭代器(forward iterator)
    假设 p 是一个前向迭代器,则 p 支持 ++p,p++,*p 操作,还可以被复制或赋值,可以用 == 和 != 运算符进行比较。此外,两个正向迭代器可以互相赋值。

  2. 双向迭代器(bidirectional iterator)
    双向迭代器具有正向迭代器的全部功能,除此之外,假设 p 是一个双向迭代器,则还可以进行 --p 或者 p-- 操作(即一次向后移动一个位置)。

  3. 随机访问迭代器(random access iterator)
    随机访问迭代器具有双向迭代器的全部功能。除此之外,假设 p 是一个随机访问迭代器,i 是一个整型变量或常量,则 p 还支持以下操作:
    p+=i:使得 p 往后移动 i 个元素。
    p-=i:使得 p 往前移动 i 个元素。
    p+i:返回 p 后面第 i 个元素的迭代器。
    p-i:返回 p 前面第 i 个元素的迭代器。
    p[i]:返回 p 后面第 i 个元素的引用。

此外,两个随机访问迭代器 p1、p2 还可以用 <、>、<=、>= 运算符进行比较。另外,表达式 p2-p1 也是有定义的,其返回值表示 p2 所指向元素和 p1 所指向元素的序号之差(也可以说是 p2 和 p1 之间的元素个数减一)。

容器对应的迭代器类型
array随机访问迭代器
vector随机访问迭代器
deque随机访问迭代器
list双向迭代器
set/multiset双向迭代器
map/multimap双向迭代器
forward_list前向迭代器
unordered_map/unordered_multimap前向迭代器
unordered_set/unordered_multiset前向迭代器
stack不支持迭代器
queue不支持迭代器

注意,容器适配器 stack 和 queue 没有迭代器,它们包含有一些成员函数,可以用来对元素进行访问。

auto 关键字

C++11 为我们提供了 auto 关键字,它的一个重要作用就是可以用来简化容器的循环遍历代码。例如,上面的代码可以用 auto 关键字简化为:

// 使用 auto 关键字作为循环控制变量的数据类型,
// 编译器会自动根据其初始值推断其真实数据类型为容器的迭代器类型
for(auto it = vecSalary.begin();
it != vecSalary.end(); // 判断是否到达容器的最后位置
++it ) // 通过自增运算符将迭代器指向容器中的下一个元素
{
	// …
}

使用了 auto 关键字代替了迭代器的数据类型 vector::iterator,编译器会根据it 的初始值,也就是 vecSalary.begin()这个函数调用的返回值自动推断其数据类型。

C++还提供了序列 for 循环语句,专门用于某个数据序列的循环遍历。这里的数据序列可以是标准的 STL 容器,也可以是 string、初始化列表或数组等。

// 使用序列 for 循环语句简化容器的循环遍历
// 定义一个 auto 类型的循环变量,表示这个容器数据序列中的每一个数据元素
// 序列 for 循环语句的两个要素:循环变量和数据序列
for(auto n : vecSalary )
{
// 访问循环变量 n,相当于访问数据序列中的每一个数据
nTotal += n;
}

如果我们把循环变量定义成值的形式,那么它只是数据序列中数据元素的一个副本,通过它,我们只能读取数据序列中的数据元素, 而无法改变这些数据元素。如果想在循环中通过循环变量修改数据序列中的值,我们可以把循环变量定义为引用的形式,这样它将成为数据序列中每一个数据元素的引用,这时我们不仅可以通过循环变量读取这些数据元素,还可以通过它修改这些数据元素。

// 将循环变量声明为引用的形式
for(auto& n : vecSalary )
{
// 通过引用形式的循环变量 n,
// 将数据序列中小于 1000 的数据元素调整为 1000
if( n < 1000 )
{
n = 1000; // 修改数据
} nTotal += n; // 读取数据
}

如何选择容器要存放的数据类型

一般来说,我们既可以在容器中存放对象,也可以存放指向这些对象的指针,从使用上来讲,两者相差并不大。那么在具体应用的时候,到底该如何选择呢?

  • 如果使用的是基于连续内存的容器,例如 vector 容器等,当在这些容器中插入或者删除元素时,往往会引起内存的重新分配或者内存的复制移动。在这种情况下,为了提高内存操作的性能,我们优先选择保存指向对象的指针,因为指针的体积通常比对象的体积更小。
  • 对于基于节点内存的容器,比如 list 容器,当进行数据元素的操作时则很少有内存的复制移动,所以在这种容器中保存对象本身或者指向对象的指针并无性能上的显著差别。但是从方便使用的角度考虑,可以优先选择保存对象。

如果容器中保存的是对象本身,那么在容器析构的时候,这些对象也会自动被销毁,所以我们无需费心。但是,如果容器中保存的是指向对象的指针,那么这些指针所指向对象的清理工作就是程序员的责任了。

// 创建一个存放 Employee*指针的 vector 容器
vector<Employee*> vecEmp;
// 对容器进行操作…
// 在容器使用完毕后,清空容器中保存的指针,
// 释放这些指针所指向的对象
for( auto it = vecEmp.begin();
	it != vecEmp.end(); ++it )
{
// 判断指针是否为 nullptr,
// 如果不为 nullptr,则释放指针指向的对象
	if( nullptr != (*it) )
		delete (*it); // 释放指针指向的对象
	(*it) = nullptr; // 将指针设置为 nullptr,防止误用
}
// 清空整个容器
vecEmployee.clear();

使用迭代器删除容器中的数据元素时需谨慎。

当使用迭代器删除容器中的数据元素时,容器中元素的位置会随着删除操作而发生变化,所以迭代器所代表的当前位置也会发生变化,这一点需要特别注意。例如,要删除一个 vector 容器中所有大于 1000 的数:

当删除某个位置上的元素后,这个位置后面的元素会自动依次向前移动一个位置,填补被删除的元素,以保持 vector 容器内存的连续性。这时的迭代器实际上已经指向的是被删除元素后的第一个元素。当进入下次循环的时候,迭代器向后移动一个位置,实际上指向的已经是被删除元素后的第二个元素,中间跳过了一个元素,这就很可能造成漏掉某些元素的检查而导致删除不完全。所以,需要在每次删除动作发生后,不移动迭代器而继续检查当前位置的数据是否符合条件,而在没有发生删除动作时才对迭代器做加 1 操作,使其指向容器中的下一个元素。

// 循环遍历删除容器中的元素
for( auto it = vecSalary.begin();
it != vecSalary.end(); ) // 变更语句留空
{
// 遇到符合条件的元素就进行删除
if( *it > 1000 )
	vecSalary.erase( it ); // 迭代器依然指向删除元素的位置
else
	++it; // 如果不删除当前元素,则将迭代器指向当前元素的下一个位置
}

如何选择合适的容器

  • 从所保存的数据的特征上来考虑

    • 如果我们要保存的数据是一个固定大小的数据序列,那么使用 array 容器比较合适;
    • 如果这个序列的大小不固定,那么 vector 容器更合适;
    • 如果这个序列中的数据具有先进后出的特征,那么最适合的应该是 stack 容器;
    • 如果这个序列的插入和删除操作很多,那么 list 容器是一个不错的选择。
  • 从内存组织形式上来考虑
    容器分为顺序容器和关联容器。

    • 其中,顺序容器就是连续内存容器,也叫基于数组的容器,是在一个或多个内存块(动态分配的)中保存它们的数据元素,各个数据元素之间是紧密相连的。
      如果一个新元素被插入到容器中的某个位置,那么这个位置之后的所有元素必须先依次向后移动一个位置以为新元素让出位置。相应地,如果从容器中删除一个元素,那么这个位置之后的所有元素也必须依次向前移动一个位置,以填补删除元素后留下的空缺。因此,顺序容器的插入和删除操作的效率比较低下。
    • 除了基于连续内存的顺序容器之外,STL 中还有基于节点的关联容器。这种容器在每个内存块中只保存一个元素,而对容器的插入或删除操作,只会改变节点之间的关联关系,而不会引起节点元素的复制拷贝。所以针对关联容器的插入和删除操作的效率相对较高
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值