我的博客起到的是指引思维的作用(也就是为什么这个容器要这么做),而基础的对stl容器的讲解(也就是容器是做什么的)你应该直接去看权威的文档会的了解的更透彻;
Reference - C++ Reference (cplusplus.com)
看不懂就用翻译软件一个一个的翻译
stack
栈容器支持数据先入后出,后来居上,在一些特殊的情况下可以使用栈对我们的数据进行处理;
因为有链表顺序表的存在,我们有了直接的存储结构,而我们的栈修改的是数据的存放形式;所以我们只需要重新定义数据的存储形式,数据结构可以使用list和vector这样的容器来直接实现;
栈的模板参数有T和Container,Container就是我们的栈使用的容器做的底层的数据结构,我们可以使用不同的数据结构来优化栈的存取数据的效率,这里的栈使用的容器是deque这种容器而并非list和vector,我们实际上也可以使用vector和list;但是为什么要使用这个deque我们都没见过的容呢?
deque
这就是我们deque头插和插入的大致思路, 尾插也类似,当我们的buffer装满了的时候,就再在我们的指针数组中添加一个指针在数组的数据末尾,然后为这个指针再开辟一个buffer并向这个buffer的开头添加数据;
这样我们的尾插功能也就实现了;
怎么查找deque中的数据呢?和其他容器一样deque是通过迭代器访问的,但是由于deque的结构,它的迭代器会非常复杂:
这就是buffer的迭代器,我们可以通过这个迭代器支持我们的随机访问[ ] ,具体如何支持呢,就是通过迭代器来计算出你要查的这个数据是再当前buffer的第几个(指针偏移量)然后在加上前面的buffer存储的数据的个数,得出来的结果就是你当前要查的数据所处在的整个deque的位置;我们通过[ ]运算符重载,也可以对当前位置的数据进行操作;
deque的优点
我们只需要知道deque是支持随机访问的这样比较我们的list,我们的list不支持随机访问,这是优于list的,
然后再结合我们这个deque的数据结构我们发现deque的头插头删的效率也很高,相比vector,vector的头删头插需要挪动数据,而deque不需要,所以这方面它是优于vector的;
为什么stack和queue要使用deque做默认容器:
我们的deque具有list和vector的优点,而我们的stack和queue又正需要这些优点(stack和vector只需要头尾插入删除)所以我们的vector和queue的数据结构容器默认使用deque;并且deque的空间利用率相比于list也是更高的(list还需要存储next这样的格外的数据,并且内存的碎片化高);
deque优点的局限性
那deque具备了vector随机访问的优点又具有list的插入删除高效率的优点,为什么我们数据结构学习不学习这么好的数据结构,而要去学习list的vector它们这两种容器呢?
deque虽然具有vector的优点,但是deque再随机访问时获得的位置需要通过计算获得,随机访问的效率是比vector低的(当数据量很大的时候,vector是连续的空间,只需要通过指针加偏移量就可以获得数据位置,而我们的deque要去找每个buffer的数据个数还有需要查找数据所在buffer位置再将这些数据相加这样一来deque的随机访问的效率一定低于vector)
deque在空间使用上,虽然deque的在任意位置的插入删除效率是一定低于list的list在任意位置都可支持,而deque的buffer是连续空间,中间的插入删除必定会带来数据的挪动,效率是一定低于list的;
也就是说deque中和了list和vector的优缺点但deque在list和vector擅长的方面又是不如它们的;所以我们与其选择这个高不成低不就的容器,还不如学习两个极致的容器vector和list;
我们接下来继续说stack
stack的接口
stack接口的实现:
template<class T,class Container=deque<T>>
class stack
{
public:
stack()
{}
void push(const T& data)
{
_con.push_back(data);
}
void pop()
{
_con.pop_back();
}
const T& top()const
{
return _con.back();
}
T& top()
{
return _con.back();
}
bool empty()
{
return _con.empty();
}
size_t size()
{
return _con.size();
}
void swap(stack &c)
{
_con.swap(c._con);
}
private:
Container _con;
};
实现非常简单,就去复用我们的stl容器的接口就可以实现我们的stack的功能了,因为存储的底层我们的容器Container _con已经帮我们做了,这也是我们面向对象编程的魅力,调用接口就可以直接实现功能!
queue
队列的数据存取形式是先入先出;
这是queue的实现模板
我们这里使用的也是默认容器deque为什么使用在前面的内容中也已经谈过了;我们下面直接看queue的模拟实现吧:
template<class T, class Container = deque<T>>
class queue
{
public:
queue()
{}
void push(const T& data)
{
_con.push_back(data);
}
void pop()
{
_con.pop_front();
}
T& front()
{
return _con.front();
}
const T& front()const
{
return _con.front();
}
T& back()
{
return _con.back();
}
const T& back()const
{
return _con.back();
}
bool empty()const
{
return _con.empty();
}
size_t size()const
{
return _con.size();
}
void swap(queue& q)
{
_con.swap(q._con);
}
private:
Container _con;
};
queue的模拟实现也是调用了Container的接口,所以实现起来很简单,观察代码吧;
priority_queue优先级队列(堆)
其实与其叫这个容器叫做优先级队列还不如叫他堆;什么是堆呢?在前面的数据结构中我们就早就学到过了;堆在逻辑上是一个完全二叉树的结构,我们可以用任意的数据结构实现它;
要实现这样的数据结构我们可以使用数组来实现(当然也可以使用节点将数据链接起来只要是能实现这种结构的数组组织方式都可以);这是二叉树的结构,而我们的堆的结构就是在完全二叉树的基础上对数据进行排序,使得任意一个父节点的值大于它的所有子节点这就叫做大堆;或者使得任意一个父节点的值小于所有的子节点这叫做小堆;这样我们的大堆的堆顶将会是我们这组数据中最大的元素,小堆则是最小的元素;
而我们的priority_queue就是这样的堆的结构;
知道了我们priority_queue容器的结构之后,我们看看它的模板参数:
它前两参数和前面stack于queue相同T是指的存储数据的类型,Container指的是底层实现逻辑结构的容器;我们第三个参数就是我们的仿函数这个参数是用来告诉我们的堆是大堆还是小堆的标志;
堆的接口
模拟实现
template<class T>
struct less
{
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
struct greater
{
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
template<class T,class Container=vector<T>,class Compare=less<T>>
class priority_queue
{
public:
priority_queue()
{}
void adjustup(size_t child)
{
while (child > 0)
{
size_t parent = (child - 1) / 2;
//if (_con[child] > _con[parent])
//这里需要注意compare是类型,我们使用这个类里面的函数得实例化出来一个对象
if (Compare()(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
child = parent;
}
else
{
break;
}
}
}
void push(const T& data)
{
_con.push_back(data);
adjustup(_con.size()-1);
}
void print()const
{
for (int i = 0; i < size(); i++)
{
cout << _con[i] << " ";
}
cout << endl;
}
size_t size()const
{
return _con.size();
}
void adjustdown(size_t parent)
{
int child = parent * 2 + 1;
while (child<size())
{
//if (child+1<size()&&_con[child + 1] > _con[child])
//这里需要注意compare是类型,我们使用这个类里面的函数得实例化出来一个对象
if (child + 1 < size() && Compare()( _con[child], _con[child + 1]))
{
child++;
}
//if (_con[child] > _con[parent])
//这里需要注意compare是类型,我们使用这个类里面的函数得实例化出来一个对象
if (Compare()(_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[size() - 1]);
_con.pop_back();
adjustdown(0);
}
T& top()
{
return _con.front();
}
const T& top()const
{
return _con.front();
}
bool empty()const
{
return _con.empty();
}
private:
Container _con;
};
接口讲解
我们需要堆所以我们需要先建堆adjustup和adjustdown函数就是帮助我们建堆的两个函数,这两个函数可以调整我们的数据的位置,使得堆建立;
建堆例子:
假设我们使用的容器是vector,且我们需要建立的是大堆(大堆的仿函数是less<T>),假设我们建堆一开始容器中没有数据(有数据的话就要自底向上建堆再向下调整)我们插入一个数据的时候,这个数自己就是一个堆,当插入第二个数据时,第二个数据会作为子节点,去和它的父节点相比较,父节点序号=(子节点序号-1)/2;根据我们要建的堆是大堆还是小堆来向上调整;之后每次插入数据时都向上比较一次,向上比较的代码在我的上面的模拟实现中写了,可以参照理解;
我们每次插入数据时都会向上调整使得堆一直存在;这就是push()接口;
pop接口则是将堆顶的数据和我们堆最末尾的数据交换位置然后让堆顶的数据向下调整(向下调整的代码在上面的模拟实现中),使得我们的堆依旧存在,并且删除了一个堆顶的数据(最大或最小的数据);我们的堆排序也是通过不断的交换堆顶数据然后向下调整获得一组有序数据的;
仿函数
我们之前说我们的第三个模板参数是用来给模板信号的,告诉模板这个堆是建大堆还是建小堆;怎么做到的呢?这个第三个参数叫做仿函数顾名思义就是像函数一样的东西,下面就是仿函数的模拟实现:
template<class T>
struct less
{
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
struct greater
{
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
我们看到我们传递给堆模板的其实还是一个类为什么要叫这个类叫做仿函数呢,因为它里面的操作符重载(),括号操作符重载使得我们在使用这个重载时像在使用函数一样;我们再把目光放到模拟实现的向上和向下调整中
看这个就是仿函数的使用,它其实起到的就是一个比较大小的作用我们的Compare是我们的类名,我们Compare()实例化出来了一个匿名对象,然后再调用运算符重载再加上一个括号,在括号中输入,我们俩个需要被比较的参数,成功实现的比较的行为,我们用这个仿函数来代替了原本的大于小于号;就这样,我们通过改变传递的模板参数来调整我们优先级队列中调整函数的大小比较;使得堆的建立发生变化从而实现自主建立大堆还是小堆的自主行为;
这就是仿函数的作用;
2023.11.20