本课涉及到的所有代码均见以下链接,欢迎参考指正!
容器适配器 · 王哲/practice - 码云 - 开源中国 (gitee.com)
stack的介绍和使用:
文档描述【提炼】:
1. stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,它只能从容器的一端进行元素的插入与提取操作。2. stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素从特定容器的尾部(即栈顶)被压入和弹出。3. stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下操作:empty:判空操作back:获取尾部元素操作push_back:尾部插入元素操作pop_back:尾部删除元素操作4. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque【后面介绍是什么】。![]()
stack的使用:
基本成员函数【高频使用的】
与容器的使用基本无异,可自行在编译器使用操作。
stack的应用:
一、最小栈
题目链接:
题目分析:
最好的思路就是用两个栈来实现,一个栈就是用来当数据栈用于正常元素的插入删除,另一个栈用于更新数据栈操作后的最小元素,具体实现办法就是:当插入元素时,数据栈正常入栈,最小栈为空或插入数据小于等于最小栈栈顶元素,同时也入最小栈,这样能保证每次插入元素后最小栈栈顶元素永远都是当前数据栈的最小元素;当删除元素时,数据栈正常出栈,当数据栈栈顶元素与最小栈栈顶元素相等时,表示要删除的元素就是当前数据栈中的最小元素,最小栈要同时出栈,这样能保证每次删除后最小栈栈顶元素为当前数据栈的最小元素,注意,插入最小栈小于等于的条件非常重要,见下例:
代码:
class MinStack { public: MinStack() {} void push(int val) { if(min.empty()||min.top()>=val) { min.push(val); } data.push(val); } void pop() { if(min.top()==data.top()) { min.pop(); } data.pop(); } int top() { return data.top(); } int getMin() { return min.top(); } private: stack<int> data; stack<int> min; };
谈谈优化:
如果存在某种需要将大量相同元素入栈的场景,就用同时向最小栈中入大量相同的元素,对空间有一定的消耗,针对如此情况,可作出以下优化:
具体代码有兴趣的可以自己实现,这里只说一下思想。
二、栈的弹出压入序列
题目链接:
题目分析:
最简单的就是用一个栈来模拟入栈出栈序列的行为,如果模拟过程中,能把入栈序列和出栈序列走完,且走完后栈为空,则说明是没有问题的,具体实现为,定义一个栈,先无脑入栈,写一个循环,循环条件是下标小于入栈序列的size(),入栈过程中,如果入栈的元素与出栈序列的第一个元素相等,就进行出栈,此时继续向后遍历出栈序列,可能是连续出栈的,因此还是要写一个循环来控制,只要栈顶元素与出栈序列对应元素相等,就出栈,此时还要注意一个条件就是栈一定不为空,等循环结束,表示当前出栈就结束,再进行入栈,等入栈序列走完了,如果出栈序列也走完了,即栈为空,则返回true,否则,就不符合要求,返回false。
代码:
class Solution { public: bool IsPopOrder(vector<int> pushV,vector<int> popV) { //用一个栈来模拟入栈出栈序列行为 stack<int> st; int pushi=0; int popi=0; //如果数据没入完,就继续,肯定是一个循环 while(pushi<pushV.size()) { st.push(pushV[pushi++]); while(!st.empty()&&st.top()==popV[popi]) { st.pop(); popi++; } } return st.empty(); } }; // class Solution { // public: // bool IsPopOrder(vector<int> pushV,vector<int> popV) { // // if(pushV.size()!=popV.size()) // { // return false; // } // stack<int> st; // int i=0,j=0; // while(j<popV.size()) // { // while(st.empty()||st.top()!=popV[j]) // { // if(i<pushV.size()) // { // st.push(pushV[i]); // i++; // } // else // return false; // } // st.pop(); // j++; // } // return true; // } // };
三、逆波兰表达式求值
题目链接:
题目分析:
代码:
class Solution { public: int evalRPN(vector<string>& tokens) { stack<int> st; for(auto& str:tokens) { if(str=="+"||str=="-"||str=="*"||str=="/")//这里不能用switch-case因为case要求必须是整型 { int right=st.top(); st.pop(); int left=st.top(); st.pop(); switch(str[0])//这里可以用,因为字符也属于整型家族 { case '+': st.push(left+right); break; case '-': st.push(left-right); break; case '*': st.push(left*right); break; case '/': st.push(left/right); break; } } else { st.push(stoi(str));//这里记住要把字符串转成整型,因为后面的运算是针对整型进行运算的 } } return st.top(); } };
拓展【学习思想是重点】:
上面直接对逆波兰表达式求解可以看出还是很容易的,其实如果这道题想考的更加复杂一些还可以要求:先将给定的中序表达式转化为逆波兰表达式【即逆序表达式】再计算,就比较复杂了,尤其是对于带括号的表达式,我们来分析一下实现的思路。
stack的模拟实现:
在学习用C语言实现数据结构的阶段,为了实现栈,我们是用数组来实现的,当然也可以实现链式栈,而到了这个阶段,我们还需要自己用数组像之前一样来手搓一个栈出来吗?当然不用,我们只需要对vector和list这样现有的容器做特定的封装以此实现栈的功能即可达到目的,这就要提到一个新的概念:容器适配器。
容器适配器概念:
标准库提供了三种顺序容器适配器:stack、queue、priority_queue。适配器是标准库中通用的概念,包括容器适配器、迭代器适配器和函数适配器。本质上,适配器是使一事物的行为类似于另一类事物的行为的一种机制。容器适配器让一种已存在的容器类型采用另一种不同的抽象类型的工作方式实现。例如,stack适配器可使任何一种顺序容器以栈的方式工作。
代码如下:
#pragma once
#include<iostream>
using namespace std;
#include<vector>
#include<list>
namespace wz
{
template<class T,class container=deque<T>>
class stack
{
public:
void push(const T& val)
{
_con.push_back(val);
}
void pop()
{
_con.pop_back();
}
const T& top()
{
return _con.back();
}
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
private:
container _con;
};
}
分析:
1.增加了一个类模板参数container,这是因为我们可以用不止一种容器来实现container,这里也是体现它与容器不同的重要之处,也就是我们要在定义一个栈时不仅需要传要存储的数据的类型,还要传实例化该栈时想用的容器类型【这里要注意体会模板的强大功能!!!】
2.无论是什么容器,它都具有一些相同的基本接口,此时直接用他们来实现栈需要的接口就好。
测试结果如下:
queue的介绍和使用:
文档描述【提炼】:
1. 队列也是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端提取元素。2. 队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。3. 底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:empty:检测队列是否为空size:返回队列中有效元素的个数front:返回队头元素的引用back:返回队尾元素的引用push_back:在队列尾部入队列pop_front:在队列头部出队列4. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。![]()
queue的使用:
基本成员函数【高频使用的】
queue的应用:
除了 用两个队列实现栈这样的题目,后续还会在二叉树的层序遍历用到队列来实现,这里先不做介绍。
queue的模拟实现:
和stack的分析方式一样,直接写出代码,唯一要注意的是,它不用vector适配,vector没有pop_front()接口【因为vector头插头删的效率比较低,当然在封装时pop()可以用erase(),但vector的头删效率低,我们这里就先不做考虑】
代码如下:
#pragma once #pragma once #include<iostream> using namespace std; #include<vector> #include<list> namespace wz { template<class T, class container = deque<T>> class queue { public: void push(const T& val) { _con.push_back(val); } void pop() { _con.pop_front(); } const T& back() { return _con.back(); } const T& front() { return _con.front(); } size_t size() { return _con.size(); } bool empty() { return _con.empty(); } private: container _con; }; }
测试结果如下:
deque的介绍(简单了解即可):
文档描述【提炼】:
deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维数组,其底层结构如下图所示:![]()
deque的迭代器结构也比较复杂:
根据上图我们分析其优缺点如下:
优点:
1.头插头删,尾插尾删的效率高
2.和vector相比,扩容代价小
3.与list相比,元素的随机访问效率高
缺点:
1.中间的插入删除很难搞,如果每个buff数组大小固定,可能存在大量的数据挪动,如果将其每个buff数组设置为大小不相等的,一定程度上提升了中间插入删除的效率,但此时随机访问的效率就变低了,无法在用先除后模来确定元素位置了
2.相较于vector和list,它的优点都没有做到极致
3.deque还有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为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的优点,而完美的避开了其缺陷。
priority_queue(优先级队列)的介绍和使用:
文档描述【提炼】:
1. 优先队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的。2. 此上下文类似于堆,在堆中可以随时插入元素,并且只能检索最大堆元素(优先队列中位于顶部的元素)。3. 优先队列被实现为容器适配器,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从特定容器的“尾部”弹出,其称为优先队列的顶部。4. 底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。容器应该可以通过随机访问迭代器访问,并支持以下操作:empty():检测容器是否为空size():返回容器中有效元素个数front():返回容器中第一个元素的引用push_back():在容器尾部插入元素pop_back():删除容器尾部元素5. 标准容器类vector和deque满足这些需求。默认情况下,如果没有为特定的priority_queue类实例化指定容器类,则使用vector。6. 需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数make_heap、push_heap和pop_heap来自动完成此操作。
priority_queue的使用:
优先级队列默认使用vector作为其底层存储数据的容器,在vector上又使用了堆算法将vector中元素构造成堆的结构,因此priority_queue就是堆,所有需要用到堆的位置,都可以考虑使用priority_queue。注意:默认情况下priority_queue是大堆。
简单使用一下:
那如果想要底层是小堆,就要在定义时再传一个模板参数:
priority_queue的应用:
题目链接:
题目分析:
这道题目的解决思路有很多,可以直接用库里的sort排序,返回第size()-k个,但时间复杂度较高,最快的其实是用快排的二分思想来搞,但这里我们利用堆排其实也能较好的解决问题,共有两种思路:
思路一:用大堆,将所有的数据建大堆,pop()k-1次后堆顶元素就是第k大的元素
class Solution { public: int findKthLargest(vector<int>& nums, int k) { priority_queue<int> q(nums.begin(),nums.end()); while(--k) { q.pop(); } return q.top(); } };
思路二:用小堆,将数组的前k个数建小堆,向后遍历数组,如果当前元素大于堆顶元素,就对堆pop()一下,再把当前元素入堆,直到遍历完数组,堆中剩下的k个元素是所有元素中最大的k个,此时堆顶元素就是第k大的元素
class Solution { public: int findKthLargest(vector<int>& nums, int k) { //用前k个数据建小堆 priority_queue<int,vector<int>,greater<int>> q(nums.begin(),nums.begin()+k); for(int i=k;i<nums.size();i++) { if(nums[i]>q.top()) { q.pop(); q.push(nums[i]); } } return q.top(); } };
分析以上两种思路的复杂度:
用大堆:时间复杂度O(k*logN+N),空间复杂度O(N)
用小堆:时间复杂度O((N-k)*logN),空间复杂度O(k),k可以忽略不计
综上用小堆,更多的是对空间上的优化,时间复杂度都在logN量级上,差不了很多。
priority_queue的模拟实现 :
基本框架如下(先实现不加仿函数的):
#pragma once
#include<iostream>
#include<vector>
#include<functional>
using namespace std;
namespace wz
{
template<class T,class container=vector<T>>
class priority_queue
{
public:
void push(const T& data)
{
//先默认底层是大堆,则按照大堆插入数据的方式来写
//首先是尾插数据,其次是向上调整
_con.push_back(data);
Adjust_up(_con);
}
void pop()
{
//同样先按照大堆删除数据的方式来写
//首先是交换堆顶和最后一个数据,其次是向下调整
swap(_con[0], _con[size() - 1]);
_con.pop_back();
Adjust_down(_con);
}
const T& top()
{
return _con[0];
}
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
private:
container _con;
};
}
插入push()、删除pop()
插入和删除部分的核心是写堆向上、向下调整的代码,在写堆的时候就已经写过了,直接给出代码,具体分析方法可参考下面链接本人对应的关于堆内容的总结:
public: void push(const T& data) { //先默认底层是大堆,则按照大堆插入数据的方式来写 //首先是尾插数据,其次是向上调整 _con.push_back(data); Adjust_up(_con); } void pop() { //同样先按照大堆删除数据的方式来写 //首先是交换堆顶和最后一个数据,其次是向下调整 swap(_con[0], _con[size() - 1]); _con.pop_back(); Adjust_down(_con); } private: //向上调整 void Adjust_up(int child) { int parent = (child - 1) / 2; while (child > 0) { if (_con[parent] < _con[child]) { swap(_con[parent], _con[child]); child = parent; parent = (child - 1) / 2; } else { break; } } } //向下调整 void Adjust_down(int parent) { int child = 2 * parent + 1; while (parent < _con.size()) { if (child+1<_con.size()&&_con[child] < _con[child+1]) { child++; } if (_con[parent] < _con[child]) { swap(_con[parent], _con[child]); parent = child; child= 2 * parent + 1; } else { break; } } }
剩余接口比较简单,直接给出如下:
const T& top() { return _con[0]; } size_t size() { return _con.size(); } bool empty() { return _con.empty(); }
简单测试一下,不传容器默认用vector,传了虽然底层不一样,但仍然实现相同功能,这里那deque为例,检测结果如下:
如何增加仿函数
上面我们写的代码底层是大堆,在使用库里的优先级队列时,我们发现可以再传一个模板参数,通过传参直接控制比较方式,而不用再搞一个建小堆的代码出来,怎么实现呢?
仿函数,又叫做函数对象,它其实是一个类,而非函数,在仿函数这个类中,重载了(),使得其用法和函数相同,由此得名,它的功能其实类似于我们在C语言中学过的函数指针,我们在学习qsort时,其中需要传的一个参数就是一个具有比较功能的函数的地址,在内部实现比较逻辑时需要调用这个函数,函数指针和数组指针一样都是很复杂的东西,我们在使用时非常的不方便,而仿函数则是一个类,将其作为模板参数传进来的时候,在使用时,直接会实例化出相应的对象,且在整个类中都能直接使用,设计和使用都很方便,增加了仿函数后的完整代码如下:
#pragma once
#include<iostream>
#include<vector>
#include<functional>
using namespace std;
namespace wz
{
template<class T>
struct less
{
bool operator ()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
struct great
{
bool operator ()(const T& x, const T& y)
{
return x > y;
}
};
template<class T,class container=vector<T>,class comapre=less<T>>
class priority_queue
{
public:
void push(const T& data)
{
//先默认底层是大堆,则按照大堆插入数据的方式来写
//首先是尾插数据,其次是向上调整
_con.push_back(data);
Adjust_up(_con.size()-1);
}
void pop()
{
if (empty())
{
return;
}
//同样先按照大堆删除数据的方式来写
//首先是交换堆顶和最后一个数据,其次是向下调整
swap(_con[0], _con[size() - 1]);
_con.pop_back();
Adjust_down(0);
}
const T& top()
{
return _con[0];
}
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
private:
void Adjust_up(int child)
{
comapre com;
int parent = (child - 1) / 2;
while (child > 0)
{
//if (_con[parent] < _con[child])
if (com(_con[parent] ,_con[child]))
{
swap(_con[parent], _con[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void Adjust_down(int parent)
{
comapre com;
int child = 2 * parent + 1;
while (child < _con.size())
{
//if (child+1<_con.size()&&_con[child] < _con[child+1])
if (child + 1 < _con.size() &&com( _con[child] , _con[child + 1]))
{
child++;
}
//if (_con[parent] < _con[child])
if (com(_con[parent] , _con[child]))
{
swap(_con[parent], _con[child]);
parent = child;
child= 2 * parent + 1;
}
else
{
break;
}
}
}
container _con;
};
}
拓展:优先级队列的日期类玩法
我们把之前实现过的日期类直接拿来入优先级队列,看看出的时候符不符合优先级队列的特征:
class Date { public: Date(int year = 1900, int month = 1, int day = 1) : _year(year) , _month(month) , _day(day) {} bool operator<(const Date& d)const { return (_year < d._year) || (_year == d._year && _month < d._month) || (_year == d._year && _month == d._month && _day < d._day); } bool operator>(const Date& d)const { return (_year > d._year) || (_year == d._year && _month > d._month) || (_year == d._year && _month == d._month && _day > d._day); } friend ostream& operator<<(ostream& _cout, const Date& d) { _cout << d._year << "-" << d._month << "-" << d._day; return _cout; } private: int _year; int _month; int _day; }; void priority_queuetest1() { // 大堆,需要用户在自定义类型中提供<的重载 wz::priority_queue<Date> q1; q1.push(Date(2018, 10, 29)); q1.push(Date(2018, 10, 28)); q1.push(Date(2018, 10, 30)); q1.push(Date(2018, 10, 25)); q1.push(Date(2018, 10, 18)); cout << q1.top() << endl; // 如果要创建小堆,需要用户提供>的重载 wz::priority_queue<Date, vector<Date>, greater<Date>> q2; q2.push(Date(2018, 10, 29)); q2.push(Date(2018, 10, 28)); q2.push(Date(2018, 10, 30)); q2.push(Date(2018, 10, 25)); q2.push(Date(2018, 10, 18)); cout << q2.top() << endl; }
也就是说,仿函数的参数不仅可以是内置类型,也可以是自定义类型,只要你在自定义类里面重载了比较逻辑符号,他就能实例化出相应的比较函数对象,测试结果如下:
那如果我们存的是日期类地址呢?
void priority_queuetest1() { // 大堆,需要用户在自定义类型中提供<的重载 wz::priority_queue<Date,vector<Date>,greater<Date>> q1; q1.push(Date(2018, 10, 29)); q1.push(Date(2018, 10, 28)); q1.push(Date(2018, 10, 30)); q1.push(Date(2018, 10, 25)); q1.push(Date(2018, 10, 18)); cout << q1.top() << endl; // 如果要创建小堆,需要用户提供>的重载 wz::priority_queue<Date*, vector<Date*>, greater<Date*>> q2; q2.push(new Date(2018, 10, 29)); q2.push(new Date(2018, 10, 28)); q2.push(new Date(2018, 10, 30)); q2.push(new Date(2018, 10, 25)); q2.push(new Date(2018, 10, 18)); cout << *(q2.top()) << endl; }
测试结果如下,不仅不能输出我们想要的最小日期,而且每一次运行的结果还不一样:
这是因为,代码调用默认仿函数在进行比较时,是按照地址直接比较的,而且new一个对象,其地址在堆上是随机的,因此 地址大小每次也是不确定的,我们实际想比较的是地址解引用后的日期类,那么我们就可以自己实现一个仿函数,定义优先级队列对象时直接传自己实现的仿函数即可,如下:
//自己定义仿函数 class pDateLess { public: bool operator()(const Date* x, const Date* y) { return *x < *y; } }; class pDateGreater { public: bool operator()(const Date* x, const Date* y) { return *x > *y; } };
测试结果如下:
综上,我们可以看出模板的强大功能,一定要多观察练习,才能慢慢理解它!
本课涉及到的所有代码均见以下链接,欢迎参考指正!