滑動視窗
範圍 — 依條件伸縮的視窗 — 固定大小、可變大小、至多 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 的那把尺。
題型分類
核心組成
- 雙指標:用
left和right界定視窗邊界 - 迴圈結構:
while-while:外層擴張,內層收縮for-while:for 負責擴張,while 負責收縮- 關鍵洞見:第一層迴圈找出可行解,第二層迴圈把它優化成最佳解
- 視窗狀態:追蹤當前視窗內的元素、次數或總和
- 合法性條件:定義視窗什麼時候合法/不合法



五種視窗型態
固定大小視窗
- 說明:視窗大小事先給定且固定不變
- 範例: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
# 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
// 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 直到視窗重新合法:
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
# 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
// 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
# 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
# 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
// 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每種字元最多一筆。
試跑
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
# 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
// 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
# 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
# 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
// 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),因為後者的集合包含在前者之中。
Exactly K = At Most K - At Most (K-1)
「恰好 k」不是單調的(把恰好 2 種的視窗縮小可能變成恰好 1 種),所以不能直接拿來驅動 while;
「至多 k」是單調的,這就是相減技巧存在的理由。
# 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
// 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 個鍵。
試跑
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 倚賴兩件它們從來不必明說的事。這兩件事都要先確認,這個迴圈才站得住; 而第二件事就是讓它變快的關鍵:
- 合法性是遺傳的 — 合法視窗的每個子視窗都合法。這正是
left只會往右走的理由: 一旦[left, right]不合法,所有包含它的視窗也都不合法。 丟掉元素永遠不可能製造出被禁止的配對或三元組,所以「沒有i, j, k使得nums[i] + nums[j] == nums[k]」是遺傳的。「總和等於 k」在有負數時不是, 這就是為什麼 LC 560 是前綴和題而不是視窗題。 - 新進元素加入前的視窗是合法的 — 這是每一輪迴圈開頭的不變量。因此唯一可能新出現的違規,
只會是用到
nums[right]的那些;valid()也就從「視窗裡有沒有任何三元組」(每步 O(w²),總共 O(n³)) 縮成「nums[right]會不會和已經在裡面的元素湊成三元組」。
在 LC 4067 裡,新進元素 v 可以扮演兩種角色,每個問題都只需掃一次值域 1..V,
而不是掃視窗:
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
# 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
// 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每個值一格。
試跑
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 |
常見模式與技巧
字元次數追蹤
# 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]
合法性條件
# 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
結果更新
# 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'
解題步驟
- 辨認模式:固定大小、可變求最大/最小,還是計數?
- 選模板:依模式挑對應的模板
- 定義視窗狀態:HashMap、集合、總和,還是計數器?
- 定義合法性:什麼情況下視窗算合法/不合法?
- 更新邏輯:什麼時候更新結果、怎麼更新?
常見錯誤與提醒
🚫 常見錯誤:
- 迴圈結構寫錯(選錯模板)
- 忘了正確維護視窗狀態
- 合法性條件的邏輯寫反
- 漏掉邊界情況(空輸入、只有一個元素)
✅ 最佳實務:
- 字元次數類題目直接用
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 |