leetcode刷题笔记7-栈与队列

栈与队列

代码随想录刷题笔记

代码随想录 (programmercarl.com)

理论基础

队列是先进先出,栈是先进后出。

如图所示:

栈与队列理论1

那么我这里在列出四个关于栈的问题,大家可以思考一下。以下是以C++为例,相信使用其他编程语言的同学也对应思考一下,自己使用的编程语言里栈和队列是什么样的。

  1. C++中stack 是容器么?
  2. 我们使用的stack是属于哪个版本的STL?
  3. 我们使用的STL中stack是如何实现的?
  4. stack 提供迭代器来遍历stack空间么?

相信这四个问题并不那么好回答, 因为一些同学使用数据结构会停留在非常表面上的应用,稍稍往深一问,就会有好像懂,好像也不懂的感觉。

有的同学可能仅仅知道有栈和队列这么个数据结构,却不知道底层实现,也不清楚所使用栈和队列和STL是什么关系。

所以这里我在给大家扫一遍基础知识,

首先大家要知道 栈和队列是STL(C++标准库)里面的两个数据结构。

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是开源软件,源码可读性甚高。

接下来介绍的栈和队列也是SGI STL里面的数据结构, 知道了使用版本,才知道对应的底层实现。

来说一说栈,栈先进后出,如图所示:

栈与队列理论2

栈提供push 和 pop 等等接口,所有元素必须符合先进后出规则,所以栈不提供走访功能,也不提供迭代器(iterator)。 不像是set 或者map 提供迭代器iterator来遍历所有元素。

栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能)。

所以STL中栈往往不被归类为容器,而被归类为container adapter(容器适配器)。

那么问题来了,STL 中栈是用什么容器实现的?

从下图中可以看出,栈的内部结构,栈的底层实现可以是vector,deque,list 都是可以的, 主要就是数组和链表的底层实现。

栈与队列理论3

我们常用的SGI STL,如果没有指定底层实现的话,默认是以deque为缺省情况下栈的低层结构。

deque是一个双向队列,只要封住一段,只开通另一端就可以实现栈的逻辑了。

SGI STL中 队列底层实现缺省情况下一样使用deque实现的。

我们也可以指定vector为栈的底层实现,初始化语句如下:

std::stack<int, std::vector<int> > third;  // 使用vector为底层容器的栈

刚刚讲过栈的特性,对应的队列的情况是一样的。

队列中先进先出的数据结构,同样不允许有遍历行为,不提供迭代器, SGI STL中队列一样是以deque为缺省情况下的底部结构。

也可以指定list 为起底层实现,初始化queue的语句如下:

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

所以STL 队列也不被归类为容器,而被归类为container adapter( 容器适配器)。

我这里讲的都是C++ 语言中情况, 使用其他语言的同学也要思考栈与队列的底层实现问题, 不要对数据结构的使用浅尝辄止,而要深挖起内部原理,才能夯实基础。

用栈实现队列

232. 用栈实现队列

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push、pop、peek、empty):

实现 MyQueue 类:

void push(int x) 将元素 x 推到队列的末尾
int pop() 从队列的开头移除并返回元素
int peek() 返回队列开头的元素
boolean empty() 如果队列为空,返回 true ;否则,返回 false
说明:

你 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。

示例 1:

输入:
["MyQueue", "push", "push", "peek", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 1, 1, false]

解释:
MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false

解题思路:

这是一道模拟题,不涉及到具体算法,考察的就是对栈和队列的掌握程度。

使用栈来模式队列的行为,如果仅仅用一个栈,是一定不行的,所以需要两个栈一个输入栈,一个输出栈,这里要注意输入栈和输出栈的关系。

  1. push数据的时候,很简单,只要数据放进输入栈就好

  2. 但在pop的时候,操作就复杂一些,

    首先需要判断输出栈是否为空。

    输出栈如果为空,就把输入栈的所有数据全部导入进来(注意是全部导入),再从出栈弹出数据,

    如果输出栈不为空,则直接从出栈弹出数据就可以了。

  3. 实现peek,其实和pop操作大致相同,可以直接复用pop操作,但同时将首元素弹出来了,所以需要将首元素再放进去。为什么这个地方是复用pop而不是将pop的代码复制粘贴过来呢?原因如果pop代码修改,那么不需要操作peek

  4. empty如何实现呢?如果进栈和出栈都为空的话,说明模拟的队列为空了。

参考代码:

class MyStack {
public:
    queue<int> que;
    MyStack() {

    }
    
    void push(int x) {
        que.push(x);
    }
    
    int pop() {
        int size = que.size();
        //首先size--,使最后一个元素保留在队列
        size--;
        while(size--){// 将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部
            que.push(que.front());
            que.pop();
        }
        int result = que.front(); // 此时弹出的元素顺序就是栈的顺序了
        que.pop();
        return result;
    }
    
    int top() {
        return que.back();
    }
    
    bool empty() {
        return que.empty();
    }
};

/**
 * Your MyStack object will be instantiated and called as such:
 * MyStack* obj = new MyStack();
 * obj->push(x);
 * int param_2 = obj->pop();
 * int param_3 = obj->top();
 * bool param_4 = obj->empty();
 */

用队列实现栈

225. 用队列实现栈

请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push、top、pop 和 empty)。

实现 MyStack 类:

void push(int x) 将元素 x 压入栈顶。
int pop() 移除并返回栈顶元素。
int top() 返回栈顶元素。
boolean empty() 如果栈是空的,返回 true ;否则,返回 false 。

注意:

你只能使用队列的基本操作 —— 也就是 push to back、peek/pop from front、size 和 is empty 这些操作。
你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。

示例:

输入:
["MyStack", "push", "push", "top", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 2, 2, false]

解释:
MyStack myStack = new MyStack();
myStack.push(1);
myStack.push(2);
myStack.top(); // 返回 2
myStack.pop(); // 返回 2
myStack.empty(); // 返回 False

题解1:使用两个队列模拟栈

刚刚做过栈与队列:我用栈来实现队列怎么样? (opens new window)的同学可能依然想着用两个队列来实现栈。

队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。

所以用栈实现队列, 和用队列实现栈的思路还是不一样的,这取决于这两个数据结构的性质。

但是依然还是要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用来备份的(暂存除最后一个元素外其余元素)!

class MyStack {
public:
    queue<int> que1;
    queue<int> que2; // 辅助队列,用来备份
    /** Initialize your data structure here. */
    MyStack() {

    }

    /** Push element x onto stack. */
    void push(int x) {
        que1.push(x);
    }

    /** Removes the element on top of the stack and returns that element. */
    int pop() {
        int size = que1.size();
        size--;
        while (size--) { // 将que1 导入que2,但要留下最后一个元素
            que2.push(que1.front());
            que1.pop();
        }

        int result = que1.front(); // 留下的最后一个元素就是要返回的值
        que1.pop();
        que1 = que2;            // 再将que2赋值给que1
        while (!que2.empty()) { // 清空que2
            que2.pop();
        }
        return result;
    }

    /** Get the top element. */
    int top() {
        return que1.back();
    }

    /** Returns whether the stack is empty. */
    bool empty() {
        return que1.empty();
    }
};

题解2:使用一个队列模拟栈

其实,这道题目还可以优化,只需要用一个队列就可以实现。

一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素(size-1个元素)外) 重新添加到队列尾部,此时再弹出元素就是栈的顺序了。

代码细节:

  1. **push:**和栈的操作一样,只需要将数据放到队列中即可。

  2. pop:

    (1)需要获取队列size(为了模拟栈后进先出,所以用于将除最后一个元素外其他size-1个元素弹出后重新放入队列)

    (2)使用while循环(while(size–)),将前size-1个元素全弹出加回到队列中(que.push(que.front())),接着,将队列中弹出的元素删除(que.pop()

    (3)剩下的最后一个元素就是pop的元素。(res = que.front()

  3. top:

    栈里获取top元素,对应于队列里其实是队尾元素,所以直接返回队列中队尾元素即可。(return que.back()

  4. empty:

    判断空,只需要判断队列是否为空,对应于栈也是否为空

参考代码:

class MyStack {
public:
    queue<int> que;
    MyStack() {

    }
    
    void push(int x) {
        que.push(x);
    }
    
    int pop() {
        int size = que.size();
        //首先size--,使最后一个元素保留在队列
        size--;
        while(size--){// 将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部
            que.push(que.front());
            que.pop();
        }
        int result = que.front(); // 此时弹出的元素顺序就是栈的顺序了
        que.pop();
        return result;
    }
    
    int top() {
        return que.back();
    }
    
    bool empty() {
        return que.empty();
    }
};

/**
 * Your MyStack object will be instantiated and called as such:
 * MyStack* obj = new MyStack();
 * obj->push(x);
 * int param_2 = obj->pop();
 * int param_3 = obj->top();
 * bool param_4 = obj->empty();
 */

栈经典题目

20. 有效的括号

给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串 s ,判断字符串是否有效。

有效字符串需满足:

左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
每个右括号都有一个对应的相同类型的左括号。

示例 1:

输入:s = "()"
输出:true

示例 2:

输入:s = "()[]{}"
输出:true

示例 3:

输入:s = "(]"
输出:false

解题思路:

这道题是用栈解决问题的经典题目。由于栈结构的特殊性,非常适合做对称匹配类的题目。

首先,需要弄清楚,字符串中的括号不匹配有几种基本情况,其实,归根到底一共有三种不匹配场景:

  1. 字符串里左方向的括号多余

    ([{}]()
    
  2. 括号没有多余,但括号的类型没有匹配上

    ([{}})
    
  3. 字符串里右方向的括号多余

    ([{}])))
    

处理好以上三种情况,就解决了不匹配的问题

  1. 已经遍历完字符串,但是栈不为空,说明有相应的左括号没有右括号匹配
  2. 遍历字符串过程中,发现栈里没有要匹配的字符,说明有括号的类型没有匹配
  3. 遍历字符串过程中,栈已经为空,没有匹配的字符,说明右括号没有找到对应的左括号

那么什么是完全匹配情况呢?就是字符串遍历完,栈也为空,则说明全都匹配了。

还有一个技巧,就是在遍历字符串时,如果遇到左括号,那么让对应的右括号入栈,这样遍历到右括号时,将栈顶元素弹出,只需要比较当前元素和栈顶相不相同即可,比右括号入栈代码实现简单。

另外可以先求出字符串长度,由于匹配的括号,所以字符串长度一定为偶数,这样就可以达到剪枝操作。

代码细节:

(1)定义一个栈 st

(2)剪枝:if(s.size % 2 != 0) return false;

(3)遍历字符串,

​ 处理遇到左括号场景:将对应右括号push进栈中

​ 处理遇到右括号场景:

​ 先判断栈是否为空(st.empty()),

  • ​ 若为空,return false;(情况3)

  • ​ 若不为空,将栈顶元素弹出,与对应当前右括号匹配,

​ 如果不匹配:return false(情况2)

​ 如果匹配:将这个栈顶元素pop删除,继续遍历

(3)循环遍历结束后,需要判断栈是否为空

  • ​ 若不为空,return false (情况1)
  • ​ 若为空,则匹配成功,return true

​ 这种情况可以合并为一个—return st.empty(); 即可

参考代码:

class Solution {
public:
    bool isValid(string s) {
        if(s.size() % 2 != 0){  // 如果s的长度为奇数,一定不符合要求
            return false;
        }
        stack<char> st;
        for(int i = 0; i < s.size(); i++){  //遍历字符串
            //遇到左括号,push入栈对应右括号
            if(s[i] == '(')     st.push(')');
            else if(s[i] == '{')    st.push('}');
            else if(s[i] == '[')    st.push(']');
            //其余情况均为遇到右括号情况
            // 第三种情况:遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号 return false
            // 第二种情况:遍历字符串匹配的过程中,发现栈里没有我们要匹配的字符。所以return false
            else if(st.empty() || st.top() != s[i])  return false;
            // 只剩下st.top() 与 s[i]相等情况,所以栈弹出元素,继续向后遍历
            else st.pop();    
        }
        // 第一种情况:此时我们已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以return false,否则就return true
        return st.empty();
    }
};
1047. 删除字符串中的所有相邻重复项

给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。

在 S 上反复执行重复项删除操作,直到无法继续删除。

在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。

示例:

输入:"abbaca"
输出:"ca"
解释:
例如,在 "abbaca" 中,我们可以删除 "bb" 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串 "aaca",其中又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。

解题思路:

本题要删除相邻相同元素,相对于20. 有效的括号 (opens new window)来说其实也是匹配问题,20. 有效的括号 是匹配左右括号,本题是匹配相邻元素,最后都是做消除的操作。

所以本题也是使用栈来解决的经典题目。

那么栈里应该放什么元素呢?

分析题目,在遍历过程中,其实我们就是寻找当前遍历的这个元素,在前一位是不是遍历过一样数值的元素。那么记录前面遍历过的元素,就是栈的意义。

也就是当遍历当前的元素时,去栈里寻找我们是不是遍历过相同数值相邻元素(栈顶元素)。

如果找到,则做对应的消除操作(弹出栈顶元素)。

如果没找到,将遍历的元素push到栈中。

如此操作,只需要遍历一次,就可以模拟出题目要求的反复执行重复项删除操作(因为动态消除栈顶后,又可以和栈顶比较)。

最终结果,从栈中弹出的元素是倒序的,所以需要对字符串进行反转一下,得到最终结果。

其实,在这里,我们还可以拿字符串直接作为栈,这样省去了栈还要转为字符串的操作。!这样更方便!

代码细节:

使用字符串result模拟栈。

遍历s中的每个字母c,

如果栈空(result.empty())或者栈顶元素(字符串尾部)与当前遍历元素不相等(s != result.back()),那么将当前元素c加入到栈(字符串)(result.push_back©)中。

如果栈顶元素与当前遍历元素相等(else情况),那么将栈顶(字符串尾部)弹出(result.pop_back();)

所以遇到这种相邻元素匹配问题使,可以选择栈来实现。

参考代码:

(使用栈解决)

class Solution {
public:
    string removeDuplicates(string s) { //使用栈解决
        stack<char> st;
        for(char c : s){
            if(st.empty() || st.top() != c){    //栈空或者栈顶元素与当前遍历元素不相等
                st.push(c);
            } else {
                st.pop();   //栈顶元素与当前遍历元素相等
            }
        } 
        string result = ""; //将栈中元素转化为字符串
        while(!st.empty()){// 将栈中元素放到result字符串汇总
            result += st.top();
            st.pop();
        } 
        reverse(result.begin(), result.end());// 此时字符串需要反转一下
        return result;
    }
};

(使用字符串模拟栈操作)

class Solution {
public:
    string removeDuplicates(string s) {//使用字符串模拟栈
        string result;
        for(char c : s){
            if(result.empty() || result.back() != c){   //注意栈顶元素,对应于字符串尾部
                result.push_back(c);    //在字符串尾部追加c
            }
            else {
                result.pop_back();
            }
        }
        return result;
    }
};
150. 逆波兰表达式求值

根据 逆波兰表示法,求表达式的值。

有效的算符包括 +、-、*、/ 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。

注意 两个整数之间的除法只保留整数部分。

可以保证给定的逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。

示例 1:

输入:tokens = ["2","1","+","3","*"]
输出:9
解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9

示例 2:

输入:tokens = ["4","13","5","/","+"]
输出:6
解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6

示例 3:

输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]
输出:22
解释:该算式转化为常见的中缀算术表达式为:
  ((10 * (6 / ((9 + 3) * -11))) + 17) + 5
= ((10 * (6 / (12 * -11))) + 17) + 5
= ((10 * (6 / -132)) + 17) + 5
= ((10 * 0) + 17) + 5
= (0 + 17) + 5
= 17 + 5
= 22

提示:

1 <= tokens.length <= 104
tokens[i] 是一个算符(“+”、“-”、“*” 或 “/”),或是在范围 [-200, 200] 内的一个整数

逆波兰表达式:

逆波兰表达式是一种后缀表达式,所谓后缀就是指算符写在后面。

  • 平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 ) 。

  • 该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * ) 。

逆波兰表达式主要有以下两个优点:

  • 去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
  • 适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中

解题思路:

依据题意,逆波兰表达式需要使用栈来实现:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中。整个后缀表达式的最终结果就是计算后的栈中元素。

代码细节:

定义一个栈,

遍历字符串s,

  • 如果(if)遇到算符,那么就去栈中寻找栈顶两个数字(num1 = st.top(); st.pop(); nums2 = st.top(); st.pop())进行对应算符计算。

  • 如果(else)遇到数字,那么将其(转为int型)push到栈中

最终 return st.top()即可。

这里有个小点点~如何将字符串转化为数字呢?使用stoi()函数。

C ++ STL stoi()函数 (C++ STL stoi() function)

stoi() stands for string to integer, it is a standard library function in C++ STL, it is used to convert a given string in various formats (like binary, octal, hex or a simple number in string formatted) into an integer.

stoi()表示将字符串转换为整数 ,它是C ++ STL中的标准库函数,用于将各种格式(例如二进制,八进制,十六进制或字符串格式的简单数字)的给定字符串转换为整数。

Syntax:

句法:

    int stoi (const string&  str, [size_t* idx], [int base]);

Parameters:

参数:

  • const string& str is an input string.

    const string&str是输入字符串。

  • size_t* idx is an optional parameter (pointer to the object whose value is set by the function), it’s default value is 0 or we can assign it to nullptr.

    size_t * idx是一个可选参数(指向由函数设置值的对象的指针),其默认值为0或我们可以将其分配给nullptr 。

  • int base is also an optional parameter, its default is 10. It specifies the radix to determine the value type of input string (2 for binary, 8 for octal, 10 for decimal and 16 for hexadecimal).

    int base也是一个可选参数,默认值为10。它指定基数来确定输入字符串的值类型(二进制为2,八进制为8,十进制为10,十六进制为16)。

Return value: It returns converted integer value.

**返回值:**返回转换后的整数值。

参考代码:

class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<int> st;
        for(int i = 0; i < tokens.size(); i++){
            //遇到算符,将栈顶两个元素弹出后做计算,并将结果压入栈中
            if(tokens[i] == "+" || tokens[i] == "-" || tokens[i] ==  "*" || tokens[i] == "/"){
                //取出栈顶两元素
                int num1 = st.top();    st.pop();
                int num2 = st.top();    st.pop();
                //最对应计算同时将结果压入栈中
                //减和除注意顺序 是num2在前!
                if(tokens[i] == "+")    st.push(num1 + num2);
                if(tokens[i] == "-")   st.push(num2 - num1);
                if(tokens[i] == "*")   st.push(num2 * num1);
                if(tokens[i] == "/")   st.push(num2 / num1);
            }
            //其余情况,遇到数字,将该字符转为数字后压入栈中
            else {
                st.push(stoi(tokens[i]));
            }
        }
        int result = st.top();
        st.pop();
        return result;
    }
};

队列经典题目

239. 滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值 。

示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值

---------------               -----

[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

示例 2:

输入:nums = [1], k = 1
输出:[1]

解题思路:

这是使用单调队列的经典题目。

难点是如何求一个区间里的最大值呢?

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

所以寻找一个更优的方式:

那就是使用队列,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。

每次窗口移动的时候,调用que.pop(滑动窗口中移除元素的数值),que.push(滑动窗口添加元素的数值),然后que.front()就返回我们要的最大值。

其中需要自己实现单调队列,队列里的元素一定是要排序的,而且要最大值放在出队口。

维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列

那么如何维护单调队列呢?队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。

单调队列模拟过程

初始化(只需要push):

push规则:

将滑动窗口内的元素加入,

如果当前元素比队列中的元素大,将该元素之前的元素(滑动窗口过程中也需要弹出之前元素,所以没必要维护)全部弹出(维护出口处为最大值)。

如果当前元素比队列中的元素小,将该元素加入到队列中。

滑动窗口向后移动(要经历一遍pop前一个元素,push后一个元素):

pop规则:

如果pop的元素是当前队列最大值(出口处元素)那么将其pop出队列

如果pop的元素不是当前队列最大值(出口处元素)那么不需要管

push规则:

如果当前元素比队列中的元素小,将该元素(对获取最大值没有影响)加入到队列中。

如果当前元素比队列中的前面元素大,将该元素之前的元素(使用while循环)全部弹出。

如果当前

设计单调队列的时候,pop,和push操作要保持如下规则:

  1. pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
  2. push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止

保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。

代码细节:

  • 那么我们用什么数据结构来实现这个单调队列呢?

使用deque最为合适,在理论基础中,我们就提到了常用的queue在没有指定容器的情况下,deque就是默认底层容器。(deque que)

基于刚刚说过的单调队列pop和push的规则,代码不难实现,如下:

pop(int value):

​ 首先判断队列是否为空,且pop的元素(value)是否等于滑动窗口内最大值(que.front())。

​ 如果队列不为空,且相等(!que.empty() && value == que.front()),需要将出口处元素pop出队列(que.pop_front())

​ 其他情况,不需要做pop,因为加入最大值的时候,就把前面的小值pop出了

push(int value):

​ 首先判断队列是否为空,且push的元素(value)是否比队列里的前面元素(que.back())都大。

​ 如果队列不为空,且value大于出口处元素(while( !que.empty() && value > que.back() )),那么将队列中的前面元素弹出(que.pop_back()),直到找到可能前面有元素大于当前value,将value加入当前队列(que.push_back(value))

getMaxValue:

直接返回队列出口元素即可(return que.front())

参考代码:

class Solution {
private:
    class MyQueue { //自定义单调队列,实现出口处永远保持最大值
    public:
        deque<int> que;// 使用deque来实现单调队列
        // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
        // 同时pop之前判断队列当前是否为空。        
        void pop(int value){
            if(!que.empty() && value == que.front()){
                que.pop_front();
            }
        }
        // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
        // 这样就保持了队列里的数值是单调从大到小的了。
        void push(int value){
            while(!que.empty() && value > que.back()){
                que.pop_back();
            }
            que.push_back(value);
        }        
        // 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
        int getMaxValue(){
            return que.front();
        }
    };
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        // 初始化que对象
        MyQueue que;
        // result存放滑动窗口划过的最大值
        vector<int> result;
        for(int i = 0; i < k; i++){ //初始化只需要push,将前k各元素放进队列
            que.push(nums[i]);
        }
        // result 记录前k的元素的最大值
        result.push_back(que.getMaxValue());
        for(int i = k; i < nums.size(); i++){   //模拟滑动窗口移动过程
            que.pop(nums[i - k]);    //滑动窗口移除最前面元素
            que.push(nums[i]);  //滑动窗口加入最后面元素
            result.push_back(que.getMaxValue());  //result记录当前最大值
        }
        return result;
    }
};

*347. 前 K 个高频元素

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。

示例 1:

输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]

示例 2:

输入: nums = [1], k = 1
输出: [1]

解题思路:

两个难点:

1.如何求数组中每个元素出现的频率

2.如何对频率进行排序并求出前k个高频元素

解决:

  1. 使用map存放对应元素以及出现的次数

  2. 按照value进行排序(使用快排要将map转换为vector的结构,然后对整个数组进行排序,:O(nlogn)),将排序后的前k个元素的key输出。

    但其实不需要对所有元素进行排序,只需要求前k个高频元素也就是维护k个有序集合即可。

也就是如何维护k个有序集合呢?这就是大顶堆、小顶堆的经典应用。—在大的数据集中求前k个高频或低频的元素

那么,是使用大顶堆还是小顶堆呢?

一般思路,求前k个高频元素,使用大顶堆。

但是!根据大顶堆的操作:定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢?(所以如果是只维护k个元素,不能使用大顶堆,但是如果全部元素排序,则使用大顶堆排序)

所以!我们要**用小顶堆,因为要统计最大前k个元素,**只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。

使用堆时间复杂度:O(logk)—数组集合很大,k较小,时间复杂度明显优于所有元素全都排序的时间复杂度。

其实,在c++中,大顶堆和小顶堆有现成的数据结构:priority_queue(优先级队列),底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。

代码细节:

  1. 定义map数据结构(map<nums[i],对应出现的次数>),用于记录元素出现次数
  2. 遍历数组中每个元素i,使用map统计每个元素(key)所对应的出现次数(value)(map[nums[i]]++
  3. 定义优先级队列(priority_queue):要求:小顶堆+对map中的value进行排序。所以需要子定义一个compare函数
  4. 遍历map中每个元素( * it),将每个元素加入到优先级队列中(pri_que.push(*it);)因为,只维持前k个元素,所以如果队列的元素个数大于k时(que.size() > k),执行pop操作(que.pop()),这样每次pop出最小元素,留下的就是较大的元素。
  5. 遍历完成后,优先级队列中的元素,就是前k个高频元素。但是由于是小顶堆,所以它的根节点(pop的第一个元素)就是堆中最小的元素,而题意要求返回频率从大到小的前k个元素。所以此时需要新定义一个数组,for循环从后(k-1)向前(for(int i = k - 1; i >= 0; i–),存入优先队列pop出的元素(注意pop出的是map对象,而我们存的是对应元素,所以要取first)(result[i] = que.top().first()

参考代码:

// 时间复杂度:O(nlogk)
// 空间复杂度:O(n)
class Solution {
public:
    //自定义优先队列排序规则:小顶堆的实现
    class mycomparison{
        public:
            //左右map型字数传入,按照value进行排序,构建小顶堆
            bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs){
                return lhs.second > rhs.second;
            }
    };
    vector<int> topKFrequent(vector<int>& nums, int k) {
        //使用map统计元素出现频率
        unordered_map<int, int> map;// map<nums[i],对应出现的次数>
        for(int i = 0; i < nums.size(); i++){
            map[nums[i]]++;
        }
        //对频率排序
        //定义一个大小为k的小顶堆
        priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pri_que;
        //使用固定大小为k的小顶堆,使用迭代器遍历map中所有元素
        for(unordered_map<int, int>::iterator it = map.begin();it != map.end(); it++){
            pri_que.push(*it);
            if(pri_que.size() > k){  如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k
                pri_que.pop();
            }
        }
        //找出前k个高频元素,因为小顶堆先弹出的是最小的,所以倒序来输出到数组
        vector<int> result(k);
        // 倒序输出到数组
        for(int i = k - 1; i >= 0; i--){
            // 取出top(map类型)的first也就是键
            result[i] = pri_que.top().first;
            pri_que.pop();
        }
        return result;
    }
};

总结

栈与队列的理论基础

首先我们在栈与队列:来看看栈和队列不为人知的一面 (opens new window)中讲解了栈和队列的理论基础。

里面提到了灵魂四问:

  1. C++中stack,queue 是容器么?
  2. 我们使用的stack,queue是属于那个版本的STL?
  3. 我们使用的STL中stack,queue是如何实现的?
  4. stack,queue 提供迭代器来遍历空间么?

相信不仅仅是C++中有这些问题,那么大家使用其他编程语言,也可以考虑一下这四个问题,栈和队列是如何实现的。

栈与队列是我们熟悉的不能再熟悉的数据结构,但它们的底层实现,很多同学都比较模糊,这其实就是基础所在。

可以出一道面试题:栈里面的元素在内存中是连续分布的么?

这个问题有两个陷阱:

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

所以这就是考察候选者基础知识扎不扎实的好问题。

大家还是要多多重视起来!

了解了栈与队列基础之后,那么可以用栈与队列:栈实现队列 (opens new window)栈与队列:队列实现栈 (opens new window)来练习一下栈与队列的基本操作。

值得一提的是,用栈与队列:用队列实现栈还有点别扭 (opens new window)中,其实只用一个队列就够了。

一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序了。

#栈经典题目
#栈在系统中的应用

如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,就是使用了栈这种数据结构。

再举个例子,linux系统中,cd这个进入目录的命令我们应该再熟悉不过了。

cd a/b/c/../../

1

这个命令最后进入a目录,系统是如何知道进入了a目录呢 ,这就是栈的应用。这在leetcode上也是一道题目,编号:71. 简化路径,大家有空可以做一下。

递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。

所以栈在计算机领域中应用是非常广泛的。

有的同学经常会想学的这些数据结构有什么用,也开发不了什么软件,大多数同学说的软件应该都是可视化的软件例如APP、网站之类的,那都是非常上层的应用了,底层很多功能的实现都是基础的数据结构和算法。

所以数据结构与算法的应用往往隐藏在我们看不到的地方!

#括号匹配问题

栈与队列:系统中处处都是栈的应用 (opens new window)中我们讲解了括号匹配问题。

括号匹配是使用栈解决的经典问题。

建议要写代码之前要分析好有哪几种不匹配的情况,如果不动手之前分析好,写出的代码也会有很多问题。

先来分析一下 这里有三种不匹配的情况,

  1. 第一种情况,字符串里左方向的括号多余了 ,所以不匹配。
  2. 第二种情况,括号没有多余,但是 括号的类型没有匹配上。
  3. 第三种情况,字符串里右方向的括号多余了,所以不匹配。

这里还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!

#字符串去重问题

栈与队列:匹配问题都是栈的强项 (opens new window)中讲解了字符串去重问题。 1047. 删除字符串中的所有相邻重复项

思路就是可以把字符串顺序放到一个栈中,然后如果相同的话 栈就弹出,这样最后栈里剩下的元素都是相邻不相同的元素了。

#逆波兰表达式问题

栈与队列:有没有想过计算机是如何处理表达式的? (opens new window)中讲解了求逆波兰表达式。

本题中每一个子表达式要得出一个结果,然后拿这个结果再进行运算,那么这岂不就是一个相邻字符串消除的过程,和栈与队列:匹配问题都是栈的强项 (opens new window)中的对对碰游戏是不是就非常像了。

#队列的经典题目
#滑动窗口最大值问题

栈与队列:滑动窗口里求最大值引出一个重要数据结构 (opens new window)中讲解了一种数据结构:单调队列。

这道题目还是比较绕的,如果第一次遇到这种题目,需要反复琢磨琢磨

主要思想是队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。

那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列

而且不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。

设计单调队列的时候,pop,和push操作要保持如下规则:

  1. pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
  2. push(value):如果push的元素value大于入口元素的数值,那么就将队列出口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止

保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。

一些同学还会对单调队列都有一些困惑,首先要明确的是,题解中单调队列里的pop和push接口,仅适用于本题。

单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。

不要以为本地中的单调队列实现就是固定的写法。

我们用deque作为单调队列的底层数据结构,C++中deque是stack和queue默认的底层实现容器(这个我们之前已经讲过),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。

#求前 K 个高频元素

栈与队列:求前 K 个高频元素和队列有啥关系? (opens new window)中讲解了求前 K 个高频元素。

通过求前 K 个高频元素,引出另一种队列就是优先级队列

什么是优先级队列呢?

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

而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?

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

什么是堆呢?

堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。

所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。

本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!

所以排序的过程的时间复杂度是 O ( log ⁡ k ) O(\log k) O(logk),整个算法的时间复杂度是 O ( n log ⁡ k ) O(n\log k) O(nlogk)

#总结

在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。

使用抽象程度越高的语言,越容易忽视其底层实现,而C++相对来说是比较接近底层的语言。

我们用栈实现队列,用队列实现栈来掌握的栈与队列的基本操作。

接着,通过括号匹配问题、字符串去重问题、逆波兰表达式问题来系统讲解了栈在系统中的应用,以及使用技巧。

通过求滑动窗口最大值,以及前K个高频元素介绍了两种队列:单调队列和优先级队列,这是特殊场景解决问题的利器,是一定要掌握的。

好了,栈与队列我们就总结到这里了,接下来Carl就要带大家开启新的篇章了,大家加油!
stack和queue默认的底层实现容器(这个我们之前已经讲过),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。

#求前 K 个高频元素

栈与队列:求前 K 个高频元素和队列有啥关系? (opens new window)中讲解了求前 K 个高频元素。

通过求前 K 个高频元素,引出另一种队列就是优先级队列

什么是优先级队列呢?

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

而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?

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

什么是堆呢?

堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。

所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。

本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!

所以排序的过程的时间复杂度是 O ( log ⁡ k ) O(\log k) O(logk),整个算法的时间复杂度是 O ( n log ⁡ k ) O(n\log k) O(nlogk)

#总结

在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。

使用抽象程度越高的语言,越容易忽视其底层实现,而C++相对来说是比较接近底层的语言。

我们用栈实现队列,用队列实现栈来掌握的栈与队列的基本操作。

接着,通过括号匹配问题、字符串去重问题、逆波兰表达式问题来系统讲解了栈在系统中的应用,以及使用技巧。

通过求滑动窗口最大值,以及前K个高频元素介绍了两种队列:单调队列和优先级队列,这是特殊场景解决问题的利器,是一定要掌握的。

好了,栈与队列我们就总结到这里了,接下来Carl就要带大家开启新的篇章了,大家加油!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值