Pattern chuyên dụng cho mọi bài có chữ "subarray liên tiếp" hoặc "substring" với điều kiện. Biến O(n²) thành O(n) bằng cách không bao giờ quay lại phần đã xét.
[left, right] trên
mảng/string, di chuyển hai biên theo điều kiện bài toán. Mỗi biên di chuyển một chiều
(chỉ tiến, không lùi) → tổng số lần di chuyển ≤ 2n → O(n).
Sliding Window là biến thể chuyên biệt của Two Pointers Same Direction. Khác biệt:
Brute force: thử mọi cặp (i, j) với i ≤ j → O(n²) cửa sổ. Sliding window: chỉ duyệt qua các cửa sổ "có ý nghĩa" theo đường monoton → O(n).
Cả hai biến thể đều có right tăng đơn điệu. Khác biệt: Fixed có left = right − k + 1; Variable có left tăng theo điều kiện.
| Tín hiệu trong đề | Biến thể |
|---|---|
| "subarray/substring kích thước k" | Fixed Window |
| "subarray có k phần tử liên tiếp..." | Fixed Window |
| "tất cả anagram độ dài p trong s" | Fixed Window (size = len(p)) |
| "subarray nhỏ nhất có tổng ≥ s" | Variable Window |
| "subarray dài nhất thoả điều kiện X" | Variable Window |
| "substring không lặp ký tự" | Variable Window |
| "≤ k loại ký tự khác nhau" | Variable Window |
| "đếm số subarray thoả điều kiện" | Variable Window (kỹ thuật ≤K − ≤K-1) |
Một lo lắng tự nhiên: trong Variable Window, vòng while shrink có thể chạy nhiều lần
trong một bước — sao tổng time là O(n)?
Quan sát: cả left và right đều di chuyển chỉ tiến.
right đi từ 0 đến n−1 → tổng nhiều nhất n bước.left đi từ 0 đến n−1 → tổng nhiều nhất n bước.def fixed_window(arr, k):
# Pha 1: build cửa sổ đầu tiên
window_state = init(arr[:k])
best = evaluate(window_state)
# Pha 2: trượt cửa sổ
for i in range(k, len(arr)):
window_state = add(window_state, arr[i]) # thêm bên phải
window_state = remove(window_state, arr[i - k]) # bỏ bên trái
best = update_best(best, evaluate(window_state))
return best
window_state: tổng / max / Counter / set / bitmask...add, remove: cập nhật O(1) nếu state là tổng / Counter.evaluate: tính giá trị muốn theo dõi từ state.Time chuẩn: O(n) nếu add/remove/evaluate đều O(1) hoặc O(k) phân bổ.
Đề (LeetCode #643): Mảng nums, số k. Tìm trung bình lớn nhất
của subarray liên tiếp size k.
Constraints: 1 ≤ k ≤ n ≤ 10⁵ → cần O(n).
Duyệt mọi cửa sổ, tính tổng → O(n × k). Với n,k = 10⁵ → 10¹⁰ → TLE.
def findMaxAverage(nums, k):
window_sum = sum(nums[:k]) # cửa sổ đầu tiên
best = window_sum
for i in range(k, len(nums)):
window_sum += nums[i] - nums[i - k] # add right, remove left — O(1)
best = max(best, window_sum)
return best / k
Time O(n), Space O(1).
nums = [1, 12, -5, -6, 50, 3], k = 4.
nums[0..3] = [1,12,-5,-6], sum = 2, best = 2.
| i | nums[i] | nums[i−k] | thay đổi | window_sum | best |
|---|---|---|---|---|---|
| 4 | 50 | 1 (idx 0) | +50 −1 | 2 + 49 = 51 | 51 |
| 5 | 3 | 12 (idx 1) | +3 −12 | 51 − 9 = 42 | 51 |
Trả về 51 / 4 = 12.75.
nums[i] − nums[i − k] — chỉ cộng phần tử mới và trừ phần tử bị đẩy ra. Đây là tinh thần
"incremental update" — không tính lại tổng từ đầu.
Đề (LeetCode #438): String s, pattern p. Tìm mọi index
bắt đầu của substring trong s là anagram của p.
Ví dụ: s = "cbaebabacd", p = "abc" → [0, 6] (substring "cba" và "bac").
Cửa sổ size len(p). So sánh Counter của cửa sổ với Counter của p.
from collections import Counter
def findAnagrams(s, p):
if len(s) < len(p): return []
k = len(p)
p_cnt = Counter(p)
w_cnt = Counter(s[:k])
res = []
if w_cnt == p_cnt: res.append(0)
for i in range(k, len(s)):
w_cnt[s[i]] += 1 # add
w_cnt[s[i - k]] -= 1 # remove
if w_cnt[s[i - k]] == 0:
del w_cnt[s[i - k]]
if w_cnt == p_cnt: res.append(i - k + 1)
return res
Code 2.4 vẫn đúng nhưng w_cnt == p_cnt tốn O(k) mỗi bước → O(n × k). Có thể tối ưu O(n).
Duy trì biến matches = số ký tự có w_cnt[ch] == p_cnt[ch]. Anagram tương đương
matches == số ký tự khác nhau trong p_cnt.
def findAnagrams(s, p):
if len(s) < len(p): return []
p_cnt = Counter(p)
w_cnt = Counter()
matches = 0
needed = len(p_cnt)
res = []
for i, ch in enumerate(s):
# add
w_cnt[ch] += 1
if w_cnt[ch] == p_cnt[ch]: matches += 1
elif w_cnt[ch] == p_cnt[ch] + 1: matches -= 1
# remove (khi cửa sổ vượt size)
if i >= len(p):
left_ch = s[i - len(p)]
if w_cnt[left_ch] == p_cnt[left_ch]: matches -= 1
elif w_cnt[left_ch] == p_cnt[left_ch] + 1: matches += 1
w_cnt[left_ch] -= 1
if matches == needed:
res.append(i - len(p) + 1)
return res
Time O(n), Space O(k).
Đề (LeetCode #567): Cho s1, s2. Trả True nếu s2 chứa
một permutation của s1 dưới dạng substring.
Đây chính là Find Anagrams nhưng chỉ cần biết "có hay không" thay vì liệt kê.
def checkInclusion(s1, s2):
if len(s1) > len(s2): return False
p_cnt = Counter(s1)
w_cnt = Counter(s2[:len(s1)])
if w_cnt == p_cnt: return True
for i in range(len(s1), len(s2)):
w_cnt[s2[i]] += 1
w_cnt[s2[i - len(s1)]] -= 1
if w_cnt[s2[i - len(s1)]] == 0:
del w_cnt[s2[i - len(s1)]]
if w_cnt == p_cnt: return True
return False
Đề (LeetCode #1456): String s, số k. Tìm số nguyên âm
nhiều nhất trong substring size k.
def maxVowels(s, k):
vowels = set('aeiou')
count = sum(1 for ch in s[:k] if ch in vowels)
best = count
for i in range(k, len(s)):
if s[i] in vowels: count += 1
if s[i - k] in vowels: count -= 1
best = max(best, count)
return best
Time O(n), Space O(1).
Nhìn lại WE1, WE4: cùng cấu trúc — state = count/sum, update bằng +/−.
Đây là cấu trúc đơn giản nhất của Fixed Window và xuất hiện ở rất nhiều bài.
new_sum = old_sum + nums[i] − nums[i−k]. Hai phép cộng/trừ thay vì k phép cộng. Đây là chìa khoá O(n) thay vì O(nk).w_cnt == p_cnt mỗi bước có complexity gì?matches.def variable_window(arr):
state = init()
left = 0
best = init_best()
for right in range(len(arr)):
state = add(state, arr[right])
# Shrink: di chuyển left cho đến khi cửa sổ "hợp lệ"
while not is_valid(state):
state = remove(state, arr[left])
left += 1
best = update_best(best, right - left + 1)
return best
state: cấu trúc theo dõi cửa sổ (Counter, sum, max...).is_valid: điều kiện bài toán — quyết định khi nào shrink.best: max length / min length / count.Câu hỏi quan trọng nhất khi thiết kế Variable Window: khi nào shrink?
| Bài toán | Shrink khi | Mục tiêu |
|---|---|---|
| Substring không lặp ký tự | Có ký tự lặp trong window | Max length |
| Subarray có tổng ≥ s | Tổng ≥ s (tìm cái ngắn nhất) | Min length |
| ≤ k loại ký tự khác nhau | > k loại | Max length |
| Min Window Substring | Đã chứa đủ pattern | Min length |
Đề (LeetCode #209): Mảng nums dương, số target. Tìm độ dài
subarray ngắn nhất có tổng ≥ target. Trả 0 nếu không có.
def minSubArrayLen(target, nums):
left = 0
cur_sum = 0
best = float('inf')
for right in range(len(nums)):
cur_sum += nums[right]
while cur_sum >= target: # đã thoả → cố thu nhỏ
best = min(best, right - left + 1)
cur_sum -= nums[left]
left += 1
return best if best != float('inf') else 0
Time O(n), Space O(1).
Nếu có số âm, mở rộng right không nhất thiết tăng tổng → không monoton → sliding window
không áp dụng được. Bài có số âm cần Prefix Sum + Deque (Tuần 6).
target = 7, nums = [2, 3, 1, 2, 4, 3]. Kỳ vọng: 2 (subarray [4,3]).
| right | nums[right] | cur_sum | shrink? | left sau | best |
|---|---|---|---|---|---|
| 0 | 2 | 2 | không | 0 | ∞ |
| 1 | 3 | 5 | không | 0 | ∞ |
| 2 | 1 | 6 | không | 0 | ∞ |
| 3 | 2 | 8 | có (8≥7) → trừ 2 → 6 | 1 | 4 |
| 4 | 4 | 10 | trừ 3 → 7, trừ 1 → 6 | 3 | 3 |
| 5 | 3 | 9 | trừ 2 → 7, trừ 4 → 3 | 5 | 2 |
Kết quả: 2.
while ở bước right=4 và right=5 chạy nhiều lần — nhưng left chỉ tiến tổng cộng
5 lần qua cả thuật toán. Đây là phân tích amortised: tổng số lần shrink ≤ n.
Đề (LeetCode #3): String s. Độ dài substring dài nhất không có ký tự lặp.
def lengthOfLongestSubstring(s):
seen = set()
left = 0
best = 0
for right, ch in enumerate(s):
while ch in seen: # shrink cho đến khi hợp lệ
seen.remove(s[left])
left += 1
seen.add(ch)
best = max(best, right - left + 1)
return best
Time O(n), Space O(k) với k = số ký tự khác nhau.
Cửa sổ phải không có ký tự lặp. Khi ch đã có trong set, vi phạm — shrink left
cho đến khi ch không còn trong set.
Variant 1 chạy đúng O(n) amortised nhưng có thể tối ưu hằng số bằng "nhảy left" thay vì shrink từng bước:
def lengthOfLongestSubstring(s):
last_seen = {}
left = 0
best = 0
for right, ch in enumerate(s):
if ch in last_seen and last_seen[ch] >= left:
left = last_seen[ch] + 1 # nhảy left thẳng tới sau vị trí cũ
last_seen[ch] = right
best = max(best, right - left + 1)
return best
| Variant | Shrink | Time | Đọc dễ? |
|---|---|---|---|
| 1 — Set + while | Tăng left từng bước | O(n) amortised | Dễ hiểu |
| 2 — Last-seen jump | Nhảy left một lần | O(n) đảm bảo | Khó hiểu hơn |
Đề (LeetCode #340): String s, số k. Độ dài substring dài nhất
có tối đa k loại ký tự khác nhau.
from collections import defaultdict
def lengthOfLongestSubstringKDistinct(s, k):
cnt = defaultdict(int)
left = 0
best = 0
for right, ch in enumerate(s):
cnt[ch] += 1
while len(cnt) > k: # vi phạm
cnt[s[left]] -= 1
if cnt[s[left]] == 0:
del cnt[s[left]]
left += 1
best = max(best, right - left + 1)
return best
Time O(n), Space O(k).
s = "eceba", k = 2. Kỳ vọng: 3 (substring "ece").
| right | ch | cnt sau add | shrink? | left sau | cnt sau shrink | best |
|---|---|---|---|---|---|---|
| 0 | e | {e:1} | không | 0 | {e:1} | 1 |
| 1 | c | {e:1,c:1} | không | 0 | {e:1,c:1} | 2 |
| 2 | e | {e:2,c:1} | không | 0 | {e:2,c:1} | 3 |
| 3 | b | {e:2,c:1,b:1} | có (3>2) | 2 | {e:1,b:1} | 3 |
| 4 | a | {e:1,b:1,a:1} | có | 4 | {a:1} | 3 |
Kết quả: 3.
del cnt[s[left]] khi count về 0 là quan trọng — nếu không xoá, len(cnt) sẽ
sai (vẫn đếm key có count = 0). Đây là bẫy phổ biến.
Đề (LeetCode #904): Mảng fruits. Hai giỏ, mỗi giỏ một loại.
Bắt đầu từ một vị trí bất kỳ, hái liên tiếp. Tối đa hái được bao nhiêu trái?
Đây chính là Longest Substring with At Most K Distinct với k = 2.
def totalFruit(fruits):
cnt = defaultdict(int)
left = 0
best = 0
for right, f in enumerate(fruits):
cnt[f] += 1
while len(cnt) > 2:
cnt[fruits[left]] -= 1
if cnt[fruits[left]] == 0:
del cnt[fruits[left]]
left += 1
best = max(best, right - left + 1)
return best
Đề (LeetCode #424): String s chữ hoa, số k. Có thể đổi tối đa
k ký tự để được substring toàn cùng một ký tự. Độ dài lớn nhất là bao nhiêu?
Window hợp lệ ⟺ (window_length − max_freq_in_window) ≤ k. Tức số ký tự "khác đa số"
trong window không vượt k.
from collections import defaultdict
def characterReplacement(s, k):
cnt = defaultdict(int)
left = 0
max_freq = 0
best = 0
for right, ch in enumerate(s):
cnt[ch] += 1
max_freq = max(max_freq, cnt[ch])
# vi phạm: cần đổi nhiều hơn k ký tự
if (right - left + 1) - max_freq > k:
cnt[s[left]] -= 1
left += 1
best = max(best, right - left + 1)
return best
Time O(n), Space O(26).
Đề (LeetCode #76): String s, t. Tìm substring ngắn nhất
của s chứa mọi ký tự của t (kể cả tần suất).
Ví dụ: s = "ADOBECODEBANC", t = "ABC" → "BANC".
from collections import Counter
def minWindow(s, t):
if not t or len(s) < len(t): return ""
need = Counter(t)
have = defaultdict(int)
needed = len(need)
formed = 0
left = 0
best_len = float('inf')
best_l, best_r = 0, 0
for right, ch in enumerate(s):
have[ch] += 1
if ch in need and have[ch] == need[ch]:
formed += 1
while formed == needed: # đã thoả → shrink để tìm min
if right - left + 1 < best_len:
best_len = right - left + 1
best_l, best_r = left, right
have[s[left]] -= 1
if s[left] in need and have[s[left]] < need[s[left]]:
formed -= 1
left += 1
return s[best_l:best_r+1] if best_len != float('inf') else ""
need = Counter của t (yêu cầu).have = Counter của window hiện tại.needed = số ký tự khác nhau trong t.formed = số ký tự khác nhau đã đủ tần suất trong window.formedTăng khi have[ch] == need[ch] (vừa đủ — không tăng nếu vượt). Giảm khi shrink làm
have[s[left]] < need[s[left]] (vừa thiếu).
formed mà không so sánh trực tiếp have == need?So sánh hai dict tốn O(số ký tự khác nhau) mỗi bước → tổng O(nk). Biến formed cập nhật
O(1) → tổng O(n). Giống tinh thần matches ở Find All Anagrams (2.5).
s = "ADOBECODEBANC", t = "ABC". need={A:1,B:1,C:1}, needed=3.
| right | ch | have | formed | shrink? | best |
|---|---|---|---|---|---|
| 0 | A | {A:1} | 1 | — | ∞ |
| 1 | D | {A:1,D:1} | 1 | — | ∞ |
| 2 | O | {A:1,D:1,O:1} | 1 | — | ∞ |
| 3 | B | {A:1,D:1,O:1,B:1} | 2 | — | ∞ |
| 4 | E | ...,E:1 | 2 | — | ∞ |
| 5 | C | ...,C:1 | 3 | shrink → "ADOBEC" (6) | 6 |
| ... | ... | ... | ... | shrink dần, formed giảm về 2 | ... |
| 10 | A | ... | 3 | shrink → "BANC" (4) | 4 |
| 11 | N | ... | 3 | shrink tiếp | 4 |
| 12 | C | ... | 3 | shrink hết | 4 |
Kết quả: "BANC". Đầy đủ 13 bước có trong code repository — đây chỉ là tóm tắt.
formed tăng chỉ khi have[ch] == need[ch] (không tăng nếu vượt)?| Đặc điểm | Fixed Window | Variable Window |
|---|---|---|
| Kích thước window | Cố định k (cho trước) | Co/dãn theo điều kiện |
| Logic shrink | Tự động left = right − k + 1 | Cần shrink condition |
| Mục tiêu điển hình | Max/min/count của window | Max/min/count độ dài window |
| Ví dụ | Max Average #643, Find Anagrams #438 | Min Subarray #209, Min Window #76 |
countAtMost(K) − countAtMost(K−1).cnt[s[left]] -= 1
if cnt[s[left]] == 0: del cnt[s[left]] # ĐỪNG QUÊN
Nếu không xoá, len(cnt) sai → bài K Distinct sẽ TLE hoặc kết quả sai.best trong Variable Window.
Cập nhật trong/sau vòng while tuỳ bài: max length thì sau (để window đã hợp lệ);
min length thì trong while (để bắt được window vừa thoả).while trong for nghĩ là O(n²). Phải lập luận:
"left chỉ tăng — tổng số bước while ≤ n".float('inf').
Min Window Substring: trả "" nếu không tìm thấy.Linked List — Fast & Slow Pointer, Reverse, Merge
Pattern Two Pointers tiếp tục được dùng — nhưng trên cấu trúc tuần tự không có index ngẫu nhiên. Học cách: phát hiện chu kỳ (Floyd's Tortoise and Hare), tìm midpoint, đảo linked list, merge sorted lists. Đây là chuẩn bị cho merge sort và k-way merge ở Tuần 9.
Nhấn T hoặc Esc để đóng