为什么“最大滑动窗口”问题的双端队列解决方案是O(n)而不是O(nk)?

时间:2018-11-01 02:32:03

标签: algorithm performance big-o

问题出在find the maximum in each subarray of size k in an array of length n

蛮力方法为O(nk)。但是使用双端队列,解决方案据说是O(n)。但是我不相信它到达O(n),特别是因为这个while循环:

# Remove all elements smaller than 
# the currently being added element  
# (Remove useless elements) 
while Qi and arr[i] >= arr[Qi[-1]] : 
    Qi.pop()

位于从k到n的for循环内。从技术上讲,这不能在每个循环上运行k次,从而在O(n)和O(kn)之间给出某个值吗?即使是双端队列解决方案,最坏情况下的时间复杂度实际上是否也为O(kn)?

7 个答案:

答案 0 :(得分:3)

让我们证明不可能进行最极端的n * k操作(只是为了理解这一点,其余类似情况也可以得到证明):

如何实现n * k?在每一步中,我们都需要使k从双端队列中“弹出”。因此,双端队列中的元素如下所示(在本示例中为k == 5):

之前

| ,              #
| | | ,          #   (like a heavy bowling ball)
| | | | ,        #  
---------------------------------------------------             
^^^^^^^^^        ^
our deque        new badass element coming *vruuuum*

之后

#
#     *bang* (sound of all pins knoked down)
#  
---------------------------------------------------             
^
this new guy totally smashed our deque in 5 operations!

但是,嘿...请稍等

  

我们的双端队列是如何累积k个元素的?

好吧,为了积累k元素,它应该在之前的k步骤中少扔很多东西(否则双端队列从一开始就为空)。废话...没有n * k代表您:(


这使我们对算法的动力学有了更一般的说明:

  

如果数组的第i个元素导致双端队列产生m个“爆裂声”,那么前面的元素肯定足够“残缺”,以使{{1 }}个元素。

现在,如果您不是从双端队列的角度来看,而是从整个数组的角度来看:每次您都在抛出一个唯一的数组元素。因此,“弹出”的数量不应大于数组中元素的数量,i

这使我们变得更加n

答案 1 :(得分:3)

每个元素都只添加到双端队列一次。

因此,弹出操作不能超过元素数O(n)。

有时会检查while条件,而不会弹出,但是完成的次数最多等于我们进入while循环的次数(即,两个for循环的迭代次数) ,即O(n)。

代码的其余部分也是O(n),因此总运行时间为O(n + n + n)= O(n)。

答案 2 :(得分:0)

我不知道数学证明,但是下面的想法可能有助于理解它:

请注意,元素的索引存储在双端队列中,但是为了便于解释复杂性,我在说的是元素而不是索引。

当窗口中的新元素不大于双端队列中的最大元素(在双端队列的前面的元素),但至少大于双端队列中的最小元素(在双端队列的后面的元素)时,我们不仅会进行比较将具有deque元素的新元素(从后到前)找到正确的位置,但也会丢弃deque中比新元素小的元素。

因此,不要将上述操作视为在长度为k的已排序双端队列中搜索新元素的正确位置,而是将其视为弹出的小于新元素的双端队列元素。那些较小的元素在某些时候以双端队列添加,在那儿住了一段时间,现在又弹出了。在最坏的情况下,每个元素都可能具有推入双端队列和从双端队列弹出的特权(尽管这样做是与从后方搜索比新元素大的第一个数字的操作同时进行的,这会引起所有混乱)。

每个元素最多可以被推入和弹出一次,因此复杂度是线性的。

答案 3 :(得分:0)

算法的复杂度为O(nk)。在通过数组的n次迭代中的任何一次迭代中,我可能都必须将新的候选元素与仍然在双端队列中的O(k)个元素进行比较。 while循环内的for循环将其释放。考虑降序排列的整数数组(当然,该算法没有此信息)。现在,我想找到滑动最大值。我考虑的每个元素都必须放在队列中,但是不能替换其他元素(显然是因为它较小)。但是,直到删除最旧(也是最大)元素并将新元素与其余所有元素(ergo,k-1比较)进行比较,我才知道这一点。如果我想使用堆作为滑动数据结构,则可以将复杂度降低到O(n log k)。

那是最坏的情况。假设数字是随机的,或者在一定范围内有效,那么每个新元素平均将替换k大小双端队列的一半。但这仍然是O(nk)。

答案 4 :(得分:0)

(在https://stackoverflow.com/a/31061015/5649620中看到知名的SO用户的相互矛盾的评论后,发布此答案)

此问题使用出队的最坏情况下的时间复杂度可以如下所示:

O(n + n) //用于每个元素的入队和出队

+

O((n / k-1)* k) //用于比较要重复访问的次数 “新元素” 队列中有'existing elements';在最坏的情况下,每个队列中将有n / k-1个这样的“新元素”和k个“现有元素”。

总数:O(n + n +(n / k-1)* k)=大约 O(n)

(第二部分通常是人们认为最糟糕的时间复杂度为O(nk)的地方)


示例1 ://最坏的情况

n = 30,k = 10

数组:10 9 8 7 6 5 4 3 2 1 11 10 9 8 7 6 5 4 3 2 12 11 10 9 8 7 6 5 4 3

对于11(首次出现)和12中的每一个,队列中将有10个现有元素要与11和12进行比较。因此,11和12各自被访问十次。

在这里,n / k-1 = 30/10-1 = 2个元素,分别是11和12。 这些可以与队列中的k个现有元素进行比较:2 * k = 2 * 10 = 20

示例2

另一种情况可能是(n = 11,k = 10):10 9 8 7 6 5 4 3 2 1 11

在这里,由于只有1个元素11在队列中有10个现有元素而被重复访问,所以这不会比前一种情况更糟,使前一种情况成为最坏的情况。

答案 5 :(得分:0)

O(k * n)的时间复杂度具有欺骗性。但是,如果仔细考虑,您会发现它确实是O(n)。我将给出O(n)的推导方式。

我们将考虑时间复杂度,即需要多少次比较才能对出队的数组中的所有数字进行处理。

假设:我们只需要进行n次比较即可。

假设的证明

  1. 处理前k个数字

    前k个数字中的第一个数字(索引为0)将不作任何比较地放入出队,因为它是放入出队的第一个数字,现在让我们考虑第i个数字,其中i> 0:< / p>

    a。如果前k个数字中的第i个数字小于出队的尾部,则将其放入尾部并停止。这花费了我们1次比较。

    b。如果前k个数字中的第ith个数字大于出队的末尾,则可能需要进行多次比较,直到找到一个大于第ith个数字的数字,或者到达出队头为止。假设我们对Ci数进行了Ci数比较。但是同时它从出队中删除了Ci数量的元素。这意味着比较数量和删除的元素数量之间一对一对应,因为在处理前k个元素时最多可以删除k个元素。因此处理前k个元素的比较次数最多为k

  2. 处理k + 1,k + 2 ... n个元素

    就像分析处理前k个数字一样,对于{k + 1,K + 2 ... n}个数字中的每个元素j。它要么小于出队的尾部,后者花费1比较。否则它将进行多次比较,直到找到正确的位置。但同时它删除了相同数量的元素。现在考虑通过处理{k + 1,k + 2 ... n}和{1,2 ... k}可以删除多少个元素,n是数组的长度。通过将一对一关系应用于对应关系,我们知道有n个比较。

尽管总时间复杂度包括n的比较数加上都为O(n)的移除和加法运算,所以总的时间复杂度为O(n)。

答案 6 :(得分:0)

remove-last操作有点棘手。

似乎每个循环索引要花K次,但实际上所有remove-last操作的总和小于n,因为每个元素最多只能被删除一次。