【问题标题】:Finding second largest element in sliding window在滑动窗口中查找第二大元素
【发布时间】:2024-01-21 14:05:01
【问题描述】:

所以给定一个数组和一个窗口大小,我需要在每个窗口中找到第二大的。蛮力解决方案很简单,但我想使用动态规划找到一个有效的解决方案

当我对大数组尝试时,蛮力解决方案会超时,所以我需要找到一个更好的解决方案。我的解决方案是通过对它们进行排序并获取第二个元素来找到每个滑动窗口中的第二大,我知道某些数据结构可以更快地排序,但我想知道是否有更好的方法。

【问题讨论】:

  • 我不认为动态编程是答案。一个专门的min-heap 应该这样做。
  • (@user3386109: 建议一个堆专门支持按键删除。)
  • 不知道为什么我说最小堆。那应该是最大堆。专业化是1)按键删除(如@greybeard所述)和2)窥视第二大。请注意,根是堆中最大的元素。它要么是根的左孩子,要么是根的右孩子,这是第二大的。
  • @user3386109 我认为动态编程可能会有所帮助,因为在滑动窗口中,现有的第二大元素仍然可能是第二大元素,如果它或第一大元素没有被删除。肯定会检查使用最小和最大堆。谢谢
  • @user3386109 我认为这里可能有一个 O(n) 解决方案,不是吗?

标签: algorithm dynamic-programming sliding-window


【解决方案1】:

有一个比较简单的dunam编程O(n^2)解决方案: 为子集上的聚合值构建经典的金字塔结构(您将下面的对中的值组合在一起以进行上述每个步骤),您可以在其中跟踪最大的 2 个值(及其位置),然后简单地保留最大的 2 个值4 个组合值中的一个(由于重叠而在实践中较少,使用位置来确保它们实际上是不同的)。然后,您只需从具有正确滑动窗口大小的图层中读取第二大值。

【讨论】:

    【解决方案2】:

    因此,只需将数据结构与集合一样有序地存储数据。 就像如果您将 4 2 6 存储在集合上,它将存储为 2 4 6。

    那么算法是什么:

    让,

    数组 = [12,8,10,11,4,5] 窗口大小=4

    第一个窗口= [12,8,10,11] 设置 =[8,10,11,12]

    如何获得第二高:
    - 从集合中删除最后一个元素并存储在容器中。设置=[8,10,11],容器 = 12
    - 删除后,集合的当前最后一个元素是当前窗口的第二大元素。
    - 再次将容器中存储的已移除元素放入 set,set=[8,10,11,12]
    现在移动你的窗户, - 从集合中删除 12 并添加 4。
    - 现在您将获得新窗口并进行设置。
    - 检查类似的过程。
    在集合中删除和添加元素的复杂度约为 log(n)。

    一招:

    如果您总是想按降序存储数据,则可以通过将数据乘以 -1 来存储数据。当你弹出数据时,将它乘以 -1 来使用它。

    【讨论】:

    • 我的蛮力解决方案几乎相同,除了我对数字进行排序而不是使用集合。集合的优点是复杂度较低。我想知道是否有其他方法可以解决这个问题。谢谢
    • 还有其他一些方法!像段树数据结构复杂度 O(logn)。如果有帮助,请点赞我的回答。谢谢
    【解决方案3】:

    对于 O(n) 解决方案,我们可以使用双端队列。队列的前面将有更大(并且更早看到)的元素:

      0  1  2  3  4  5
    {12, 8,10,11, 4, 5}
    window size: 3
    
    i   queue (stores indexes)
    -   -----
    0   0
    1   1,0
    2   2,0 (pop 1, then insert 2)
    output 10
    remove 0 (remove indexes not in
       the next window from the front of
       the queue.)
    3   3 (special case: there's only one
       smaller element in queue, which we
       need so keep 2 as a temporary variable.)
    output 10
    4   4,3
    output 10
    remove 2 from temporary storage
    5   5,3 (pop 4, insert 5)
    output 5
    

    “pop”和“remove from front”分别是while A[queue_back] <= A[i]while queue_front is outside next window(尽管队列中只剩下一个较小元素的复杂性)。我们输出由队列前面的第二个元素索引的数组元素(尽管我们的前面可能有一个特殊的临时朋友,也曾经在前面;这个特殊的朋友一旦代表一个在外面的元素就会被转储窗口或小于从前面的第二个队列元素索引的元素)。双端队列的复杂度为 O(1),可以从前面或后面删除。我们只在后面插入。

    根据 cmets 中的 templatetypedef 请求:“您如何确定要使用哪些队列操作?”在每次迭代中,索引为i,在将其插入队列之前,我们(1)从队列后面弹出每个表示数组中小于或等于A[i]的元素的元素,以及(2)从队列的前面删除作为当前窗口之外的索引的每个元素。 (如果在(1)期间,我们只剩下一个较小或相等的元素,我们将其保存为临时变量,因为它是当前的第二大元素。)

    【讨论】:

    • 我第二个user3386109:我不明白你的回答。调用 window&queue k 的大小,nk 是您的解决方案所用时间的严格上限吗?如果不是:如何在删除已经输出一段时间的元素后,选择下一个要输出的元素?
    • @greybeard 我们从前面(右侧)输出第二个元素。
    • 来自您与 user3386109 的交流:请放置一个测试框架和测试用例,窗口大小>4,编码您的方法并报告结果。过程或程序代码的草图也将是一个福音。
    • @greybeard 我更愿意解释任何简单的反例。为什么大于而不等于 4?
    • 您能否详细说明您如何决定在每个点从队列中删除哪个值?例如,假设我的窗口大小为 3,并且我有数组 [10, 12, 9, 5]。当它移出窗口时,我如何将 10 踢出队列?
    【解决方案4】:

    有很多方法可以解决这个问题。这里有几个选项。接下来,我将让 n 表示输入数组中的元素个数,w 表示窗口大小。

    选项 1:一个简单的 O(n log w) 时间算法

    一种选择是维护一个平衡的二叉搜索树,其中包含当前窗口中的所有元素,包括重复项。向这个 BST 中插入一些东西需要时间 O(log w),因为窗口中只有 w 个元素​​,出于同样的原因,删除一个元素也需要时间 O(log w)。这意味着将窗口滑动一个位置需要时间 O(log w)。

    要在窗口中查找第二大元素,您只需要应用 standard algorithm for finding the second-largest element in a BST,这在具有 w 个元素​​的 BST 中需要时间 O(log w)。

    这种方法的优势在于,在大多数编程语言中,编写这个代码相当简单。它还利用了许多众所周知的标准技术。缺点是运行时不是最优的,我们可以对其进行改进。

    选项 2:O(n) 前缀/后缀算法

    这是一个相对容易实施的线性时间解决方案。在高层次上,该解决方案通过将数组拆分为一系列块来工作,每个块的大小为 w。例如,考虑以下数组:

    31  41  59  26  53  58  97  93  23  84  62  64  33  83  27  95  02  88  41  97
    

    假设 w = 5。我们将数组拆分为大小为 5 的块,如下所示:

    31  41  59  26  53 | 58  97  93  23  84 | 62  64  33  83  27 | 95  02  88  41  97
    

    现在,想象一下在这个数组的某处放置一个长度为 5 的窗口,如下所示:

    31  41  59  26  53 | 58  97  93  23  84 | 62  64  33  83  27 | 95  02  88  41  97
                                 |-----------------|
    

    请注意,此窗口将始终由一个块的后缀和另一个块的前缀组成。这很好,因为它允许我们解决一个稍微简单的问题。想象一下,不知何故,我们可以有效地确定任何块的任何前缀或后缀中的两个最大值。然后我们可以在任何窗口中找到第二个最大值,如下所示:

    • 找出窗口对应的块的前缀和后缀。
    • 从每个前缀和后缀中获取前两个元素(如果窗口足够小,则仅获取前一个元素)。
    • 在这(最多)四个值中,确定第二大的值并返回。

    通过一点预处理,我们确实可以设置我们的窗口来回答“每个后缀中最大的两个元素是什么?”形式的查询。和“每个前缀中最大的两个元素是什么?”您可以将其视为一个动态规划问题,设置如下:

    • 对于任何长度为 1 的前缀/后缀,将单个值存储在该前缀/后缀中。
    • 对于长度为 2 的任何前缀/后缀,前两个值是两个元素本身。
    • 对于任何较长的前缀或后缀,可以通过将较小的前缀或后缀扩展单个元素来形成该前缀或后缀。要确定该较长前缀/后缀的前两个元素,请将用于扩展范围的元素与前两个元素进行比较,然后选择该范围之外的前两个。

    请注意,填写每个前缀/后缀的前两个值需要时间 O(1)。这意味着我们可以在 O(w) 时间内填写任何窗口,因为有 w 个条目要填写。此外,由于总共有 O(n / w) 个窗口,因此填写这些条目所需的总时间是 O (n),所以我们的整体算法运行时间为 O(n)。

    至于空间使用:如果您急切地计算整个数组中的所有前缀/后缀值,则需要使用空间 O(n) 来保存所有内容。但是,由于在任何时候我们只关心两个窗口,因此您也可以只在需要时计算前缀/后缀。这将只需要 O(w) 空间,这非常非常好!

    选项 3:使用智能数据结构的 O(n) 时间解决方案

    最后一种方法与上述方法完全等价,但框架不同。

    可以build a queue that allows for constant-time querying of its maximum element。这个队列背后的想法 - 从a stack that supports efficient find-max 开始,然后在双栈队列构造中使用它 - 可以很容易地推广到构建一个队列,该队列提供对第二大元素的恒定时间访问。为此,您只需调整堆栈结构以存储每个时间点的前两个元素,而不仅仅是最大的元素。

    如果你有一个这样的队列,在任何窗口中找到第二个最大值的算法都非常快:用前 w 个元素​​加载队列,然后重复出队一个元素(将一些东西移出窗口)并将下一个元素排入队列(将某些东西移入窗口)。这些操作中的每一个都需要平均 O(1) 时间才能完成,因此总体上需要 O(n) 时间。

    有趣的事实 - 如果您查看此队列实现在此特定用例中的实际作用,您会发现它完全等同于上述策略。一个栈对应上一个块的后缀,另一个对应下一个块的前缀。

    最后一个策略是我个人最喜欢的,但不可否认,这只是我自己的数据结构偏见。

    希望这会有所帮助!

    【讨论】:

    • 我不明白为什么我们需要一个特殊的 get_max 操作(在您共享的链接中描述),因为我们可以像我描述的那样从一个简单的队列中弹出较小的元素(因为无论如何这些都是无用的)。
    • @גלעדברקן 问题是如何表示队列,以便弹出更小的元素。我阅读了您的解决方案,不幸的是,我无法理解您如何决定选择从队列中插入或删除元素的基本原理。你能详细说明一下吗?
    • 每一个元素都被插入到队列中,但只有在所有较小或相等的元素从后面移除,并且此窗口之外的所有元素都从前面移除之后。 (由于我们正在寻找第二大的,我们需要维护一个偶尔填充的临时变量,它曾经是队列中较小的单个元素,与队列中当前最大的两个元素进行比较并适当地丢弃。)(因为队列存储索引,很容易判断前面是否在窗口之外。)
    最近更新 更多