演算法圖鑑
Searching & Two Pointers · 05 / 05

Sliding Window滑動視窗

固定長度與可變長度兩種

用在:串流統計、限流(rate limit)、最長不重複子字串

時間複雜度O(n)
空間複雜度O(k)
難度進階
前置知識Two Pointers、Hash Table

01為什麼需要它

監控系統的「過去 5 分鐘平均延遲」

延遲資料每秒進來,儀表板每秒要更新「過去 300 秒的平均」。每秒重新加總 300 筆,資料量一大就跟不上。

為什麼用它視窗每秒只變動兩筆:新的一筆進來、最舊的一筆出去。維護一個總和,加一筆減一筆就是新的平均,每秒 O(1)。這是固定長度的滑動視窗。

API 限流:每 10 秒最多 100 次

每個請求進來要判斷「這個用戶過去 10 秒內是否已經打了 100 次」。存所有歷史再每次過濾,太慢也太占空間。

為什麼用它只保留視窗內的請求時間戳,新請求進來時先把左端過期的丟掉,再看剩多少。每個時間戳進一次、出一次,攤銷 O(1)。視窗的時間跨度固定,但裡面有幾筆請求不固定,所以要用佇列而不是固定大小的陣列。

最長不重複子字串、最短滿足條件的子陣列

字串裡最長的一段沒有重複字元;或陣列裡和至少為 S 的最短一段。暴力枚舉所有區間是 O(n²) 甚至 O(n³)。

為什麼用它右端往右擴、條件被破壞時左端往右縮,兩端都只往右走。每個元素進出視窗各一次,O(n)。認出「連續區間」加「單調的合法性」,就是可變視窗。

看到這些關鍵字就想到它:連續子陣列/子字串、過去 k 個、過去 t 秒、最長/最短滿足條件的區間、串流統計、限流、加一個減一個。

02核心概念

滑動視窗是同向雙指標的特例:lr 之間的區間就是「視窗」,視窗上維護一些可以增量更新的統計量(總和、計數、字元集合)。r 往右一格就把新元素「加進」統計,l 往右一格就把舊元素「減掉」。不重算整個區間,每個元素進出各一次,每次 O(1),所以 O(n)。額外空間是統計量的大小:總和只要 O(1),集合或計數最多 O(k)(k 是視窗長度或字元種類數)。

固定長度的視窗最簡單:長度永遠是 k,每步 rl 同時前進一格,統計量加 a[r]a[l-1]。過去 k 筆的平均、長度 k 的最大和、固定長度的字母異位詞,都是這個。

可變長度的視窗靠一個合法性條件驅動:r 每次擴一格,若視窗不合法(有重複字元、和超過上限),l 往右縮到合法為止;若問的是「最短的合法區間」,就反過來,在合法時盡量縮並更新答案。能這樣做的前提是合法性對區間單調,而兩種問法要的方向相反:求最長,要「合法的區間縮小後仍合法」(沒有重複的子字串去掉一端還是沒有重複);求最短,要「合法的區間放大後仍合法」(元素全是正數時,和 ≥ target 的區間再多包一個仍然 ≥ target)。條件跟「和」有關時,陣列一有負數這種單調性就不成立,l 不能只往右走,要改用前綴和等其他方法。要計數「恰好 k 種」的子陣列,條件本身不單調,就拆成「至多 k 種」的個數減「至多 k−1 種」的個數,每個 r 貢獻以它結尾的 r − l + 1 個合法子陣列。

統計量的選擇決定每步的成本。總和用一個變數;「有沒有重複」用集合或計數陣列;「視窗最大值」沒辦法只靠一個變數維護(最大值移出後不知道下一個是誰),要配合單調佇列,攤銷 O(1)。限流的視窗以時間為界,元素數量不固定,用佇列存時間戳,左端過期就彈出,本質相同。

03演算法步驟

  1. 1確認問的是連續區間,且合法性對區間的伸縮是單調的(求最長:縮小仍合法;求最短:放大仍合法)。決定視窗上要維護什麼統計量,必須能 O(1) 加入與移除。
  2. 2l = 0,統計量清空。for r in range(n):把 a[r] 加進統計量。
  3. 3while 視窗不合法:把 a[l] 從統計量移除,l += 1。這個內層迴圈總共最多跑 n 次,不是每步 n 次。
  4. 4視窗現在合法,用 r − l + 1 更新答案(最長)。找最短時把更新放在縮的迴圈裡,條件改成「合法時縮」。
  5. 5固定長度時省掉合法性判斷:r ≥ k 後每步移除 a[r − k],視窗長度恆為 k。

04互動示範

最長不重複子字串。藍色格子是目前視窗,下方是視窗裡的字元集合。r 指到一個已經在集合裡的字元(黃色)時,它先不加入,l 往右縮到那個字元離開為止,再把它加進來。綠線是目前最佳區間。

擴視窗(r 往右)s = "abcadbcxab" · n = 10
字串與視窗
a0
l
b1
c2
a3
d4
b5
c6
x7
a8
b9
視窗裡的字元集合
abcdx
亮的在視窗裡。黃色是「想加進來但已經存在」的字元。
目前狀態
l = 0,r = −1,長度 0視窗 = ""最佳 = 0
步驟 0/17視窗一開始是空的。r 每次往右一格把 s[r] 加進來;若 s[r] 已在視窗裡,先不加,l 往右縮到它離開為止。

05程式碼

四段:可變視窗的最長不重複子字串、固定視窗的最大平均、找最短區間的可變視窗,以及用佇列當視窗的限流器。留意最長和最短兩種可變視窗,更新答案的位置不同。

# 可變視窗:最長不重複子字串(LeetCode 3)
# r 每次往右加一個字元;視窗不合法(有重複)時 l 往右縮到合法為止
def length_of_longest_substring(s):
    seen = set()                           # 視窗裡的字元
    l = 0
    best = 0
    for r, c in enumerate(s):
        while c in seen:                   # 視窗不合法
            seen.remove(s[l])
            l += 1
        seen.add(c)
        best = max(best, r - l + 1)
    return best


# 固定視窗:長度 k 的子陣列最大平均(LeetCode 643),假設 1 <= k <= len(nums)
# 每滑一格:加新的、減舊的,不重算整個視窗
def max_average(nums, k):
    total = sum(nums[:k])
    best = total
    for r in range(k, len(nums)):
        total += nums[r] - nums[r - k]     # 進一個、出一個
        best = max(best, total)
    return best / k


# 可變視窗的另一種形狀:和 >= target 的最短子陣列(LeetCode 209)
# 條件一滿足就盡量縮,縮的時候更新答案
# 前提:nums 全是正數。有負數時縮左端不一定讓和變小,視窗就不成立
def min_subarray_len(target, nums):
    l = 0
    total = 0
    best = float("inf")
    for r, x in enumerate(nums):
        total += x
        while total >= target:             # 合法,試著縮到最短
            best = min(best, r - l + 1)
            total -= nums[l]
            l += 1
    return 0 if best == float("inf") else best


# 限流(rate limit):過去 window 秒內最多 limit 次請求
# 用佇列當視窗,過期的從左邊丟掉
from collections import deque

class RateLimiter:
    def __init__(self, limit, window):
        self.limit, self.window = limit, window
        self.q = deque()                   # 請求的時間戳,遞增

    def allow(self, now):
        while self.q and self.q[0] <= now - self.window:
            self.q.popleft()               # 視窗左端過期
        if len(self.q) < self.limit:
            self.q.append(now)
            return True
        return False


if __name__ == "__main__":
    print(length_of_longest_substring("abcadbcxab"))   # 5
    print(max_average([1, 12, -5, -6, 50, 3], 4))      # 12.75
    print(min_subarray_len(7, [2, 3, 1, 2, 4, 3]))     # 2
    rl = RateLimiter(3, 10)
    print([rl.allow(t) for t in (1, 2, 3, 4, 12)])     # [True, True, True, False, True]

06練習題

  • LeetCode 3Longest Substring Without Repeating CharactersMedium
  • LeetCode 643Maximum Average Subarray I(固定視窗)Easy
  • LeetCode 209Minimum Size Subarray Sum(最短合法區間)Medium
  • LeetCode 424Longest Repeating Character ReplacementMedium
  • LeetCode 567Permutation in String(固定視窗 + 計數)Medium
  • LeetCode 76Minimum Window SubstringHard