迭代器之入门介绍(中)

迭代器的使用

这次,我们继续聊迭代器的使用。
不过这次,还是老规矩(啥时成规矩了?)先从STL聊起吧。
STL处处是以效率优先的。
也就是说,只要使用得当,STL绝对足够快,如果你的程序使用STL很慢,先考虑考虑是不是自己的代码的问题或者是不是误用了STL。因为在大多数情况下,你手写的算法是比不过诸如StepanovPlauger这些世界级大师的水准的。
不过其实我真正想说的是这句:STL竭尽所能给你提供最高的效率,因此他假定你是一个高素质的程序员。
什么意思?
更直白点说,STL假定你不会犯一些(它认为的)低级错误,它认为你给它的东西都是完全没有隐患的,它可以不加验证放心使用,总之,它信任了你。
因此你要对得起这种信任。
你心想这算什么话,我没事给它一个有问题的数据干吗?

呵呵,来看看下面的代码吧:

C++ 代码:

vector<int> v;
//... 然后向v里面添加了些数据

list<
double> li;
copy(v.begin(),v.end(),li.begin());


你在想,STL的确方便啊,迭代器的确大显神威啊,一个copy就可以将数据从一个容器中拷贝到另外一个结构完全不相同、数据类型也完全不相同的容器中,太好用了。
且慢,不妨运行下试试。
什么?运行了一切ok?那算你走……,不,算你倒霉
上面那段代码中有非常严重的问题,如果你RP好的话会立刻导致你的程序崩掉,这样你还可以知道你写错了。但是很不幸,你的RP没有那么高——这次没有崩。于是你还以为你的程序一切ok,哼着小曲将它交给用户,直到有一天崩溃终于来临,你就要被客户死缠烂打了……——这还算好的,问题是你回去之后在代码的海洋中寻找问题的时候,可能根本就记不起到底是什么地方的问题导致你的程序挂掉了。

扯远了。。回到上面的代码。仔细看一看。。哦。。。原来如此。
对的。
copy
这个函数执行的是拷贝操作,将源区间中的内容拷贝到目标区间。关键就在于它的参数:它有三个参数,前两个是源区间的起始和末尾,最后一个是目标区间的起始——注意,没有目标区间的末尾。那它怎么知道目标区间有多大呢?显然,copy知道源区间有多大,因此它当然可以猜出来要拷贝到的目标区间至少有多大。
问题就在这里出现了:它猜的不一定是对的。像上面的例子,那个li明明还是一个空容器,显然长度无法容纳要拷贝的数据,但是copy不知道。依然老老实实的执行着拷贝,于是便会导致不可预料的结果。

你心说,这个copy真笨,不会看看目标容器空间够不够啊?但是这怨得着copy吗?copy根本不知道目标容器啊,它只知道一个目标迭代器。
那你又说了,copy为啥不传四个参数,把目标区间的起始和末尾都传进去,这样它就能知道目标区间够不够大了。好吧,想想我最开始说的话:“STL竭尽所能给你提供最高的效率,因此他假定你是一个高素质的程序员是的,为了检测区间够不够大会耗费额外时间,而这本身是应该由来保证的。

其实这个问题,很容易解决:事先为那个list留够足够的空间不就可以了吗?只要li.resize(n);这么一下,li这个容器中就有n个空位了,再拷贝就ok啦。
不过上面的方法还不是很优雅,一来n必须得足够大,不然仍然可能出现区间长度不够的问题,二来resize(n)相当于一次性创建并初始化了n个元素,这些元素还没来得及使用就被copy覆盖了,浪费啊。
其实有个很简单且很方便的办法,让这个问题更优雅的解决。那就是将copy默认进行的拷贝操作变成插入操作。插入和拷贝是有本质的差别的,只要内存足够,插入操作便永远不会出问题。那如何变呢?呵呵,再卖个关子(我拍~),后面讲。

我们从STL谈起,谈到了在使用迭代器时的一些问题。是的,这段我就是打算谈一些很小的不起眼的问题——这些问题有时候出现的频率很高。

区间的有效性

来看看这代码有啥问题:

C++ 代码:

vector<int> cont1;
deque<
double> cont2;
//... 在这里给cont1cont2添加一些东西
sort(cont1.begin(),cont2.end());


很显然,这样使用sort并不能帮你把cont1cont2都排序,事实上会导致编译错误。
更隐蔽一点:

C++ 代码:

vector<int> cont1,cont2;
//... 在这里给cont1cont2添加一些东西
sort(cont1.begin(),cont2.end());


这段代码会很顺利的通过编译,但是会导致未定义行为:你的程序可能完蛋,也可能啥事没有,取决于你的RP -_-
是的,你必须保证前后两个参数都是同一个容器上的迭代器。
你或许会说,谁会犯这么弱智的错误啊?是的,这个例子不是很好,或许只是不小心把cont1打错成cont2而已,但是假如复杂一点的情况,说不定就很容易犯错了:

C++ 代码:

vector<int> cont1,cont2;
vector<int>::iterator it1,it2;
//假设接下来对it1it2做了许多操作,写了很复杂的分支等语句.

sort(it1,it2);
//到这里你恐怕就不容易看出来it1it2到底谁是哪个容器上的了。


要构成一个有效的区间,必须首先保证区间起始和末尾的迭代器都是同一个容器上的。
如果说上面这种保证还是比较容易的话,那么下面这个恐怕就不那么容易了:
同时你还得保证区间起始的迭代器位置不能在区间末尾的迭代器的后面。
也就是说,区间[begin,end)begin的位置要么在end前面,要么和end在同一位置,总之绝对不能跑到end后面去。
你心里嘀咕,这个天经地义啊,谁都会保证的。
未必。
看看这段代码有啥问题:

C++ 代码:

list<int> li;
for(int i=20;i<=50;++i)  //li中插入20~50
{
  li.push_back(i);
}
list<
int>::iterator it1,it2;
it1=find(li.begin(),li.end(),30);
//li中寻找30的位置
it2=find(li.begin(),li.end(),40); //li中寻找40的位置

reverse(it1,it2);
//反转[it1,it2)这个区间中的元素


看起来很不错的代码啊,先使用find找到3040的位置,然后调用reverse将其之间的元素顺序反转,最后li中的元素就应该是20,21,22....28,29,39,38,37...32,31,30,40,41,42....49,50
没错,这次你的代码工作的非常正常,最后结果也如你预期!
既然如此我还揪它出来批斗是干啥?

呵呵,你不妨试试将 li.push_back(i); 改为li.push_front(i); ,也就是说我们这次反向插入20~50,这样li中的元素应该是倒过来的。
呃。。那么这次最终结果应该是50,49,48...42,41,31,32,33...38,39,40,30,29,28...20吧?
呵呵,别急着做预言家,这次你先运行下试试?啊?乱七八糟的数据?!啊?完全不一样的序列?!啊?程序挂掉了!
都有可能。
问题就在于这段代码在不经意间违背了我上面说的保证有效区间的原则的第二条。
保证区间起始的迭代器不在区间末尾的迭代器的后面
回头看看代码吧,你怎么知道it1一定在it2前面?it1it2都是通过find动态找到的位置,没有任何人能够保证找到的位置就一定如你假设那么顺利啊!
因此,这类错误虽然不太容易犯,但是一旦犯了,大都很隐蔽。

最后小小的总结一下:
在使用迭代器的时候一定要小心,要保证给算法的区间是有效的,即:
从区间起始的迭代器开始,一直往下走,可以到达区间末尾的迭代器。
如果不能满足这个条件,那么会导致未定义行为。



迭代器失效

不要过于长久的保存一个迭代器,应该现取现用,因为它可能失效。
而且即使你现取现用,如果不注意一些细节的话,仍然可能导致手上的迭代器失效。
对失效的迭代器,你做除了赋值之外的任何操作都是未定义的,也就是说一旦做了其下场要看你的RP……

因此我们有必要搞清楚迭代器在什么情况下失效。
先来看一个简单的例子,某天你写了这么一堆代码:

C++ 代码:

//如果你看不懂这一段代码,建议回去看一看《迭代器之入门介绍(上)》
template<typename Container>
int deleteElement(Container& cont,Container::value_type val)
{
 
int deleteTimes=0;
 
for(Container::iterator it=cont.begin();it!=cont.end();++it)
  {
   
if((*it)==val)
    {
      cont.erase(it);
//#1
      ++deleteTimes;
    }
  }
 
return deleteTimes;
}


嗯,通过对迭代器的学习,你已经能写出这样的函数了,不错不错。这个函数对一个容器遍历,发现元素有等于val的便毫不留情的删除掉,并且最后返回被咔嚓掉的元素数量。
可是不要高兴的太早,这个代码是要挨批的。它有严重的错误:不信你试着运行下……哦,算了,反正是看RP。。
呃,这次其实我不用看你的RP也大体上可以猜出你的状况:如果是一个vector的话,这个函数很可能不会出错,但是执行结果恐怕会是乱七八糟;如果是一个list的话,嘿嘿,基本上可以确定这个函数可以连你的程序一起给咔嚓了。

怎么回事?错在哪里呢?
就错在#1处。这里不过是调用了erase删除了一个迭代器所指向的的元素,这有什么不对吗?
呵呵,删除本身没有啥问题。问题在于,删除了这个元素,那么它所在的位置就无效了,指向它的迭代器也应失效。
失效了也没啥,如果你的函数立刻返回的话。但是偏偏你这个函数冷酷无情,咔嚓了一个元素还不停手,于是继续进入下一轮循环——对,调用了++it ——这是一个未定义行为。于是,自己给挂了。活活,刽子手都没啥好下场……
至于为啥vector就可能不崩而list会崩,呵呵,想想数据结构吧,不过这个也不绝对。不过注意一点:即使vector没有崩,也不代表没有问题,迭代器仍然失效了,执行结果不如你所愿就是证明。
那么该如何正确的实现这个函数呢?

C++ 代码:

template<typename Container>
int deleteElement(Container& cont,Container::value_type val)
{
 
int deleteTimes=0;
 
for(Container::iterator it=cont.begin();it!=cont.end();)
  {
   
if((*it)==val)
    {
      cont.erase(it++);
//#1
      ++deleteTimes;
    }
else
    {
      ++it;
    }
  }
 
return deleteTimes;
}


还是看#1处,这次怎么就对了呢?
注意it++是什么意思:
it向后移动一个位置,但是返回的是之前的it的位置。
于是在erase的时候那个返回的临时位置倒是被咔嚓了,但是这时候我it已经移动到后面去啦,躲过一劫~

既然说到这里,就顺便提下前置自增和后置自增。
刚才也说了it++返回的是自增前的值,++it返回的是自增后的值,这个相信只要C语言过关的人都没问题。

然而你也应该注意到了:虽然在上例中我用了一次it++,但是我前面写的所有代码,for循环中的自增全都是++it而不是it++

为什么要优先用前置自增?

为啥你在循环中总写++it呢?你问,我从小都写it++,有区别吗?
……其实也没啥大不了的,就是我的上面那些写法……效率可能更高一些而已。
啊?凭啥++it就比it++效率高啊?
嗯。。这样看吧:
++i
相当于这么一个函数:

C++ 代码:

T operator++(T& val)
{
  val+=1;
 
return val;
}


i++相当于这么一个函数:

C++ 代码:

T operator++(T& val,int) //这里的int没有实际意义,只是为了区分
{
  T tmp=val;
  val+=1;
 
return tmp;
}



看到了吗?
后置自增,需要事先用一个临时变量来保存自增前的值,然后再自增,自增完毕将这个临时变量返回;这里的临时变量就是一个额外的开销。而前置自增就没有这么麻烦,自增后直接将自己返回即可。

因此我们在写循环的时候,那些控制变量的递增什么的,如果可以,尽量用前置递增,特别对于一些如listmap等容器的迭代器,前置递增的效率要高不少

好了,回来,回来~我们继续谈迭代器失效。

这里有另外一个迭代器失效的例子,更加隐蔽:
今天你想练习下vector的使用,于是写了这么一堆代码:

C++ 代码:

void Fib(int n,vector<int>& ret)
{
   
vector<int>::iterator it1,it2;
    ret.clear();       
//先清空vector
    ret.push_back(1); //数列第一个数是1
    if(n==1){return;}
    ret.push_back(1);
//数列第二个数还是1
    it1=t2=ret.begin();//it1指向第一个位置
    ++it2; //it2指向第二个位置
    for(int i=2;i<n;++i)
    {
        ret.push_back((*it1)+(*it2));
//每次取出最后的两个数相加后放在队列的尾部
        ++it1;
        ++it2;
    }
}



你不过是想写一个求斐波那契数列的函数:参数n给定想要的数列的长度,参数ret返回装满了斐波那契数列的vector
想法很好,代码也很简单。可惜运行的时候,输出了一堆乱七八糟的数据。(可能就前几个数还是对的)
你晕,就这么两行代码都给出问题,太不给面子了!难道又是迭代器失效?你哭丧着脸:这次没有删除迭代器指向的位置啊???我不过是往里面添加新元素而已,旧元素连动都没动过啊!

呃,冷静一下嘛,你的算法思路是没有问题的。简单的说,你只要把vector换成list就完全ok了,不信试试?
咦?真的耶。可是这到底是什么个鬼缘由啊?凭什么list可以vector不可以?

嗯,其实你猜的没错,还是因为迭代器失效。不过这次造成失效的原因不是因为删除,而是添加
哎哎,我添加个数据关原来的那些迭代器X事啊,为啥就平白无故失效?vector你给个说法。。
呃。。vector是不会说话的,不过list说话了:
嘿嘿,怎么着老兄,知道我的好了吧。告诉你吧,只有像俺list一样这种用指针串起来的容器,才会有这个好处……”

还记得list是个链表吧。内部的元素都是像串腊肉一样一个一个串起来的。这样你添加元素就不会对其它元素造成影响:因为你不过是把一串新腊肉又串上去了而已。

vector内部的故事

可是vector呢?你该不会不知道它内部是个数组吧。数组怎么能平白无故改变大小呢?显然这里有玄机。其实,vector为了达到动态改变数组的大小的能力,内部偷偷的做了点小动作:虚报经费与偷梁换柱。
先别急着把vector送交检察机关了,容我来为其辩护一下:虚报经费是怎么回事呢?假如你需要vector能容纳10int,这么算在32位系统下应该耗费40Byte的空间,可是vector向系统申请的时候,一般会偷偷多报一点上去,比如64Byte。这样申请下来的空间就有24Byte空余了。别打!其实vector还是很无私的,这些空间它不会自己霸占,而是原原本本留在那里,假如你下次又添加一个int进来,这下不就立刻有地方放啦?不用再找系统申请了。。你知道现在社会,审批个东西多麻烦啊,呵呵。

那偷梁换柱是怎么回事呢?还是继续上面的故事吧。继上次安排了一个int之后,这次你又安排了10intvector那里去。这下vector犯愁了,还剩20Byte了,只够容纳5个的,另外5个咋办?踢回去?哎呀于心不忍,于是只好再找系统批一块更大的区域,比如128Byte,然后把原来那个小区域里面的住户一个个请出来,原样不动的搬到新地皮上,再把小区域交还系统(为啥要把老住户搬迁到新地皮上?你应该知道这些住户都想住在同一块地皮上吧——数组在空间上必须是连续的)。经过这私下偷偷的一换,地皮就增大了,就可以容纳更多的伙计啦。

原来如此啊,虽然vector是个好人,哦……好容器,但是他的偷梁换柱行为还是引起了我们的问题:原来指向旧地方的迭代器,现在全都不能用啦(大家都搬到新地方去了,旧地皮都被回收了,当然这些老位置都不能用啦)。唉,虽然你很想打,但是念在vector辛劳的份上,算了。


现在搞明白了vector的小动作,你应该就明白了:给vector中添加元素,可能会引起迭代器失效。因此如果你不想在出错的时候大挠头,你最好事先假定每次添加东西进vector迭代器都会失效;要么,你就用一些措施来保证它绝不会失效。

因此,对于刚才那个斐波那契数列的程序,我们就可以这样改:

C++ 代码:

//既然迭代器可能失效,我干脆不用迭代器了
void Fib(int n,vector<int>& ret)
{
    ret.clear();
    ret.push_back(1);
   
if(n==1){return;}
    ret.push_back(1);
   
for(int i=2;i<n;++i)
    {
        ret.push_back(ret[i-1]+[i-2]);
//改用下标操作符,虽然可能会稍稍慢一点
    }
}



或者这样改:

C++ 代码:

//假如我还是想用迭代器
void Fib(int n,vector<int>& ret)
{
   
vector<int>::iterator it1,it2;
    ret.clear();
    ret.reserve(n);
//让它一次性申请个足够大的地方,保证下次再不需要重新申请
    ret.push_back(1);
   
if(n==1){return;}
    ret.push_back(1);
    it2=it1=ret.begin();
    ++it2;
   
for(int i=2;i<n;++i)
    {
        ret.push_back((*it1)+(*it2));
        ++it1;
        ++it2;
    }
}



通过这个例子就知道了:不仅是删除元素会导致迭代器失效,对于vectordeque这种连续或分段连续的容器,添加元素也可能导致迭代器失效。

上面唠叨了那么多,其实这里有一个列表可以详细的总结一下迭代器失效的情况(啥?为啥不早说?)
适用于一般情况的STL

 

任何容器的erase操作

erase的位置的迭代器失效;

vectorpush_back操作

可能没事,但是一旦引发内存重分配,所有迭代器都会失效;

vectorinsert操作

插入点之后的所有迭代器失效;但一旦引发内存重分配,所有迭代器都会失效;

vectorerase操作

插入点之后的所有迭代器失效;

vectorreserve操作

所有迭代器失效(因为它导致内存重分配);

dequeinsert操作

所有迭代器失效;

dequeerase操作

所有迭代器失效;



其实从列表看出来,大量迭代器失效主要都集中在vectordeque这种要求内部空间连续或者分段连续的容器上。(都是偷梁换柱的结果,嘿嘿) 顺带一提,dequepush_backpush_front不会引起迭代器失效,因为它的内存重分配不涉及搬家问题。
而像listmapset这些用指针互相串起来的结构的容器,一般很少发生迭代器失效的情况。除了erase那个位置,一般不会影响其它位置的迭代器。


好了这次就讲这么多,总结一下吧

这次我们聊了迭代器的使用以及可能碰到的一些问题,包括:
-- copy
时注意目标区间的大小;
--
区间的有效性:
------
起始末尾必须是同一容器上的迭代器;
------
起始必须保证不在末尾之后;
--
迭代器失效:
------
删除引起失效;
------
添加引起失效;
另外顺便穿插了些小内容比如前置迭代器的效率、vector内部的管理等。
希望大家有所收获。

 






附录:

上文中出现的一些STL算法和容器的简要说明:

话说我本来以为这篇文章写的很简单了,结果还是有人说内容有点多,想想可能是因为涉及了一些STL算法和容器的原因。呵呵,我觉得这些应该是看此文之前先去做点大致了解的,不过为了方便,还是在这里添加一个附录列一下吧。

上文中出现的一些算法:

 

名称

参数

说明

copy

(srcBegin,srcEnd,dstBegin)

拷贝。将区间[srcBegin,srcEnd)拷贝到以dstBegin开始的一个区间中

sort

(begin,end)

排序。将区间[begin,end)内的元素排序。默认从小到大

reverse

(begin,end)

反转。将区间[begin,end)内的元素顺序反转。比如区间内的元素是1,2,3,4,5,调用后变成5,4,3,2,1


以上算法全部包含在algorithm头文件中

上文中出现的一些容器:

 

vector

序列容器,相当于一个大小可变的动态数组(你完全可以把它当作普通数组那么用)。内部的存储空间是连续的。可以以非常快的速度向尾部添加数据。

list

序列容器,本质上是一个双向链表。可以在任何地方高效的插入数据。但是不允许随机访问。

deque

双端队列,很神奇的一个序列容器,和vetor用法差不多,但是可以从两头高效的追加数据。

map

关联式容器,其中的元素是一个两人组keyvaluekey是索引,value是值,元素在容器中的位置取决于key的值。可以用key快速索引到valuemap内部是一个树状结构(平衡二叉树或者红黑树)

set

关联式容器,和map很像,不过元素是单身——只有value,相当于用value自身来作为索引的mapset内部也是一个树状结构(平衡二叉树或者红黑树)



以及这些容器提供的一些成员函数:

 

aaaaaaaaaaaaa

 

push_back(i)

向容器尾部追加数据i。适用于所有序列容器(vectordequelist,还有string

push_front(i)

正如其名,这次是向容器头部添加。适用于dequelist

insert(pos,i)

看名字也知道,向容器的pos位置插入数据i。适用于所有STL标准容器。 不过对于关联式容器(mapset),pos只是提示,没有实际效果 (因为关联式容器元素的顺序是取决于元素本身,你不能随意指定位置)。

erase(pos)

删除pos位置的元素。



其实我觉得这些列表中的各个函数,看名字就能猜出它是啥意思了啊。。应该不用我说了嘛。。(砖~

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值