【问题标题】:Why is the deque solution to the "Sliding Window Maximum" problem O(n) instead of O(nk)?为什么“滑动窗口最大值”问题的双端队列解决方案是 O(n) 而不是 O(nk)?
【发布时间】:2019-04-05 06:37:15
【问题描述】:

问题是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) 吗?

【问题讨论】:

  • 我在这篇文章的底部回答了你的问题:stackoverflow.com/questions/39885520/…
  • 每个元素被添加然后从队列中移除,并且不再出现,因此内部循环的总步数最多为2*n。

标签: algorithm performance big-o


【解决方案1】:

让我们证明最坏的情况n * k 操作是不可能的(只是为了理解这个想法,其余的中间-ish 可以类似地证明):

如何实现n * k?在每一步,我们都需要从双端队列中生成k“pops”。所以双端队列中的元素看起来像这样(在这个插图中,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你:(


这对我们算法的动态做出了更一般的陈述:

如果数组的ith 元素导致双端队列中的m“弹出”,那么前面的元素肯定会“蹩脚”,足以消除ith 元素的“坏蛋”。

现在,如果您不是从双端队列的角度而是从整个数组的角度来看:每次您抛出一个唯一的数组元素时。所以“pops”的个数不能大于数组的元素个数,也就是n

这使得我们的复杂性O(n)

【讨论】:

  • 这就是我在脑海中想象的方式:D 我通常不擅长数字证明,所以我喜欢以几何和动态的方式对其进行可视化:D
  • 很有趣,而且方向正确,但由于您的引理不正确,因此不能证明。您可以弹出 k 个项目,而无需之前的 k 个操作不执行弹出操作: push push pop push push pop push push pop push push pop pop pop pop pop
  • 因此最坏的情况会是 O(2n),因为您最多可以有 n 次推送和 n 次弹出 - 最终是 O(n)?
  • @MattTimmermans 谢谢,也注意到了(这就是为什么几何思维可能会导致糟糕的东西)。虽然添加了证明
【解决方案2】:

您可以分两步分别计算在while循环中完成的比较次数,然后将它们相加。这也是 while 循环的总迭代次数,并且由于每次迭代花费的时间是固定的,它也是 while 循环的总运行时间。

真实比较

如果Qi and arr[i] >= arr[Qi[-1]]为真,还会有弹出操作(因为这是在while循环体中)。

每个元素都只添加到双端队列一次。因此,弹出操作的数量不能超过元素的数量,即 O(n)。

因此这些比较的总数也是 O(n)。

虚假比较

Qi and arr[i] >= arr[Qi[-1]] 也可以为假,但每次进入 while 循环时只会发生一次(因为如果为假,则循环将停止并继续执行后续代码)。

我们到达while循环的次数等于两个for循环的迭代次数,也就是O(n)。

因此这些比较的总数也是 O(n)。

总运行时间

剩下的代码也是O(n),因此总运行时间是O(n+n+n) = O(n)。

【讨论】:

  • 每个新元素与队列中的元素比较 k 次,因此复杂度为 O(nk)。
  • @AbuNassar 每一步都有最多 k 次比较,但大多数时候比较会比较少。如果你把所有这些比较加起来,你最终只会得到 n 的某个倍数,即 O(n),如答案中所述。
  • 不,更大的k 显然需要在每次迭代中进行更多比较,我声称平均为k/2。
  • @AbuNassar 我稍微扩展了我的答案。如果您发现我的推理有任何问题,请告诉我(如果您是正确的,则需要有问题)。尝试直接计算每次迭代的比较次数(我在这里没有这样做)不会导致比较总数的确切答案。它只会给你一个 Ω(n) 的下限和一个 O(nk) 的上限,而不是一个紧密的界限。如果您想进一步扩展这种给出不同答案的其他方法,那么在不同的答案中这样做可能是有意义的(我看到您已经发布了一个,我在那里留下了评论)
【解决方案3】:

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

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

当窗口中的新元素不大于 deque 中的最大元素(dequeue 前面的元素)但至少大于 deque 中的最小元素(deque 后面的元素),那么我们不仅比较新元素与 deque 元素(从后到前)找到正确的位置,但也丢弃 deque 中小于新元素的元素

因此,不要将上述操作视为在长度为 k 的已排序双端队列中搜索新元素的正确位置,而是将其视为弹出小于新元素的双端队列元素。那些较小的元素在某个时候被添加到双端队列中,在那里住了一段时间,现在它们被弹出了。在最坏的情况下,每个元素都可以获得这种从双端队列中推入和弹出的特权(尽管这是与从后面搜索大于新元素的第一个数字的操作一起完成的,这会导致所有混乱)。

由于每个元素最多只能被推送和弹出一次,因此复杂度是线性的。

【讨论】:

    【解决方案4】:

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

    这是最坏的情况。假设这些数字是随机的,或者在某个范围内实际上是随机的,每个新元素将平均取代 k 大小双端队列的一半。但这仍然是 O(nk)。

    【讨论】:

    • 我认为我的解释还不够,尽管我仍然认为它是正确的。假设输入中的数字是随机的。假设我们开始迭代时双端队列有 k 个元素。要添加新元素,我从后面删除最旧的元素,然后将新元素与前面的元素进行比较。我有 0.5 的机会丢弃第一个元素(即要添加的元素更大),有 0.25 的机会丢弃第二个元素,依此类推。因此,我可以丢弃的元素数量的预期值是该系列的总和:1/2 + 1/4 + &c。 = 1.
    • 请注意,双端队列将增长回k 元素的可能性可以用相同的方式进行分析。换句话说,双端队列的大小保持在k 附近,因此每次添加都需要对k 进行比较。
    • 如果数组是降序排列的,在for循环的每一步只会做1次比较,不会进行k-1次比较。 while 循环总是将当前元素与其他 1 个元素进行比较,看看其他元素更大,然后跳出循环。
    • 如果数组是降序排列?然后你在每次迭代中对其进行排序。而且......您不再知道哪个元素是最古老的,并且必须被弹出。
    • the code (see method 3) 中没有排序。元素以这样一种方式插入,即双端队列始终保持排序,但它从未显式排序,并且您永远不必搜索需要插入的位置(它总是在末尾)。 Append 和 pop 是那里唯一的操作(除了琐碎的恒定时间操作),它们都是最佳双端队列中的恒定时间。
    【解决方案5】:

    O(k*n) 的时间复杂度具有欺骗性。但是如果你仔细想想你会发现它确实是 O(n)。我将给出 O(n) 是如何推导的直觉。

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

    假设:我们只需要 n 次比较。

    假设的证明:

    1. 处理前 k 个数字

      前k个数字中的第一个数字(索引为0)将被放入出队而不进行任何比较,因为它是第一个放入出队的数字,现在让我们考虑i> 0的第i个数字:

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

      b.如果前 k 个数字中的第 i 个数字大于 dequeue 的尾部,我们可能需要多次比较,直到找到一个大于第 i 个数字的数字或者我们到达 dequeue 的头部。假设我们对第 i 个数字进行了 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】:

      remove-last 操作有点棘手。

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

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2019-12-04
        • 1970-01-01
        • 1970-01-01
        • 2020-07-09
        • 1970-01-01
        • 2016-10-31
        • 2017-11-19
        • 2014-10-14
        相关资源
        最近更新 更多