拉勾网教育课程之《数据结构与算法面试宝典》

数据结构之一题多解

首先,分析题目的时候,要特别注意以下 4 点,归纳为“四步分析法”。

模拟   
	模拟题目的运行    
规律  
	尝试总结出题目的一般规律和特点
匹配
	找到符合这些特点的数据结构和算法
边界
	考虑特殊情况

栈的特性与使用

特点: 先进后出

案例1:判断字符串括号是否合法

判断字符串括号是否合法
字符串中只有字符“(”和“)”。合法字符串需要括号可以配对。
比如:输入:"()" 输出:true
解释:() ()() (())都是合法的
实现一个函数,来判断给定的字符串是否合法

【解题思路】

模拟   
	模拟题目的运行    
		字符串 "()()(())"
规律  
	尝试总结出题目的一般规律和特点
		每个左括号"("或者右括号")"都要完成配对 才是合法的
		配对可通过消除法来消除合法的括号,如果最后没有任何字符 就是合法字符串
		奇数长度的字符串总是非法的
匹配
	找到符合这些特点的数据结构和算法
		可以使用栈进行消除法的模拟
边界
	考虑特殊情况
		字符串为空
		字符串只有一个或者为奇数
当遇到左括号"(" 进行压栈  
当遇到右括号")"进行弹栈

【代码】

class Solution:
  def isValid(self, s):
    if not s or len(s) == 0:
      return True
    if len(s) % 2 == 1:
      return False
    
    t = []
    for c in s:
      if c == '(':
        t.append(c)
      elif c == ')':
        if len(t) == 0:
          return False
        t.pop()
      else:
        return False
    return len(t) == 0

solution = Solution()

assert solution.isValid("")
assert not solution.isValid("(")
assert not solution.isValid(")")

复杂度分析: 每个字符只入栈一次,出栈一次,所以时间复杂度O(N),而空间复杂度O(N),因为最差情况下可能会把整个字符串都入栈。

【深度思考】

  • 深度扩展
    配对&消除
    栈内容一样 实际上没有必要使用栈,只需要记录栈中元素个数。 计数器优化
    栈内容不一样 存放内容

复杂度分析: 每个字符只入栈一次,出栈一次,所以时间复杂度为 O(N),而空间复杂度为 O(1),因为我们已经只用一个变量来记录栈中的内容。

  • 广度扩展
    类似于"()"的 “{}”, “[]” 判断字符串是否有效

案例2:大鱼吃小鱼

【题目】在水中有许多鱼,可以认为这些鱼停放在 x 轴上。再给定两个数组 Size,Dir,Size[i] 表示第 i 条鱼的大小,Dir[i] 表示鱼的方向 (0 表示向左游,1 表示向右游)。这两个数组分别表示鱼的大小和游动的方向,并且两个数组的长度相等。鱼的行为符合以下几个条件:
所有的鱼都同时开始游动,每次按照鱼的方向,都游动一个单位距离;
当方向相对时,大鱼会吃掉小鱼;
鱼的大小都不一样。
输入:Size = [4, 2, 5, 3, 1], Dir = [1, 1, 0, 0, 0]
输出:3
请完成以下接口来计算还剩下几条鱼?

【分析】
对于这道题而言,大鱼吃掉小鱼的时候,可以认为是一种消除行为。只不过与括号匹配时的行为不一样:

  • 括号匹配是会同时把左括号与右括号消除掉;
  • 大鱼吃小鱼,只会把小鱼消除掉。

【解题思路】

模拟
	Size = [4, 3, 2, 1 5], Dir = [0, 1, 0, 0, 0]   # 注意:当鱼的游动方向相同,或者相反时,并不会相遇,此时大鱼不能吃掉小鱼。
规律
	如果两条鱼相对而游时,那么较小的鱼会被吃掉;
	其他情况没有鱼被吃掉。
匹配
	我们发现,下面活下来的鱼的行为(上图红框部分)就是一个栈。每当有新的鱼要进来的时候,就会与栈顶的鱼进行比较。那么我们匹配到的算法就是栈了。
边界
	所有的鱼都朝着一个方向游;
	一条鱼吃掉了其他的所有鱼。

【代码】

def solution(fishSize, fishDirection):
    # write your code in Python 3.6
    fishNumber = len(fishSize)
    if fishNumber <= 1:
        return fishNumber
    left = 0
    right = 1
    t = []
    for i in range(0, fishNumber):
      # 当前鱼的情况:1,游动的方向;2,大小
      curFishDirection = fishDirection[i]
      curFishSize = fishSize[i]

      # 当前的鱼是否被栈中的鱼吃掉了
      hasEat = False
      # 如果栈中还有鱼,并且栈中鱼向右,当前的鱼向左游,那么就会有相遇的可能性
      while len(t) > 0 and fishDirection[t[-1]] == right and curFishDirection == left:
        # 如果栈顶的鱼比较大,那么把新来的吃掉
        if fishSize[t[-1]] > curFishSize:
          hasEat = True
          break
        # 如果栈中的鱼较小,那么会把栈中的鱼吃掉,栈中的鱼被消除,所以需要弹栈。
        t.pop()
      # 如果新来的鱼,没有被吃掉,那么压入栈中。
      if not hasEat:
        t.append(i)
    return len(t)

复杂度分析: 每只鱼只入栈一次,出栈一次,所以时间复杂度O(N),而空间复杂度O(N),因为最差情况下可能把所有的鱼都入栈。

【小结】
接下来我们一起对这道题做一下归纳。可以发现,与例 1 相比,它们的消除行为有所不同:

  • 在例 1 中,消除行为表现为配对的两者都会消除;
  • 在例 2 中,消除行为表现为配对的两者中有一个会被消除。

此外,在与 例 1 的比较中,可以发现,栈中的内容也有所不同:

  • 在例 1 中,栈中的存放的就是内容本身;
  • 在例 2 中,栈中存放的只是内容的索引,可以通过索引得到内容。

在这里插入图片描述

单调栈的解题技巧

单调栈的定义: 单调栈就是指栈中的元素必须是按照升序排列的栈,或者是降序排列的栈。对于这两种排序方式的栈,还给它们各自取了小名。递增栈 递减栈

在这里插入图片描述
案例3:找出数组中右边比我小的元素

一个整数数组 A,找到每个元素:右边第一个比我小的下标位置,没有则用 -1 表示。
输入:[5, 2] 输出:[1, -1]
因为元素 5 的右边离我最近且比我小的位置应该是 A[1],最后一个元素 2 右边没有比 2 小的元素,所以应该输出 -1。

【分析】
一个数总是想与左边比它大的数进行匹配,匹配到了之后,小的数会消除掉大的数。
当你发现要解决的题目有两个特点:

  • 小的数要与大的数配对
  • 小的数会消除大的数

【解题思路】
在这里插入图片描述

Step 1. 首先将 A[0] = 1 的下标 0 入栈。
Step 2. 将 A[1] = 2 的下标 1 入栈。满足单调栈。
Step 3. 将 A[2] = 4 的下标 2 入栈。满足单调栈。
Step 4. 将 A[3] = 9 的下标 3 入栈。满足单调栈。
Step 5. 将 A[4] = 4 的下标 4 入栈时,不满足单调性,需要将 A[3] = 9 从栈中弹出去。下标 4 将栈中下标 3 弹出栈,记录 A[3] 右边更小的是 index = 4。
Step 6. 将 A[5] = 0 的下标 5 入栈时,不满足单调性,需要将 A[4] = 4 从栈中弹出去。下标 5 将下标 4 弹出栈,记录 A[4] 右边更小的是 index = 5。A[5] = 0 会将栈中的下标 0, 1, 2 都弹出栈,因此也需要记录相应下标右边比其小的下标为 5,再将 A[5] = 0 的下标 5 放入栈中。
Step 7. 将 A[6] = 5 的下标 6 放入栈中。满足单调性。
Step 8. 此时,再也没有元素要入栈了,那么栈中的元素右边没有比其更小的元素。因此设置为 -1.

【代码】

class Solution:
  def findRightSmall(self, A):
    if not A or len(A) == 0:
      return []

    # 结果数组
    ans =[0] * len(A)

    # 注意,栈中的元素记录的是下标
    t = []

    for i in range(0, len(A)):
      x = A[i]
      # 每个元素都向左遍历栈中的元素完成消除动作
      while len(t) > 0 and A[t[-1]] > x:
        # 消除的时候,记录一下被谁消除了
        ans[t[-1]] = i
        # 消除时候,值更大的需要从栈中消失
        t.pop()

      # 剩下的入栈
      t.append(i)

    # 栈中剩下的元素,由于没有人能消除他们,因此,只能将结果设置为-1。
    while len(t) > 0:
      ans[t[-1]] = -1
      t.pop()

    return ans


# 测试代码
solution = Solution()
assert [1,-1] == solution.findRightSmall([5,4])
assert [5, 5, 5, 4, 5, -1, -1] ==  solution.findRightSmall([1, 2, 4, 9, 4, 0, 5])

复杂度分析: 每个元素只入栈一次,出栈一次,所以时间复杂度O(N),而空间复杂度O(N),因为最差情况可能会把所有的元素都入栈。

案例4:字典序最小的 k 个数的子序列

【题目】给定一个正整数数组和 k,要求依次取出 k 个数,输出其中数组的一个子序列,需要满足:1. 长度为 k;2.字典序最小。
输入:nums = [3,5,2,6], k = 2 输出:[2,6]
解释:在所有可能的解:{[3,5], [3,2], [3,6], [5,2], [5,6], [2,6]} 中,[2,6] 字典序最小。

【分析】
一个特点:一旦发现更小的数时,就可以把前面已经放好的数扔掉,然后把这个最小的数放在最前面。

【解题思路】

假定输入为[9, 2, 4, 5, 1, 2, 3, 0], k = 3.输出能构成的最小的序列。
Step 1. 首先将 9 加入栈中。
Step 2. 当 2 要入栈时,不满足单调栈,需要将数字 9 出栈。由于后面还有足够多的元素,可以把 9 弹栈,再将 2 入栈。
Step 3. 将 4 入栈,满足单调性。
Step 4. 再将元素 5 入栈,满足单调性。
Step 5. 将要入栈的元素 1,会弹出栈中所有元素。
Step 6. 将元素 1 入栈。
Step 7. 将元素 2 入栈,满足单调性。
Step 8. 将元素 3 入栈,满足单调性。
Step 9. 将 0 入栈时,需要将栈顶元素 3 弹出。
Step 10. 将 0 入栈,不满足单调性。这是因为,如果 0 将前面的元素再弹栈,余下的元素个数就小于 k = 3 个了。所以不能再利用单调性来弹出栈中元素了。

【代码】

class Solution:
  def findSmallSeq(self, nums, k):
    if not nums or len(nums) == 0 or k <= 0:
      return []

    ans = [0] * k
    s = []

    # 这里生成单调栈
    for i in range(0, len(nums)):
      x = nums[i]
      left = len(nums) - i
      # 注意我们想要提取出k个数,所以注意控制扔掉的数的个数
      while len(s) > 0 and (len(s) + left) > k and s[-1] > x:
        s.pop()
      s.append(x)

    # 如果递增栈里面的数太多,那么我们只需要取出前k个就可以了。
    # 多余的栈中的元素需要扔掉。
    while len(s) > k:
      s.pop()

    # 把k个元素取出来,注意这里取的顺序!
    for i in range(k-1, -1, -1):
      ans[i] = s[-1]
      s.pop()

    return ans

# 测试代码
solution = Solution()
assert [1,2,3] == solution.findSmallSeq([9,2,4,5,1,2,6,3,100,4], 3)
assert [1,2] == solution.findSmallSeq([9,2,4,5,1,2,6,3,100,4], 2)
assert [1] == solution.findSmallSeq([9,2,4,5,1,2,6,3,100,4], 1)

复杂度分析:每个元素只入栈一次,出栈一次,所以时间复杂度为 O(N),而空间复杂度为 O(N),因为最差情况可能会把所有元素都入栈。

【小结】
写完代码之后,我们需要对代码和题目做一个小结:

  • 较小的数消除掉较大的数的时候,使用递增栈;
  • 要注意控制剩下的元素的个数;

如果更进一步推而广之,会发现从简单栈到单调栈,层层推进的过程中,不停变化就是入栈与出栈的时机。

总结

在这里插入图片描述

队列

FIFO 队列

FIFO 有两个特点:

  • push 元素时,总是将元素放在队列尾部;
  • pop 元素时,总是将队列首部的元素扔掉。

案例1:二叉树的层次遍历

【题目】从上到下按层打印二叉树,同一层结点按从左到右的顺序打印,每一层打印到一行。
输入在这里插入图片描述
输出:[[3], [9, 8], [6, 7]]

【分析】

  • 模拟
    在这里插入图片描述
  • 规律
    (1)广度遍历(层次遍历):由于二叉树的特点,当我们拿到第 N 层的结点 A 之后,可以通过 A 的 left 和 right 指针拿到下一层的结点
    (2)顺序输出:每层输出时,排在左边的结点,它的子结点同样排在下一层最左边。
  • 匹配
    当你发现题目具备广度遍历(分层遍历)和顺序输出的特点,就应该想到用FIFO 队列来试一试。
  • 边界
    关于二叉树的边界,需要考虑一种空二叉树的情况。当遇到一棵空的二叉树,有两种解决办法。
    (1)特殊判断:如果发现是一棵空二叉树,就直接返回空结果。
    (2)制定一个规则:不要让空指针进入到 FIFO 队列。
Step1. 在一开始首先将根结点 3 加入队列中。
Step 2. 开始新一层遍历,记录下当前队列长度 QSize=1,初始化当前层存放结果的[]。
Step 3. 将结点 3 出队,然后将其放到当前层中。
Step 4. 再将结点 3 的左右子结点分别入队。QSize = 1 的这一层已经处理完毕。
Step 5. 开始新一层的遍历。记录下新一层的 QSize = 2,初始化新的当前层存放当前层结果的[]。
Step 6. 从队列中取出 9,放到当前层结果中。结点 9 没有左右子结点,不需要继续处理左右子结点。
Step 7. 从队列中取出 8,放到当前层结果中。
Step 8. 将结点 8 的左右子结点分别入队。此时,QSize = 2 的部分已经全部处理完成。
Step 9.开始新一层的遍历,记录下当前队列中的结点数 QSize = 2,并且生成存放当前层结果的 list[]。
Step 10. 将队首结点 6 出队放到当前层结果中。结点 6 没有左右子结点,没有元素要入队。
Step 11. 将队首结点 7 出队,放到当前层结果中。结点 7 没有左右子结点,没有元素要入队。
结束,返回我们层次遍历的结果。

【代码】

# 本题的测试平台链接:
# https://leetcode-cn.com/problems/binary-tree-level-order-traversal/
# Definition for a binary tree node.
# class TreeNode(object):
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None
import Queue as queue
class Solution(object):
    def levelOrder(self, root):
        """
        :type root: TreeNode
        :rtype: List[List[int]]
        """
        # 生成FIFO队列
        Q = queue.Queue()

        # 如果结点不为空,那么放到队列中
        if root:
            Q.put(root)
        # 需要返回的结果
        ans = []
        # 依次处理,直到遍历完所有的元素
        while not Q.empty():
            # 拿到当前层结点的个数
            qSize = Q.qsize()
            # 存放当前层遍历的结果
            curLevel = []

            # 依次取出当前层的结点
            for i in range(qSize):
                # 把当前层的结点放到curLevel里面。
                cur = Q.get()
                curLevel.append(cur.val)

                # 按照顺序取出下一层
                if cur.left:
                    Q.put(cur.left)
                if cur.right:
                    Q.put(cur.right)
            # 把当前层添加到结果里面
            ans.append([x for x in curLevel])

        # 返回最终的结果
        return 

复杂度分析: 由于二叉树的每个结点,我们都只访问了一遍,所以时间复杂度为 O(n)。如果不算返回的数组,那么空间复杂度为 O(k),这里的 k 表示二叉树横向最宽的那一层的结点数目。

案例2:循环队列

设计一个可以容纳 k 个元素的循环队列。需要实现以下接口:

class MyCircularQueue {
    // 参数k表示这个循环队列最多只能容纳k个元素
    public MyCircularQueue(int k);
    // 将value放到队列中, 成功返回true
    public boolean enQueue(int value);
    // 删除队首元素,成功返回true
    public boolean deQueue();
    // 得到队首元素,如果为空,返回-1
    public int Front();
    // 得到队尾元素,如果队列为空,返回-1
    public int Rear();
    // 看一下循环队列是否为空
    public boolean isEmpty();
    // 看一下循环队列是否已放满k个元素
    public boolean isFull();
}
# [622] 设计循环队列
#
# https://leetcode-cn.com/problems/design-circular-queue/description/
#
# algorithms
# Medium (42.07%)
# Likes:    155
# Dislikes: 0
# Total Accepted:    43.6K
# Total Submissions: 103.7K
# Testcase Example:  '["MyCircularQueue","enQueue","enQueue","enQueue","enQueue","Rear","isFull","deQueue","enQueue","Rear"]\n' +
  '[[3],[1],[2],[3],[4],[],[],[],[4],[]]'
#
# 设计你的循环队列实现。 循环队列是一种线性数据结构,其操作表现基于
# FIFO(先进先出)原则并且队尾被连接在队首之后以形成一个循环。它也被称为“环形缓冲器”。
# 
# 
# 循环队列的一个好处是我们可以利用这个队列之前用过的空间。在一个普通队列里,一旦一个队列满了,我们就不能插入下一个元素,即使在队列前面仍有空间。但是使用循环队列,我们能使用这些空间去存储新的值。
# 
# 你的实现应该支持如下操作:
# MyCircularQueue(k): 构造器,设置队列长度为 k 。
# Front: 从队首获取元素。如果队列为空,返回 -1 。
# Rear: 获取队尾元素。如果队列为空,返回 -1 。
# enQueue(value): 向循环队列插入一个元素。如果成功插入则返回真。
# deQueue(): 从循环队列中删除一个元素。如果成功删除则返回真。
# isEmpty(): 检查循环队列是否为空。
# isFull(): 检查循环队列是否已满。
# 
# 示例:
# 
# MyCircularQueue circularQueue = new MyCircularQueue(3); // 设置长度为 3
# circularQueue.enQueue(1);  // 返回 true
# circularQueue.enQueue(2);  // 返回 true
# circularQueue.enQueue(3);  // 返回 true
# circularQueue.enQueue(4);  // 返回 false,队列已满
# circularQueue.Rear();  // 返回 3
# circularQueue.isFull();  // 返回 true
# circularQueue.deQueue();  // 返回 true
# circularQueue.enQueue(4);  // 返回 true
# circularQueue.Rear();  // 返回 4
#  
# 提示:
# 所有的值都在 0 至 1000 的范围内;
# 操作数将在 1 至 1000 的范围内;
# 请不要使用内置的队列库。

class MyCircularQueue(object):
    def __init__(self, k):
        """
        :type k: int
        """
        # 第一个元素所在位置
        self.front = 0
        # rear是enQueue可在存放的位置
        # 注意开闭原则
        # [front, rear)
        self.rear = 0
        # 已经使用的元素个数
        self.used = 0
        # 循环队列的存储空间
        self.a = [0] * k
        # 记录最大空量
        self.capacity = k

    def enQueue(self, value):
        """
        :type value: int
        :rtype: bool
        """
        # 如果已经满了,
        if self.isFull():
            return False
        # 如果没有放满,那么a[rear]用来存放新进来的元素
        self.a[self.rear] = value
        # rear向前进
        self.rear = (self.rear + 1) % self.capacity
        # 已经使用的空间
        self.used += 1
        return True

    def deQueue(self):
        """
        :rtype: bool
        """
        # 如果是一个空队列,当然不能出队
        if self.isEmpty():
            return False
        # 注意取模
        self.front = (self.front + 1) % self.capacity
        # 已经存放的元素减减
        self.used -= 1
        # 取出元素成功
        return True

    def Front(self):
        """
        :rtype: int
        """
        # 如果为空,不能取出队首元素
        if self.isEmpty():
            return -1
        # 取出队首元素
        return self.a[self.front]

    def Rear(self):
        """
        :rtype: int
        """
        # 如果为空,不能取出队尾元素
        if self.isEmpty():
            return -1
        # 注意:这里不能使用rear - 1
        # 需要取模
        tail = (self.rear - 1 + self.capacity) % self.capacity
        return self.a[tail]

    def isEmpty(self):
        """
        :rtype: bool
        """
        return self.used == 0

    def isFull(self):
        """
        :rtype: bool
        """
        return self.used == self.capacity


# Your MyCircularQueue object will be instantiated and called as such:
# obj = MyCircularQueue(k)
# param_1 = obj.enQueue(value)
# param_2 = obj.deQueue()
# param_3 = obj.Front()
# param_4 = obj.Rear()
# param_5 = obj.isEmpty()
# param_6 = obj.isFull()

单调队列

单调队列属于双端队列的一种。双端队列与 FIFO 队列的区别在于:

  • FIFO 队列只能从尾部添加元素,首部弹出元素;
  • 双端队列可以从首尾两端 push/pop 元素。

什么是单调队列
首先来看一下单调队列的定义:要求队列中的元素必须满足单调性,比如单调递增,或者单调递减。那么在入栈与出栈的时候,就与普通的队列不一样了。

单调队列在入队的时候,需要满足 2 点:

  • 入队前队列已经满足单调性;
  • 入队后队列仍然满足单调性。

案例 3:滑动窗口的最大值

【题目】给定一个数组和滑动窗口的大小,请找出所有滑动窗口里的最大值。
输入:nums = [1,3,-1,-3,5,3], k = 3
输出:[3,3,5,5]
在这里插入图片描述

【分析】
在这里插入图片描述

Step 1. 首先将元素 1 入队。
Step 2. 再将元素 3 入队。
Step 3. 再将 -1 入队,此时队列长度为 3,可以从 [1, 3, -1] 中得到最大值 3。
Step 4. 将 1 出队,然后将 3 入队,可以得到 [3,-1,3] 的最大值为3。
Step 5. 将 3 出队,然后再将 5 入队,可以得到 [-1, 3, 5] 的最大值为 5。
Step 6. 将 -1 队出,然后再将 3 入队,可以得到 [3,5,3] 的最大值为 5。

我们发现两点:
(1)不停地有元素出队入队
(2)需要拿到队列中的最大值
如果能够在 O(1) 时间内拿到队列中的最大值,那么就可以在 O(N) 时间解决掉这个问题。

【代码】

#
# [239] 滑动窗口最大值
#
# https://leetcode-cn.com/problems/sliding-window-maximum/description/
#
# algorithms
# Hard (49.00%)
# Likes:    670
# Dislikes: 0
# Total Accepted:    93K
# Total Submissions: 189.7K
# Testcase Example:  '[1,3,-1,-3,5,3,6,7]\n3'
#
# 给定一个数组 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
# 
# 
# 
# 提示:
# 
# 
# 1 <= nums.length <= 10^5
# -10^4 <= nums[i] <= 10^4
# 1 <= k <= nums.length
# 
# 
#

from collections import deque

class Solution(object):
    def __init__(self):
        # 单调队列使用双端队列来实现
        self.Q = deque()
    
    def push(self, val):
        """
        # 入队的时候,last方向入队,但是入队的时候
        # 需要保证整个队列的数值是单调的
        # (在这个题里面我们需要是递减的)
        # 并且需要注意,这里是Q[-1] < val
        """
        while self.Q and self.Q[-1] < val:
            self.Q.pop()
        # 将元素入队
        self.Q.append(val)

    def pop(self, val):
        # 出队的时候,要相等的时候才会出队
        if self.Q and self.Q[0] == val:
            self.Q.popleft()

    def maxSlidingWindow(self, nums, k):
        """
        :type nums: List[int]
        :type k: int
        :rtype: List[int]
        """
        ans = []
        for i in range(0, len(nums)):
            self.push(nums[i])
            # 如果队列中的元素还少于k个
            # 那么这个时候,还不能去取最大值
            if i < k - 1:
                continue
            # 队首元素就是最大值
            ans.append(self.Q[0])
            # 尝试去移除元素
            self.pop(nums[i-k+1])
        return ans

复杂度分析: 每个元素都只入队一次,出队一次,每次入队与出队都是 O(1) 的复杂度,因此整个算法的复杂度为 O(n)。

案例 4:捡金币游戏

【题目】给定一个数组 A[],每个位置 i 放置了金币 A[i],小明从 A[0] 出发。当小明走到 A[i] 的时候,下一步他可以选择 A[i+1, i+k](当然,不能超出数组边界)。每个位置一旦被选择,将会把那个位置的金币收走(如果为负数,就要交出金币)。请问,最多能收集多少金币?
输入:[1,-1,-100,-1000,100,3], k = 2
输出:4
解释:从 A[0] = 1 出发,收获金币 1。下一步走往 A[2] = -100, 收获金币 -100。再下一步走到 A[4] = 100,收获金币 100,最后走到 A[5] = 3,收获金币 3。最多收获 1 - 100 + 100 + 3 = 4。没有比这个更好的走法了。

【分析】
在这里插入图片描述
黄色区域就是一个滑动窗口,我们要选的是滑动窗口的最大值。
在这里插入图片描述

Step1. 当 index = 0 时,队列 Q[] 为空,那么 get[0] = A[0]。然后将 A[0] 入队。
Step 2. 当 index = 1 时,get[1] = 队首元素 + A[1] = 1 + -1 = 0。然后将 0 入队
Step 3. 当 index = 2 时,get[2] = 队首元素 + A[2] = 1 - 100 = -99。然后将 -99 入队。
Step 4. 当 index = 3 时,首先将超出范围的元素出队。然后,get[3] = 队首元素 + A[3] = 0 - 1000 = -1000。然后将 -1000 入队。
Step 5. 当 index = 4 时,首先将队列中超出范围的元素出队,然后 get[4] = 队首元素 + A[4] = -99 + 100 = 1。然后再将 1 入队。
接下来我们重点看一下入队,由于 1 比队列中的元素都要大,按照单调队列的定义,所以队列中的元素都被清空。
Step 6. 当 index = 5 时,首先将队列中超出范围的元素出队(只不过此时队首元素和要出队的元素并不相等)。然后 get[5] = 1 + 3 = 4。

【代码】

# [1696] 跳跃游戏 VI
#
# https://leetcode-cn.com/problems/jump-game-vi/description/
#
# algorithms
# Medium (33.27%)
# Likes:    18
# Dislikes: 0
# Total Accepted:    2.5K
# Total Submissions: 7.4K
# Testcase Example:  '[1,-1,-2,4,-7,3]\n2'
#
# 给你一个下标从 0 开始的整数数组 nums 和一个整数 k 。
# 
# 一开始你在下标 0 处。每一步,你最多可以往前跳 k 步,但你不能跳出数组的边界。也就是说,你可以从下标 i 跳到 [i + 1, min(n - 1,
# i + k)] 包含 两个端点的任意位置。
# 
# 你的目标是到达数组最后一个位置(下标为 n - 1 ),你的 得分 为经过的所有数字之和。
# 
# 请你返回你能得到的 最大得分 。
# 
# 
# 
# 示例 1:
# 
# 
# 输入:nums = [1,-1,-2,4,-7,3], k = 2
# 输出:7
# 解释:你可以选择子序列 [1,-1,4,3] (上面加粗的数字),和为 7 。
# 
# 
# 示例 2:
# 
# 
# 输入:nums = [10,-5,-2,4,0,3], k = 3
# 输出:17
# 解释:你可以选择子序列 [10,4,3] (上面加粗数字),和为 17 。
# 
# 
# 示例 3:
# 
# 
# 输入:nums = [1,-5,-20,4,-1,3,-6,-3], k = 2
# 输出:0
# 
# 
# 
# 
# 提示:
# 
# 
# 1 
# -10^4 
# 
# 
#


from collections import deque

class Solution(object):
    def maxResult(self, A, k):
        """
        :type nums: List[int]
        :type k: int
        :rtype: int
        """
        # 处理掉各种边界条件!
        if not A or len(A) == 0 or k <= 0:
            return 0

        # 单调队列,这里并不是严格递减
        Q = deque()

        # 每个位置可以收集到的金币数目
        get = [0] * len(A)

        for i in range(0, len(A)):
            # 在取最大值之前,需要保证单调队列中都是有效值。
            # 也就是都在区间里面的值
            # 当要求get[i]的时候,
            # 单调队列中应该是只能保存[i-k, i-1]这个范围的数
            if i - k > 0:
                if Q and Q[0] == get[i-k-1]:
                    Q.popleft()

            get[i] = (Q[0] + A[i]) if Q else A[i]

            while Q and Q[-1] < get[i]:
                Q.pop()
            Q.append(get[i])

        return get[-1]

复杂度分析: 每个元素只入队一次,出队一次,每次入队与出队复杂度都是 O(n)。因此,时间复杂度为 O(n),空间复杂度为 O(n)。

【小结】
这仍然是一个单调队列的题目。不同之处在于操作的时候,是通过了一个 get[] 数组来进行滑动窗口的。因此,这道题的考点就是两方面:

  • 找到 get[] 数组,并且知道如何生成;
  • 利用单调队列在 get[] 数组上操作,找到滑动窗口的最大值。

通过这道题你应该明白,有的时候,滑动窗口不一定是在给定的数组上操作,还可能会在一个隐藏的数组上操作。

总结

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值