1.适配器模式
1.1 容器适配器
这一篇文章我们将要学习栈、队列、优先级队列的使用方法和细节问题。首先我们需要考虑怎么去定义这些容器的基本结构。
以栈stack为例,在C语言阶段,我们曾经学习过栈的结构与实现,所以我们当然可以自然地想到可以仿照C语言中的经验,以顺序表的方式组织栈的结构。于是我们就能够写出对应C++下栈这个类的定义。
template<class T>
class stack {
private:
T* _a;
int _top;
int _capacity;
};
在C语言中,我们采取一个顺序表数组来作为栈的底层结构。但是现在我们使用了C++,作为C语言的plus版本,理应有更方便的方式来定义出来栈。C语言版本下的栈因为是一个动态数组,所以需要考虑容量和元素数量等的问题,这些都需要我们自己动手处理。但是仔细一想,所谓动态数组我们曾在C++中使用过,不正是vector吗?所以我们现在为了省时省力,不禁思考是否能用vector作为栈的底层来组织栈呢?
事实告诉我们是可以的,而且这种方法很好用。stack并不会对数据有什么特殊的组织方式,它只是对数据的存取访问有一些规则的限制,实际对数据的底层组织形式还是顺序表的方式。所以如stack、queue、priority_queue实际就是对其他容器进行了一层包装而得到的,因此我们称之为容器适配器。
所谓容器适配器,就是为了实现自己的功能选择合适的容器进行适配,也就是对已经存在的容器进行了转换包装,让其作为自己的底层从而可以实现新的功能。
适配器的设计模式,因为需要选择合适的容器进行适配,所以体现在代码中表现为增加了一个选择底层容器的参数。
1.2 迭代器适配器
除了容器适配器,适配器模式同样可以运用在迭代器的设计中。我们之前已经介绍过了链表、顺序表等容器的迭代器,但是我们只是实现了它们的正向迭代器。为了实现他们的反向迭代器,再独立写一份反向迭代器的代码似乎不怎么方便。由于反向迭代器和正向迭代器的逻辑相似,只是迭代方向相反,所以我们就采取了迭代器适配器来实现反向迭代器。
还是那句话!适配器是对已经存在的类或接口进行包装,转换为我们需要的样子。在此处,我们就可以写一个反向迭代器的类模板,然后对于各种容器利用他们的正向迭代器实例化出他们独特的反向迭代器。
模板参数:在给出完整代码前我们需要一步步理解我们需要的功能和实现方法,首先是反向迭代器类模板模板参数的问题。在之前的正向迭代器中,模板参数包括元素的数据类型、元素的引用、元素的指针。在反向迭代器的类模板参数中,第一个是正向迭代器类型(代替了原本的元素数据类型),还需要元素的引用和指针(为了实现const)。
成员变量:由于是适配器模式,我们为正向迭代器进行包装,变成反向迭代器。所以反向迭代器的成员变量则自然就是正向迭代器。
在介绍成员函数之前,我们首先需要明确反向迭代器的使用原理。为了满足对称,反向迭代器和正向迭代器的指向刚好相反,所以正向迭代器的begin是反向迭代器的end,正向迭代器的end是反向迭代器的begin。因此就可以发现,正向迭代器可以直接访问的位置,对于反向迭代器而言实际上是所在位置的前一个位置(因为从end开始,而end位置是不访问的)。
解引用:在有了如上认识之后,我们就可以妥善处理解引用操作了。由于正向迭代器访问当前迭代器指向的元素,而反向迭代器指向当前指向的前一个元素,所以需要在解引用时解引用前一个位置的正向迭代器。另外需要注意的是,解引用不改变迭代器的指向,如果迭代器中没有重载+或-而只有++和--的话,就需要给一个临时变量来自增自减。
自增自减:反向迭代器++实际上是向前走,--实际上是向后走,需要搞清楚这一逻辑。另外反向迭代器返回的是反向迭代器的引用类型,所以一定需要返回标标准准的反向迭代器对象,而不可以是一个其他类型的隐式类型转换。根源在于隐式类型转换产生的是一个临时变量,具有常性,而返回参数是引用类型,而不是常引用,从而引发权限的放大而报错。
反向迭代器完整代码:
//迭代器适配器
template <class Iterator, class Ref, class Ptr> //模板参数传递的是容器的正向迭代器类型,元素引用以及元素指针
struct ReverseIterator //反向迭代器和之前的正向迭代器等都一样,因为其成员全部都是公开的,所以使用struct结构体定义
{
//使用正向迭代器变量作为成员变量,可以通过正向迭代器的行为模拟出反向迭代器的效果
Iterator _it;
//构造函数
ReverseIterator(Iterator it)
:_it(it)
{}
Ref operator*()
{
//因为需要访问前一个位置,且解引用不改变迭代器的指向,所以通过一个临时变量来找到前一个位置
//对于其中出现的--操作和*操作,是调用了正向迭代器Iterator的操作符重载,这也就是所谓的通过正向迭代器实现反向迭代器
Iterator tmp = _it;
return *(--tmp);
}
Ptr operator->()
{
//和*一样,解引用访问前一个位置,所以使用临时变量
//Iterator tmp = _it;
//return (--tmp).operator->(); //调用->解引用操作符作为返回值
return &(operator*()); //复用反向迭代器自己的*重载函数,*重载返回所要求的位置值引用,对其取地址即可
}
//因为自增运算需要返回操作数本身,所以迭代器自增自减运算也需要返回迭代器变量,为了方便编写,将返回类型为自身的反向迭代器进行typedef
typedef ReverseIterator<Iterator, Ref, Ptr> self;
//前置++,反向迭代器的++实际上迭代器在向前走
self& operator++()
{
--_it;
//return _it; //返回的是反向迭代器的引用类型,所以不支持隐式类型转换
return *this;
}
//前置--,反向迭代器的--实际上迭代器在向后走
self& operator--()
{
++_it;
return *this;
}
bool operator!=(const self& rit)
{
return _it != rit._it;
}
};
因此,我们如果想使用反向迭代器,就需要调用反向迭代器模板实例化出反向迭代器类型,然后实现rbegin和rend函数即可。以list为例:
//反向迭代器
//对于*it,非const对象拿到的是非const返回值,const对象拿到的是const返回值
//对于it->,非const对象拿到的是非const指针,const对象拿到的是const指针
typedef ReverseIterator<iterator, T&, T*> reverse_iterator;
typedef ReverseIterator<const_iterator, const T&, const T*> const_reverse_iterator;
//begin和end返回值都是迭代器类的对象,单参数构造函数所以支持隐式类型转换
//非const
reverse_iterator rbegin()
{
return end(); //同样是单参数的隐式类型转换
}
reverse_iterator rend()
{
return begin();
}
//const
const_reverse_iterator rbegin() const
{
return end();
}
const_reverse_iterator rend() const
{
return begin();
}
2.deque
在开始了解栈、队列之前,我们需要再铺垫一层知识,我们引入另外一种容器,即deque。
2.1 deque数据结构
deque作为stl容器之一,叫做双端队列。通过它的名称我们已经窥见一些门路了,即这个容器是一个重点着眼于双端操作的结构。
对于实例化的deque<T>,是通过一个元素为T*的中控数组(vector)来管理数据,这个中控器中的每个元素都是一段连续空间(缓冲区)(vector)。所以可以感觉到deque有种类似于二维数组的感觉,中控数组中存储着缓冲区的地址,这样可以通过中控数组访问管理各个段的缓冲区。而缓冲区各自连续,彼此之间不连续,所以中控数组的作用就是维持这样一个连续存储的假象,以方便访问。
所以通过这种方式,deque就可以支持任意下标的访问,并且效率可观。但是因为缓冲区之间彼此不连续,所以存在着对下标进行计算,找到对应的缓冲区,再找到缓冲区中的元素,所以效率不及vector。
deque的另一个优点是无论是头部操作还是尾部操作效率都很高,因为其特殊的缓冲区的结构,所以在头尾操作时不需要移动任何元素,只需要在最前方和最后方插入删除即可。因此deque的优点就是在可以随机访问的同时头尾操作效率高,因此很适合头尾操作频繁的场景,所以stack和queue选择了deque作为容器。
2.2 deque的迭代器
deque的迭代器定义了一个元素的四个信息:
cur--当前元素的指针
first--当前元素所在缓冲区的起始指针
last--当前元素所在缓冲区的结尾指针
node--当前元素所在缓冲区在中控数组的指针
3.stack
在具有了以上知识的铺垫,我们再来设计栈就显得轻松许多了。
我们采取适配器模式,在模板参数中增加一个选择容器的参数,缺省值使用deque容器,这样的话我们仅仅需要一个成员变量,即容器对象即可。在实例化栈的时候,确定了模板参数之后,实例化出的栈对象中是另一个容器的对象。于是对栈操作的成员函数就可以反应为容器对象的成员函数的操作。
于是借助容器的操作我们就可以实现如出栈、入栈、取栈顶和判空等操作。这里也看出来了对于容器成员函数命名一致的作用,在调用的时候因为容器命名一致所以可以直接不加考虑使用同一个函数名调用同样功能但是不同容器的函数。
template<class T,class Container = std::deque<T>>
class stack {
public:
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_back();
}
const T& top()
{
return _con.back();
}
private:
Container _con;
};
4.queue
对于队列而言,和栈没有什么大的区别了。和栈类似,队列也使用适配器模式,同时使用容器的成员函数完成自己特定的功能。
template<class T, class Container = std::deque<T>>
class queue {
public:
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_front();
}
const T& front()
{
return _con.front();
}
const T& back()
{
return _con.back();
}
private:
Container _con;
};
5.priority_queue
对于优先级队列而言,就不像栈和队列那么容易了,优先级队列中有一些需要考虑到的细节问题。
5.1 优先级队列简介
优先队列实际就是我们所学过的堆的数据结构的容器,默认是大堆。之所以取名叫作优先队列,是因为对于堆而言,最有价值的就是堆顶的元素,因为堆顶的元素是所有元素中的最大或最小的一个,堆的各种应用也是利用了这一点。所以因为其首元素的特殊特征,所以认为是队列中优先级最高的元素,所以称为优先队列。
priority_queue也是采取适配器模式,默认使用的底层容器是vector。栈和队列因为是FILO和FIFO,所以操作的均是头尾的元素,而deque的出现正是为了解决头尾操作问题的,所以它们默认使用了deque的容器。而回忆堆的知识会发现,堆的最关键的向上调整和向下调整会涉及到频繁的随机下标访问,而下标访问最佳的就是vector,所以采用vector作为容器适配器。
5.2 仿函数
既然优先级队列是堆的结构,那就必然涉及到大堆小堆的分别,而我们稍微回顾一下就可以回忆起来,大小堆区分的关键在于向上调整和向下调整的比较逻辑:大堆要求父结点大于子结点,而小堆则相反,所以只要能够合理地进行比较逻辑控制,就可以进行大小堆的控制。
在C语言中,我们为了可以灵活控制大小堆,对于这个比较逻辑我们使用了函数来实现,而比较函数则是在调用向上向下调整函数时作为参数传入的。所以在C语言中我们是使用了函数指针的方法来选择大小堆的。那么进入C++中,是否有新的方便的方法可以控制大小堆比较函数呢?因此就提出了C++中仿函数的概念。
仿函数,如其名,仿函数并不是一个函数,而是一个类,只是使用方法类似于函数。
函数的调用方法:函数名(参数)
可以发现函数调用如果有运算符的话,那括号似乎就是这个运算符。所以仿函数的类重载了()这个运算符,可以使用这个类的实例化对象调用()重载函数,而运算符重载的调用方式是直接使用在对象上即可。因此这样的调用方式在形式上就类似于函数调用,对象(参数) 的方式进行调用。
运算符重载的调用方法:对象.operator运算符(参数)
运算符的常规逻辑
我们知道()操作符有两种使用方式,一个是(表达式),另一个是函数调用的函数名(形参)。而我们重载的正是函数调用的括号操作符,它是一个具有若干操作数的操作符,它有左操作数即函数名,以及若干个右操作数,即形参列表。
template<class T>
class less {
public:
bool operator()(const T& e1, const T& e2) {
return e1 < e2;
}
};
template<class T>
class greater {
public:
bool operator()(const T& e1, const T& e2) {
return e1 > e2;
}
};
因此对于如上两个类,我们先分析一下。首先他们是非静态成员函数,所以它们就有一个隐藏的第一个参数,即this指针。这个对象的this指针在调用的时候会接收第一个操作数,因此说明我们在调用仿函数的时候第一个操作数(即左操作数)应该是对象,然后剩余的操作数对应着重载函数中的其他参数。所以最后的括号运算符的调用方式应该是:对象(其他操作数),这就和函数调用的形式不谋而合了。
因此我们调用的方式就很明晰了:
less<int> lessfunc;
lessfunc(1,2);
greater<int> greaterfunc;
greaterfunc(1,2);
5.3 优先级队列的模拟
那么再来考虑priority_queue,除了元素类型、容器适配器之外,还需要一个仿函数类来控制大堆和小堆。实现大堆利用的就是less仿函数,less的()重载了小于比较,因此调用()时完成的就是两个参数之间的小于比较;而实现小堆则利用的是greater仿函数,其将()重载为了大于比较。
template<class T>
class less {
public:
bool operator()(const T& e1, const T& e2) {
return e1 < e2;
}
};
template<class T>
class greater {
public:
bool operator()(const T& e1, const T& e2) {
return e1 > e2;
}
};
template<class T, class Container = std::vector<T>, class Compare = less<T>>
class priority_queue {
public:
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
void adjust_up(size_t child)
{
size_t parent = (child - 1) / 2;
while (child > 0)
{
//Compare cmp;
//if (_con[child] > _con[parent]) //大堆
//if (_con[child] < _con[parent]) //小堆
if(Compare()(_con[parent],_con[child])) //仿函数(匿名对象)
{
std::swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void push(const T& x)
{
_con.push_back(x);
adjust_up(_con.size()-1);
}
void adjust_down(size_t parent)
{
Compare cmp;
size_t child = parent * 2 + 1;
while (child < _con.size())
{
//if (child + 1 < _con.size() && _con[child] < _con[child + 1]) //大堆
//if (child + 1 < _con.size() && _con[child] > _con[child + 1]) //小堆
if (child + 1 < _con.size() && cmp(_con[child] , _con[child + 1])) //仿函数(有名对象)
{
++child;
}
//if (_con[child] > _con[parent]) //大堆
//if (_con[child] < _con[parent]) //小堆
if (cmp(_con[parent] , _con[child])) //仿函数
{
std::swap(_con[child], _con[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void pop()
{
std::swap(_con[0], _con[_con.size()-1]);
_con.pop_back();
adjust_down(0);
}
const T& top()
{
return _con.front();
}
private:
Container _con;
};
其中对于仿函数的调用方式,首先要明确虽然称作仿函数,但实际上仍然只是一个类,调用仿函数实际上就是在调用类的成员函数。所以在调用时首先需要实例化对象,因为我们的目的是调用仿函数,所以可以实例化为有名对象,也可以是匿名对象。然后利用对象进行()运算符的重载函数的调用,对象(参数) ,因为()运算符的调用方式和函数不谋而合,所以才叫仿函数。
5.4 优先级队列的使用
因为优先级队列的模板列表中由仿函数来控制比较,在C++中已经实现了less和greater两个类,而这两个类中的比较实际是通过大于小于运算符进行比较的。所以需要明确,当模板实例化为内置类型时,因为内置类型由标准库提供大于小于比较重载,所以可以直接使用。
void Test3()
{
priority_queue<int, std::vector<int>> s1;
s1.push(1);
s1.push(2);
s1.push(9);
s1.push(4);
s1.push(8);
s1.push(3);
while (!s1.empty())
{
std::cout << s1.top() << ' ';
s1.pop();
}
std::cout << std::endl;
}
9 8 4 3 2 1
而对于自定义类型实例化时,首先要保证自定义类型重载了>、<的操作符,否则会无法比较而产生bug。
对于string类型,std容器实现了其的大于小于操作符重载所以可以正常使用优先级队列。
void Test6()
{
priority_queue < std::string, std::vector<std::string>, greater<std::string>> s1;
s1.push("hello");
s1.push("the next");
s1.push("final");
while (!s1.empty())
{
std::cout << s1.top() << std::endl;
s1.pop();
}
}
final
hello
the next
而对于其他自定义类型,则需要自己实现一个新的仿函数或者大于小于比较符重载来支持优先级队列。一般选择实现新的仿函数,因为比较符重载最后只能有一种逻辑,而对于需要多种逻辑的比较而言可以提供多种仿函数。
//对于其他没有重载比较运算符的自定义类型,就需要自己实现比较逻辑来交给优先队列
struct A {
int a;
char c;
};
//可以看到,仿函数的类不一定需要类模板,在priority_queue实例化时是类即可
class A_less_a {
public:
bool operator()(const A& e1, const A& e2)
{
return e1.a < e2.a;
}
};
template<class T>
class A_less_c {
public:
bool operator()(const T& e1, const T& e2)
{
return e1.c < e2.c;
}
};
std::ostream& operator<<(std::ostream& out, const A& a)
{
out << a.a << ',' << a.c;
return out;
}
void Test7()
{
priority_queue<A, std::vector<A>, A_less_a> q;
q.push({ 7,'f' });
q.push({ 3,'j' });
q.push({ 10,'b' });
q.push({ 9,'a' });
q.push({ 2,'p' });
q.push({ 5,'k' });
while (!q.empty())
{
std::cout << q.top() << std::endl;
q.pop();
}
}
void Test8()
{
priority_queue<A, std::vector<A>, A_less_c<A>> q;
q.push({ 7,'f' });
q.push({ 3,'j' });
q.push({ 10,'b' });
q.push({ 9,'a' });
q.push({ 2,'p' });
q.push({ 5,'k' });
while (!q.empty())
{
std::cout << q.top() << std::endl;
q.pop();
}
}
10,b
9,a
7,f
5,k
3,j
2,p2,p
5,k
3,j
7,f
10,b
9,a