首页 > 编程语言 >代码随想录算法训练营第十一天 | 150. 逆波兰表达式求值+ 239. 滑动窗口最大值+347.前 K 个高频元素

代码随想录算法训练营第十一天 | 150. 逆波兰表达式求值+ 239. 滑动窗口最大值+347.前 K 个高频元素

时间:2024-11-11 12:50:03浏览次数:3  
标签:第十一天 优先级 队列 rhs 元素 随想录 que 求值 lhs

今天接着补上周末的栈与队列的part2,下午继续完成今天的任务。


150. 逆波兰表达式求值

 给你一个字符串数组 tokens ,表示一个根据 逆波兰表示法 表示的算术表达式。

请你计算该表达式。返回一个表示表达式值的整数。

注意:

  • 有效的算符为 '+''-''*' 和 '/' 。
  • 每个操作数(运算对象)都可以是一个整数或者另一个表达式。
  • 两个整数之间的除法总是 向零截断 。
  • 表达式中不含除零运算。
  • 输入是一个根据逆波兰表示法表示的算术表达式。
  • 答案及所有中间计算结果可以用 32 位 整数表示。
  • 示例 1:

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

 逆波兰表达式相当于是二叉树中的后序遍历。

后序遍历(Postorder Traversal)是指按照 “左子树 - 右子树 - 根节点” 的顺序依次访问二叉树中的节点。也就是说,先递归地遍历左子树,再递归地遍历右子树,最后访问根节点。

实际上逆波兰表达式是为了方便计算机去“理解”,去实现,在这里算法逻辑和 1047. 删除字符串中的所有相邻重复项 类似,消除操作换成了表达式中对应算符的操作,同时计算后的结果要重新压入栈,在二叉树里就相当于是把根节点替换成计算子节点后的值。

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

如动画所示: 

150.逆波兰表达式求值

stoll 函数

  •  stoll 是 C++ 标准库中的一个函数,全称为 std::stoll。它的功能是将一个字符串转换为长整型(long long)数值。
  • 例如,如果有一个字符串 "12345",通过调用 stoll("12345"),它会尝试将这个字符串解析成对应的长整型数值 12345。如果字符串表示的内容无法正确转换为长整型(比如包含非数字字符且不符合数值表示规范),那么可能会抛出异常(在没有进行异常处理的情况下)。 
class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        // 力扣修改了后台测试数据,需要用longlong
        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]));
//整体遍历逻辑就是,遇到操作数,压入栈,遇到操作符,取出栈顶两个操作数进行操作后再将结果压入。
            }
        }

        int result = st.top();
        st.pop(); // 把栈里最后一个元素弹出(其实不弹出也没事)
        return result;
    }
};

补充:我们学习时较为熟悉的计算表达式为中缀表达式,例如:4 + 13 / 5。但这对于计算机来说,并不是一个很好处理的式子,从左往右读,要判断优先级,要回退到前面继续操作。而后缀表达式对于计算机就十分友好了,可以用栈顺序处理。

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

针对这个问题,很容易我们可以想到暴力遍历来解决,但这样的时间复杂度达到了O(n*k),是不满足要求的。所以我们引入单调队列来解决。

单调队列

  1. 定义
    • 单调队列(Monotonic Queue)是一种特殊的数据结构。它在普通队列的基础上,队列中的元素具有单调性。通常可以分为单调递增队列和单调递减队列。
    • 以单调递减队列为例,队列中的元素从队首到队尾是按照从大到小的顺序排列的;单调递增队列则是元素从队首到队尾按照从小到大的顺序排列。
  2. 操作特点
    • 入队操作
      • 当一个新元素要进入单调队列时,需要先将队列中不符合单调性的元素删除,然后再将新元素插入。例如,对于单调递减队列,如果新元素比队尾元素大,那么就将队尾元素依次弹出,直到队尾元素大于新元素或者队列为空,然后再将新元素插入队尾。
    • 出队操作
      • 和普通队列类似,单调队列的出队操作也是从队首取出元素。不过在单调队列中,由于元素的单调性,队首元素通常是具有某种最值性质的元素(在单调递减队列中是最大值,单调递增队列中是最小值)。

Deque:

(常用的queue在没有指定容器的情况下,deque就是默认底层容器。)

  1. 定义与性质
    • Deque 是双端队列(Double - Ended Queue)的简称,是 C++ 中的一种序列容器。它的主要特点是可以在队列的两端(头部和尾部)进行高效的插入和删除操作。
  2. 存储结构
    • 其内部存储方式是一种分段连续存储,和连续存储的vector不同。这种结构使得它在进行头部和尾部操作时不需要像vector那样可能频繁地整体重新分配内存,从而保证了两端操作的高效性。
  3. 元素访问
    • 能像vector一样通过索引访问元素,不过在索引访问速度上比vector稍慢一点。同时,提供了边界检查的访问方式,以避免因越界访问导致程序错误。
  4. 操作特点
    • 支持在头部插入(push_front)和删除(pop_front)元素,以及在尾部插入(push_back)和删除(pop_back)元素。也可以在指定位置插入(insert)和删除(erase)元素。
  5. 容量操作
    • 可以获取其大小(size)、判断是否为空(empty)、调整大小(resize)。
  6. 迭代器支持
    • 提供迭代器用于遍历双端队列中的元素。
  7. 应用场景与比较
    • 适用于两端频繁插入和删除元素的场景。和vector相比,两端操作更高效;和list相比,随机访问更具优势,且两端操作效率也不低。

动画如下:

239.滑动窗口最大值-2

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 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++) { // 先将前k的元素放进队列
            que.push(nums[i]);
        }
        result.push_back(que.front()); // result 记录前k的元素的最大值
        for (int i = k; i < nums.size(); i++) {
            que.pop(nums[i - k]); // 滑动窗口移除最前面元素
            que.push(nums[i]); // 滑动窗口前加入最后面的元素
            result.push_back(que.front()); // 记录对应的最大值
        }
        return result;
    }
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(k)

347.前 K 个高频元素

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

示例 1:

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

  首先分析题目需求:

  1. 要统计元素出现频率 = map
  2. 对频率排序 = 优先级队列
  3. 找出前K个高频元素

为什么不用快排?

  1. 快排的局限性
    • 数据结构转换成本
      • 当使用快排时,如果数据存储在map结构中,map通常是基于键值对的关联容器,它的存储方式可能是红黑树(在 C++ 中std::map是红黑树实现)等结构。快排需要一个连续的内存空间来操作,如数组(vector)结构。所以要使用快排,首先需要将map转换为vector,这涉及到数据的复制和重新组织,会带来额外的时间和空间开销。
    • 排序范围的低效性
      • 快排会对整个数组进行排序。然而在特定场景下,可能只需要维护k个有序的序列,这意味着快排做了很多可能不必要的工作。例如,如果有一个很大的数据集,但最终只关心其中k个最大值或者最小值组成的有序序列,快排会对整个数据集排序,包括那些不需要的部分,这在时间复杂度上是不高效的。
  2. 优先级队列的优势
    • 针对性的维护有序性
      • 优先级队列(如最小堆或最大堆实现)可以直接聚焦于维护k个有序的序列。例如,如果是一个最小堆实现的优先级队列,它自然地会将最小的元素放在堆顶。当插入新元素时,它可以根据元素的优先级(对于最小堆就是比较大小)快速地确定新元素是否属于这k个有序序列(比如新元素比堆顶元素小,就可以忽略它;如果比堆顶元素大,就可以替换堆顶元素并且调整堆结构来保持有序性)。
    • 高效的操作
      • 优先级队列的插入和删除操作在维护k个有序序列的场景下时间复杂度相对较低。例如,基于堆实现的优先级队列,插入和删除操作的时间复杂度通常是(n是堆中的元素个数),而快排的平均时间复杂度是,并且快排是对整个数组进行操作,优先级队列可以只针对k个元素进行操作,所以在这种场景下优先级队列更加高效。

为什么用小顶堆? 

  1. 大顶堆的问题
    • 定义大小为 k 的大顶堆,每次移动更新弹出最大元素,这样难以直接保留前 k 个高频元素,因为持续弹出最大元素会导致无法准确留存所需的前 k 个元素。
    • 使用大顶堆时,若要按常规操作会对所有元素排序,难以做到仅排序 k 个元素来满足获取前 k 个高频元素的需求。
  2. 小顶堆的优势
    • 因为要统计最大前 k 个元素,小顶堆每次弹出最小元素,经过不断更新操作后,最终小顶堆里积累的就是前 k 个最大元素,能满足获取前 k 个高频元素的要求.

(引用:数据结构:大顶堆、小顶堆-CSDN博客

 优先级队列:

  1. 定义与概念
    • 优先级队列(Priority Queue)是一种数据结构,它与普通队列不同的是,元素被赋予了优先级。当访问或删除元素时,具有最高(或最低,取决于具体实现)优先级的元素会首先被处理。它就像是一个排队系统,其中某些人(元素)可以因为更重要(更高优先级)而插队到前面。
  2. 存储结构与实现方式
    • 基于数组或链表实现:可以通过数组或链表来存储元素,但这两种方式在插入和删除操作时效率可能较低。
    • 基于堆实现(常见):在实际应用中,优先级队列通常基于堆(Heap)数据结构来实现。堆是一种完全二叉树,分为最大堆和最小堆。在最大堆中,父节点的值大于或等于子节点的值;在最小堆中,父节点的值小于或等于子节点的值。通过这种结构,堆顶元素(根节点)就是优先级最高(最大堆为最大值,最小堆为最小值)的元素。
  3. 操作特点
    • 插入(Push)操作:当向优先级队列中添加一个元素时,会根据元素的优先级将其放置在合适的位置。如果是基于堆实现的优先级队列,插入操作可能涉及到向上调整堆的操作,以维护堆的性质。
    • 删除(Pop)操作:删除操作通常会移除优先级最高的元素(堆顶元素)。在基于堆的优先级队列中,删除后需要进行向下调整堆的操作,来保证堆的性质不变。
    • 获取队首元素(Top)操作:可以获取优先级最高的元素,但不移除它。这在只需要查看最高优先级元素而不进行删除操作时很有用。
  4. 应用场景
    • 任务调度:在操作系统中,任务可以有不同的优先级,优先级队列可以用来安排任务的执行顺序,确保高优先级的任务先被执行。
    • 数据压缩算法:如哈夫曼编码,通过构建哈夫曼树(可以利用优先级队列来构建)来实现高效的数据压缩,其中频率较高(优先级高)的字符编码较短。
    • 路径搜索算法:在一些路径搜索算法(如 A * 算法)中,使用优先级队列来存储待探索的节点,根据节点到目标的估计成本(优先级)来决定下一个探索的节点,有助于更高效地找到最优路径。
  5. 初始化:
    对于基本数据类型,如intdouble等,可以简单地指定元素类型来创建优先级队列,它会自动按照默认的比较规则(对于数值类型是从大到小,形成最大堆)初始化。
    示例:priority_queue<int> pq;创建了一个存储整数的优先级队列pq,初始为空,默认是最大堆,即队首元素是当前队列中的最大值。
  6. 指定容器和比较函数初始化(自定义比较规则)
    class mycomparison {
    public:
        bool operator()(const int& lhs, const int& rhs) {
            return lhs > rhs;
        }
    };
  7. 比较函数的作用和逻辑

当定义一个比较函数(例如class mycomparison中的operator()函数)来构建最小堆时,返回lhs > rhs(其中lhsrhs是比较的两个元素)是因为这个返回值的语义在优先级队列和堆的实现中是这样被解释的:

  1. 如果返回true(即lhs > rhs),这意味着在堆的结构中,lhs应该被放置在比rhs“更靠下” 的位置。因为在最小堆中,我们希望较小的元素更靠近堆顶,所以当lhs大于rhs时,就认为lhs的优先级较低,应该在rhs的下方。
  2. 例如,假设有两个节点AB,如果比较函数返回A > B,那么在构建最小堆的过程中,A会被放置在B的下方,以满足最小堆的性质,即较小的元素更靠近堆顶。

在使用自定义比较函数来构建最大堆时,比较函数应该返回lhs < rhs(假设lhsrhs是比较的两个元素)来确定元素在堆中的排列顺序。

  1. 原因是,当返回true(即lhs < rhs)时,在堆的结构中,lhs会被放置在比rhs“更靠下” 的位置。因为在最大堆中,我们希望较大的元素更靠近堆顶,所以当lhs小于rhs时,就认为lhs的优先级较低,应该在rhs的下方。
  2. 例如,假设有两个节点AB,如果比较函数返回A < B,那么在构建最大堆的过程中,A会被放置在B的下方,以此来满足最大堆的性质,即较大的元素更靠近堆顶。

代码里 return lhs.second > rhs.second; 和 priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pri_que; 需要额外思考一下。

class Solution {
public:
    // 小顶堆
    class mycomparison {
    public:
        bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
            return lhs.second > rhs.second;//比较权重值即为频率,左大返回True
        }
    };
    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;

    }
};

总结:

主要是了解了栈,双端队列,优先队列的使用,其中 347.在理解上稍微麻烦一些。
 

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

这个问题有两个陷阱:

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


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

标签:第十一天,优先级,队列,rhs,元素,随想录,que,求值,lhs
From: https://blog.csdn.net/qq_53853175/article/details/143674852

相关文章

  • 代码随想录算法训练营第十天 | 232.用栈实现队列+225. 用队列实现栈+20. 有效的括号+1
    加入训练营有点晚,没跟上任务就开始有些偷懒没有真的认真开始,从第十天开始下决心认真完成每天任务还有博客记录学习过程。栈与队列理论基础首先是学习了栈和队列的理论基础,队列 先进先出,栈 先进后出。栈 以底层容器完成其所有的工作,且对外接口统一,有.push,.pop等,不提供......
  • 代码随想录——二叉树-12.平衡二叉树
    自顶向下递归(前序遍历)这种方法是一开始想到的,虽然ac了但是对于它的本质却不清不楚,不知道时间复杂度,不知道属于前序遍历。思路首先得到root节点的左右子树的深度(左右),若深度差绝对值大于1(中),则root为根的树不是平衡二叉树;否则继续递归root的左右子树,其左右子树都是平衡二叉树......
  • 代码随想录——二叉树-11.完全二叉树的节点个数
    思路一、层序遍历,时间复杂度O(n)二、利用完全二叉树性质,时间复杂度O(logn*logn)(小于O(n))完全二叉树性质:若树深度为h,则前h-1层节点都达到最大值。第h层节点都集中在最左侧的位置完全二叉树要么1.是满二叉树2.最后一层没满满二叉树计算节点数太方便了,直接用公式2^h-1。......
  • 代码随想录之滑动窗口、Java日期api、集合(11.4-11.11)
    代码1、长度最小的子数组⭐使用滑动窗口的思想,外层循环控制结束位置j,内层循环控制起始位置i,看似是双层循环,但时间复杂度是o(2n)。 2、水果成篮自己想法:使用backet1和backet2表示篮子1和篮子2;使用backet1Account和backet2Account分别表示两个篮子里水果的数量,内层循环将i指针......
  • (代码随想录)132. 分割回文串 II(动态规划)
    132.分割回文串II这一题直接将我打回cv工程师的原型除了dp还要定义一个辅助数组,用于表示i区间到j区间是否为回文串. 动规五部曲1.确定dp含义dp[i]表示0到i之间的字符串需要切割的最小次数2.确定递推公式第一种就是0到i之间直接就是一个回文串,那么直接dp[i]=0......
  • (代码随想录)leetcode300. 最长递增子序列
    自己还是写不出来[笑哭]思路错了,自己死要去只遍历一遍代码随想录答案:classSolution{public:intlengthOfLIS(vector<int>&nums){if(nums.size()<=1)returnnums.size();vector<int>dp(nums.size(),1);//所有元素都是1长度//dp[i]......
  • 代码随想录算法训练营第19天|235. 二叉搜索树的最近公共祖先 ,701.二叉搜索树中的插入
    235.二叉搜索树的最近公共祖先文章链接:https://programmercarl.com/0235.二叉搜索树的最近公共祖先.html题目链接:https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-search-tree/思路:利用二叉搜索树的特性,当第一次遇到在[p,q]区间或[q,p]区间的元素的节点,则......
  • 代码随想录算法训练营第18天| 530.二叉搜索树的最小绝对差, 501.二叉搜索树中的众数 , 2
    530.二叉搜索树的最小绝对差文章链接:https://programmercarl.com/0530.二叉搜索树的最小绝对差.html视频链接:https://www.bilibili.com/video/BV1DD4y11779/?vd_source=6cb513d59bf1f73f86d4225e9803d47b题目链接:https://leetcode.cn/problems/minimum-absolute-difference-in......
  • 代码随想录算法训练营day41| 188.买卖股票的最佳时机IV 309.最佳买卖股票时机含冷冻
    学习资料:https://programmercarl.com/0188.买卖股票的最佳时机IV.html#算法公开课动态规划之股票系列(2)主要是要分持股状态来讨论各种情况,并由前一天的情况来讨论今天的金额学习记录:188.买卖股票的最佳时机IV(相当于2k+1维度)点击查看代码classSolution:defmaxProfit(s......
  • 代码随想录算法训练营第十七天| 654.最大二叉树 , 617.合并二叉树 , 700.二叉搜索树中的
    654.最大二叉树文章链接:https://programmercarl.com/0654.最大二叉树.html题目链接:https://leetcode.cn/problems/maximum-binary-tree/description/classSolution{public:TreeNode*traversal(vector<int>&nums,intleft,intright){if(left>=right)ret......