前言:
我们已经掌握了string vector list三种最基本的数据容器模板,而对于数据结构的内容来说,其余的数据结构容器基本都是这三种容器的延申和扩展,在他们的基础上扩展出更多功能和用法,今天我们便来模拟实现一下C++库中的栈和队列以及优先级队列。
1.适配器:
让我们打开stack模板库的介绍界面,我们会看到这样一个东西:
stack的模板中的这个Container是什么呢?没错,在这里我们将其称之为适配器,它的作用是,为我们的的栈或者其他数据结构自动匹配底层的容器模板库,如下:
cpp
template<class T, class Container = deque<T>>
class stack
{
private:
Container _con;
};
int main()
{
hbw::stack<int,list<int>> a1;
}
你会发现,我们的私有的成员直接就是适配器类型的,这样当我们的主程序让其适配器为list类型的时候,适配器会去判断当前的stack的功能函数能否复用到list的成员函数中,倘若可以,我们就可以通过list的底层模板函数功能直接实现出stack的功能。
因此,只要我们的容器的底层容器的功能能够匹配我们当前容器的功能,适配器就可以自动适配到对应的容器中,这样的复用大大节省了效率,让我们在开发时不用再去自己构建复杂的函数和对应的容器即可实现,或许我这样说,你还没法理解,我们接下来的栈和队列的模拟实现的代码,你通过去与我C语言实现的栈和队列去对比即可明白。
deque容器:
依旧是拿出来我们的stack的模板参数,你会发现,它在适配器参数上给了个缺省值deque,根据刚才的是适配器的知识点我们知道,这里的container指代的是一个容器类型,故我们的deque也一定是一个容器类型,因此,我们可以先猜测一下,这个deque为何让它作为缺省参数呢?
根据之前的C语言栈的队列篇我曾经说到,栈和队列都是可以同时使用数组和链表来实现的,对应到C++里,也就是说vector或者list都可以作为stack的底层,因此,作为缺省值,这个deque理论上应当具备两者都有的功能,如下:
没错,从它的成员函数来看,它既可以和vector一样去利用[]访问下标,也可以实现list的头删头插,访问头尾的功能,因此,在这里让deque作为缺省值,确实是合适不过的,但是,它是如何同时具备两种数据结构的特点的呢?下面让我们一起来分析一下它的容器构建原理:
deque容器的介绍:
deque被称为双端队列。虽然叫它队列,但实际上它并不是队列,也就是说它不是仅仅可以尾插头删,只不过叫这个名字,这个首先要明确,别搞混。它是一种从中间向两边延申的结构,在它的模板中,我们看到了deque使用了内存池allocator来存储数据:
或许说到这里,我们大致猜出来deque的大体模型是怎样的了,在我看来更类似即可几个数组通过某种方式拼接,让这些数组在逻辑上是连续的,deque的具体构造如下图:
我们的deque支持两个容器的功能,可以说它的功能是全面的,而我们也知道它使用了内存池allocator,这个内存池的就是我们上图中的BUFF子数组,每一个BUFF数组的长度都是统一的,这样方便我们的下标访问执行。
它实现功能大致如下:
1.尾插:
则在最后一个数组之后再开辟一个新的BUFF,将尾插数据放在这个数组的第一位
2.头插:
在第一个BUFF之前再开辟一个BUFF,将头插数据放在这个数组的最后一位
我们发现,这样的处理方式是没有扩容的消耗的,也不需要挪动数据,很高效
3.之间插入:
中间插入时,我们只有两种办法:
1.BUFF进行扩容/控制数据个数
2.局部整体挪动
这两种方法都可以,但是都有各自的缺点,比如,如果我们对BUFF进行扩容,就会影响我们的[]下标访问,根据deque的结构我们可以总结出,deque下标的计算公式是:x=i/10+i%10,当然,这里是我们假设我们的BUFF都为10的情况下,因此,首先/10确定对应的元素在哪个BUFF子数列里,然后%10确定它在这个子数列的第几个,这样,我们就可以精确的锁定位置,从而让下标访问生效。所以,一旦我们去修改BUFF的长度,就会导致我们这个公式直接无效,十分影响[]访问,但倘若挪动数据,则又会消耗大量的时间,因此,两种方案各有取舍,我们可以根据库STL去看看官方是如何实现的。
我们的deque只涉及到中控数组的扩容问题。但是,由于存储的数据是指针,故只要进行浅拷贝即可,因此,它扩容的消耗并不大。
综上,虽然deque兼具vector和list的双重特点,但它却没有将自己的特性优化到极致,我们可以说deque在头插和尾插方面确实有很大的优势,但是它的下标访问和中间的增删都不如vector和list,具体的deque的实现如下:
它一共有四个指针去控制整个结构,其中cur用来指针的实时位置,first指向一个BUFF的头,last指向这个BUFF的尾部,而node则用来控制中控数组的指针,当cur==last的时候,node自动向下移动一位,从而实现了下标的连续访问。
2.stack模拟实现:
有了前面的知识铺垫,我们已经不需要怎样去解释实现的细节,直接按照代码理解即可
cpp
namespace hbw
{
template<class T, class Container = deque<T>>//这里通过这个模板参数控制底层的容器是哪个类型,是谁,这个Container即为适配器,不管底层容器是谁,它都可以适配一个后进先出的栈,如果适配器不适用(比如对应的底层的容器不支持上层的功能),则编译器会报错
class stack//我们在这里加上了一个缺省参数,deque容器,这就符合了我们倘若不传对应的数据类型,编译器就会为我们自动适配deque容器,deque容器是一个功能很全面的容器,虽然效率上不够极致,但是泛用性强,故用在这里可以支持list和vector两者的全部功能,从而有了作为缺省值的条件
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_back();
}
const T& top()
{
return _con.back();
}
bool empty()
{
return _con.empty();
}
size_t size()
{
return _con.size();
}
private:
Container _con;
};
}
3.queue模拟实现:
cpp
namespace hbw
{
template<class T, class Container = deque<T>>
class queue
{
public:
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();
}
bool empty()
{
return _con.empty();
}
size_t size()
{
return _con.size();
}
private:
Container _con;
};
}
他们两个的本质都是复用,复用底层的函数封装成自己的函数功能,这就是适配器的优点所在,不过,值得注意的是,适配器只能转换为符合当前功能的底层容器,对于不符合功能的容器,编译器是没法通过的
4.优先级队列priority_queue容器:
何为优先级队列,即是一个优先按照升序或者降序存储输出值的容器,我们可以先看一下它的一些模板功能:
没错,看到它的函数功能,它可以返回顶部的元素,又结合它按照顺序输出数的特性,我们不难看出,它很像我们曾经模拟实现的一个数据结构-堆。因此,它的默认底层容器为vector,也就死用数组作为默认的缺省底层容器
没错,虽然叫它优先级队列,但实际上,它的本质就是一个升序或者降序的堆,既然是堆,我们就可以复用我们之前学过的堆的各种接口,故它的模拟实现如下:
首先,我们依旧使用适配器来作为priority_ queue的底层如下:
cpp
template<class T,class Container=vector<T>,class Comapre=Less<T>>
private:
Container _con;
};
1.push函数:
cpp
void Adjustup(int child)//向上调整
{
Compare com;
int parent = (child-1)/ 2;
while (child > 0)
{
if (com(_con[parent],_con[child]))
{
std::swap(_con[child], _con[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
void push(const T& x)
{
_con.push_back(x);
Adjustup(_con.size()-1);
}
push函数,我们的基本思路就是,将任意数据放入我们的底层容器后,将其向上调整到对应的位置,保证我们的堆的数据之间的关系不会乱,这里的向上调整的函数之前实现过,在这里我们可以复习一遍。
2.pop函数:
cpp
void Adjustdown(int parent)//向下调整
{
Compare com;
int child = 2 * parent + 1;
while(child<_con.size())
{
if (child + 1 < _con.size() &&com(_con[child],_con[child+1]))
{
child++;
}
if(com(_con[parent],_con[child]))
{
std::swap(_con[child], _con[parent]);
parent = child;
child = 2 * child + 1;
}
else
{
break;
}
}
}
void pop()
{
std::swap(_con[0],_con[_con.size()-1]);
_con.pop_back();//尾删一个
Adjustdown(0);
}
对于删除来说,我们一定是删除头数据比较有价值,故我们采取的方式是,首先让头尾数据交换位置,然后将尾部数据删除,然后将头数据向下调整到正确的位置,保证堆的数据大小关系不会错误。
3.其余的接口
cpp
const T& top()
{
return _con[0];
}
bool empty()
{
return _con.empty();
}
size_t size()
{
return _con.size();
}
T& operator[](size_t i)
{
return _con[i];
}
都是一些很简单的接口,我在这里不多解释,读代码就应该能看懂。
4.仿函数(关键!!!!):
在priority_queue中,我们看到了这样的一个模板参数,compare,它的默认参数值给了一个less,经过尝试,我们知道,这个less实际上就是构建大堆的意思,但是,在这里的这个Compare是什么意思呢?这就是我们要说的仿函数.
那什么是仿函数呢?我们先看一个例子:
cpp
template<class T>
class Less//仿函数less
{
public:
bool operator()(T& x, T& y)
{
return x < y;
}
};
template<class T>
class Greater//仿函数greater
{
public:
bool operator()(T& x, T& y)
{
return x > y;
}
};
仿函数的本质就是一个只封装了一个()运算符重载的函数的类,当我们使用的时候,直接在类名后面带上括号即可调用这个函数,导致我们看到它的形式就类似一个函数调用,但本质上它依旧是一个类,所以称它为仿函数。如下:
cpp
void Adjustdown(int parent)//向下调整
{
Compare com;
int child = 2 * parent + 1;
while(child<_con.size())
{
if (child + 1 < _con.size() &&com(_con[child],_con[child+1]))
{
child++;
}
if(com(_con[parent],_con[child]))
{
std::swap(_con[child], _con[parent]);
parent = child;
child = 2 * child + 1;
}
else
{
break;
}
}
}
当我们看到这个compare com时,这个com就是仿函数类,后续比较的时候,直接利用仿函数传入参数就可以直接进行比较,比如这里的com(_con[child],_con[child+1]。
仿函数的优点和用处:
有了仿函数,我们就可以像预处理那样对一些运算方法进行复用和小成本的修改,比如我们想从建立大堆变成建立小堆,就像上面一样,分别写一个大堆一个小堆两个仿函数,想使用哪个直接在模板参数里实例化即可,这样提高了效率。
但是仿函数的用处更多的在于它替换了函数指针,我们不用在写繁杂的函数指针参数去使用回调函数,不仅难写而且易错,而是利用仿函数,调用即可,其实本质上仿函数和回调函数的用处一样的,但是仿函数更加好用和简单。
一般仿函数都写成模板类,让其可以针对任意类型进行函数使用,让其运用的场景更加广泛。
5.构造函数:迭代器数据传入构造建堆
cpp
priority_queue()//写一个默认无参的构造函数,让编译器自己生成默认的构造函数构成重载
{}
template<class InputIterator>
priority_queue(InputIterator first, InputIterator end)//利用区间进行构造函数
:_con(first,end)//首先利用vector可以区间构造的特点,先把数据放入到vector容器中
{
int i = 0;
for (i = (_con.size() - 2) / 2; i >= 0; i--)
{
Adjustdown(i);
}
}
priority_queue支持传入迭代器区间去建堆,其构造的特点就是首先利用底层容器的vector支持迭代器区间构建数组的特点先初始化_con,然后利用向下建堆的方法,从而建立一个堆,但是写下这个构造函数之后,我们的默认构造函数就没有了,也就是说,后续的构造函数都得传区间,这个是不一定,故我们再写一个默认无参或者全缺省的构造函数,这个就是由编译器自动生成的构造函数,由此,我们就可以同时支持区间迭代器构造和默认构造了。
总结:
以上便是我们的stack queue 优先级队列的基本内容,到这里,我们基本已经掌握了STL库的基本容器模板,但我还是要强调的一点,我已经反复强调了,模拟实现模板的目的是让我们更好的去使用模板,从C语言的思维中走出来,尝试利用C++的思维去解题和分析,熟练的利用模板去简化代码和提高开发效率。同时,模拟实现的过程中我们也学到了如迭代器,迭代器自定义封装,内存池,如何忽略空格任意字符识别,如何扩容,迭代器失效,仿函数等一系列更加重要的属于C++的知识点,我认为这些才是关键,因此,我们应该要抓住我们的重点去学习,在我看来,这是最关键的。