滑動視窗

陣列與字串優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 依條件伸縮的視窗 — 固定大小、可變大小、至多 k 個,以及用相減湊出的恰好 k 個;擴張/收縮迴圈與七個標準視窗模板都歸這裡管。 另見 — 從本檔拆出:sliding_window_examples.md — 實作題目,每題一張卡,依它歸約到的模板歸檔;sliding_window_advanced.md — 至多 K 的通用化、超出單一題目的恰好 K、同時處理多個視窗、單字層級的視窗。 相鄰文件:2_pointers.md — 相向逼近而非一前一後的指標;hash_map.md — 多數視窗都會帶著的計數表;monotonic_queue.md — 用 O(n) 求視窗極值;prefix_sum.md — 視窗內可能有負值時該去的地方。

LeetCode 題目清單

總覽

滑動視窗用兩個指標在陣列或字串上維護一個「視窗」,靠擴張與收縮有效率地找出最佳解。

關鍵性質

  • 時間複雜度:O(n) — 每個元素最多被走訪兩次
  • 空間複雜度:指標本身 O(1),視窗狀態 O(k)
  • 核心想法:維護一個在資料結構上滑動的視窗 [left, right]
  • 兩個階段:
    • 擴張:右指標往前,把視窗撐大
    • 收縮:視窗不合法時,左指標往前縮小視窗

什麼時候用滑動視窗

  • 子陣列/子字串題:找出具有特定性質的最佳子陣列
  • 視窗型限制:牽涉到固定或可變視窗大小的題目
  • 最佳化:在限制條件下求最小/最大長度、數量或總和
  • 字元/元素追蹤:需要統計出現次數的題目

參考資料

辨識

題目敘述裡的訊號 限制條件提示 該用 不是這個,如果
「每個長度為 k 的子陣列/子字串」、「k 個連續元素的最大平均」 n ≤ 10⁵,k 已給定 模板 1 固定大小視窗 沒給 k,而題目要的正是長度 → 模板 3/4
「最多 k 個 … 的最長子陣列」、「可以翻轉/替換 k 次」 n ≤ 10⁵ → O(n) 模板 2 先擴後縮 有負數時求總和恰好 == k → prefix_sum.md(LC 560)
「沒有重複字元/至多 k 種字元的最長子字串」 n ≤ 5·10⁴ 模板 3 最長視窗 視窗必須「包含」某些東西 → 模板 4/5
「總和 ≥ target 的最短子陣列」,所有值皆為正 n ≤ 10⁵ 模板 4 最短視窗 值可能為負 → 在前綴和上用單調雙端佇列,LC 862(monotonic_queue.md)
「包含 t 所有字元的最小視窗」、「s 裡是否有 p 的排列/字母重排」 |s|, |t| ≤ 10⁵ 模板 5 have/need 計數器 t 的字元順序有意義 → 子序列雙指標(2_pointers.md)
「恰好 k 個相異/奇數/總和為 k 的子陣列個數」 n ≤ 2·10⁴ 模板 6 atMost(k) − atMost(k−1) 值可能為負(LC 560)→ 前綴和 + 雜湊表
「最長的子陣列,使得其中沒有任兩個/任三個元素 …」 值 ≤ 500 模板 7 只檢查新進元素 規則不具遺傳性(丟掉一個元素可能讓它失效)→ 不是視窗題
「每個大小為 k 的視窗的最大/最小值」 n ≤ 10⁵ 單調雙端佇列 → monotonic_queue.md(LC 239)

限制條件提示取自 complexity_cheatsheet.md 的那把尺。

題型分類

核心組成

  1. 雙指標:用 left 和 right 界定視窗邊界
  2. 迴圈結構:
    • while-while:外層擴張,內層收縮
    • for-while:for 負責擴張,while 負責收縮
    • 關鍵洞見:第一層迴圈找出可行解,第二層迴圈把它優化成最佳解
  3. 視窗狀態:追蹤當前視窗內的元素、次數或總和
  4. 合法性條件:定義視窗什麼時候合法/不合法

五種視窗型態

固定大小視窗

  • 說明:視窗大小事先給定且固定不變
  • 範例:LC 438(Find All Anagrams)、LC 567(Permutation in String)
  • 模式:維持固定的視窗大小,一次滑動一格

可變大小視窗 — 求最大

  • 說明:找出滿足限制的最大視窗
  • 範例:LC 3(Longest Substring)、LC 424(Character Replacement)
  • 模式:擴張到不合法為止,記錄最大值,然後收縮

可變大小視窗 — 求最小

  • 說明:找出滿足限制的最小視窗
  • 範例:LC 209(Minimum Subarray Sum)、LC 76(Minimum Window Substring)
  • 模式:收縮到不合法為止,記錄最小值,然後擴張

子陣列計數

  • 說明:計算符合條件的子陣列/子字串個數
  • 範例:LC 713(Subarray Product)、LC 992(Subarrays with K Different)
  • 模式:對每個右端點,數出合法的左端點有幾個

字串比對(雜湊式)

  • 說明:追蹤視窗內的字元出現次數
  • 範例:LC 567(Permutation)、LC 438(Anagrams)、LC 76(Window Substring)
  • 模式:用 HashMap/Counter 追蹤字元次數

視窗狀態與輔助工具

  • 技巧:雙指標、滑動視窗、次數統計
  • 資料結構:HashMap、Counter、Set、陣列
  • 輔助工具:Collections.Counter(Python)、HashMap.getOrDefault(Java)

固定大小視窗 vs 可變大小視窗

類型 什麼時候用 收縮條件 範例
固定大小 k 題目直接給定視窗大小 right - left + 1 > k LC 567 (Permutation in String)
可變(求最小) 找最小的合法視窗 視窗合法時就收縮 LC 76 (Min Window Substring)
可變(求最大) 找最大的合法視窗 視窗不合法時才收縮 LC 3 (Longest No-Repeat)
恰好 K → 至多 K 統計符合精確限制的視窗數 不適用 — 用相減技巧 LC 992, LC 1248
可變(求最大),規則定義在每一對/每三個元素上 「視窗內沒有 i, j, k 使得 …」 新進元素會破壞規則時才收縮 LC 4067 (Restricted Pair Sums)

模板與演算法

六個模板涵蓋所有必會的滑動視窗型態,第七個則處理「難的不是迴圈,而是合法性判斷本身」的情況。 模板 2 是第一個要背到能默寫的 — 這個家族裡所有可變大小視窗,都只是換掉它的合法性判斷和結果更新而已。

模板比較表

# 模板 型態 結果更新 時間/空間 代表題
1 固定大小視窗 for i + 逐出 i - k i >= k - 1 時判斷 O(n) / O(k) LC 643, 438, 567
2 先擴後縮(while 不變量) for right + while invalid: shrink 任何合法視窗 O(n) / O(k) LC 1004, 904 — 3–7 的基底
3 滿足 P 的最長視窗 不合法時收縮 max(res, r - l + 1) O(n) / O(k) LC 3, 424, 1004
4 滿足 P 的最短視窗 合法時收縮 min(res, r - l + 1) O(n) / O(k) LC 209, 76
5 字元計數視窗(have/need) 次數表 + 匹配計數器 在 have == need 時更新 O(n) / O(charset) LC 76, 438, 567
6 用「至多」相減湊出「恰好 K」 跑兩次至多 count += r - l + 1 O(n) / O(k) LC 992, 1248, 930
7 遺傳性限制 — 只檢查新進元素 while joins(a[r]): shrink,再加入 max(res, r - l + 1) O(n · V) / O(V) LC 4067

第 3 列和第 4 列只差一個字:最長是在視窗不合法時收縮,最短是在合法時收縮。 這個字寫錯,答案就會默默地錯掉。

模板 1:固定大小視窗 — LC 643 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「每個長度為 k 的子陣列/子字串」、「k 個連續元素的最大平均」、「p 的字母重排」 (視窗寬度就是 len(p))。k 已給定、n ≤ 10⁵ → 預期 O(n),所以每個視窗都從頭重算(O(n·k))正是要被排除的做法。

關鍵 — 加入索引 i、逐出 i - k 之後,狀態裡剛好是 nums[i-k+1 .. i]; 每一步視窗只進一個、出一個,所以狀態是 O(1) 更新,而不是重建。

python
# python
# LC 643 - Maximum Average Subarray I
# IDEA: add nums[i], evict nums[i - k] once the window is over-full, measure once it is k wide
# time = O(n), space = O(1)   (a counting map instead of a sum makes it O(k))
def findMaxAverage(nums, k):
    window_sum = 0
    best = float("-inf")
    for i in range(len(nums)):
        window_sum += nums[i]                 # add the newcomer
        if i >= k:
            window_sum -= nums[i - k]         # evict the element that fell out
        if i >= k - 1:                        # window is exactly k wide from here on
            best = max(best, window_sum)
    return best / k
java
// java
// LC 643 - Maximum Average Subarray I
// IDEA: add nums[i], evict nums[i - k] once the window is over-full, measure once it is k wide
// time = O(n), space = O(1)
public double findMaxAverage(int[] nums, int k) {
    int windowSum = 0;
    int best = Integer.MIN_VALUE;
    for (int i = 0; i < nums.length; i++) {
        windowSum += nums[i];                // add the newcomer
        if (i >= k) {
            windowSum -= nums[i - k];        // evict the element that fell out
        }
        if (i >= k - 1) {                    // exactly k wide from here on
            best = Math.max(best, windowSum);
        }
    }
    return (double) best / k;
}

複雜度

  • 時間 O(n):只有一個 for i 迴圈;每一步做一次加入、至多一次逐出。
  • 空間 O(1):一個累計總和。若狀態是計數表(LC 438 / 567),最多 k 個鍵,是 O(k); 改用固定的 int[26] 又回到 O(1)。

陷阱

  • 視窗還沒滿就量:少了 if i >= k - 1,[5, -10, 1]、k = 2 會拿只有一個元素的視窗 [5] 來比,回傳 2.5 而不是 −2.5。
  • best 從 0 開始:全負的輸入如 [-1]、k = 1 會回傳 0 而不是 −1。
  • 計數表裡降到 0 的鍵一定要刪掉,否則直接比較兩個一般 dict(window == need)時, {'a': 0} 和 {} 會被判成不相等(LC 438)。

不適用時 — 沒給 k,題目問的是長度(「最長」、「最短」) → 模板 3/ 模板 4。

練習 — LC 643 · LC 219 (視窗是一個 set) · LC 1456 · LC 567 ⭐ (計數表,和 need 比較) · LC 438 (記下每個起點)

模板 2:先擴後縮 — while 不變量 — LC 1004 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「最多 k 個 … 的最長子陣列」、「最多可以翻轉/替換/刪除 k 次」, 任何「視窗越大只會越不合法」的連續區間題。n ≤ 10⁵ → 預期 O(n)。

關鍵 — 每輪迴圈結束時 [left, right] 都是合法的,而 left 永遠不會往回走, 所以每個元素最多被加入一次、移除一次:不管合法性判斷怎麼寫,都是 O(n)。

整份文件最重要的一個慣用寫法。 一個 for 推進 right 並加入元素;一個 while 推進 left 直到視窗重新合法:

text
for right in range(n):         # 1. expand: the window may now be invalid
    add(a[right])
    while not valid(window):   # 2. restore the invariant (may run 0 times)
        remove(a[left]); left += 1
    update_result(left, right)  # 3. the window is valid HERE, and only here

那三個空位就是整個設計空間:add/remove 維護什麼、valid 判斷什麼、 update_result 記錄什麼。模板 3–6 就是把這個迴圈的空位填滿而已。在 LC 1004 裡, 狀態只是一個整數 — 視窗裡 0 的個數,而 valid 就是 zeros <= k:

python
# python
# LC 1004 - Max Consecutive Ones III
# IDEA: grow right; while the window holds more than k zeros, shrink left; measure when valid
# time = O(n), space = O(1)
def longestOnes(nums, k):
    left = 0
    zeros = 0                                 # window state: zeros inside [left, right]
    best = 0
    for right in range(len(nums)):
        if nums[right] == 0:                  # 1. add
            zeros += 1
        while zeros > k:                      # 2. restore the invariant
            if nums[left] == 0:
                zeros -= 1
            left += 1
        best = max(best, right - left + 1)    # 3. valid here, so measure here
    return best
java
// java
// LC 1004 - Max Consecutive Ones III
// IDEA: grow right; while the window holds more than k zeros, shrink left; measure when valid
// time = O(n), space = O(1)
public int longestOnes(int[] nums, int k) {
    int left = 0;
    int zeros = 0;                           // window state: zeros inside [left, right]
    int best = 0;
    for (int right = 0; right < nums.length; right++) {
        if (nums[right] == 0) {
            zeros++;                         // 1. add
        }
        while (zeros > k) {                  // 2. restore the invariant
            if (nums[left] == 0) {
                zeros--;
            }
            left++;
        }
        best = Math.max(best, right - left + 1);  // 3. valid here
    }
    return best;
}

複雜度

  • 時間 O(n):while 雖然是巢狀的,但 left 只會往前、且不會超過 right, 整個執行過程中最多前進 n 次。
  • 空間 O(1)(一個計數器);一般情況是 O(k),取決於視窗狀態存了什麼。

陷阱

  • 在 while 裡面或之前更新結果,量到的是不合法的視窗: [0, 0]、k = 0 會回傳 1 而不是 0。
  • 用 if 取代 while:狀態是一張表時,一個新進元素可能需要收縮好幾次 ("abcb" 在「至多 2 種」時要縮兩次)。LC 1004 只有一個整數的狀態會把這點藏起來。
  • 這個迴圈只有在合法性單調時才成立:合法視窗縮小後仍然合法。 有負數的總和就不是:在 [2, -2] 上求 sum <= 0 的最長子陣列答案是 2,但視窗在 -2 進來之前就丟掉了 2,回傳 1。

不適用時 — 值可能為負、條件又是總和 → 前綴和 + 雜湊表 (prefix_sum.md,LC 560)。

練習 — LC 1004 · LC 904 (至多 2 種) · LC 424 ⭐ (合法性 = len − maxFreq ≤ k) · LC 1838 (先排序,再開視窗)

模板 3:滿足 P 的最長視窗 — LC 3 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「滿足 … 的最長子字串/子陣列」:沒有重複字元、至多 k 種字元、至多替換 k 次。 n ≤ 5·10⁴ → 預期 O(n)。

關鍵 — 視窗不合法時才收縮,所以每輪迴圈結束時,視窗就是以 right 結尾的最長合法視窗; 在 while 之後記錄 right - left + 1,絕對不要寫在裡面。

python
# python
# LC 3 - Longest Substring Without Repeating Characters
# IDEA: a set holds the window's characters; shrink from the left until s[right] is not in it
# time = O(n), space = O(min(n, charset))
def lengthOfLongestSubstring(s):
    in_window = set()
    left = 0
    best = 0
    for right in range(len(s)):
        while s[right] in in_window:          # invalid -> shrink until the duplicate is gone
            in_window.remove(s[left])
            left += 1
        in_window.add(s[right])
        best = max(best, right - left + 1)    # valid here, so measure here
    return best

第二種寫法,留著是因為它是一個不同的技巧:記下每個字元上次出現的索引, 遇到重複時一步把 left 跳過去,而不是一格一格縮。

python
# python
# LC 3 - jump variant
# IDEA: last_seen[ch] = latest index of ch; on a repeat, jump left to last_seen[ch] + 1
# time = O(n), space = O(min(n, charset))
def lengthOfLongestSubstring_jump(s):
    last_seen = {}
    left = 0
    best = 0
    for right, ch in enumerate(s):
        if ch in last_seen:
            left = max(left, last_seen[ch] + 1)   # max: a stale index must never pull left back
        last_seen[ch] = right
        best = max(best, right - left + 1)
    return best
java
// java
// LC 3 - Longest Substring Without Repeating Characters
// IDEA: a set holds the window's characters; shrink from the left until s[right] is not in it
// time = O(n), space = O(min(n, charset))
public int lengthOfLongestSubstring(String s) {
    Set<Character> inWindow = new HashSet<>();
    int left = 0;
    int best = 0;
    for (int right = 0; right < s.length(); right++) {
        char ch = s.charAt(right);
        while (inWindow.contains(ch)) {      // invalid -> shrink until ch is gone
            inWindow.remove(s.charAt(left));
            left++;
        }
        inWindow.add(ch);
        best = Math.max(best, right - left + 1);
    }
    return best;
}

複雜度

  • 時間 O(n):left 只會往前,所以內層 while 總共最多跑 n 次;跳躍版連內層迴圈都沒有。
  • 空間 O(min(n, charset)):in_window/last_seen 每種字元最多一筆。

試跑

text
s = "abba"            (jump variant)
right=0 'a'  left=0  window "a"   best=1
right=1 'b'  left=0  window "ab"  best=2
right=2 'b'  seen at 1 -> left=2  window "b"   best=2
right=3 'a'  seen at 0 -> left=max(2, 1)=2   window "ba"  best=2

陷阱

  • 跳躍時少了 max:在 "abba" 上,最後那個 'a' 上次出現在 0,left 會被拉回 1, 答案變成 3("bba")。
  • 在收縮迴圈之前記錄 best,會把重複字元也算進去:"bb" 回傳 2。
  • "" 必須回傳 0;兩個版本都會,因為迴圈根本不會執行。

不適用時 — 視窗必須包含某些東西(t 的全部字元、某個目標和)→ 那是最短視窗題, 模板 4/ 模板 5。

練習 — LC 3 ⭐ · LC 159 · LC 340 (至多 k 種:一張計數表,len(map) > k 時收縮) · LC 424 ⭐

模板 4:滿足 P 的最短視窗 — LC 209 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「總和 ≥ target 的最短子陣列」、「能覆蓋 … 的最小視窗」, 而且值非負,視窗變大永遠不會更差。n ≤ 10⁵ → O(n)。

關鍵 — 視窗合法時就收縮,每次收縮之前先記錄長度。 這就是 模板 3 把 while 條件反過來、 再把結果更新移進迴圈裡 — 其他一個字都沒變。

python
# python
# LC 209 - Minimum Size Subarray Sum
# IDEA: grow right; while the sum still reaches target, record the length and shrink left
# time = O(n), space = O(1)
def minSubArrayLen(target, nums):
    left = 0
    window_sum = 0
    best = len(nums) + 1                      # sentinel: longer than any real window
    for right in range(len(nums)):
        window_sum += nums[right]
        while window_sum >= target:           # valid -> record, then try a shorter one
            best = min(best, right - left + 1)
            window_sum -= nums[left]
            left += 1
    return 0 if best == len(nums) + 1 else best
java
// java
// LC 209 - Minimum Size Subarray Sum
// IDEA: grow right; while the sum still reaches target, record the length and shrink left
// time = O(n), space = O(1)
public int minSubArrayLen(int target, int[] nums) {
    int left = 0;
    int windowSum = 0;
    int best = Integer.MAX_VALUE;
    for (int right = 0; right < nums.length; right++) {
        windowSum += nums[right];
        while (windowSum >= target) {        // valid -> record, then try a shorter one
            best = Math.min(best, right - left + 1);
            windowSum -= nums[left];
            left++;
        }
    }
    return best == Integer.MAX_VALUE ? 0 : best;
}

複雜度

  • 時間 O(n):和模板 2 一樣,left 在整個執行過程中最多前進 n 次。
  • 空間 O(1):一個累計總和。

追問 — O(n log n)。 LC 209 本身在 O(n) 解之後會追問 O(n log n) 解。 值都是正數時前綴和嚴格遞增,所以對每個起點 left,最短的終點只要一次二分搜尋: 第一個滿足 prefix[j] ≥ prefix[left] + target 的位置。它比視窗慢,只有在追問要求時才給這個答案。

python
# python
# LC 209 - Minimum Size Subarray Sum (follow-up)
# IDEA: prefix sums are increasing; per start, binary-search the first end reaching target
# time = O(n log n), space = O(n)
from bisect import bisect_left

def minSubArrayLen_log(target, nums):
    n = len(nums)
    prefix = [0] * (n + 1)                    # prefix[i] = sum(nums[:i]), increasing
    for i in range(n):
        prefix[i + 1] = prefix[i] + nums[i]
    best = n + 1
    for left in range(n):
        # smallest right_end with prefix[right_end] - prefix[left] >= target
        right_end = bisect_left(prefix, prefix[left] + target)
        if right_end <= n:
            best = min(best, right_end - left)
    return 0 if best == n + 1 else best

陷阱

  • 沒有合法視窗:target = 11, nums = [1,1,1,1,1,1,1,1] 必須回傳 0,而不是哨兵值。
  • 在收縮之後才記錄,量到的是已經不合法的視窗: target = 4, nums = [1, 4, 4] 會回報 0 而不是 1。
  • 有負數就壞掉:target = 3, nums = [1, -1, 3] 回傳 3,因為總和一掉到 target 以下收縮就停了, 永遠走不到 [3](長度 1)。

不適用時 — 值可能為負 → 在前綴和上用單調雙端佇列,LC 862 (monotonic_queue.md)。

練習 — LC 209 · LC 1658 (移除的最短 = 保留總和為 total − x 的最長) · LC 76 ⭐ (合法性是「覆蓋」,模板 5) · LC 862 (有負數:雙端佇列)

模板 5:帶 have/need 計數器的字元計數視窗 — LC 76 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「s 中包含 t 所有字元(含重複次數)的最小視窗」、 「p 的排列/字母重排是不是 s 的子字串」。|s|, |t| ≤ 10⁵ → O(n + m)。

關鍵 — need 記錄視窗對每個字元還欠多少,一個整數(missing)記錄總共還欠多少; missing == 0 時視窗剛好覆蓋 t,所以合法性判斷是 O(1),而不是每一步都做 O(charset) 的次數表比對。

python
# python
# LC 76 - Minimum Window Substring
# IDEA: need[c] = copies of c still owed (negative = surplus); missing = total owed;
#       when missing hits 0, drop surplus from the left, record, then break the window
# time = O(n + m), space = O(charset)
from collections import Counter

def minWindow(s, t):
    need = Counter(t)
    missing = len(t)
    best = ""
    left = 0
    for right, c in enumerate(s):
        if need[c] > 0:                       # c was owed -> one less missing
            missing -= 1
        need[c] -= 1
        if missing == 0:                      # window covers t
            while need[s[left]] < 0:          # drop surplus characters from the left
                need[s[left]] += 1
                left += 1
            if not best or right - left + 1 < len(best):
                best = s[left:right + 1]
            need[s[left]] += 1                # give up s[left]: the window owes it again
            missing += 1
            left += 1
    return best
java
// java
// LC 76 - Minimum Window Substring
// IDEA: count map of the window vs need; valid = distinct chars whose need is fully met;
//       while valid == need.size(), record and shrink
// time = O(n + m), space = O(charset)
public String minWindow(String s, String t) {
    Map<Character, Integer> need = new HashMap<>();
    Map<Character, Integer> window = new HashMap<>();
    for (char c : t.toCharArray()) {
        need.put(c, need.getOrDefault(c, 0) + 1);
    }
    int left = 0;
    int valid = 0;                           // distinct chars whose need is fully met
    int start = 0;
    int minLen = Integer.MAX_VALUE;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        window.put(c, window.getOrDefault(c, 0) + 1);
        if (need.containsKey(c) && window.get(c).equals(need.get(c))) {
            valid++;
        }
        while (valid == need.size()) {       // valid -> record, then shrink
            if (right - left + 1 < minLen) {
                minLen = right - left + 1;
                start = left;
            }
            char out = s.charAt(left);
            left++;
            if (need.containsKey(out) && window.get(out).equals(need.get(out))) {
                valid--;                     // about to drop below what t needs
            }
            window.put(out, window.get(out) - 1);
        }
    }
    return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
}

複雜度

  • 時間 O(n + m):建 need 花 O(m),之後 s 的每個索引進出視窗各一次。
  • 空間 O(charset):need 和 window 每種字元最多一個鍵。

陷阱

  • 在 Java 裡用 == 比較 Integer 物件:超過 127 的數不會被快取, 所以 window.get(c) == need.get(c) 在長輸入上會出錯。用 .equals,或改用 int[128]。
  • 重複次數:s = "a", t = "aa" 必須回傳 "";只數字元種類而不數份數會回傳 "a"。
  • valid(Java)數的是已滿足的字元種類,和 need.size() 比; missing(Python)數的是還欠的份數,和 0 比。兩者混用就永遠到不了目標。

不適用時 — 視窗大小固定(len(p),LC 438 / 567)→ 在 模板 1 的迴圈裡直接比較兩張計數表;計數器只有在視窗長度不固定時才划算。 如果 t 的順序有意義,那是子序列題(2_pointers.md)。

練習 — LC 76 ⭐ · LC 567 ⭐ (固定大小,同樣的計數) · LC 438 · LC 30 (以單字取代字元, sliding_window_advanced.md)

模板 6:用「至多」相減湊出「恰好 K」 — LC 992 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「恰好 k 個相異整數/k 個奇數/總和等於 k 的子陣列個數」,值為非負。 n ≤ 2·10⁴ → O(n);要排除的是 O(n²) 的列舉。

關鍵 — 一旦 [left, right] 是以 right 結尾的最長合法視窗,它的每個後綴也都合法, 所以 count += right - left + 1 數的就是以 right 結尾、至多 k 的子陣列; 而恰好 k = atMost(k) − atMost(k − 1),因為後者的集合包含在前者之中。

text
Exactly K = At Most K - At Most (K-1)

「恰好 k」不是單調的(把恰好 2 種的視窗縮小可能變成恰好 1 種),所以不能直接拿來驅動 while; 「至多 k」是單調的,這就是相減技巧存在的理由。

python
# python
# LC 992 - Subarrays with K Different Integers
# IDEA: at_most(k) counts subarrays with <= k distinct, adding right - left + 1 per right;
#       exactly k = at_most(k) - at_most(k - 1)
# time = O(n), space = O(k)
def subarraysWithKDistinct(nums, k):
    def at_most(k):
        if k == 0:
            return 0
        freq = {}
        left = 0
        count = 0
        for right in range(len(nums)):
            freq[nums[right]] = freq.get(nums[right], 0) + 1
            while len(freq) > k:              # shrink while more than k distinct
                freq[nums[left]] -= 1
                if freq[nums[left]] == 0:
                    del freq[nums[left]]
                left += 1
            count += right - left + 1         # every suffix of [left, right] is valid
        return count

    return at_most(k) - at_most(k - 1)
java
// java
// LC 992 - Subarrays with K Different Integers
// IDEA: atMostK counts subarrays with <= k distinct, adding right - left + 1 per right;
//       exactly k = atMostK(k) - atMostK(k - 1)
// time = O(n), space = O(k)
public int subarraysWithKDistinct(int[] nums, int k) {
    return atMostK(nums, k) - atMostK(nums, k - 1);
}

private int atMostK(int[] nums, int k) {
    if (k == 0) {
        return 0;
    }
    Map<Integer, Integer> freq = new HashMap<>();
    int left = 0;
    int count = 0;
    for (int right = 0; right < nums.length; right++) {
        freq.put(nums[right], freq.getOrDefault(nums[right], 0) + 1);
        while (freq.size() > k) {            // shrink while more than k distinct
            freq.put(nums[left], freq.get(nums[left]) - 1);
            if (freq.get(nums[left]) == 0) {
                freq.remove(nums[left]);
            }
            left++;
        }
        count += right - left + 1;           // every suffix of [left, right] is valid
    }
    return count;
}

複雜度

  • 時間 O(n):跑兩次模板 2 的迴圈,因為 left 只會往前,每次都是 O(n)。
  • 空間 O(k):每次計數之前,freq 都已縮回至多 k 個鍵。

試跑

text
nums = [1, 2, 1, 2, 3], k = 2
at_most(2): ends at r=0..4 add 1, 2, 3, 4, 2   -> 12
at_most(1): ends at r=0..4 add 1, 1, 1, 1, 1   -> 5
exactly 2  = 12 - 5 = 7
  [1,2] [1,2,1] [1,2,1,2] [2,1] [2,1,2] [1,2] [2,3]

陷阱

  • 計數降到 0 時沒把鍵從 freq 刪掉:len(freq) 會把過期的鍵也算進去,while 停不下來, 在 [1, 2, 1, 2, 3], k = 2 上 left 會跑出陣列尾端(IndexError)。
  • 用 count += 1 取代 right - left + 1,數到的是視窗而不是子陣列: [1, 2, 1, 2, 3], k = 2 的結果會遠少於 7。
  • 直接用「恰好 k」驅動 while 會少算:收縮會丟掉之後某個 right 還需要的左端點。

不適用時 — 值可能為負(LC 560「和為 k 的子陣列」)→ 至多就不再單調; 改用前綴和 + 雜湊表(prefix_sum.md)。對二元陣列來說, 同樣的計數也可以用前綴和計數做出來(LC 930、LC 1248)。

練習 — LC 992 · LC 713 (單獨用計數那一行,乘積 < k) · LC 930 · LC 1248 (奇數 → 1、偶數 → 0) · LC 2062 (遇到子音就重置視窗)

題目 LC# 難度 轉換方式 關鍵洞見
Subarrays with K Different Integers 992 Hard 恰好 K 個相異 = atMost(K) - atMost(K-1) 核心範例
Count Vowel Substrings of a String 2062 Medium 恰好 5 個母音 = atMost(5) - atMost(4) 遇到子音就重置視窗(只能全母音)
Count Nice Subarrays 1248 Medium 恰好 K 個奇數 = atMost(K) - atMost(K-1) 把奇數轉成 1、偶數轉成 0
Binary Subarrays With Sum 930 Medium 總和恰好 K = atMost(K) - atMost(K-1) 子陣列和
Longest Substring with At Most K Distinct 340 Medium 直接用 atMost(K) 求最大長度 不用相減
Fruits Into Baskets 904 Medium atMost(2) 個相異,求最大長度 K=2 的簡化版
Max Consecutive Ones III 1004 Medium atMost(K) 個 0,求最大長度 統計 0 的個數 ≤ K

受限字母集的重置變化(LC 2062)、單趟前綴和的替代解(LC 1248)、 圖解證明,以及「為什麼直接做恰好 K 很難」的論證,全都在 sliding_window_advanced.md。

模板 7:遺傳性限制 — 只檢查新進元素 — LC 4067 優先度 3/5 — 值得會 — 多半是必備模式的變形

辨識 — 「最長的子陣列,使得其中沒有任兩個/任三個元素滿足 …」:合法性規則定義在視窗內的 每一對或每三個元素上,而不是某個次數或總和。值域很小(值 ≤ 500)、n ≤ 1000, 就是提示:判斷可以掃值域,而不必掃視窗。

關鍵 — nums[right] 加入之前的視窗是合法的(迴圈不變量),所以唯一可能新出現的違規, 一定用到 nums[right];valid() 只問新進元素一個問題,從不去問整個視窗。

模板 3–6 倚賴兩件它們從來不必明說的事。這兩件事都要先確認,這個迴圈才站得住; 而第二件事就是讓它變快的關鍵:

  1. 合法性是遺傳的 — 合法視窗的每個子視窗都合法。這正是 left 只會往右走的理由: 一旦 [left, right] 不合法,所有包含它的視窗也都不合法。 丟掉元素永遠不可能製造出被禁止的配對或三元組,所以「沒有 i, j, k 使得 nums[i] + nums[j] == nums[k]」是遺傳的。「總和等於 k」在有負數時不是, 這就是為什麼 LC 560 是前綴和題而不是視窗題。
  2. 新進元素加入前的視窗是合法的 — 這是每一輪迴圈開頭的不變量。因此唯一可能新出現的違規, 只會是用到 nums[right] 的那些;valid() 也就從「視窗裡有沒有任何三元組」(每步 O(w²),總共 O(n³)) 縮成「nums[right] 會不會和已經在裡面的元素湊成三元組」。

在 LC 4067 裡,新進元素 v 可以扮演兩種角色,每個問題都只需掃一次值域 1..V, 而不是掃視窗:

text
v is the sum    :  a + b == v   with a and b both in the window   (a == b needs two copies)
v is an addend  :  a and a + v  both in the window

值都 ≥ 1,所以一對數的和一定嚴格大於兩個加數:和某一對配上的 k 永遠不會是那一對自己的索引, 「三個相異索引」的條件自然成立 (兩個不同的 3 湊出 3 + 3 = 6 仍然算數,而「要有兩份」的檢查看得到它)。

python
# python
# LC 4067 Longest Subarray With Restricted Pair Sums
# IDEA : SLIDING WINDOW OVER VALUE COUNTS — test only the newcomer
# time = O(n * V), space = O(V)   (V = max(nums) <= 500)
class Solution(object):
    def maxSubarray(self, nums):
        n = len(nums)
        if n <= 2:                       # fewer than 3 elements can not form a triple
            return n
        max_val = max(nums)
        cnt = [0] * (max_val + 1)        # value -> copies inside the window
        left = 0
        best = 0
        for right in range(n):
            v = nums[right]
            # the window WITHOUT v is valid (loop invariant), so the only
            # triples that can appear are the ones that use v
            while self.joins_triple(cnt, v, max_val):
                cnt[nums[left]] -= 1     # removing can never create a triple
                left += 1
            cnt[v] += 1
            best = max(best, right - left + 1)
        return best

    def joins_triple(self, cnt, v, max_val):
        # 1) v is the sum : a + b == v, a <= b, both present (a == b needs two copies)
        for a in range(1, v // 2 + 1):
            b = v - a
            if a == b:
                if cnt[a] >= 2:
                    return True
            elif cnt[a] >= 1 and cnt[b] >= 1:
                return True
        # 2) v is an addend : a and a + v both present (a + v > a, so distinct indices)
        for a in range(1, max_val - v + 1):
            if cnt[a] >= 1 and cnt[a + v] >= 1:
                return True
        return False
java
// java
// LC 4067 - Longest Subarray With Restricted Pair Sums
// IDEA: Sliding window over value counts; the window before nums[r] joins is valid,
//       so only a triple that uses nums[r] can appear — test the newcomer, not the window
// time = O(n * V), space = O(V)   (V = max(nums) <= 500)
public int maxSubarray(int[] nums) {
    int n = nums.length;
    if (n <= 2) return n;                 // fewer than 3 elements can not form a triple
    int maxVal = 0;
    for (int x : nums) maxVal = Math.max(maxVal, x);
    int[] cnt = new int[maxVal + 1];      // value -> copies inside the window
    int l = 0, best = 0;
    for (int r = 0; r < n; r++) {
        int v = nums[r];
        // the window WITHOUT v is valid, so shrink until v completes no triple
        while (joinsTriple(cnt, v, maxVal)) {
            cnt[nums[l++]]--;             // removing can never create a triple
        }
        cnt[v]++;
        best = Math.max(best, r - l + 1);
    }
    return best;
}

private boolean joinsTriple(int[] cnt, int v, int maxVal) {
    // 1) v is the sum: a + b == v, a <= b, both present (a == b needs two copies)
    for (int a = 1; a <= v / 2; a++) {
        int b = v - a;
        if (a == b) {
            if (cnt[a] >= 2) return true;
        } else if (cnt[a] >= 1 && cnt[b] >= 1) {
            return true;
        }
    }
    // 2) v is an addend: a and a + v both present (a + v > a, so distinct indices)
    for (int a = 1; a + v <= maxVal; a++) {
        if (cnt[a] >= 1 && cnt[a + v] >= 1) return true;
    }
    return false;
}

複雜度

  • 時間 O(n · V):每個 right 在每次收縮時呼叫一次 joins_triple,最後再多一次; left 總共最多移動 n 次,所以總呼叫次數是 O(n);每次呼叫是掃兩遍值域 1..V。
  • 空間 O(V):cnt 每個值一格。

試跑

text
nums = [2,3,5,3,2,1]

r=0  v=2  window {}       joins nothing                         -> [2]        len 1
r=1  v=3  window {2}      2+3=5 absent, no a+b=3                -> [2,3]      len 2
r=2  v=5  window {2,3}    sum: 2+3 = 5     -> drop 2 -> {3} ok  -> [3,5]      len 2
r=3  v=3  window {3,5}    3+3=6, 3+5=8 absent, no a+b=3         -> [3,5,3]    len 3  <- answer
r=4  v=2  window {3,5,3}  addend: 3 and 3+2=5 -> drop 3, still  -> drop 5 -> [3,2]  len 2
r=5  v=1  window {3,2}    addend: 2 and 2+1=3 -> drop 3         -> [2,1]      len 2

陷阱

  • 先檢查、再收縮、最後加入。 模板 2 是先加入再收縮,在 valid() 讀的是次數或總和時沒問題。 但這裡問的是「v 和已經在裡面的元素合不合得來」,所以問的時候 v 不能在 cnt 裡; 先加入之所以碰巧也能過,只是因為值都 ≥ 1,v 永遠不可能成為自己的搭檔。
  • 檢查整個視窗而不是只檢查新進元素 — 正確,但 O(n³),n = 1000 就超時。 題目要你注意到的正是那個不變量。
  • 忘了 a == b 需要兩份。 [2, 2, 4] 不合法;[2, 4] 合法。
  • O(n²) 的第一版維護一張 pair_sum -> count 表加一個 conflicts 計數器, 新進元素和視窗內每個元素配對時都要更新兩者,加入/移除是互為鏡像的記帳,很容易寫錯。 上面的版本就是那個解法多想到一件事之後的樣子 — 只有新進元素可能出現在新的三元組裡 — 所以根本不必記任何配對。配對表只有在值域無上限時才值得用; 解法檔 兩種都有。

不適用時 — 規則不具遺傳性:如果丟掉一個元素可能讓不合法視窗變合法、也可能讓合法視窗變不合法, left 就不是單調的,這個迴圈不成立(有負數時的「總和等於 k」,LC 560)→ prefix_sum.md。

練習 — LC 4067 · LC 3 (「沒有兩個相同」這條配對規則,用 set 檢查新進元素) · LC 1695 (同上,改成算總和)

練習路徑

階段 題目(由易到難) 新增了什麼
核心 ⭐ LC 567 ok ⭐ · LC 3 未評定 ⭐ · LC 424 ok ⭐ · LC 76 ok ⭐ 帶計數的固定視窗;最長視窗;用過期最大值的合法性判斷;have/need 計數器
練習 LC 643 未評定 · LC 1004 未評定 · LC 209 未評定 · LC 438 again · LC 904 ok · LC 1248 ok 每種型態一題:固定、先擴後縮、最短、記下每個起點、至多 2 種、恰好 k
延伸 LC 4067 again · LC 992 again · LC 30 未評定 · LC 239 again 檢查新進元素;用相減做恰好 k;單字層級的視窗;視窗最大值 → monotonic_queue.md

「練習」那一列的錨定題都在上面的卡片裡;LC 438 again 在 hash_map.md,LC 904 ok 與 LC 1248 ok 在 sliding_window_advanced.md。其餘的實作題都在 sliding_window_examples.md。

總結與快速查表

該用哪個模板?— 決策表

題型 模板 關鍵模式 範例
找固定大小的視窗 1 — 固定大小 for i 搭配大小追蹤 LC 438, 567, 643
找最大的合法視窗 3 — 最長視窗 for-while,不合法時收縮 LC 3, 424, 1004
找最小的合法視窗 4 — 最短視窗 for-while,合法時收縮 LC 76, 209
比對字元多重集合 5 — 字元計數(have/need) 次數表 + 匹配計數器 LC 76, 438, 567
計算合法子陣列個數 6 — 計數那一行 count += right-left+1 LC 713, 992
恰好 K 個相異/唯一 6 — 至多相減 atMostK(k) - atMostK(k-1) LC 992, 1248, 930
規則定義在視窗內每一對/每三個元素上 7 — 只檢查新進元素 先確認遺傳性,再 while joins(v): shrink LC 4067
用 O(1) 求視窗最大/最小 不在本文件 單調雙端佇列 LC 239 → monotonic_queue.md
值可能是負數 根本不是視窗題 前綴和 + HashMap LC 560, 974 → prefix_sum.md

怎麼用:先確認題目的目標(最大/最小/計數/恰好),再挑對應的模板。模板 2 是第 2–7 列的底層 — 它是那個迴圈,不是另一個答案。

各模板複雜度速查

模板 時間 空間 空間花在哪
1 — 固定大小 O(n) O(k) 視窗本身的內容
2 — 先擴後縮 O(n) O(k) 視窗狀態裝了什麼就是什麼
3 — 最長視窗 O(n) O(k) 次數表/計數器
4 — 最短視窗 O(n) O(k) 次數表/計數器
5 — 字元計數 O(n + m) O(charset) 兩張大小取決於字母集的表
6 — 至多相減湊恰好 K O(n) O(k) 一張表,陣列掃兩趟
7 — 只檢查新進元素 O(n · V) O(V) 每個值一個計數;檢查掃的是值域,不是視窗

全部都是 O(n),因為 left 永遠不往回走:每個元素最多被加入一次、移除一次。 優化:字母集有限時,改用固定的 int[26] / int[128] 陣列取代 HashMap — 漸近複雜度一樣,但實際快很多,比較起來也更單純。

依模式分類的題目

固定大小視窗

題目 LC # 關鍵技巧 難度
Find All Anagrams in a String 438 字元次數比對 Medium
Permutation in String 567 字元次數比對 Medium
Maximum Average Subarray I 643 固定視窗求和 Easy
Contains Duplicate II 219 固定視窗 + HashSet Easy
Maximum Number of Vowels 1456 固定視窗計數 Medium

可變大小 — 最大長度

題目 LC # 關鍵技巧 難度
Longest Substring Without Repeating Characters 3 追蹤字元唯一性 Medium
Longest Repeating Character Replacement 424 次數表 + 最大字元次數 Medium
Max Consecutive Ones III 1004 K 次翻轉限制 Medium
Longest Substring with At Most K Distinct Characters 340 統計相異字元數 Medium
Longest Substring with At Most Two Distinct Characters 159 兩種相異字元的限制 Medium
Longest Subarray With Restricted Pair Sums 4067 遺傳性限制 — 只在值域上檢查新進元素 Medium

可變大小 — 最小長度

題目 LC # 關鍵技巧 難度
Minimum Window Substring 76 追蹤字元覆蓋程度 Hard
Minimum Size Subarray Sum 209 比較累加和 Medium
Smallest Subarray with Sum ≥ K 862 前綴和 + 雙端佇列 Hard
Minimum Window with Characters 1176 飲食計畫限制 Hard

子陣列計數

題目 LC # 關鍵技巧 難度
Subarray Product Less Than K 713 乘積限制 Medium
Subarrays with K Different Integers 992 恰好 K = 至多 K - 至多 (K-1) Hard
Count Vowel Substrings of a String 2062 恰好 5 個母音 = atMost(5) - atMost(4)(遇子音重置) Medium
Number of Subarrays with Bounded Maximum 795 值域上下界限制 Medium
Count Number of Nice Subarrays 1248 統計奇數個數 Medium

進階滑動視窗

題目 LC # 關鍵技巧 難度
Sliding Window Maximum 239 單調雙端佇列 Hard
Sliding Window Median 480 兩個堆積 Hard
Minimum Swaps to Group All 1’s Together 1151 固定視窗上的最佳化 Medium
Grumpy Bookstore Owner 1052 狀態變更的最佳化 Medium

常見模式與技巧

字元次數追蹤

python
# Track character counts in window
window = {}
window[char] = window.get(char, 0) + 1

# Remove character from window
window[char] -= 1
if window[char] == 0:
    del window[char]

合法性條件

python
# Common validity checks
def is_valid_permutation(window, target):
    return window == target

def is_valid_distinct_k(window, k):
    return len(window) <= k

def is_valid_sum(current_sum, target):
    return current_sum >= target

結果更新

python
# Maximum length problems
max_len = max(max_len, right - left + 1)

# Minimum length problems  
if is_valid:
    min_len = min(min_len, right - left + 1)

# Counting problems
count += right - left + 1  # All subarrays ending at 'right'

解題步驟

  1. 辨認模式:固定大小、可變求最大/最小,還是計數?
  2. 選模板:依模式挑對應的模板
  3. 定義視窗狀態:HashMap、集合、總和,還是計數器?
  4. 定義合法性:什麼情況下視窗算合法/不合法?
  5. 更新邏輯:什麼時候更新結果、怎麼更新?

常見錯誤與提醒

🚫 常見錯誤:

  • 迴圈結構寫錯(選錯模板)
  • 忘了正確維護視窗狀態
  • 合法性條件的邏輯寫反
  • 漏掉邊界情況(空輸入、只有一個元素)

✅ 最佳實務:

  • 字元次數類題目直接用 collections.Counter
  • 從 HashMap 移除元素時一定要處理歸零的情況
  • 用邊界情況測:空字串、單一字元、所有字元都相同
  • 想清楚題目要的是「恰好 k」還是「至多 k」
  • 「恰好 k」的題目:用「至多 k - 至多 (k-1)」

面試訊號

訊號 模式
「帶限制的最長子字串/子陣列」 可變視窗,右邊擴張、左邊收縮
「包含所有字元的最小視窗」 合法就收縮(LC 76)
「固定大小 k 的視窗」 固定視窗,兩端一起滑
「恰好 k 個相異/奇數/…」 AtMost(k) - AtMost(k-1)
「用 O(n) 求視窗最大/最小」 單調雙端佇列
「字串中的排列/字母重排」 固定視窗 + Counter 比對
「最長的子陣列,其中沒有任兩個/任三個元素使得 …」 先確認規則是遺傳的,再只檢查新進元素(模板 7)

其他內容在哪

想找什麼 去哪份文件
LC 424、1838、713、413、1151 的實作解法 — 以及 LC 438、567、763 放在哪 sliding_window_examples.md
至多 K 個相異的家族、帶重置或一次掃描的恰好 K、同時處理多個視窗、單字層級的視窗 sliding_window_advanced.md
完整的單調雙端佇列家族(LC 239、862、1438、1499) monotonic_queue.md
可能含負值的視窗 → 前綴和 + HashMap prefix_sum.md
相向逼近(而非一前一後)的指標 2_pointers.md