一.适配器
stack和queue实际上并不能算是一种容器,而是一种容器适配器。而适配器作为stl的6大组件之一,其实是一种设计模式。适配器模式其实就是将一个类的接口(该接口无法直接满足客户的需求)转换成客户希望的另一个接口,以满足客户的需求。
而对于stack和queue来说,其就是借助另一个容器实现了LIFO(last in firtst out)和FIFO(first in first out)的功能。
而我们看到stack和queue的第二个模板参数容器给了缺省值,如果不指定容器,就会默认用deque来实现queue和stack。当然也可以利用其他的容器来实现,但是容器的结构一定得符合queue和stack的压入和弹出规则。
二.模拟实现stack
模拟实现stack时,必须要遵循stack的规则LIFO(last in first out)。而栈的接口只有压栈、出栈、取栈顶元素等操作,而我们用vector就可以完美实现stack的接口。
压栈直接调用vector的push_back即可,出栈调用vector的pop_back,取栈顶元素调用vector的back。
namespace xsc
{
template>
class stack
{
public:
void push(const T& val)
{
_con.push_back(val);
}
void pop()
{
_con.pop_back();
}
T& top()
{
return _con.back();
}
bool empty()
{
return _con.empty();
}
int size()
{
return _con.size();
}
private:
Container _con;
};
}
但是当我们了解了list的结构之后,发现其也可以很好的实现stack的各种接口。所以我们使用栈时,既可以用缺省参数vector,也可以显式给出容器list。
xsc::stack is;
xsc::stack> is2;
三.模拟实现queue
模拟实现queue时,我们要了解其规则FIFO(first in first out).当我们要借助其他容器来实现queue时就不能再用vector来实现了。因为queue涉及到了删除队头的数据,vector并没有头删的接口,当然list和vector都有erase的接口,我们可以借助这个来实现,但是用vector会涉及到数据的挪动影响效率。所以我们默认采取list来实现queue。
queue的push借助list的push_back,pop借助list的pop_front,back、front分别借助list的back和front
namespace xsc
{
template>
class queue
{
public:
void push(const T& val)
{
_con.push_back(val);
}
void pop()
{
_con.pop_front();
}
T& front()
{
return _con.front();
}
T& back()
{
return _con.back();
}
bool empty()
{
return _con.empty();
}
int size()
{
return _con.size();
}
private:
Container _con;
};
}
四.deque
虽然我们借助list或者vector实现了stack和deque,但是我们看到标准库中栈和队列的容器参数默认是deque。那什么是deque呢?
deque实际是是一个双端队列,也是一个顺序容器,是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端 进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与 list比较,空间利用率比较高。
我们可以看一下deque都实现了那些接口:
deque其实是vector和list的结合体。
因为vector和list都有其各自的优点和一些不足,所以设计容器的人就想是否可以设计出一个容器既包含vector的优点也包含list的优点——所有deque就问世了。
但是deque并没有达到预期,如果deque真的实现了vector和list优点的结合,那么vector和list就不会再用人用了。
1.vector和list的对比
vector的优点:
1、尾插尾删效率不错,支持高效下标随机访问
2、物理空间连续,所以高速缓存利用率高
vector的缺点:
1、空间需要扩容,扩容有一些代价(效率和空间浪费),且这个代价到后期数据多的时候更大
2、头部和中间插入/删除效率低(挪动数据)
list的优点:
1、按需申请释放空间,不需要扩容
2、支持任意位置O(1)的插入删除
list的缺点:
1、不支持下标的随机访问
2.deque的结构
deque的底层结构并不是一段连续的物理空间,而是类似于一个二维数组。由一个中控数组和若干个buff组成。
中控数组map其实是一个指针数组,其中存储着指向一个个buffer的指针。而buffer是一个定长数组(或者动态数组),所有的数据都存储在buffer上。
不同的是,中控数组map是从中间开始存储的,第一个buffer的指针存储在中控数组的中间部分,如果要头插就在该位置的前面存储一个buffer的地址。当map满了之后还需要对map进行扩容。
deque还重载了[] ,那么deque是如何支持下标访问的呢?
我们可以采取/和%的方式,定位到指定下标的元素位置。
假设要获取下标为i的元素,每一个buffer的大小为N。我们先用 x = i / N就可以得到该元素位于第几个buffer中;然后用y = i % N就可以知道该元素是这个buffer的第几个位置,然后借助两次operator[][]的调用map[x][y]就可以得到该数据。
3.deque的迭代器
deque的迭代器是由4个指针来实现的
cur指向当前正在访问的元素,first指向这段buffer的开始位置,last指向这段buffer的结束位置,node指向中控数组中存储这个buffer的位置。
借助这个迭代器,deque模拟出来物理空间连续的假象。
当我们遍历时,当cur等于last时说明这个buffer已经遍历完了,此时需要走到下一个buffer,只需要让node++即可。
4.deque的维护
deque这个容器的维护主要是借助两个迭代器来实现的:
这两个迭代器就相当于begin()和end()。分别指向开始和结尾。
遍历容器时,其实是创建一个迭代器,然后将start的值给他,然后通过cur来遍历。
5.deque的push_back()/push_front()
deque的尾插和头插效率都很高,尾插直接向buffer里面插入即可,如果buffer满了在申请一个即可。
deque的头插数据的方式有些许不同。头插时是往中控数组的的前面插入一个buffer,然后将头插的元素插入到buffer的尾部。
要注意的是头插和尾插之后start和finish都需要改变。
6.deque的insert/erase
当在中间位置插入删除时,有两种方式:
1、挪动数据,保证buffer的大小不变,但是这样就会导致效率极低
2、扩大或缩小buffer,但是这样会导致下标访问会更加困难,不能用/和%的方式得出下标位置。
总结:
1、deque的头尾插入删除效率很高,更甚于vector和list
2、下标随机访问也还不错,但略逊于vector
3、中间位置的插入删除效率很低O(N),需要挪动数据
7.deque的缺陷
与vector比较,deque的优势是:头部插入和删除时,不需要挪动数据,效率特别高,而且在扩容时,也不需要搬移大量数据,因此其效率是比vector高的。
与list比较,其底层空间是连续空间,空间利用率比较高,不需要存储额外字段
但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器需要频繁的去检查其是否移动到buffer的边界,导致效率低下,而在序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构
8.为什么选择deque作为stack和queue的底层默认容器
stack是一种后进先出的特殊线性数据结构,因此只要具有push_back()和pop_back()操作的线性结构,都可以作为stack的底层容器,比如vector和list;
queue是先进先出的特殊线性数据结构,只要具有push_back()和pop_front()操作的线性结构,都可以作为queue的底层容器,比如:list。
但是STL中对stack和queue默认选择deque作为其底层容器,主要是因为:
1、stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作
2、在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率还高。
结合了deque的优点,而完美的避开了其缺陷。
9.对比deque和vector的排序效率
我们从结果可以分析出,在存储相同数据的情况下,不论是直接比较deque和vector,还是用两个存储相同数据的deque,一个直接排序,一个先将数据拷贝到vector中,排序,拷贝会去,效率都是采用vector的高。
这是因为在sort算法下采用的是快排,快排需要对数据进行访问,而vector对数据的随机访问效率要比deque高得多。
void test_op1()
{
srand(time(0));
const int N = 1000000;
deque dq;
vector v;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
v.push_back(e);
dq.push_back(e);
}
int begin1 = clock();
sort(v.begin(), v.end());
int end1 = clock();
int begin2 = clock();
sort(dq.begin(), dq.end());
int end2 = clock();
printf("vector:%d\n", end1 - begin1);
printf("deque:%d\n", end2 - begin2);
}
void test_op2()
{
srand(time(0));
const int N = 1000000;
deque dq1;
deque dq2;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
dq1.push_back(e);
dq2.push_back(e);
}
int begin1 = clock();
sort(dq1.begin(), dq1.end());
int end1 = clock();
int begin2 = clock();
// 拷贝到vector
vector v(dq2.begin(), dq2.end());
sort(v.begin(), v.end());
dq2.assign(v.begin(), v.end());
int end2 = clock();
printf("deque sort:%d\n", end1 - begin1);
printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
}
五.priority_queue
1.priority_queue基本概念
priority_queue就是优先级队列,它也是一种容器适配器。但是它不同于queue,不再以deque作为底层容器,而是vector。
而priority_queue在底层是vector的前提下,又采用了堆的算法,将其包装成一个堆。所以priority_queue其实就是一个堆(默认是大堆)。其设计的一系列接口也都是模拟了堆的行为。
top取出堆顶的元素(最大/最小的元素)
push插入数据然后采用向上调整算法使其依旧是一个堆
pop删除堆顶的数据然后采用向下调整算法使其依旧是一个堆
在使用向上(向下)调整算法时,要保证其原本就是堆。
小伙饿坏了,赶紧点了一份爱学的二叉树——堆,狼吞虎咽学完很过瘾-CSDN博客
在这里可以回顾一下堆的基本操作:
1、堆是一个完全二叉树,完全二叉树就是前面每层都是满的,最后一层从左到右是连续的。
2、知道一个父亲节点的下标parent,怎么求它的左右孩子:left = 2*parent+1,right = 2*parent+2
3、知道孩子节点的下标child,怎么求父亲节点:parent = (child-1)/2.
4、大堆:父亲节点的值大于等于左右孩子的值,但左右孩子之间没有大小关系
5、小堆:父亲节点的值小于等于左右孩子的值,但左右孩子之前没有大小关系。
6.插入节点:插入节点是插入到最后一层从左到右的最后一个位置,但是插入必须要保证插入之后也是一个堆,所以插入之后要进行向上调整,与它的父亲节点比较,如果是大堆的话,插入的大于父亲节点就交换,直到小于某个父亲节点或者到达堆顶就停止交换。
7.删除堆顶元素:首先将堆顶元素与最后一个节点交换,然后再删除最后一个节点,然后进行向下调整,使之仍然是堆
优先级队列之所以默认是大堆,原因在于其第三个模板参数 ,当我们改变第三个模板参数时,就可以将其变为小堆
//priority_queue q; // 大堆
priority_queue,greater> q; // 小堆
2.模拟实现priority_queue
默认是大堆
插入之后向上调整时,将孩子与父亲进行比较,如果孩子大于父亲就交换,直到孩子小于父亲/已经交换到了堆顶。
删除之后向下调整时,父亲与左右孩子大的那个交换(采取假设法:先假设左孩子大,再判断右孩子是否大于左孩子,如果大于那么child+1),直到父亲大于左右孩子,或者已经交换到最后一层。
小伙饿坏了,赶紧点了一份爱学的二叉树——堆,狼吞虎咽学完很过瘾-CSDN博客
namespace xsc
{
//默认是大堆
template >
class priority_queue
{
public:
void AdjustUp(int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (_con[child] > _con[parent])
{
swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void push(const T& val)
{
_con.push_back(val);
AdjustUp(_con.size() - 1);
}
void AdjustDown(int parent)
{
int child = parent * 2 + 1;
while (child < _con.size())
{
if (child + 1 < _con.size() && _con[child] < _con[child + 1])//只有这个条件可能会越界:a[child] > a[child + 1]
{
child += 1;
}
if (_con[parent] < _con[child])
{
swap(_con[child], _con[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void pop()
{
swap(_con[0], _con[_con.size() - 1]);
_con.pop_back();
AdjustDown(0);
}
T& top()
{
return _con[0];
}
bool empty()
{
return _con.empty();
}
int size()
{
return _con.size();
}
private:
Container _con;
};
}
那如果要实现一个小堆呢?难道要写出两个类模板么?
在标准库中的priority_queue是借助第三个模板参数来实现的:
//priority_queue q; // 大堆
//priority_queue, less>;// 第三个默认参数是less
priority_queue,greater> q; // 小堆
而这第三个模板参数其实是借助仿函数来实现的。
3.仿函数
仿函数人如其名,它并不是一个函数,而是一个重载了()的一个类或者结构体,它可以使其看上去像一个函数一样,像函数一样使用。仿函数可以接受参数并返回值,可以用于STL算法中的函数对象参数,也可以用于函数指针的替代。
template
struct Less
{
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
int main()
{
Less l;
cout << l(1, 2) << endl;
return 0;
}
我们实际上是借助这个类创建了一个对象,但是我们使用这个对象的方式却是调用函数的行为,这就是仿函数。
实际上是调用了该类的operator()。
cout << l.operator(1, 2) << endl;
我们可以实现两个仿函数Less和Greater来封装比较的逻辑,对priority_queue增加第三个模板参数,并将其内部的比较都换成仿函数的比较方式,以此来改变向上向下调整的比较逻辑,实现大堆/小堆。
在库中,less默认是<,gerater默认是>,所以我们采取和库里一样的比较方式。
template
struct Less
{
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template
struct Greater
{
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
用第三个模板参数创建一个对象,然后用该对象调用operator(),进行比较。注意在比较时要注意符合符号以及是大堆还是小堆。
完!