我们先模拟实现, 然后根据代码讲解如何使用.
目录
一.栈
1.stack类中的相关成员
2.stack成员函数的具体实现
3.stack的使用
二.队列
1.queue类中的相关成员
2.queue成员函数的具体实现
3.queue的使用
三.堆
1.priority_queue的特性介绍
2.priority_queue类中的相关成员
3.priority_queue类中成员函数的具体实现
四.deque的介绍
1.deque的组织形式
2.deque插入元素
3.deque删除元素
4.deque缺陷
五.容器和容器适配器
1.容器
2.适配器
因为c++编译器中已经给出了stack, 所以我们在实现时把其放到自己的命名空间中.
采用模板类的方式实现, 可以针对于各种不同的数据, 因为c++中已经给出了很多可以用来实现stack的容器, 我们这里选用deque(双端队列).(deque在后面会详细讲解)
功能介绍:
①.push入栈.
②.pop出栈.
③.top访问栈顶元素.
④.size计算栈中元素个数.
⑤.empty判断栈中是否为空.
为什么使用deque而不是vector实现栈?
1.首先我们要知道, 我们实现的栈只有两种操作, 一种是push, 一种是pop, 也就是出栈和入栈两种操作, 因此使用vector, list, deque 都可以, 但他们的性能上有区别, 所以我们在使用的时候, 需要根据实际情况来选择使用那种容器, 但这里为什么将deque作为stack的默认容器呢? 我们平时在c中实现的satck都是使用动态数组来完成的, 也就是vector那么为什么在这里就不使用了, 其实它是考虑了扩容的问题, 因为我们使用vector, 一但容器容量满了, 就需要扩容, 但vector扩容时十分耗费资源, 需要重新申请空间和拷贝元素, 而如果我们使用deque的话, 实现push和pop两个操作和vector相似, 并且扩容时相对比较方便, 因此在这里编译器选择使用deque来作为stack的默认容器.
2.栈不需要遍历, 因此刚好把deque的劣势规避了, 所以使用deque更加合适.
template>
class stack{
public:
stack();
void push(const T& x) ;
void pop() ;
T& top() ;
const T& top()const ;
size_t size()const ;
bool empty()const ;
private:
Con _c;
};
a.构造函数不需要显示定义, 默认的构造函数会调用deque的构造函数对类的数据成员进行初始化.
b.push入栈
void push(const T& x) {
_c.push_back(x);
}
直接调用deque的push_back函数, 向双端队列尾部插入元素即可.
c.pop出栈
void pop() {
_c.pop_back();
}
同理, 调用deque的pop_back函数, 从双端队列尾部删除元素即可.
d.top访问栈顶元素
T& top() {
return _c.back();
}
返回双端队列的尾部元素.(const类型的top与普通top完全相同)
e.size有效元素个数
size_t size()const {
return _c.size();
}
依然是返回双端队列中的有效元素个数, 调用deque的size函数.
f.empty判空
bool empty()const {
return _c.empty();
}
调用deque的empty函数, 判断双端队列中元素是否为空.
测试:
①.创建栈.
②.向栈中插入元素.
③.打印栈顶元素.
④.打印栈中元素个数.
⑤.出栈.
⑥.打印栈顶元素.
⑦.打印栈中元素个数.
lz::stack s1;
s1.push(1);
s1.push(2);
s1.push(3);
s1.push(4);
s1.push(5);
s1.push(6);
cout << s1.top() << endl;
if (!s1.empty()) {
cout << s1.size() << endl;
}
s1.pop();
s1.pop();
s1.pop();
cout << s1.top() << endl;
if (!s1.empty()) {
cout << s1.size() << endl;
}
运行结果:
同样我们的队列需要在自己的命名空间中实现.
采用模板类的方式实现, 应对于不同类型的数据, 底层使用的默认容器为deque.
功能介绍:
①.push入队列.
②.pop出队列.
③.back访问队列尾部元素.
④.front访问队列首部元素.
⑤.size返回队列中有效元素个数.
⑥.empty判断队列是否为空.
为什么默认容器使用deque而不使用list?
1.queue不需要存储一些额外的内容, 而list中的每个结点还存储了next | prev两个结构体指针, 所以使用list会浪费内存空间.
2.deque是分段连续的, 因此其缓存效率高.
3.queue不需要遍历, 因此规避了deque的劣势.
template>
class queue{
public:
queue() ;
void push(const T& x) ;
void pop() ;
T& back() ;
const T& back()const ;
T& front() ;
const T& front()const ;
size_t size()const ;
bool empty()const ;
private:
Con _c;
};
a.构造函数不需要显示定义, 默认的构造函数会调用deque的构造函数对类的数据成员进行初始化.
b.push
void push(const T& x) {
_c.push_back(x);
}
调用deque的push_back函数, 向队列尾部插入元素.
c.pop
void pop() {
_c.pop_front();
}
调用deque的pop_front函数, 从队列首部删除元素.
d.back
T& back() {
return _c.back();
}
const T& back()const {
return _c.back();
}
调用deque的back函数, 访问队列尾部的元素.(const类型的back和普通类型back实现方式完全相同)
e.front
T& front() {
return _c.front();
}
const T& front()const {
return _c.front();
}
调用deque的front函数, 访问队列首部元素.(const类型的实现和普通类型的实现完全相同)
f.size
size_t size()const {
return _c.size();
}
调用deque的size函数, 返回队列中有效元素的个数.
g.empty
bool empty()const {
return _c.empty();
}
调用deque的empty函数, 判断队列是否为空.
测试:
①.创建队列.
②.向对列中插入元素.
③.打印队列首部和尾部的元素.
④.将元素出队列.
⑤.打印队列首部元素和尾部元素.
⑥.打印有效元素个数.
lz::queue a;
a.push(1);
a.push(2);
a.push(3);
a.push(4);
a.push(5);
cout << a.front() << endl;
cout << a.back() << endl;
a.pop();
a.pop();
a.pop();
cout << a.front() << endl;
cout << a.back() << endl;
if (!a.empty()) {
cout << a.size() << endl;
}
运行结果:
priority_queue(优先级队列), 是一个完全二叉树, 每个父亲结点都比子节点大或者小, 因为其性能和使用方式跟堆完全相同, 因此在c++中就将其看作堆.
使用时注意包含头文件: #include
注意:
1. 默认情况下, priority_queue是大堆.
2. 如果在priority_queue中放自定义类型的数据, 用户需要在自定义类型中提供> 或者< 的重载.
3. 当我们在堆中放置的元素是自定义类型的指针时, 堆在进行元素比较的时候是使用地址来进行比较的, 并不会使用指针指向的内容来进行比较, 因此为了解决这种问题, 我们要自定义比较方式, 而自定义比较方式的方法有三种: ①.函数指针 ②.仿函数 ③.Iambda表达式(c++11).
默认的存储结构:
优先级队列默认情况下将元素存储到vector中, 堆的概念: 元素存储到数组中(完全二叉树)
堆中默认的比较方式:
默认情况下优先级队列中元素是按照小于的方式进行比较的, 创建出来的是大堆, 如果想要创建小堆, 只需要在实例化时, 让优先级队列中的元素按照大于及greater
堆中存储的数据类型:
1.内置类型(c++编译器给出的):
如果优先级队列中存储的是内置类型的元素, 直接采用less的默认方式进行比较或者传递greater
. 2.自定义类型:
如果优先级队列中存储的是自定义类型的元素, 使用less的默认方式或greater
进行比较时, 需要用户在自定义的类中将<或者>重载. 3.有些情况下less或者greater已经不能进行比较了(指针类型):
我们需要自定义比较方式, 比如: priority_queue, Compare> p;
如何自定义比较方式:
①.函数指针(不推荐).
②.仿函数
说明(什么是仿函数): 仿函数也可以将其称为函数对象, 可以像函数调用方式使用的对象.
实现方式: 只需在类中将函数调用运算符即()重载即可.
实例:
作用: 让算法或者类更加灵活----->通过仿函数来配置算法或者类具体的功能, 例如: sort中的比较方式, priority_queue中的比较方式.
③.Iambda表达式(后期会介绍)
依旧是按模板类的方式实现, 应对于不同类型的数据, 防止重复代码, 模板参数列表分别为: 数据类型T, 容器类型默认vector, 比较方式默认less
. 成员函数介绍:
①.默认构造函数.
②.区间构造函数.
③.empty判空
④.size有效元素个数.
⑤.top返回堆顶元素.
⑥.push向堆中插入元素.
⑦.pop删除堆顶元素.
⑧.AdjustDown向下调整算法(实现堆的核心算法)
template , class Compare = less >
class priority_queue{
public:
priority_queue();
template
priority_queue(InputIterator first, InputIterator last) ;
bool empty() const ;
size_t size() const ;
const T& top() const ;
void push(const T& x) ;
void pop() ;
private:
void AdjustDown(int n) ;
private:
Container c;
Compare comp;
};
(1).向下调整算法
void AdjustDown(int n) {
int father = n;
int child = father * 2 + 1;
int size = c.size();
while (child < size) {
if (child + 1 < size && comp(c[child], c[child + 1])) {
child++;
}
if (comp(c[father], c[child])) {
swap(c[father], c[child]);
father = child;
child = father * 2 + 1;
}
else {
return;
}
}
}
根据大堆的建立我们来分析一下代码, 首先我们取到一个父亲节点和一个左孩子节点, 然后进入循环, 在其右孩子存在的情况下, 取到两个孩子中的最大值, 然后再拿孩子中的最大值和父亲节点比, 如果比父亲节点大, 则交换两个的位置, 交换之后重新设置父亲节点和孩子结点继续循环向下调整, 没有父亲节点大, 则退出函数, 经过这样一套流程, 我们就能保证在创建大根堆时, 父节点的值是最大的, 但这个算法显然只能保证一条二叉树满足堆的特性, 但整个堆则是一个完全二叉树, 有很多二叉树的分支, 我们要保证堆中每个父亲结点和孩子结点都要满足堆的特性, 因此使用循环从最后一个父亲结点开始调用该算法, 直到堆中的根节点.
运行图示:
(2).构造函数
priority_queue(){}
template
priority_queue(InputIterator first, InputIterator last) {
while (first != last) {
c.push_back(*first);
first++;
}
int i;
for (i = (size() - 2) / 2; i >= 0; i--) {
AdjustDown(i);
}
}
①.默认构造函数的实现是为了, 在创建堆对象时, 由默认构造函数调用vector和less的构造函数对这两个数据成员进行初始化.(调用这个的时候创建出来的是一个空的堆, 后续我们在使用时需要自己向堆中插入元素)
②.区间构造函数, 我们提前创建好一个数组, 并在其中存入元素, 调用该函数创建堆, 则是利用我们提前存入数组中的那些元素, 对其进行组织, 将其调整成堆, 存在我们的数据成员vector中, 具体实现, 将区间中的数据先拷贝到自己的数据成员vector中, 然后从完全二叉树的最后一个父亲结点开始调用向下调整算法, 并向前循环调用, 直到根节点.(和前面向下调整算法中讲解的完全相同)
测试:
int a[] = { 3,4,5,6,7,8,2,3,1,0 };
lz::priority_queue a1;
lz::priority_queue a2(a, a + sizeof(a) / sizeof(a[0]));
a1.put();
a2.put();
运行结果:
(3).容量相关操作
bool empty() const {
return c.empty();
}
size_t size() const {
return c.size();
}
const T& top() const {
if (empty()) {
assert(false);
}
return c[0];
}
①.判断堆是否为空, 直接调用vector的empty即可.
②.判断堆中有效元素个数, 依旧是调用vector的size函数.
③.访问堆顶元素, 首先判断堆是否为空, 在堆不为空的前提下返回vector中0号下标所对应的元素, 为空则报错.
测试:
int a[] = { 3,4,5,6,7,8,2,3,1,0 };
lz::priority_queue a2(a, a + sizeof(a) / sizeof(a[0]));
if (!a2.empty()) {
cout << a2.size() << " " << a2.top() << endl;
}
运行结果:
(4).堆中插入元素和删除元素(push&pop)
void push(const T& x) {
c.push_back(x);
int i;
if (size() > 1) {
for (i = (size() - 2) / 2; i >= 0; i--) {
AdjustDown(i);
}
}
}
void pop() {
if (empty()) {
assert(false);
}
int size = c.size();
swap(c[0], c[size - 1]);
c.resize(size - 1);
AdjustDown(0);
}
①.向堆中插入元素, 我们先将元素放到vector的尾部, 然后和创建堆时一样, 从最后一个父亲结点开始, 调用向下调整算法, 直到根节点.(每插入一个元素都要重新保证二叉树符合堆的特性)
②.从堆中删除元素, 堆中元素的删除, 只能删除堆顶元素, 因此我们先将堆顶元素和堆中最后一个元素交换位置, 将vector的有效元素个数减1, 然后再看堆顶元素, 除了堆顶的这个元素外,其左右两边的子树都符合堆的特性, 因此只用从根节点开始, 再调用一次向下调整算法, 即可让整个二叉树依旧保证堆的特性.
堆的删除图示:
测试:
int a[] = { 3,4,5,6,7,8,2,3,1,0 };
lz::priority_queue a2(a, a + sizeof(a) / sizeof(a[0]));
a2.push(10);
a2.push(1);
a2.push(11);
a2.push(5);
a2.put();
a2.pop();
a2.pop();
a2.pop();
a2.pop();
a2.put();
运行结果:
double-ended queue(双端队列)(容器)
图示:
两个迭代器, start和finish, (start)一个负责队列的前端, (finish)一个负责队列的后端, 其次还需要一个map来记录数据空间, 接着来看, 我们start中的node指针则指向map中当前所在空间的位置, 从而可以让start通过map找到数据空间, 然后就是first和last指针, 分别指向数据空间首部和尾部, 以此来作为我们使用cur指针插入元素时的标志, finish中也同样如此, 因为其使用了map来记录数据空间, 因此deque的扩容也变的更为简单(相对于vector), 只用创建一个更大的map, 将旧的map中的数据空间的地址复制过去即可, 但也正因为这样组织空间所以使其元素的遍历变的更加困难, 所以我们在使用deque的时候, 要考虑到其是否需要遍历操作, 如果要用到就考虑换个容器, 不适用deque.
插入: 当我们进行头插尾插时, 直接寻找start和finish中的cur即可, 头插时start中的cur向前走插入元素, 如果start对应的空间满了, 则在map中重新申请一块空间, 使用start指向就行, 尾插时finish中的cur向后走插入元素, 如果finish对应的空间满了, 同样在map中新申请一块空间, 使用finish指向就行, 如果map满了这时就涉及到我们deque的扩容问题了, 我们只需重新申请一块更大的map类型的空间, 再将原来map中存储的地址拷贝过去并更改start和finish中的node指针的指向就行, 无需对每个存储数据的空间进行拷贝, 这也正好显示了对比于vector时deque扩容的优越性. 头插尾插的时间复杂度为0(1).
删除: 当我们进行头删尾删时, 也是针对start和finish中的cur指针进行操作, 头删时start中的cur向后移动即可, 如果cur走到last的位置, 则node向后走一位, 并改变first, cur, last的指向即可, 尾删时finish中的cur向前移动即可, 如果cur走到first的位置, 则node向前走一位, 并改变first, cur, last的指向即可, 当start和finish走到一起时, 并且他们的cur也都指向同一个空间时, 意味着deque为空. 头删尾删的时间复杂度为0(1).
缺陷:
1.代码实现复杂.
2.遍历操作复杂并且效率低, 因为迭代器每移动一次都要检测是否在空间的末尾.
容器是什么?
容器就是能够存储数据的一种结构体, 因此容器适配器在某种情况下也可以看做容器.
容器的分类:
(1).序列式容器(线性数据结构)
a. c++98: vector, list, deque
b. c++11: array(静态类型顺序表) forward_list(带头结点循环单链表)
(2).关联式容器(存储的是键值对)
C++ STL 标准库提供了 4 种关联式容器,分别为 map、set、multimap、multiset
具体的在c++后期会介绍.
适配器是什么?
适配器是一种设计模式(设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结), 该种模式是将一个类的接口转换成客户希望的另外一个接口.
例子:
(1).像我们前面说到的栈(stack)、队列(queue)和堆(priority_queue)都属于容器适配器.
(2).在list中实现了一个正向迭代器和一个反向迭代器, 反向迭代器就是对正向迭代器进行了封装, 因此反向迭代器也可以称为迭代器适配器.