目录
stack
基础框架
template<class T>
class stack
{
public:
private:
vector<T>v;
};
因为没有其他的内置类型,因此stack使用编译器自动生成的默认构造,拷贝构造,析构函数,赋值运算符函数即可,这些函数对于自定义类型又会去调用自定义类型对应的成员函数,因此我们不必在stack中编写这些成员函数。
push和pop
template<class T>
class stack
{
public:
void push(const T& x)
{
v.push_back(x);
}
void pop()
{
v.pop_back();
}
private:
vector<T>v;
};
因为当前stack的底层是vector,而vector头插和头删的效率需要将vector中所有数据都挪动一次,效率是特别低的,所以我们选择vector的尾作为栈顶,因此stack的push和pop操作就对应vector的尾插和尾删。
top
template<class T>
class stack
{
public:
T& top()
{
return v.back();
}
const T& top()const
{
return v.back();
}
private:
vector<T>v;
};
empty和size
template<class T>
class stack
{
public:
bool empty()const
{
return v.empty();
}
size_t size()const
{
return v.size();
}
private:
vector<T>v;
};
加上const,让普通对象和const对象都能调用这些接口。
基础测试和容器适配器的相关说明
可以看到正常运行,符合我们的预期。
但走到这里有一个问题,上面通过这么多代码所实现的stack是不是容器适配器呢?
答案:并不是容器适配器。拿电源适配器比如我们手机的充电器来说明,手机的电源适配器只负责充电的功能,而不管你电压是多少,我可以插在家里的插座上,经过适配器将220v的电压转化成合适的大小给手机充电,也可以插在车上,经过适配器将车载电压转化成合适的大小给手机充电,甚至可以插在蓝牙耳机上给手机充电,也就是说电源适配器能够适应不同的电压,能灵活的转化和控制。
既然电源适配器能够适配不同的电源(电压),那我们容器适配器是不是也应该适配不同的容器呀?没错,应该适配。而反观我们上面模拟实现的stack,虽然能实现栈(对应充电)的功能,但并不能适配不同的容器(对应电压),因为上面模拟实现时,把stack底层的容器定死成了vector,导致我们的stack只能在容器vector下才能发挥栈的功能,这就不符合适配器能灵活转化和控制的特性,因此我们要对自己实现的stack做出修改。
重制版的stack
上文中也说过,之前实现的stack虽然能跑,但该stack顶多只算是一层vector的封装,并不能算是适配器,因此接下来咱们要对模拟实现的适配器stack进行修正,代码如下所示。
template<class T,class Container>
class stack
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_back();
}
T& top()
{
return _con.back();
}
const T& top()const
{
return _con.back();
}
bool empty()const
{
return _con.empty();
}
size_t size()const
{
return _con.size();
}
private:
//vector<T>v;
Container _con;
};
修正后的代码加了一个类型模板参数Container,表示我不管你是什么类型的容器,只要你Container类型支持push_back、pop_back、back、empty、size等操作,那我stack就能适配你这个容器来发挥我栈该具有的功能。
修正代码后,定义stack对象时就得传一个具体的容器类型给stack的第二个模板参数了,如下两图所示。
底层为vector。
因为list也支持push_back、pop_back、back等操作,因此stack也可以适配list。
可以看到虽然list和vector底层数据的结构和组织已经是千差万别了,但对上层stack的使用是没有一点影响的,我栈依然保持着后进先出等等的一些栈该具有的性质。
stack的整体代码
在之前重制版stack的代码的基础上,给类型模板参数Container加上缺省值deque<T>后,就是stack的整体代码了,如下所示。
template<class T,class Container=deque<T>>
class stack
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_back();
}
T& top()
{
return _con.back();
}
const T& top()const
{
return _con.back();
}
bool empty()const
{
return _con.empty();
}
size_t size()const
{
return _con.size();
}
private:
//vector<T>v;
Container _con;
};
对容器deque的了解性学习
走到这里,我们已经成功地模拟实现了一个容器适配器stack。看看STL标准库中的stack,如上图红框处,我们和它一样,也是定义了一个模板参数Container,但STL中的Container是有缺省值的,默认给的是deque<T>这个类模板,那么它是一种什么样的容器呢?
deque的学名叫做双端队列,但它并不是一个队列,因为没有对它要求先进先出。
如下图红框处,更神奇的是,deque既支持push_front和pop_front(vector不持支,list支持),也支持operator[](vector持支,list不支持),它好像就是list和vector的合体版,注意虽然听上去好像很厉害,但实际效率是有点外强中干的。
deque的逻辑结构图如下所示。
deque通过管理一个中控指针数组对deque内的数据进行管理,中控数组表示数组从中间开始赋值。中控指针数组上的每一个值都是地址,是某个小数组buffer的地址。
deque内的数据是通过一个个小数组buffer组织起来的,每个小数组的容量都是固定的,比如上图中容量就为8。小数组buffer分为头插小数组buffer、尾插小数组buffer、其他都称之为中间插入小数组。头插数组最多只能存在一个,尾插数组也最多只能存在一个,中间插入数组可以存在多个。举个例子,调用deque的头插函数,则数据只会赋给头插小数组A,如果从右向左赋值插满了,则新开辟一个头插小数组bufferB,头插数组A将不再是头插数组,转而成为中间插入数组,头插数组B就会是唯一的头插数组了。
在头插数组和尾插数组中插入或者删除都不需要挪动数据,拿上图的环境举个例子,第一行的头插数组中只有-1和0,如果头插,则把数据赋给-1左边的值,然后更改记录【头插数组中有效数据】的变量即可。如果头删,则只需修改对应的记录【头插数组中有效数据】的变量即可。
如果是往中间插入数组中插入数据,因为既不是头插,也不是尾插,所以一定得指定插入到哪。如果该数组没有插满,则直接在对应位置上赋值即可,如果该数组已经插满,则需要挪动数据,在插入位置后的所有元素都得往后挪动一次,在这过程中,溢出的数据会挪动到其他小数组buffer当中。拿上图的环境举个例子,当往第二行的中间插入数组中插入数据时,因为第二行的中间插入数组已经插满,则位于插入位置后的所有数据都要往后挪动,第二行的8将会挪动到第三行的1位置上,第三行的8则会移到第四行的尾插小数组的9位置上。
deque的优势与设计缺陷如下。
deque的迭代器如下图所示。
总结:
对deque的介绍走到这里,我们可以大致想象出deque会在哪些场景中被使用。
如果尾插尾删多,特别是随机访问多,比如需要排序,则应该用vector;
如果任意位置,特别是中间插入删除多,则应该用list;
如果头部和尾部的插入删除都多,则在当前场景下,比起list或者vector,此时就更应该使用deque,因为vector不支持头插头删,对于尾插,vector比起deque也不占优势,因为vector尾插如果要扩容是整体扩2倍,还要将大量数据拷贝到新空间上,然后尾插新数据,而deque只要扩一个小数组buffer,无需拷贝数据到新空间上,只需尾插新数据即可。list对比deque每次插入删除都得申请空间释放空间,效率低下。而stack需要频繁尾插尾删,queue需要频繁的尾插和头删,所以deque就很适合去做他俩的底层容器。
queue
在<< stack与queue的介绍和使用>> 一文中也说过,stack和queue在性质上的唯一区别在于queue是先进先出,而stack是后进先出,其余所有性质,他俩都是完全一样的,比如上图所示,queue和stack一样,也是一个容器适配器,并且他俩默认适配的都是deque这个容器;而stack和queue在操作函数上的唯一区别在于除了queue没有stack的top函数,以及stack没有queue的back和front函数,其余接口的使用方法是和stack完全一致的。
因为queue和stack具有相似性,所以上面模拟实现完stack后,接下来的工作就简单不少了。
基础框架
template<class T, class Container>
class queue
{
public:
private:
Container _con;
};
和stack一样,也无需手写默认构造、拷贝构造、赋值运算符函数等等接口,编译器默认生成的就够用了。
push和pop
对于一个队列,是有队头和队尾之分的,既然要插入进行排队,理所应当的就是从队尾开始排队,所以queue是从队尾进,队头出,先进先出。模拟实现queue时,我们让底层容器的尾部充当队列的尾部,头部充当队列的头部。
template<class T, class Container>
class queue
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_front();
}
private:
Container _con;
};
back和front
template<class T, class Container>
class queue
{
public:
T& back()
{
return _con.back();
}
const T& back()const
{
return _con.back();
}
T& front()
{
return _con.front();
}
const T& front()const
{
return _con.front();
}
private:
Container _con;
};
size和empty
template<class T, class Container>
class queue
{
public:
bool empty()const
{
return _con.empty();
}
size_t size()const
{
return _con.size();
}
private:
Container _con;
};
让成员函数加上const,让普通对象和const对象都能调用这些接口。
queue的整体代码
给类型模板参数Container加上缺省值deque<T>后,就是queue的整体代码了,如下所示。
template<class T, class Container=deque<T>>
class queue
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_front();
}
T& back()
{
return _con.back();
}
const T& back()const
{
return _con.back();
}
T& front()
{
return _con.front();
}
const T& front()const
{
return _con.front();
}
bool empty()const
{
return _con.empty();
}
size_t size()const
{
return _con.size();
}
private:
Container _con;
};
测试代码
如下图,当给queue的第二个模板参数传list<int>时正常运行。
如下图,当给queue的第二个模板参数传vector<int>时编译不通过。
这也很好的符合了我们的预期,如下图,想要queue能适配一个容器是有条件的,容器需要提供pop_front函数,而STL的vector因为头删的效率太低所以没有提供pop_front这个成员函数,所以queue无法适配vector,所以编译不通过。注意如果vector提供了pop_front反而是个祸端,因为提供后,queue就可以适配vector,导致queue的效率会极低,对于学习了数据结构底层实现的人还好说,如果不学习底层实现,连出现效率低下问题的原因都找不到。