算法重温(十三): 回归基础数据结构之栈与队列

1. 写在前面

今天这篇文章复习栈和队列的相关题目了,栈和队列也是两种非常重要的数据结构,在很多地方都会看到,比如非常重要的dfs和bfs中就是分别用的这两个结构,关于这块的题目,我之前刷的不多, 但是这里有几个很重要的结构,单调栈, 单调队列,一些经典的中等偏上的题目要靠它们来搞定,所有这里会重点看看这几个东西的使用。

关于栈,我们需要了解:

  • 特点: 栈的最大特点就是后进先出(LIFO)。对于栈中的数据来说,所有操作都是在栈的顶部完成的,只可以查看栈顶部的元素,只能够向栈的顶部压⼊数据,也只能从栈的顶部弹出数据。
  • 实现:利用一个单链表来实现栈的数据结构。而且,因为我们都只针对栈顶元素进行操作,所以借用单链表的头就能让所有栈的操作在 O(1) 的时间内完成。 如果打算用一个数组外加一个指针来实现相似的效果,那么,一旦数组的长度发生了改变,哪怕只是在最后添加一个新的元素,时间复杂度都不再是 O(1),而且,空间复杂度也得不到优化。
  • 复杂度: 添加、删除元素皆为O(1), 查询的话时间复杂度O(N), python中可以通过一个列表进行实现, append操作和pop操作就是入栈和出栈操作。
  • 应用场景:在解决某个问题的时候,只要求关心最近一次的操作,并且在操作完成了之后,需要向前查找到更前一次的操作。
  • 注意:栈是许多 LeetCode 中等难度偏上的题目里面经常需要用到的数据结构,掌握好它是十分必要的。

关于队列,我们需要了解:

  • 特点:和栈不同,队列的最大特点是先进先出(FIFO),就好像按顺序排队一样。对于队列的数据来说,我们只允许在队尾查看和添加数据,在队头查看和删除数据。
  • 复杂度: 添加删除元素皆为O(1), 查询的话O(N), python里面的话好像是可以import queue, 用queue.Queue建立一个队列。
  • 应用场景:直观来看,当我们需要按照一定的顺序来处理数据,而该数据的数据量在不断地变化的时候,则需要队列来帮助解题。在算法面试题当中,广度优先搜索(Breadth-First Search)是运用队列最多的地方。
  • 双端队列(Deque): 可以理解成Stack和Queue的组合, 两端都可以进出的Queue, 插入和删除是O(1)的操作, 查询依然是O(N), 这个python里面可以使用collections里面的deque实现, 这个比较好用。
    • 实现:可以借助双链表来实现队列。和普通队列不同的就是在队列的首尾都可以在O(1)的时间内进行数据的查看、添加和删除。
    • 应用场景:双端队列最常用的地方就是实现一个长度动态变化的窗口或者连续区间,而动态窗口这种数据结构在很多题目里都有运用。
  • 优先队列(Priority Queue): 插入操作O(1), 取出操作O(logN) - 按照元素的优先级取出元素, 可以理解成VIP, 即可以根据元素的重要性取出元素, queue里面也有这个东西。

python里面实现双端队列可以使用collections模块里面的deque类,deque 是双边队列(double-ended queue),具有队列和栈的性质,在 list 的基础上增加了移动、旋转和增删等。常用方法:

d = collections.deque([])
d.append('a') # 在最右边添加一个元素,此时 d=deque('a')
d.appendleft('b') # 在最左边添加一个元素,此时 d=deque(['b', 'a'])
d.extend(['c','d']) # 在最右边添加所有元素,此时 d=deque(['b', 'a', 'c', 'd'])
d.extendleft(['e','f']) # 在最左边添加所有元素,此时 d=deque(['f', 'e', 'b', 'a', 'c', 'd'])
d.pop() # 将最右边的元素取出,返回 'd',此时 d=deque(['f', 'e', 'b', 'a', 'c'])
d.popleft() # 将最左边的元素取出,返回 'f',此时 d=deque(['e', 'b', 'a', 'c'])
d.rotate(-2) # 向左旋转两个位置(正数则向右旋转),此时 d=deque(['a', 'c', 'e', 'b'])
d.count('a') # 队列中'a'的个数,返回 1
d.remove('c') # 从队列中将'c'删除,此时 d=deque(['a', 'e', 'b'])
d.reverse() # 将队列倒序,此时 d=deque(['b', 'e', 'a'])

好了, 理论和强大的实现工具介绍完, 整理几个经典的高频题目了,很重要哟!

2. 题目思路和代码梳理

2.1 栈(stack)

  • LeetCode20: 有效的括号: 这是一个使用栈来解决的经典问题, 思路也比较简单,就是遍历字符串, 如果遇到左括号系列了,就入栈, 如果遇到右括号系列了,就出栈一个元素,看看是不是能够匹配上,如果不能匹配,直接返回False, 如果栈空,也返回False,说明右括号多了。 当遍历完毕,如果此时栈空就返回True,说明匹配成功, 如果栈非空,返回False,说明左括号多了。
    算法重温(十三): 回归基础数据结构之栈与队列

  • LeetCode1249: 移除多余的括号: 这个题是听一个算法课时,上课老师偶然提到的一个题目,说是面试常考的一个题目,但是叫了几个感觉比较自信的几个同学去做,结果都没有做出来, 然后老师也陷入了沉思,说了一句话,感触挺深的,我们都拼命的去学习算法的屠龙之术,自认为掌握了这些高级知识,就能够找到好工作,可往往,我们却连最基本的问题都还没有搞明白。说完,之后,我也去刷了一下, 大约用了20分钟A掉,我觉得,不是因为我基础扎实,而是因为我今天刚学习了栈, 立马就想到了用栈来解决哈哈。

    这个问题我的思路是这样, 遍历一遍数组, 如果遇到左括号, 先默认有效,加入结果数组,然后入栈, 如果用到右括号, 那么就看看栈里面有没有左括号和他匹配,如果有, 那么当前右括号就有效, 加入结果数组,如果是普通字符,直接加入结果数组。 那么这样遍历完了之后,就能把字符和右括号正确的处理了, 剩下的是多余的左括号没有处理,因为上面是默认有效, 可能没有右括号和他匹配, 而这些其实都在栈里面存着呢? 所以,当栈非空的时候, 我又从尾部遍历,遇到左括号,就删掉,然后出栈个左括号, 这样栈空,就把多余的左括号删掉了。
    算法重温(十三): 回归基础数据结构之栈与队列

  • Leetcode1047: 删除字符串中的所有相邻重复项: 这个题目和上面这个其实很像, 也是在匹配, 思路就是遍历字符串,如果发现当前字符和栈顶元素相等, 那么就抵消掉,否则,入栈即可。最后返回栈里面的元素。 这里面有两个技巧, 一个是for循环中的continue,我突然悟到了原来continue有这么一个功效,跳过元素原来是这么回事啊(之前,我记得都是for循环里面如果符合某个条件,不想执行当前后面的逻辑了,直接i+=1往后走的, 但python里面,由于经常刷的原因吧,让我突然想到了continue了,哈哈,真实熟能生巧哇), 另一个是栈里面设置一个哨兵的操作,这个也是突然想到的,之前应该是见过,这东西能防止栈的空越界
    算法重温(十三): 回归基础数据结构之栈与队列

  • LeetCode150: 逆波兰表达式求值: 原来这个逆波兰表达式求值,就是一个后缀转中缀表达式计算的问题呀, 这个问题依然是栈的经典解决问题。 这个的思路,就是遍历一遍数组,如果遇到了运算符了,那么就出栈两个元素做相应的运算,然后把运算结果存入栈中。否则,就直接把字符转成整数存到栈里面。遍历结束之后, 栈中的结果就是最后的结果了。
    算法重温(十三): 回归基础数据结构之栈与队列
    这里学习到的一个东西int()函数强转,因为遇到/的时候,要得到整数,一开始我用的那个//, 这个东西是下取整, 但是发现遇到负数的时候,就不好使了,这里要求的是两数相除, 得到正数结果的时候要下取整, 得到负数结果的时候要上取整。然后用//就不行了,结果int()强转就是干这个活的,学习到了。int()对于负数是向上取整,对于正数是向下取整。所以以后,如果除法要得到整数的时候,还应该记住有个int函数了。

  • LeetCode84: 柱状图中的最大矩形: 这是一个非常典型的利用单调栈来解决顺序和大小综合问题的题目, 单调栈特别适合解决两头大小决定中间值大小的题目。做题很重要的一个思路就是把复杂的问题化简单, 或者要从整体到局部, 先拿其中一个来看, 会有什么操作, 由于是往两边绘制图形, 所以最重要的就是从一个柱子出发, 往左右找边界去, 即我到哪里就不能再画下去了, 会发现, 往两边走, 碰到比他矮的柱子, 就无法再画图了。 所以这个题的关键,就是遍历每一根柱子,去找到它的左右边界,然后计算当前围成的矩形面积。 所以两边的边界决定了面积的大小, 单调栈试试。我们维护一个单调递增栈,遍历每一根柱子,然后发现当前的柱子比栈顶的柱子高, 那么就把当前的柱子进栈,这样,栈里面的元素是单调递增的,如果当前的柱子,比栈顶的柱子矮了, 那么此时对于栈顶的元素来说,就同时找到了它的左右边界, 左边界就是栈顶元素压着的那根柱子,右边界就是当前遍历的柱子。此时,出栈栈顶元素,计算面积并更新。拿个图看看:

    算法重温(十三): 回归基础数据结构之栈与队列
    所以,一定要弄清楚这个过程是是如何找的左右边界,我们说,左右边界,就是往左和往右找第一个比当前柱子矮的元素, 而单调栈的作用就是从开始,元素保持单调往上,其实就是保存每个栈顶元素的左边界, 而一旦遇到当前柱子比栈顶柱子矮了,就找到了右边界,这时候计算的是栈顶柱子能围成的最大面积,当前遍历的柱子是右边界。 明白了这一点,就可以写代码了。当然下面代码里面还有两个骚操作,就是两头哨兵的作用。左边的哨兵是为了防止栈空越界,而右边的哨兵是为了防止柱子如果全是单调递增的时候,找不到右边界了,非常的巧妙。具体看代码吧:
    算法重温(十三): 回归基础数据结构之栈与队列

  • LeetCode42: 接雨水: 这个要维护一个单调递减栈,因为当两边的柱子都大于中间位置的时候,这个地方才能形成雨水。所以这个是对于当前柱子,要去左右两边找比他高的柱子,然后计算雨水形成的面积。其他的就和上面一样的思路了, 遍历每根柱子,如果发现当前柱子的高度小于栈顶柱子, 那么就入栈,一旦发现,当前的柱子比栈顶柱子高度高了,出栈栈顶元素,计算栈顶元素这里围成的雨水面积累加到结果(这里是个循环做,直到当前柱子高度比栈顶的低了,才停,把当前的入栈)。
    算法重温(十三): 回归基础数据结构之栈与队列

2.2 队列(Queue)

  • LeetCode239: 滑动窗口最大值: 这个题是单调队列的经典题目, 这些工具我发现,如果真能用上,会非常方便,但是如果想不起来, 就会很难,所以还是得多练多用才行。 这个题要用一个单调递减的双端队列, 在遍历前面那个窗口的时候, 就把窗口内元素按照递减顺序放到一个双端队列里面, 这时候, 队头元素就是最大值, 当窗口后移, 后面一个元素加入的时候, 需要在添加之前, 把队列里面把它小的移除掉, 因为有新元素的加入后, 比他小的不可能再成为最大值, 且窗口是往右滑动的, 我们必须始终保证对头元素是队列中最大的值, 而保证的方式就是在添加一个值之前,比他小的都要被移除掉,然后再添加这个值。 因为每一次滑动窗口,总是前面那个元素出去, 后面一个元素进来, 而这个双端队列的作用就是后面这个元素进来的时候,想办法把前面比他小的都移出去, 这样它就有可能成为当前窗口的最大或者下一次移动进去的窗口的最大了。这样,就相当于队首元素就是每次滑动的最大值。
    算法重温(十三): 回归基础数据结构之栈与队列
    代码如下:
    算法重温(十三): 回归基础数据结构之栈与队列

3 小总

依然是一天的时间,快速复习了栈和队列的几道经典题目, 栈这个工具是非常好用的,括号匹配, 后缀表达式转中缀表达式,单调栈等,所以要把这个东西牢记于心,在栈中,先加入一个哨兵的操作,有利于屏蔽掉栈空时发生的越界错误。 队列这边,目前刷过的题目较少,只有一个单调队列的题目,也是非常经典。 下面把所有题目整理如下:

栈和队列:

上一篇:Java容器学习-栈和队列


下一篇:C++ | STL 浅谈deque容器