数据结构之一题多解
首先,分析题目的时候,要特别注意以下 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[] 数组上操作,找到滑动窗口的最大值。
通过这道题你应该明白,有的时候,滑动窗口不一定是在给定的数组上操作,还可能会在一个隐藏的数组上操作。