【C++】常见的STL容器适配器详解及其模拟实现
前言:
栈和队列这两个数据结构在前面的文章中已经介绍过了,并且当时也使用C语言分别进行了实现,而优先级队列的底层其实就是堆,关于堆前面同样使用C语言实现过,所以本文就不再花费太多篇幅介绍它们的基本原理了。本篇主要来认识一下STL中的容器适配器,并尝试借助前面介绍过的容器对stack、queue和priority_queue进行简单的模拟实现,后面还会顺便介绍一下deque以及它的迭代器。
1.什么是容器适配器
在STL中,vector、list和deque等容器都有自己完整的一套接口,而stack、queue和priority_queue却没有重新设计一套底层存储结构。其实可以把容器适配器理解成在已有容器外面又套了一层接口,它的内部使用现成的容器保存数据,外部只能通过适配器提供的接口来操作数据。至于内部到底使用vector、list还是deque,只要这个容器能够提供适配器所需要的接口,就可以作为它的底层容器。

1.1 stack
stack就是我们比较熟悉的栈,它遵循后进先出的原则,只允许在栈顶进行插入、删除和访问。因此它的底层容器至少需要提供push_back、pop_back和back这些接口,vector、list和deque都可以满足这个要求。
需要注意的是,标准库中的std::stack默认使用deque作为底层容器,而本文模拟实现的qen::stack默认使用的是vector。底层容器只是实现上的选择,并不会改变stack对外所表现出来的使用方式。
1.2 queue
queue遵循先进先出的原则,数据从队尾进入,再从队头离开,所以它的底层容器需要同时支持push_back、pop_front、front和back。deque和list都能满足这些要求,而vector没有pop_front接口,所以不能直接作为queue的底层容器。
标准库中的std::queue默认使用的同样是deque。deque既能在尾部插入,又能在头部删除,刚好可以满足queue的需要。

1.3 priority_queue
priority_queue叫做优先级队列。普通队列按照数据进入的先后顺序出队,而优先级队列每次取出的都是当前优先级最高的元素。它的底层通常使用数组保存一棵完全二叉树,再通过堆的向上调整和向下调整来维护元素之间的关系。
标准库中的priority_queue默认使用vector作为底层容器,并建立大堆,因此top得到的是当前最大的元素;如果把比较方式改为greater,就可以建立小堆,让top得到当前最小的元素。这里需要注意,堆只保证父子结点之间满足对应关系,并不代表底层所有元素已经整体有序。

2.deque与它的迭代器
deque叫做双端队列,从名字也能看出来它的两端都可以进行插入和删除。vector虽然支持随机访问,但是头部操作的代价比较大;list能够比较方便地插入和删除,却不支持随机访问。deque则同时照顾了这两方面,既能高效地操作两端,又支持随机访问。

从使用者的角度来看,deque中的元素像是放在一段连续空间中,但它的一种典型实现并不是开辟一整块连续空间,而是由多段定长缓冲区组成,再通过一个中控数组把这些缓冲区的地址组织起来。这个中控数组通常被称为map,不过它和STL中的map容器并不是一回事。

由于deque的空间是分段的,所以它的迭代器也不能只是一个普通指针。一个典型的deque迭代器通常需要保存下面几个位置:
cur:指向当前访问的元素。first:指向当前缓冲区的起始位置。last:指向当前缓冲区的尾后位置。node:指向中控数组中保存当前缓冲区地址的位置。
迭代器在同一段缓冲区中移动时,直接改变cur就可以了;当cur走到当前缓冲区的边界时,就需要先通过node找到相邻的缓冲区,再让cur指向新缓冲区中的对应位置。随机访问也是在确定跨越了多少个缓冲区之后,再计算最终落在哪一段以及段内的哪个位置。
这也说明了deque虽然支持随机访问,但是它的底层结构并不像vector那样简单连续,它需要借助这些额外的结构把一段段缓冲区组织起来。
3.容器适配器的模拟实现
下面就借助前面介绍过的容器,简单模拟实现一下这三个容器适配器。真正负责保存数据的是成员变量_con,stack、queue和priority_queue只需要根据自己的特点去调用底层容器提供的接口即可。
3.1 stack的模拟实现
#pragma once
#include <vector>
#include <list>
namespace qen
{
// 实现的是适配器模式, 默认底层为vector
template<class T, class Container = std::vector<T>>
class stack
{
public:
// 因为这里我们使用了std的容器模拟栈,会自动
// 调用他们自己的构造函数所以我们这里
// 就不用写了, 同理析构也是
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_back();
}
const T& top() const
{
return _con.back();
}
size_t size() const
{
return _con.size();
}
bool empty() const
{
return _con.empty();
}
private:
Container _con;
};
}
这里把底层容器的类型设置成了模板参数Container,默认类型是vector。stack的push、pop和top分别复用了底层容器的push_back、pop_back和back,而size与empty直接使用底层容器对应的接口。
由于_con本身就是一个容器对象,它会自动调用自己对应的构造函数和析构函数,所以这里不需要再单独编写构造和析构。更换底层容器时也不需要修改stack内部的代码,只需要在实例化时传入新的容器类型即可。
3.2 queue的模拟实现
#pragma once
#include <deque>
#include <queue>
namespace qen
{
//队列这里默认用双端队列
template<class T, class Container = std::deque<T>>
class queue
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_front();
}
const T& front() const
{
return _con.front();
}
const T& back() const
{
return _con.back();
}
size_t size() const
{
return _con.size();
}
bool empty() const
{
return _con.empty();
}
private:
Container _con;
};
}
queue和stack的整体结构基本相同,区别主要在于数据进出的方向。push仍然从尾部插入数据,而pop需要从头部删除数据,所以默认底层容器选择了deque。front用来得到队头元素,back用来得到队尾元素,这样就保留了queue先进先出的特点。
3.3 priority_queue的模拟实现与仿函数
#pragma once
#include <vector>
namespace qen
{
template<class T>
class Less
{
public:
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
class Greater
{
public:
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
// 默认是大根堆
template<class T, class Container = std::vector<T>, class Compare = Less<T>>
class priority_queue
{
public:
void AdjustUp(int child)
{
Compare com;
int parent = (child - 1) / 2;
while (child > 0)
{
//if (_con[child] > _con[parent])
if (com(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(int parent)
{
Compare com;
int child = (parent * 2) + 1;
while (child < _con.size())
{
// _con[child] < _con[child + 1]
if (child + 1 < _con.size() && com(_con[child], _con[child + 1]))
{
++child;
}
// _con[child] > _con[parent]
if (com(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
parent = child;
child = (parent * 2) + 1;
}
else
{
break;
}
}
}
void push(const T& x)
{
_con.push_back(x);
AdjustUp(_con.size() - 1);
}
void pop()
{
std::swap(_con[0], _con[_con.size() - 1]);
_con.pop_back();
AdjustDown(0);
}
const T& top() const
{
return _con[0];
}
size_t size() const
{
return _con.size();
}
bool empty() const
{
return _con.empty();
}
private:
Container _con;
};
}
priority_queue的底层默认使用vector保存堆。插入元素时,先把新元素放到容器尾部,再从新元素所在的位置开始向上调整;删除堆顶元素时,先交换堆顶与最后一个元素,删除尾部元素以后,再从堆顶开始向下调整。top只需要返回下标为0的元素,因为这里始终会把当前堆顶维护在这个位置。
这里还使用了仿函数来决定建立大堆还是小堆。所谓仿函数,其实就是重载了operator()的类,创建出来的对象可以像普通函数一样使用。Less判断第一个元素是否小于第二个元素,Greater则判断第一个元素是否大于第二个元素。
调整函数中的Compare com会根据模板参数创建对应的比较对象。当使用默认的Less时,如果父结点小于子结点就进行交换,最后得到大堆;把比较方式换成Greater以后,如果父结点大于子结点就进行交换,最后得到小堆。这样只需要改变比较方式,就可以让同一份调整代码维护两种不同的堆。
4.简单测试
#include <iostream>
#include "MyStack.h"
#include "MyQueue.h"
#include "Mypriority_queue.h"
void Test1()
{
//qen::stack<int> st;
//你也可以指定的使用底层的容器
qen::stack<int, std::list<int>> st;
st.push(1);
st.push(2);
st.push(3);
st.push(4);
while (!st.empty())
{
std::cout << "元素个数-》" << st.size() << std::endl;
std::cout << st.top() << std::endl;
st.pop();
}
std::cout << "元素个数-》" << st.size() << std::endl;
}
void Test2()
{
qen::queue<int> q;
q.push(1);
q.push(2);
q.push(3);
std::cout << "front = " << q.front() << std::endl; // 1
std::cout << "back = " << q.back() << std::endl; // 3
while (!q.empty())
{
std::cout << q.front() << " ";
q.pop();
}
std::cout << std::endl;
}
template <class T, class Container, class Compare>
void std_print_heap(std::priority_queue<T, Container, Compare>& pq)
{
pq.push(4);
pq.push(1);
pq.push(5);
pq.push(7);
pq.push(9);
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
void Test3()
{
// 默认是大堆
std::priority_queue<int> pq1;
// 也可以通过仿函数指定成小根堆
std::priority_queue<int, std::vector<int>, std::greater<int>> pq2;
std_print_heap(pq1);
std::cout << "===========================" << std::endl;
std_print_heap(pq2);
}
template <class T, class Container, class Compare>
void qen_print_heap(qen::priority_queue<T, Container, Compare>& pq)
{
pq.push(4);
pq.push(1);
pq.push(5);
pq.push(7);
pq.push(9);
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
void Test4()
{
qen::priority_queue<int> pq1;
qen::priority_queue<int, std::vector<int>, qen::Greater<int>> pq2;
qen_print_heap(pq1);
std::cout << "===========================" << std::endl;
qen_print_heap(pq2);
}
int main()
{
// Test1();
// Test2();
// Test3();
Test4();
return 0;
}
Test1把list指定为stack的底层容器,用来说明只要接口满足要求,适配器就可以更换底层容器;Test2验证了queue的队头、队尾以及先进先出的顺序;Test3对标准库中的大堆和小堆进行了测试;Test4则使用相同的数据测试我们自己模拟实现的priority_queue。
当前main函数调用的是Test4,运行结果如下:
9 7 5 4 1
===========================
1 4 5 7 9
可以看到,默认比较方式维护的是大堆,所以元素按照从大到小的顺序离开;换成Greater以后维护的是小堆,元素按照从小到大的顺序离开。模拟实现与预期结果相同。
模拟实现完成以后,再回头看容器适配器其实就很简单了:内部还是借助已有的容器保存数据,只是根据自己的特点把需要的接口重新组合了一下。stack和queue主要处理数据进出的方向,而priority_queue还需要借助堆和仿函数维护元素的优先级。
完
更多推荐
所有评论(0)