5.栈与队列

基础知识

队列是先进先出,栈是先进后出。
在这里插入图片描述
C++标准库是有多个版本的,要知道我们使用的STL是哪个版本,才能知道对应的栈和队列的实现原理。三个最为普遍的STL版本

  1. HP STL 其他版本的C++ STL,一般是以HP STL为蓝本实现出来的,HP STL是C++ STL的第一个实现版本,而且开放源代码。
  2. P.J.Plauger STL 由P.J.Plauger参照HP STL实现出来的,被Visual C++编译器所采用,不是开源的。
  3. SGI STL 由Silicon Graphics Computer Systems公司参照HP STL实现,被Linux的C++编译器GCC所采用,SGI STL是开源软件,源码可读性甚高。

栈提供push pop 等等接口,所有元素必须符合先进后出规则,所以栈不提供走访功能,也不提供迭代器(iterator),不像是set 或者map 提供迭代器iterator来遍历所有元素。栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能).
所以STL中栈往往不被归类为容器,而被归类为container adapter(容器适配器)
在这里插入图片描述
我们常用的SGI STL,如果没有指定底层实现的话,默认是以deque为缺省情况下栈的底层结构(deque是一个双向队列,只要封住一段,只开通另一端就可以实现栈的逻辑了)。
以C++语言的数据结构为例子,设置std::vector,list为栈的底层实现。

std::stack<int, std::vector<int> > third;  // 使用vector为底层容器的栈
std::queue<int, std::list<int>> third; // 定义以list为底层容器的队列

题目

1.使用std::stack实现std::queue功能

题目链接
在这里插入图片描述

// 使用栈实现队列功能-stack基础接口的push,pop,top,
class MyQueue{
public:
    std::stack<int> Inst;
    std::stack<int> Oust;
    MyQueue(){};
    ~MyQueue(){};
    void push(int x){
        Inst.push(x);
    }
    // queue.pop会弹出头部的元素
    int pop(){
        if(Oust.empty()){
            while (!Inst.empty())
            {
                Oust.push(Inst.top());
                Inst.pop();
            }
        }
        int res = Oust.top();
        Oust.pop();
        return res;
    }
    // queue.peek会返回头部的元素,但不会弹出
    int peek(){
        int res = this->pop();
        Oust.push(res);
        return res;
    }
    bool empty(){
        if(Inst.empty() && Oust.empty()) return true;
        else return false;
    }
};

有意思的点在于如何使用stack保持queue先进先出的特点,使用两个stack通过彼此的元素转换,就可以改变原stack弹出的顺序。一个Inst用于接受push进的数值,一个Oust用于接受从Inst转移而来的数值;


2.使用std::queue实现std::stack功能

题目链接

// 使用队列实现堆栈-queue基础接口.push,pop,front,back(peek),empty
class MyStack{
public:
    MyStack(){};
    ~MyStack(){};
    std::queue<int> que1;
    std::queue<int> que2;
    
    void push(int x){
        que1.push(x);
    }

    int pop(){
        int res = que1.back();
        while (que1.size()>1)
        {
            que2.push(que1.front());
            que1.pop();
        }
        que1.pop();
        while (que2.size()>0)
        {
            que1.push(que2.front());
            que2.pop();
        }
        return res;
    }

    int top(){
        return que1.back();
    }

    bool empty(){
        if(que1.empty()) return true;
        else return false;
    }
};

队列是先进先出(pop)的特性,因此可以使用两个queue,其中一个作为数据备份tmp,当每次pop时,将原queue中数据转移只另外一个queue中,当pop出最后一个元素后,再将tmp中数值转移至原queue中


3.判断有效括号

题目链接
给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。
  • 注意空字符串可被认为是有效字符串。

判断括号是否有效,可以利用栈结构的特殊性,解决一些对称匹配的问题。从左向右遍历字符串,将遍历的到的左括号push进stack,在遍历到的右括号,将对应括号pop出stack,并与有括号比较是否匹配。分析会出现无效的括号的情况主要为分以下三种:
1.左括号多余了
2.有括号多余了
3.括号没有多余,但左右括号的类型没有匹配上

class Solution {
public:
    bool isValid(string s) {
        if(s.size()%2!=0 || s.size()==0) return false;
        stack<char> stack1;
        for(int i=0; i<s.size(); i++){
            if(s[i]=='(') stack1.push(')');
            else if(s[i]=='[') stack1.push(']');
            else if(s[i]=='{') stack1.push('}');
            else if(stack1.empty()) return false; // 多余有括号
            else if(stack1.top()!=s[i]) return false; // 左右括号不匹配
            else if(stack1.top()==s[i]) stack1.pop();
        }
        return stack1.empty(); // 多余左括号
    }
};

复杂度分析:时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( n ) O(n) O(n)


4.删除字符串中的所有相邻重复项

题目链接
给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 S 上反复执行重复项删除操作,直到无法继续删除。在完成所有重复项删除操作后返回最终的字符串。答案保证唯一
例如 输入:“abbaca” 输出:“ca”

思路:创建一个stack,可通过一次遍历S,每次遍历将S[i]stack.top()进行比较,若相同,则使stack.pop()弹出,若不同,则将stack.push[S[i])。最后再将stack里字符依次pop出来给std::string,得到一顺序相反的字符串。反向该字符串即可。

class Solution {
public:
    string removeDuplicates(string s) {
        stack<char> stack1;
        string res = "";
        for(int i=0; i<s.size(); i++){
            if(!stack1.empty() && stack1.top()==s[i]) stack1.pop();
            else stack1.push(s[i]);
        }
        while(!stack1.empty()){
            res += stack1.top();
            stack1.pop();
        }
        reverse(res.begin(), res.end());
        return res;
    }
};

复杂度分析:时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( 1 ) O(1) O(1),返回值不计空间复杂度


5.逆波兰表达式求值

题目链接
逆波兰表达式:是一种后缀表达式,所谓后缀就是指运算符写在后面。这与我们使用的中缀表达式不同。逆波兰表达式主要有以下两个优点:
1.去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
2.适合用栈操作运算:遇到数字则入栈遇到运算符则取出栈顶两个数字进行计算,并将结果压入栈中

根据 逆波兰表示法,求表达式的值。有效的运算符包括+ , - , * , /。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
说明:整数除法只保留整数部分。 给定逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。示例:
-输入: [“2”, “1”, “+”, “3”, " * "]
-输出: 9
-解释: 该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9

class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<long long> st;
        for(int i=0; i<tokens.size(); i++){
            if(tokens[i]=="+" || tokens[i]=="-" || tokens[i]=="*" || tokens[i]=="/"){
                long long num1 = st.top();
                st.pop();
                long long num2 = st.top();
                st.pop();
                if(tokens[i]=="+") st.push(num1+num2);
                else if(tokens[i]=="-") st.push(num2-num1);
                else if(tokens[i]=="*") st.push(num1*num2);
                else if(tokens[i]=="/") st.push(num2/num1);
            }
            else st.push(std::stoi(tokens[i])); //使用stoi()函数将string数值转化为int
        }
        return st.top();
    }
};

题外话:我们习惯看到的表达式都是中缀表达式,因为符合我们的习惯,但是中缀表达式对于计算机来说就不是很友好了


6.滑动窗口最大值

题目链接
给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。
提示:
-.1 <= nums.length <= 10^5
-.-10^4 <= nums[i] <= 10^4
-.1 <= k <= nums.length

暴力求解法:遍历一遍的过程中每次从窗口中再找到最大的数值,这样很明显是 O ( n × k ) O(n × k) O(n×k)的算法。

对于滑动窗口,我们可以使用队列queue,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列返回i里面的最大值。
该如何返回队列返回i里面的最大值?需要对队列里的元素进行排序,因此需要队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。

例如:对于窗口里的元素{2, 3, 5, 1 ,4},单调队列里只维护{5, 4} 就够了保持单调队列里单调递减,此时队列出口元素就是窗口里最大元素。当窗口向右移动时,动态窗口的front端需要弹出,若单调队列里的front端与此时动态窗口的front端的数值相等,则一同将单调队列里的front的数值弹出;动态窗口back端需要压入新的数值new,此时需要将单调队列的back端数值与新数值new比较,若back端数值小于new,则将back端数值弹出,直至大于new,此时再压入单调队列的back端——这样就保证了单调队列是单调递减的,而且front端是最大值。

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        Myqueue myque;
        vector<int> res(nums.size()-k+1, 0);
        for(int i=0; i<k; i++) myque.push(nums[i]);
        res[0] = myque.front();
        for(int i=k; i<nums.size(); i++){
            myque.pop(nums[i-k]);
            myque.push(nums[i]);
            res[i-k+1] = myque.front();
        }
        return res;

    }
private:
    struct Myqueue{
        deque<int> que;
        // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
        void push(int value){
            while(!que.empty() && value>que.back()) que.pop_back();
            que.push_back(value);
        }
        // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
        void pop(int value){
            if(!que.empty() && value==que.front()) que.pop_front();
        }
        int front(){
            return que.front();
        }
    };
};

时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( k ) O(k) O(k)


7.前 K 个高频元素

题目链接
给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
示例 1:
-.输入: nums = [1,1,1,2,2,3], k = 2
-.输出: [1,2]

思路
-.统计元素出现的频率——使用map来统计元素出现次数
-.对频率进行排序——使用一种 容器适配器就是优先级队列
-.找出前k个高频元素

优先级队列:其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。优先级队列内部元素是自动依照元素的权值排列

缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)

堆:是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆
我们可以使用快排要将map转换为vector的结构,然后对整个数组进行排序,而这种场景下,我们其实只需要维护k个有序的序列就可以了,所以使用优先级队列是最优的。
如果定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢,所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素
在这里插入图片描述
思考:为了建立小顶堆,需要使用left>right的,从而建立了一个递减的队列,但使用std::priority_queue::pop()把队尾的元素(即最小数值)弹出。这与优先级队列的定义正好反过来了,可能和优先级队列的源码实现有关(我没有仔细研究),我估计是底层实现上优先队列队首指向后面,队尾指向最前面的缘故:

// 优先队列priority_queue优先级队列的权值函数
struct MyComparison{
    bool operator()(const std::pair<int, int>& lhs, const std::pair<int, int>& rhs) {
        return lhs.second > rhs.second;
    }
};

完整代码:

class Solution {
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        std::unordered_map<int, int> map;
        for(int i=0; i<nums.size(); i++){
            map[nums[i]]++;
        }

        std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, MyComparison> pri_que;
        for(auto it = map.begin(); it != map.end(); it++){
            pri_que.push(*it);
            if(pri_que.size()>k) pri_que.pop();
        }

        std::vector<int> res(k);
        for(int j=0; j<k; j++){
            res[k-j-1] = pri_que.top().first;
            pri_que.pop() ;
        }
        return res;
    }
private:
    // 优先队列priority_queue的排序函数
    struct MyComparison{
        bool operator()(const std::pair<int, int>& lhs, const std::pair<int, int>& rhs) {
            return lhs.second > rhs.second;
        }
    };
};

时间复杂度: O ( n l o g k ) O(nlogk) O(nlogk);空间复杂度: O ( n ) O(n) O(n)


总结

  • 在栈与队列系列中,强调栈与队列的基础,也是很容易忽视的点。
  • 使用抽象程度越高的语言,越容易忽视其底层实现,而C++相对来说是比较接近底层的语言。
  • 使用栈实现队列,用队列实现栈来掌握的栈与队列的基本操作。
  • 通过括号匹配问题、字符串去重问题、逆波兰表达式问题来系统讲解了栈在系统中的应用,以及使用技巧。
  • 通过求滑动窗口最大值,以及前K个高频元素介绍了两种队列:单调队列优先级队列,这是特殊场景解决问题的利器,是一定要掌握的。

栈里面的元素在内存中是连续分布的么?这个问题有两个陷阱:
陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中不一定是连续分布的。
陷阱2:缺省情况下,默认底层容器是deque,那么deque在内存中的数据分布是什么样的呢? 答案是:不连续的,下文也会提到deque。

在了解栈和队列后,之前题目使用队列实现栈功能中,其实只用一个队列就够了,一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序了。

栈在系统中的应用
如linux系统中,cd这个进入目录的命令我们应该再熟悉不过了。假使一个命令最后进入a目录,系统是如何知道进入了a目录呢 ,这就是栈的应用。这在leetcode上也是一道题目,编号:71. 简化路径。

对称性数据匹配问题
这种带有对称性数据的匹配,需要先分析出有几种不匹配的情况。例如 判断有效符号,字符串去重

逆波兰表达式
这题中每一个子表达式要得出一个结果,然后拿这个结果再进行运算,那么这岂不就是一个相邻字符串消除的过程

滑动窗口最大值问题
这题是十分经典的队列题目,主要思想是队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。而保证队列的元素的排序就叫做单调队列——C++中没有直接支持单调队列,需要我们自己来一个单调队列。而且必须强调的一点是与优先级队列的区别,后者是对窗口里面的数进行排序。

求前K个高频元素
通过求前 K 个高频元素,引出另一种队列就是优先级队列。这是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。而且优先级队列内部元素是自动依照元素的权值排列,因此需要自己设计权值函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值