数据结构:栈和队列

数据结构:栈和队列

栈和队列基础知识

栈和队列都是受限的数据结构;栈只允许在栈顶进行元素的插入和删除,只能访问最近添加元素,没有随机访问元素的能力,基于后进先出LIFO的原则;队列只允许在队尾进行元素插入,在队头进行元素删除,只能访问最远添加的元素,没有随机访问元素的能力,基于先进先出FIFO的原则;

栈和队列是特殊的数据结构,在实现上和之前学的容器类vector有什么区别呢?

  1. vector是容器,栈stack和队列queue不是容器,而是容器适配器;
  2. stackqueue可以用vector作为底层结构;(容器适配器不是容器,容器适配器的底层结构是容器
  3. vector明显更加不受限制,vector可以随机访问,也提供了push_backpop_back这些成员函数;而stackqueue只提供了有限功能的成员函数,比如pushpoptop等,不能随意插入和删除元素,也不能随意访问任意位置元素;(如果底层容器选择vectorstack就可以看作受限的vector
  4. 即使以vector容器为底层实现的stack,也不可以使用vector的大部分强大的接口,而只能使用受限的stack的成员函数;所以从外表看,stack是一个独立的数据结构,有独立且特殊的成员函数,并且只能通过这些特定的成员函数操作数据,和vecter无关;
  5. vector等容器类有迭代器,而stack没有迭代器,要访问栈中所有元素只能将一个个元素从栈顶弹出栈;
  6. stack只关注栈顶元素,queue只关注队头队尾元素;

容器和容器适配器

  • 容器是用来存储一组元素的数据结构,向量vector、列表list、双端队列deque等都是容器,而栈stack、队列queue、优先队列priority_queue是容器适配器而不是容器,这些容器适配器实际上是将现有的序列式容器的基础上封装出不同的接口,以提供特定的功能
  • 容器适配器的底层容器是可拔插的,也就是既可以用vector实现stack,也可以用list实现stack
  • 容器适配器本身并不存储元素,而是依赖于底层的容器来实际存储数据;
  • 容器适配器在外表上看有自己的成员函数,看上去就像是容器,可是它没办法存储元素,必须依靠底层容器来存储元素。

C++中的stackqueue实现:(以stack为例)

指定底层容器:(默认底层容器是deque

std::stack<int, std::vector<int>> myStack;  // 使用 vector 作为底层容器
std::stack<int, std::list<int>> myStack;  // 使用 list 作为底层容器
std::stack<int, std::deque<int>> myStack;  // 使用 deque 作为底层容器
std::stack<int> myStack; // 使用默认底层容器

不同底层容器实现的stack的差异:stack并不存储元素,存储元素是底层容器实现的;所以不同容器在某些操作上可能有不同的时间复杂度;在存储元素时内存分配方式也不同,比如vector的扩容机制;但是在stack的操作上,差异不大;(底层有差异,表现出来差异不大)

使用vector实现简单stack容器适配器:

#include <vector>

template <typename T>
class MyStack {
private: // 私有,不能直接访问,必须通过public提供的接口,这样就能实现受限访问
    std::vector<T> data; // 使用 vector 作为底层容器

public: // 公开必要接口,实现受限访问,避免数据泄露
    void push(const T& value) {
        data.push_back(value); // 调用底层容器的 push_back() 添加元素
    }

    void pop() {
        data.pop_back(); // 调用底层容器的 pop_back() 移除元素
    }

    T& top() {
        return data.back(); // 返回底层容器的最后一个元素
    }

    bool empty() const {
        return data.empty(); // 检查底层容器是否为空
    }

    size_t size() const {
        return data.size(); // 返回底层容器的大小
    }
};

虽然底层容器是vector,但是public中只公开了部分接口可以操作vector,所以stack此时就是受限访问的vector;修改底层容器,对于使用stack时的接口没有改变,但是底层实现可能发生变化。

栈里面的元素在内存中是连续分布的么?

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

栈和队列题目

用栈实现队列:用两个栈模拟出队、入队、获取队顶元素等;

class MyQueue {
private:
    stack<int> stIn; //输入栈;
    stack<int> stOut; //输出栈;
public:
    MyQueue() {

    }
    
    void push(int x) {
        stIn.push(x); //入队直接压栈到输入栈;
    }
    
    int pop() {
        if(stOut.empty()) { //如果输出栈为空,则输入栈所有进入输出栈
            while(!stIn.empty()){
                stOut.push(stIn.top());
                stIn.pop();
            }
        }
        int result = stOut.top();
        stOut.pop();
        return result;
    }
    
    int peek() {
        int res = this->pop(); //直接使用定义的pop函数
        stOut.push(res); //弹出栈再压回去;
        return res;
    }
    
    bool empty() {
        //输入栈和输出栈都为空时,队列为空;
        return stIn.empty() && stOut.empty();
    }
};

我们之前说C++中std::queue队列是容器适配器类,可以选择不同的容器作为底层;上面代码中,我们自己实现了一个队列,具体是用两个栈实现的队列;我们实现的队列 MyQueue 是由栈实现的,而栈的底层是由 vector 实现的,看起来像不像是套娃?

之前我们用vector实现一个简单的stack类时提到,底层容器vectorprivate中,而public中只提供了栈的接口,所以我们只能用栈的操作方式来操作stack类,而不能用vector的操作方式来修改stack内容。这里用两个stack实现队列时,也应该将两个stack定义在private中,这样我们不能通过操作栈来修改Myqueue中元素,只能通过public中提供的队列的操作方式来操作Myqueue

用队列模拟栈:用两个队列实现栈的基本操作;

class MyStack {
private:
    queue<int> que1;
    queue<int> que2;    
public:
    MyStack() {

    }
    
    void push(int x) {
        //模拟入栈就是直接入队即可;
        que1.push(x);
    }
    
    int pop() {
        //模拟出栈:将队列中清空至只剩一个最后入队的元素后,出队;
        int size = que1.size();
        size--;
        while(size--) {
            que2.push(que1.front());
            que1.pop();
        }
        int result = que1.front();
        que1.pop();
        //恢复现场,que1空了,que2成了之前的备份,que2要变成que1;
        que1 = que2;
        //que2可以清空了;
        while(!que2.empty()) {
            que2.pop();
        }
        return result;
    }
    
    int top() {
        //利用之前写好的pop()实现;或者直接返回队尾元素的值;
        return que1.back();
    }
    
    bool empty() {
        return que1.empty();
    }
};

有效的括号:括号匹配,栈的典型应用;

class Solution {
public:
    bool isValid(string s) {
        if(s.size() % 2 != 0) return false; //提前终止;
        stack<char> st;
        for(int i = 0; i < s.size(); i++) {
            if (s[i] == '(') st.push(')'); //找到左括号,右括号反而入栈,降低匹配到的判断逻辑;
            else if (s[i] == '{') st.push('}');
            else if (s[i] == '[') st.push(']');
            else if (st.empty() || st.top() != s[i]) return false;
            else st.pop(); //匹配成功;
        } 
        return st.empty();
    }
};

栈在编译、操作系统和其他领域中有着广泛的应用,以下是一些常见的应用场景:

  1. 编译器对于括号的处理:编译器在编译代码时需要检查代码中的括号是否匹配,这通常通过栈的数据结构来实现。编译器会遍历代码中的括号,遇到左括号则将其压入栈中,遇到右括号则将栈顶元素弹出并检查是否与对应的左括号匹配,如果匹配则继续,否则报错。
  2. 操作系统中文件目录结构:在操作系统中,文件系统通常采用树形结构来组织文件目录,每个目录下可以包含子目录或文件。操作系统在管理文件目录结构时也会使用到栈的数据结构,比如在实现路径解析、递归遍历文件目录等过程中。
  3. 递归中的递归栈:在递归函数中,每一次函数调用都会将当前函数的局部变量、参数等信息保存在栈帧中,同时在调用新的函数时会将新函数的信息推入栈中,形成调用栈。这个调用栈实际上就是栈的应用之一。如果递归层级很深,栈的大小可能会有限制,导致栈溢出。

递归过程中使用的递归栈可能溢出,所以在工程项目中,减少使用递归很有必要;

逆波兰表达式求值:后缀表达式求值;

/*
    逆波兰表达式求值;遇到数字入栈,遇到符号取出栈顶两个数字运算后入栈;
    没有括号,就没有歧义;
*/
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(num2 + num1);
                if (tokens[i] == "-") st.push(num2 - num1);
                if (tokens[i] == "*") st.push(num2 * num1);
                if (tokens[i] == "/") st.push(num2 / num1);
            } else {
                st.push(stoll(tokens[i])); //字符要转换为数字;
                //stoll是将字符串转换为整型的函数;
            }
        }
        int result = st.top();
        st.pop();
        return result;
    }
};

中缀表达式转换为后缀表达式的手算方法:

例如中缀表达式"(a+b)*c+d",首先加小括号,变为"(((a+b)*c)+d)",其次将符号放到最近的右括号的有右边"(((ab)+c)*d)+",最后去除所有括号"ab+c*d+",即从中缀表达式得到后缀表达式;

计算后缀表达式的值:用栈,遇到数字入栈,遇到符号从栈中取出两个数字计算,计算结果放回栈中;

中缀表达式转换为后缀表达式(也可以利用栈实现):

  1. 创建一个操作数栈和一个运算符栈。
  2. 从左到右遍历中缀表达式的每个元素:
    • 如果是操作数,直接输出到后缀表达式。
    • 如果是左括号,将其压入运算符栈。
    • 如果是右括号,将运算符栈顶的元素逐个弹出并输出到后缀表达式,直到遇到对应的左括号。
    • 如果是运算符:
      1. 当前运算符优先级高于栈顶运算符,直接入栈。
      2. 否则,将栈顶运算符弹出并输出到后缀表达式,直到栈顶运算符优先级小于当前运算符或栈为空。
  3. 遍历完整个中缀表达式后,将运算符栈中的所有元素依次弹出并输出到后缀表达式。

滑动窗口的最大值:单调队列的应用;

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
输入: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

可以使用现成的数据结构multiset:(mutiset红黑树实现的有序集合)

思路:

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

怎么找最大值?难道队首元素就是最大值?

如果排序,则没办法保持元素的相对顺序,滑动窗口滑动后,哪个元素应该滑出去?(最大值元素不一定是滑动窗口最左边的元素)

如果不排序,怎么知道最大值,怎么把最大值返回?

之所以纠结于排序还是不排序,是因为我们陷入了思维误区,为什么一定要全部有序?为什么要维护窗口里的所有元素?对窗口里所有元素排序当然可以获取到最大元素,可是获取最大元素未必要对窗口里所有元素排序;

不维护所有元素,只维护可能是最大值的元素;

单调队列就可以完成这一功能;单调队列不是对窗口里的所有元素排序,而是选择窗口里的部分元素,保持这些元素的相对位置,并且保证选择的这些元素的值保持单调递增或者单调递减

窗口里的元素: {2, 3, 5, 1, 4}
对窗口里的所有元素进行排序:{5, 4, 3, 2, 1}
单调队列(单减):{5, 4}
单调队列(单增):{2, 3, 5}

以递减的单调队列为例,滑动窗口最大值元素就是单调队列的第一个元素;

如何维护一个单调队列?见下表(模拟滑动窗口使用单调队列实现的过程):

滑动窗口的位置                最大值
---------------               -----
[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
    
单调队列:(模拟过程)
[1] // 队空,直接插入
[3] // 插入元素3,3大于1,一旦插入队列就不是单调递减队列了,所以将1弹出后插入3
[3  -1] // 插入-1,-1小于3,不改变单调性,此时i指向2,第一个窗口已经扫描完成,获取队头元素3为最大值(注意只是获取,并不弹出,自此后面每次都要输出最大值)
[3  -1  -3] // 插入-3,不改变单调性;获取队首元素3作为最大值
[5] // 插入5,首先由于滑动窗口长度为3,所以队列最大也为3,队首元素先直接出队;然后5和-1比,5大于-1,则说明队中所有元素均小于5,队中元素全部弹出;5入队,然后输出队首元素5作为最大值
[5  3] // 插入3,最大值为5
[6] // 弹出5,弹出3,插入6,最大值为6
[7] // 弹出6,插入7,最大值为7

单调队列构建的规则:

  • 队列长度最长为3,如果队长已经为3,而且此时有新元素要插入,弹出队首元素;
  • 插入队尾元素时,要先和队首元素比较,如果队首元素大于插入元素,则直接插入;如果队首元素小于插入元素,则弹出队列中所有元素后插入;(如果队首元素小于插入元素,则说明队列中所有元素都小于插入元素,无需继续比较,直接全部弹出)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

/*
    找一个区间内的最值——使用单调队列(单调队列指单调递减或递增的队列)
    单调队列的push和pop原则:
        1. pop:如果队首元素等于窗口移除元素,则队首弹出;
        2. push:如果窗口增加元素大于队尾元素,则将队尾元素弹出,继续判断
           直到队尾元素小于等于窗口增加元素;(最大值在队头)
        要注意,单调队列元素个数小于等于窗口大小,单调队列只保存较大的几个元素,
        也只维护较大的元素;
    最大值:队头元素是每轮的最大值;
    注意:队列可以pop_front和pop_back;
*/
class Solution {
private: //自己重写单调队列的功能;
    class MyQueue {
    public:
        deque<int> que;
        void pop(int value) { 
            //pop:如果队首元素等于窗口移除元素value,则队首弹出;
            if (!que.empty() && que.front() == value) {
                que.pop_front(); //弹出队首元素;
            }
        }
        void push(int value) {
            //push:如果窗口增加元素大于队尾元素,则将队尾元素弹出,继续判断直到队尾元素小于等于窗口增加元素;
            while (!que.empty() && value > que.back()) {
                que.pop_back();
            }
            que.push_back(value);
        }
        int front() {
            return que.front();
        }
    };
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        MyQueue que;
        vector<int> result;
        for (int i = 0; i < k; i++) { //开始的窗口载入
            que.push(nums[i]);
        }
        result.push_back(que.front()); //最大值在队头,将队头元素添加到result;
        for (int i = k; i < nums.size(); i++) {
            que.pop(nums[i-k]); //移除滑动窗口最前面元素nums[i-k];
            que.push(nums[i]); //增加滑动窗口最后的元素;
            result.push_back(que.front()); //每轮滑动后记录最大值;
        }
        return result;
    }
};
/*
    单调队列的使用:
        deque重写push和pop方法,使队列元素有序;
        deque可以两头进出,区分于stack;push_front(), push_back();
        pop_front(); pop_back();,所以更灵活;
        本题中的单调队列写法并不是一切单调队列的写法;
*/

单调队列的实现,nums 中的每个元素最多也就被 push_back()pop_back() 各一次,没有任何多余操作,所以整体的复杂度还是 O ( n ) O(n) O(n)。(如果使用暴力算法,则时间复杂度为 O ( n ∗ k ) O(n*k) O(nk) k k k为窗口大小)

为什么使用单调队列?因为我们遇到了问题中,如果排序则没办法维护元素的相对顺序,如果不排序,则没办法获取最大最小值;而单调队列,即获取了最大最小值,又可以不改变较大值或者较小值的相对顺序

单调序列的常见应用:

  1. 滑动窗口的最大值/最小值问题;(滑动窗口每次移动时,维护队列的单调性;可以在常数时间内获取到当前窗口的最值)
  2. 股票价格涨跌模拟问题;(获取最近一段时间内股票涨跌的峰值,本质和滑动窗口求最值一致,是滑动窗口求最值的现实应用)
  3. 动态规划中的优化问题(动态规划中对状态转移中的最值进行优化,提高算法效率,尤其是关于区间最值的动态规划问题,可以降低其时间复杂度)
  4. 其他应用于要维护单调性的问题中(例如求解数组中第K大元素等)

单调队列未必一成不变,单调队列只是一种思想,保证队列里单调递增或者递减即可;

前k个高频元素:优先级队列实现;

元素出现的频率要进行统计,并且元素的值和元素的频率要对应,所以要选择合适的数据结构记录元素的值及其频率,选择键值对,即使用map;我们要对频率进行排序,但是对于元素的值我们不关心其顺序,所以使用key无序的unordered_map存储键值对;

数据结构选择了,其次我们要选择什么算法?直接使用优先队列,优先队列和stackqueue一样,都是容器适配器;在C++中,优先级队列基于堆(heap)数据结构实现。优先级队列中的元素按照一定的优先级顺序排列,每次取出队首元素都是当前最高(或最低)优先级的元素。

#include <queue>
#include <functional> // std::greater

std::priority_queue<int> pq; // 默认是基于 std::vector 实现的最大堆

std::priority_queue<int, std::vector<int>, std::greater<int>> minPQ; // 自定义元素的优先级,指定std::vector<int>作为底层容器,用 std::greater 创建最小堆

// 也可以自己写一个greater函数:
bool operator()(int a, int b) const {
    // 比较函数将返回 true 如果 a 比 b 大,即实现逆序
    return a > b;
}

使用大顶堆还是小顶堆?大顶堆每次弹出最大元素,我们还要考虑保留的逻辑;小顶堆每次弹出最小元素,我们丢弃即可,保证队列中有k个元素即可;所以使用小顶堆;

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

/*
    题目的要求分析:
        1. 统计元素出现的频率;
        2. 对频率排序;
        3. 输出频率排序前K高的元素;
    优先级队列:从队头取元素,从队尾添加元素;内部元素自动按照权值排序;
        map有key和value,key可以是值,value可以是出现的频率,根据value进行排序,然后输出前k个key值;
    思路步骤:
        1. 构建map,key为元素的值,value为频率;
        2. 构建小顶堆(将value作为小顶堆的值),保证堆的大小为k;
        3. 构建小顶堆的结果构建key的数组;
*/
class Solution {
public:
    // 小顶堆
    class mycomparison {
    public:
        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) {
        // 要统计元素出现频率
        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的小顶堆,扫面所有频率的数值
        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--) {
            result[i] = pri_que.top().first;
            pri_que.pop();
        }
        return result;

    }
};

总结

C++中栈stack、队列queue、优先级队列priority_queue都是容器适配器而不是容器,其底层结构可以选择不同的容器;容器适配器不存储元素,但是容器适配器提供了受限操作元素的接口,容器适配器的底层结构容器才存储元素。正因为如此,栈、队列、优先级队列中的元素未必是连续的,要看其底层容器选择了什么,如果底层容器选择了deque,则元素是不连续的;

栈和队列都是受限访问的,而这一受限访问的原因在于底层容器是private的,而元素存储在底层容器中,只能通过栈和队列定义在public中的特定的成员函数进行操作;

栈在现实中的应用很多,包括操作系统的文件管理、编译器的括号匹配、递归过程中的递归栈等;

两个栈可以模拟实现队列,同样两个队列也可以模拟实现栈;

单调队列可以解决滑动窗口、股票涨跌峰值、动态规划算法优化等问题;单调队列的构造方法要知道,单调队列中,只维护了部分元素,这些元素整体保持单调,并且元素的相对位置不发生改变;

优先级队列也是容器适配器,优先级队列的本质就是一个大顶堆/小顶堆,可以找到数据中的最大值/最小值;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

OutlierLi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值