滑動窗口的最大值問題


給出一個序列,要求找出滑動窗口中的最大值,比如:

# 序列: 2, 6, 1, 5, 3, 9, 7, 4
# 窗口大小: 4

[2,  6,  1,  5], 3,  9,  7,  4    => 6
 2, [6,  1,  5,  3], 9,  7,  4    => 6
 2,  6, [1,  5,  3,  9], 7,  4    => 9
 2,  6,  1, [5,  3,  9,  7], 4    => 9
 2,  6,  1,  5, [3,  9,  7,  4]   => 9

# 期望結果: [6, 6, 9, 9, 9]

並要求算法的時間復雜度為 O(n)

算法思路

稍加觀察便能發現滑動窗口其實就是一個隊列:窗口每滑動一次,相當於出列一個元素,並入列一個元素。因此這個問題實際上也可以看作是要求設計一個 pop(), push(), max() 均為 O(1) 的隊列。如果能設計出這樣的隊列類型,那么在窗口滑動的過程中不斷地入列出列,則最終只需要 O(n) 的時間便能找到所有滑動窗口的最大值。

pop()push() 做到 O(1) 很簡單,max() 就沒那么容易了。隨着元素的進隊,我們可以記錄元素之間的大小關系,維護一個最大值記錄,但當隊首元素彈出時,已有的大小關系就會被破壞——被彈出的元素可能就是最大值,這樣就需要重新開始評估新的最大值。但若我們只從隊末彈出呢?這樣便不會破壞已記錄的剩余元素的最大值。這種只在一端進出的數據結構就是。只要在進棧的同時維護一個最大值棧,我們就可以輕松得到一個 pop(), push(), max() 均為 O(1) 的棧。比如令 2, 7, 4 依次進棧,並同時維護一個當前時刻的最大值棧 2, 7, 7,彈出一個元素的時候也同時彈出最大值棧中的元素,這樣我們就可以在 O(1) 的時間內找到一個棧的 max

我們知道,使用兩個棧可以構造一個隊列,即一個棧用於 push,一個棧用於 pop,因此我們可以使用兩個 max()O(1) 操作的棧來構造一個 max()O(1) 的隊列。

這是因為一個滑動窗口中的元素要么全在一個棧中,此種情況下只需 O(1) 的時間便可得到該滑動窗口的最大值;要么一部分在一個棧中,一部分在另一個棧中,而從兩個棧中找到各自的最大值只需要 O(1) 的時間,再比較兩個部分各自的最大值,便可以得到該滑動窗口的最大值,因此此種情況下也只需要 O(1) 的操作就可以得到該滑動窗口的最大值。

以序列 2, 6, 1, 5, 3, 9, 7, 4 為例,設其滑動窗口的大小為 4,記用於出列的棧為 stack_out,用於入列的棧為 stack_in。首先得到第一個滑動窗口,即入列 4 個元素:

    stack_out:		stack_in:
                         (5, 6)         <- Top
       None              (1, 6)
                         (6, 6)
                         (2, 2)         <- Bottom

使用 (value, max) 表示當前要入棧的元素 value 以及當前的最大值 max。此時只需要讀出 stack_in 棧頂元素的最大值即為當前滑動窗口的最大值。

向右滑動一格即表示將 2 出列,將 3 入列:

stack_out:		stack_in:
  		  	                    <- Top
  (6, 6)	
  (1, 5)		
  (5, 5)		  (3, 3)	    <- Bottom

此時便得到了第二個滑動窗口。它的元素被分置在兩個棧中:有 3 個元素在 stack_out 中、 1 個元素在 stack_in 中。而我們可以用 O(1) 的時間從 stack_out 中找到 3 個元素這個部分中的最大值,同時用 O(1) 的時間從 stack_in 中找到另一部分的最大值。因此將 stack_outstack_in 棧頂的最大值相比較即可得到第二個滑動窗口的最大值。也就是說當一個滑動窗口的元素被分散在兩個棧中時,我們需要 O(1) + O(1) + O(1) = O(1) 的時間找到該滑動窗口的最大值。三個 O(1) 依次為:從 stack_out 找到第一部分最大值的時間、從 stack_in 中找到另一部分最大值的時間、比較兩個最大值得到最終的最大值的時間。

依次處理下去,便可得到我們想要的結果。

時間復雜度

我們在前文提到「設計一個 pop(), push(), max() 均為 O(1) 的隊列」。但似乎上述算法中 pop() 的時間復雜度不是 O(1),因為使用兩個棧實現的隊列類型在出列時會遇到以下兩種情形:

  1. 當用於出列的棧 stack_out 不空時,可以直接彈出 stack_out 的棧頂元素以出列;
  2. 當用於出列的棧 stack_out 為空時,我們首先需要將用於入列的棧 stack_in 中的元素全部轉移到 stack_out 中,然后再彈出其棧頂元素以出列。

顯然情形 1) 是 O(1) 的,但是情形 2) 是 O(m) 的(記 m 為每個棧的大小,也即滑動窗口的大小)。如果我們所設計的隊列的 pop() 可能不是 O(1) 的,那么算法的最終時間復雜度還會是 O(n) 嗎?

我們注意到只有當 stack_out 為空(即情形 2)時,才會需要 O(m) 的時間出列一個元素。因此若將算法的最終時間復雜度記為 O(nm),雖然正確,但這樣做未免太過悲觀,畢竟情形 2 出現的次數相對較少,而其它時間又都是 O(1) 的。所以我們希望可以得到一個更小的時間復雜度上界。一個比較簡單的想法是,在遇到情形 2 之前,我們首先需要做 m 次出棧操作將 stack_out 清空,然后才會發生情形 2,也就是將 stack_in 中的 m 個元素移入到 stack_out 中,這一步操作就是我們的“罪魁禍首”,但我們可以把這個“罪名”分別安到在此之前的 m 次用時為 O(1) 的出棧操作上,即將它之前的 m 次出棧操作看作是 O(2) 的,如此一來就除掉了這個「罪魁禍首」,代價則是所有的出棧操作都變成了 O(2),不過好在也是 O(1) 的。因此我們的 pop() 操作的時間復雜度還是 O(1) 的,算法最終的時間復雜度為 O(n)。這種時間復雜度也被稱為均攤時間復雜度——我們將一個代價比較高的操作轉移到其它操作上,從而降低所有操作的平均時間復雜度。這種平均時間復雜度考慮的是一系列操作的平均時間復雜度,而不是單個操作的時間復雜度。

當然我們也可以從另外一個角度來考慮該算法的時間復雜度:即考慮每個元素進棧出棧的次數。我們使用了兩個棧來實現了這個隊列,從算法開始到結束,每個元素分別先從 stack_in 進棧、出棧,然后再從 stack_out 進棧、出棧,而這些操作都是 O(1) 的,因此我們最終只需要 O(4n) 的時間來完成所有操作,因此該算法的時間復雜度為 O(n)

P.S.
如果在實現上有疑惑,不妨看看下面給出的這種隊列類型的 Python 代碼。在該代碼中,入隊操作被命名為 append,而不是 push,其目的是與 Python 標准庫中隊列的方法名保持一致。

from typing import List
from collections import namedtuple

Node = namedtuple('Node', ['value', 'max'])


class MaxQueue():

    def __init__(self, stack_len: int) -> None:
        self.stack_in = []
        self.stack_out = []
        self.stack_len = stack_len

    def pop(self) -> int:
        if not self.stack_out:
            if not self.stack_in:
                raise IndexError('pop from an empty queue')
            else:
                self._move_in_to_out()
        return self.stack_out.pop().value

    def append(self, value: int) -> None:
        if len(self.stack_in) >= self.stack_len:
            if self.stack_out:
                raise IndexError('the queue is full')
            else:
                self._move_in_to_out()
        self._push_to_stack(self.stack_in, value)

    def max(self) -> int:
        if self.stack_in and self.stack_out:
            return max(self.stack_in[-1].max, self.stack_out[-1].max)
        if self.stack_in:
            return self.stack_in[-1].max
        if self.stack_out:
            return self.stack_out[-1].max

    def _move_in_to_out(self) -> None:
        while self.stack_in:
            self._push_to_stack(self.stack_out,
                                self.stack_in.pop().value)

    def _push_to_stack(self, stack: List[Node], value: int) -> None:
        if stack:
            stack.append(Node(value, max=max(value, stack[-1].max)))
        else:
            stack.append(Node(value, max=value))


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM